From e32d475aa9a8d4da75c871e4c95dc88e1bb7ba20 Mon Sep 17 00:00:00 2001 From: Varun Shah Date: Wed, 21 Jan 2026 10:36:51 +0800 Subject: [PATCH] Add AnyClip integration tools and extracted source code - Add authentication scripts with SubtleCrypto password encryption - Add sourcemap extraction pipeline (update-urls, download-sourcemaps, extract-sources) - Add Playwright API interception script for monetization endpoints - Document two-step auth flow with JWT tokens and dual cookies - Move extracted source from root to anyclip/ directory - Add project configuration (.env.example, .gitignore, CLAUDE.md) --- .env.example | 2 + .gitignore | 41 + CLAUDE.md | 151 + README.md | 113 +- {client => anyclip/client}/add-base-path.ts | 0 {client => anyclip/client}/add-locale.ts | 0 .../client}/detect-domain-locale.ts | 0 anyclip/client/get-domain-locale.ts | 35 + {client => anyclip/client}/has-base-path.ts | 0 {client => anyclip/client}/head-manager.ts | 0 anyclip/client/image-component.tsx | 421 + {client => anyclip/client}/index.tsx | 0 anyclip/client/link.tsx | 717 + {client => anyclip/client}/next.ts | 0 .../client}/normalize-trailing-slash.ts | 0 {client => anyclip/client}/page-loader.ts | 0 .../client}/remove-base-path.ts | 0 {client => anyclip/client}/remove-locale.ts | 0 .../client}/request-idle-callback.ts | 0 {client => anyclip/client}/resolve-href.ts | 0 .../client}/route-announcer.tsx | 0 {client => anyclip/client}/route-loader.ts | 0 {client => anyclip/client}/router.ts | 0 {client => anyclip/client}/script.tsx | 0 .../client}/set-attributes-from-props.ts | 0 {client => anyclip/client}/trusted-types.ts | 0 anyclip/client/use-intersection.tsx | 137 + anyclip/client/use-merged-ref.ts | 67 + {client => anyclip/client}/webpack.ts | 0 {client => anyclip/client}/with-router.tsx | 0 {pages => anyclip/pages}/_app.tsx | 0 {pages => anyclip/pages}/_error.tsx | 0 {src => anyclip/src}/assets/img/empty.svg | 0 .../android-chrome-192x192-1621329404738.png | 1 + .../android-chrome-512x512-1621329404738.png | 1 + .../apple-touch-icon-1621329404738.png | 1 + .../favicon/favicon-16x16-1621329404738.png | 1 + .../favicon/favicon-32x32-1621329404738.png | 1 + anyclip/src/assets/img/form-banner/blue.png | 1 + anyclip/src/assets/img/form-banner/coffee.png | 1 + anyclip/src/assets/img/form-banner/desk.png | 1 + anyclip/src/assets/img/form-banner/flower.png | 1 + anyclip/src/assets/img/form-banner/green.png | 1 + .../src/assets/img/form-banner/notebook.png | 1 + anyclip/src/assets/img/form-banner/pink.png | 1 + .../assets/img/form-banner/textureblue.png | 1 + .../src}/assets/img/logo-symbol.png | 0 {src => anyclip/src}/assets/img/logo-text.png | 0 anyclip/src/assets/img/logo.png | 1 + .../src}/assets/img/no-image-portrait.svg | 0 .../src/assets/img/source-icons/instagram.svg | 1 + anyclip/src/assets/img/source-icons/mrss.svg | 1 + .../src/assets/img/source-icons/ms_stream.svg | 1 + .../assets/img/source-icons/sharepoint.svg | 1 + anyclip/src/assets/img/source-icons/teams.svg | 1 + .../src/assets/img/source-icons/tiktok.svg | 1 + anyclip/src/assets/img/source-icons/zoom.svg | 1 + .../upload/default-img-audio-thumbnail.jpg | 1 + anyclip/src/client/components/forbidden.ts | 33 + .../http-access-fallback.ts | 0 .../client/components/is-next-router-error.ts | 0 .../components/navigation.react-server.ts | 41 + anyclip/src/client/components/navigation.ts | 287 + anyclip/src/client/components/not-found.ts | 29 + .../src}/client/components/redirect-error.ts | 0 .../client/components/redirect-status-code.ts | 0 anyclip/src/client/components/redirect.ts | 99 + .../reducers/get-segment-value.ts | 5 + anyclip/src/client/components/unauthorized.ts | 34 + .../components/unrecognized-action-error.ts | 34 + .../components/unstable-rethrow.browser.ts | 12 + .../src/client/components/unstable-rethrow.ts | 15 + {src => anyclip/src}/client/portal/index.tsx | 0 .../on-recoverable-error.ts | 0 .../report-global-error.ts | 0 {src => anyclip/src}/client/tracing/tracer.ts | 0 .../src/graphql/services/_helpers/common.js | 14 + .../services/accounts/constants/index.js | 14 + .../accounts/types/payload/account.js | 14 + .../accounts/types/payload/accounts.js | 29 + .../accounts/types/payload/contentOwner.js | 38 + .../accounts/types/payload/contentOwners.js | 26 + .../accounts/types/payload/deleteAllVideos.js | 14 + .../services/accounts/types/payload/item.js | 211 + .../accounts/types/payload/salesForce.js | 14 + .../services/adsServers/constants/index.js | 5 + .../adsServers/types/input/itemCreate.js | 39 + .../types/payload/bulkChangeStatusAction.js | 17 + .../services/advertisers/constants/index.js | 12 + .../advertisers/types/payload/advertiser.js | 14 + .../advertisers/types/payload/advertisers.js | 26 + .../advertisers/types/payload/item.js | 20 + .../services/aiWorkbench/constants/tagLog.js | 9 + .../aiWorkbench/constants/thumbnail.js | 11 + .../aiWorkbench/types/tagLog/payload/data.js | 23 + .../types/tagLog/payload/tagInfo.js | 32 + .../types/tagLog/payload/upserTag.js | 47 + .../payload/generateThubnailOptions.js | 17 + .../thumbnail/payload/publishThumbnail.js | 17 + .../thumbnail/payload/setThumbnailDarft.js | 20 + .../payload/setThumbnailFromVideoFrame.js | 17 + .../types/thumbnail/payload/thumbnail.js | 14 + .../constants/index.ts | 6 + .../types/payload/list.ts | 73 + .../services/configuration/resolvers/iab.js | 2976 +++ .../services/contentOwners/constants/index.js | 13 + .../contentOwners/types/payload/accounts.js | 20 + .../payload/bulkActionDisableOrActive.js | 17 + .../types/payload/contentOwners.js | 29 + .../contentOwners/types/payload/item.js | 44 + .../services/customReports/constants/index.js | 13 + .../customReports/types/payload/accounts.js | 17 + .../customReports/types/payload/item.js | 41 + .../customReports/types/payload/list.js | 32 + .../graphql/services/feeds/constants/index.ts | 20 + .../services/feeds/types/payload/accounts.ts | 17 + .../services/feeds/types/payload/feedItem.ts | 253 + .../services/feeds/types/payload/hubs.ts | 20 + .../feeds/types/payload/oAuthClient.ts | 14 + .../services/feeds/types/payload/owners.ts | 20 + .../types/payload/selfserve/importArchived.ts | 20 + .../feeds/types/payload/selfserve/list.ts | 35 + .../services/hubs/types/input/itemCreate.js | 128 + .../services/invitations/constants/index.js | 10 + .../invitations/types/payload/account.js | 20 + .../invitations/types/payload/item.js | 14 + .../invitations/types/payload/list.js | 35 + .../services/notifications/constants/index.js | 6 + .../notifications/types/payload/item.js | 20 + .../services/onlineHelp/constants/index.js | 12 + .../onlineHelp/types/payload/configuration.js | 14 + .../types/payload/configurations.js | 26 + .../services/onlineHelp/types/payload/item.js | 23 + .../services/permissions/constatnts/index.js | 6 + .../rolesPermissions/constants/index.js | 14 + .../rolesPermissions/types/payload/account.js | 17 + .../types/payload/permissionMetadata.js | 20 + .../types/payload/roleItem.js | 35 + .../types/payload/roleList.js | 32 + .../types/payload/roleModuleMetadata.js | 38 + .../graphql/services/sso/constants/index.js | 13 + .../graphql/services/sso/types/payload/hub.js | 17 + .../services/sso/types/payload/ssoGetItem.js | 17 + .../services/sso/types/payload/ssoList.js | 23 + .../sso/types/payload/ssoUpsertItem.js | 89 + .../services/sso/types/payload/status.js | 17 + .../graphql/services/users/constants/index.js | 22 + .../services/users/types/payload/accounts.js | 20 + .../services/users/types/payload/apiToken.js | 26 + .../users/types/payload/bulkActions.js | 20 + .../users/types/payload/contentOwners.js | 20 + .../users/types/payload/department.js | 17 + .../users/types/payload/departmentList.js | 17 + .../services/users/types/payload/item.js | 69 + .../users/types/payload/itemDetails.js | 14 + .../services/users/types/payload/list.js | 38 + .../users/types/payload/resetPassword.js | 14 + .../videoBulkActions/constants/index.js | 7 + .../videoBulkActions/types/payload/hub.js | 17 + .../videoBulkActions/types/payload/user.js | 17 + .../services/xRayCampaings/constants/index.js | 15 + .../xRayCampaings/types/payload/advertiser.js | 17 + .../types/payload/advertiserItem.js | 14 + .../xRayCampaings/types/payload/archive.js | 14 + .../xRayCampaings/types/payload/hub.js | 17 + .../types/payload/xRayCampaignItem.js | 23 + .../types/payload/xRayCampaigns.js | 35 + .../services/xRayCreatives/constants/index.js | 13 + .../xRayCreatives/types/payload/archive.js | 14 + .../types/payload/xRayCreativeItem.js | 50 + .../types/payload/xRayCreatives.js | 32 + .../services/xRayLineItems/constants/index.js | 24 + .../xRayLineItems/types/payload/archive.js | 14 + .../types/payload/brandSafety.js | 20 + .../xRayLineItems/types/payload/campain.js | 20 + .../xRayLineItems/types/payload/creative.js | 20 + .../xRayLineItems/types/payload/domain.js | 20 + .../xRayLineItems/types/payload/hub.js | 17 + .../xRayLineItems/types/payload/label.js | 20 + .../xRayLineItems/types/payload/player.js | 20 + .../xRayLineItems/types/payload/taxonomy.js | 23 + .../xRayLineItems/types/payload/video.js | 20 + .../xRayLineItems/types/payload/watch.js | 20 + .../types/payload/xRayLineItemList.js | 41 + .../types/payload/xRayLineItemUpsert.js | 302 + .../types/payload/xRayLineItemsList.js | 35 + .../ActionAutocomplete.module.scss | 2 + .../@common/ActionAutocomplete/index.jsx | 113 + .../src/modules/@common}/ActionIAB/index.jsx | 0 .../EmbedCodePopup/EmbedCodePopup.module.scss | 2 + .../EmbedCodePopup/constants/index.ts | 0 .../modules/@common/EmbedCodePopup/index.tsx | 263 + .../modules/@common/Empty/Empty.module.scss | 2 + anyclip/src/modules/@common/Empty/Empty.tsx | 33 + .../@common}/Form/Form/Form.module.scss | 0 .../src/modules/@common}/Form/Form/Form.tsx | 0 .../@common}/Form/FormContent/FormContent.jsx | 0 .../Form/FormContent/FormContent.module.scss | 0 .../@common}/Form/FormGroup/FormGroup.jsx | 0 .../Form/FormGroup/FormGroup.module.scss | 0 .../Form/FormGroupTitle/FormGroupTitle.jsx | 0 .../FormGroupTitle/FormGroupTitle.module.scss | 0 .../FormImageUploader/FormImageUploader.jsx | 0 .../FormImageUploader.module.scss | 0 .../modules/@common}/Form/FormRow/FormRow.jsx | 0 .../Form/FormRow/components/Label/Label.jsx | 0 .../components/Label/Label.module.scss | 0 .../Form/FormRow/components/Value/Value.jsx | 0 .../components/Value/Value.module.scss | 0 .../@common}/Form/FormRowItem/FormRowItem.jsx | 0 .../Form/FormRowItem/FormRowItem.module.scss | 0 .../@common}/Form/FormSection/FormSection.jsx | 0 .../Form/FormSection/FormSection.module.scss | 0 .../modules/@common/Form/constants/index.js | 4 + .../modules/@common}/Form/helpers/hooks.js | 0 .../src/modules/@common/Form/helpers/index.js | 65 + .../src/modules/@common}/Form/index.js | 0 .../@common/Form/redux/selectors/index.js | 6 + .../@common/Form/redux/slices/index.js | 102 + .../modules/@common}/List/List.module.scss | 0 .../src/modules/@common}/List/index.tsx | 0 .../MultiAutocomplete.module.scss | 2 + .../MultiAutocomplete/MultiAutocomplete.tsx | 222 + .../@common/PlayerWidget/helpers/index.js | 59 + .../TableCellActions/TableCellActions.jsx | 0 .../TableCellActions.module.scss | 0 .../@common/Table/hooks/useLocalPagination.js | 28 + anyclip/src/modules/@common/Table/index.jsx | 215 + .../@common/Table/redux/epics/index.js | 44 + .../@common/Table/redux/selectors/index.js | 13 + .../@common/Table/redux/slices/index.js | 27 + .../TagIabSelector/TagIabSelector.module.scss | 0 .../TagIabSelector/TagIabSelector.tsx | 0 .../TagSelector/TagSelector/TagSelector.tsx | 0 .../StateSelect/StateSelect.module.scss | 0 .../components/StateSelect/StateSelect.tsx | 0 .../components/TagList/TagList.module.scss | 0 .../components/TagList/TagList.tsx | 0 .../@common}/TagSelector/constants/index.ts | 0 .../src/modules/@common}/TagSelector/index.ts | 0 .../ViewportDraggable.module.scss | 2 + .../ViewportDraggable/ViewportDraggable.tsx | 118 + .../modules/@common/acl/constants/index.ts | 242 + .../src/modules/@common/app/ErrorBoundary.tsx | 33 + .../@common/app/GoogleAnalyticsProvider.tsx | 66 + .../modules/@common/app/IntercomProvider.jsx | 156 + .../@common/app/IntercomProvider.module.scss | 2 + .../modules/@common/app/RecordingProvider.jsx | 266 + .../modules/@common/app/SettingsProvider.jsx | 59 + .../modules/@common/app/components/error.jsx | 26 + .../@common/app/components/notFound.jsx | 20 + .../@common/app/components/notPermitted.jsx | 26 + .../modules/@common/app/components/page.jsx | 54 + .../@common/app/components/page.module.scss | 2 + .../modules/@common/app/constants/index.js | 4 + .../src/modules/@common/app/helpers/index.js | 60 + .../@common/ccFiles/constants/index.js | 29 + .../epics/ccFilesInVideoTab/addCcFile.js | 71 + .../ccFilesInVideoTab/checkCcFileState.js | 85 + .../epics/ccFilesInVideoTab/deleteCcFile.js | 72 + .../epics/ccFilesInVideoTab/getCcFiles.js | 68 + .../ccFiles/redux/epics/getCCTotalSegments.js | 39 + .../ccFiles/redux/epics/getLanguages.js | 40 + .../redux/epics/getTranslateLanguages.js | 35 + .../ccFiles/redux/epics/getUploadUrl.js | 74 + .../@common/ccFiles/redux/epics/index.js | 25 + .../ccFiles/redux/epics/setAutoTranslate.js | 60 + .../@common/ccFiles/redux/epics/upload.js | 54 + .../@common/ccFiles/redux/selectors/index.js | 13 + .../@common/ccFiles/redux/slices/index.js | 119 + .../components/PCNProxy/PCNProxy.module.scss | 2 + .../@common/components/PCNProxy/PCNProxy.tsx | 64 + .../components/ReactList/ReactList.jsx | 0 .../ReactList/ReactList.module.scss | 0 .../src/modules/@common/constants/account.ts | 6 + .../modules/@common/constants/aspectRatios.ts | 14 + anyclip/src/modules/@common/constants/db.ts | 1 + .../modules/@common/constants/embedTypes.ts | 5 + .../src/modules/@common/constants/errors.ts | 9 + anyclip/src/modules/@common/constants/file.ts | 7 + .../src/modules/@common/constants/index.ts | 34 + .../modules/@common}/constants/keyCodes.ts | 0 .../modules/@common/constants/mapApiError.ts | 43 + .../modules/@common/constants/playerTypes.ts | 21 + anyclip/src/modules/@common/constants/sort.ts | 2 + .../modules/@common/constants/validation.ts | 5 + .../@common/dnd/SortableItem/SortableItem.tsx | 49 + .../modules/@common/envs/constants/index.ts | 31 + .../@common/gql/queries/allPublishers.js | 20 + .../src/modules/@common/gql/queries/init.js | 13 + .../modules/@common/gql/queries/userInit.js | 63 + .../modules/@common/gql/queries/videoById.js | 184 + .../@common/gql/queries/videoUpdate.js | 137 + .../src/modules/@common/gql/queries/videos.js | 313 + .../modules/@common/gql/redux/epics/index.js | 5 + .../modules/@common/gql/redux/epics/init.js | 36 + .../@common/gql/redux/selectors/index.js | 12 + .../modules/@common/gql/redux/slices/index.js | 99 + anyclip/src/modules/@common/helpers/copy.ts | 23 + anyclip/src/modules/@common/helpers/events.ts | 58 + .../modules/@common/helpers/featureFlags.ts | 22 + .../src/modules/@common/helpers/file-saver.ts | 104 + anyclip/src/modules/@common/helpers/format.ts | 28 + .../@common}/helpers/hooks/useTitle.ts | 0 anyclip/src/modules/@common/helpers/index.ts | 106 + anyclip/src/modules/@common/helpers/number.ts | 27 + anyclip/src/modules/@common/helpers/string.ts | 41 + anyclip/src/modules/@common/helpers/time.ts | 69 + .../src/modules/@common/helpers/videoLangs.ts | 18 + .../IabSelector/IabSelector.module.scss | 2 + .../components/IabSelector/IabSelector.tsx | 163 + .../src/modules/@common/iab/helpers/index.ts | 206 + .../modules/@common/init/redux/epics/error.js | 13 + .../modules/@common/init/redux/epics/index.js | 7 + .../@common/init/redux/epics/request.js | 12 + .../@common/init/redux/epics/response.js | 27 + .../@common/init/redux/slices/index.js | 25 + .../location/redux/epics/addQueries.js | 31 + .../@common/location/redux/epics/index.js | 6 + .../location/redux/epics/removeQueries.js | 56 + .../@common/location/redux/helpers/queue.js | 38 + .../@common/location/redux/slices/index.js | 36 + .../@common/monitoring/helpers/monitoring.js | 23 + .../@common/monitoring/redux/epics/index.js | 8 + .../monitoring/redux/epics/monitoring.js | 23 + .../monitoring/redux/epics/monitoringEnd.js | 23 + .../redux/epics/monitoringRepeat.js | 20 + .../monitoring/redux/epics/monitoringRun.js | 91 + .../monitoring/redux/selectors/index.js | 9 + .../@common/monitoring/redux/slices/index.js | 36 + .../modules/@common/notify/constants/index.js | 4 + .../@common/notify/redux/selectors/index.js | 7 + .../@common/notify/redux/slices/index.ts | 65 + .../@common/request/constants/index.js | 3 + anyclip/src/modules/@common/request/index.js | 225 + .../@common/request/redux/slices/index.js | 22 + .../modules/@common/router/constants/index.ts | 377 + .../@common}/router/constants/mapping.ts | 0 .../router/helpers/getRoutesAllowed.ts | 25 + .../@common/storage/constants/index.ts | 1 + .../modules/@common/storage/helpers/index.ts | 47 + .../helpers/persistPermanentStorageObjects.ts | 13 + anyclip/src/modules/@common/store/helpers.ts | 14 + anyclip/src/modules/@common/store/hooks.ts | 7 + anyclip/src/modules/@common/store/index.ts | 9 + anyclip/src/modules/@common/store/store.ts | 29 + .../modules/@common/token/constants/index.ts | 5 + .../modules/@common/token/helpers/index.ts | 51 + .../token/redux/epics/cancelImpersonation.ts | 82 + .../@common/token/redux/epics/index.ts | 7 + .../@common/token/redux/epics/logOut.ts | 77 + .../@common/token/redux/epics/loggedIn.ts | 20 + .../@common/token/redux/slices/index.ts | 23 + .../modules/@common/user/constants/index.ts | 3 + .../modules/@common/user/constants/roles.ts | 1 + .../@common/user/constants/rolesType.ts | 19 + .../src/modules/@common/user/helpers/index.ts | 58 + .../modules/@common/user/helpers/initUser.ts | 37 + .../@common/user/redux/epics/getUserData.ts | 40 + .../modules/@common/user/redux/epics/index.ts | 5 + .../@common/user/redux/selectors/index.ts | 44 + .../@common/user/redux/slices/index.ts | 32 + .../modules/@common/watch/constants/index.ts | 7 + .../modules/@common/watch/helpers/index.ts | 16 + .../accounts/Editor/components/Editor.jsx | 269 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/ContentTab/ContentTab.jsx | 87 + .../Tabs/ContentTab/ContentTab.module.scss | 2 + .../Tabs/ContentTab/components/Row/Row.jsx | 135 + .../ContentTab/components/Row/Row.module.scss | 2 + .../Tabs/DashboardsTab/DashboardsTab.jsx | 207 + .../DashboardsTab/DashboardsTab.module.scss | 2 + .../components/Tabs/DetailsTab/DetailsTab.jsx | 429 + .../Tabs/DetailsTab/DetailsTab.module.scss | 2 + .../Tabs/FeaturesTab/FeaturesTab.jsx | 216 + .../Tabs/FeaturesTab/FeaturesTab.module.scss | 2 + .../accounts/Editor/constants/index.js | 50 + .../Editor/helpers/buildRequestBody.js | 91 + .../Editor/helpers/validationScheme.js | 150 + .../accounts/Editor/redux/epics/createItem.js | 68 + .../Editor/redux/epics/deleteAllVideos.js | 41 + .../Editor/redux/epics/getContentOwners.js | 46 + .../accounts/Editor/redux/epics/getItem.js | 216 + .../Editor/redux/epics/getSalesforceData.js | 101 + .../accounts/Editor/redux/epics/index.js | 19 + .../Editor/redux/epics/updateContentOwner.js | 50 + .../accounts/Editor/redux/epics/updateItem.js | 63 + .../accounts/Editor/redux/selectors/index.js | 59 + .../accounts/Editor/redux/slices/index.js | 120 + .../modules/accounts/List/components/List.jsx | 201 + .../accounts/List/components/List.module.scss | 2 + .../modules/accounts/List/constants/index.js | 72 + .../accounts/List/redux/epics/getData.js | 64 + .../accounts/List/redux/epics/index.js | 5 + .../accounts/List/redux/selectors/index.js | 21 + .../accounts/List/redux/slices/index.js | 39 + .../adServers/Editor/components/Editor.jsx | 135 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 159 + .../adServers/Editor/constants/index.js | 2 + .../Editor/helpers/validationScheme.js | 43 + .../Editor/redux/epics/createItem.js | 80 + .../adServers/Editor/redux/epics/getItem.js | 75 + .../redux/epics/getPlayerTypesOptions.js | 54 + .../adServers/Editor/redux/epics/index.js | 8 + .../Editor/redux/epics/updateItem.js | 78 + .../adServers/Editor/redux/selectors/index.js | 28 + .../adServers/Editor/redux/slices/index.js | 62 + .../adServers/List/components/List.jsx | 325 + .../List/components/List.module.scss | 2 + .../components/MacrosModal/MacrosModal.jsx | 33 + .../MacrosModal/MacrosModal.module.scss | 2 + .../modules/adServers/List/constants/index.js | 73 + .../redux/epics/bulkChangeStatusAction.js | 67 + .../adServers/List/redux/epics/getData.js | 73 + .../adServers/List/redux/epics/index.js | 6 + .../adServers/List/redux/selectors/index.js | 22 + .../adServers/List/redux/slices/index.js | 41 + .../advertisers/Editor/components/Editor.jsx | 141 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 70 + .../advertisers/Editor/constants/index.js | 3 + .../Editor/helpers/validationScheme.js | 26 + .../Editor/redux/epics/createItem.js | 56 + .../Editor/redux/epics/getAccountOptions.js | 56 + .../advertisers/Editor/redux/epics/getItem.js | 79 + .../advertisers/Editor/redux/epics/index.js | 8 + .../Editor/redux/epics/updateItem.js | 59 + .../Editor/redux/selectors/index.js | 18 + .../advertisers/Editor/redux/slices/index.js | 64 + .../List/components/Empty/Empty.jsx | 35 + .../List/components/Empty/Empty.module.scss | 2 + .../advertisers/List/components/List.jsx | 150 + .../List/components/List.module.scss | 2 + .../advertisers/List/constants/index.js | 6 + .../advertisers/List/helpers/computedState.js | 12 + .../modules/advertisers/List/helpers/index.js | 32 + .../advertisers/List/redux/epics/getData.js | 51 + .../advertisers/List/redux/epics/index.js | 5 + .../advertisers/List/redux/selectors/index.js | 20 + .../advertisers/List/redux/slices/index.js | 38 + .../components/AreaGraph/CustomTooltip.jsx | 0 .../AreaGraph/CustomTooltip.module.scss | 0 .../common/components/AreaGraph/index.jsx | 0 .../DialogCalendarRange.module.scss | 0 .../components/DialogCalendarRange/index.jsx | 0 .../GlobalStateEmpty.module.scss | 0 .../components/GlobalStateEmpty/img/img.png | 0 .../components/GlobalStateEmpty/index.jsx | 0 .../GlobalStateEmpty.module.scss | 0 .../components/GlobalStateError/index.jsx | 0 .../components/Header/Header.module.scss | 0 .../common/components/Header/index.jsx | 0 .../components/Layout/Layout.module.scss | 0 .../common/components/Layout/index.jsx | 0 .../RoundItemContainer.module.scss | 0 .../components/RoundItemContainer/index.jsx | 0 .../common/components/Stub/Stub.module.scss | 0 .../common/components/Stub/img/imgError.png | 0 .../common/components/Stub/img/imgNoData.png | 0 .../common/components/Stub/index.jsx | 0 .../analytics/common/components/index.js | 0 .../ConfirmDialog/index.jsx | 0 .../analytics/common/constants/index.js | 6 + .../modules/analytics/common/helpers/index.js | 66 + .../components/CustomReports.module.scss | 0 .../ReportSetup/ReportSetup.module.scss | 2 + .../components/CancelDialog/index.jsx | 29 + .../DimensionItem/DimensionItem.module.scss | 2 + .../components/DimensionItem/index.jsx | 54 + .../components/EmptyDialog/index.jsx | 26 + .../components/Filters/Filters.module.scss | 2 + .../ReportSetup/components/Filters/index.jsx | 239 + .../FiltersDialog/FilterDialog.module.scss | 2 + .../components/Filter/Filter.module.scss | 2 + .../FiltersDialog/components/Filter/index.jsx | 103 + .../components/FiltersDialog/index.jsx | 117 + .../MetricItem/MetricItem.module.scss | 2 + .../components/MetricItem/index.jsx | 140 + .../components/Name/Name.module.scss | 2 + .../ReportSetup/components/Name/index.jsx | 93 + .../components/Preview/Preview.module.scss | 2 + .../ReportSetup/components/Preview/index.jsx | 79 + .../ScheduleDialog/ScheduleDialog.module.scss | 2 + .../components/ScheduleDialog/index.jsx | 273 + .../components/SideBar/SideBar.module.scss | 2 + .../ReportSetup/components/SideBar/index.jsx | 202 + .../components/ReportSetup/index.jsx | 211 + .../customReports/components/index.jsx | 0 .../customReports/constants/index.js | 1062 ++ .../helpers/createCustomReportRequestBody.js | 82 + .../getStateFromCustomReportRequestBody.js | 97 + .../analytics/customReports/helpers/index.js | 21 + .../modules/analytics/customReports/index.jsx | 0 .../redux/epics/checkDownloadReport.js | 138 + .../redux/epics/createCustomReport.js | 96 + .../redux/epics/deleteCustomReport.js | 43 + .../customReports/redux/epics/getCountries.js | 50 + .../redux/epics/getCustomReportById.js | 98 + .../redux/epics/getCustomReports.js | 106 + .../redux/epics/getDemandSources.js | 78 + .../customReports/redux/epics/getHubs.js | 64 + .../redux/epics/getUserDomains.js | 61 + .../redux/epics/getUserPlayers.js | 97 + .../customReports/redux/epics/index.js | 29 + .../redux/epics/runDownloadReport.js | 108 + .../redux/epics/updateCustomReport.js | 98 + .../customReports/redux/selectors/index.js | 42 + .../customReports/redux/slices/index.js | 139 + .../components/Failure/Failure.module.scss | 0 .../general/components/Failure/index.jsx | 0 .../general/components/General.module.scss | 0 .../InfoNeedSelectAccount.module.scss | 0 .../InfoNeedSelectAccount/index.jsx | 0 .../components/Loader/Loader.module.scss | 0 .../general/components/Loader/index.jsx | 0 .../general/components/Menu/Menu.module.scss | 0 .../general/components/Menu/index.jsx | 0 .../analytics/general/components/index.jsx | 0 .../analytics/general/constants/index.js | 11 + .../analytics/general/helpers/index.js | 7 + .../src}/modules/analytics/general/index.jsx | 0 .../general/redux/epics/getAccounts.js | 70 + .../general/redux/epics/getLookerUrl.js | 68 + .../general/redux/epics/getMenuItems.js | 46 + .../general/redux/epics/getStatus.js | 46 + .../analytics/general/redux/epics/index.js | 8 + .../general/redux/selectors/index.js | 71 + .../analytics/general/redux/slices/index.js | 43 + .../components/Chart/Chart.module.scss | 2 + .../components/Chart/CustomActiveDot.jsx | 23 + .../components/Chart/CustomCursor.jsx | 51 + .../components/Chart/CustomDot.jsx | 20 + .../components/Chart/CustomTick.jsx | 54 + .../components/Chart/CustomTick.module.scss | 2 + .../components/Chart/CustomTooltip.jsx | 48 + .../Chart/CustomTooltip.module.scss | 2 + .../liveDashboard/components/Chart/index.jsx | 246 + .../Countries/Countries.module.scss | 2 + .../components/Countries/index.jsx | 104 + .../components/Devices/Devices.module.scss | 2 + .../components/Devices/index.jsx | 71 + .../components/Filters/Filters.module.scss | 2 + .../components/Filters/index.jsx | 154 + .../components/LiveDashboard.module.scss | 2 + .../components/Tabs/Tabs.module.scss | 2 + .../liveDashboard/components/Tabs/index.jsx | 113 + .../components/Timezone/Timezone.module.scss | 2 + .../components/Timezone/index.jsx | 34 + .../liveDashboard/components/index.jsx | 262 + .../liveDashboard/constants/index.js | 176 + .../liveDashboard/helpers/exportCSV.js | 78 + .../analytics/liveDashboard/helpers/index.js | 22 + .../modules/analytics/liveDashboard/index.jsx | 3 + .../liveDashboard/redux/epics/exportToCSV.js | 65 + .../liveDashboard/redux/epics/exportToPDF.js | 34 + .../liveDashboard/redux/epics/getChartData.js | 103 + .../redux/epics/getCountriesFullList.js | 44 + .../redux/epics/getLiveEventById.js | 83 + .../redux/epics/getLiveEvents.js | 139 + .../redux/epics/getLivePerformanceTotals.js | 167 + .../liveDashboard/redux/epics/index.js | 19 + .../liveDashboard/redux/selectors/index.js | 23 + .../liveDashboard/redux/slices/index.js | 75 + .../components/Card/Card.module.scss | 0 .../monetization/components/Card/index.jsx | 0 .../components/Chart/Chart.module.scss | 0 .../components/Chart/CustomTick.jsx | 0 .../components/Chart/CustomTick.module.scss | 0 .../components/Chart/CustomTooltip.jsx | 0 .../Chart/CustomTooltip.module.scss | 0 .../monetization/components/Chart/index.jsx | 0 .../Countries/Countries.module.scss | 0 .../components/Countries/index.jsx | 0 .../components/Device/Device.module.scss | 0 .../monetization/components/Device/index.jsx | 0 .../components/Filters/Filters.module.scss | 0 .../Filters/components/SpecialPopper.tsx | 0 .../monetization/components/Filters/index.jsx | 0 .../components/Monetization.module.scss | 0 .../monetization/components/index.jsx | 0 .../analytics/monetization/constants/index.js | 925 + .../monetization/helpers/exportCSV.js | 88 + .../modules/analytics/monetization/index.jsx | 0 .../monetization/redux/epics/exportToCSV.js | 70 + .../monetization/redux/epics/exportToPDF.js | 34 + .../monetization/redux/epics/getChartData.js | 200 + .../monetization/redux/epics/getCountries.js | 94 + .../redux/epics/getCountriesFullList.js | 45 + .../redux/epics/getDemandSources.js | 89 + .../monetization/redux/epics/getDomains.js | 88 + .../monetization/redux/epics/getPlayers.js | 60 + .../monetization/redux/epics/getTotals.js | 214 + .../monetization/redux/epics/index.js | 23 + .../monetization/redux/selectors/index.js | 24 + .../monetization/redux/slices/index.js | 105 + .../List/components/Empty/Empty.module.scss | 2 + .../List/components/Empty/Empty.tsx | 23 + .../List/components/List.module.scss | 2 + .../revenueOverview/List/components/List.tsx | 433 + .../revenueOverview/List/constants/index.ts | 5 + .../List/helpers/computedState.ts | 11 + .../revenueOverview/List/helpers/index.ts | 93 + .../List/redux/epics/exportToCsv.ts | 123 + .../List/redux/epics/getCountries.ts | 82 + .../List/redux/epics/getCountriesFullList.ts | 51 + .../List/redux/epics/getData.ts | 135 + .../List/redux/epics/getDemandSources.ts | 80 + .../List/redux/epics/getDomains.ts | 77 + .../List/redux/epics/getPlayers.ts | 55 + .../revenueOverview/List/redux/epics/index.ts | 19 + .../List/redux/selectors/index.ts | 45 + .../List/redux/slices/index.ts | 83 + .../TopSearches/TopSearches.module.scss | 0 .../components/TopSearches/index.jsx | 0 .../VideoContentPerfomance.module.scss | 0 .../components/VideoGraph/CustomTooltip.jsx | 0 .../VideoGraph/CustomTooltip.module.scss | 0 .../VideoGraph/VideoGraph.module.scss | 0 .../components/VideoGraph/index.jsx | 0 .../VideoSearch/VideoSearch.module.scss | 0 .../components/VideoSearch/index.jsx | 0 .../TextTooltip/TextTooltip.module.scss | 0 .../VideoTable/TextTooltip/index.jsx | 0 .../VideoTable/VideoTable.module.scss | 0 .../components/VideoTable/index.jsx | 0 .../components/index.jsx | 0 .../constants/index.js | 167 + .../helpers/csv/createMainMetrics.js | 30 + .../helpers/csv/createTopSearches.js | 18 + .../helpers/csv/createTopVideos.js | 23 + .../helpers/csv/index.js | 5 + .../videoContentPerformance/index.jsx | 0 .../redux/epics/exportToCsv.js | 63 + .../redux/epics/exportToPdf.js | 34 + .../redux/epics/getHubOptionsAutocomplete.js | 72 + .../redux/epics/getItemMetrics.js | 286 + .../epics/getPerformanceVideosTopSearches.js | 153 + .../redux/epics/getPerformanceVideosTotal.js | 172 + .../epics/getPerformanceVideosTotalMetric.js | 153 + .../epics/getSearchOptionsAutocomplete.js | 228 + .../redux/epics/getTableItems.js | 371 + .../epics/getWatchOptionsAutocomplete.js | 99 + .../redux/epics/index.js | 25 + .../redux/selectors/index.js | 52 + .../redux/slices/index.js | 92 + .../CreateResetPassword.jsx | 156 + .../CreateResetPassword.module.scss | 2 + .../CreateResetPassword/redux/epics/index.js | 6 + .../redux/epics/submitPassword.js | 96 + .../redux/epics/verifyCode.js | 68 + .../redux/selectors/index.js | 8 + .../CreateResetPassword/redux/slices/index.js | 24 + .../auth/ForgotPassword/ForgotPassword.jsx | 94 + .../auth/GuestActivation/GuestActivation.jsx | 114 + .../auth/GuestActivation/redux/epics/index.js | 6 + .../redux/epics/registerUser.js | 59 + .../redux/epics/verifyToken.js | 76 + .../GuestActivation/redux/selectors/index.js | 9 + .../GuestActivation/redux/slices/index.js | 25 + anyclip/src/modules/auth/Login/Login.jsx | 270 + .../epics/getCustomLoginPageByAccount.js | 33 + .../auth/Login/redux/epics/getSsoLink.js | 102 + .../auth/Login/redux/epics/getUserData.js | 114 + .../modules/auth/Login/redux/epics/index.js | 19 + .../Login/redux/epics/passwordLessAuth.js | 98 + .../Login/redux/epics/passwordLessAuthCode.js | 115 + .../auth/Login/redux/epics/resetPassword.js | 42 + .../auth/Login/redux/epics/verifyCode.js | 83 + .../auth/Login/redux/selectors/index.js | 10 + .../modules/auth/Login/redux/slices/index.js | 58 + .../PasswordLessLogin/PasswordLessLogin.jsx | 81 + .../PasswordLessLoginCode.jsx | 108 + .../auth/SsoLogin/components/index.jsx | 124 + .../components/useSsoLocalStorageEmails.jsx | 49 + .../modules/auth/SsoLogin/constants/index.js | 6 + .../helpers/ssoEmailStorageManager.js | 10 + anyclip/src/modules/auth/SsoLogin/index.jsx | 3 + .../auth/UserAuthError/UserAuthError.jsx | 62 + .../auth/common/components/Layout/Layout.jsx | 137 + .../components/Layout/Layout.module.scss | 2 + .../src/modules/auth/common/constants/auth.js | 3 + .../auth/common/redux/selectors/index.js | 7 + .../modules/auth/common/redux/slices/index.js | 17 + .../modules/auth/common/useSetRedirectUrl.js | 59 + .../auth/common/useSsoBackgroundLogin.js | 124 + .../dictionary/buttonsFileLoader.jsx | 63 + .../components/dictionary/dictionary.jsx | 405 + .../dictionary/dictionary.module.scss | 2 + .../dictionary/multiButtonsFileLoader.jsx | 89 + .../multiButtonsFileLoader.module.scss | 2 + .../configuration/components/index.jsx | 47 + .../components/index.module.scss | 2 + .../configuration/components/model/Models.jsx | 488 + .../components/model/Models.module.scss | 2 + .../modules/configuration/helpers/index.js | 62 + anyclip/src/modules/configuration/index.js | 3 + .../configuration/redux/epics/addModelForm.js | 39 + .../redux/epics/changeModelForm.js | 37 + .../redux/epics/changeTempModelForm.js | 33 + .../redux/epics/deleteModelForm.js | 30 + .../redux/epics/getConfiguration.js | 208 + .../configuration/redux/epics/index.js | 21 + .../epics/saveConfigurationDataOnServer.js | 253 + .../redux/epics/updateModelForm.js | 16 + .../redux/epics/uploadS3ConfigurationFile.js | 172 + .../configuration/redux/selectors/index.js | 80 + .../configuration/redux/slices/index.js | 86 + .../Editor/components/Editor.jsx | 133 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 198 + .../contentOwners/Editor/constants/index.js | 4 + .../Editor/helpers/validationScheme.js | 45 + .../Editor/redux/epics/createItem.js | 72 + .../Editor/redux/epics/getAccountOptions.js | 72 + .../Editor/redux/epics/getItem.js | 107 + .../contentOwners/Editor/redux/epics/index.js | 8 + .../Editor/redux/epics/updateItem.js | 73 + .../Editor/redux/selectors/index.js | 26 + .../Editor/redux/slices/index.js | 68 + .../List/components/Empty/Empty.jsx | 35 + .../List/components/Empty/Empty.module.scss | 2 + .../contentOwners/List/components/List.jsx | 306 + .../List/components/List.module.scss | 2 + .../contentOwners/List/constants/index.js | 48 + .../List/helpers/computedState.js | 0 .../contentOwners/List/helpers/index.js | 73 + .../redux/epics/bulkActionDisableOrActive.js | 67 + .../contentOwners/List/redux/epics/getData.js | 65 + .../contentOwners/List/redux/epics/index.js | 6 + .../List/redux/selectors/index.js | 21 + .../contentOwners/List/redux/slices/index.js | 40 + .../Editor/components/Editor.jsx | 147 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 191 + .../customReports/Editor/constants/index.js | 10 + .../Editor/helpers/getRestrictions.js | 9 + .../Editor/helpers/validationScheme.js | 77 + .../Editor/redux/epics/createItem.js | 77 + .../Editor/redux/epics/getAccountOptions.js | 63 + .../Editor/redux/epics/getItem.js | 83 + .../customReports/Editor/redux/epics/index.js | 8 + .../Editor/redux/epics/updateItem.js | 80 + .../Editor/redux/selectors/index.js | 26 + .../Editor/redux/slices/index.js | 72 + .../List/components/Empty/Empty.jsx | 35 + .../List/components/Empty/Empty.module.scss | 2 + .../customReports/List/components/List.jsx | 286 + .../List/components/List.module.scss | 2 + .../customReports/List/constants/index.js | 70 + .../List/helpers/computedState.js | 0 .../List/redux/epics/getAccounts.js | 59 + .../customReports/List/redux/epics/getData.js | 70 + .../customReports/List/redux/epics/index.js | 6 + .../List/redux/selectors/index.js | 23 + .../customReports/List/redux/slices/index.js | 42 + .../designSystem/DesignSystemLayout.jsx | 192 + .../DesignSystemLayout.module.scss | 2 + .../designSystem/components/MenuItem.jsx | 26 + .../AccordionSection/AccordionSection.jsx | 117 + .../AccordionSection.module.scss | 2 + .../modules/AlertSection/AlertSection.jsx | 65 + .../AutocompleteSection.jsx | 206 + .../modules/AvatarSection/AvatarSection.jsx | 161 + .../modules/BadgeSection/BadgeSection.jsx | 60 + .../BottomNavigationSection.jsx | 56 + .../BreadcrumbsSection/BreadcrumbsSection.jsx | 94 + .../ButtonGroupSection/ButtonGroupSection.jsx | 79 + .../modules/ButtonSection/ButtonSection.jsx | 149 + .../modules/CardSection/CardSection.jsx | 19 + .../CheckboxSection/CheckboxSection.jsx | 63 + .../modules/ChipAltSection/ChipAltSection.jsx | 249 + .../modules/ChipSection/ChipSection.jsx | 285 + .../ColorPickerSection/ColorPickerSection.jsx | 124 + .../ColorPickerSection.module.scss | 2 + .../modules/CompareSection/CompareSection.tsx | 154 + .../DataGridSection/DataGridSection.jsx | 290 + .../DataGridSection/components/Edit.jsx | 51 + .../DatePickerSection/DatePickerSection.jsx | 79 + .../DateRangePickerSection.jsx | 79 + .../DateTimePickerSection.jsx | 79 + .../DateTimeRangePickerSection.jsx | 38 + .../modules/DialogSection/DialogSection.jsx | 134 + .../DurationFieldSection.jsx | 197 + .../modules/FabSection/FabSection.jsx | 76 + .../modules/FormsSection/FormsSection.jsx | 441 + .../FormsSection/FormsSection.module.scss | 2 + .../GridListSection/GridListSection.jsx | 83 + .../IconButtonSection/IconButtonSection.jsx | 59 + .../modules/IconSection/IconSection.jsx | 148 + .../InlineAutocompleteSection.jsx | 82 + .../InlineDateTimePickerSection.jsx | 191 + .../InlineTextFieldSection.jsx | 103 + .../JSONEditorSection/JSONEditorSection.tsx | 89 + .../modules/ListSection/ListSection.jsx | 251 + .../modules/MenuSection/MenuSection.jsx | 67 + .../NumberFieldSection/NumberFieldSection.jsx | 232 + .../PaginationSection/PaginationSection.jsx | 109 + .../modules/Playground/Playground.jsx | 321 + .../modules/Playground/Playground.module.scss | 2 + .../ProgressSection/ProgressSection.jsx | 85 + .../modules/RadioSection/RadioSection.jsx | 57 + .../modules/RatingSection/RatingSection.jsx | 75 + .../modules/SelectSection/SelectSection.jsx | 125 + .../SkeletonSection/SkeletonSection.jsx | 36 + .../modules/SliderSection/SliderSection.jsx | 191 + .../SnackbarSection/SnackbarSection.jsx | 169 + .../modules/StepperSection/StepperSection.jsx | 480 + .../modules/SwitchSection/SwitchSection.jsx | 70 + .../modules/TabSection/TabSection.jsx | 212 + .../modules/TableSection/TableSection.jsx | 372 + .../TextFieldSection/TextFieldSection.jsx | 217 + .../TimePickerSection/TimePickerSection.jsx | 79 + .../TimeRangePickerSection.jsx | 38 + .../ToggleButtonSection.jsx | 172 + .../modules/TooltipSection/TooltipSection.jsx | 99 + .../TreeViewSection/TreeViewSection.jsx | 150 + .../TypographySection/TypographySection.jsx | 84 + .../designSystem/modules/constants/index.js | 21 + .../designSystem/modules/constants/routes.js | 54 + .../designSystem/modules/helpers/index.js | 131 + .../TabPlaylist/Edit/constants/index.js | 11 + .../TabPlaylist/Edit/helpers/player.js | 27 + .../Edit/helpers/validationScheme.js | 103 + .../Edit/redux/epics/createPlaylist.js | 122 + .../Edit/redux/epics/duplicatePlaylist.js | 77 + .../Edit/redux/epics/getPlayers.js | 63 + .../Edit/redux/epics/getPlaylistById.js | 102 + .../Edit/redux/epics/getPlaylistsByUrl.js | 99 + .../Edit/redux/epics/getPublishers.js | 53 + .../Edit/redux/epics/getSupplyTagOptions.ts | 96 + .../TabPlaylist/Edit/redux/epics/index.js | 21 + .../Edit/redux/epics/updatePlaylist.js | 121 + .../TabPlaylist/Edit/redux/selectors/index.js | 36 + .../TabPlaylist/Edit/redux/slices/index.js | 113 + .../redux/epics/aiFilters/getAiFilters.js | 378 + .../redux/epics/aiFilters/updateAiFilters.js | 210 + .../epics/aiPlaylist/createAiPlaylist.js | 84 + .../epics/aiPlaylist/deleteAiPlaylist.js | 45 + .../redux/epics/aiPlaylist/getAiPlaylist.js | 51 + .../epics/aiPlaylist/updateAiPlaylist.js | 67 + .../TabPlaylist/redux/epics/getEmbedCode.js | 53 + .../TabPlaylist/redux/epics/getPlayers.js | 50 + .../TabPlaylist/redux/epics/getPublishers.js | 49 + .../TabPlaylist/redux/epics/index.js | 42 + .../epics/playlists/addVideoToPlaylist.js | 41 + .../redux/epics/playlists/deletePlaylist.js | 63 + .../redux/epics/playlists/dragAndDrop.js | 74 + .../redux/epics/playlists/getPlaylistById.js | 130 + .../epics/playlists/getPlaylistVideos.js | 153 + .../redux/epics/playlists/getPlaylists.js | 177 + .../redux/epics/playlists/updatePlaylist.js | 83 + .../TabPlaylist/redux/epics/searchVideo.js | 12 + .../TabPlaylist/redux/selectors/index.js | 23 + .../TabPlaylist/redux/slices/index.js | 244 + .../TabWatch/ChannelEdit/constants/index.js | 49 + .../ChannelEdit/helpers/validationScheme.js | 95 + .../ChannelEdit/redux/epics/createChannel.js | 157 + .../ChannelEdit/redux/epics/getChannelById.js | 103 + .../redux/epics/getSupplyTagOptions.ts | 96 + .../TabWatch/ChannelEdit/redux/epics/index.js | 15 + .../ChannelEdit/redux/epics/updateChannel.js | 160 + .../redux/epics/updateDefaultPropFromWatch.js | 83 + .../ChannelEdit/redux/selectors/index.js | 47 + .../ChannelEdit/redux/slices/index.js | 109 + .../TabWatch/Edit/constants/index.js | 173 + .../TabWatch/Edit/constants/label.js | 64 + .../TabWatch/Edit/constants/orderOfTabs.js | 45 + .../TabWatch/Edit/constants/pages.js | 23 + .../TabWatch/Edit/constants/presets.js | 60 + .../TabWatch/Edit/helpers/index.js | 19 + .../helpers/shouldAddAllOptionsSelector.js | 21 + .../TabWatch/Edit/helpers/validationScheme.js | 149 + .../TabWatch/Edit/redux/epics/createWatch.js | 320 + .../Edit/redux/epics/getAccountFeatures.js | 44 + .../redux/epics/getDomainsAutocomplete.js | 74 + .../Edit/redux/epics/getHubsAutocomplete.js | 70 + .../Edit/redux/epics/getSourceAutocomplete.js | 77 + .../TabWatch/Edit/redux/epics/getWatchById.js | 212 + .../TabWatch/Edit/redux/epics/index.js | 19 + .../TabWatch/Edit/redux/epics/updateWatch.js | 333 + .../TabWatch/Edit/redux/selectors/index.js | 124 + .../TabWatch/Edit/redux/slices/index.js | 210 + .../TabWatch/Watches/helpers/channels.js | 26 + .../TabWatch/Watches/helpers/index.js | 12 + .../Watches/redux/epics/addVideoToChannel.js | 59 + .../Watches/redux/epics/copyWatchChannel.js | 63 + .../Watches/redux/epics/createAiPlaylist.js | 84 + .../Watches/redux/epics/deleteAiPlaylist.js | 50 + .../Watches/redux/epics/deleteChannel.js | 56 + .../Watches/redux/epics/deleteWatch.js | 42 + .../Watches/redux/epics/dragAndDrop.js | 124 + .../Watches/redux/epics/getAiFilters.js | 388 + .../Watches/redux/epics/getAiPlaylist.js | 56 + .../Watches/redux/epics/getPlaylistById.js | 68 + .../Watches/redux/epics/getPlaylistVideos.js | 141 + .../Watches/redux/epics/getPublishers.js | 49 + .../Watches/redux/epics/getWatchEmbedCode.js | 39 + .../Watches/redux/epics/getWatches.js | 164 + .../TabWatch/Watches/redux/epics/index.js | 41 + .../Watches/redux/epics/updateAiFilters.js | 235 + .../Watches/redux/epics/updateAiPlaylist.js | 63 + .../Watches/redux/epics/updateChannel.js | 39 + .../Watches/redux/epics/updatePlaylist.js | 86 + .../TabWatch/Watches/redux/selectors/index.js | 25 + .../TabWatch/Watches/redux/slices/index.js | 191 + .../RightSideBar/TabWatch/constants/index.js | 6 + .../TabWatch/helpers/permissions.js | 13 + .../RightSideBar/common/constants/index.js | 33 + .../RightSideBar/common/helpers/index.js | 35 + .../modules/editorial/RightSideBar/index.jsx | 0 .../editorial/RightSideBar/styles.module.scss | 0 .../CategoryColors/CategoryColors.module.scss | 0 .../components/CategoryColors/index.jsx | 0 .../TagCreate/TagCreate.module.scss | 0 .../SelectCreateCategory/CreateCategory.jsx | 0 .../CreateCategory.module.scss | 0 .../SelectCreateCategory.module.scss | 0 .../components/SelectCreateCategory/index.jsx | 0 .../WithCategory/WithCategory.module.scss | 0 .../WithCategory/components/IabSelect.jsx | 0 .../components/IabSelect.module.scss | 0 .../components/WithCategory/index.jsx | 0 .../WithoutCategory.module.scss | 0 .../components/WithoutCategory/index.jsx | 0 .../components/SelectCreateTag/index.jsx | 0 .../TagEditor/components/TagCreate/index.jsx | 0 .../components/TagEditor.module.scss | 0 .../EditCategory/EditCategory.module.scss | 0 .../components/EditCategory/index.jsx | 0 .../components/EditTag/Edit.module.scss | 0 .../CustomTags/components/EditTag/index.jsx | 0 .../components/Tags/CustomTags/index.jsx | 0 .../Tags/CustomTags/index.module.scss | 0 .../Tags/EmptyTag/EmptyTag.module.scss | 0 .../components/Tags/EmptyTag/index.jsx | 0 .../components/Tags/Layout/Layout.module.scss | 0 .../components/Tags/Layout/index.jsx | 0 .../components/Tags/SystemTags/index.jsx | 0 .../Tags/SystemTags/index.module.scss | 0 .../editorial/TagEditor/components/index.jsx | 0 .../editorial/TagEditor/constants/index.js | 96 + .../TagEditor/constants/propTypes.js | 0 .../editorial/TagEditor/helpers/index.js | 0 .../TagEditor/hooks/useTagEditorDialog.js | 0 .../src}/modules/editorial/TagEditor/index.js | 0 .../redux/epics/createCustomCategory.js | 66 + .../TagEditor/redux/epics/createSystemTag.js | 69 + .../redux/epics/editCustomCategory.js | 48 + .../redux/epics/getCustomCategoriesOptions.js | 69 + .../redux/epics/getCustomTagsOptions.js | 68 + .../epics/getCustomTagsWithCategoryOptions.js | 82 + .../redux/epics/getSystemTagsOptions.js | 108 + .../editorial/TagEditor/redux/epics/index.js | 19 + .../TagEditor/redux/selectors/index.js | 0 .../editorial/TagEditor/redux/slices/index.js | 46 + .../ConfirmWarnEdit/ConfirmWarnEdit.jsx | 63 + .../redux/epics/getFeedOptions.js | 68 + .../redux/epics/getHubsOptions.js | 55 + .../CreateVideoForm/redux/epics/index.js | 6 + .../CreateVideoForm/redux/selectors/index.js | 32 + .../CreateVideoForm/redux/slices/index.js | 55 + .../AiWorkbench/constants/index.js | 15 + .../redux/epics/getModulesAttribute.js | 100 + .../redux/epics/getSelectedVideo.js | 41 + .../AiWorkbench/redux/epics/index.js | 7 + .../AiWorkbench/redux/epics/warnEdit.js | 90 + .../AiWorkbench/redux/selectors/index.js | 13 + .../AiWorkbench/redux/slices/index.js | 37 + .../aiWorkbench/Chapters/helpers/index.js | 8 + .../Chapters/redux/epics/createVideo.js | 140 + .../redux/epics/generateByAiChapters.js | 81 + .../Chapters/redux/epics/getChapters.js | 80 + .../redux/epics/getListOfCreatedVideo.js | 51 + .../aiWorkbench/Chapters/redux/epics/index.js | 19 + .../redux/epics/monitoringChapters.js | 53 + .../redux/epics/publishUnpublishChapters.js | 68 + .../Chapters/redux/epics/setChapters.js | 64 + .../Chapters/redux/selectors/index.js | 26 + .../Chapters/redux/slices/index.js | 73 + .../redux/epics/generateByAiDescription.js | 54 + .../Description/redux/epics/getDescription.js | 80 + .../Description/redux/epics/index.js | 17 + .../redux/epics/monitoringDescription.js | 53 + .../epics/publishUnpublishDescription.js | 71 + .../Description/redux/epics/setDescription.js | 62 + .../updateDescriptionInVideoListOrDetail.js | 34 + .../Description/redux/selectors/index.js | 19 + .../Description/redux/slices/index.js | 56 + .../aiWorkbench/Highlights/constants/index.js | 7 + .../Highlights/redux/epics/createVideo.js | 121 + .../redux/epics/generateByAiHighlights.js | 66 + .../Highlights/redux/epics/getCcSegments.js | 56 + .../Highlights/redux/epics/getHighlights.js | 70 + .../redux/epics/getListOfCreatedVideo.js | 51 + .../Highlights/redux/epics/index.js | 21 + .../redux/epics/monitoringHighlights.js | 71 + .../redux/epics/publishUnpublishHighlights.js | 68 + .../Highlights/redux/epics/setHighlights.js | 64 + .../Highlights/redux/selectors/index.js | 23 + .../Highlights/redux/slices/index.js | 61 + .../aiWorkbench/Slides/constants/index.js | 6 + .../Slides/redux/epics/generateByAiSlides.js | 49 + .../Slides/redux/epics/getSlides.js | 83 + .../aiWorkbench/Slides/redux/epics/index.js | 19 + .../Slides/redux/epics/monitoringPdf.js | 89 + .../Slides/redux/epics/monitoringSlides.js | 53 + .../Slides/redux/epics/setSlides.js | 64 + .../Slides/redux/epics/setStateSlides.js | 100 + .../aiWorkbench/Slides/redux/epics/upload.js | 81 + .../Slides/redux/selectors/index.js | 22 + .../aiWorkbench/Slides/redux/slices/index.js | 58 + .../aiWorkbench/TagLog/constants/index.js | 26 + .../aiWorkbench/TagLog/helpers/index.js | 12 + .../TagLog/redux/epics/download.js | 50 + .../aiWorkbench/TagLog/redux/epics/getData.js | 96 + .../TagLog/redux/epics/getTagInfo.js | 106 + .../aiWorkbench/TagLog/redux/epics/index.js | 9 + .../epics/updateSelectedVideoAttributes.js | 93 + .../TagLog/redux/epics/upsertTags.js | 125 + .../TagLog/redux/selectors/index.js | 50 + .../aiWorkbench/TagLog/redux/slices/index.js | 75 + .../aiWorkbench/Thumbnail/constants/index.js | 11 + .../Thumbnail/redux/epics/generateByAi.js | 56 + .../Thumbnail/redux/epics/getThumbnail.js | 103 + .../Thumbnail/redux/epics/index.js | 25 + .../epics/monitoringThumbnailProcessing.js | 59 + .../redux/epics/monitoringThumbnailPublish.js | 59 + .../epics/monitoringThumbnailSetToFrame.js | 59 + .../Thumbnail/redux/epics/publish.js | 59 + .../redux/epics/setThumbnailDraft.js | 66 + .../redux/epics/setThumbnailFromVideoFrame.js | 59 + .../updateThumbnailInVideoListOrDetail.js | 35 + .../Thumbnail/redux/epics/upload.js | 61 + .../Thumbnail/redux/selectors/index.js | 19 + .../Thumbnail/redux/slices/index.js | 60 + .../Translations/constants/index.js | 16 + .../redux/epics/addTranslationFile.js | 77 + .../redux/epics/generateByAiTranscript.js | 72 + .../redux/epics/generateByAiTranslation.js | 56 + .../redux/epics/getSubtitleByLang.js | 75 + .../redux/epics/getTranslationFiles.js | 117 + .../Translations/redux/epics/index.js | 27 + .../redux/epics/monitoringPublishTrancript.js | 64 + .../redux/epics/monitoringTrancript.js | 57 + .../redux/epics/monitoringTranslations.js | 57 + .../redux/epics/removeTranslationFile.js | 86 + .../Translations/redux/epics/setSubtitle.js | 58 + .../Translations/redux/epics/upload.js | 67 + .../Translations/redux/selectors/index.js | 22 + .../Translations/redux/slices/index.js | 86 + .../BulkActionAddTags/BulkActionAddTags.jsx | 0 .../BulkActionAddTags.module.scss | 0 .../BulkActionArchive/BulkActionArchive.jsx | 0 .../BulkActionPanelSuccess.jsx | 0 .../BulkActionsPanelSuccess.module.scss | 0 .../BulkActionReplaceHubs.jsx | 0 .../BulkActionReplaceHubs.module.scss | 0 .../BulkActionReplaceTags.jsx | 0 .../components/TagsItem/TagsItem.jsx | 0 .../components/TagsItem/TagsItem.module.scss | 0 .../components/BulkActionShare.jsx | 0 .../components/BulkActionShare.module.scss | 0 .../ChangeAccessLevel/ChangeAccessLevel.jsx | 0 .../ChangeAccessLevel.module.scss | 0 .../components/ShareToUsers/ShareToUsers.jsx | 0 .../ShareToUsers/ShareToUsers.module.scss | 0 .../components/Title/Title.jsx | 0 .../components/Title/Title.module.scss | 0 .../BulkActionShare/constants/index.js | 0 .../BulkActionShare/redux/epics/getHubs.js | 57 + .../BulkActionShare/redux/epics/getUsers.js | 59 + .../BulkActionShare/redux/epics/index.js | 6 + .../BulkActionShare/redux/selectors/index.js | 13 + .../BulkActionShare/redux/slices/index.js | 34 + .../BulkActionStatusDialog.jsx | 0 .../components/Completed/Completed.jsx | 0 .../Completed/Completed.module.scss | 0 .../components/Processing/Processing.jsx | 0 .../Processing/Processing.module.scss | 0 .../BulkActionsActivateButton.jsx | 0 .../BulkActionsPanel/BulkActionsPanel.jsx | 0 .../BulkActionsPanel.module.scss | 0 .../editorial/bulkActions/constants/index.js | 12 + .../bulkActions/hooks/useGetSelectedVideo.js | 0 .../bulkActions/redux/epics/addTags.js | 83 + .../bulkActions/redux/epics/archive.js | 73 + .../redux/epics/changeAccessLevelAction.js | 93 + .../bulkActions/redux/epics/getHubOptions.js | 55 + .../redux/epics/getMonitoringState.js | 71 + .../bulkActions/redux/epics/index.js | 21 + .../bulkActions/redux/epics/replaceHubs.js | 85 + .../bulkActions/redux/epics/replaceTags.js | 92 + .../bulkActions/redux/epics/shareWithUsers.js | 85 + .../bulkActions/redux/selectors/index.js | 16 + .../bulkActions/redux/slices/index.js | 77 + .../common/components/NewCard/Tags/Tags.jsx | 0 .../components/NewCard/Tags/Tags.module.scss | 0 .../NewCard/Thumbnail/Thumbnail.jsx | 0 .../NewCard/Thumbnail/Thumbnail.module.scss | 0 .../VideoDescription/VideoDescription.jsx | 0 .../VideoDescription.module.scss | 0 .../NewCard/VideoName/VideoName.jsx | 0 .../NewCard/VideoName/VideoName.module.scss | 0 .../NewCard/VideoPlayer/VideoPlayer.jsx | 0 .../VideoPlayer/VideoPlayer.module.scss | 0 .../NewCard/useEditableComponent.js | 0 .../redux/epics/fetchPlayerConfigAction.js | 40 + .../PlayerPreview/redux/epics/index.js | 5 + .../PlayerPreview/redux/slices/index.js | 25 + .../common/components/statusBlock/index.jsx | 0 .../components/statusBlock/styles.module.scss | 0 .../trimVideo/helpers/canShowTrim.js | 0 .../trimVideo/helpers/converSecToMs.js | 2 + .../components/trimVideo/redux/epics/index.js | 5 + .../components/trimVideo/redux/epics/trim.js | 183 + .../trimVideo/redux/selectors/index.js | 25 + .../trimVideo/redux/slices/index.js | 48 + .../src/modules/editorial/constants/dnd.js | 7 + .../editorial/constants/monitoringJobs.js | 0 .../modules/editorial/constants/routing.js | 7 + .../src/modules/editorial/constants/video.js | 2 + .../components/search/index.jsx | 0 .../components/search/styles.module.scss | 0 .../editorialSearch/constants/searchFilter.js | 9 + .../helpers/helpers/monitoring.js | 23 + .../editorial/editorialSearch/index.js | 0 .../editorialSearch/redux/epics/index.js | 23 + .../editorialSearch/redux/epics/monitoring.js | 93 + .../redux/epics/monitoringRepeat.js | 18 + .../redux/epics/monitoringStart.js | 12 + .../redux/epics/monitoringStop.js | 14 + .../editorialSearch/redux/epics/reload.js | 27 + .../redux/epics/videoDelete.js | 47 + .../editorialSearch/redux/epics/videoJob.js | 62 + .../redux/epics/videoStatusUpdate.js | 41 + .../redux/epics/videoVerificationUpdate.js | 55 + .../editorialSearch/redux/selectors/index.js | 12 + .../editorialSearch/redux/slices/index.js | 72 + .../reduxSearch/epics/index.js | 6 + .../reduxSearch/epics/searchSuggester.js | 71 + .../reduxSearch/epics/showUploader.js | 12 + .../reduxSearch/selectors/index.js | 19 + .../reduxSearch/slices/index.js | 49 + .../VideoTabs/VideoTabs.module.scss | 0 .../editorialSearchFilter/VideoTabs/index.jsx | 0 .../additionalFilters/additionalFilters.jsx | 0 .../additionalFilters.module.scss | 0 .../editorialSearchFilter/constants.js | 335 + .../editorialSearchFilter/filterConfig.js | 460 + .../component/searchFilter.jsx | 0 .../component/searchFilter.module.scss | 0 .../filterContainer/index.js | 0 .../filterContainer/redux/epics/index.js | 5 + .../redux/epics/restoreCopiedConfig.js | 30 + .../filterContainer/redux/selectors/index.js | 22 + .../filterContainer/redux/slices/index.js | 61 + .../filterDatePicker/filterDatePicker.jsx | 0 .../filterItem/filterItem.jsx | 0 .../filterSelector/index.jsx | 0 .../ActionAutocomplete.module.scss | 0 .../component/ActionAutocomplete/index.jsx | 0 .../component/ActionIAB/index.jsx | 59 + .../component/Autocomplete/index.jsx | 0 .../component/filterSuggester.jsx | 0 .../filterSuggester/component/types.js | 0 .../filterSuggester/redux/epics/accounts.js | 55 + .../redux/epics/contentOwnersByAccount.js | 78 + .../filterSuggester/redux/epics/index.js | 8 + .../filterSuggester/redux/epics/keywords.js | 226 + .../filterSuggester/redux/epics/publishers.js | 69 + .../filterSuggester/redux/selectors/index.js | 40 + .../filterSuggester/redux/slices/index.js | 130 + .../component/filterTimePicker.jsx | 0 .../filterTimePicker/index.js | 0 .../helpers/filterComponentMapper.js | 0 .../editorialSearchFilter/helpers/index.js | 0 .../AccessControlView/constants/index.js | 11 + .../components/AccessControlView/index.jsx | 0 .../AccessControlView/index.module.scss | 0 .../components/listItem/index.jsx | 0 .../components/placeholder/index.jsx | 0 .../components/placeholder/index.module.scss | 0 .../components/searchResults/index.jsx | 0 .../searchResults/styles.module.scss | 0 .../editorialSearchResults/constants/index.js | 24 + .../editorialSearchResults/helpers/filter.js | 310 + .../helpers/filterConfig.js | 68 + .../helpers/filterInternal.js | 341 + .../editorialSearchResults/helpers/index.js | 50 + .../editorial/editorialSearchResults/index.js | 0 .../redux/epics/addQueries.js | 92 + .../redux/epics/canAddToPlaylist.js | 28 + .../redux/epics/download.js | 86 + .../redux/epics/filterParams.js | 79 + .../redux/epics/getEmbedCode.js | 149 + .../redux/epics/getPlayers.js | 48 + .../redux/epics/index.js | 35 + .../redux/epics/monitoring.js | 17 + .../redux/epics/monitoringFinish.js | 96 + .../redux/epics/removeVideoIdQuery.js | 19 + .../redux/epics/searchNextVideos.js | 83 + .../redux/epics/searchRestart.js | 31 + .../redux/epics/searchVideos.js | 205 + .../redux/epics/showNotification.js | 16 + .../redux/epics/targetClipId.js | 50 + .../videosChangeProcessingStatusMonitoring.js | 82 + .../redux/selectors/index.js | 43 + .../redux/slices/index.js | 179 + .../modules/editorial/editorialTool/index.jsx | 0 .../editorialTool/styles.module.scss | 0 .../redux/epics/addPublishEntries.js | 86 + .../redux/epics/deletePublishEntries.js | 63 + .../TabPublish/redux/epics/getDestinations.js | 90 + .../redux/epics/getDestinationsPublishers.js | 38 + .../redux/epics/getPublishEntries.js | 65 + .../epics/getPublishViewabilityConfig.js | 42 + .../Tabs/TabPublish/redux/epics/index.js | 19 + .../redux/epics/updatePublishEntry.js | 45 + .../Tabs/TabPublish/redux/slices/index.js | 83 + .../Tabs/TabTargeting/constants/index.js | 52 + .../helpers/calculatePermissions.js | 12 + .../TabTargeting/helpers/createDefaultRow.js | 27 + .../Tabs/TabTargeting/helpers/index.js | 6 + .../helpers/responseToTableRow.js | 37 + .../TabTargeting/helpers/rowToRequestBody.js | 30 + .../redux/epics/changeStatuses.js | 83 + .../Tabs/TabTargeting/redux/epics/create.js | 84 + .../Tabs/TabTargeting/redux/epics/get.js | 91 + .../epics/getPlayersWithDisabledTargeting.js | 49 + .../TabTargeting/redux/epics/getStatuses.js | 75 + .../Tabs/TabTargeting/redux/epics/index.js | 21 + .../Tabs/TabTargeting/redux/epics/remove.js | 68 + .../TabTargeting/redux/epics/runAction.js | 89 + .../Tabs/TabTargeting/redux/epics/update.js | 88 + .../TabTargeting/redux/selectors/index.js | 12 + .../Tabs/TabTargeting/redux/slices/index.js | 48 + .../Tabs/TabVersions/constants/index.js | 25 + .../Tabs/TabVersions/redux/epics/getData.js | 60 + .../Tabs/TabVersions/redux/epics/index.js | 5 + .../Tabs/TabVersions/redux/selectors/index.js | 15 + .../Tabs/TabVersions/redux/slices/index.js | 27 + .../components/index.jsx | 0 .../components/index.module.scss | 0 .../components/menu/index.jsx | 0 .../components/menu/styles.module.scss | 0 .../editorialVideoDetails/constants/index.js | 0 .../editorialVideoDetails/helpers/index.js | 225 + .../editorial/editorialVideoDetails/index.js | 0 .../redux/epics/createTaxonomyKeyword.js | 82 + .../redux/epics/deleteVideo.js | 64 + .../redux/epics/getAdvertiser.ts | 85 + .../redux/epics/getOwnersAutocomplete.js | 103 + .../redux/epics/getSpeechToTextModels.js | 37 + .../redux/epics/index.js | 25 + .../redux/epics/reloadSelectedVideo.js | 51 + .../redux/epics/showNotification.js | 16 + .../redux/epics/taxonomyAutocomplete.js | 54 + .../redux/epics/updateVideoTags.js | 70 + .../redux/epics/videoUpdate.js | 123 + .../redux/selectors/index.js | 37 + .../redux/slices/index.js | 133 + .../editorialVideoInfo/components/index.jsx | 0 .../components/styles.module.scss | 0 .../editorialVideoInfo/helpers/index.js | 26 + .../modules/editorial/helpers/createDndId.js | 4 + .../src/modules/editorial/helpers/videoTab.js | 58 + .../shareAndAccess/constants/index.js | 60 + .../shareAndAccess/helpers/permissions.js | 0 .../redux/epics/accessAddSites.js | 75 + .../redux/epics/accessChangeLevel.js | 84 + .../accessCopyAccessUsersToTrimedVideo.js | 63 + .../redux/epics/accessDeleteSites.js | 71 + .../redux/epics/accessDeleteUsers.js | 62 + .../redux/epics/accessUpdateLevel.js | 57 + .../redux/epics/getPublishersByIds.js | 62 + .../redux/epics/getSharedUsersName.js | 76 + .../redux/epics/getUsersByAccount.js | 73 + .../shareAndAccess/redux/epics/index.js | 25 + .../redux/epics/shareVideoWithUsers.js | 85 + .../shareAndAccess/redux/selectors/index.js | 20 + .../shareAndAccess/redux/slices/index.js | 91 + .../Entities/components/AliasDialog.jsx | 192 + .../components/AliasDialog.module.scss | 2 + .../Entities/components/AvatarEdit.jsx | 65 + .../entities/Entities/components/Entities.jsx | 647 + .../Entities/components/Entities.module.scss | 2 + .../components/MergeEntitiesDialog.jsx | 169 + .../MergeEntitiesDialog.module.scss | 2 + .../entities/Entities/constants/index.js | 70 + .../Entities/helpers/formatObjects.js | 53 + .../entities/Entities/helpers/validations.js | 13 + .../Entities/redux/epics/createEntity.js | 65 + .../Entities/redux/epics/getAccounts.js | 63 + .../entities/Entities/redux/epics/getData.js | 92 + .../Entities/redux/epics/getLanguages.js | 52 + .../entities/Entities/redux/epics/index.js | 10 + .../Entities/redux/epics/mergeEntities.js | 92 + .../Entities/redux/epics/updateEntity.js | 96 + .../Entities/redux/selectors/index.js | 30 + .../entities/Entities/redux/slices/index.js | 71 + .../ConfigurationErrorDialog.tsx | 64 + .../constants/index.tsx | 36 + .../setupGetConfigurationErrorCode.tsx | 127 + .../ModelEditDialog.module.scss | 2 + .../ModelEditDialog/ModelEditDialog.tsx | 101 + .../AccessLevel/AccessLevel.module.scss | 2 + .../FormElements/AccessLevel/AccessLevel.tsx | 92 + .../AccessVideoOwner/AccessVideoOwner.tsx | 62 + .../FormElements/Account/Account.tsx | 58 + .../FormElements/AspectRatio/AspectRatio.tsx | 41 + .../FormElements/AuthMethod/AuthMethod.tsx | 41 + .../Authorization/Authorization.module.scss | 2 + .../Authorization/Authorization.tsx | 95 + .../Authorization/constants/index.ts | 14 + .../FormElements/AutoImport/AutoImport.tsx | 34 + .../AutomationScript/AutomationScript.tsx | 31 + .../FormElements/Bitrate/Bitrate.tsx | 45 + .../FormElements/ContentType/ContentType.tsx | 49 + .../CreateClipSelector/CreateClipSelector.tsx | 34 + .../DefaultTimeZone/DefaultTimeZone.tsx | 34 + .../DiscardLongClips/DiscardLongClips.tsx | 33 + .../FormElements/DisplayName/DisplayName.tsx | 40 + .../FormElements/Evergreen/Evergreen.tsx | 34 + .../FeedPriority/FeedPriority.tsx | 41 + .../FileSelection/FileSelection.tsx | 44 + .../FillLandingPage/FillLandingPage.tsx | 33 + .../components/FormElements/Fit/Fit.tsx | 41 + .../components/FormElements/Hubs/Hubs.tsx | 86 + .../IabCategories/IabCategories.module.scss | 2 + .../IabCategories/IabCategories.tsx | 138 + .../ImmediateAvailability.tsx | 34 + .../ImportCCFromMrssFile.tsx | 34 + .../FormElements/ImportPlot/ImportPlot.tsx | 34 + .../ImportShortsThumbnail.tsx | 33 + .../FormElements/JsRendering/JsRendering.tsx | 34 + .../FormElements/Keywords/Keywords.tsx | 36 + .../FormElements/Language/Language.tsx | 69 + .../LoadFromLastDays/LoadFromLastDays.tsx | 44 + .../FormElements/MaxDuration/MaxDuration.tsx | 43 + .../MaxDurationForTagging.tsx | 43 + .../FormElements/MaxStories/MaxStories.tsx | 42 + .../FormElements/MinBitrate/MinBitrate.tsx | 45 + .../MinResolutionValue/MinResolutionValue.tsx | 45 + .../components/FormElements/Name/Name.tsx | 40 + .../ParseKeywordsToLabels.tsx | 34 + .../FormElements/Password/Password.tsx | 46 + .../PriorityVerification.tsx | 34 + .../ProcessVideoVersions.tsx | 34 + .../FormElements/Resolution/Resolution.tsx | 45 + .../ResolutionValue/ResolutionValue.tsx | 45 + .../FormElements/Restricted/Restricted.tsx | 34 + .../ScheduleFrequency/ScheduleFrequency.tsx | 45 + .../SchedulePeriod/SchedulePeriod.tsx | 41 + .../ScheduleStartTime/ScheduleStartTime.tsx | 51 + .../SkipTaggingForLongClips.tsx | 34 + .../SpeechToTextProvider.tsx | 109 + .../FormElements/Timezone/Timezone.tsx | 62 + .../components/FormElements/Url/Url.tsx | 41 + .../UseForDownload/UseForDownload.tsx | 34 + .../components/FormElements/User/User.tsx | 45 + .../VersionAttributeName.tsx | 41 + .../VideoDuration/VideoDuration.tsx | 42 + .../VideoFileType/VideoFileType.tsx | 41 + .../VideoMaxZoom/VideoMaxZoom.tsx | 42 + .../YouTubeChannelId/YouTubeChannelId.tsx | 41 + .../YouTubeLoadFromDate.tsx | 38 + .../YoutubeContentType/YoutubeContentType.tsx | 41 + .../components/FormTabs/ModelTab/ModelTab.tsx | 224 + .../FormTabs/ModelTab/helpers/index.ts | 160 + .../FormTabs/ScriptTab/ScriptTab.tsx | 27 + .../FormWrapper/FormWrapper.module.scss | 2 + .../components/FormWrapper/FormWrapper.tsx | 121 + .../feeds/Editor/components/Forms/Csv/Csv.tsx | 138 + .../Csv/Tabs/AdvancedTab/AdvancedTab.tsx | 123 + .../Forms/Csv/Tabs/GeneralTab/GeneralTab.tsx | 121 + .../Editor/components/Forms/Manual/Manual.tsx | 101 + .../Manual/Tabs/AdvancedTab/AdvancedTab.tsx | 52 + .../Manual/Tabs/GeneralTab/GeneralTab.tsx | 56 + .../Editor/components/Forms/Mrss/Mrss.tsx | 142 + .../Mrss/Tabs/AdvancedTab/AdvancedTab.tsx | 137 + .../Forms/Mrss/Tabs/GeneralTab/GeneralTab.tsx | 153 + .../components/Forms/MsStream/MsStream.tsx | 142 + .../MsStream/Tabs/AdvancedTab/AdvancedTab.tsx | 65 + .../MsStream/Tabs/GeneralTab/GeneralTab.tsx | 100 + .../feeds/Editor/components/Forms/Rss/Rss.tsx | 127 + .../Rss/Tabs/AdvancedTab/AdvancedTab.tsx | 83 + .../Forms/Rss/Tabs/GeneralTab/GeneralTab.tsx | 103 + .../components/Forms/Sitemap/Sitemap.tsx | 127 + .../Sitemap/Tabs/AdvancedTab/AdvancedTab.tsx | 83 + .../Sitemap/Tabs/GeneralTab/GeneralTab.tsx | 103 + .../components/Forms/StoryApi/StoryApi.tsx | 127 + .../StoryApi/Tabs/AdvancedTab/AdvancedTab.tsx | 79 + .../StoryApi/Tabs/GeneralTab/GeneralTab.tsx | 99 + .../components/Forms/Tiktok/Auth/Auth.tsx | 41 + .../Tiktok/Tabs/AdvancedTab/AdvancedTab.tsx | 71 + .../Tiktok/Tabs/GeneralTab/GeneralTab.tsx | 99 + .../Editor/components/Forms/Tiktok/Tiktok.tsx | 144 + .../VideoApi/Tabs/AdvancedTab/AdvancedTab.tsx | 123 + .../VideoApi/Tabs/GeneralTab/GeneralTab.tsx | 105 + .../components/Forms/VideoApi/VideoApi.tsx | 138 + .../Vimeo/Tabs/AdvancedTab/AdvancedTab.tsx | 112 + .../Vimeo/Tabs/GeneralTab/GeneralTab.tsx | 97 + .../Editor/components/Forms/Vimeo/Vimeo.tsx | 138 + .../Youtube/Tabs/AdvancedTab/AdvancedTab.tsx | 111 + .../Youtube/Tabs/GeneralTab/GeneralTab.tsx | 121 + .../components/Forms/Youtube/Youtube.tsx | 135 + .../modules/feeds/Editor/constants/index.ts | 301 + .../src/modules/feeds/Editor/helpers/index.ts | 23 + .../Editor/helpers/injectNewFeedFormUtil.ts | 64 + .../helpers/requestPayload/getAllFields.ts | 73 + .../helpers/requestPayload/getCsvFields.ts | 140 + .../helpers/requestPayload/getManualFields.ts | 86 + .../helpers/requestPayload/getMrssFields.ts | 182 + .../requestPayload/getMsStreamFields.ts | 130 + .../helpers/requestPayload/getRssFields.ts | 114 + .../requestPayload/getSitemapFields.ts | 114 + .../requestPayload/getStoryApiFields.ts | 110 + .../helpers/requestPayload/getTikTokFields.ts | 152 + .../requestPayload/getVideoApiFields.ts | 136 + .../helpers/requestPayload/getVimeoFields.ts | 121 + .../requestPayload/getYoutubeFields.ts | 128 + .../Editor/helpers/requestPayload/index.ts | 65 + .../feeds/Editor/helpers/validationScheme.ts | 620 + .../Editor/hooks/useSetIsSelfServeUser.tsx | 19 + .../Editor/hooks/useSetStoreDefaultValues.tsx | 23 + .../Editor/redux/epics/createItemAction.ts | 69 + .../Editor/redux/epics/getAccountOptions.ts | 71 + .../Editor/redux/epics/getHubsOptions.ts | 75 + .../feeds/Editor/redux/epics/getItem.ts | 170 + .../feeds/Editor/redux/epics/getMetadata.ts | 93 + .../Editor/redux/epics/getOAuthClientId.ts | 65 + .../feeds/Editor/redux/epics/getOAuthToken.ts | 68 + .../Editor/redux/epics/getOwnerOptions.ts | 74 + .../modules/feeds/Editor/redux/epics/index.ts | 23 + .../Editor/redux/epics/updateItemAction.ts | 69 + .../feeds/Editor/redux/selectors/index.ts | 96 + .../feeds/Editor/redux/slices/index.ts | 261 + .../components/Empty/Empty.module.scss | 2 + .../SelfServeList/components/Empty/Empty.tsx | 50 + .../components/ImportDialog/ImportDialog.tsx | 89 + .../SelfServeList/components/List.module.scss | 2 + .../feeds/SelfServeList/components/List.tsx | 242 + .../components/StatusCell/StatusCell.tsx | 50 + .../components/TypeCell/TypeCell.tsx | 41 + .../feeds/SelfServeList/constants/index.tsx | 67 + .../feeds/SelfServeList/helpers/index.tsx | 62 + .../SelfServeList/redux/epics/getData.ts | 92 + .../redux/epics/importArchived.ts | 51 + .../feeds/SelfServeList/redux/epics/index.ts | 6 + .../SelfServeList/redux/selectors/index.ts | 40 + .../feeds/SelfServeList/redux/slices/index.ts | 50 + .../SelfServeList/useIsSelfServeNewList.tsx | 22 + anyclip/src/modules/feeds/constants/index.ts | 19 + .../components/DownloadResponse/index.jsx | 0 .../DynamicFields/DynamicFields.module.scss | 2 + .../common/components/DynamicFields/index.jsx | 449 + .../FormPreview/FormPreview.module.scss | 2 + .../PlayerFormPreview/PlayerFormPreview.jsx | 33 + .../PlayerFormPreview.module.scss | 2 + .../common/components/FormPreview/index.jsx | 51 + .../RichEditor/RichEditor.module.scss | 2 + .../components/RichEditor/RichEditor.tsx | 146 + .../components/MenuBar/MenuBar.module.scss | 2 + .../RichEditor/components/MenuBar/MenuBar.tsx | 480 + .../components/ColorPicker/index.jsx | 40 + .../UploadImage/UploadImage.module.scss | 2 + .../components/UploadImage/index.jsx | 54 + .../UploadPictureBanner.module.scss | 2 + .../components/UploadPictureBanner/index.jsx | 78 + .../components/StylesSettings/index.jsx | 275 + anyclip/src/modules/forms/constants/index.js | 31 + .../components/DetailsTab/DetailsTab.jsx | 657 + .../DetailsTab/DetailsTab.module.scss | 2 + .../PlayerPreview/PlayerPreview.module.scss | 2 + .../components/PlayerPreview/index.jsx | 196 + .../editor/components/Editor.module.scss | 2 + .../components/TriggerTab/TriggerTab.jsx | 669 + .../ActionAutocomplete.module.scss | 2 + .../components/ActionAutocomplete/index.jsx | 113 + .../TriggerTab/components/ActionIAB/index.jsx | 59 + .../modules/forms/editor/components/index.jsx | 321 + .../modules/forms/editor/constants/index.js | 142 + .../editor/helpers/calculationsFromState.js | 78 + .../forms/editor/helpers/createRequestBody.js | 187 + .../helpers/metadata/v1/createFormMetadata.js | 128 + .../metadata/v1/parseFormMetadataToState.js | 65 + .../helpers/metadata/v1/widgetsMetadata.js | 228 + .../editor/helpers/parseResponseToState.js | 154 + .../helpers/parseTemplateResponseToState.js | 23 + .../forms/editor/helpers/validationRules.js | 29 + .../forms/editor/helpers/validationScheme.js | 212 + anyclip/src/modules/forms/editor/index.js | 3 + .../forms/editor/redux/epics/create.js | 62 + .../editor/redux/epics/downloadFormData.js | 151 + .../forms/editor/redux/epics/duplicate.js | 65 + .../getBrandSafetyOptionsAutocomplete.js | 86 + .../epics/getDomainOptionsAutocomplete.js | 77 + .../forms/editor/redux/epics/getFormById.js | 171 + .../redux/epics/getGeoOptionsAutocomplete.js | 63 + .../epics/getLabelOptionsAutocomplete.js | 106 + .../epics/getPlayerOptionsAutocomplete.js | 79 + .../redux/epics/getSiteOptionsAutocomplete.js | 71 + .../editor/redux/epics/getTemplateById.js | 77 + .../redux/epics/getTextOptionsAutocomplete.js | 84 + .../epics/getVideoOptionsAutocomplete.js | 71 + .../epics/getWatchOptionsAutocomplete.js | 105 + .../modules/forms/editor/redux/epics/index.js | 43 + .../redux/epics/sendFormReportToUserEmail.js | 52 + .../forms/editor/redux/epics/sendTestEmail.js | 49 + .../forms/editor/redux/epics/update.js | 65 + .../forms/editor/redux/epics/uploadBanner.js | 53 + .../forms/editor/redux/epics/uploadLogo.js | 53 + .../forms/editor/redux/selectors/index.js | 37 + .../forms/editor/redux/slices/index.js | 187 + .../forms/forms/components/Forms.module.scss | 0 .../TemplateGallery.module.scss | 0 .../TemplateItem/TemplateItem.module.scss | 0 .../components/TemplateItem/index.jsx | 0 .../components/TemplateGallery/index.jsx | 0 .../modules/forms/forms/components/index.jsx | 0 .../modules/forms/forms/constants/index.js | 71 + .../forms/helpers/calculationsFromState.js | 0 .../forms/helpers/upsertLastUsedTemplates.js | 22 + .../src}/modules/forms/forms/index.js | 0 .../forms/forms/redux/epics/archive.js | 57 + .../forms/redux/epics/downloadFormData.js | 168 + .../forms/forms/redux/epics/getForms.js | 106 + .../redux/epics/getSiteOptionsAutocomplete.js | 55 + .../forms/redux/epics/getTemplateGallery.js | 103 + .../redux/epics/getTemplateGalleryCategory.js | 60 + .../modules/forms/forms/redux/epics/index.js | 19 + .../redux/epics/sendFormReportToUserEmail.js | 52 + .../forms/forms/redux/selectors/index.js | 25 + .../modules/forms/forms/redux/slices/index.js | 61 + .../forms/helpers/createDynamicField.js | 83 + anyclip/src/modules/forms/helpers/index.ts | 6 + .../forms/helpers/useFormSubmitMode.js | 25 + .../forms/helpers/useGetReadOnlyStatus.js | 35 + .../components/DetailsTab/DetailsTab.jsx | 494 + .../DetailsTab/DetailsTab.module.scss | 2 + .../components/TemplateEditor.module.scss | 2 + .../forms/templateEditor/components/index.jsx | 182 + .../forms/templateEditor/constants/index.js | 1 + .../helpers/calculationsFromState.js | 42 + .../helpers/createRequestBody.js | 29 + .../helpers/parseResponseToState.js | 36 + .../helpers/validationScheme.js | 116 + .../modules/forms/templateEditor/index.jsx | 3 + .../templateEditor/redux/epics/create.js | 62 + .../templateEditor/redux/epics/duplicate.js | 62 + .../redux/epics/getAccountsAutocomplete.js | 83 + .../templateEditor/redux/epics/getById.js | 84 + .../templateEditor/redux/epics/getCategory.js | 58 + .../forms/templateEditor/redux/epics/index.js | 23 + .../templateEditor/redux/epics/saveProcess.js | 87 + .../templateEditor/redux/epics/update.js | 65 + .../redux/epics/uploadBanner.js | 53 + .../templateEditor/redux/epics/uploadLogo.js | 53 + .../templateEditor/redux/selectors/index.js | 29 + .../templateEditor/redux/slices/index.js | 113 + .../components/Templates.module.scss | 2 + .../forms/templates/components/index.jsx | 308 + .../forms/templates/constants/index.js | 52 + .../modules/forms/templates/helpers/index.js | 6 + anyclip/src/modules/forms/templates/index.js | 3 + .../templates/redux/epics/deleteTemplate.js | 67 + .../templates/redux/epics/getCategory.js | 60 + .../templates/redux/epics/getTemplates.js | 104 + .../forms/templates/redux/epics/index.js | 7 + .../forms/templates/redux/selectors/index.js | 14 + .../forms/templates/redux/slices/index.js | 37 + .../Watches/redux/epics/getHubs.js | 41 + .../Watches/redux/epics/getWatches.js | 95 + .../hostedWatch/Watches/redux/epics/index.js | 6 + .../Watches/redux/selectors/index.js | 33 + .../hostedWatch/Watches/redux/slices/index.js | 116 + .../modules/hubs/Editor/components/Editor.jsx | 202 + .../hubs/Editor/components/Editor.module.scss | 2 + .../Editor/components/ItemList/ItemList.jsx | 67 + .../Tabs/AdvancedTab/AdvancedTab.jsx | 306 + .../AdvancedTab/components/RuleList/index.jsx | 84 + .../SortableRuleItem.module.scss | 2 + .../components/SortableRuleItem/index.jsx | 56 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 190 + .../MarketplaceSelfServiceTab.jsx | 85 + .../PlayerSelfServiceTab.jsx | 309 + .../SyndicatedContentTab.jsx | 92 + .../modules/hubs/Editor/constants/index.js | 21 + .../hubs/Editor/helpers/createRequestBody.js | 73 + .../hubs/Editor/helpers/getDefaultDomain.js | 5 + .../hubs/Editor/helpers/getRestrictions.js | 9 + .../Editor/helpers/parseResponseToState.js | 95 + .../hubs/Editor/helpers/validationScheme.js | 81 + .../hubs/Editor/redux/epics/createItem.js | 52 + .../Editor/redux/epics/getAccountOptions.js | 75 + .../redux/epics/getDemandAccountsOptions.js | 66 + .../hubs/Editor/redux/epics/getItem.js | 143 + .../redux/epics/getTemplatePlayerOptions.js | 76 + .../modules/hubs/Editor/redux/epics/index.js | 17 + .../hubs/Editor/redux/epics/updateItem.js | 54 + .../hubs/Editor/redux/selectors/index.js | 49 + .../modules/hubs/Editor/redux/slices/index.js | 101 + .../hubs/List/components/Empty/Empty.jsx | 0 .../List/components/Empty/Empty.module.scss | 0 .../modules/hubs/List/components/List.jsx | 0 .../hubs/List/components/List.module.scss | 0 .../src/modules/hubs/List/constants/index.js | 23 + .../hubs}/List/helpers/computedState.js | 0 .../src}/modules/hubs/List/helpers/index.js | 0 .../hubs/List/redux/epics/getAccounts.js | 59 + .../modules/hubs/List/redux/epics/getData.js | 71 + .../modules/hubs/List/redux/epics/index.js | 6 + .../hubs/List/redux/selectors/index.js | 23 + .../modules/hubs/List/redux/slices/index.js | 41 + .../components/CopyTooltip/CopyTooltip.jsx | 0 .../List/components/Empty/Empty.jsx | 0 .../List/components/Empty/Empty.module.scss | 0 .../invitations/List/components/List.jsx | 0 .../List/components/List.module.scss | 0 .../invitations/List/constants/index.js | 20 + .../List/helpers/computedState.js | 0 .../modules/invitations/List/helpers/index.js | 0 .../List/redux/epics/getAccounts.js | 59 + .../invitations/List/redux/epics/getData.js | 68 + .../invitations/List/redux/epics/index.js | 7 + .../List/redux/epics/revokeInvitation.js | 51 + .../invitations/List/redux/selectors/index.js | 23 + .../invitations/List/redux/slices/index.js | 55 + .../components/FloatBlock/FloatBlock.jsx | 0 .../FloatBlock/FloatBlock.module.scss | 0 .../components/Container/Container.jsx | 0 .../Container/Container.module.scss | 0 .../src}/modules/layout/components/index.jsx | 0 .../layout/components/index.module.scss | 0 .../layout/components/menu/ItemMenu/index.jsx | 0 .../menu/ItemMenu/index.module.scss | 0 .../menu/SubMenuItem/SubMenu.module.scss | 0 .../components/menu/SubMenuItem/index.jsx | 0 .../layout/components/menu/constants/index.js | 8 + .../modules/layout/components/menu/index.jsx | 0 .../layout/components/menu/index.module.scss | 0 anyclip/src/modules/layout/helpers/index.js | 330 + {src => anyclip/src}/modules/layout/index.js | 0 .../src/modules/layout/redux/epics/clear.js | 13 + .../src/modules/layout/redux/epics/error.js | 21 + .../layout/redux/epics/followToHelpLink.js | 39 + .../src/modules/layout/redux/epics/index.js | 8 + .../src/modules/layout/redux/epics/loading.js | 12 + .../modules/layout/redux/selectors/index.js | 0 .../src/modules/layout/redux/slices/index.js | 30 + .../LiveEventsHeader/FilterSuggester.jsx | 47 + .../components/LiveEventsHeader/Search.jsx | 55 + .../components/LiveEventsHeader/index.jsx | 218 + .../LiveEventsHeader/styles.module.scss | 2 + .../components/LiveEventsTable/Header.jsx | 125 + .../components/LiveEventsTable/Live.jsx | 46 + .../components/LiveEventsTable/index.jsx | 337 + .../LiveEventsTable/styles.module.scss | 2 + .../LiveEventsList/components/index.jsx | 16 + .../liveEvents/LiveEventsList/index.js | 3 + .../redux/epics/archiveLiveEvents.js | 47 + .../redux/epics/getEmbedCode.js | 42 + .../redux/epics/getLiveEventPlayers.js | 58 + .../redux/epics/getLiveEventPublishers.js | 42 + .../redux/epics/getLiveEvents.js | 89 + .../LiveEventsList/redux/epics/index.js | 15 + .../LiveEventsList/redux/selectors/index.js | 31 + .../LiveEventsList/redux/slices/index.js | 75 + .../EventDelivery/EventDelivery.module.scss | 2 + .../components/EventDelivery/index.jsx | 499 + .../components/EventPrePost/index.jsx | 247 + .../EventSchedule/EventSchedule.module.scss | 2 + .../components/EventSchedule/Recurring.jsx | 262 + .../components/EventSchedule/index.jsx | 561 + .../components/EventSetting/index.jsx | 265 + .../components/ForсePoster/ForсePoster.jsx | 74 + .../components/Preview/Preview.jsx | 54 + .../components/Preview/Preview.module.scss | 2 + .../ViewersCounter/ViewersCounter.jsx | 62 + .../components/PlayerPreview/index.jsx | 61 + .../PlayerPreview/styles.module.scss | 2 + .../liveEvents/liveEvent/components/index.jsx | 555 + .../liveEvent/components/styles.module.scss | 2 + .../liveEvent/constants/forcePoster.js | 13 + .../liveEvents/liveEvent/constants/index.js | 39 + .../liveEvents/liveEvent/constants/livecc.js | 63 + .../liveEvents/liveEvent/constants/regions.js | 57 + .../liveEvents/liveEvent/helpers/request.js | 139 + .../liveEvents/liveEvent/helpers/timezones.js | 444 + .../liveEvent/helpers/validation.js | 165 + .../liveEvent/hooks/useForceReload.ts | 26 + .../hooks/useIsAllowedToCreateOrEdit.js | 18 + .../src/modules/liveEvents/liveEvent/index.js | 3 + .../liveEvent/redux/epics/createLiveEvent.js | 152 + .../redux/epics/createLiveEventEndpoint.js | 39 + .../redux/epics/createLiveEventFlow.js | 15 + .../redux/epics/createLiveEventImage.js | 78 + .../liveEvent/redux/epics/createLiveStream.js | 118 + .../liveEvent/redux/epics/getDefaultImages.js | 63 + .../redux/epics/getImageUploadUrl.js | 82 + .../liveEvent/redux/epics/getLiveEventById.js | 143 + .../redux/epics/getLiveEventContentOwner.js | 56 + .../redux/epics/getLiveEventImage.js | 59 + .../redux/epics/getLiveEventPublishers.js | 44 + .../liveEvent/redux/epics/getLiveStream.js | 152 + .../liveEvent/redux/epics/getPlayerConfig.js | 84 + .../redux/epics/getPublisherInfoById.js | 47 + .../redux/epics/getPublisherPlayers.js | 57 + .../liveEvent/redux/epics/getTmPlaylist.js | 70 + .../redux/epics/getTmViewersCounter.js | 50 + .../liveEvents/liveEvent/redux/epics/index.js | 51 + .../redux/epics/testLiveEventEndpoint.js | 47 + .../liveEvent/redux/epics/updateLiveEvent.js | 184 + .../redux/epics/updateLiveEventFlow.js | 11 + .../redux/epics/updateLiveEventImage.js | 48 + .../liveEvent/redux/epics/updateLiveStream.js | 80 + .../redux/epics/uploadLiveEventImage.js | 37 + .../liveEvent/redux/selectors/index.js | 76 + .../liveEvent/redux/slices/index.js | 187 + .../account/components/Account.module.scss | 2 + .../components/AdvertiserPricingTab/index.jsx | 203 + .../AdvertiserSettings/constants/index.ts | 1 + .../helpers/validationScheme.ts | 41 + .../components/AdvertiserSettings/index.jsx | 332 + .../Breadcrumbs/Breadcrumbs.module.scss | 2 + .../account/components/Breadcrumbs/index.jsx | 109 + .../account/components/Cells/IdCell.jsx | 48 + .../components/Cells/IdCell.module.scss | 2 + .../account/components/Cells/NameCell.jsx | 0 .../components/Cells/NameCell.module.scss | 0 .../components/Cells/TargetingCell.jsx | 0 .../Cells/TargetingCell.module.scss | 0 .../components/Cells/TextFieldCell.jsx | 72 + .../Cells/TextFieldCell.module.scss | 2 + .../account/components/Cells/TierCell.jsx | 66 + .../components/Cells/TierCell.module.scss | 2 + .../components/Chart/Chart.module.scss | 2 + .../account/components/Chart/index.jsx | 242 + .../components/FormLineItem/index.jsx | 151 + .../components/FormLineItem/index.module.scss | 2 + .../components/LineItem/index.jsx | 237 + .../components/LineItem/index.module.scss | 2 + .../components/BudgetingTab/index.jsx | 111 + .../components/FrequencyCapTab/index.jsx | 176 + .../components/BusinessModel/index.jsx | 170 + .../components/FormLineItem/index.jsx | 223 + .../components/FormLineItem/index.module.scss | 2 + .../PricingTab/components/LineItem/index.jsx | 281 + .../components/LineItem/index.module.scss | 2 + .../components/PricingTab/index.jsx | 651 + .../components/BidMappingFileButton/index.jsx | 147 + .../BidMappingFileButton/index.module.scss | 2 + .../components/FormLineItem/index.jsx | 104 + .../components/FormLineItem/index.module.scss | 2 + .../components/HeaderBiddingForm/index.jsx | 548 + .../SettingsTab/components/LineItem/index.jsx | 173 + .../components/LineItem/index.module.scss | 2 + .../components/SettingsTab/index.jsx | 792 + .../components/SettingsTab/index.module.scss | 2 + .../ActionAutocomplete.module.scss | 2 + .../components/ActionAutocomplete/index.jsx | 121 + .../components/KeyValue/index.jsx | 135 + .../components/TargetingTab/index.jsx | 416 + .../components/DemandSettings/index.jsx | 7 + .../account/components/Main/Main.module.scss | 2 + .../account/components/Main/index.jsx | 507 + .../components/Modals/ChangeAdFeeModal.jsx | 94 + .../Modals/ChangeAdFeeModal.module.scss | 2 + .../components/Modals/ChangeExpensesModal.jsx | 89 + .../components/Modals/ChangeRevShareModal.jsx | 91 + .../components/Modals/ChangeTierModal.jsx | 82 + .../Modals/ChangeTierModal.module.scss | 2 + .../Modals/ChangeViewabilityThreshold.jsx | 86 + .../Modals/ConfirmFrequencyCapModal.jsx | 59 + .../components/Modals/WaterfallModal.jsx | 398 + .../Modals/WaterfallModal.module.scss | 2 + .../AutomaticOptimization/index.jsx | 176 + .../components/BaseSettingsForm/index.jsx | 449 + .../components/ExportTagTab/ExportTagTab.jsx | 60 + .../components/FormLineItem/index.jsx | 152 + .../components/FormLineItem/index.module.scss | 2 + .../components/LineItem/index.jsx | 206 + .../components/LineItem/index.module.scss | 2 + .../components/SupplyTagSettings/index.jsx | 324 + .../components/Total/Total.module.scss | 2 + .../account/components/Total/index.jsx | 103 + .../marketplace/account/components/index.jsx | 599 + .../account/components/usePageConfig.jsx | 701 + .../account/constants/addTagModal.js | 307 + .../marketplace/account/constants/chart.js | 206 + .../account/constants/demandAccountPage.js | 504 + .../account/constants/demandAdvertiserPage.js | 847 + .../account/constants/demandTagPage.js | 1380 ++ .../marketplace/account/constants/index.js | 9 + .../account/constants/supplyAccountPage.js | 746 + .../account/constants/supplySitePage.js | 962 + .../account/constants/supplyTagPage.js | 1829 ++ .../marketplace/account/constants/table.js | 24 + .../createDemandTagPriceRequestBody.js | 62 + .../helpers/createDemandTagRequestBody.js | 370 + .../account/helpers/createHistoryCSV.js | 94 + .../createSupplyTagPriceRequestBody.js | 17 + .../helpers/createSupplyTagRequestBody.js | 131 + .../marketplace/account/helpers/demandTabs.js | 2 + .../helpers/demandTagFormValidationRules.js | 13 + .../marketplace/account/helpers/filters.js | 71 + .../helpers/getCurrentAndFuturePrice.js | 26 + .../account/helpers/isFrequencyCapHasData.js | 4 + .../account/helpers/useLocalPagination.js | 28 + .../marketplace/account/helpers/validate.js | 17 + .../src/modules/marketplace/account/index.jsx | 3 + .../account/redux/epics/bulkCreateWatefall.js | 83 + .../redux/epics/bulkUpdateDemandTag.js | 47 + .../redux/epics/bulkUpdateSupplyTag.js | 46 + .../epics/bulkUpdateViewabilityThreshold.js | 48 + .../account/redux/epics/createAdvertiser.js | 139 + .../account/redux/epics/createDemandTag.js | 67 + .../account/redux/epics/createSupplyTag.js | 67 + .../account/redux/epics/deleteWaterfall.js | 48 + .../account/redux/epics/downloadCSV.js | 19 + .../account/redux/epics/duplicateDemandTag.js | 67 + .../account/redux/epics/duplicateSupplyTag.js | 67 + .../redux/epics/getAccoutsForWaterfall.js | 101 + .../account/redux/epics/getAdServers.js | 58 + .../redux/epics/getAdvertiserSettingsTab.js | 89 + .../account/redux/epics/getBudgetingTab.js | 29 + .../account/redux/epics/getChartData.js | 154 + .../account/redux/epics/getCountries.js | 54 + .../account/redux/epics/getData.js | 262 + .../redux/epics/getDataForWaterfall.js | 276 + .../redux/epics/getDemandAccountById.js | 42 + .../redux/epics/getDemandTagPricing.js | 77 + .../account/redux/epics/getFrequencyCapTab.js | 41 + .../account/redux/epics/getHistoryForCSV.js | 113 + .../account/redux/epics/getHistoryForChart.js | 127 + .../account/redux/epics/getInfo.js | 56 + .../account/redux/epics/getKeyListsOptions.js | 93 + .../account/redux/epics/getKeyNamesOptions.js | 84 + .../redux/epics/getLabelsForTableFilter.js | 48 + .../redux/epics/getLabelsForWaterfall.js | 54 + .../account/redux/epics/getPlatforms.js | 67 + .../account/redux/epics/getPlayers.js | 90 + .../account/redux/epics/getPricingTab.js | 53 + .../account/redux/epics/getSettingsTab.js | 151 + .../account/redux/epics/getTargetingTab.js | 111 + .../account/redux/epics/getTotal.js | 148 + .../epics/getUserHubsAndDemandAccounts.js | 117 + .../marketplace/account/redux/epics/index.js | 101 + .../redux/epics/initializeAdvertiserForm.js | 14 + .../redux/epics/initializeDemandForm.js | 27 + .../redux/epics/initializeSupplyForm.js | 89 + .../account/redux/epics/saveDataToCSV.js | 33 + .../account/redux/epics/showConfirmModal.js | 44 + .../account/redux/epics/updateAdvertiser.js | 155 + .../account/redux/epics/updateDemandTag.js | 86 + .../account/redux/epics/updateSupplyTag.js | 96 + .../account/redux/epics/updateTiers.js | 54 + .../account/redux/epics/updateWaterfall.js | 58 + .../account/redux/epics/validateAdvertiser.js | 65 + .../account/redux/epics/validateTag.js | 579 + .../account/redux/selectors/index.js | 124 + .../marketplace/account/redux/slices/index.js | 564 + .../accounts/components/Accounts.module.scss | 0 .../components/Filters/Filters.module.scss | 0 .../accounts/components/Filters/index.jsx | 0 .../components/Modals/DisclaimerModal.jsx | 0 .../Modals/DisclaimerModal.module.scss | 0 .../marketplace/accounts/components/index.jsx | 0 .../accounts/components/usePageConfig.jsx | 0 .../accounts/constants/demandAccountsPage.js | 1080 ++ .../marketplace/accounts/constants/index.js | 4 + .../accounts/constants/supplyAccountsPage.js | 1115 ++ .../accounts/helpers/disclaimerModal.js | 10 + .../modules/marketplace/accounts/index.jsx | 0 .../accounts/redux/epics/downloadCSV.js | 19 + .../accounts/redux/epics/getAccounts.js | 241 + .../getSelfServeUserHubsAndDemandAccounts.js | 57 + .../marketplace/accounts/redux/epics/index.js | 8 + .../accounts/redux/epics/saveDataToCSV.js | 36 + .../accounts/redux/selectors/index.js | 19 + .../accounts/redux/slices/index.js | 98 + .../marketplace/common/Cells/CopyCell.jsx | 28 + .../common/Cells/CopyCell.module.scss | 2 + .../marketplace/common/Cells/StatusCell.jsx | 15 + .../marketplace/common/Chart/CustomLogBar.jsx | 106 + .../common/Chart/CustomLogBar.module.scss | 2 + .../marketplace/common/Chart/CustomTick.jsx | 44 + .../common/Chart/CustomTick.module.scss | 2 + .../common/Chart/CustomTooltip.jsx | 38 + .../common/Chart/CustomTooltip.module.scss | 2 + .../marketplace/common/Chart/index.jsx | 184 + .../marketplace/common/ChipInput/index.jsx | 29 + .../common/DateSelect/CustomPeriod.jsx | 0 .../DateSelect/CustomPeriod.module.scss | 0 .../common/DateSelect/DateSelect.module.scss | 0 .../marketplace/common/DateSelect/index.jsx | 0 .../common/HeaderNew/Header.module.scss | 0 .../marketplace/common/HeaderNew/index.jsx | 0 .../common/Table/Table.module.scss | 2 + .../marketplace/common/Table/index.jsx | 419 + .../marketplace/common/constants/index.js | 329 + .../marketplace/common/helpers/histogram.js | 102 + .../marketplace/common/helpers/index.js | 105 + .../marketplace/common/helpers/interval.jsx | 44 + .../helpers/supplyDemandTransitionLinks.js | 0 .../marketplace/common/helpers/uploadToS3.ts | 38 + .../components/Dashboard.module.scss | 2 + .../dashboard/components/SearchNew.jsx | 0 .../components/SearchNew.module.scss | 0 .../dashboard/components/Total.jsx | 69 + .../dashboard/components/Total.module.scss | 2 + .../dashboard/components/index.jsx | 350 + .../marketplace/dashboard/constants/index.js | 227 + .../modules/marketplace/dashboard/index.jsx | 3 + .../redux/epics/getComparisonHistogram.js | 175 + .../redux/epics/getFinancialsHistogram.js | 129 + .../redux/epics/getPerformanceHistogram.js | 129 + .../getSelfServeUserHubsAndDemandAccounts.js | 55 + .../dashboard/redux/epics/getTotal.js | 187 + .../dashboard/redux/epics/index.js | 17 + .../dashboard/redux/epics/search.js | 98 + .../dashboard/redux/selectors/index.js | 21 + .../dashboard/redux/slices/index.js | 113 + .../HBConnectorForm.module.scss | 2 + .../components/HBConnectorForm/index.jsx | 331 + .../components/HBConnectors.module.scss | 2 + .../hbConnectors/components/index.jsx | 277 + .../hbConnectors/constants/index.js | 48 + .../marketplace/hbConnectors/index.jsx | 3 + .../redux/epics/createConnector.js | 100 + .../redux/epics/getConnectorById.js | 78 + .../hbConnectors/redux/epics/getConnectors.js | 118 + .../hbConnectors/redux/epics/index.js | 8 + .../redux/epics/updateConnector.js | 130 + .../hbConnectors/redux/selectors/index.js | 19 + .../hbConnectors/redux/slices/index.js | 60 + .../components/Filters/Filters.module.scss | 2 + .../keyLists/components/Filters/index.jsx | 129 + .../KeyListForm/KeyListForm.module.scss | 2 + .../KeyListForm/LineItem/FormLineItem.jsx | 70 + .../KeyListForm/LineItem/LineItem.module.scss | 2 + .../components/KeyListForm/LineItem/index.jsx | 139 + .../keyLists/components/KeyListForm/index.jsx | 456 + .../keyLists/components/KeyLists.module.scss | 2 + .../KeyValueForm/KeyListForm.module.scss | 2 + .../components/KeyValueForm/index.jsx | 147 + .../marketplace/keyLists/components/index.jsx | 391 + .../marketplace/keyLists/constants/index.js | 130 + .../keyLists/helpers/validationScheme.js | 28 + .../keyLists/helpers/validationSchemeValue.js | 30 + .../modules/marketplace/keyLists/index.jsx | 3 + .../redux/epics/bulkUpdateAvailableKeys.js | 43 + .../redux/epics/bulkUpdateKeyLists.js | 43 + .../redux/epics/createAvailableKey.js | 59 + .../keyLists/redux/epics/createKeyList.js | 66 + .../redux/epics/getAvailableKeyById.js | 59 + .../keyLists/redux/epics/getDemandAccounts.js | 74 + .../keyLists/redux/epics/getKeyListById.js | 63 + .../getSelfServeUserHubsAndDemandAccounts.js | 55 + .../marketplace/keyLists/redux/epics/index.js | 29 + .../redux/epics/searchAvailableKeys.js | 109 + .../keyLists/redux/epics/searchKeyLists.js | 127 + .../redux/epics/updateAvailableKey.js | 63 + .../keyLists/redux/epics/updateKeyList.js | 66 + .../keyLists/redux/selectors/index.js | 37 + .../keyLists/redux/slices/index.js | 124 + .../components/Notifications.jsx | 88 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 75 + .../notifications/redux/epics/getItem.js | 76 + .../notifications/redux/epics/index.js | 6 + .../notifications/redux/epics/saveItem.js | 79 + .../notifications/redux/selectors/index.js | 11 + .../notifications/redux/slices/index.js | 27 + .../onlineHelp/Editor/components/Editor.jsx | 163 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 76 + .../onlineHelp/Editor/constants/index.js | 3 + .../Editor/helpers/getRestrictions.js | 9 + .../Editor/helpers/validationScheme.js | 49 + .../Editor/redux/epics/createItem.js | 58 + .../onlineHelp/Editor/redux/epics/getItem.js | 77 + .../onlineHelp/Editor/redux/epics/index.js | 7 + .../Editor/redux/epics/updateItem.js | 59 + .../Editor/redux/selectors/index.js | 22 + .../onlineHelp/Editor/redux/slices/index.js | 62 + .../List/components/Empty/Empty.jsx | 35 + .../List/components/Empty/Empty.module.scss | 2 + .../onlineHelp/List/components/List.jsx | 264 + .../List/components/List.module.scss | 2 + .../onlineHelp/List/constants/index.js | 8 + .../onlineHelp/List/helpers/computedState.js | 12 + .../modules/onlineHelp/List/helpers/index.js | 53 + .../onlineHelp/List/redux/epics/deleteItem.js | 48 + .../onlineHelp/List/redux/epics/getData.js | 53 + .../onlineHelp/List/redux/epics/index.js | 6 + .../onlineHelp/List/redux/selectors/index.js | 20 + .../onlineHelp/List/redux/slices/index.js | 41 + .../permissions/components/Permissions.jsx | 68 + .../components/Permissions.module.scss | 2 + .../modules/permissions/constants/index.js | 2 + .../permissions/redux/epics/getData.js | 38 + .../modules/permissions/redux/epics/index.js | 5 + .../permissions/redux/selectors/index.js | 18 + .../modules/permissions/redux/slices/index.js | 30 + .../Carousel/component/Arrow/Arrow.jsx | 35 + .../component/Arrow/Arrow.module.scss | 2 + .../Carousel/component/Carousel.jsx | 166 + .../Carousel/component/Carousel.module.scss | 2 + .../Carousel/component/Slide/Slide.jsx | 42 + .../component/Slide/Slide.module.scss | 2 + .../components/Carousel/constants/index.js | 60 + .../components/Carousel/helpers/index.js | 21 + .../players/Editor/components/Editor.jsx | 406 + .../Editor/components/Editor.module.scss | 2 + .../IntervalsList/IntervalsList.jsx | 152 + .../IntervalsList/IntervalsList.module.scss | 2 + .../SaveAndCreateTagsConfirmDialog.tsx | 36 + .../ContentFiltersTab/ContentFiltersTab.jsx | 332 + .../TagsFilter/TagsFilter.jsx | 220 + .../TagsFilter/TagsFilter.module.scss | 2 + .../CustomPlaceholderTab.jsx | 132 + .../PlaceholderList/PlaceholderList.jsx | 110 + .../PlaceholderList.module.scss | 2 + .../Tabs/DisplayAdsTab/DisplayAdsTab.jsx | 140 + .../Tabs/FloatingTab/FloatingTab.jsx | 408 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 262 + .../Tabs/LookAndFeelTab/LookAndFeelTab.jsx | 729 + .../Tabs/LookAndFeelTab/helpers/handlers.js | 133 + .../Tabs/PlaybackTab/PlaybackTab.jsx | 400 + .../components/Tabs/PlayerPreviewWrapper.jsx | 218 + .../Tabs/PlayerPreviewWrapper.module.scss | 2 + .../Tabs/VideoAdsTab/VideoAdsTab.jsx | 478 + .../Tabs/VideoAdsTab/VideoAdsTab.module.scss | 2 + .../modules/players/Editor/constants/index.js | 175 + .../Editor/helpers/createRequestBody.js | 371 + .../modules/players/Editor/helpers/index.js | 33 + .../players/Editor/helpers/playerPreview.js | 44 + .../Editor/helpers/validationScheme.js | 329 + .../Editor/redux/epics/createPlayer.js | 65 + .../Editor/redux/epics/getPlayerBoostList.js | 57 + .../Editor/redux/epics/getPlayerData.js | 736 + .../epics/getPlayerEditorialPlaylists.js | 81 + .../Editor/redux/epics/getPlayerFeeds.js | 69 + .../Editor/redux/epics/getPlayerPublishers.js | 113 + .../players/Editor/redux/epics/index.js | 19 + .../Editor/redux/epics/updatePlayer.js | 61 + .../players/Editor/redux/selectors/index.js | 227 + .../players/Editor/redux/slices/index.js | 270 + .../PlayerIframeView/PlayerIframeView.jsx | 69 + .../PlayerIframeView.module.scss | 2 + .../PlayerPreview/PlayerPreview.jsx | 251 + .../PlayerPreview/PlayerPreview.module.scss | 2 + .../DesktopWrapper/DesktopWrapper.jsx | 32 + .../DesktopWrapper/DesktopWrapper.module.scss | 2 + .../MobileWrapper/MobileWrapper.jsx | 29 + .../MobileWrapper/MobileWrapper.module.scss | 2 + .../PlayerIframeView/constants/index.js | 23 + .../Players/components/Empty/Empty.jsx | 51 + .../components/Empty/Empty.module.scss | 2 + .../players/Players/components/Players.jsx | 419 + .../Players/components/Players.module.scss | 2 + .../players/Players/constants/index.js | 209 + .../Players/redux/epics/deletePlayer.js | 48 + .../redux/epics/getPlayerPublishers.js | 62 + .../Players/redux/epics/getPlayerUiProps.js | 53 + .../Players/redux/epics/getPlayersData.js | 70 + .../players/Players/redux/epics/index.js | 8 + .../players/Players/redux/selectors/index.js | 32 + .../players/Players/redux/slices/index.js | 117 + .../components/SignButton/SignButton.jsx | 79 + .../components/auth/CognitoAuth.jsx | 48 + .../components/auth/FacebookAuth.jsx | 44 + .../components/auth/GoogleAuth.jsx | 41 + .../components/auth/MicrosoftAuth.jsx | 69 + .../Destination/components/auth/ZoomAuth.jsx | 41 + .../Destination/components/index.jsx | 437 + .../Destination/components/styles.module.scss | 2 + .../publishing/Destination/constants/index.js | 79 + .../Destination/helpers/computedState.js | 27 + .../Destination/helpers/validationScheme.js | 67 + .../modules/publishing/Destination/index.js | 3 + .../redux/epics/createDestination.js | 105 + .../redux/epics/exchangeFBAuthCode.js | 58 + .../redux/epics/exchangeGAuthCode.js | 61 + .../Destination/redux/epics/getDestination.js | 78 + .../epics/getPublishViewabilityConfig.js | 57 + .../Destination/redux/epics/index.js | 17 + .../redux/epics/updateDestination.js | 95 + .../Destination/redux/selectors/index.js | 42 + .../Destination/redux/slices/index.js | 114 + .../DestinationsHeader/FilterSuggester.tsx | 53 + .../components/DestinationsHeader/index.tsx | 216 + .../DestinationsHeader/styles.module.scss | 2 + .../components/DestinationsTable/Header.tsx | 87 + .../components/DestinationsTable/index.tsx | 186 + .../DestinationsTable/styles.module.scss | 2 + .../DestinationList/components/index.tsx | 16 + .../DestinationList/constants/index.ts | 0 .../DestinationList/helpers/index.ts | 1 + .../publishing/DestinationList/index.ts | 3 + .../redux/epics/addPublishEntries.ts | 95 + .../redux/epics/deletePublishEntries.ts | 73 + .../redux/epics/getDestinations.ts | 95 + .../redux/epics/getDestinationsPublishers.ts | 63 + .../redux/epics/getPublishEntries.ts | 74 + .../DestinationList/redux/epics/index.ts | 17 + .../redux/epics/updatePublishEntry.ts | 52 + .../DestinationList/redux/selectors/index.ts | 0 .../DestinationList/redux/slices/index.ts | 99 + .../Editor/components/Editor.jsx | 159 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/DetailsTab/DetailsTab.jsx | 156 + .../Tabs/PermissionsTab/PermissionsTab.jsx | 270 + .../PermissionsTab/PermissionsTab.module.scss | 2 + .../UpdatePermissionDialog.jsx | 85 + .../UpdateRoleModuleDialog.jsx | 119 + .../Editor/constants/index.js | 20 + .../Editor/helpers/validationScheme.js | 19 + .../Editor/redux/epics/createItem.js | 68 + .../Editor/redux/epics/getAccounts.js | 57 + .../Editor/redux/epics/getItem.js | 119 + .../Editor/redux/epics/index.js | 17 + .../Editor/redux/epics/updateItem.js | 67 + .../redux/epics/updatePermissionMetadata.js | 70 + .../redux/epics/updateRoleModuleMetadata.js | 78 + .../Editor/redux/selectors/index.js | 24 + .../Editor/redux/slices/index.js | 75 + .../List/components/Empty/Empty.jsx | 35 + .../List/components/Empty/Empty.module.scss | 2 + .../rolesPermissions/List/components/List.jsx | 213 + .../List/components/List.module.scss | 2 + .../rolesPermissions/List/constants/index.js | 20 + .../List/helpers/computedState.js | 15 + .../rolesPermissions/List/helpers/index.js | 42 + .../List/redux/epics/getAccounts.js | 57 + .../List/redux/epics/getData.js | 63 + .../List/redux/epics/index.js | 6 + .../List/redux/selectors/index.js | 25 + .../List/redux/slices/index.js | 43 + .../modules/sso/Editor/components/Editor.jsx | 137 + .../sso/Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 316 + .../Tabs/GeneralTab/GeneralTab.module.scss | 2 + .../GeneralTab/components/AttributeTable.jsx | 213 + .../components/AttributeTable.module.scss | 2 + .../GeneralTab/components/AttributesForm.jsx | 292 + .../components/AttributesForm.module.scss | 2 + .../src/modules/sso/Editor/constants/index.js | 6 + .../sso/Editor/helpers/addAllHubsOption.js | 3 + .../helpers/normalizeCustomAttributes.js | 14 + .../sso/Editor/helpers/validationScheme.js | 122 + .../sso/Editor/redux/epics/createItem.js | 77 + .../modules/sso/Editor/redux/epics/getHubs.js | 58 + .../modules/sso/Editor/redux/epics/getItem.js | 123 + .../modules/sso/Editor/redux/epics/index.js | 8 + .../sso/Editor/redux/epics/updateItem.js | 78 + .../sso/Editor/redux/selectors/index.js | 39 + .../modules/sso/Editor/redux/slices/index.js | 96 + .../sso/List/components/Empty/Empty.jsx | 70 + .../List/components/Empty/Empty.module.scss | 2 + .../src/modules/sso/List/components/List.jsx | 245 + .../sso/List/components/List.module.scss | 2 + .../src/modules/sso/List/constants/index.js | 25 + .../modules/sso/List/helpers/computedState.js | 11 + anyclip/src/modules/sso/List/helpers/index.js | 42 + .../modules/sso/List/redux/epics/getData.js | 51 + .../src/modules/sso/List/redux/epics/index.js | 6 + .../modules/sso/List/redux/epics/status.js | 52 + .../modules/sso/List/redux/selectors/index.js | 17 + .../modules/sso/List/redux/slices/index.js | 36 + .../MultiUploadStatusDialog.module.scss | 0 .../MultiUploadStatusDialog/helpers/index.js | 0 .../MultiUploadStatusDialog/index.jsx | 0 .../components/useShowCancelUploadDialog.js | 0 .../modules/uploaderNew/constants/index.js | 52 + .../src/modules/uploaderNew/helpers/audio.js | 18 + .../helpers/getContentOwnersList.js | 11 + .../modules/uploaderNew/helpers/persist.js | 25 + .../redux/epics/clearUploadQueue.js | 18 + .../uploaderNew/redux/epics/createVideo.js | 264 + .../uploaderNew/redux/epics/createVideoCsv.js | 112 + .../redux/epics/createVideoVersion.js | 175 + .../uploaderNew/redux/epics/getAdvertisers.ts | 86 + .../uploaderNew/redux/epics/getFeedSources.js | 76 + .../handleCancelUploadFromConfirmDialog.js | 58 + .../modules/uploaderNew/redux/epics/index.js | 49 + .../uploaderNew/redux/epics/siteSuggester.js | 62 + .../redux/epics/startUploadFromQueue.js | 61 + .../epics/uploadFlow/chooseUploadBranch.js | 40 + .../epics/uploadFlow/startUploadProcess.js | 123 + .../epics/uploadFlow/uploadCcFilesBranch.js | 129 + .../redux/epics/uploadFlow/uploadCsvBranch.js | 75 + .../epics/uploadFlow/uploadThumbnailBranch.js | 32 + .../epics/uploadFlow/uploadVideoBranch.js | 96 + .../chooseUploadBranch.js | 29 + .../startUploadProcess.js | 87 + .../uploadVideoBranch.js | 82 + .../uploaderNew/redux/selectors/index.js | 102 + .../modules/uploaderNew/redux/slices/index.js | 313 + .../CollapsedContainer.module.scss | 0 .../components/CollapsedContainer/index.jsx | 0 .../components/Empty/Empty.module.scss | 0 .../components/Empty/index.jsx | 0 .../components/UserRulesSettings.module.scss | 0 .../UsersAutocomplete.module.scss | 0 .../components/UsersAutocomplete/index.jsx | 0 .../userRulesSettings/components/index.jsx | 0 .../userRulesSettings/components/useLogic.jsx | 0 .../userRulesSettings/constants/index.js | 36 + .../helpers/calculationsFromStore.js | 0 .../helpers/createEmptyRules.js | 32 + .../helpers/createRequestBodyFromState.js | 91 + .../userRulesSettings/helpers/index.js | 6 + .../userRulesSettings/helpers/mapArray.js | 13 + .../helpers/parseResponseToState.js | 60 + .../src}/modules/userRulesSettings/index.jsx | 0 .../userRulesSettings/redux/epics/get.js | 98 + .../redux/epics/getHubsAutocomplete.js | 68 + .../redux/epics/getSourcesAutocomplete.js | 74 + .../redux/epics/getUsersAutocomplete.js | 79 + .../userRulesSettings/redux/epics/index.js | 9 + .../userRulesSettings/redux/epics/save.js | 65 + .../redux/selectors/index.js | 9 + .../userRulesSettings/redux/slices/index.js | 37 + .../users/Editor/components/Editor.jsx | 239 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 518 + .../Tabs/GeneralTab/GeneralTab.module.scss | 2 + .../modules/users/Editor/constants/index.js | 13 + .../users/Editor/helpers/getRestrictions.js | 9 + .../users/Editor/helpers/validationScheme.js | 98 + .../Editor/redux/epics/createDepartment.js | 58 + .../users/Editor/redux/epics/createItem.js | 88 + .../Editor/redux/epics/getAccountOptions.js | 67 + .../redux/epics/getContentOwnersOptions.js | 62 + .../redux/epics/getDepartmentOptions.js | 65 + .../users/Editor/redux/epics/getItem.js | 157 + .../Editor/redux/epics/getRoleOptions.js | 69 + .../Editor/redux/epics/getTimezoneOptions.js | 59 + .../users/Editor/redux/epics/impersonate.js | 89 + .../modules/users/Editor/redux/epics/index.js | 25 + .../users/Editor/redux/epics/updateItem.js | 85 + .../users/Editor/redux/selectors/index.js | 44 + .../users/Editor/redux/slices/index.js | 109 + .../users/List/components/Empty/Empty.jsx | 0 .../List/components/Empty/Empty.module.scss | 0 .../modules/users/List/components/List.jsx | 0 .../users/List/components/List.module.scss | 0 .../src/modules/users/List/constants/index.js | 18 + .../users/List/helpers/computedState.js | 18 + .../src}/modules/users/List/helpers/index.js | 0 .../users/List/redux/epics/bulkUpdate.js | 68 + .../users/List/redux/epics/generateToken.js | 60 + .../users/List/redux/epics/getAccounts.js | 59 + .../users/List/redux/epics/getApiSet.js | 50 + .../modules/users/List/redux/epics/getData.js | 90 + .../List/redux/epics/getDepartmentOptions.js | 65 + .../users/List/redux/epics/getRoleOptions.js | 75 + .../modules/users/List/redux/epics/index.js | 21 + .../users/List/redux/epics/resetPassword.js | 50 + .../users/List/redux/selectors/index.js | 31 + .../modules/users/List/redux/slices/index.js | 66 + .../campaigns/Editor/components/Editor.jsx | 133 + .../Editor/components/Editor.module.scss | 2 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 158 + .../Tabs/GeneralTab/GeneralTab.module.scss | 2 + .../xRay/campaigns/Editor/constants/index.js | 4 + .../Editor/helpers/validationScheme.js | 41 + .../Editor/redux/epics/createAdvertiser.js | 52 + .../Editor/redux/epics/createItem.js | 58 + .../Editor/redux/epics/getAdvertisers.js | 57 + .../campaigns/Editor/redux/epics/getHubs.js | 57 + .../campaigns/Editor/redux/epics/getItem.js | 90 + .../campaigns/Editor/redux/epics/index.js | 10 + .../Editor/redux/epics/updateItem.js | 59 + .../campaigns/Editor/redux/selectors/index.js | 20 + .../campaigns/Editor/redux/slices/index.js | 65 + .../campaigns/List/components/Empty/Empty.jsx | 0 .../List/components/Empty/Empty.module.scss | 0 .../xRay/campaigns/List/components/List.jsx | 0 .../List/components/List.module.scss | 0 .../xRay/campaigns/List/constants/index.js | 16 + .../campaigns}/List/helpers/computedState.js | 0 .../xRay/campaigns/List/helpers/index.js | 0 .../campaigns/List/redux/epics/archive.js | 51 + .../List/redux/epics/getAdvertisers.js | 57 + .../campaigns/List/redux/epics/getData.js | 77 + .../campaigns/List/redux/epics/getHubs.js | 57 + .../xRay/campaigns/List/redux/epics/index.js | 8 + .../campaigns/List/redux/selectors/index.js | 27 + .../xRay/campaigns/List/redux/slices/index.js | 55 + .../creatives/Editor/components/Editor.jsx | 174 + .../Editor/components/Editor.module.scss | 2 + .../Tabs/AdvancedTab/AdvancedTab.jsx | 78 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 242 + .../xRay/creatives/Editor/constants/index.js | 5 + .../Editor/helpers/validationScheme.js | 126 + .../Editor/redux/epics/createItem.js | 89 + .../creatives/Editor/redux/epics/getHubs.js | 57 + .../creatives/Editor/redux/epics/getItem.js | 104 + .../creatives/Editor/redux/epics/index.js | 8 + .../Editor/redux/epics/updateItem.js | 85 + .../creatives/Editor/redux/selectors/index.js | 28 + .../creatives/Editor/redux/slices/index.js | 74 + .../creatives/List/components/Empty/Empty.jsx | 0 .../List/components/Empty/Empty.module.scss | 0 .../xRay/creatives/List/components/List.jsx | 0 .../List/components/List.module.scss | 0 .../xRay/creatives/List/constants/index.js | 16 + .../creatives}/List/helpers/computedState.js | 0 .../xRay/creatives/List/helpers/index.js | 0 .../creatives/List/redux/epics/archive.js | 51 + .../creatives/List/redux/epics/getData.js | 65 + .../creatives/List/redux/epics/getHubs.js | 57 + .../xRay/creatives/List/redux/epics/index.js | 7 + .../creatives/List/redux/selectors/index.js | 25 + .../xRay/creatives/List/redux/slices/index.js | 44 + .../lineItems/Editor/components/Editor.jsx | 168 + .../Editor/components/Editor.module.scss | 2 + .../Tabs/DeliveryTab/DeliveryTab.jsx | 161 + .../components/Tabs/GeneralTab/GeneralTab.jsx | 354 + .../Tabs/TargetingTab/TargetingTab.jsx | 451 + .../xRay/lineItems/Editor/constants/index.js | 19 + .../lineItems/Editor/helpers/buildBody.js | 139 + .../lineItems/Editor/helpers/timestamp.js | 17 + .../Editor/helpers/validationScheme.js | 114 + .../Editor/redux/epics/createItem.js | 67 + .../Editor/redux/epics/getBrandSafety.js | 61 + .../lineItems/Editor/redux/epics/getBrands.js | 72 + .../Editor/redux/epics/getCampaings.js | 65 + .../Editor/redux/epics/getCreatives.js | 65 + .../Editor/redux/epics/getDomains.js | 66 + .../Editor/redux/epics/getGeographies.js | 46 + .../lineItems/Editor/redux/epics/getHubs.js | 57 + .../lineItems/Editor/redux/epics/getItem.js | 239 + .../Editor/redux/epics/getKeywords.js | 72 + .../lineItems/Editor/redux/epics/getLabels.js | 84 + .../Editor/redux/epics/getPeoples.js | 72 + .../Editor/redux/epics/getPlayers.js | 66 + .../Editor/redux/epics/getTimezones.js | 46 + .../lineItems/Editor/redux/epics/getVideos.js | 66 + .../Editor/redux/epics/getWatches.js | 69 + .../lineItems/Editor/redux/epics/index.js | 39 + .../Editor/redux/epics/updateItem.js | 68 + .../lineItems/Editor/redux/selectors/index.js | 66 + .../lineItems/Editor/redux/slices/index.js | 150 + .../lineItems/List/components/Empty/Empty.jsx | 0 .../List/components/Empty/Empty.module.scss | 0 .../xRay/lineItems/List/components/List.jsx | 0 .../List/components/List.module.scss | 0 .../xRay/lineItems/List/constants/index.js | 23 + .../lineItems/List/helpers/computedState.js | 15 + .../xRay/lineItems/List/helpers/index.js | 0 .../lineItems/List/redux/epics/archive.js | 51 + .../lineItems/List/redux/epics/getData.js | 73 + .../lineItems/List/redux/epics/getHubs.js | 57 + .../xRay/lineItems/List/redux/epics/index.js | 7 + .../lineItems/List/redux/selectors/index.js | 25 + .../xRay/lineItems/List/redux/slices/index.js | 44 + .../ColorPicker/ButtonColorPicker.tsx | 102 + .../ColorPicker/ColorPicker.module.scss | 2 + .../ColorPicker/StaticColorPicker.tsx | 329 + .../ColorPicker/TextFieldColorPicker.tsx | 125 + .../AlphaControl/AlphaControl.module.scss | 2 + .../components/AlphaControl/AlphaControl.tsx | 45 + .../HueControl/HueControl.module.scss | 2 + .../components/HueControl/HueControl.tsx | 47 + .../Interactive/Interactive.module.scss | 2 + .../components/Interactive/Interactive.tsx | 168 + .../SaturationControl.module.scss | 2 + .../SaturationControl/SaturationControl.tsx | 53 + .../ColorPicker/helpers/index.ts | 103 + .../ColorPicker/hooks/useColorManipulation.ts | 47 + .../ColorPicker/hooks/useEventCallback.ts | 15 + .../internal/Alpha/Alpha.module.scss | 2 + .../ColorPicker/internal/Alpha/Alpha.tsx | 46 + .../internal/HexInput/HexInput.tsx | 130 + .../ColorPicker/internal/Hue/Hue.module.scss | 2 + .../ColorPicker/internal/Hue/Hue.tsx | 45 + .../internal/Interactive/Interactive.tsx | 156 + .../internal/Pointer/Pointer.module.scss | 2 + .../ColorPicker/internal/Pointer/Pointer.tsx | 28 + .../internal/RgbaInput/RgbaInput.tsx | 127 + .../Saturation/Saturation.module.scss | 2 + .../internal/Saturation/Saturation.tsx | 49 + .../CustomActionBar/CustomActionBar.tsx | 91 + .../DotPagination/DotPagination.module.scss | 2 + .../DotPagination/DotPagination.tsx | 64 + .../DurationField/DurationField.tsx | 381 + .../DurationField/helpers/index.ts | 112 + .../GridList/GridList.module.scss | 2 + .../@extendedComponents/GridList/GridList.tsx | 45 + .../Autocomplete/Autocomplete.module.scss | 2 + .../InlineEdit/Autocomplete/Autocomplete.tsx | 118 + .../InlineEdit/DateTime/DatePicker.tsx | 131 + .../DateTime/DateTimePicker.module.scss | 2 + .../InlineEdit/DateTime/DateTimePicker.tsx | 131 + .../InlineEdit/DateTime/TimePicker.tsx | 131 + .../InlineEdit/DateTime/helpers/index.ts | 32 + .../TextField/TextField.module.scss | 2 + .../InlineEdit/TextField/TextField.tsx | 181 + .../InlineEdit/constants/index.ts | 1 + .../InlineEdit/helpers/index.ts | 29 + .../JSONEditor/JSONEditor.module.scss | 2 + .../JSONEditor/JSONEditor.tsx | 450 + .../JSONEditor/helpers/index.ts | 28 + .../JSONEditor/helpers/prismJson.js | 1 + .../NumberField/NumberField.tsx | 176 + .../UserAvatar/UserAvatar.tsx | 54 + .../components/Accordion/Accordion.api.tsx | 34 + .../mui/components/Accordion/Accordion.tsx | 3 + .../AccordionActions/AccordionActions.api.tsx | 5 + .../AccordionDetails/AccordionDetails.api.tsx | 11 + .../AccordionDetails/AccordionDetails.tsx | 3 + .../AccordionSummary/AccordionSummary.api.tsx | 34 + .../AccordionSummary/AccordionSummary.tsx | 3 + .../src/mui/components/Alert/Alert.api.tsx | 125 + anyclip/src/mui/components/Alert/Alert.tsx | 3 + .../components/AlertTitle/AlertTitle.api.tsx | 5 + .../mui/components/AlertTitle/AlertTitle.tsx | 3 + .../src/mui/components/AppBar/AppBar.api.tsx | 5 + .../Autocomplete/Autocomplete.api.tsx | 125 + .../components/Autocomplete/Autocomplete.tsx | 163 + .../src/mui/components/Avatar/Avatar.api.tsx | 58 + anyclip/src/mui/components/Avatar/Avatar.tsx | 3 + .../AvatarGroup/AvatarGroup.api.tsx | 14 + .../components/AvatarGroup/AvatarGroup.tsx | 3 + .../mui/components/Backdrop/Backdrop.api.tsx | 20 + .../src/mui/components/Badge/Badge.api.tsx | 51 + anyclip/src/mui/components/Badge/Badge.tsx | 3 + .../BottomNavigation/BottomNavigation.api.tsx | 5 + .../BottomNavigation/BottomNavigation.tsx | 3 + .../BottomNavigationAction.api.tsx | 5 + .../BottomNavigationAction.tsx | 3 + anyclip/src/mui/components/Box/Box.tsx | 3 + .../Breadcrumbs/Breadcrumbs.api.tsx | 21 + .../components/Breadcrumbs/Breadcrumbs.tsx | 3 + .../src/mui/components/Button/Button.api.tsx | 176 + anyclip/src/mui/components/Button/Button.tsx | 3 + .../components/ButtonBase/ButtonBase.api.tsx | 9 + .../ButtonGroup/ButtonGroup.api.tsx | 52 + .../components/ButtonGroup/ButtonGroup.tsx | 3 + anyclip/src/mui/components/Card/Card.api.tsx | 14 + anyclip/src/mui/components/Card/Card.tsx | 3 + .../CardActionArea/CardActionArea.api.tsx | 5 + .../CardActions/CardActions.api.tsx | 5 + .../CardContent/CardContent.api.tsx | 12 + .../components/CardHeader/CardHeader.api.tsx | 20 + .../components/CardMedia/CardMedia.api.tsx | 5 + .../mui/components/CardMedia/CardMedia.tsx | 3 + .../mui/components/Checkbox/Checkbox.api.tsx | 64 + .../src/mui/components/Checkbox/Checkbox.tsx | 3 + anyclip/src/mui/components/Chip/Chip.api.tsx | 251 + .../src/mui/components/Chip/Chip.module.scss | 2 + anyclip/src/mui/components/Chip/Chip.tsx | 173 + .../CircularProgress/CircularProgress.api.tsx | 38 + .../CircularProgress/CircularProgress.tsx | 3 + .../ClickAwayListener/ClickAwayListener.tsx | 3 + .../mui/components/Collapse/Collapse.api.tsx | 5 + .../src/mui/components/Collapse/Collapse.tsx | 3 + .../components/Container/Container.api.tsx | 5 + .../CssBaseline/CssBaseline.api.tsx | 5 + .../components/CssBaseline/CssBaseline.tsx | 3 + .../src/mui/components/CustomIcon/index.tsx | 263 + .../components/DataGridPro/DataGridPro.jsx | 3 + .../DataGridPro/components/DateCell/View.tsx | 13 + .../components/DateTimeCell/View.tsx | 16 + .../mui/components/DatePicker/DatePicker.tsx | 3 + .../DatePicker/StaticDatePicker.tsx | 3 + .../DateRangePicker/DateRangePicker.tsx | 3 + .../DateRangePicker/StaticDateRangePicker.tsx | 3 + .../DateTimePicker/DateTimePicker.tsx | 3 + .../DateTimePicker/StaticDateTimePicker.tsx | 3 + .../DateTimeRangePicker.tsx | 3 + .../StaticDateTimeRangePicker.tsx | 3 + .../src/mui/components/Dialog/Dialog.api.tsx | 24 + anyclip/src/mui/components/Dialog/Dialog.tsx | 3 + .../DialogActions/DialogActions.api.tsx | 15 + .../DialogActions/DialogActions.tsx | 3 + .../DialogContent/DialogContent.api.tsx | 23 + .../DialogContent/DialogContent.tsx | 3 + .../DialogContentText.api.tsx | 5 + .../DialogContentText/DialogContentText.tsx | 3 + .../DialogTitle/DialogTitle.api.tsx | 41 + .../components/DialogTitle/DialogTitle.tsx | 31 + .../mui/components/Divider/Divider.api.tsx | 11 + .../src/mui/components/Divider/Divider.tsx | 3 + .../src/mui/components/Drawer/Drawer.api.tsx | 12 + anyclip/src/mui/components/Fab/Fab.api.tsx | 36 + anyclip/src/mui/components/Fab/Fab.tsx | 3 + .../FilledInput/FilledInput.api.tsx | 160 + .../FormControl/FormControl.api.tsx | 5 + .../components/FormControl/FormControl.tsx | 3 + .../FormControlLabel/FormControlLabel.api.tsx | 19 + .../FormControlLabel/FormControlLabel.tsx | 3 + .../components/FormGroup/FormGroup.api.tsx | 5 + .../FormHelperText/FormHelperText.api.tsx | 16 + .../FormHelperText/FormHelperText.tsx | 3 + .../components/FormLabel/FormLabel.api.tsx | 15 + .../mui/components/FormLabel/FormLabel.tsx | 3 + anyclip/src/mui/components/Grid/Grid.api.tsx | 5 + anyclip/src/mui/components/Grid/Grid.tsx | 3 + anyclip/src/mui/components/Icon/Icon.api.tsx | 5 + .../components/IconButton/IconButton.api.tsx | 61 + .../mui/components/IconButton/IconButton.tsx | 3 + .../components/ImageList/ImageList.api.tsx | 5 + .../ImageListItem/ImageListItem.api.tsx | 5 + .../ImageListItemBar/ImageListItemBar.api.tsx | 5 + .../src/mui/components/Input/Input.api.tsx | 5 + .../InputAdornment/InputAdornment.api.tsx | 36 + .../InputAdornment/InputAdornment.tsx | 3 + .../components/InputBase/InputBase.api.tsx | 24 + .../components/InputLabel/InputLabel.api.tsx | 90 + .../mui/components/InputLabel/InputLabel.tsx | 3 + .../LinearProgress/LinearProgress.api.tsx | 20 + .../LinearProgress/LinearProgress.tsx | 3 + anyclip/src/mui/components/Link/Link.api.tsx | 18 + anyclip/src/mui/components/Link/Link.tsx | 3 + anyclip/src/mui/components/List/List.api.tsx | 13 + anyclip/src/mui/components/List/List.tsx | 33 + .../mui/components/ListItem/ListItem.api.tsx | 18 + .../src/mui/components/ListItem/ListItem.tsx | 3 + .../ListItemAvatar/ListItemAvatar.api.tsx | 13 + .../ListItemButton/ListItemButton.api.tsx | 5 + .../ListItemButton/ListItemButton.tsx | 3 + .../ListItemIcon/ListItemIcon.api.tsx | 15 + .../components/ListItemIcon/ListItemIcon.tsx | 3 + .../ListItemText/ListItemText.api.tsx | 18 + .../components/ListItemText/ListItemText.tsx | 3 + .../ListSubheader/ListSubheader.api.tsx | 12 + .../ListSubheader/ListSubheader.tsx | 3 + anyclip/src/mui/components/Menu/Menu.api.tsx | 16 + anyclip/src/mui/components/Menu/Menu.tsx | 32 + .../mui/components/MenuItem/MenuItem.api.tsx | 15 + .../src/mui/components/MenuItem/MenuItem.tsx | 3 + .../mui/components/MenuList/MenuList.api.tsx | 5 + .../MobileStepper/MobileStepper.api.tsx | 5 + .../src/mui/components/Modal/Modal.api.tsx | 12 + anyclip/src/mui/components/MuiOverrides.tsx | 715 + .../NativeSelect/NativeSelect.api.tsx | 5 + .../OutlinedInput/OutlinedInput.api.tsx | 135 + .../components/Pagination/Pagination.api.tsx | 57 + .../mui/components/Pagination/Pagination.tsx | 3 + .../PaginationItem/PaginationItem.api.tsx | 72 + .../src/mui/components/Paper/Paper.api.tsx | 14 + anyclip/src/mui/components/Paper/Paper.tsx | 3 + .../mui/components/Popover/Popover.api.tsx | 15 + .../src/mui/components/Popover/Popover.tsx | 32 + .../src/mui/components/Popper/Popper.api.tsx | 15 + anyclip/src/mui/components/Popper/Popper.tsx | 3 + .../src/mui/components/Portal/Portal.api.tsx | 3 + .../src/mui/components/Radio/Radio.api.tsx | 63 + anyclip/src/mui/components/Radio/Radio.tsx | 3 + .../components/RadioGroup/RadioGroup.api.tsx | 5 + .../mui/components/RadioGroup/RadioGroup.tsx | 3 + .../src/mui/components/Rating/Rating.api.tsx | 43 + anyclip/src/mui/components/Rating/Rating.tsx | 3 + .../src/mui/components/Select/Select.api.tsx | 109 + anyclip/src/mui/components/Select/Select.tsx | 30 + .../mui/components/Skeleton/Skeleton.api.tsx | 15 + .../src/mui/components/Skeleton/Skeleton.tsx | 3 + .../src/mui/components/Slide/Slide.api.tsx | 5 + .../src/mui/components/Slider/Slider.api.tsx | 253 + anyclip/src/mui/components/Slider/Slider.tsx | 40 + .../mui/components/Snackbar/Snackbar.api.tsx | 5 + .../src/mui/components/Snackbar/Snackbar.tsx | 3 + .../mui/components/Snackbar/SnackbarGroup.tsx | 57 + .../mui/components/Snackbar/SnackbarItem.tsx | 64 + .../SnackbarContent/SnackbarContent.api.tsx | 5 + .../components/SpeedDial/SpeedDial.api.tsx | 5 + .../SpeedDialAction/SpeedDialAction.api.tsx | 5 + .../SpeedDialIcon/SpeedDialIcon.api.tsx | 5 + .../src/mui/components/Stack/Stack.api.tsx | 17 + anyclip/src/mui/components/Stack/Stack.tsx | 3 + anyclip/src/mui/components/Step/Step.api.tsx | 5 + anyclip/src/mui/components/Step/Step.tsx | 3 + .../components/StepButton/StepButton.api.tsx | 5 + .../mui/components/StepButton/StepButton.tsx | 3 + .../StepConnector/StepConnector.api.tsx | 5 + .../StepContent/StepContent.api.tsx | 5 + .../mui/components/StepIcon/StepIcon.api.tsx | 5 + .../components/StepLabel/StepLabel.api.tsx | 5 + .../mui/components/StepLabel/StepLabel.tsx | 3 + .../mui/components/Stepper/Stepper.api.tsx | 5 + .../src/mui/components/Stepper/Stepper.tsx | 3 + .../mui/components/SvgIcon/SvgIcon.api.tsx | 65 + .../src/mui/components/SvgIcon/SvgIcon.tsx | 40 + .../SwipeableDrawer/SwipeableDrawer.api.tsx | 5 + .../src/mui/components/Switch/Switch.api.tsx | 124 + anyclip/src/mui/components/Switch/Switch.tsx | 3 + anyclip/src/mui/components/Tab/Tab.api.tsx | 72 + anyclip/src/mui/components/Tab/Tab.tsx | 3 + .../mui/components/TabContent/TabContent.tsx | 65 + .../TabScrollButton/TabScrollButton.api.tsx | 74 + .../src/mui/components/Table/Table.api.tsx | 12 + anyclip/src/mui/components/Table/Table.tsx | 3 + .../components/TableBody/TableBody.api.tsx | 5 + .../mui/components/TableBody/TableBody.tsx | 3 + .../components/TableCell/TableCell.api.tsx | 70 + .../mui/components/TableCell/TableCell.tsx | 28 + .../TableContainer/TableContainer.api.tsx | 17 + .../TableContainer/TableContainer.tsx | 25 + .../TableFooter/TableFooter.api.tsx | 5 + .../components/TableHead/TableHead.api.tsx | 13 + .../mui/components/TableHead/TableHead.tsx | 3 + .../TablePagination/TablePagination.api.tsx | 117 + .../TablePagination/TablePagination.tsx | 28 + .../mui/components/TableRow/TableRow.api.tsx | 17 + .../src/mui/components/TableRow/TableRow.tsx | 3 + .../TableScroll/TableScroll.module.scss | 2 + .../components/TableScroll/TableScroll.tsx | 57 + .../TableSortLabel/TableSortLabel.api.tsx | 15 + .../TableSortLabel/TableSortLabel.tsx | 3 + anyclip/src/mui/components/Tabs/Tabs.api.tsx | 52 + anyclip/src/mui/components/Tabs/Tabs.tsx | 58 + .../components/TextField/TextField.api.tsx | 204 + .../mui/components/TextField/TextField.tsx | 3 + .../mui/components/TimeField/TimeField.tsx | 3 + .../TimePicker/StaticTimePicker.tsx | 3 + .../mui/components/TimePicker/TimePicker.tsx | 3 + .../TimeRangePicker/StaticTimeRangePicker.tsx | 3 + .../TimeRangePicker/TimeRangePicker.tsx | 3 + .../ToggleButton/ToggleButton.api.tsx | 71 + .../components/ToggleButton/ToggleButton.tsx | 3 + .../ToggleButtonGroup.api.tsx | 88 + .../ToggleButtonGroup/ToggleButtonGroup.tsx | 3 + .../mui/components/Toolbar/Toolbar.api.tsx | 5 + .../mui/components/Tooltip/Tooltip.api.tsx | 40 + .../src/mui/components/Tooltip/Tooltip.tsx | 3 + .../src/mui/components/TreeView/TreeView.tsx | 134 + .../TreeView/TreeViewItem.module.scss | 2 + .../mui/components/TreeView/TreeViewItem.tsx | 91 + .../components/Typography/Typography.api.tsx | 78 + .../mui/components/Typography/Typography.tsx | 3 + anyclip/src/mui/components/constants/index.ts | 12 + anyclip/src/mui/components/index.ts | 154 + anyclip/src/mui/constants/breakpoints.ts | 14 + anyclip/src/mui/constants/config.ts | 9 + anyclip/src/mui/constants/index.ts | 13 + anyclip/src/mui/constants/opacity.ts | 8 + .../src/mui/constants/palette/dark/index.ts | 185 + anyclip/src/mui/constants/palette/index.ts | 31 + .../src/mui/constants/palette/light/index.ts | 185 + anyclip/src/mui/constants/shadows.ts | 42 + anyclip/src/mui/constants/shape.ts | 9 + anyclip/src/mui/constants/treeView.ts | 1 + anyclip/src/mui/constants/types.ts | 4 + anyclip/src/mui/constants/typography.ts | 117 + anyclip/src/mui/constants/zIndex.ts | 14 + .../UnstyledIcons/GoogleIconColored.tsx | 28 + .../UnstyledIcons/MicrosoftIconColored.tsx | 16 + .../UnstyledIcons/OktaIconColored.tsx | 16 + anyclip/src/mui/helpers/color.ts | 95 + anyclip/src/mui/helpers/cookie.ts | 49 + anyclip/src/mui/helpers/index.ts | 243 + anyclip/src/mui/helpers/license.tsx | 3 + anyclip/src/mui/helpers/treeView.ts | 157 + anyclip/src/mui/theme.global.scss | 1 + anyclip/src/mui/theme.tsx | 87 + anyclip/src/pages/403.tsx | 12 + anyclip/src/pages/404.tsx | 12 + anyclip/src/pages/_app.tsx | 229 + anyclip/src/pages/accounts/[id].tsx | 12 + anyclip/src/pages/accounts/index.tsx | 12 + anyclip/src/pages/activation-guest.tsx | 3 + anyclip/src/pages/ad-servers/[id].tsx | 12 + anyclip/src/pages/ad-servers/index.tsx | 12 + anyclip/src/pages/advertisers/[id].tsx | 12 + anyclip/src/pages/advertisers/index.tsx | 12 + .../pages/analytics-new/live-events-past.jsx | 12 + .../src}/pages/analytics-new/monetization.jsx | 0 .../pages/analytics-new/revenue-overview.tsx | 12 + .../video-content-performance.jsx | 0 {src => anyclip/src}/pages/analytics.tsx | 0 anyclip/src/pages/auth/cognito.tsx | 7 + anyclip/src/pages/auth/facebook.tsx | 7 + anyclip/src/pages/auth/google.tsx | 7 + .../pages/auth/microsoft/[authService].tsx | 7 + anyclip/src/pages/auth/tiktok.tsx | 7 + anyclip/src/pages/auth/zoom.tsx | 7 + anyclip/src/pages/config.tsx | 12 + anyclip/src/pages/content-owners/[id].tsx | 12 + anyclip/src/pages/content-owners/index.tsx | 12 + anyclip/src/pages/create-password.tsx | 3 + .../pages/custom-reports-new/[...params].jsx | 12 + .../src}/pages/custom-reports-new/index.jsx | 0 anyclip/src/pages/custom-reports/[id].tsx | 12 + anyclip/src/pages/custom-reports/index.tsx | 12 + anyclip/src/pages/demand/[...params].jsx | 12 + {src => anyclip/src}/pages/demand/index.jsx | 0 anyclip/src/pages/design-system/accordion.tsx | 12 + anyclip/src/pages/design-system/alert.tsx | 12 + .../src/pages/design-system/autocomplete.tsx | 12 + anyclip/src/pages/design-system/avatar.tsx | 12 + anyclip/src/pages/design-system/badge.tsx | 12 + .../pages/design-system/bottom-navigation.tsx | 12 + .../src/pages/design-system/breadcrumbs.tsx | 12 + .../src/pages/design-system/button-group.tsx | 12 + anyclip/src/pages/design-system/button.tsx | 12 + anyclip/src/pages/design-system/card.tsx | 12 + anyclip/src/pages/design-system/checkbox.tsx | 12 + anyclip/src/pages/design-system/chip-alt.tsx | 12 + anyclip/src/pages/design-system/chip.tsx | 12 + .../src/pages/design-system/color-picker.tsx | 12 + anyclip/src/pages/design-system/compare.tsx | 12 + anyclip/src/pages/design-system/data-grid.tsx | 12 + .../src/pages/design-system/date-picker.tsx | 12 + .../pages/design-system/date-range-picker.tsx | 12 + .../pages/design-system/date-time-picker.tsx | 12 + .../design-system/date-time-range-picker.tsx | 12 + anyclip/src/pages/design-system/dialog.tsx | 12 + .../pages/design-system/duration-field.tsx | 12 + anyclip/src/pages/design-system/fab.tsx | 12 + anyclip/src/pages/design-system/forms.tsx | 12 + anyclip/src/pages/design-system/grid-list.tsx | 12 + .../src/pages/design-system/icon-button.tsx | 12 + anyclip/src/pages/design-system/icon.tsx | 12 + .../inline-edit/autocomplete.tsx | 12 + .../inline-edit/date-time-picker.tsx | 12 + .../design-system/inline-edit/text-field.tsx | 12 + .../src/pages/design-system/json-editor.tsx | 12 + anyclip/src/pages/design-system/list.tsx | 12 + anyclip/src/pages/design-system/menu.tsx | 12 + .../src/pages/design-system/number-field.tsx | 12 + .../src/pages/design-system/pagination.tsx | 12 + anyclip/src/pages/design-system/progress.tsx | 12 + anyclip/src/pages/design-system/radio.tsx | 12 + anyclip/src/pages/design-system/rating.tsx | 12 + anyclip/src/pages/design-system/select.tsx | 12 + anyclip/src/pages/design-system/skeleton.tsx | 12 + anyclip/src/pages/design-system/slider.tsx | 12 + anyclip/src/pages/design-system/snackbar.tsx | 12 + anyclip/src/pages/design-system/stepper.tsx | 12 + anyclip/src/pages/design-system/switch.tsx | 12 + anyclip/src/pages/design-system/tab.tsx | 12 + anyclip/src/pages/design-system/table.tsx | 12 + .../src/pages/design-system/text-field.tsx | 12 + .../src/pages/design-system/time-picker.tsx | 12 + .../pages/design-system/time-range-picker.tsx | 12 + .../src/pages/design-system/toggle-button.tsx | 12 + anyclip/src/pages/design-system/tooltip.tsx | 12 + anyclip/src/pages/design-system/tree-view.tsx | 12 + .../src/pages/design-system/typography.tsx | 12 + anyclip/src/pages/entities/index.jsx | 12 + anyclip/src/pages/feeds/[[...params]].tsx | 20 + anyclip/src/pages/feeds/[id]/csv.tsx | 12 + anyclip/src/pages/feeds/[id]/manual.tsx | 12 + anyclip/src/pages/feeds/[id]/mrss.tsx | 12 + anyclip/src/pages/feeds/[id]/ms-stream.tsx | 12 + anyclip/src/pages/feeds/[id]/rss.tsx | 12 + anyclip/src/pages/feeds/[id]/sitemap.tsx | 12 + anyclip/src/pages/feeds/[id]/story-api.tsx | 12 + anyclip/src/pages/feeds/[id]/tiktok.tsx | 12 + anyclip/src/pages/feeds/[id]/video-api.tsx | 12 + anyclip/src/pages/feeds/[id]/vimeo.tsx | 12 + anyclip/src/pages/feeds/[id]/youtube.tsx | 12 + anyclip/src/pages/forgot-password.tsx | 3 + .../src/pages/form-templates/[...params].tsx | 12 + anyclip/src/pages/form-templates/index.tsx | 12 + anyclip/src/pages/forms/[...params].tsx | 12 + anyclip/src/pages/forms/_preview.tsx | 13 + {src => anyclip/src}/pages/forms/index.tsx | 0 .../src/pages/hb-connectors/[...params].jsx | 12 + anyclip/src/pages/hb-connectors/index.jsx | 12 + anyclip/src/pages/hubs/[id].tsx | 12 + {src => anyclip/src}/pages/hubs/index.tsx | 0 anyclip/src/pages/index.tsx | 11 + anyclip/src/pages/inventory/[[...params]].tsx | 14 + .../src}/pages/invitations/index.tsx | 0 anyclip/src/pages/key-lists/keys/[id].jsx | 12 + anyclip/src/pages/key-lists/keys/index.jsx | 12 + anyclip/src/pages/key-lists/lists/[id].jsx | 12 + anyclip/src/pages/key-lists/lists/index.jsx | 12 + anyclip/src/pages/live/[id].tsx | 12 + anyclip/src/pages/live/_preview.tsx | 13 + anyclip/src/pages/live/index.tsx | 12 + anyclip/src/pages/login.tsx | 3 + anyclip/src/pages/logout.tsx | 44 + anyclip/src/pages/marketplace-dashboard.tsx | 12 + .../src/pages/notifications/[[...params]].tsx | 12 + anyclip/src/pages/online-help/[id].tsx | 12 + anyclip/src/pages/online-help/index.tsx | 12 + anyclip/src/pages/passwordless-login-code.tsx | 3 + anyclip/src/pages/passwordless-login.tsx | 3 + .../src/pages/permissions/[[...params]].tsx | 12 + .../src}/pages/personal-settings.tsx | 0 .../src/pages/player-old/[[...params]].tsx | 14 + anyclip/src/pages/player/[...params].tsx | 12 + anyclip/src/pages/player/_preview.tsx | 16 + anyclip/src/pages/player/index.tsx | 12 + .../src/pages/publishing/[platform]/[id].tsx | 12 + anyclip/src/pages/publishing/index.tsx | 12 + anyclip/src/pages/reset-password.tsx | 3 + anyclip/src/pages/roles-permissions/[id].tsx | 12 + .../roles-permissions/[id]/duplicate.tsx | 12 + anyclip/src/pages/roles-permissions/index.tsx | 12 + anyclip/src/pages/sso-login.tsx | 3 + anyclip/src/pages/sso/[id].tsx | 12 + anyclip/src/pages/sso/index.tsx | 12 + {src => anyclip/src}/pages/studio.tsx | 0 anyclip/src/pages/supply/[...params].jsx | 12 + anyclip/src/pages/supply/index.jsx | 12 + anyclip/src/pages/user-auth-error.tsx | 3 + anyclip/src/pages/users/[id].tsx | 12 + {src => anyclip/src}/pages/users/index.tsx | 0 anyclip/src/pages/watch/[[...params]].tsx | 235 + anyclip/src/pages/x-ray/campaigns/[id].tsx | 12 + .../src}/pages/x-ray/campaigns/index.tsx | 0 anyclip/src/pages/x-ray/creatives/[id].tsx | 12 + .../pages/x-ray/creatives/[id]/duplicate.tsx | 12 + .../src}/pages/x-ray/creatives/index.tsx | 0 anyclip/src/pages/x-ray/line-items/[id].tsx | 12 + .../pages/x-ray/line-items/[id]/duplicate.tsx | 12 + .../src}/pages/x-ray/line-items/index.tsx | 0 anyclip/src/rootEpics.ts | 230 + anyclip/src/rootReducer.ts | 250 + anyclip/src/session.js | 62 + .../shared/lib/amp-context.shared-runtime.ts | 0 {src => anyclip/src}/shared/lib/amp-mode.ts | 0 .../lib/app-router-context.shared-runtime.ts | 0 .../src}/shared/lib/bloom-filter.ts | 0 {src => anyclip/src}/shared/lib/constants.ts | 0 anyclip/src/shared/lib/dynamic.tsx | 148 + .../src}/shared/lib/encode-uri-path.ts | 0 .../src}/shared/lib/escape-regexp.ts | 0 anyclip/src/shared/lib/get-img-props.ts | 748 + .../head-manager-context.shared-runtime.ts | 0 {src => anyclip/src}/shared/lib/head.tsx | 0 .../hooks-client-context.shared-runtime.ts | 0 .../shared/lib/i18n/normalize-locale-path.ts | 0 anyclip/src/shared/lib/image-blur-svg.ts | 34 + .../image-config-context.shared-runtime.ts | 0 .../src}/shared/lib/image-config.ts | 0 anyclip/src/shared/lib/image-external.tsx | 36 + anyclip/src/shared/lib/image-loader.ts | 108 + .../src}/shared/lib/is-plain-object.ts | 0 .../shared/lib/lazy-dynamic/bailout-to-csr.ts | 0 .../lib/loadable-context.shared-runtime.ts | 11 + .../shared/lib/loadable.shared-runtime.tsx | 302 + {src => anyclip/src}/shared/lib/mitt.ts | 0 .../shared/lib/modern-browserslist-target.js | 0 .../lib/page-path/denormalize-page-path.ts | 0 .../lib/page-path/ensure-leading-slash.ts | 0 .../lib/page-path/normalize-path-sep.ts | 0 .../lib/router-context.shared-runtime.ts | 0 .../src}/shared/lib/router/adapters.tsx | 0 .../src}/shared/lib/router/router.ts | 0 .../shared/lib/router/utils/add-locale.ts | 0 .../lib/router/utils/add-path-prefix.ts | 0 .../lib/router/utils/add-path-suffix.ts | 0 .../src}/shared/lib/router/utils/app-paths.ts | 0 .../router/utils/as-path-to-search-params.ts | 0 .../shared/lib/router/utils/compare-states.ts | 0 .../lib/router/utils/disable-smooth-scroll.ts | 0 .../router/utils/format-next-pathname-info.ts | 0 .../shared/lib/router/utils/format-url.ts | 0 .../router/utils/get-asset-path-from-route.ts | 0 .../lib/router/utils/get-dynamic-param.ts | 0 .../router/utils/get-next-pathname-info.ts | 0 .../src}/shared/lib/router/utils/html-bots.ts | 0 .../src}/shared/lib/router/utils/index.ts | 0 .../lib/router/utils/interception-routes.ts | 0 .../shared/lib/router/utils/interpolate-as.ts | 0 .../src}/shared/lib/router/utils/is-bot.ts | 0 .../shared/lib/router/utils/is-dynamic.ts | 0 .../shared/lib/router/utils/is-local-url.ts | 0 .../src}/shared/lib/router/utils/omit.ts | 0 .../shared/lib/router/utils/parse-path.ts | 0 .../lib/router/utils/parse-relative-url.ts | 0 .../src}/shared/lib/router/utils/parse-url.ts | 0 .../lib/router/utils/path-has-prefix.ts | 0 .../shared/lib/router/utils/path-match.ts | 0 .../lib/router/utils/prepare-destination.ts | 0 .../shared/lib/router/utils/querystring.ts | 0 .../lib/router/utils/remove-path-prefix.ts | 0 .../lib/router/utils/remove-trailing-slash.ts | 0 .../lib/router/utils/resolve-rewrites.ts | 0 .../lib/router/utils/route-match-utils.ts | 0 .../shared/lib/router/utils/route-matcher.ts | 0 .../shared/lib/router/utils/route-regex.ts | 0 .../shared/lib/router/utils/sorted-routes.ts | 0 .../shared/lib/runtime-config.external.ts | 0 {src => anyclip/src}/shared/lib/segment.ts | 0 .../server-inserted-html.shared-runtime.tsx | 22 + .../src}/shared/lib/side-effect.tsx | 0 {src => anyclip/src}/shared/lib/utils.ts | 0 anyclip/src/shared/lib/utils/error-once.ts | 12 + .../src}/shared/lib/utils/warn-once.ts | 0 anyclip/webpack/bootstrap.js | 36 + anyclip/webpack/runtime/chunk-loaded.js | 28 + .../runtime/compat-get-default-export.js | 8 + .../runtime/create-fake-namespace-object.js | 26 + anyclip/webpack/runtime/css-loading.js | 77 + .../runtime/define-property-getters.js | 8 + anyclip/webpack/runtime/ensure-chunk.js | 9 + .../runtime/get-javascript-chunk-filename.js | 11 + .../runtime/get-mini-css-chunk-filename.js | 5 + anyclip/webpack/runtime/global.js | 8 + .../runtime/hasOwnProperty-shorthand.js | 1 + .../webpack/runtime/jsonp-chunk-loading.js | 86 + anyclip/webpack/runtime/load-script.js | 42 + .../webpack/runtime/make-namespace-object.js | 7 + .../webpack/runtime/node-module-decorator.js | 5 + anyclip/webpack/runtime/publicPath.js | 1 + .../webpack/runtime/trusted-types-policy.js | 13 + .../runtime/trusted-types-script-url.js | 1 + bun.lock | 26 + docs/auth.md | 168 + docs/monetization-api.md | 152 + package.json | 13 + scripts/auth.ts | 275 + scripts/crypto-subtle.ts | 152 + scripts/crypto.ts | 111 + scripts/download-sourcemaps.ts | 70 + scripts/extract-login-code.ts | 22 + scripts/extract-sources.ts | 197 + scripts/intercept-monetization.ts | 244 + scripts/test-auth-final.ts | 112 + scripts/update-urls.ts | 66 + src/modules/common/Table/index.jsx | 147 - .../common/Table/Table.module.scss | 2 - .../marketplace/common/Table/index.jsx | 398 - tsconfig.json | 29 + urls.txt | 254 +- vendor/node_modules/d3-array/src/ascending.js | 3 - vendor/node_modules/d3-array/src/bisect.js | 9 - vendor/node_modules/d3-array/src/bisector.js | 56 - .../node_modules/d3-array/src/descending.js | 7 - vendor/node_modules/d3-array/src/max.js | 20 - vendor/node_modules/d3-array/src/min.js | 20 - vendor/node_modules/d3-array/src/number.js | 20 - vendor/node_modules/d3-array/src/quantile.js | 47 - .../node_modules/d3-array/src/quickselect.js | 53 - vendor/node_modules/d3-array/src/range.js | 13 - vendor/node_modules/d3-array/src/sort.js | 39 - vendor/node_modules/d3-array/src/ticks.js | 55 - vendor/node_modules/d3-color/src/color.js | 396 - vendor/node_modules/d3-color/src/define.js | 10 - .../d3-format/src/defaultLocale.js | 18 - vendor/node_modules/d3-format/src/exponent.js | 5 - .../d3-format/src/formatDecimal.js | 20 - .../node_modules/d3-format/src/formatGroup.js | 18 - .../d3-format/src/formatNumerals.js | 7 - .../d3-format/src/formatPrefixAuto.js | 16 - .../d3-format/src/formatRounded.js | 11 - .../d3-format/src/formatSpecifier.js | 47 - .../node_modules/d3-format/src/formatTrim.js | 11 - .../node_modules/d3-format/src/formatTypes.js | 19 - vendor/node_modules/d3-format/src/identity.js | 3 - vendor/node_modules/d3-format/src/locale.js | 148 - .../d3-format/src/precisionFixed.js | 5 - .../d3-format/src/precisionPrefix.js | 5 - .../d3-format/src/precisionRound.js | 6 - .../node_modules/d3-interpolate/src/array.js | 22 - .../node_modules/d3-interpolate/src/basis.js | 19 - .../d3-interpolate/src/basisClosed.js | 13 - .../node_modules/d3-interpolate/src/color.js | 29 - .../d3-interpolate/src/constant.js | 1 - .../node_modules/d3-interpolate/src/date.js | 6 - .../node_modules/d3-interpolate/src/number.js | 5 - .../d3-interpolate/src/numberArray.js | 14 - .../node_modules/d3-interpolate/src/object.js | 23 - .../d3-interpolate/src/piecewise.js | 11 - vendor/node_modules/d3-interpolate/src/rgb.js | 55 - .../node_modules/d3-interpolate/src/round.js | 5 - .../node_modules/d3-interpolate/src/string.js | 64 - .../node_modules/d3-interpolate/src/value.js | 22 - vendor/node_modules/d3-path/src/path.js | 156 - vendor/node_modules/d3-scale/src/band.js | 101 - vendor/node_modules/d3-scale/src/constant.js | 5 - .../node_modules/d3-scale/src/continuous.js | 125 - vendor/node_modules/d3-scale/src/diverging.js | 104 - vendor/node_modules/d3-scale/src/identity.js | 28 - vendor/node_modules/d3-scale/src/index.js | 78 - vendor/node_modules/d3-scale/src/init.js | 26 - vendor/node_modules/d3-scale/src/linear.js | 70 - vendor/node_modules/d3-scale/src/log.js | 140 - vendor/node_modules/d3-scale/src/nice.js | 18 - vendor/node_modules/d3-scale/src/number.js | 3 - vendor/node_modules/d3-scale/src/ordinal.js | 46 - vendor/node_modules/d3-scale/src/pow.js | 50 - vendor/node_modules/d3-scale/src/quantile.js | 57 - vendor/node_modules/d3-scale/src/quantize.js | 56 - vendor/node_modules/d3-scale/src/radial.js | 63 - .../node_modules/d3-scale/src/sequential.js | 107 - .../d3-scale/src/sequentialQuantile.js | 38 - vendor/node_modules/d3-scale/src/symlog.js | 35 - vendor/node_modules/d3-scale/src/threshold.js | 39 - .../node_modules/d3-scale/src/tickFormat.js | 29 - vendor/node_modules/d3-scale/src/time.js | 71 - vendor/node_modules/d3-scale/src/utcTime.js | 8 - vendor/node_modules/d3-shape/src/area.js | 112 - vendor/node_modules/d3-shape/src/array.js | 7 - vendor/node_modules/d3-shape/src/constant.js | 5 - .../node_modules/d3-shape/src/curve/basis.js | 51 - .../d3-shape/src/curve/basisClosed.js | 52 - .../d3-shape/src/curve/basisOpen.js | 39 - .../node_modules/d3-shape/src/curve/bump.js | 75 - .../node_modules/d3-shape/src/curve/linear.js | 31 - .../d3-shape/src/curve/linearClosed.js | 25 - .../d3-shape/src/curve/monotone.js | 104 - .../d3-shape/src/curve/natural.js | 65 - .../node_modules/d3-shape/src/curve/step.js | 53 - vendor/node_modules/d3-shape/src/line.js | 58 - vendor/node_modules/d3-shape/src/math.js | 20 - vendor/node_modules/d3-shape/src/noop.js | 1 - .../d3-shape/src/offset/expand.js | 10 - .../node_modules/d3-shape/src/offset/none.js | 9 - .../d3-shape/src/offset/silhouette.js | 10 - .../d3-shape/src/offset/wiggle.js | 24 - .../node_modules/d3-shape/src/order/none.js | 5 - vendor/node_modules/d3-shape/src/path.js | 19 - vendor/node_modules/d3-shape/src/point.js | 7 - vendor/node_modules/d3-shape/src/stack.js | 58 - vendor/node_modules/d3-shape/src/symbol.js | 66 - .../d3-shape/src/symbol/asterisk.js | 17 - .../d3-shape/src/symbol/circle.js | 9 - .../node_modules/d3-shape/src/symbol/cross.js | 20 - .../d3-shape/src/symbol/diamond.js | 16 - .../d3-shape/src/symbol/diamond2.js | 12 - .../node_modules/d3-shape/src/symbol/plus.js | 11 - .../d3-shape/src/symbol/square.js | 9 - .../d3-shape/src/symbol/square2.js | 12 - .../node_modules/d3-shape/src/symbol/star.js | 24 - .../node_modules/d3-shape/src/symbol/times.js | 11 - .../d3-shape/src/symbol/triangle.js | 13 - .../d3-shape/src/symbol/triangle2.js | 15 - .../node_modules/d3-shape/src/symbol/wye.js | 25 - .../d3-time-format/src/defaultLocale.js | 27 - .../node_modules/d3-time-format/src/locale.js | 697 - vendor/node_modules/d3-time/src/day.js | 35 - vendor/node_modules/d3-time/src/duration.js | 7 - vendor/node_modules/d3-time/src/hour.js | 26 - vendor/node_modules/d3-time/src/interval.js | 69 - .../node_modules/d3-time/src/millisecond.js | 25 - vendor/node_modules/d3-time/src/minute.js | 26 - vendor/node_modules/d3-time/src/month.js | 27 - vendor/node_modules/d3-time/src/second.js | 14 - vendor/node_modules/d3-time/src/ticks.js | 58 - vendor/node_modules/d3-time/src/week.js | 56 - vendor/node_modules/d3-time/src/year.js | 49 - .../dayjs/plugin/isSameOrAfter.js | 1 - .../node_modules/decimal.js-light/decimal.js | 2014 -- .../accessibility/dist/accessibility.esm.js | 61 - .../dnd-kit/core/dist/core.esm.js | 3969 ---- .../dnd-kit/utilities/dist/utilities.esm.js | 333 - vendor/node_modules/eventemitter3/index.js | 336 - .../fast-equals/dist/esm/index.mjs | 633 - vendor/node_modules/internmap/src/index.js | 61 - vendor/node_modules/lodash/_DataView.js | 7 - vendor/node_modules/lodash/_Hash.js | 32 - vendor/node_modules/lodash/_ListCache.js | 32 - vendor/node_modules/lodash/_Map.js | 7 - vendor/node_modules/lodash/_MapCache.js | 32 - vendor/node_modules/lodash/_Promise.js | 7 - vendor/node_modules/lodash/_Set.js | 7 - vendor/node_modules/lodash/_SetCache.js | 27 - vendor/node_modules/lodash/_Stack.js | 27 - vendor/node_modules/lodash/_Symbol.js | 6 - vendor/node_modules/lodash/_Uint8Array.js | 6 - vendor/node_modules/lodash/_WeakMap.js | 7 - vendor/node_modules/lodash/_apply.js | 21 - vendor/node_modules/lodash/_arrayEvery.js | 23 - vendor/node_modules/lodash/_arrayFilter.js | 25 - vendor/node_modules/lodash/_arrayIncludes.js | 17 - .../node_modules/lodash/_arrayIncludesWith.js | 22 - vendor/node_modules/lodash/_arrayLikeKeys.js | 49 - vendor/node_modules/lodash/_arrayMap.js | 21 - vendor/node_modules/lodash/_arrayPush.js | 20 - vendor/node_modules/lodash/_arraySome.js | 23 - vendor/node_modules/lodash/_asciiToArray.js | 12 - vendor/node_modules/lodash/_assocIndexOf.js | 21 - .../node_modules/lodash/_baseAssignValue.js | 25 - vendor/node_modules/lodash/_baseEach.js | 14 - vendor/node_modules/lodash/_baseEvery.js | 21 - vendor/node_modules/lodash/_baseExtremum.js | 32 - vendor/node_modules/lodash/_baseFindIndex.js | 24 - vendor/node_modules/lodash/_baseFlatten.js | 38 - vendor/node_modules/lodash/_baseFor.js | 16 - vendor/node_modules/lodash/_baseForOwn.js | 16 - vendor/node_modules/lodash/_baseGet.js | 24 - vendor/node_modules/lodash/_baseGetAllKeys.js | 20 - vendor/node_modules/lodash/_baseGetTag.js | 28 - vendor/node_modules/lodash/_baseGt.js | 14 - vendor/node_modules/lodash/_baseHasIn.js | 13 - vendor/node_modules/lodash/_baseIndexOf.js | 20 - .../node_modules/lodash/_baseIsArguments.js | 18 - vendor/node_modules/lodash/_baseIsEqual.js | 28 - .../node_modules/lodash/_baseIsEqualDeep.js | 83 - vendor/node_modules/lodash/_baseIsMatch.js | 62 - vendor/node_modules/lodash/_baseIsNaN.js | 12 - vendor/node_modules/lodash/_baseIsNative.js | 47 - .../node_modules/lodash/_baseIsTypedArray.js | 60 - vendor/node_modules/lodash/_baseIteratee.js | 31 - vendor/node_modules/lodash/_baseKeys.js | 30 - vendor/node_modules/lodash/_baseLt.js | 14 - vendor/node_modules/lodash/_baseMap.js | 22 - vendor/node_modules/lodash/_baseMatches.js | 22 - .../lodash/_baseMatchesProperty.js | 33 - vendor/node_modules/lodash/_baseOrderBy.js | 49 - vendor/node_modules/lodash/_baseProperty.js | 14 - .../node_modules/lodash/_basePropertyDeep.js | 16 - vendor/node_modules/lodash/_baseRange.js | 28 - vendor/node_modules/lodash/_baseRest.js | 17 - .../node_modules/lodash/_baseSetToString.js | 22 - vendor/node_modules/lodash/_baseSlice.js | 31 - vendor/node_modules/lodash/_baseSome.js | 22 - vendor/node_modules/lodash/_baseSortBy.js | 21 - vendor/node_modules/lodash/_baseTimes.js | 20 - vendor/node_modules/lodash/_baseToString.js | 37 - vendor/node_modules/lodash/_baseTrim.js | 19 - vendor/node_modules/lodash/_baseUnary.js | 14 - vendor/node_modules/lodash/_baseUniq.js | 72 - vendor/node_modules/lodash/_cacheHas.js | 13 - vendor/node_modules/lodash/_castPath.js | 21 - vendor/node_modules/lodash/_castSlice.js | 18 - .../node_modules/lodash/_compareAscending.js | 41 - .../node_modules/lodash/_compareMultiple.js | 44 - vendor/node_modules/lodash/_coreJsData.js | 6 - vendor/node_modules/lodash/_createBaseEach.js | 32 - vendor/node_modules/lodash/_createBaseFor.js | 25 - .../node_modules/lodash/_createCaseFirst.js | 33 - vendor/node_modules/lodash/_createFind.js | 25 - vendor/node_modules/lodash/_createRange.js | 30 - vendor/node_modules/lodash/_createSet.js | 19 - vendor/node_modules/lodash/_defineProperty.js | 11 - vendor/node_modules/lodash/_equalArrays.js | 84 - vendor/node_modules/lodash/_equalByTag.js | 112 - vendor/node_modules/lodash/_equalObjects.js | 90 - vendor/node_modules/lodash/_freeGlobal.js | 4 - vendor/node_modules/lodash/_getAllKeys.js | 16 - vendor/node_modules/lodash/_getMapData.js | 18 - vendor/node_modules/lodash/_getMatchData.js | 24 - vendor/node_modules/lodash/_getNative.js | 17 - vendor/node_modules/lodash/_getPrototype.js | 6 - vendor/node_modules/lodash/_getRawTag.js | 46 - vendor/node_modules/lodash/_getSymbols.js | 30 - vendor/node_modules/lodash/_getTag.js | 58 - vendor/node_modules/lodash/_getValue.js | 13 - vendor/node_modules/lodash/_hasPath.js | 39 - vendor/node_modules/lodash/_hasUnicode.js | 26 - vendor/node_modules/lodash/_hashClear.js | 15 - vendor/node_modules/lodash/_hashDelete.js | 17 - vendor/node_modules/lodash/_hashGet.js | 30 - vendor/node_modules/lodash/_hashHas.js | 23 - vendor/node_modules/lodash/_hashSet.js | 23 - vendor/node_modules/lodash/_isFlattenable.js | 20 - vendor/node_modules/lodash/_isIndex.js | 25 - vendor/node_modules/lodash/_isIterateeCall.js | 30 - vendor/node_modules/lodash/_isKey.js | 29 - vendor/node_modules/lodash/_isKeyable.js | 15 - vendor/node_modules/lodash/_isMasked.js | 20 - vendor/node_modules/lodash/_isPrototype.js | 18 - .../lodash/_isStrictComparable.js | 15 - vendor/node_modules/lodash/_listCacheClear.js | 13 - .../node_modules/lodash/_listCacheDelete.js | 35 - vendor/node_modules/lodash/_listCacheGet.js | 19 - vendor/node_modules/lodash/_listCacheHas.js | 16 - vendor/node_modules/lodash/_listCacheSet.js | 26 - vendor/node_modules/lodash/_mapCacheClear.js | 21 - vendor/node_modules/lodash/_mapCacheDelete.js | 18 - vendor/node_modules/lodash/_mapCacheGet.js | 16 - vendor/node_modules/lodash/_mapCacheHas.js | 16 - vendor/node_modules/lodash/_mapCacheSet.js | 22 - vendor/node_modules/lodash/_mapToArray.js | 18 - .../lodash/_matchesStrictComparable.js | 20 - vendor/node_modules/lodash/_memoizeCapped.js | 26 - vendor/node_modules/lodash/_nativeCreate.js | 6 - vendor/node_modules/lodash/_nativeKeys.js | 6 - vendor/node_modules/lodash/_nodeUtil.js | 30 - vendor/node_modules/lodash/_objectToString.js | 22 - vendor/node_modules/lodash/_overArg.js | 15 - vendor/node_modules/lodash/_overRest.js | 36 - vendor/node_modules/lodash/_root.js | 9 - vendor/node_modules/lodash/_setCacheAdd.js | 19 - vendor/node_modules/lodash/_setCacheHas.js | 14 - vendor/node_modules/lodash/_setToArray.js | 18 - vendor/node_modules/lodash/_setToString.js | 14 - vendor/node_modules/lodash/_shortOut.js | 37 - vendor/node_modules/lodash/_stackClear.js | 15 - vendor/node_modules/lodash/_stackDelete.js | 18 - vendor/node_modules/lodash/_stackGet.js | 14 - vendor/node_modules/lodash/_stackHas.js | 14 - vendor/node_modules/lodash/_stackSet.js | 34 - vendor/node_modules/lodash/_strictIndexOf.js | 23 - vendor/node_modules/lodash/_stringToArray.js | 18 - vendor/node_modules/lodash/_stringToPath.js | 27 - vendor/node_modules/lodash/_toKey.js | 21 - vendor/node_modules/lodash/_toSource.js | 26 - .../node_modules/lodash/_trimmedEndIndex.js | 19 - vendor/node_modules/lodash/_unicodeToArray.js | 40 - vendor/node_modules/lodash/constant.js | 26 - vendor/node_modules/lodash/debounce.js | 191 - vendor/node_modules/lodash/eq.js | 37 - vendor/node_modules/lodash/every.js | 56 - vendor/node_modules/lodash/find.js | 42 - vendor/node_modules/lodash/findIndex.js | 55 - vendor/node_modules/lodash/flatMap.js | 29 - vendor/node_modules/lodash/get.js | 33 - vendor/node_modules/lodash/hasIn.js | 34 - vendor/node_modules/lodash/identity.js | 21 - vendor/node_modules/lodash/isArguments.js | 36 - vendor/node_modules/lodash/isArray.js | 26 - vendor/node_modules/lodash/isArrayLike.js | 33 - vendor/node_modules/lodash/isBoolean.js | 29 - vendor/node_modules/lodash/isBuffer.js | 38 - vendor/node_modules/lodash/isEqual.js | 35 - vendor/node_modules/lodash/isFunction.js | 37 - vendor/node_modules/lodash/isLength.js | 35 - vendor/node_modules/lodash/isNaN.js | 38 - vendor/node_modules/lodash/isNil.js | 25 - vendor/node_modules/lodash/isNumber.js | 38 - vendor/node_modules/lodash/isObject.js | 31 - vendor/node_modules/lodash/isObjectLike.js | 29 - vendor/node_modules/lodash/isPlainObject.js | 62 - vendor/node_modules/lodash/isString.js | 30 - vendor/node_modules/lodash/isSymbol.js | 29 - vendor/node_modules/lodash/isTypedArray.js | 27 - vendor/node_modules/lodash/keys.js | 37 - vendor/node_modules/lodash/last.js | 20 - vendor/node_modules/lodash/map.js | 53 - vendor/node_modules/lodash/mapValues.js | 43 - vendor/node_modules/lodash/max.js | 29 - vendor/node_modules/lodash/maxBy.js | 34 - vendor/node_modules/lodash/memoize.js | 73 - vendor/node_modules/lodash/min.js | 29 - vendor/node_modules/lodash/minBy.js | 34 - vendor/node_modules/lodash/noop.js | 17 - vendor/node_modules/lodash/now.js | 23 - vendor/node_modules/lodash/property.js | 32 - vendor/node_modules/lodash/range.js | 46 - vendor/node_modules/lodash/some.js | 51 - vendor/node_modules/lodash/sortBy.js | 48 - vendor/node_modules/lodash/stubArray.js | 23 - vendor/node_modules/lodash/stubFalse.js | 18 - vendor/node_modules/lodash/throttle.js | 69 - vendor/node_modules/lodash/toFinite.js | 42 - vendor/node_modules/lodash/toInteger.js | 36 - vendor/node_modules/lodash/toNumber.js | 64 - vendor/node_modules/lodash/toString.js | 28 - vendor/node_modules/lodash/uniqBy.js | 31 - vendor/node_modules/lodash/upperFirst.js | 22 - .../mui/icons-material/esm/AccessTime.js | 9 - .../mui/icons-material/esm/Add.js | 7 - .../esm/AddCircleOutlineRounded.js | 7 - .../mui/icons-material/esm/AddRounded.js | 7 - .../icons-material/esm/AddToQueueRounded.js | 7 - .../mui/icons-material/esm/ArchiveRounded.js | 7 - .../mui/icons-material/esm/ArrowBack.js | 7 - .../mui/icons-material/esm/ArrowDownward.js | 7 - .../mui/icons-material/esm/ArrowDropDown.js | 7 - .../mui/icons-material/esm/ArrowUpward.js | 7 - .../mui/icons-material/esm/AutoAwesome.js | 7 - .../mui/icons-material/esm/CalendarToday.js | 7 - .../mui/icons-material/esm/CheckCircle.js | 7 - .../mui/icons-material/esm/CheckOutlined.js | 7 - .../mui/icons-material/esm/CheckRounded.js | 7 - .../mui/icons-material/esm/Checklist.js | 7 - .../icons-material/esm/ChevronRightRounded.js | 7 - .../mui/icons-material/esm/Circle.js | 7 - .../mui/icons-material/esm/CloseOutlined.js | 7 - .../icons-material/esm/CloudUploadOutlined.js | 7 - .../mui/icons-material/esm/ContentCopy.js | 7 - .../icons-material/esm/ContentCopyRounded.js | 7 - .../icons-material/esm/ContentCutRounded.js | 7 - .../icons-material/esm/CropSquareOutlined.js | 7 - .../mui/icons-material/esm/DeleteForever.js | 7 - .../esm/DeleteForeverRounded.js | 7 - .../mui/icons-material/esm/DeleteRounded.js | 7 - .../mui/icons-material/esm/Description.js | 7 - .../mui/icons-material/esm/Devices.js | 7 - .../mui/icons-material/esm/Download.js | 7 - .../mui/icons-material/esm/DownloadRounded.js | 7 - .../mui/icons-material/esm/DragIndicator.js | 7 - .../mui/icons-material/esm/EditOutlined.js | 7 - .../esm/EnergySavingsLeafRounded.js | 7 - .../icons-material/esm/ExpandMoreRounded.js | 7 - .../mui/icons-material/esm/ExploreOutlined.js | 7 - .../icons-material/esm/FileDownloadRounded.js | 7 - .../icons-material/esm/FileUploadRounded.js | 7 - .../icons-material/esm/FilterAltRounded.js | 7 - .../mui/icons-material/esm/Fullscreen.js | 7 - .../mui/icons-material/esm/FullscreenExit.js | 7 - .../mui/icons-material/esm/Groups.js | 7 - .../mui/icons-material/esm/GroupsRounded.js | 7 - .../icons-material/esm/HelpOutlineOutlined.js | 7 - .../mui/icons-material/esm/ImageRounded.js | 7 - .../mui/icons-material/esm/Info.js | 7 - .../mui/icons-material/esm/Inventory.js | 7 - .../icons-material/esm/Inventory2Outlined.js | 9 - .../icons-material/esm/Inventory2Rounded.js | 7 - .../esm/KeyboardArrowLeftOutlined.js | 7 - .../esm/KeyboardArrowRightOutlined.js | 7 - .../esm/KeyboardArrowUpRounded.js | 7 - .../mui/icons-material/esm/Language.js | 7 - .../esm/LibraryAddCheckOutlined.js | 7 - .../mui/icons-material/esm/Lock.js | 7 - .../mui/icons-material/esm/LockOpen.js | 7 - .../mui/icons-material/esm/LockOutlined.js | 7 - .../icons-material/esm/LockPersonRounded.js | 9 - .../icons-material/esm/MoreHorizOutlined.js | 7 - .../mui/icons-material/esm/MoreVertRounded.js | 7 - .../mui/icons-material/esm/NearMeOutlined.js | 7 - .../esm/NotInterestedOutlined.js | 7 - .../mui/icons-material/esm/PauseRounded.js | 7 - .../mui/icons-material/esm/PersonAddAlt1.js | 7 - .../esm/PersonAddAltOutlined.js | 7 - .../icons-material/esm/PersonAddOutlined.js | 7 - .../icons-material/esm/PictureAsPdfRounded.js | 7 - .../icons-material/esm/PlayArrowRounded.js | 7 - .../icons-material/esm/PlayCircleOutline.js | 7 - .../icons-material/esm/PlayCircleRounded.js | 7 - .../icons-material/esm/PlaylistAddRounded.js | 7 - .../mui/icons-material/esm/PublicRounded.js | 7 - .../mui/icons-material/esm/Publish.js | 7 - .../esm/QueuePlayNextRounded.js | 7 - .../mui/icons-material/esm/Refresh.js | 7 - .../mui/icons-material/esm/RemoveCircle.js | 7 - .../mui/icons-material/esm/RemoveOutlined.js | 7 - .../esm/RemoveRedEyeOutlined.js | 7 - .../mui/icons-material/esm/SearchRounded.js | 7 - .../mui/icons-material/esm/SendRounded.js | 7 - .../mui/icons-material/esm/ShareRounded.js | 7 - .../mui/icons-material/esm/SpeedRounded.js | 9 - .../icons-material/esm/StickyNote2Rounded.js | 7 - .../mui/icons-material/esm/SyncOutlined.js | 7 - .../mui/icons-material/esm/TheatersRounded.js | 7 - .../mui/icons-material/esm/ThumbUpRounded.js | 7 - .../mui/icons-material/esm/TokenRounded.js | 7 - .../mui/icons-material/esm/TrendingDown.js | 7 - .../mui/icons-material/esm/TrendingUp.js | 7 - .../icons-material/esm/TrendingUpRounded.js | 7 - .../mui/icons-material/esm/UploadRounded.js | 7 - .../mui/icons-material/esm/Visibility.js | 7 - .../icons-material/esm/VisibilityRounded.js | 7 - .../mui/icons-material/esm/VolumeDown.js | 7 - .../mui/icons-material/esm/VolumeMute.js | 7 - .../mui/icons-material/esm/VolumeOff.js | 7 - .../mui/icons-material/esm/VolumeUp.js | 7 - .../icons-material/esm/WarningAmberRounded.js | 7 - .../mui/material/esm/useMediaQuery/index.js | 6 - .../system/esm/useMediaQuery/useMediaQuery.js | 121 - .../next/dist/build/deployment-id.js | 18 - .../dist/build/polyfills/polyfill-module.js | 1 - .../next/dist/build/polyfills/process.js | 5 - .../next/dist/compiled/cookie/index.js | 7 - .../dist/compiled/path-to-regexp/index.js | 1 - .../next/dist/compiled/process/browser.js | 1 - .../node_modules/next/dist/lib/constants.js | 395 - .../next/dist/lib/is-api-route.js | 15 - vendor/node_modules/next/dist/lib/is-error.js | 72 - .../lib/require-instrumentation-client.js | 26 - .../next/dist/lib/route-pattern-normalizer.js | 104 - .../server/api-utils/get-cookie-parser.js | 22 - .../cjs/react-dom-client.production.js | 15393 ---------------- ...ct-dom-server-legacy.browser.production.js | 5892 ------ .../react-dom-server.browser.production.js | 6384 ------- .../react-dom/cjs/react-dom.production.js | 210 - vendor/node_modules/react-dom/client.js | 38 - vendor/node_modules/react-dom/index.js | 38 - .../node_modules/react-dom/server.browser.js | 18 - .../node_modules/react-smooth/es6/Animate.js | 354 - .../react-smooth/es6/AnimateManager.js | 60 - .../react-smooth/es6/configUpdate.js | 135 - .../node_modules/react-smooth/es6/easing.js | 178 - vendor/node_modules/react-smooth/es6/index.js | 5 - .../react-smooth/es6/setRafTimeout.js | 19 - vendor/node_modules/react-smooth/es6/util.js | 94 - .../react/cjs/react-jsx-runtime.production.js | 34 - .../react/cjs/react.production.js | 546 - vendor/node_modules/react/index.js | 7 - vendor/node_modules/react/jsx-runtime.js | 7 - .../recharts-scale/es6/getNiceTickValues.js | 306 - .../node_modules/recharts-scale/es6/index.js | 1 - .../recharts-scale/es6/util/arithmetic.js | 103 - .../recharts-scale/es6/util/utils.js | 132 - .../recharts/es6/cartesian/Area.js | 543 - .../recharts/es6/cartesian/Bar.js | 450 - .../recharts/es6/cartesian/Brush.js | 621 - .../recharts/es6/cartesian/CartesianAxis.js | 358 - .../recharts/es6/cartesian/CartesianGrid.js | 369 - .../recharts/es6/cartesian/ErrorBar.js | 157 - .../recharts/es6/cartesian/Line.js | 511 - .../recharts/es6/cartesian/ReferenceArea.js | 130 - .../recharts/es6/cartesian/ReferenceDot.js | 128 - .../recharts/es6/cartesian/ReferenceLine.js | 195 - .../recharts/es6/cartesian/Scatter.js | 420 - .../recharts/es6/cartesian/XAxis.js | 86 - .../recharts/es6/cartesian/YAxis.js | 83 - .../recharts/es6/cartesian/ZAxis.js | 39 - .../es6/cartesian/getEquidistantTicks.js | 55 - .../recharts/es6/cartesian/getTicks.js | 155 - .../es6/chart/AccessibilityManager.js | 110 - .../recharts/es6/chart/AreaChart.js | 20 - .../recharts/es6/chart/ComposedChart.js | 27 - .../recharts/es6/chart/LineChart.js | 20 - .../recharts/es6/chart/PieChart.js | 32 - .../es6/chart/generateCategoricalChart.js | 2099 --- .../recharts/es6/component/Cell.js | 8 - .../recharts/es6/component/Cursor.js | 80 - .../es6/component/DefaultLegendContent.js | 185 - .../es6/component/DefaultTooltipContent.js | 128 - .../recharts/es6/component/Label.js | 469 - .../recharts/es6/component/LabelList.js | 109 - .../recharts/es6/component/Legend.js | 202 - .../es6/component/ResponsiveContainer.js | 159 - .../recharts/es6/component/Text.js | 250 - .../recharts/es6/component/Tooltip.js | 126 - .../es6/component/TooltipBoundingBox.js | 156 - .../recharts/es6/container/Layer.js | 18 - .../recharts/es6/container/Surface.js | 35 - .../es6/context/chartLayoutContext.js | 162 - vendor/node_modules/recharts/es6/polar/Pie.js | 553 - .../recharts/es6/polar/PolarAngleAxis.js | 204 - .../recharts/es6/polar/PolarRadiusAxis.js | 209 - .../node_modules/recharts/es6/shape/Cross.js | 51 - .../node_modules/recharts/es6/shape/Curve.js | 116 - vendor/node_modules/recharts/es6/shape/Dot.js | 24 - .../recharts/es6/shape/Polygon.js | 90 - .../recharts/es6/shape/Rectangle.js | 168 - .../node_modules/recharts/es6/shape/Sector.js | 213 - .../recharts/es6/shape/Symbols.js | 96 - .../recharts/es6/shape/Trapezoid.js | 120 - .../recharts/es6/util/ActiveShapeUtils.js | 199 - .../recharts/es6/util/BarUtils.js | 68 - .../recharts/es6/util/CartesianUtils.js | 282 - .../recharts/es6/util/ChartUtils.js | 1061 -- .../recharts/es6/util/CssPrefixUtils.js | 20 - .../recharts/es6/util/DOMUtils.js | 112 - .../recharts/es6/util/DataUtils.js | 170 - .../es6/util/DetectReferenceElementsDomain.js | 51 - .../node_modules/recharts/es6/util/Events.js | 4 - .../node_modules/recharts/es6/util/Global.js | 21 - .../recharts/es6/util/IfOverflowMatches.js | 8 - .../recharts/es6/util/LogUtils.js | 22 - .../recharts/es6/util/PolarUtils.js | 208 - .../recharts/es6/util/ReactUtils.js | 301 - .../recharts/es6/util/ReduceCSSCalc.js | 173 - .../recharts/es6/util/ScatterUtils.js | 26 - .../recharts/es6/util/ShallowEqual.js | 14 - .../recharts/es6/util/TickUtils.js | 38 - .../recharts/es6/util/calculateViewBox.js | 18 - .../es6/util/cursor/getCursorPoints.js | 39 - .../es6/util/cursor/getCursorRectangle.js | 11 - .../es6/util/cursor/getRadialCursorPoints.js | 23 - .../es6/util/getEveryNthWithCondition.js | 26 - .../recharts/es6/util/getLegendProps.js | 62 - .../es6/util/isDomainSpecifiedByUser.js | 23 - .../es6/util/payload/getUniqPayload.js | 20 - .../recharts/es6/util/tooltip/translate.js | 107 - .../node_modules/recharts/es6/util/types.js | 126 - .../scheduler/cjs/scheduler.production.js | 340 - vendor/node_modules/scheduler/index.js | 7 - .../swc/helpers/esm/_define_property.js | 8 - .../helpers/esm/_interop_require_default.js | 4 - .../helpers/esm/_interop_require_wildcard.js | 36 - .../swc/helpers/esm/_object_spread.js | 23 - .../swc/helpers/esm/_object_spread_props.js | 28 - .../helpers/esm/_object_without_properties.js | 21 - .../esm/_object_without_properties_loose.js | 16 - .../tiny-invariant/dist/esm/tiny-invariant.js | 15 - .../victory-vendor/es/d3-scale.js | 6 - 3463 files changed, 184648 insertions(+), 64341 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md rename {client => anyclip/client}/add-base-path.ts (100%) rename {client => anyclip/client}/add-locale.ts (100%) rename {client => anyclip/client}/detect-domain-locale.ts (100%) create mode 100644 anyclip/client/get-domain-locale.ts rename {client => anyclip/client}/has-base-path.ts (100%) rename {client => anyclip/client}/head-manager.ts (100%) create mode 100644 anyclip/client/image-component.tsx rename {client => anyclip/client}/index.tsx (100%) create mode 100644 anyclip/client/link.tsx rename {client => anyclip/client}/next.ts (100%) rename {client => anyclip/client}/normalize-trailing-slash.ts (100%) rename {client => anyclip/client}/page-loader.ts (100%) rename {client => anyclip/client}/remove-base-path.ts (100%) rename {client => anyclip/client}/remove-locale.ts (100%) rename {client => anyclip/client}/request-idle-callback.ts (100%) rename {client => anyclip/client}/resolve-href.ts (100%) rename {client => anyclip/client}/route-announcer.tsx (100%) rename {client => anyclip/client}/route-loader.ts (100%) rename {client => anyclip/client}/router.ts (100%) rename {client => anyclip/client}/script.tsx (100%) rename {client => anyclip/client}/set-attributes-from-props.ts (100%) rename {client => anyclip/client}/trusted-types.ts (100%) create mode 100644 anyclip/client/use-intersection.tsx create mode 100644 anyclip/client/use-merged-ref.ts rename {client => anyclip/client}/webpack.ts (100%) rename {client => anyclip/client}/with-router.tsx (100%) rename {pages => anyclip/pages}/_app.tsx (100%) rename {pages => anyclip/pages}/_error.tsx (100%) rename {src => anyclip/src}/assets/img/empty.svg (100%) create mode 100644 anyclip/src/assets/img/favicon/android-chrome-192x192-1621329404738.png create mode 100644 anyclip/src/assets/img/favicon/android-chrome-512x512-1621329404738.png create mode 100644 anyclip/src/assets/img/favicon/apple-touch-icon-1621329404738.png create mode 100644 anyclip/src/assets/img/favicon/favicon-16x16-1621329404738.png create mode 100644 anyclip/src/assets/img/favicon/favicon-32x32-1621329404738.png create mode 100644 anyclip/src/assets/img/form-banner/blue.png create mode 100644 anyclip/src/assets/img/form-banner/coffee.png create mode 100644 anyclip/src/assets/img/form-banner/desk.png create mode 100644 anyclip/src/assets/img/form-banner/flower.png create mode 100644 anyclip/src/assets/img/form-banner/green.png create mode 100644 anyclip/src/assets/img/form-banner/notebook.png create mode 100644 anyclip/src/assets/img/form-banner/pink.png create mode 100644 anyclip/src/assets/img/form-banner/textureblue.png rename {src => anyclip/src}/assets/img/logo-symbol.png (100%) rename {src => anyclip/src}/assets/img/logo-text.png (100%) create mode 100644 anyclip/src/assets/img/logo.png rename {src => anyclip/src}/assets/img/no-image-portrait.svg (100%) create mode 100644 anyclip/src/assets/img/source-icons/instagram.svg create mode 100644 anyclip/src/assets/img/source-icons/mrss.svg create mode 100644 anyclip/src/assets/img/source-icons/ms_stream.svg create mode 100644 anyclip/src/assets/img/source-icons/sharepoint.svg create mode 100644 anyclip/src/assets/img/source-icons/teams.svg create mode 100644 anyclip/src/assets/img/source-icons/tiktok.svg create mode 100644 anyclip/src/assets/img/source-icons/zoom.svg create mode 100644 anyclip/src/assets/img/upload/default-img-audio-thumbnail.jpg create mode 100644 anyclip/src/client/components/forbidden.ts rename {src => anyclip/src}/client/components/http-access-fallback/http-access-fallback.ts (100%) rename {src => anyclip/src}/client/components/is-next-router-error.ts (100%) create mode 100644 anyclip/src/client/components/navigation.react-server.ts create mode 100644 anyclip/src/client/components/navigation.ts create mode 100644 anyclip/src/client/components/not-found.ts rename {src => anyclip/src}/client/components/redirect-error.ts (100%) rename {src => anyclip/src}/client/components/redirect-status-code.ts (100%) create mode 100644 anyclip/src/client/components/redirect.ts create mode 100644 anyclip/src/client/components/router-reducer/reducers/get-segment-value.ts create mode 100644 anyclip/src/client/components/unauthorized.ts create mode 100644 anyclip/src/client/components/unrecognized-action-error.ts create mode 100644 anyclip/src/client/components/unstable-rethrow.browser.ts create mode 100644 anyclip/src/client/components/unstable-rethrow.ts rename {src => anyclip/src}/client/portal/index.tsx (100%) rename {src => anyclip/src}/client/react-client-callbacks/on-recoverable-error.ts (100%) rename {src => anyclip/src}/client/react-client-callbacks/report-global-error.ts (100%) rename {src => anyclip/src}/client/tracing/tracer.ts (100%) create mode 100644 anyclip/src/graphql/services/_helpers/common.js create mode 100644 anyclip/src/graphql/services/accounts/constants/index.js create mode 100644 anyclip/src/graphql/services/accounts/types/payload/account.js create mode 100644 anyclip/src/graphql/services/accounts/types/payload/accounts.js create mode 100644 anyclip/src/graphql/services/accounts/types/payload/contentOwner.js create mode 100644 anyclip/src/graphql/services/accounts/types/payload/contentOwners.js create mode 100644 anyclip/src/graphql/services/accounts/types/payload/deleteAllVideos.js create mode 100644 anyclip/src/graphql/services/accounts/types/payload/item.js create mode 100644 anyclip/src/graphql/services/accounts/types/payload/salesForce.js create mode 100644 anyclip/src/graphql/services/adsServers/constants/index.js create mode 100644 anyclip/src/graphql/services/adsServers/types/input/itemCreate.js create mode 100644 anyclip/src/graphql/services/adsServers/types/payload/bulkChangeStatusAction.js create mode 100644 anyclip/src/graphql/services/advertisers/constants/index.js create mode 100644 anyclip/src/graphql/services/advertisers/types/payload/advertiser.js create mode 100644 anyclip/src/graphql/services/advertisers/types/payload/advertisers.js create mode 100644 anyclip/src/graphql/services/advertisers/types/payload/item.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/constants/tagLog.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/constants/thumbnail.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/data.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/tagInfo.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/upserTag.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/generateThubnailOptions.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/publishThumbnail.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailDarft.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailFromVideoFrame.js create mode 100644 anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/thumbnail.js create mode 100644 anyclip/src/graphql/services/analyticsRevenueOverview/constants/index.ts create mode 100644 anyclip/src/graphql/services/analyticsRevenueOverview/types/payload/list.ts create mode 100644 anyclip/src/graphql/services/configuration/resolvers/iab.js create mode 100644 anyclip/src/graphql/services/contentOwners/constants/index.js create mode 100644 anyclip/src/graphql/services/contentOwners/types/payload/accounts.js create mode 100644 anyclip/src/graphql/services/contentOwners/types/payload/bulkActionDisableOrActive.js create mode 100644 anyclip/src/graphql/services/contentOwners/types/payload/contentOwners.js create mode 100644 anyclip/src/graphql/services/contentOwners/types/payload/item.js create mode 100644 anyclip/src/graphql/services/customReports/constants/index.js create mode 100644 anyclip/src/graphql/services/customReports/types/payload/accounts.js create mode 100644 anyclip/src/graphql/services/customReports/types/payload/item.js create mode 100644 anyclip/src/graphql/services/customReports/types/payload/list.js create mode 100644 anyclip/src/graphql/services/feeds/constants/index.ts create mode 100644 anyclip/src/graphql/services/feeds/types/payload/accounts.ts create mode 100644 anyclip/src/graphql/services/feeds/types/payload/feedItem.ts create mode 100644 anyclip/src/graphql/services/feeds/types/payload/hubs.ts create mode 100644 anyclip/src/graphql/services/feeds/types/payload/oAuthClient.ts create mode 100644 anyclip/src/graphql/services/feeds/types/payload/owners.ts create mode 100644 anyclip/src/graphql/services/feeds/types/payload/selfserve/importArchived.ts create mode 100644 anyclip/src/graphql/services/feeds/types/payload/selfserve/list.ts create mode 100644 anyclip/src/graphql/services/hubs/types/input/itemCreate.js create mode 100644 anyclip/src/graphql/services/invitations/constants/index.js create mode 100644 anyclip/src/graphql/services/invitations/types/payload/account.js create mode 100644 anyclip/src/graphql/services/invitations/types/payload/item.js create mode 100644 anyclip/src/graphql/services/invitations/types/payload/list.js create mode 100644 anyclip/src/graphql/services/notifications/constants/index.js create mode 100644 anyclip/src/graphql/services/notifications/types/payload/item.js create mode 100644 anyclip/src/graphql/services/onlineHelp/constants/index.js create mode 100644 anyclip/src/graphql/services/onlineHelp/types/payload/configuration.js create mode 100644 anyclip/src/graphql/services/onlineHelp/types/payload/configurations.js create mode 100644 anyclip/src/graphql/services/onlineHelp/types/payload/item.js create mode 100644 anyclip/src/graphql/services/permissions/constatnts/index.js create mode 100644 anyclip/src/graphql/services/rolesPermissions/constants/index.js create mode 100644 anyclip/src/graphql/services/rolesPermissions/types/payload/account.js create mode 100644 anyclip/src/graphql/services/rolesPermissions/types/payload/permissionMetadata.js create mode 100644 anyclip/src/graphql/services/rolesPermissions/types/payload/roleItem.js create mode 100644 anyclip/src/graphql/services/rolesPermissions/types/payload/roleList.js create mode 100644 anyclip/src/graphql/services/rolesPermissions/types/payload/roleModuleMetadata.js create mode 100644 anyclip/src/graphql/services/sso/constants/index.js create mode 100644 anyclip/src/graphql/services/sso/types/payload/hub.js create mode 100644 anyclip/src/graphql/services/sso/types/payload/ssoGetItem.js create mode 100644 anyclip/src/graphql/services/sso/types/payload/ssoList.js create mode 100644 anyclip/src/graphql/services/sso/types/payload/ssoUpsertItem.js create mode 100644 anyclip/src/graphql/services/sso/types/payload/status.js create mode 100644 anyclip/src/graphql/services/users/constants/index.js create mode 100644 anyclip/src/graphql/services/users/types/payload/accounts.js create mode 100644 anyclip/src/graphql/services/users/types/payload/apiToken.js create mode 100644 anyclip/src/graphql/services/users/types/payload/bulkActions.js create mode 100644 anyclip/src/graphql/services/users/types/payload/contentOwners.js create mode 100644 anyclip/src/graphql/services/users/types/payload/department.js create mode 100644 anyclip/src/graphql/services/users/types/payload/departmentList.js create mode 100644 anyclip/src/graphql/services/users/types/payload/item.js create mode 100644 anyclip/src/graphql/services/users/types/payload/itemDetails.js create mode 100644 anyclip/src/graphql/services/users/types/payload/list.js create mode 100644 anyclip/src/graphql/services/users/types/payload/resetPassword.js create mode 100644 anyclip/src/graphql/services/videoBulkActions/constants/index.js create mode 100644 anyclip/src/graphql/services/videoBulkActions/types/payload/hub.js create mode 100644 anyclip/src/graphql/services/videoBulkActions/types/payload/user.js create mode 100644 anyclip/src/graphql/services/xRayCampaings/constants/index.js create mode 100644 anyclip/src/graphql/services/xRayCampaings/types/payload/advertiser.js create mode 100644 anyclip/src/graphql/services/xRayCampaings/types/payload/advertiserItem.js create mode 100644 anyclip/src/graphql/services/xRayCampaings/types/payload/archive.js create mode 100644 anyclip/src/graphql/services/xRayCampaings/types/payload/hub.js create mode 100644 anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaignItem.js create mode 100644 anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaigns.js create mode 100644 anyclip/src/graphql/services/xRayCreatives/constants/index.js create mode 100644 anyclip/src/graphql/services/xRayCreatives/types/payload/archive.js create mode 100644 anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreativeItem.js create mode 100644 anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreatives.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/constants/index.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/archive.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/brandSafety.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/campain.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/creative.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/domain.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/hub.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/label.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/player.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/taxonomy.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/video.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/watch.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemList.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemUpsert.js create mode 100644 anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemsList.js create mode 100644 anyclip/src/modules/@common/ActionAutocomplete/ActionAutocomplete.module.scss create mode 100644 anyclip/src/modules/@common/ActionAutocomplete/index.jsx rename {src/modules/editorial/editorialSearchFilter/filterSuggester/component => anyclip/src/modules/@common}/ActionIAB/index.jsx (100%) create mode 100644 anyclip/src/modules/@common/EmbedCodePopup/EmbedCodePopup.module.scss rename {src/modules/common => anyclip/src/modules/@common}/EmbedCodePopup/constants/index.ts (100%) create mode 100644 anyclip/src/modules/@common/EmbedCodePopup/index.tsx create mode 100644 anyclip/src/modules/@common/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/@common/Empty/Empty.tsx rename {src/modules/common => anyclip/src/modules/@common}/Form/Form/Form.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/Form/Form.tsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormContent/FormContent.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormContent/FormContent.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormGroup/FormGroup.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormGroup/FormGroup.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormGroupTitle/FormGroupTitle.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormGroupTitle/FormGroupTitle.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormImageUploader/FormImageUploader.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormImageUploader/FormImageUploader.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormRow/FormRow.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormRow/components/Label/Label.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormRow/components/Label/Label.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormRow/components/Value/Value.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormRow/components/Value/Value.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormRowItem/FormRowItem.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormRowItem/FormRowItem.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormSection/FormSection.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Form/FormSection/FormSection.module.scss (100%) create mode 100644 anyclip/src/modules/@common/Form/constants/index.js rename {src/modules/common => anyclip/src/modules/@common}/Form/helpers/hooks.js (100%) create mode 100644 anyclip/src/modules/@common/Form/helpers/index.js rename {src/modules/common => anyclip/src/modules/@common}/Form/index.js (100%) create mode 100644 anyclip/src/modules/@common/Form/redux/selectors/index.js create mode 100644 anyclip/src/modules/@common/Form/redux/slices/index.js rename {src/modules/common => anyclip/src/modules/@common}/List/List.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/List/index.tsx (100%) create mode 100644 anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.module.scss create mode 100644 anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.tsx create mode 100644 anyclip/src/modules/@common/PlayerWidget/helpers/index.js rename {src/modules/common => anyclip/src/modules/@common}/Table/components/TableCellActions/TableCellActions.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/Table/components/TableCellActions/TableCellActions.module.scss (100%) create mode 100644 anyclip/src/modules/@common/Table/hooks/useLocalPagination.js create mode 100644 anyclip/src/modules/@common/Table/index.jsx create mode 100644 anyclip/src/modules/@common/Table/redux/epics/index.js create mode 100644 anyclip/src/modules/@common/Table/redux/selectors/index.js create mode 100644 anyclip/src/modules/@common/Table/redux/slices/index.js rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/TagIabSelector/TagIabSelector.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/TagIabSelector/TagIabSelector.tsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/TagSelector/TagSelector.tsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/components/StateSelect/StateSelect.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/components/StateSelect/StateSelect.tsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/components/TagList/TagList.module.scss (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/components/TagList/TagList.tsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/constants/index.ts (100%) rename {src/modules/common => anyclip/src/modules/@common}/TagSelector/index.ts (100%) create mode 100644 anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.module.scss create mode 100644 anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.tsx create mode 100644 anyclip/src/modules/@common/acl/constants/index.ts create mode 100644 anyclip/src/modules/@common/app/ErrorBoundary.tsx create mode 100644 anyclip/src/modules/@common/app/GoogleAnalyticsProvider.tsx create mode 100644 anyclip/src/modules/@common/app/IntercomProvider.jsx create mode 100644 anyclip/src/modules/@common/app/IntercomProvider.module.scss create mode 100644 anyclip/src/modules/@common/app/RecordingProvider.jsx create mode 100644 anyclip/src/modules/@common/app/SettingsProvider.jsx create mode 100644 anyclip/src/modules/@common/app/components/error.jsx create mode 100644 anyclip/src/modules/@common/app/components/notFound.jsx create mode 100644 anyclip/src/modules/@common/app/components/notPermitted.jsx create mode 100644 anyclip/src/modules/@common/app/components/page.jsx create mode 100644 anyclip/src/modules/@common/app/components/page.module.scss create mode 100644 anyclip/src/modules/@common/app/constants/index.js create mode 100644 anyclip/src/modules/@common/app/helpers/index.js create mode 100644 anyclip/src/modules/@common/ccFiles/constants/index.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/addCcFile.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/checkCcFileState.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/deleteCcFile.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/getCcFiles.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/getCCTotalSegments.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/getLanguages.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/getTranslateLanguages.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/getUploadUrl.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/index.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/setAutoTranslate.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/epics/upload.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/selectors/index.js create mode 100644 anyclip/src/modules/@common/ccFiles/redux/slices/index.js create mode 100644 anyclip/src/modules/@common/components/PCNProxy/PCNProxy.module.scss create mode 100644 anyclip/src/modules/@common/components/PCNProxy/PCNProxy.tsx rename {src/modules/common => anyclip/src/modules/@common}/components/ReactList/ReactList.jsx (100%) rename {src/modules/common => anyclip/src/modules/@common}/components/ReactList/ReactList.module.scss (100%) create mode 100644 anyclip/src/modules/@common/constants/account.ts create mode 100644 anyclip/src/modules/@common/constants/aspectRatios.ts create mode 100644 anyclip/src/modules/@common/constants/db.ts create mode 100644 anyclip/src/modules/@common/constants/embedTypes.ts create mode 100644 anyclip/src/modules/@common/constants/errors.ts create mode 100644 anyclip/src/modules/@common/constants/file.ts create mode 100644 anyclip/src/modules/@common/constants/index.ts rename {src/modules/common => anyclip/src/modules/@common}/constants/keyCodes.ts (100%) create mode 100644 anyclip/src/modules/@common/constants/mapApiError.ts create mode 100644 anyclip/src/modules/@common/constants/playerTypes.ts create mode 100644 anyclip/src/modules/@common/constants/sort.ts create mode 100644 anyclip/src/modules/@common/constants/validation.ts create mode 100644 anyclip/src/modules/@common/dnd/SortableItem/SortableItem.tsx create mode 100644 anyclip/src/modules/@common/envs/constants/index.ts create mode 100644 anyclip/src/modules/@common/gql/queries/allPublishers.js create mode 100644 anyclip/src/modules/@common/gql/queries/init.js create mode 100644 anyclip/src/modules/@common/gql/queries/userInit.js create mode 100644 anyclip/src/modules/@common/gql/queries/videoById.js create mode 100644 anyclip/src/modules/@common/gql/queries/videoUpdate.js create mode 100644 anyclip/src/modules/@common/gql/queries/videos.js create mode 100644 anyclip/src/modules/@common/gql/redux/epics/index.js create mode 100644 anyclip/src/modules/@common/gql/redux/epics/init.js create mode 100644 anyclip/src/modules/@common/gql/redux/selectors/index.js create mode 100644 anyclip/src/modules/@common/gql/redux/slices/index.js create mode 100644 anyclip/src/modules/@common/helpers/copy.ts create mode 100644 anyclip/src/modules/@common/helpers/events.ts create mode 100644 anyclip/src/modules/@common/helpers/featureFlags.ts create mode 100644 anyclip/src/modules/@common/helpers/file-saver.ts create mode 100644 anyclip/src/modules/@common/helpers/format.ts rename {src/modules/common => anyclip/src/modules/@common}/helpers/hooks/useTitle.ts (100%) create mode 100644 anyclip/src/modules/@common/helpers/index.ts create mode 100644 anyclip/src/modules/@common/helpers/number.ts create mode 100644 anyclip/src/modules/@common/helpers/string.ts create mode 100644 anyclip/src/modules/@common/helpers/time.ts create mode 100644 anyclip/src/modules/@common/helpers/videoLangs.ts create mode 100644 anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.module.scss create mode 100644 anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.tsx create mode 100644 anyclip/src/modules/@common/iab/helpers/index.ts create mode 100644 anyclip/src/modules/@common/init/redux/epics/error.js create mode 100644 anyclip/src/modules/@common/init/redux/epics/index.js create mode 100644 anyclip/src/modules/@common/init/redux/epics/request.js create mode 100644 anyclip/src/modules/@common/init/redux/epics/response.js create mode 100644 anyclip/src/modules/@common/init/redux/slices/index.js create mode 100644 anyclip/src/modules/@common/location/redux/epics/addQueries.js create mode 100644 anyclip/src/modules/@common/location/redux/epics/index.js create mode 100644 anyclip/src/modules/@common/location/redux/epics/removeQueries.js create mode 100644 anyclip/src/modules/@common/location/redux/helpers/queue.js create mode 100644 anyclip/src/modules/@common/location/redux/slices/index.js create mode 100644 anyclip/src/modules/@common/monitoring/helpers/monitoring.js create mode 100644 anyclip/src/modules/@common/monitoring/redux/epics/index.js create mode 100644 anyclip/src/modules/@common/monitoring/redux/epics/monitoring.js create mode 100644 anyclip/src/modules/@common/monitoring/redux/epics/monitoringEnd.js create mode 100644 anyclip/src/modules/@common/monitoring/redux/epics/monitoringRepeat.js create mode 100644 anyclip/src/modules/@common/monitoring/redux/epics/monitoringRun.js create mode 100644 anyclip/src/modules/@common/monitoring/redux/selectors/index.js create mode 100644 anyclip/src/modules/@common/monitoring/redux/slices/index.js create mode 100644 anyclip/src/modules/@common/notify/constants/index.js create mode 100644 anyclip/src/modules/@common/notify/redux/selectors/index.js create mode 100644 anyclip/src/modules/@common/notify/redux/slices/index.ts create mode 100644 anyclip/src/modules/@common/request/constants/index.js create mode 100644 anyclip/src/modules/@common/request/index.js create mode 100644 anyclip/src/modules/@common/request/redux/slices/index.js create mode 100644 anyclip/src/modules/@common/router/constants/index.ts rename {src/modules/common => anyclip/src/modules/@common}/router/constants/mapping.ts (100%) create mode 100644 anyclip/src/modules/@common/router/helpers/getRoutesAllowed.ts create mode 100644 anyclip/src/modules/@common/storage/constants/index.ts create mode 100644 anyclip/src/modules/@common/storage/helpers/index.ts create mode 100644 anyclip/src/modules/@common/storage/helpers/persistPermanentStorageObjects.ts create mode 100644 anyclip/src/modules/@common/store/helpers.ts create mode 100644 anyclip/src/modules/@common/store/hooks.ts create mode 100644 anyclip/src/modules/@common/store/index.ts create mode 100644 anyclip/src/modules/@common/store/store.ts create mode 100644 anyclip/src/modules/@common/token/constants/index.ts create mode 100644 anyclip/src/modules/@common/token/helpers/index.ts create mode 100644 anyclip/src/modules/@common/token/redux/epics/cancelImpersonation.ts create mode 100644 anyclip/src/modules/@common/token/redux/epics/index.ts create mode 100644 anyclip/src/modules/@common/token/redux/epics/logOut.ts create mode 100644 anyclip/src/modules/@common/token/redux/epics/loggedIn.ts create mode 100644 anyclip/src/modules/@common/token/redux/slices/index.ts create mode 100644 anyclip/src/modules/@common/user/constants/index.ts create mode 100644 anyclip/src/modules/@common/user/constants/roles.ts create mode 100644 anyclip/src/modules/@common/user/constants/rolesType.ts create mode 100644 anyclip/src/modules/@common/user/helpers/index.ts create mode 100644 anyclip/src/modules/@common/user/helpers/initUser.ts create mode 100644 anyclip/src/modules/@common/user/redux/epics/getUserData.ts create mode 100644 anyclip/src/modules/@common/user/redux/epics/index.ts create mode 100644 anyclip/src/modules/@common/user/redux/selectors/index.ts create mode 100644 anyclip/src/modules/@common/user/redux/slices/index.ts create mode 100644 anyclip/src/modules/@common/watch/constants/index.ts create mode 100644 anyclip/src/modules/@common/watch/helpers/index.ts create mode 100644 anyclip/src/modules/accounts/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/accounts/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.jsx create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.module.scss create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.jsx create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.module.scss create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.jsx create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.module.scss create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.jsx create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.module.scss create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.jsx create mode 100644 anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.module.scss create mode 100644 anyclip/src/modules/accounts/Editor/constants/index.js create mode 100644 anyclip/src/modules/accounts/Editor/helpers/buildRequestBody.js create mode 100644 anyclip/src/modules/accounts/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/deleteAllVideos.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/getContentOwners.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/getSalesforceData.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/updateContentOwner.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/accounts/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/accounts/List/components/List.jsx create mode 100644 anyclip/src/modules/accounts/List/components/List.module.scss create mode 100644 anyclip/src/modules/accounts/List/constants/index.js create mode 100644 anyclip/src/modules/accounts/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/accounts/List/redux/epics/index.js create mode 100644 anyclip/src/modules/accounts/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/accounts/List/redux/slices/index.js create mode 100644 anyclip/src/modules/adServers/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/adServers/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/adServers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/adServers/Editor/constants/index.js create mode 100644 anyclip/src/modules/adServers/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/adServers/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/adServers/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/adServers/Editor/redux/epics/getPlayerTypesOptions.js create mode 100644 anyclip/src/modules/adServers/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/adServers/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/adServers/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/adServers/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/adServers/List/components/List.jsx create mode 100644 anyclip/src/modules/adServers/List/components/List.module.scss create mode 100644 anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.jsx create mode 100644 anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.module.scss create mode 100644 anyclip/src/modules/adServers/List/constants/index.js create mode 100644 anyclip/src/modules/adServers/List/redux/epics/bulkChangeStatusAction.js create mode 100644 anyclip/src/modules/adServers/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/adServers/List/redux/epics/index.js create mode 100644 anyclip/src/modules/adServers/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/adServers/List/redux/slices/index.js create mode 100644 anyclip/src/modules/advertisers/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/advertisers/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/advertisers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/advertisers/Editor/constants/index.js create mode 100644 anyclip/src/modules/advertisers/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/advertisers/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/advertisers/Editor/redux/epics/getAccountOptions.js create mode 100644 anyclip/src/modules/advertisers/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/advertisers/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/advertisers/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/advertisers/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/advertisers/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/advertisers/List/components/Empty/Empty.jsx create mode 100644 anyclip/src/modules/advertisers/List/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/advertisers/List/components/List.jsx create mode 100644 anyclip/src/modules/advertisers/List/components/List.module.scss create mode 100644 anyclip/src/modules/advertisers/List/constants/index.js create mode 100644 anyclip/src/modules/advertisers/List/helpers/computedState.js create mode 100644 anyclip/src/modules/advertisers/List/helpers/index.js create mode 100644 anyclip/src/modules/advertisers/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/advertisers/List/redux/epics/index.js create mode 100644 anyclip/src/modules/advertisers/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/advertisers/List/redux/slices/index.js rename {src => anyclip/src}/modules/analytics/common/components/AreaGraph/CustomTooltip.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/AreaGraph/CustomTooltip.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/AreaGraph/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/DialogCalendarRange/DialogCalendarRange.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/DialogCalendarRange/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/GlobalStateEmpty/GlobalStateEmpty.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/GlobalStateEmpty/img/img.png (100%) rename {src => anyclip/src}/modules/analytics/common/components/GlobalStateEmpty/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/GlobalStateError/GlobalStateEmpty.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/GlobalStateError/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/Header/Header.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/Header/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/Layout/Layout.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/Layout/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/RoundItemContainer/RoundItemContainer.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/RoundItemContainer/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/Stub/Stub.module.scss (100%) rename {src => anyclip/src}/modules/analytics/common/components/Stub/img/imgError.png (100%) rename {src => anyclip/src}/modules/analytics/common/components/Stub/img/imgNoData.png (100%) rename {src => anyclip/src}/modules/analytics/common/components/Stub/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/common/components/index.js (100%) rename {src => anyclip/src}/modules/analytics/common/components/muiCustomComponents/ConfirmDialog/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/common/constants/index.js create mode 100644 anyclip/src/modules/analytics/common/helpers/index.js rename {src => anyclip/src}/modules/analytics/customReports/components/CustomReports.module.scss (100%) create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/ReportSetup.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/CancelDialog/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/DimensionItem.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/EmptyDialog/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/Filters.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/FilterDialog.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/Filter.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/MetricItem.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/Name.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/Preview.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/ScheduleDialog.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/SideBar.module.scss create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/index.jsx create mode 100644 anyclip/src/modules/analytics/customReports/components/ReportSetup/index.jsx rename {src => anyclip/src}/modules/analytics/customReports/components/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/customReports/constants/index.js create mode 100644 anyclip/src/modules/analytics/customReports/helpers/createCustomReportRequestBody.js create mode 100644 anyclip/src/modules/analytics/customReports/helpers/getStateFromCustomReportRequestBody.js create mode 100644 anyclip/src/modules/analytics/customReports/helpers/index.js rename {src => anyclip/src}/modules/analytics/customReports/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/checkDownloadReport.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/createCustomReport.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/deleteCustomReport.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/getCountries.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/getCustomReportById.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/getCustomReports.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/getDemandSources.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/getUserDomains.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/getUserPlayers.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/index.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/runDownloadReport.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/epics/updateCustomReport.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/selectors/index.js create mode 100644 anyclip/src/modules/analytics/customReports/redux/slices/index.js rename {src => anyclip/src}/modules/analytics/general/components/Failure/Failure.module.scss (100%) rename {src => anyclip/src}/modules/analytics/general/components/Failure/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/general/components/General.module.scss (100%) rename {src => anyclip/src}/modules/analytics/general/components/InfoNeedSelectAccount/InfoNeedSelectAccount.module.scss (100%) rename {src => anyclip/src}/modules/analytics/general/components/InfoNeedSelectAccount/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/general/components/Loader/Loader.module.scss (100%) rename {src => anyclip/src}/modules/analytics/general/components/Loader/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/general/components/Menu/Menu.module.scss (100%) rename {src => anyclip/src}/modules/analytics/general/components/Menu/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/general/components/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/general/constants/index.js create mode 100644 anyclip/src/modules/analytics/general/helpers/index.js rename {src => anyclip/src}/modules/analytics/general/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/general/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/analytics/general/redux/epics/getLookerUrl.js create mode 100644 anyclip/src/modules/analytics/general/redux/epics/getMenuItems.js create mode 100644 anyclip/src/modules/analytics/general/redux/epics/getStatus.js create mode 100644 anyclip/src/modules/analytics/general/redux/epics/index.js create mode 100644 anyclip/src/modules/analytics/general/redux/selectors/index.js create mode 100644 anyclip/src/modules/analytics/general/redux/slices/index.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/Chart.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomActiveDot.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomCursor.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomDot.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Chart/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Countries/Countries.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Countries/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Devices/Devices.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Devices/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Filters/Filters.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Filters/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/LiveDashboard.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Tabs/Tabs.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Tabs/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Timezone/Timezone.module.scss create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/Timezone/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/components/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/constants/index.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/helpers/exportCSV.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/helpers/index.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/index.jsx create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToCSV.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToPDF.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/getChartData.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/getCountriesFullList.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEventById.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEvents.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/getLivePerformanceTotals.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/epics/index.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/selectors/index.js create mode 100644 anyclip/src/modules/analytics/liveDashboard/redux/slices/index.js rename {src => anyclip/src}/modules/analytics/monetization/components/Card/Card.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Card/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Chart/Chart.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Chart/CustomTick.jsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Chart/CustomTick.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Chart/CustomTooltip.jsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Chart/CustomTooltip.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Chart/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Countries/Countries.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Countries/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Device/Device.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Device/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Filters/Filters.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Filters/components/SpecialPopper.tsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Filters/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/Monetization.module.scss (100%) rename {src => anyclip/src}/modules/analytics/monetization/components/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/monetization/constants/index.js create mode 100644 anyclip/src/modules/analytics/monetization/helpers/exportCSV.js rename {src => anyclip/src}/modules/analytics/monetization/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/exportToCSV.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/exportToPDF.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/getChartData.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/getCountries.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/getCountriesFullList.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/getDemandSources.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/getDomains.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/getPlayers.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/getTotals.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/epics/index.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/selectors/index.js create mode 100644 anyclip/src/modules/analytics/monetization/redux/slices/index.js create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.tsx create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/components/List.module.scss create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/components/List.tsx create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/constants/index.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/helpers/computedState.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/helpers/index.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/exportToCsv.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountries.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountriesFullList.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getData.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDemandSources.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDomains.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getPlayers.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/epics/index.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/selectors/index.ts create mode 100644 anyclip/src/modules/analytics/revenueOverview/List/redux/slices/index.ts rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/TopSearches/TopSearches.module.scss (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/TopSearches/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoContentPerfomance.module.scss (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.jsx (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.module.scss (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoGraph/VideoGraph.module.scss (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoGraph/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoSearch/VideoSearch.module.scss (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoSearch/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/TextTooltip.module.scss (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoTable/VideoTable.module.scss (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/VideoTable/index.jsx (100%) rename {src => anyclip/src}/modules/analytics/videoContentPerformance/components/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/constants/index.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createMainMetrics.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopSearches.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopVideos.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/index.js rename {src => anyclip/src}/modules/analytics/videoContentPerformance/index.jsx (100%) create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToCsv.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToPdf.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getHubOptionsAutocomplete.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getItemMetrics.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTopSearches.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotal.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotalMetric.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getSearchOptionsAutocomplete.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getTableItems.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getWatchOptionsAutocomplete.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/epics/index.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/selectors/index.js create mode 100644 anyclip/src/modules/analytics/videoContentPerformance/redux/slices/index.js create mode 100644 anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.jsx create mode 100644 anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.module.scss create mode 100644 anyclip/src/modules/auth/CreateResetPassword/redux/epics/index.js create mode 100644 anyclip/src/modules/auth/CreateResetPassword/redux/epics/submitPassword.js create mode 100644 anyclip/src/modules/auth/CreateResetPassword/redux/epics/verifyCode.js create mode 100644 anyclip/src/modules/auth/CreateResetPassword/redux/selectors/index.js create mode 100644 anyclip/src/modules/auth/CreateResetPassword/redux/slices/index.js create mode 100644 anyclip/src/modules/auth/ForgotPassword/ForgotPassword.jsx create mode 100644 anyclip/src/modules/auth/GuestActivation/GuestActivation.jsx create mode 100644 anyclip/src/modules/auth/GuestActivation/redux/epics/index.js create mode 100644 anyclip/src/modules/auth/GuestActivation/redux/epics/registerUser.js create mode 100644 anyclip/src/modules/auth/GuestActivation/redux/epics/verifyToken.js create mode 100644 anyclip/src/modules/auth/GuestActivation/redux/selectors/index.js create mode 100644 anyclip/src/modules/auth/GuestActivation/redux/slices/index.js create mode 100644 anyclip/src/modules/auth/Login/Login.jsx create mode 100644 anyclip/src/modules/auth/Login/redux/epics/getCustomLoginPageByAccount.js create mode 100644 anyclip/src/modules/auth/Login/redux/epics/getSsoLink.js create mode 100644 anyclip/src/modules/auth/Login/redux/epics/getUserData.js create mode 100644 anyclip/src/modules/auth/Login/redux/epics/index.js create mode 100644 anyclip/src/modules/auth/Login/redux/epics/passwordLessAuth.js create mode 100644 anyclip/src/modules/auth/Login/redux/epics/passwordLessAuthCode.js create mode 100644 anyclip/src/modules/auth/Login/redux/epics/resetPassword.js create mode 100644 anyclip/src/modules/auth/Login/redux/epics/verifyCode.js create mode 100644 anyclip/src/modules/auth/Login/redux/selectors/index.js create mode 100644 anyclip/src/modules/auth/Login/redux/slices/index.js create mode 100644 anyclip/src/modules/auth/PasswordLessLogin/PasswordLessLogin.jsx create mode 100644 anyclip/src/modules/auth/PasswordLessLoginCode/PasswordLessLoginCode.jsx create mode 100644 anyclip/src/modules/auth/SsoLogin/components/index.jsx create mode 100644 anyclip/src/modules/auth/SsoLogin/components/useSsoLocalStorageEmails.jsx create mode 100644 anyclip/src/modules/auth/SsoLogin/constants/index.js create mode 100644 anyclip/src/modules/auth/SsoLogin/helpers/ssoEmailStorageManager.js create mode 100644 anyclip/src/modules/auth/SsoLogin/index.jsx create mode 100644 anyclip/src/modules/auth/UserAuthError/UserAuthError.jsx create mode 100644 anyclip/src/modules/auth/common/components/Layout/Layout.jsx create mode 100644 anyclip/src/modules/auth/common/components/Layout/Layout.module.scss create mode 100644 anyclip/src/modules/auth/common/constants/auth.js create mode 100644 anyclip/src/modules/auth/common/redux/selectors/index.js create mode 100644 anyclip/src/modules/auth/common/redux/slices/index.js create mode 100644 anyclip/src/modules/auth/common/useSetRedirectUrl.js create mode 100644 anyclip/src/modules/auth/common/useSsoBackgroundLogin.js create mode 100644 anyclip/src/modules/configuration/components/dictionary/buttonsFileLoader.jsx create mode 100644 anyclip/src/modules/configuration/components/dictionary/dictionary.jsx create mode 100644 anyclip/src/modules/configuration/components/dictionary/dictionary.module.scss create mode 100644 anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.jsx create mode 100644 anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.module.scss create mode 100644 anyclip/src/modules/configuration/components/index.jsx create mode 100644 anyclip/src/modules/configuration/components/index.module.scss create mode 100644 anyclip/src/modules/configuration/components/model/Models.jsx create mode 100644 anyclip/src/modules/configuration/components/model/Models.module.scss create mode 100644 anyclip/src/modules/configuration/helpers/index.js create mode 100644 anyclip/src/modules/configuration/index.js create mode 100644 anyclip/src/modules/configuration/redux/epics/addModelForm.js create mode 100644 anyclip/src/modules/configuration/redux/epics/changeModelForm.js create mode 100644 anyclip/src/modules/configuration/redux/epics/changeTempModelForm.js create mode 100644 anyclip/src/modules/configuration/redux/epics/deleteModelForm.js create mode 100644 anyclip/src/modules/configuration/redux/epics/getConfiguration.js create mode 100644 anyclip/src/modules/configuration/redux/epics/index.js create mode 100644 anyclip/src/modules/configuration/redux/epics/saveConfigurationDataOnServer.js create mode 100644 anyclip/src/modules/configuration/redux/epics/updateModelForm.js create mode 100644 anyclip/src/modules/configuration/redux/epics/uploadS3ConfigurationFile.js create mode 100644 anyclip/src/modules/configuration/redux/selectors/index.js create mode 100644 anyclip/src/modules/configuration/redux/slices/index.js create mode 100644 anyclip/src/modules/contentOwners/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/contentOwners/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/contentOwners/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/contentOwners/Editor/constants/index.js create mode 100644 anyclip/src/modules/contentOwners/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/contentOwners/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/contentOwners/Editor/redux/epics/getAccountOptions.js create mode 100644 anyclip/src/modules/contentOwners/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/contentOwners/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/contentOwners/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/contentOwners/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/contentOwners/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/contentOwners/List/components/Empty/Empty.jsx create mode 100644 anyclip/src/modules/contentOwners/List/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/contentOwners/List/components/List.jsx create mode 100644 anyclip/src/modules/contentOwners/List/components/List.module.scss create mode 100644 anyclip/src/modules/contentOwners/List/constants/index.js rename {src/modules/xRay/campaigns => anyclip/src/modules/contentOwners}/List/helpers/computedState.js (100%) create mode 100644 anyclip/src/modules/contentOwners/List/helpers/index.js create mode 100644 anyclip/src/modules/contentOwners/List/redux/epics/bulkActionDisableOrActive.js create mode 100644 anyclip/src/modules/contentOwners/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/contentOwners/List/redux/epics/index.js create mode 100644 anyclip/src/modules/contentOwners/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/contentOwners/List/redux/slices/index.js create mode 100644 anyclip/src/modules/customReports/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/customReports/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/customReports/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/customReports/Editor/constants/index.js create mode 100644 anyclip/src/modules/customReports/Editor/helpers/getRestrictions.js create mode 100644 anyclip/src/modules/customReports/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/customReports/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/customReports/Editor/redux/epics/getAccountOptions.js create mode 100644 anyclip/src/modules/customReports/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/customReports/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/customReports/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/customReports/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/customReports/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/customReports/List/components/Empty/Empty.jsx create mode 100644 anyclip/src/modules/customReports/List/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/customReports/List/components/List.jsx create mode 100644 anyclip/src/modules/customReports/List/components/List.module.scss create mode 100644 anyclip/src/modules/customReports/List/constants/index.js rename {src/modules/hubs => anyclip/src/modules/customReports}/List/helpers/computedState.js (100%) create mode 100644 anyclip/src/modules/customReports/List/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/customReports/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/customReports/List/redux/epics/index.js create mode 100644 anyclip/src/modules/customReports/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/customReports/List/redux/slices/index.js create mode 100644 anyclip/src/modules/designSystem/DesignSystemLayout.jsx create mode 100644 anyclip/src/modules/designSystem/DesignSystemLayout.module.scss create mode 100644 anyclip/src/modules/designSystem/components/MenuItem.jsx create mode 100644 anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.module.scss create mode 100644 anyclip/src/modules/designSystem/modules/AlertSection/AlertSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/AutocompleteSection/AutocompleteSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/AvatarSection/AvatarSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/BadgeSection/BadgeSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/BottomNavigationSection/BottomNavigationSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/BreadcrumbsSection/BreadcrumbsSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/ButtonGroupSection/ButtonGroupSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/ButtonSection/ButtonSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/CardSection/CardSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/CheckboxSection/CheckboxSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/ChipAltSection/ChipAltSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/ChipSection/ChipSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.module.scss create mode 100644 anyclip/src/modules/designSystem/modules/CompareSection/CompareSection.tsx create mode 100644 anyclip/src/modules/designSystem/modules/DataGridSection/DataGridSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/DataGridSection/components/Edit.jsx create mode 100644 anyclip/src/modules/designSystem/modules/DatePickerSection/DatePickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/DateRangePickerSection/DateRangePickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/DateTimePickerSection/DateTimePickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/DateTimeRangePickerSection/DateTimeRangePickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/DialogSection/DialogSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/DurationFieldSection/DurationFieldSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/FabSection/FabSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.module.scss create mode 100644 anyclip/src/modules/designSystem/modules/GridListSection/GridListSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/IconButtonSection/IconButtonSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/IconSection/IconSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/InlineAutocompleteSection/InlineAutocompleteSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/InlineDateTimePickerSection/InlineDateTimePickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/InlineTextFieldSection/InlineTextFieldSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/JSONEditorSection/JSONEditorSection.tsx create mode 100644 anyclip/src/modules/designSystem/modules/ListSection/ListSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/MenuSection/MenuSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/NumberFieldSection/NumberFieldSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/PaginationSection/PaginationSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/Playground/Playground.jsx create mode 100644 anyclip/src/modules/designSystem/modules/Playground/Playground.module.scss create mode 100644 anyclip/src/modules/designSystem/modules/ProgressSection/ProgressSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/RadioSection/RadioSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/RatingSection/RatingSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/SelectSection/SelectSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/SkeletonSection/SkeletonSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/SliderSection/SliderSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/SnackbarSection/SnackbarSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/StepperSection/StepperSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/SwitchSection/SwitchSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TabSection/TabSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TableSection/TableSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TextFieldSection/TextFieldSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TimePickerSection/TimePickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TimeRangePickerSection/TimeRangePickerSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/ToggleButtonSection/ToggleButtonSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TooltipSection/TooltipSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TreeViewSection/TreeViewSection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/TypographySection/TypographySection.jsx create mode 100644 anyclip/src/modules/designSystem/modules/constants/index.js create mode 100644 anyclip/src/modules/designSystem/modules/constants/routes.js create mode 100644 anyclip/src/modules/designSystem/modules/helpers/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/constants/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/player.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/validationScheme.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/createPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/duplicatePlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlayers.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistById.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistsByUrl.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPublishers.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getSupplyTagOptions.ts create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/updatePlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/getAiFilters.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/updateAiFilters.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/createAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/deleteAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/getAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/updateAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getEmbedCode.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPlayers.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPublishers.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/addVideoToPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/deletePlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/dragAndDrop.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistById.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistVideos.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylists.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/updatePlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/searchVideo.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/constants/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/helpers/validationScheme.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/createChannel.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getChannelById.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getSupplyTagOptions.ts create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateChannel.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateDefaultPropFromWatch.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/label.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/orderOfTabs.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/pages.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/presets.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/shouldAddAllOptionsSelector.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/validationScheme.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/createWatch.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getAccountFeatures.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getDomainsAutocomplete.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getHubsAutocomplete.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getSourceAutocomplete.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getWatchById.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/updateWatch.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/channels.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/addVideoToChannel.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/copyWatchChannel.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/createAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteChannel.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteWatch.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/dragAndDrop.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiFilters.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistById.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistVideos.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPublishers.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatchEmbedCode.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatches.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiFilters.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiPlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateChannel.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updatePlaylist.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/constants/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/TabWatch/helpers/permissions.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/common/constants/index.js create mode 100644 anyclip/src/modules/editorial/RightSideBar/common/helpers/index.js rename {src => anyclip/src}/modules/editorial/RightSideBar/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/RightSideBar/styles.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/CategoryColors/CategoryColors.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/CategoryColors/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/TagCreate.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/SelectCreateCategory.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/WithCategory.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/WithoutCategory.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagCreate/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/TagEditor.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/EditCategory.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/Edit.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/CustomTags/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/CustomTags/index.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/EmptyTag/EmptyTag.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/EmptyTag/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/Layout/Layout.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/Layout/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/SystemTags/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/Tags/SystemTags/index.module.scss (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/components/index.jsx (100%) create mode 100644 anyclip/src/modules/editorial/TagEditor/constants/index.js rename {src => anyclip/src}/modules/editorial/TagEditor/constants/propTypes.js (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/helpers/index.js (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/hooks/useTagEditorDialog.js (100%) rename {src => anyclip/src}/modules/editorial/TagEditor/index.js (100%) create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/createCustomCategory.js create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/createSystemTag.js create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/editCustomCategory.js create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomCategoriesOptions.js create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsOptions.js create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsWithCategoryOptions.js create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/getSystemTagsOptions.js create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/epics/index.js rename {src => anyclip/src}/modules/editorial/TagEditor/redux/selectors/index.js (100%) create mode 100644 anyclip/src/modules/editorial/TagEditor/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/ConfirmWarnEdit/ConfirmWarnEdit.jsx create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getFeedOptions.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getHubsOptions.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/constants/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getModulesAttribute.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getSelectedVideo.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/warnEdit.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/helpers/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/createVideo.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/generateByAiChapters.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getChapters.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getListOfCreatedVideo.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/monitoringChapters.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/publishUnpublishChapters.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/setChapters.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/generateByAiDescription.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/getDescription.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/monitoringDescription.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/publishUnpublishDescription.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/setDescription.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/updateDescriptionInVideoListOrDetail.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Description/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/constants/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/createVideo.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/generateByAiHighlights.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getCcSegments.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getHighlights.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getListOfCreatedVideo.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/monitoringHighlights.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/publishUnpublishHighlights.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/setHighlights.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/constants/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/generateByAiSlides.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/getSlides.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringPdf.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringSlides.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setSlides.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setStateSlides.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/upload.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Slides/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/constants/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/helpers/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/download.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getData.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getTagInfo.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/updateSelectedVideoAttributes.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/upsertTags.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/constants/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/generateByAi.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/getThumbnail.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailProcessing.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailPublish.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailSetToFrame.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/publish.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailDraft.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailFromVideoFrame.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/updateThumbnailInVideoListOrDetail.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/upload.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/constants/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/addTranslationFile.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranscript.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranslation.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getSubtitleByLang.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getTranslationFiles.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringPublishTrancript.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTrancript.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTranslations.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/removeTranslationFile.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/setSubtitle.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/upload.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/aiWorkbench/Translations/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionArchive/BulkActionArchive.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionPanelSuccess.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionsPanelSuccess.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionReplaceTags/BulkActionReplaceTags.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionShare/constants/index.js (100%) create mode 100644 anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getUsers.js create mode 100644 anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionStatusDialog/BulkActionStatusDialog.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.module.scss (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionsActivateButton/BulkActionsActivateButton.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.jsx (100%) rename {src => anyclip/src}/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.module.scss (100%) create mode 100644 anyclip/src/modules/editorial/bulkActions/constants/index.js rename {src => anyclip/src}/modules/editorial/bulkActions/hooks/useGetSelectedVideo.js (100%) create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/addTags.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/archive.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/changeAccessLevelAction.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/getHubOptions.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/getMonitoringState.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/replaceHubs.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/replaceTags.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/epics/shareWithUsers.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/bulkActions/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/common/components/NewCard/Tags/Tags.jsx (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/Tags/Tags.module.scss (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.jsx (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.module.scss (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.jsx (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.module.scss (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/VideoName/VideoName.jsx (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/VideoName/VideoName.module.scss (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.jsx (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.module.scss (100%) rename {src => anyclip/src}/modules/editorial/common/components/NewCard/useEditableComponent.js (100%) create mode 100644 anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/fetchPlayerConfigAction.js create mode 100644 anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/common/components/PlayerPreview/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/common/components/statusBlock/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/common/components/statusBlock/styles.module.scss (100%) rename {src => anyclip/src}/modules/editorial/common/components/trimVideo/helpers/canShowTrim.js (100%) create mode 100644 anyclip/src/modules/editorial/common/components/trimVideo/helpers/converSecToMs.js create mode 100644 anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/trim.js create mode 100644 anyclip/src/modules/editorial/common/components/trimVideo/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/common/components/trimVideo/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/constants/dnd.js rename {src => anyclip/src}/modules/editorial/constants/monitoringJobs.js (100%) create mode 100644 anyclip/src/modules/editorial/constants/routing.js create mode 100644 anyclip/src/modules/editorial/constants/video.js rename {src => anyclip/src}/modules/editorial/editorialSearch/components/search/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearch/components/search/styles.module.scss (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearch/constants/searchFilter.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/helpers/helpers/monitoring.js rename {src => anyclip/src}/modules/editorial/editorialSearch/index.js (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoring.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringRepeat.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStart.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStop.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/reload.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/videoDelete.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/videoJob.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/videoStatusUpdate.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/epics/videoVerificationUpdate.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/searchSuggester.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/showUploader.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/reduxSearch/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearch/reduxSearch/slices/index.js rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/VideoTabs/VideoTabs.module.scss (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/VideoTabs/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.module.scss (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/constants.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterConfig.js rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.module.scss (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterContainer/index.js (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/restoreCopiedConfig.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterDatePicker/filterDatePicker.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterItem/filterItem.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterSelector/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/ActionAutocomplete.module.scss (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/index.jsx (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionIAB/index.jsx rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterSuggester/component/Autocomplete/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterSuggester/component/filterSuggester.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterSuggester/component/types.js (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/accounts.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/contentOwnersByAccount.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/keywords.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/publishers.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterTimePicker/component/filterTimePicker.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/filterTimePicker/index.js (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/helpers/filterComponentMapper.js (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchFilter/helpers/index.js (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/constants/index.js rename {src => anyclip/src}/modules/editorial/editorialSearchResults/components/AccessControlView/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchResults/components/AccessControlView/index.module.scss (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchResults/components/listItem/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchResults/components/placeholder/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchResults/components/placeholder/index.module.scss (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchResults/components/searchResults/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialSearchResults/components/searchResults/styles.module.scss (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/constants/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/helpers/filter.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/helpers/filterConfig.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/helpers/filterInternal.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/helpers/index.js rename {src => anyclip/src}/modules/editorial/editorialSearchResults/index.js (100%) create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/addQueries.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/canAddToPlaylist.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/download.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/filterParams.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getEmbedCode.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getPlayers.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoring.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoringFinish.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/removeVideoIdQuery.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchNextVideos.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchRestart.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchVideos.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/showNotification.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/targetClipId.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/epics/videosChangeProcessingStatusMonitoring.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialSearchResults/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/editorialTool/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialTool/styles.module.scss (100%) create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/addPublishEntries.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/deletePublishEntries.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinations.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinationsPublishers.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishEntries.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishViewabilityConfig.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/updatePublishEntry.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/constants/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/calculatePermissions.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/createDefaultRow.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/responseToTableRow.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/rowToRequestBody.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/changeStatuses.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/create.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/get.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getPlayersWithDisabledTargeting.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getStatuses.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/remove.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/runAction.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/update.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/slices/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/constants/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/getData.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/editorialVideoDetails/components/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialVideoDetails/components/index.module.scss (100%) rename {src => anyclip/src}/modules/editorial/editorialVideoDetails/components/menu/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialVideoDetails/components/menu/styles.module.scss (100%) rename {src => anyclip/src}/modules/editorial/editorialVideoDetails/constants/index.js (100%) create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/helpers/index.js rename {src => anyclip/src}/modules/editorial/editorialVideoDetails/index.js (100%) create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/createTaxonomyKeyword.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/deleteVideo.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getAdvertiser.ts create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getOwnersAutocomplete.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getSpeechToTextModels.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/reloadSelectedVideo.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/showNotification.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/taxonomyAutocomplete.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/updateVideoTags.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/videoUpdate.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/editorialVideoDetails/redux/slices/index.js rename {src => anyclip/src}/modules/editorial/editorialVideoInfo/components/index.jsx (100%) rename {src => anyclip/src}/modules/editorial/editorialVideoInfo/components/styles.module.scss (100%) create mode 100644 anyclip/src/modules/editorial/editorialVideoInfo/helpers/index.js create mode 100644 anyclip/src/modules/editorial/helpers/createDndId.js create mode 100644 anyclip/src/modules/editorial/helpers/videoTab.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/constants/index.js rename {src => anyclip/src}/modules/editorial/shareAndAccess/helpers/permissions.js (100%) create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessAddSites.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessChangeLevel.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessCopyAccessUsersToTrimedVideo.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteSites.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteUsers.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessUpdateLevel.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/getPublishersByIds.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/getSharedUsersName.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/getUsersByAccount.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/index.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/epics/shareVideoWithUsers.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/selectors/index.js create mode 100644 anyclip/src/modules/editorial/shareAndAccess/redux/slices/index.js create mode 100644 anyclip/src/modules/entities/Entities/components/AliasDialog.jsx create mode 100644 anyclip/src/modules/entities/Entities/components/AliasDialog.module.scss create mode 100644 anyclip/src/modules/entities/Entities/components/AvatarEdit.jsx create mode 100644 anyclip/src/modules/entities/Entities/components/Entities.jsx create mode 100644 anyclip/src/modules/entities/Entities/components/Entities.module.scss create mode 100644 anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.jsx create mode 100644 anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.module.scss create mode 100644 anyclip/src/modules/entities/Entities/constants/index.js create mode 100644 anyclip/src/modules/entities/Entities/helpers/formatObjects.js create mode 100644 anyclip/src/modules/entities/Entities/helpers/validations.js create mode 100644 anyclip/src/modules/entities/Entities/redux/epics/createEntity.js create mode 100644 anyclip/src/modules/entities/Entities/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/entities/Entities/redux/epics/getData.js create mode 100644 anyclip/src/modules/entities/Entities/redux/epics/getLanguages.js create mode 100644 anyclip/src/modules/entities/Entities/redux/epics/index.js create mode 100644 anyclip/src/modules/entities/Entities/redux/epics/mergeEntities.js create mode 100644 anyclip/src/modules/entities/Entities/redux/epics/updateEntity.js create mode 100644 anyclip/src/modules/entities/Entities/redux/selectors/index.js create mode 100644 anyclip/src/modules/entities/Entities/redux/slices/index.js create mode 100644 anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/ConfigurationErrorDialog.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/constants/index.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/helpers/setupGetConfigurationErrorCode.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.module.scss create mode 100644 anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.module.scss create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Account/Account.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/AspectRatio/AspectRatio.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.module.scss create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/constants/index.ts create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/AutomationScript/AutomationScript.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ContentType/ContentType.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/DefaultTimeZone/DefaultTimeZone.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/FillLandingPage/FillLandingPage.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Fit/Fit.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Hubs/Hubs.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.module.scss create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ImportCCFromMrssFile/ImportCCFromMrssFile.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ImportShortsThumbnail/ImportShortsThumbnail.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/JsRendering/JsRendering.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Keywords/Keywords.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Language/Language.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/LoadFromLastDays/LoadFromLastDays.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/MaxStories/MaxStories.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Name/Name.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ParseKeywordsToLabels/ParseKeywordsToLabels.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Password/Password.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ProcessVideoVersions/ProcessVideoVersions.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Resolution/Resolution.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Restricted/Restricted.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/SpeechToTextProvider/SpeechToTextProvider.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Timezone/Timezone.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/Url/Url.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/User/User.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/VersionAttributeName/VersionAttributeName.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/VideoDuration/VideoDuration.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/VideoMaxZoom/VideoMaxZoom.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeChannelId/YouTubeChannelId.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeLoadFromDate/YouTubeLoadFromDate.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormElements/YoutubeContentType/YoutubeContentType.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/ModelTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/helpers/index.ts create mode 100644 anyclip/src/modules/feeds/Editor/components/FormTabs/ScriptTab/ScriptTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.module.scss create mode 100644 anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Csv/Csv.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Manual/Manual.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Mrss.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/MsStream/MsStream.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Rss/Rss.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Sitemap.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/StoryApi.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Auth/Auth.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tiktok.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/VideoApi.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Vimeo.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/AdvancedTab/AdvancedTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/GeneralTab/GeneralTab.tsx create mode 100644 anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Youtube.tsx create mode 100644 anyclip/src/modules/feeds/Editor/constants/index.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/index.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/injectNewFeedFormUtil.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getAllFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getCsvFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getManualFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMrssFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMsStreamFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getRssFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getSitemapFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getStoryApiFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getTikTokFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVideoApiFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVimeoFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/getYoutubeFields.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/requestPayload/index.ts create mode 100644 anyclip/src/modules/feeds/Editor/helpers/validationScheme.ts create mode 100644 anyclip/src/modules/feeds/Editor/hooks/useSetIsSelfServeUser.tsx create mode 100644 anyclip/src/modules/feeds/Editor/hooks/useSetStoreDefaultValues.tsx create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/createItemAction.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/getAccountOptions.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/getHubsOptions.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/getItem.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/getMetadata.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/getOAuthClientId.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/getOAuthToken.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/getOwnerOptions.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/index.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/epics/updateItemAction.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/selectors/index.ts create mode 100644 anyclip/src/modules/feeds/Editor/redux/slices/index.ts create mode 100644 anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.tsx create mode 100644 anyclip/src/modules/feeds/SelfServeList/components/ImportDialog/ImportDialog.tsx create mode 100644 anyclip/src/modules/feeds/SelfServeList/components/List.module.scss create mode 100644 anyclip/src/modules/feeds/SelfServeList/components/List.tsx create mode 100644 anyclip/src/modules/feeds/SelfServeList/components/StatusCell/StatusCell.tsx create mode 100644 anyclip/src/modules/feeds/SelfServeList/components/TypeCell/TypeCell.tsx create mode 100644 anyclip/src/modules/feeds/SelfServeList/constants/index.tsx create mode 100644 anyclip/src/modules/feeds/SelfServeList/helpers/index.tsx create mode 100644 anyclip/src/modules/feeds/SelfServeList/redux/epics/getData.ts create mode 100644 anyclip/src/modules/feeds/SelfServeList/redux/epics/importArchived.ts create mode 100644 anyclip/src/modules/feeds/SelfServeList/redux/epics/index.ts create mode 100644 anyclip/src/modules/feeds/SelfServeList/redux/selectors/index.ts create mode 100644 anyclip/src/modules/feeds/SelfServeList/redux/slices/index.ts create mode 100644 anyclip/src/modules/feeds/SelfServeList/useIsSelfServeNewList.tsx create mode 100644 anyclip/src/modules/feeds/constants/index.ts rename {src => anyclip/src}/modules/forms/common/components/DownloadResponse/index.jsx (100%) create mode 100644 anyclip/src/modules/forms/common/components/DynamicFields/DynamicFields.module.scss create mode 100644 anyclip/src/modules/forms/common/components/DynamicFields/index.jsx create mode 100644 anyclip/src/modules/forms/common/components/FormPreview/FormPreview.module.scss create mode 100644 anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.jsx create mode 100644 anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.module.scss create mode 100644 anyclip/src/modules/forms/common/components/FormPreview/index.jsx create mode 100644 anyclip/src/modules/forms/common/components/RichEditor/RichEditor.module.scss create mode 100644 anyclip/src/modules/forms/common/components/RichEditor/RichEditor.tsx create mode 100644 anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.module.scss create mode 100644 anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.tsx create mode 100644 anyclip/src/modules/forms/common/components/StylesSettings/components/ColorPicker/index.jsx create mode 100644 anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/UploadImage.module.scss create mode 100644 anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/index.jsx create mode 100644 anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/UploadPictureBanner.module.scss create mode 100644 anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/index.jsx create mode 100644 anyclip/src/modules/forms/common/components/StylesSettings/index.jsx create mode 100644 anyclip/src/modules/forms/constants/index.js create mode 100644 anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.jsx create mode 100644 anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.module.scss create mode 100644 anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/PlayerPreview.module.scss create mode 100644 anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/index.jsx create mode 100644 anyclip/src/modules/forms/editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/forms/editor/components/TriggerTab/TriggerTab.jsx create mode 100644 anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/ActionAutocomplete.module.scss create mode 100644 anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/index.jsx create mode 100644 anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionIAB/index.jsx create mode 100644 anyclip/src/modules/forms/editor/components/index.jsx create mode 100644 anyclip/src/modules/forms/editor/constants/index.js create mode 100644 anyclip/src/modules/forms/editor/helpers/calculationsFromState.js create mode 100644 anyclip/src/modules/forms/editor/helpers/createRequestBody.js create mode 100644 anyclip/src/modules/forms/editor/helpers/metadata/v1/createFormMetadata.js create mode 100644 anyclip/src/modules/forms/editor/helpers/metadata/v1/parseFormMetadataToState.js create mode 100644 anyclip/src/modules/forms/editor/helpers/metadata/v1/widgetsMetadata.js create mode 100644 anyclip/src/modules/forms/editor/helpers/parseResponseToState.js create mode 100644 anyclip/src/modules/forms/editor/helpers/parseTemplateResponseToState.js create mode 100644 anyclip/src/modules/forms/editor/helpers/validationRules.js create mode 100644 anyclip/src/modules/forms/editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/forms/editor/index.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/create.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/downloadFormData.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/duplicate.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getBrandSafetyOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getDomainOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getFormById.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getGeoOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getLabelOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getPlayerOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getSiteOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getTemplateById.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getTextOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getVideoOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/getWatchOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/index.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/sendFormReportToUserEmail.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/sendTestEmail.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/update.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/uploadBanner.js create mode 100644 anyclip/src/modules/forms/editor/redux/epics/uploadLogo.js create mode 100644 anyclip/src/modules/forms/editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/forms/editor/redux/slices/index.js rename {src => anyclip/src}/modules/forms/forms/components/Forms.module.scss (100%) rename {src => anyclip/src}/modules/forms/forms/components/TemplateGallery/TemplateGallery.module.scss (100%) rename {src => anyclip/src}/modules/forms/forms/components/TemplateGallery/components/TemplateItem/TemplateItem.module.scss (100%) rename {src => anyclip/src}/modules/forms/forms/components/TemplateGallery/components/TemplateItem/index.jsx (100%) rename {src => anyclip/src}/modules/forms/forms/components/TemplateGallery/index.jsx (100%) rename {src => anyclip/src}/modules/forms/forms/components/index.jsx (100%) create mode 100644 anyclip/src/modules/forms/forms/constants/index.js rename {src => anyclip/src}/modules/forms/forms/helpers/calculationsFromState.js (100%) create mode 100644 anyclip/src/modules/forms/forms/helpers/upsertLastUsedTemplates.js rename {src => anyclip/src}/modules/forms/forms/index.js (100%) create mode 100644 anyclip/src/modules/forms/forms/redux/epics/archive.js create mode 100644 anyclip/src/modules/forms/forms/redux/epics/downloadFormData.js create mode 100644 anyclip/src/modules/forms/forms/redux/epics/getForms.js create mode 100644 anyclip/src/modules/forms/forms/redux/epics/getSiteOptionsAutocomplete.js create mode 100644 anyclip/src/modules/forms/forms/redux/epics/getTemplateGallery.js create mode 100644 anyclip/src/modules/forms/forms/redux/epics/getTemplateGalleryCategory.js create mode 100644 anyclip/src/modules/forms/forms/redux/epics/index.js create mode 100644 anyclip/src/modules/forms/forms/redux/epics/sendFormReportToUserEmail.js create mode 100644 anyclip/src/modules/forms/forms/redux/selectors/index.js create mode 100644 anyclip/src/modules/forms/forms/redux/slices/index.js create mode 100644 anyclip/src/modules/forms/helpers/createDynamicField.js create mode 100644 anyclip/src/modules/forms/helpers/index.ts create mode 100644 anyclip/src/modules/forms/helpers/useFormSubmitMode.js create mode 100644 anyclip/src/modules/forms/helpers/useGetReadOnlyStatus.js create mode 100644 anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.jsx create mode 100644 anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.module.scss create mode 100644 anyclip/src/modules/forms/templateEditor/components/TemplateEditor.module.scss create mode 100644 anyclip/src/modules/forms/templateEditor/components/index.jsx create mode 100644 anyclip/src/modules/forms/templateEditor/constants/index.js create mode 100644 anyclip/src/modules/forms/templateEditor/helpers/calculationsFromState.js create mode 100644 anyclip/src/modules/forms/templateEditor/helpers/createRequestBody.js create mode 100644 anyclip/src/modules/forms/templateEditor/helpers/parseResponseToState.js create mode 100644 anyclip/src/modules/forms/templateEditor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/forms/templateEditor/index.jsx create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/create.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/duplicate.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/getAccountsAutocomplete.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/getById.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/getCategory.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/index.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/saveProcess.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/update.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/uploadBanner.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/epics/uploadLogo.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/selectors/index.js create mode 100644 anyclip/src/modules/forms/templateEditor/redux/slices/index.js create mode 100644 anyclip/src/modules/forms/templates/components/Templates.module.scss create mode 100644 anyclip/src/modules/forms/templates/components/index.jsx create mode 100644 anyclip/src/modules/forms/templates/constants/index.js create mode 100644 anyclip/src/modules/forms/templates/helpers/index.js create mode 100644 anyclip/src/modules/forms/templates/index.js create mode 100644 anyclip/src/modules/forms/templates/redux/epics/deleteTemplate.js create mode 100644 anyclip/src/modules/forms/templates/redux/epics/getCategory.js create mode 100644 anyclip/src/modules/forms/templates/redux/epics/getTemplates.js create mode 100644 anyclip/src/modules/forms/templates/redux/epics/index.js create mode 100644 anyclip/src/modules/forms/templates/redux/selectors/index.js create mode 100644 anyclip/src/modules/forms/templates/redux/slices/index.js create mode 100644 anyclip/src/modules/hostedWatch/Watches/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/hostedWatch/Watches/redux/epics/getWatches.js create mode 100644 anyclip/src/modules/hostedWatch/Watches/redux/epics/index.js create mode 100644 anyclip/src/modules/hostedWatch/Watches/redux/selectors/index.js create mode 100644 anyclip/src/modules/hostedWatch/Watches/redux/slices/index.js create mode 100644 anyclip/src/modules/hubs/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/hubs/Editor/components/ItemList/ItemList.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/RuleList/index.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/SortableRuleItem.module.scss create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/index.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/MarketplaceSelfServiceTab/MarketplaceSelfServiceTab.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/PlayerSelfServiceTab/PlayerSelfServiceTab.jsx create mode 100644 anyclip/src/modules/hubs/Editor/components/Tabs/SyndicatedContentTab/SyndicatedContentTab.jsx create mode 100644 anyclip/src/modules/hubs/Editor/constants/index.js create mode 100644 anyclip/src/modules/hubs/Editor/helpers/createRequestBody.js create mode 100644 anyclip/src/modules/hubs/Editor/helpers/getDefaultDomain.js create mode 100644 anyclip/src/modules/hubs/Editor/helpers/getRestrictions.js create mode 100644 anyclip/src/modules/hubs/Editor/helpers/parseResponseToState.js create mode 100644 anyclip/src/modules/hubs/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/epics/getAccountOptions.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/epics/getDemandAccountsOptions.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/epics/getTemplatePlayerOptions.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/hubs/Editor/redux/slices/index.js rename {src => anyclip/src}/modules/hubs/List/components/Empty/Empty.jsx (100%) rename {src => anyclip/src}/modules/hubs/List/components/Empty/Empty.module.scss (100%) rename {src => anyclip/src}/modules/hubs/List/components/List.jsx (100%) rename {src => anyclip/src}/modules/hubs/List/components/List.module.scss (100%) create mode 100644 anyclip/src/modules/hubs/List/constants/index.js rename {src/modules/invitations => anyclip/src/modules/hubs}/List/helpers/computedState.js (100%) rename {src => anyclip/src}/modules/hubs/List/helpers/index.js (100%) create mode 100644 anyclip/src/modules/hubs/List/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/hubs/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/hubs/List/redux/epics/index.js create mode 100644 anyclip/src/modules/hubs/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/hubs/List/redux/slices/index.js rename {src => anyclip/src}/modules/invitations/List/components/CopyTooltip/CopyTooltip.jsx (100%) rename {src => anyclip/src}/modules/invitations/List/components/Empty/Empty.jsx (100%) rename {src => anyclip/src}/modules/invitations/List/components/Empty/Empty.module.scss (100%) rename {src => anyclip/src}/modules/invitations/List/components/List.jsx (100%) rename {src => anyclip/src}/modules/invitations/List/components/List.module.scss (100%) create mode 100644 anyclip/src/modules/invitations/List/constants/index.js rename {src/modules/users => anyclip/src/modules/invitations}/List/helpers/computedState.js (100%) rename {src => anyclip/src}/modules/invitations/List/helpers/index.js (100%) create mode 100644 anyclip/src/modules/invitations/List/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/invitations/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/invitations/List/redux/epics/index.js create mode 100644 anyclip/src/modules/invitations/List/redux/epics/revokeInvitation.js create mode 100644 anyclip/src/modules/invitations/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/invitations/List/redux/slices/index.js rename {src => anyclip/src}/modules/layout/components/FloatBlock/FloatBlock.jsx (100%) rename {src => anyclip/src}/modules/layout/components/FloatBlock/FloatBlock.module.scss (100%) rename {src => anyclip/src}/modules/layout/components/FloatBlock/components/Container/Container.jsx (100%) rename {src => anyclip/src}/modules/layout/components/FloatBlock/components/Container/Container.module.scss (100%) rename {src => anyclip/src}/modules/layout/components/index.jsx (100%) rename {src => anyclip/src}/modules/layout/components/index.module.scss (100%) rename {src => anyclip/src}/modules/layout/components/menu/ItemMenu/index.jsx (100%) rename {src => anyclip/src}/modules/layout/components/menu/ItemMenu/index.module.scss (100%) rename {src => anyclip/src}/modules/layout/components/menu/SubMenuItem/SubMenu.module.scss (100%) rename {src => anyclip/src}/modules/layout/components/menu/SubMenuItem/index.jsx (100%) create mode 100644 anyclip/src/modules/layout/components/menu/constants/index.js rename {src => anyclip/src}/modules/layout/components/menu/index.jsx (100%) rename {src => anyclip/src}/modules/layout/components/menu/index.module.scss (100%) create mode 100644 anyclip/src/modules/layout/helpers/index.js rename {src => anyclip/src}/modules/layout/index.js (100%) create mode 100644 anyclip/src/modules/layout/redux/epics/clear.js create mode 100644 anyclip/src/modules/layout/redux/epics/error.js create mode 100644 anyclip/src/modules/layout/redux/epics/followToHelpLink.js create mode 100644 anyclip/src/modules/layout/redux/epics/index.js create mode 100644 anyclip/src/modules/layout/redux/epics/loading.js rename {src => anyclip/src}/modules/layout/redux/selectors/index.js (100%) create mode 100644 anyclip/src/modules/layout/redux/slices/index.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/FilterSuggester.jsx create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/Search.jsx create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/index.jsx create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/styles.module.scss create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Header.jsx create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Live.jsx create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/index.jsx create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/styles.module.scss create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/components/index.jsx create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/index.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/archiveLiveEvents.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getEmbedCode.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPlayers.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPublishers.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEvents.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/index.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/selectors/index.js create mode 100644 anyclip/src/modules/liveEvents/LiveEventsList/redux/slices/index.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/EventDelivery.module.scss create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/index.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/EventPrePost/index.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/EventSchedule.module.scss create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/Recurring.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/index.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/EventSetting/index.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ForсePoster/ForсePoster.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.module.scss create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ViewersCounter/ViewersCounter.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/index.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/styles.module.scss create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/index.jsx create mode 100644 anyclip/src/modules/liveEvents/liveEvent/components/styles.module.scss create mode 100644 anyclip/src/modules/liveEvents/liveEvent/constants/forcePoster.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/constants/index.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/constants/livecc.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/constants/regions.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/helpers/request.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/helpers/timezones.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/helpers/validation.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/hooks/useForceReload.ts create mode 100644 anyclip/src/modules/liveEvents/liveEvent/hooks/useIsAllowedToCreateOrEdit.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/index.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEvent.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventEndpoint.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventFlow.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventImage.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveStream.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getDefaultImages.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getImageUploadUrl.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventById.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventContentOwner.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventImage.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventPublishers.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveStream.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPlayerConfig.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherInfoById.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherPlayers.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmPlaylist.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmViewersCounter.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/index.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/testLiveEventEndpoint.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEvent.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventFlow.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventImage.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveStream.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/epics/uploadLiveEventImage.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/selectors/index.js create mode 100644 anyclip/src/modules/liveEvents/liveEvent/redux/slices/index.js create mode 100644 anyclip/src/modules/marketplace/account/components/Account.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/AdvertiserSettings/components/AdvertiserPricingTab/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/AdvertiserSettings/constants/index.ts create mode 100644 anyclip/src/modules/marketplace/account/components/AdvertiserSettings/helpers/validationScheme.ts create mode 100644 anyclip/src/modules/marketplace/account/components/AdvertiserSettings/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Breadcrumbs/Breadcrumbs.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Breadcrumbs/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Cells/IdCell.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Cells/IdCell.module.scss rename {src => anyclip/src}/modules/marketplace/account/components/Cells/NameCell.jsx (100%) rename {src => anyclip/src}/modules/marketplace/account/components/Cells/NameCell.module.scss (100%) rename {src => anyclip/src}/modules/marketplace/account/components/Cells/TargetingCell.jsx (100%) rename {src => anyclip/src}/modules/marketplace/account/components/Cells/TargetingCell.module.scss (100%) create mode 100644 anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Cells/TierCell.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Cells/TierCell.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Chart/Chart.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Chart/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/FrequencyCapTab/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/BusinessModel/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/HeaderBiddingForm/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/ActionAutocomplete.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/KeyValue/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/DemandSettings/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Main/Main.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Main/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ChangeExpensesModal.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ChangeRevShareModal.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ChangeViewabilityThreshold.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/ConfirmFrequencyCapModal.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/AutomaticOptimization/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/BaseSettingsForm/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/ExportTagTab/ExportTagTab.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/SupplyTagSettings/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/Total/Total.module.scss create mode 100644 anyclip/src/modules/marketplace/account/components/Total/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/components/usePageConfig.jsx create mode 100644 anyclip/src/modules/marketplace/account/constants/addTagModal.js create mode 100644 anyclip/src/modules/marketplace/account/constants/chart.js create mode 100644 anyclip/src/modules/marketplace/account/constants/demandAccountPage.js create mode 100644 anyclip/src/modules/marketplace/account/constants/demandAdvertiserPage.js create mode 100644 anyclip/src/modules/marketplace/account/constants/demandTagPage.js create mode 100644 anyclip/src/modules/marketplace/account/constants/index.js create mode 100644 anyclip/src/modules/marketplace/account/constants/supplyAccountPage.js create mode 100644 anyclip/src/modules/marketplace/account/constants/supplySitePage.js create mode 100644 anyclip/src/modules/marketplace/account/constants/supplyTagPage.js create mode 100644 anyclip/src/modules/marketplace/account/constants/table.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/createDemandTagPriceRequestBody.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/createDemandTagRequestBody.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/createHistoryCSV.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/createSupplyTagPriceRequestBody.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/createSupplyTagRequestBody.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/demandTabs.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/demandTagFormValidationRules.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/filters.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/getCurrentAndFuturePrice.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/isFrequencyCapHasData.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/useLocalPagination.js create mode 100644 anyclip/src/modules/marketplace/account/helpers/validate.js create mode 100644 anyclip/src/modules/marketplace/account/index.jsx create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/bulkCreateWatefall.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateDemandTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateSupplyTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateViewabilityThreshold.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/createAdvertiser.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/createDemandTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/createSupplyTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/deleteWaterfall.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/downloadCSV.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/duplicateDemandTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/duplicateSupplyTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getAccoutsForWaterfall.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getAdServers.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getAdvertiserSettingsTab.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getBudgetingTab.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getChartData.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getCountries.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getData.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getDataForWaterfall.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getDemandAccountById.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getDemandTagPricing.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getFrequencyCapTab.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getHistoryForCSV.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getHistoryForChart.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getInfo.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getKeyListsOptions.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getKeyNamesOptions.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getLabelsForTableFilter.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getLabelsForWaterfall.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getPlatforms.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getPlayers.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getPricingTab.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getSettingsTab.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getTargetingTab.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getTotal.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/getUserHubsAndDemandAccounts.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/index.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/initializeAdvertiserForm.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/initializeDemandForm.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/initializeSupplyForm.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/saveDataToCSV.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/showConfirmModal.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/updateAdvertiser.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/updateDemandTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/updateSupplyTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/updateTiers.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/updateWaterfall.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/validateAdvertiser.js create mode 100644 anyclip/src/modules/marketplace/account/redux/epics/validateTag.js create mode 100644 anyclip/src/modules/marketplace/account/redux/selectors/index.js create mode 100644 anyclip/src/modules/marketplace/account/redux/slices/index.js rename {src => anyclip/src}/modules/marketplace/accounts/components/Accounts.module.scss (100%) rename {src => anyclip/src}/modules/marketplace/accounts/components/Filters/Filters.module.scss (100%) rename {src => anyclip/src}/modules/marketplace/accounts/components/Filters/index.jsx (100%) rename {src => anyclip/src}/modules/marketplace/accounts/components/Modals/DisclaimerModal.jsx (100%) rename {src => anyclip/src}/modules/marketplace/accounts/components/Modals/DisclaimerModal.module.scss (100%) rename {src => anyclip/src}/modules/marketplace/accounts/components/index.jsx (100%) rename {src => anyclip/src}/modules/marketplace/accounts/components/usePageConfig.jsx (100%) create mode 100644 anyclip/src/modules/marketplace/accounts/constants/demandAccountsPage.js create mode 100644 anyclip/src/modules/marketplace/accounts/constants/index.js create mode 100644 anyclip/src/modules/marketplace/accounts/constants/supplyAccountsPage.js create mode 100644 anyclip/src/modules/marketplace/accounts/helpers/disclaimerModal.js rename {src => anyclip/src}/modules/marketplace/accounts/index.jsx (100%) create mode 100644 anyclip/src/modules/marketplace/accounts/redux/epics/downloadCSV.js create mode 100644 anyclip/src/modules/marketplace/accounts/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/marketplace/accounts/redux/epics/getSelfServeUserHubsAndDemandAccounts.js create mode 100644 anyclip/src/modules/marketplace/accounts/redux/epics/index.js create mode 100644 anyclip/src/modules/marketplace/accounts/redux/epics/saveDataToCSV.js create mode 100644 anyclip/src/modules/marketplace/accounts/redux/selectors/index.js create mode 100644 anyclip/src/modules/marketplace/accounts/redux/slices/index.js create mode 100644 anyclip/src/modules/marketplace/common/Cells/CopyCell.jsx create mode 100644 anyclip/src/modules/marketplace/common/Cells/CopyCell.module.scss create mode 100644 anyclip/src/modules/marketplace/common/Cells/StatusCell.jsx create mode 100644 anyclip/src/modules/marketplace/common/Chart/CustomLogBar.jsx create mode 100644 anyclip/src/modules/marketplace/common/Chart/CustomLogBar.module.scss create mode 100644 anyclip/src/modules/marketplace/common/Chart/CustomTick.jsx create mode 100644 anyclip/src/modules/marketplace/common/Chart/CustomTick.module.scss create mode 100644 anyclip/src/modules/marketplace/common/Chart/CustomTooltip.jsx create mode 100644 anyclip/src/modules/marketplace/common/Chart/CustomTooltip.module.scss create mode 100644 anyclip/src/modules/marketplace/common/Chart/index.jsx create mode 100644 anyclip/src/modules/marketplace/common/ChipInput/index.jsx rename {src => anyclip/src}/modules/marketplace/common/DateSelect/CustomPeriod.jsx (100%) rename {src => anyclip/src}/modules/marketplace/common/DateSelect/CustomPeriod.module.scss (100%) rename {src => anyclip/src}/modules/marketplace/common/DateSelect/DateSelect.module.scss (100%) rename {src => anyclip/src}/modules/marketplace/common/DateSelect/index.jsx (100%) rename {src => anyclip/src}/modules/marketplace/common/HeaderNew/Header.module.scss (100%) rename {src => anyclip/src}/modules/marketplace/common/HeaderNew/index.jsx (100%) create mode 100644 anyclip/src/modules/marketplace/common/Table/Table.module.scss create mode 100644 anyclip/src/modules/marketplace/common/Table/index.jsx create mode 100644 anyclip/src/modules/marketplace/common/constants/index.js create mode 100644 anyclip/src/modules/marketplace/common/helpers/histogram.js create mode 100644 anyclip/src/modules/marketplace/common/helpers/index.js create mode 100644 anyclip/src/modules/marketplace/common/helpers/interval.jsx rename {src => anyclip/src}/modules/marketplace/common/helpers/supplyDemandTransitionLinks.js (100%) create mode 100644 anyclip/src/modules/marketplace/common/helpers/uploadToS3.ts create mode 100644 anyclip/src/modules/marketplace/dashboard/components/Dashboard.module.scss rename {src => anyclip/src}/modules/marketplace/dashboard/components/SearchNew.jsx (100%) rename {src => anyclip/src}/modules/marketplace/dashboard/components/SearchNew.module.scss (100%) create mode 100644 anyclip/src/modules/marketplace/dashboard/components/Total.jsx create mode 100644 anyclip/src/modules/marketplace/dashboard/components/Total.module.scss create mode 100644 anyclip/src/modules/marketplace/dashboard/components/index.jsx create mode 100644 anyclip/src/modules/marketplace/dashboard/constants/index.js create mode 100644 anyclip/src/modules/marketplace/dashboard/index.jsx create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/epics/getComparisonHistogram.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/epics/getFinancialsHistogram.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/epics/getPerformanceHistogram.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/epics/getSelfServeUserHubsAndDemandAccounts.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/epics/getTotal.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/epics/index.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/epics/search.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/selectors/index.js create mode 100644 anyclip/src/modules/marketplace/dashboard/redux/slices/index.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/HBConnectorForm.module.scss create mode 100644 anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/index.jsx create mode 100644 anyclip/src/modules/marketplace/hbConnectors/components/HBConnectors.module.scss create mode 100644 anyclip/src/modules/marketplace/hbConnectors/components/index.jsx create mode 100644 anyclip/src/modules/marketplace/hbConnectors/constants/index.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/index.jsx create mode 100644 anyclip/src/modules/marketplace/hbConnectors/redux/epics/createConnector.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectorById.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectors.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/redux/epics/index.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/redux/epics/updateConnector.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/redux/selectors/index.js create mode 100644 anyclip/src/modules/marketplace/hbConnectors/redux/slices/index.js create mode 100644 anyclip/src/modules/marketplace/keyLists/components/Filters/Filters.module.scss create mode 100644 anyclip/src/modules/marketplace/keyLists/components/Filters/index.jsx create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyListForm/KeyListForm.module.scss create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/FormLineItem.jsx create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/LineItem.module.scss create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/index.jsx create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyListForm/index.jsx create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyLists.module.scss create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/KeyListForm.module.scss create mode 100644 anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/index.jsx create mode 100644 anyclip/src/modules/marketplace/keyLists/components/index.jsx create mode 100644 anyclip/src/modules/marketplace/keyLists/constants/index.js create mode 100644 anyclip/src/modules/marketplace/keyLists/helpers/validationScheme.js create mode 100644 anyclip/src/modules/marketplace/keyLists/helpers/validationSchemeValue.js create mode 100644 anyclip/src/modules/marketplace/keyLists/index.jsx create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateAvailableKeys.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateKeyLists.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/createAvailableKey.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/createKeyList.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/getAvailableKeyById.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/getDemandAccounts.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/getKeyListById.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/getSelfServeUserHubsAndDemandAccounts.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/index.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/searchAvailableKeys.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/searchKeyLists.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/updateAvailableKey.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/epics/updateKeyList.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/selectors/index.js create mode 100644 anyclip/src/modules/marketplace/keyLists/redux/slices/index.js create mode 100644 anyclip/src/modules/notifications/components/Notifications.jsx create mode 100644 anyclip/src/modules/notifications/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/notifications/redux/epics/getItem.js create mode 100644 anyclip/src/modules/notifications/redux/epics/index.js create mode 100644 anyclip/src/modules/notifications/redux/epics/saveItem.js create mode 100644 anyclip/src/modules/notifications/redux/selectors/index.js create mode 100644 anyclip/src/modules/notifications/redux/slices/index.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/onlineHelp/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/onlineHelp/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/onlineHelp/Editor/constants/index.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/helpers/getRestrictions.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/onlineHelp/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/onlineHelp/List/components/Empty/Empty.jsx create mode 100644 anyclip/src/modules/onlineHelp/List/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/onlineHelp/List/components/List.jsx create mode 100644 anyclip/src/modules/onlineHelp/List/components/List.module.scss create mode 100644 anyclip/src/modules/onlineHelp/List/constants/index.js create mode 100644 anyclip/src/modules/onlineHelp/List/helpers/computedState.js create mode 100644 anyclip/src/modules/onlineHelp/List/helpers/index.js create mode 100644 anyclip/src/modules/onlineHelp/List/redux/epics/deleteItem.js create mode 100644 anyclip/src/modules/onlineHelp/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/onlineHelp/List/redux/epics/index.js create mode 100644 anyclip/src/modules/onlineHelp/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/onlineHelp/List/redux/slices/index.js create mode 100644 anyclip/src/modules/permissions/components/Permissions.jsx create mode 100644 anyclip/src/modules/permissions/components/Permissions.module.scss create mode 100644 anyclip/src/modules/permissions/constants/index.js create mode 100644 anyclip/src/modules/permissions/redux/epics/getData.js create mode 100644 anyclip/src/modules/permissions/redux/epics/index.js create mode 100644 anyclip/src/modules/permissions/redux/selectors/index.js create mode 100644 anyclip/src/modules/permissions/redux/slices/index.js create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/constants/index.js create mode 100644 anyclip/src/modules/players/Editor/components/Carousel/helpers/index.js create mode 100644 anyclip/src/modules/players/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.jsx create mode 100644 anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/SaveAndCreateTagsConfirmDialog/SaveAndCreateTagsConfirmDialog.tsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/ContentFiltersTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/CustomPlaceholderTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/DisplayAdsTab/DisplayAdsTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/FloatingTab/FloatingTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/LookAndFeelTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/helpers/handlers.js create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/PlaybackTab/PlaybackTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.module.scss create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.jsx create mode 100644 anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.module.scss create mode 100644 anyclip/src/modules/players/Editor/constants/index.js create mode 100644 anyclip/src/modules/players/Editor/helpers/createRequestBody.js create mode 100644 anyclip/src/modules/players/Editor/helpers/index.js create mode 100644 anyclip/src/modules/players/Editor/helpers/playerPreview.js create mode 100644 anyclip/src/modules/players/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/createPlayer.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/getPlayerBoostList.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/getPlayerData.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/getPlayerEditorialPlaylists.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/getPlayerFeeds.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/getPlayerPublishers.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/players/Editor/redux/epics/updatePlayer.js create mode 100644 anyclip/src/modules/players/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/players/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.jsx create mode 100644 anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.module.scss create mode 100644 anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.jsx create mode 100644 anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.module.scss create mode 100644 anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/components/DesktopWrapper/DesktopWrapper.jsx create mode 100644 anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/components/DesktopWrapper/DesktopWrapper.module.scss create mode 100644 anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/components/MobileWrapper/MobileWrapper.jsx create mode 100644 anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/components/MobileWrapper/MobileWrapper.module.scss create mode 100644 anyclip/src/modules/players/PlayerIframeView/constants/index.js create mode 100644 anyclip/src/modules/players/Players/components/Empty/Empty.jsx create mode 100644 anyclip/src/modules/players/Players/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/players/Players/components/Players.jsx create mode 100644 anyclip/src/modules/players/Players/components/Players.module.scss create mode 100644 anyclip/src/modules/players/Players/constants/index.js create mode 100644 anyclip/src/modules/players/Players/redux/epics/deletePlayer.js create mode 100644 anyclip/src/modules/players/Players/redux/epics/getPlayerPublishers.js create mode 100644 anyclip/src/modules/players/Players/redux/epics/getPlayerUiProps.js create mode 100644 anyclip/src/modules/players/Players/redux/epics/getPlayersData.js create mode 100644 anyclip/src/modules/players/Players/redux/epics/index.js create mode 100644 anyclip/src/modules/players/Players/redux/selectors/index.js create mode 100644 anyclip/src/modules/players/Players/redux/slices/index.js create mode 100644 anyclip/src/modules/publishing/Destination/components/SignButton/SignButton.jsx create mode 100644 anyclip/src/modules/publishing/Destination/components/auth/CognitoAuth.jsx create mode 100644 anyclip/src/modules/publishing/Destination/components/auth/FacebookAuth.jsx create mode 100644 anyclip/src/modules/publishing/Destination/components/auth/GoogleAuth.jsx create mode 100644 anyclip/src/modules/publishing/Destination/components/auth/MicrosoftAuth.jsx create mode 100644 anyclip/src/modules/publishing/Destination/components/auth/ZoomAuth.jsx create mode 100644 anyclip/src/modules/publishing/Destination/components/index.jsx create mode 100644 anyclip/src/modules/publishing/Destination/components/styles.module.scss create mode 100644 anyclip/src/modules/publishing/Destination/constants/index.js create mode 100644 anyclip/src/modules/publishing/Destination/helpers/computedState.js create mode 100644 anyclip/src/modules/publishing/Destination/helpers/validationScheme.js create mode 100644 anyclip/src/modules/publishing/Destination/index.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/epics/createDestination.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/epics/exchangeFBAuthCode.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/epics/exchangeGAuthCode.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/epics/getDestination.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/epics/getPublishViewabilityConfig.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/epics/index.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/epics/updateDestination.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/selectors/index.js create mode 100644 anyclip/src/modules/publishing/Destination/redux/slices/index.js create mode 100644 anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/FilterSuggester.tsx create mode 100644 anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/index.tsx create mode 100644 anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/styles.module.scss create mode 100644 anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/Header.tsx create mode 100644 anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/index.tsx create mode 100644 anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/styles.module.scss create mode 100644 anyclip/src/modules/publishing/DestinationList/components/index.tsx rename {src => anyclip/src}/modules/publishing/DestinationList/constants/index.ts (100%) create mode 100644 anyclip/src/modules/publishing/DestinationList/helpers/index.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/index.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/epics/addPublishEntries.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/epics/deletePublishEntries.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinations.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinationsPublishers.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/epics/getPublishEntries.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/epics/index.ts create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/epics/updatePublishEntry.ts rename {src => anyclip/src}/modules/publishing/DestinationList/redux/selectors/index.ts (100%) create mode 100644 anyclip/src/modules/publishing/DestinationList/redux/slices/index.ts create mode 100644 anyclip/src/modules/rolesPermissions/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/rolesPermissions/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/rolesPermissions/Editor/components/Tabs/DetailsTab/DetailsTab.jsx create mode 100644 anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.jsx create mode 100644 anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.module.scss create mode 100644 anyclip/src/modules/rolesPermissions/Editor/components/UpdatePermissionDialog/UpdatePermissionDialog.jsx create mode 100644 anyclip/src/modules/rolesPermissions/Editor/components/UpdateRoleModuleDialog/UpdateRoleModuleDialog.jsx create mode 100644 anyclip/src/modules/rolesPermissions/Editor/constants/index.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/epics/updatePermissionMetadata.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateRoleModuleMetadata.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/rolesPermissions/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.jsx create mode 100644 anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/rolesPermissions/List/components/List.jsx create mode 100644 anyclip/src/modules/rolesPermissions/List/components/List.module.scss create mode 100644 anyclip/src/modules/rolesPermissions/List/constants/index.js create mode 100644 anyclip/src/modules/rolesPermissions/List/helpers/computedState.js create mode 100644 anyclip/src/modules/rolesPermissions/List/helpers/index.js create mode 100644 anyclip/src/modules/rolesPermissions/List/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/rolesPermissions/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/rolesPermissions/List/redux/epics/index.js create mode 100644 anyclip/src/modules/rolesPermissions/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/rolesPermissions/List/redux/slices/index.js create mode 100644 anyclip/src/modules/sso/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/sso/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss create mode 100644 anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.jsx create mode 100644 anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.module.scss create mode 100644 anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.jsx create mode 100644 anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.module.scss create mode 100644 anyclip/src/modules/sso/Editor/constants/index.js create mode 100644 anyclip/src/modules/sso/Editor/helpers/addAllHubsOption.js create mode 100644 anyclip/src/modules/sso/Editor/helpers/normalizeCustomAttributes.js create mode 100644 anyclip/src/modules/sso/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/sso/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/sso/Editor/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/sso/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/sso/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/sso/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/sso/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/sso/Editor/redux/slices/index.js create mode 100644 anyclip/src/modules/sso/List/components/Empty/Empty.jsx create mode 100644 anyclip/src/modules/sso/List/components/Empty/Empty.module.scss create mode 100644 anyclip/src/modules/sso/List/components/List.jsx create mode 100644 anyclip/src/modules/sso/List/components/List.module.scss create mode 100644 anyclip/src/modules/sso/List/constants/index.js create mode 100644 anyclip/src/modules/sso/List/helpers/computedState.js create mode 100644 anyclip/src/modules/sso/List/helpers/index.js create mode 100644 anyclip/src/modules/sso/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/sso/List/redux/epics/index.js create mode 100644 anyclip/src/modules/sso/List/redux/epics/status.js create mode 100644 anyclip/src/modules/sso/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/sso/List/redux/slices/index.js rename {src => anyclip/src}/modules/uploaderNew/components/MultiUploadStatusDialog/MultiUploadStatusDialog.module.scss (100%) rename {src => anyclip/src}/modules/uploaderNew/components/MultiUploadStatusDialog/helpers/index.js (100%) rename {src => anyclip/src}/modules/uploaderNew/components/MultiUploadStatusDialog/index.jsx (100%) rename {src => anyclip/src}/modules/uploaderNew/components/useShowCancelUploadDialog.js (100%) create mode 100644 anyclip/src/modules/uploaderNew/constants/index.js create mode 100644 anyclip/src/modules/uploaderNew/helpers/audio.js create mode 100644 anyclip/src/modules/uploaderNew/helpers/getContentOwnersList.js create mode 100644 anyclip/src/modules/uploaderNew/helpers/persist.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/clearUploadQueue.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/createVideo.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/createVideoCsv.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/createVideoVersion.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/getAdvertisers.ts create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/getFeedSources.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/handleCancelUploadFromConfirmDialog.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/index.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/siteSuggester.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/startUploadFromQueue.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/chooseUploadBranch.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/startUploadProcess.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCcFilesBranch.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCsvBranch.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadThumbnailBranch.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadVideoBranch.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/chooseUploadBranch.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/startUploadProcess.js create mode 100644 anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/uploadVideoBranch.js create mode 100644 anyclip/src/modules/uploaderNew/redux/selectors/index.js create mode 100644 anyclip/src/modules/uploaderNew/redux/slices/index.js rename {src => anyclip/src}/modules/userRulesSettings/components/CollapsedContainer/CollapsedContainer.module.scss (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/CollapsedContainer/index.jsx (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/Empty/Empty.module.scss (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/Empty/index.jsx (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/UserRulesSettings.module.scss (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/UsersAutocomplete/UsersAutocomplete.module.scss (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/UsersAutocomplete/index.jsx (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/index.jsx (100%) rename {src => anyclip/src}/modules/userRulesSettings/components/useLogic.jsx (100%) create mode 100644 anyclip/src/modules/userRulesSettings/constants/index.js rename {src => anyclip/src}/modules/userRulesSettings/helpers/calculationsFromStore.js (100%) create mode 100644 anyclip/src/modules/userRulesSettings/helpers/createEmptyRules.js create mode 100644 anyclip/src/modules/userRulesSettings/helpers/createRequestBodyFromState.js create mode 100644 anyclip/src/modules/userRulesSettings/helpers/index.js create mode 100644 anyclip/src/modules/userRulesSettings/helpers/mapArray.js create mode 100644 anyclip/src/modules/userRulesSettings/helpers/parseResponseToState.js rename {src => anyclip/src}/modules/userRulesSettings/index.jsx (100%) create mode 100644 anyclip/src/modules/userRulesSettings/redux/epics/get.js create mode 100644 anyclip/src/modules/userRulesSettings/redux/epics/getHubsAutocomplete.js create mode 100644 anyclip/src/modules/userRulesSettings/redux/epics/getSourcesAutocomplete.js create mode 100644 anyclip/src/modules/userRulesSettings/redux/epics/getUsersAutocomplete.js create mode 100644 anyclip/src/modules/userRulesSettings/redux/epics/index.js create mode 100644 anyclip/src/modules/userRulesSettings/redux/epics/save.js create mode 100644 anyclip/src/modules/userRulesSettings/redux/selectors/index.js create mode 100644 anyclip/src/modules/userRulesSettings/redux/slices/index.js create mode 100644 anyclip/src/modules/users/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/users/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss create mode 100644 anyclip/src/modules/users/Editor/constants/index.js create mode 100644 anyclip/src/modules/users/Editor/helpers/getRestrictions.js create mode 100644 anyclip/src/modules/users/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/createDepartment.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/getAccountOptions.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/getContentOwnersOptions.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/getDepartmentOptions.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/getRoleOptions.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/getTimezoneOptions.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/impersonate.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/users/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/users/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/users/Editor/redux/slices/index.js rename {src => anyclip/src}/modules/users/List/components/Empty/Empty.jsx (100%) rename {src => anyclip/src}/modules/users/List/components/Empty/Empty.module.scss (100%) rename {src => anyclip/src}/modules/users/List/components/List.jsx (100%) rename {src => anyclip/src}/modules/users/List/components/List.module.scss (100%) create mode 100644 anyclip/src/modules/users/List/constants/index.js create mode 100644 anyclip/src/modules/users/List/helpers/computedState.js rename {src => anyclip/src}/modules/users/List/helpers/index.js (100%) create mode 100644 anyclip/src/modules/users/List/redux/epics/bulkUpdate.js create mode 100644 anyclip/src/modules/users/List/redux/epics/generateToken.js create mode 100644 anyclip/src/modules/users/List/redux/epics/getAccounts.js create mode 100644 anyclip/src/modules/users/List/redux/epics/getApiSet.js create mode 100644 anyclip/src/modules/users/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/users/List/redux/epics/getDepartmentOptions.js create mode 100644 anyclip/src/modules/users/List/redux/epics/getRoleOptions.js create mode 100644 anyclip/src/modules/users/List/redux/epics/index.js create mode 100644 anyclip/src/modules/users/List/redux/epics/resetPassword.js create mode 100644 anyclip/src/modules/users/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/users/List/redux/slices/index.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/constants/index.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createAdvertiser.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getAdvertisers.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/xRay/campaigns/Editor/redux/slices/index.js rename {src => anyclip/src}/modules/xRay/campaigns/List/components/Empty/Empty.jsx (100%) rename {src => anyclip/src}/modules/xRay/campaigns/List/components/Empty/Empty.module.scss (100%) rename {src => anyclip/src}/modules/xRay/campaigns/List/components/List.jsx (100%) rename {src => anyclip/src}/modules/xRay/campaigns/List/components/List.module.scss (100%) create mode 100644 anyclip/src/modules/xRay/campaigns/List/constants/index.js rename {src/modules/xRay/creatives => anyclip/src/modules/xRay/campaigns}/List/helpers/computedState.js (100%) rename {src => anyclip/src}/modules/xRay/campaigns/List/helpers/index.js (100%) create mode 100644 anyclip/src/modules/xRay/campaigns/List/redux/epics/archive.js create mode 100644 anyclip/src/modules/xRay/campaigns/List/redux/epics/getAdvertisers.js create mode 100644 anyclip/src/modules/xRay/campaigns/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/xRay/campaigns/List/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/xRay/campaigns/List/redux/epics/index.js create mode 100644 anyclip/src/modules/xRay/campaigns/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/xRay/campaigns/List/redux/slices/index.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/xRay/creatives/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/xRay/creatives/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx create mode 100644 anyclip/src/modules/xRay/creatives/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/xRay/creatives/Editor/constants/index.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/xRay/creatives/Editor/redux/slices/index.js rename {src => anyclip/src}/modules/xRay/creatives/List/components/Empty/Empty.jsx (100%) rename {src => anyclip/src}/modules/xRay/creatives/List/components/Empty/Empty.module.scss (100%) rename {src => anyclip/src}/modules/xRay/creatives/List/components/List.jsx (100%) rename {src => anyclip/src}/modules/xRay/creatives/List/components/List.module.scss (100%) create mode 100644 anyclip/src/modules/xRay/creatives/List/constants/index.js rename {src/modules/xRay/lineItems => anyclip/src/modules/xRay/creatives}/List/helpers/computedState.js (100%) rename {src => anyclip/src}/modules/xRay/creatives/List/helpers/index.js (100%) create mode 100644 anyclip/src/modules/xRay/creatives/List/redux/epics/archive.js create mode 100644 anyclip/src/modules/xRay/creatives/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/xRay/creatives/List/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/xRay/creatives/List/redux/epics/index.js create mode 100644 anyclip/src/modules/xRay/creatives/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/xRay/creatives/List/redux/slices/index.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/components/Editor.jsx create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/components/Editor.module.scss create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/DeliveryTab/DeliveryTab.jsx create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/GeneralTab/GeneralTab.jsx create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/TargetingTab/TargetingTab.jsx create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/constants/index.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/helpers/buildBody.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/helpers/timestamp.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/helpers/validationScheme.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/createItem.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrandSafety.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrands.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCampaings.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCreatives.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getDomains.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getGeographies.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getItem.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getKeywords.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getLabels.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPeoples.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPlayers.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getTimezones.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getVideos.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getWatches.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/index.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/epics/updateItem.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/selectors/index.js create mode 100644 anyclip/src/modules/xRay/lineItems/Editor/redux/slices/index.js rename {src => anyclip/src}/modules/xRay/lineItems/List/components/Empty/Empty.jsx (100%) rename {src => anyclip/src}/modules/xRay/lineItems/List/components/Empty/Empty.module.scss (100%) rename {src => anyclip/src}/modules/xRay/lineItems/List/components/List.jsx (100%) rename {src => anyclip/src}/modules/xRay/lineItems/List/components/List.module.scss (100%) create mode 100644 anyclip/src/modules/xRay/lineItems/List/constants/index.js create mode 100644 anyclip/src/modules/xRay/lineItems/List/helpers/computedState.js rename {src => anyclip/src}/modules/xRay/lineItems/List/helpers/index.js (100%) create mode 100644 anyclip/src/modules/xRay/lineItems/List/redux/epics/archive.js create mode 100644 anyclip/src/modules/xRay/lineItems/List/redux/epics/getData.js create mode 100644 anyclip/src/modules/xRay/lineItems/List/redux/epics/getHubs.js create mode 100644 anyclip/src/modules/xRay/lineItems/List/redux/epics/index.js create mode 100644 anyclip/src/modules/xRay/lineItems/List/redux/selectors/index.js create mode 100644 anyclip/src/modules/xRay/lineItems/List/redux/slices/index.js create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/ButtonColorPicker.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/ColorPicker.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/StaticColorPicker.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/TextFieldColorPicker.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/AlphaControl/AlphaControl.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/AlphaControl/AlphaControl.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/HueControl/HueControl.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/HueControl/HueControl.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/Interactive/Interactive.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/Interactive/Interactive.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/SaturationControl/SaturationControl.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/components/SaturationControl/SaturationControl.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/helpers/index.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/hooks/useColorManipulation.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/hooks/useEventCallback.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Alpha/Alpha.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Alpha/Alpha.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/HexInput/HexInput.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Hue/Hue.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Hue/Hue.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Interactive/Interactive.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Pointer/Pointer.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Pointer/Pointer.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/RgbaInput/RgbaInput.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Saturation/Saturation.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/ColorPicker/internal/Saturation/Saturation.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/CustomActionBar/CustomActionBar.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/DurationField/DurationField.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/DurationField/helpers/index.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/GridList/GridList.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/GridList/GridList.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DatePicker.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/TimePicker.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/helpers/index.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/constants/index.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/InlineEdit/helpers/index.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.module.scss create mode 100644 anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/JSONEditor/helpers/index.ts create mode 100644 anyclip/src/mui/components/@extendedComponents/JSONEditor/helpers/prismJson.js create mode 100644 anyclip/src/mui/components/@extendedComponents/NumberField/NumberField.tsx create mode 100644 anyclip/src/mui/components/@extendedComponents/UserAvatar/UserAvatar.tsx create mode 100644 anyclip/src/mui/components/Accordion/Accordion.api.tsx create mode 100644 anyclip/src/mui/components/Accordion/Accordion.tsx create mode 100644 anyclip/src/mui/components/AccordionActions/AccordionActions.api.tsx create mode 100644 anyclip/src/mui/components/AccordionDetails/AccordionDetails.api.tsx create mode 100644 anyclip/src/mui/components/AccordionDetails/AccordionDetails.tsx create mode 100644 anyclip/src/mui/components/AccordionSummary/AccordionSummary.api.tsx create mode 100644 anyclip/src/mui/components/AccordionSummary/AccordionSummary.tsx create mode 100644 anyclip/src/mui/components/Alert/Alert.api.tsx create mode 100644 anyclip/src/mui/components/Alert/Alert.tsx create mode 100644 anyclip/src/mui/components/AlertTitle/AlertTitle.api.tsx create mode 100644 anyclip/src/mui/components/AlertTitle/AlertTitle.tsx create mode 100644 anyclip/src/mui/components/AppBar/AppBar.api.tsx create mode 100644 anyclip/src/mui/components/Autocomplete/Autocomplete.api.tsx create mode 100644 anyclip/src/mui/components/Autocomplete/Autocomplete.tsx create mode 100644 anyclip/src/mui/components/Avatar/Avatar.api.tsx create mode 100644 anyclip/src/mui/components/Avatar/Avatar.tsx create mode 100644 anyclip/src/mui/components/AvatarGroup/AvatarGroup.api.tsx create mode 100644 anyclip/src/mui/components/AvatarGroup/AvatarGroup.tsx create mode 100644 anyclip/src/mui/components/Backdrop/Backdrop.api.tsx create mode 100644 anyclip/src/mui/components/Badge/Badge.api.tsx create mode 100644 anyclip/src/mui/components/Badge/Badge.tsx create mode 100644 anyclip/src/mui/components/BottomNavigation/BottomNavigation.api.tsx create mode 100644 anyclip/src/mui/components/BottomNavigation/BottomNavigation.tsx create mode 100644 anyclip/src/mui/components/BottomNavigationAction/BottomNavigationAction.api.tsx create mode 100644 anyclip/src/mui/components/BottomNavigationAction/BottomNavigationAction.tsx create mode 100644 anyclip/src/mui/components/Box/Box.tsx create mode 100644 anyclip/src/mui/components/Breadcrumbs/Breadcrumbs.api.tsx create mode 100644 anyclip/src/mui/components/Breadcrumbs/Breadcrumbs.tsx create mode 100644 anyclip/src/mui/components/Button/Button.api.tsx create mode 100644 anyclip/src/mui/components/Button/Button.tsx create mode 100644 anyclip/src/mui/components/ButtonBase/ButtonBase.api.tsx create mode 100644 anyclip/src/mui/components/ButtonGroup/ButtonGroup.api.tsx create mode 100644 anyclip/src/mui/components/ButtonGroup/ButtonGroup.tsx create mode 100644 anyclip/src/mui/components/Card/Card.api.tsx create mode 100644 anyclip/src/mui/components/Card/Card.tsx create mode 100644 anyclip/src/mui/components/CardActionArea/CardActionArea.api.tsx create mode 100644 anyclip/src/mui/components/CardActions/CardActions.api.tsx create mode 100644 anyclip/src/mui/components/CardContent/CardContent.api.tsx create mode 100644 anyclip/src/mui/components/CardHeader/CardHeader.api.tsx create mode 100644 anyclip/src/mui/components/CardMedia/CardMedia.api.tsx create mode 100644 anyclip/src/mui/components/CardMedia/CardMedia.tsx create mode 100644 anyclip/src/mui/components/Checkbox/Checkbox.api.tsx create mode 100644 anyclip/src/mui/components/Checkbox/Checkbox.tsx create mode 100644 anyclip/src/mui/components/Chip/Chip.api.tsx create mode 100644 anyclip/src/mui/components/Chip/Chip.module.scss create mode 100644 anyclip/src/mui/components/Chip/Chip.tsx create mode 100644 anyclip/src/mui/components/CircularProgress/CircularProgress.api.tsx create mode 100644 anyclip/src/mui/components/CircularProgress/CircularProgress.tsx create mode 100644 anyclip/src/mui/components/ClickAwayListener/ClickAwayListener.tsx create mode 100644 anyclip/src/mui/components/Collapse/Collapse.api.tsx create mode 100644 anyclip/src/mui/components/Collapse/Collapse.tsx create mode 100644 anyclip/src/mui/components/Container/Container.api.tsx create mode 100644 anyclip/src/mui/components/CssBaseline/CssBaseline.api.tsx create mode 100644 anyclip/src/mui/components/CssBaseline/CssBaseline.tsx create mode 100644 anyclip/src/mui/components/CustomIcon/index.tsx create mode 100644 anyclip/src/mui/components/DataGridPro/DataGridPro.jsx create mode 100644 anyclip/src/mui/components/DataGridPro/components/DateCell/View.tsx create mode 100644 anyclip/src/mui/components/DataGridPro/components/DateTimeCell/View.tsx create mode 100644 anyclip/src/mui/components/DatePicker/DatePicker.tsx create mode 100644 anyclip/src/mui/components/DatePicker/StaticDatePicker.tsx create mode 100644 anyclip/src/mui/components/DateRangePicker/DateRangePicker.tsx create mode 100644 anyclip/src/mui/components/DateRangePicker/StaticDateRangePicker.tsx create mode 100644 anyclip/src/mui/components/DateTimePicker/DateTimePicker.tsx create mode 100644 anyclip/src/mui/components/DateTimePicker/StaticDateTimePicker.tsx create mode 100644 anyclip/src/mui/components/DateTimeRangePicker/DateTimeRangePicker.tsx create mode 100644 anyclip/src/mui/components/DateTimeRangePicker/StaticDateTimeRangePicker.tsx create mode 100644 anyclip/src/mui/components/Dialog/Dialog.api.tsx create mode 100644 anyclip/src/mui/components/Dialog/Dialog.tsx create mode 100644 anyclip/src/mui/components/DialogActions/DialogActions.api.tsx create mode 100644 anyclip/src/mui/components/DialogActions/DialogActions.tsx create mode 100644 anyclip/src/mui/components/DialogContent/DialogContent.api.tsx create mode 100644 anyclip/src/mui/components/DialogContent/DialogContent.tsx create mode 100644 anyclip/src/mui/components/DialogContentText/DialogContentText.api.tsx create mode 100644 anyclip/src/mui/components/DialogContentText/DialogContentText.tsx create mode 100644 anyclip/src/mui/components/DialogTitle/DialogTitle.api.tsx create mode 100644 anyclip/src/mui/components/DialogTitle/DialogTitle.tsx create mode 100644 anyclip/src/mui/components/Divider/Divider.api.tsx create mode 100644 anyclip/src/mui/components/Divider/Divider.tsx create mode 100644 anyclip/src/mui/components/Drawer/Drawer.api.tsx create mode 100644 anyclip/src/mui/components/Fab/Fab.api.tsx create mode 100644 anyclip/src/mui/components/Fab/Fab.tsx create mode 100644 anyclip/src/mui/components/FilledInput/FilledInput.api.tsx create mode 100644 anyclip/src/mui/components/FormControl/FormControl.api.tsx create mode 100644 anyclip/src/mui/components/FormControl/FormControl.tsx create mode 100644 anyclip/src/mui/components/FormControlLabel/FormControlLabel.api.tsx create mode 100644 anyclip/src/mui/components/FormControlLabel/FormControlLabel.tsx create mode 100644 anyclip/src/mui/components/FormGroup/FormGroup.api.tsx create mode 100644 anyclip/src/mui/components/FormHelperText/FormHelperText.api.tsx create mode 100644 anyclip/src/mui/components/FormHelperText/FormHelperText.tsx create mode 100644 anyclip/src/mui/components/FormLabel/FormLabel.api.tsx create mode 100644 anyclip/src/mui/components/FormLabel/FormLabel.tsx create mode 100644 anyclip/src/mui/components/Grid/Grid.api.tsx create mode 100644 anyclip/src/mui/components/Grid/Grid.tsx create mode 100644 anyclip/src/mui/components/Icon/Icon.api.tsx create mode 100644 anyclip/src/mui/components/IconButton/IconButton.api.tsx create mode 100644 anyclip/src/mui/components/IconButton/IconButton.tsx create mode 100644 anyclip/src/mui/components/ImageList/ImageList.api.tsx create mode 100644 anyclip/src/mui/components/ImageListItem/ImageListItem.api.tsx create mode 100644 anyclip/src/mui/components/ImageListItemBar/ImageListItemBar.api.tsx create mode 100644 anyclip/src/mui/components/Input/Input.api.tsx create mode 100644 anyclip/src/mui/components/InputAdornment/InputAdornment.api.tsx create mode 100644 anyclip/src/mui/components/InputAdornment/InputAdornment.tsx create mode 100644 anyclip/src/mui/components/InputBase/InputBase.api.tsx create mode 100644 anyclip/src/mui/components/InputLabel/InputLabel.api.tsx create mode 100644 anyclip/src/mui/components/InputLabel/InputLabel.tsx create mode 100644 anyclip/src/mui/components/LinearProgress/LinearProgress.api.tsx create mode 100644 anyclip/src/mui/components/LinearProgress/LinearProgress.tsx create mode 100644 anyclip/src/mui/components/Link/Link.api.tsx create mode 100644 anyclip/src/mui/components/Link/Link.tsx create mode 100644 anyclip/src/mui/components/List/List.api.tsx create mode 100644 anyclip/src/mui/components/List/List.tsx create mode 100644 anyclip/src/mui/components/ListItem/ListItem.api.tsx create mode 100644 anyclip/src/mui/components/ListItem/ListItem.tsx create mode 100644 anyclip/src/mui/components/ListItemAvatar/ListItemAvatar.api.tsx create mode 100644 anyclip/src/mui/components/ListItemButton/ListItemButton.api.tsx create mode 100644 anyclip/src/mui/components/ListItemButton/ListItemButton.tsx create mode 100644 anyclip/src/mui/components/ListItemIcon/ListItemIcon.api.tsx create mode 100644 anyclip/src/mui/components/ListItemIcon/ListItemIcon.tsx create mode 100644 anyclip/src/mui/components/ListItemText/ListItemText.api.tsx create mode 100644 anyclip/src/mui/components/ListItemText/ListItemText.tsx create mode 100644 anyclip/src/mui/components/ListSubheader/ListSubheader.api.tsx create mode 100644 anyclip/src/mui/components/ListSubheader/ListSubheader.tsx create mode 100644 anyclip/src/mui/components/Menu/Menu.api.tsx create mode 100644 anyclip/src/mui/components/Menu/Menu.tsx create mode 100644 anyclip/src/mui/components/MenuItem/MenuItem.api.tsx create mode 100644 anyclip/src/mui/components/MenuItem/MenuItem.tsx create mode 100644 anyclip/src/mui/components/MenuList/MenuList.api.tsx create mode 100644 anyclip/src/mui/components/MobileStepper/MobileStepper.api.tsx create mode 100644 anyclip/src/mui/components/Modal/Modal.api.tsx create mode 100644 anyclip/src/mui/components/MuiOverrides.tsx create mode 100644 anyclip/src/mui/components/NativeSelect/NativeSelect.api.tsx create mode 100644 anyclip/src/mui/components/OutlinedInput/OutlinedInput.api.tsx create mode 100644 anyclip/src/mui/components/Pagination/Pagination.api.tsx create mode 100644 anyclip/src/mui/components/Pagination/Pagination.tsx create mode 100644 anyclip/src/mui/components/PaginationItem/PaginationItem.api.tsx create mode 100644 anyclip/src/mui/components/Paper/Paper.api.tsx create mode 100644 anyclip/src/mui/components/Paper/Paper.tsx create mode 100644 anyclip/src/mui/components/Popover/Popover.api.tsx create mode 100644 anyclip/src/mui/components/Popover/Popover.tsx create mode 100644 anyclip/src/mui/components/Popper/Popper.api.tsx create mode 100644 anyclip/src/mui/components/Popper/Popper.tsx create mode 100644 anyclip/src/mui/components/Portal/Portal.api.tsx create mode 100644 anyclip/src/mui/components/Radio/Radio.api.tsx create mode 100644 anyclip/src/mui/components/Radio/Radio.tsx create mode 100644 anyclip/src/mui/components/RadioGroup/RadioGroup.api.tsx create mode 100644 anyclip/src/mui/components/RadioGroup/RadioGroup.tsx create mode 100644 anyclip/src/mui/components/Rating/Rating.api.tsx create mode 100644 anyclip/src/mui/components/Rating/Rating.tsx create mode 100644 anyclip/src/mui/components/Select/Select.api.tsx create mode 100644 anyclip/src/mui/components/Select/Select.tsx create mode 100644 anyclip/src/mui/components/Skeleton/Skeleton.api.tsx create mode 100644 anyclip/src/mui/components/Skeleton/Skeleton.tsx create mode 100644 anyclip/src/mui/components/Slide/Slide.api.tsx create mode 100644 anyclip/src/mui/components/Slider/Slider.api.tsx create mode 100644 anyclip/src/mui/components/Slider/Slider.tsx create mode 100644 anyclip/src/mui/components/Snackbar/Snackbar.api.tsx create mode 100644 anyclip/src/mui/components/Snackbar/Snackbar.tsx create mode 100644 anyclip/src/mui/components/Snackbar/SnackbarGroup.tsx create mode 100644 anyclip/src/mui/components/Snackbar/SnackbarItem.tsx create mode 100644 anyclip/src/mui/components/SnackbarContent/SnackbarContent.api.tsx create mode 100644 anyclip/src/mui/components/SpeedDial/SpeedDial.api.tsx create mode 100644 anyclip/src/mui/components/SpeedDialAction/SpeedDialAction.api.tsx create mode 100644 anyclip/src/mui/components/SpeedDialIcon/SpeedDialIcon.api.tsx create mode 100644 anyclip/src/mui/components/Stack/Stack.api.tsx create mode 100644 anyclip/src/mui/components/Stack/Stack.tsx create mode 100644 anyclip/src/mui/components/Step/Step.api.tsx create mode 100644 anyclip/src/mui/components/Step/Step.tsx create mode 100644 anyclip/src/mui/components/StepButton/StepButton.api.tsx create mode 100644 anyclip/src/mui/components/StepButton/StepButton.tsx create mode 100644 anyclip/src/mui/components/StepConnector/StepConnector.api.tsx create mode 100644 anyclip/src/mui/components/StepContent/StepContent.api.tsx create mode 100644 anyclip/src/mui/components/StepIcon/StepIcon.api.tsx create mode 100644 anyclip/src/mui/components/StepLabel/StepLabel.api.tsx create mode 100644 anyclip/src/mui/components/StepLabel/StepLabel.tsx create mode 100644 anyclip/src/mui/components/Stepper/Stepper.api.tsx create mode 100644 anyclip/src/mui/components/Stepper/Stepper.tsx create mode 100644 anyclip/src/mui/components/SvgIcon/SvgIcon.api.tsx create mode 100644 anyclip/src/mui/components/SvgIcon/SvgIcon.tsx create mode 100644 anyclip/src/mui/components/SwipeableDrawer/SwipeableDrawer.api.tsx create mode 100644 anyclip/src/mui/components/Switch/Switch.api.tsx create mode 100644 anyclip/src/mui/components/Switch/Switch.tsx create mode 100644 anyclip/src/mui/components/Tab/Tab.api.tsx create mode 100644 anyclip/src/mui/components/Tab/Tab.tsx create mode 100644 anyclip/src/mui/components/TabContent/TabContent.tsx create mode 100644 anyclip/src/mui/components/TabScrollButton/TabScrollButton.api.tsx create mode 100644 anyclip/src/mui/components/Table/Table.api.tsx create mode 100644 anyclip/src/mui/components/Table/Table.tsx create mode 100644 anyclip/src/mui/components/TableBody/TableBody.api.tsx create mode 100644 anyclip/src/mui/components/TableBody/TableBody.tsx create mode 100644 anyclip/src/mui/components/TableCell/TableCell.api.tsx create mode 100644 anyclip/src/mui/components/TableCell/TableCell.tsx create mode 100644 anyclip/src/mui/components/TableContainer/TableContainer.api.tsx create mode 100644 anyclip/src/mui/components/TableContainer/TableContainer.tsx create mode 100644 anyclip/src/mui/components/TableFooter/TableFooter.api.tsx create mode 100644 anyclip/src/mui/components/TableHead/TableHead.api.tsx create mode 100644 anyclip/src/mui/components/TableHead/TableHead.tsx create mode 100644 anyclip/src/mui/components/TablePagination/TablePagination.api.tsx create mode 100644 anyclip/src/mui/components/TablePagination/TablePagination.tsx create mode 100644 anyclip/src/mui/components/TableRow/TableRow.api.tsx create mode 100644 anyclip/src/mui/components/TableRow/TableRow.tsx create mode 100644 anyclip/src/mui/components/TableScroll/TableScroll.module.scss create mode 100644 anyclip/src/mui/components/TableScroll/TableScroll.tsx create mode 100644 anyclip/src/mui/components/TableSortLabel/TableSortLabel.api.tsx create mode 100644 anyclip/src/mui/components/TableSortLabel/TableSortLabel.tsx create mode 100644 anyclip/src/mui/components/Tabs/Tabs.api.tsx create mode 100644 anyclip/src/mui/components/Tabs/Tabs.tsx create mode 100644 anyclip/src/mui/components/TextField/TextField.api.tsx create mode 100644 anyclip/src/mui/components/TextField/TextField.tsx create mode 100644 anyclip/src/mui/components/TimeField/TimeField.tsx create mode 100644 anyclip/src/mui/components/TimePicker/StaticTimePicker.tsx create mode 100644 anyclip/src/mui/components/TimePicker/TimePicker.tsx create mode 100644 anyclip/src/mui/components/TimeRangePicker/StaticTimeRangePicker.tsx create mode 100644 anyclip/src/mui/components/TimeRangePicker/TimeRangePicker.tsx create mode 100644 anyclip/src/mui/components/ToggleButton/ToggleButton.api.tsx create mode 100644 anyclip/src/mui/components/ToggleButton/ToggleButton.tsx create mode 100644 anyclip/src/mui/components/ToggleButtonGroup/ToggleButtonGroup.api.tsx create mode 100644 anyclip/src/mui/components/ToggleButtonGroup/ToggleButtonGroup.tsx create mode 100644 anyclip/src/mui/components/Toolbar/Toolbar.api.tsx create mode 100644 anyclip/src/mui/components/Tooltip/Tooltip.api.tsx create mode 100644 anyclip/src/mui/components/Tooltip/Tooltip.tsx create mode 100644 anyclip/src/mui/components/TreeView/TreeView.tsx create mode 100644 anyclip/src/mui/components/TreeView/TreeViewItem.module.scss create mode 100644 anyclip/src/mui/components/TreeView/TreeViewItem.tsx create mode 100644 anyclip/src/mui/components/Typography/Typography.api.tsx create mode 100644 anyclip/src/mui/components/Typography/Typography.tsx create mode 100644 anyclip/src/mui/components/constants/index.ts create mode 100644 anyclip/src/mui/components/index.ts create mode 100644 anyclip/src/mui/constants/breakpoints.ts create mode 100644 anyclip/src/mui/constants/config.ts create mode 100644 anyclip/src/mui/constants/index.ts create mode 100644 anyclip/src/mui/constants/opacity.ts create mode 100644 anyclip/src/mui/constants/palette/dark/index.ts create mode 100644 anyclip/src/mui/constants/palette/index.ts create mode 100644 anyclip/src/mui/constants/palette/light/index.ts create mode 100644 anyclip/src/mui/constants/shadows.ts create mode 100644 anyclip/src/mui/constants/shape.ts create mode 100644 anyclip/src/mui/constants/treeView.ts create mode 100644 anyclip/src/mui/constants/types.ts create mode 100644 anyclip/src/mui/constants/typography.ts create mode 100644 anyclip/src/mui/constants/zIndex.ts create mode 100644 anyclip/src/mui/customIcons/UnstyledIcons/GoogleIconColored.tsx create mode 100644 anyclip/src/mui/customIcons/UnstyledIcons/MicrosoftIconColored.tsx create mode 100644 anyclip/src/mui/customIcons/UnstyledIcons/OktaIconColored.tsx create mode 100644 anyclip/src/mui/helpers/color.ts create mode 100644 anyclip/src/mui/helpers/cookie.ts create mode 100644 anyclip/src/mui/helpers/index.ts create mode 100644 anyclip/src/mui/helpers/license.tsx create mode 100644 anyclip/src/mui/helpers/treeView.ts create mode 100644 anyclip/src/mui/theme.global.scss create mode 100644 anyclip/src/mui/theme.tsx create mode 100644 anyclip/src/pages/403.tsx create mode 100644 anyclip/src/pages/404.tsx create mode 100644 anyclip/src/pages/_app.tsx create mode 100644 anyclip/src/pages/accounts/[id].tsx create mode 100644 anyclip/src/pages/accounts/index.tsx create mode 100644 anyclip/src/pages/activation-guest.tsx create mode 100644 anyclip/src/pages/ad-servers/[id].tsx create mode 100644 anyclip/src/pages/ad-servers/index.tsx create mode 100644 anyclip/src/pages/advertisers/[id].tsx create mode 100644 anyclip/src/pages/advertisers/index.tsx create mode 100644 anyclip/src/pages/analytics-new/live-events-past.jsx rename {src => anyclip/src}/pages/analytics-new/monetization.jsx (100%) create mode 100644 anyclip/src/pages/analytics-new/revenue-overview.tsx rename {src => anyclip/src}/pages/analytics-new/video-content-performance.jsx (100%) rename {src => anyclip/src}/pages/analytics.tsx (100%) create mode 100644 anyclip/src/pages/auth/cognito.tsx create mode 100644 anyclip/src/pages/auth/facebook.tsx create mode 100644 anyclip/src/pages/auth/google.tsx create mode 100644 anyclip/src/pages/auth/microsoft/[authService].tsx create mode 100644 anyclip/src/pages/auth/tiktok.tsx create mode 100644 anyclip/src/pages/auth/zoom.tsx create mode 100644 anyclip/src/pages/config.tsx create mode 100644 anyclip/src/pages/content-owners/[id].tsx create mode 100644 anyclip/src/pages/content-owners/index.tsx create mode 100644 anyclip/src/pages/create-password.tsx create mode 100644 anyclip/src/pages/custom-reports-new/[...params].jsx rename {src => anyclip/src}/pages/custom-reports-new/index.jsx (100%) create mode 100644 anyclip/src/pages/custom-reports/[id].tsx create mode 100644 anyclip/src/pages/custom-reports/index.tsx create mode 100644 anyclip/src/pages/demand/[...params].jsx rename {src => anyclip/src}/pages/demand/index.jsx (100%) create mode 100644 anyclip/src/pages/design-system/accordion.tsx create mode 100644 anyclip/src/pages/design-system/alert.tsx create mode 100644 anyclip/src/pages/design-system/autocomplete.tsx create mode 100644 anyclip/src/pages/design-system/avatar.tsx create mode 100644 anyclip/src/pages/design-system/badge.tsx create mode 100644 anyclip/src/pages/design-system/bottom-navigation.tsx create mode 100644 anyclip/src/pages/design-system/breadcrumbs.tsx create mode 100644 anyclip/src/pages/design-system/button-group.tsx create mode 100644 anyclip/src/pages/design-system/button.tsx create mode 100644 anyclip/src/pages/design-system/card.tsx create mode 100644 anyclip/src/pages/design-system/checkbox.tsx create mode 100644 anyclip/src/pages/design-system/chip-alt.tsx create mode 100644 anyclip/src/pages/design-system/chip.tsx create mode 100644 anyclip/src/pages/design-system/color-picker.tsx create mode 100644 anyclip/src/pages/design-system/compare.tsx create mode 100644 anyclip/src/pages/design-system/data-grid.tsx create mode 100644 anyclip/src/pages/design-system/date-picker.tsx create mode 100644 anyclip/src/pages/design-system/date-range-picker.tsx create mode 100644 anyclip/src/pages/design-system/date-time-picker.tsx create mode 100644 anyclip/src/pages/design-system/date-time-range-picker.tsx create mode 100644 anyclip/src/pages/design-system/dialog.tsx create mode 100644 anyclip/src/pages/design-system/duration-field.tsx create mode 100644 anyclip/src/pages/design-system/fab.tsx create mode 100644 anyclip/src/pages/design-system/forms.tsx create mode 100644 anyclip/src/pages/design-system/grid-list.tsx create mode 100644 anyclip/src/pages/design-system/icon-button.tsx create mode 100644 anyclip/src/pages/design-system/icon.tsx create mode 100644 anyclip/src/pages/design-system/inline-edit/autocomplete.tsx create mode 100644 anyclip/src/pages/design-system/inline-edit/date-time-picker.tsx create mode 100644 anyclip/src/pages/design-system/inline-edit/text-field.tsx create mode 100644 anyclip/src/pages/design-system/json-editor.tsx create mode 100644 anyclip/src/pages/design-system/list.tsx create mode 100644 anyclip/src/pages/design-system/menu.tsx create mode 100644 anyclip/src/pages/design-system/number-field.tsx create mode 100644 anyclip/src/pages/design-system/pagination.tsx create mode 100644 anyclip/src/pages/design-system/progress.tsx create mode 100644 anyclip/src/pages/design-system/radio.tsx create mode 100644 anyclip/src/pages/design-system/rating.tsx create mode 100644 anyclip/src/pages/design-system/select.tsx create mode 100644 anyclip/src/pages/design-system/skeleton.tsx create mode 100644 anyclip/src/pages/design-system/slider.tsx create mode 100644 anyclip/src/pages/design-system/snackbar.tsx create mode 100644 anyclip/src/pages/design-system/stepper.tsx create mode 100644 anyclip/src/pages/design-system/switch.tsx create mode 100644 anyclip/src/pages/design-system/tab.tsx create mode 100644 anyclip/src/pages/design-system/table.tsx create mode 100644 anyclip/src/pages/design-system/text-field.tsx create mode 100644 anyclip/src/pages/design-system/time-picker.tsx create mode 100644 anyclip/src/pages/design-system/time-range-picker.tsx create mode 100644 anyclip/src/pages/design-system/toggle-button.tsx create mode 100644 anyclip/src/pages/design-system/tooltip.tsx create mode 100644 anyclip/src/pages/design-system/tree-view.tsx create mode 100644 anyclip/src/pages/design-system/typography.tsx create mode 100644 anyclip/src/pages/entities/index.jsx create mode 100644 anyclip/src/pages/feeds/[[...params]].tsx create mode 100644 anyclip/src/pages/feeds/[id]/csv.tsx create mode 100644 anyclip/src/pages/feeds/[id]/manual.tsx create mode 100644 anyclip/src/pages/feeds/[id]/mrss.tsx create mode 100644 anyclip/src/pages/feeds/[id]/ms-stream.tsx create mode 100644 anyclip/src/pages/feeds/[id]/rss.tsx create mode 100644 anyclip/src/pages/feeds/[id]/sitemap.tsx create mode 100644 anyclip/src/pages/feeds/[id]/story-api.tsx create mode 100644 anyclip/src/pages/feeds/[id]/tiktok.tsx create mode 100644 anyclip/src/pages/feeds/[id]/video-api.tsx create mode 100644 anyclip/src/pages/feeds/[id]/vimeo.tsx create mode 100644 anyclip/src/pages/feeds/[id]/youtube.tsx create mode 100644 anyclip/src/pages/forgot-password.tsx create mode 100644 anyclip/src/pages/form-templates/[...params].tsx create mode 100644 anyclip/src/pages/form-templates/index.tsx create mode 100644 anyclip/src/pages/forms/[...params].tsx create mode 100644 anyclip/src/pages/forms/_preview.tsx rename {src => anyclip/src}/pages/forms/index.tsx (100%) create mode 100644 anyclip/src/pages/hb-connectors/[...params].jsx create mode 100644 anyclip/src/pages/hb-connectors/index.jsx create mode 100644 anyclip/src/pages/hubs/[id].tsx rename {src => anyclip/src}/pages/hubs/index.tsx (100%) create mode 100644 anyclip/src/pages/index.tsx create mode 100644 anyclip/src/pages/inventory/[[...params]].tsx rename {src => anyclip/src}/pages/invitations/index.tsx (100%) create mode 100644 anyclip/src/pages/key-lists/keys/[id].jsx create mode 100644 anyclip/src/pages/key-lists/keys/index.jsx create mode 100644 anyclip/src/pages/key-lists/lists/[id].jsx create mode 100644 anyclip/src/pages/key-lists/lists/index.jsx create mode 100644 anyclip/src/pages/live/[id].tsx create mode 100644 anyclip/src/pages/live/_preview.tsx create mode 100644 anyclip/src/pages/live/index.tsx create mode 100644 anyclip/src/pages/login.tsx create mode 100644 anyclip/src/pages/logout.tsx create mode 100644 anyclip/src/pages/marketplace-dashboard.tsx create mode 100644 anyclip/src/pages/notifications/[[...params]].tsx create mode 100644 anyclip/src/pages/online-help/[id].tsx create mode 100644 anyclip/src/pages/online-help/index.tsx create mode 100644 anyclip/src/pages/passwordless-login-code.tsx create mode 100644 anyclip/src/pages/passwordless-login.tsx create mode 100644 anyclip/src/pages/permissions/[[...params]].tsx rename {src => anyclip/src}/pages/personal-settings.tsx (100%) create mode 100644 anyclip/src/pages/player-old/[[...params]].tsx create mode 100644 anyclip/src/pages/player/[...params].tsx create mode 100644 anyclip/src/pages/player/_preview.tsx create mode 100644 anyclip/src/pages/player/index.tsx create mode 100644 anyclip/src/pages/publishing/[platform]/[id].tsx create mode 100644 anyclip/src/pages/publishing/index.tsx create mode 100644 anyclip/src/pages/reset-password.tsx create mode 100644 anyclip/src/pages/roles-permissions/[id].tsx create mode 100644 anyclip/src/pages/roles-permissions/[id]/duplicate.tsx create mode 100644 anyclip/src/pages/roles-permissions/index.tsx create mode 100644 anyclip/src/pages/sso-login.tsx create mode 100644 anyclip/src/pages/sso/[id].tsx create mode 100644 anyclip/src/pages/sso/index.tsx rename {src => anyclip/src}/pages/studio.tsx (100%) create mode 100644 anyclip/src/pages/supply/[...params].jsx create mode 100644 anyclip/src/pages/supply/index.jsx create mode 100644 anyclip/src/pages/user-auth-error.tsx create mode 100644 anyclip/src/pages/users/[id].tsx rename {src => anyclip/src}/pages/users/index.tsx (100%) create mode 100644 anyclip/src/pages/watch/[[...params]].tsx create mode 100644 anyclip/src/pages/x-ray/campaigns/[id].tsx rename {src => anyclip/src}/pages/x-ray/campaigns/index.tsx (100%) create mode 100644 anyclip/src/pages/x-ray/creatives/[id].tsx create mode 100644 anyclip/src/pages/x-ray/creatives/[id]/duplicate.tsx rename {src => anyclip/src}/pages/x-ray/creatives/index.tsx (100%) create mode 100644 anyclip/src/pages/x-ray/line-items/[id].tsx create mode 100644 anyclip/src/pages/x-ray/line-items/[id]/duplicate.tsx rename {src => anyclip/src}/pages/x-ray/line-items/index.tsx (100%) create mode 100644 anyclip/src/rootEpics.ts create mode 100644 anyclip/src/rootReducer.ts create mode 100644 anyclip/src/session.js rename {src => anyclip/src}/shared/lib/amp-context.shared-runtime.ts (100%) rename {src => anyclip/src}/shared/lib/amp-mode.ts (100%) rename {src => anyclip/src}/shared/lib/app-router-context.shared-runtime.ts (100%) rename {src => anyclip/src}/shared/lib/bloom-filter.ts (100%) rename {src => anyclip/src}/shared/lib/constants.ts (100%) create mode 100644 anyclip/src/shared/lib/dynamic.tsx rename {src => anyclip/src}/shared/lib/encode-uri-path.ts (100%) rename {src => anyclip/src}/shared/lib/escape-regexp.ts (100%) create mode 100644 anyclip/src/shared/lib/get-img-props.ts rename {src => anyclip/src}/shared/lib/head-manager-context.shared-runtime.ts (100%) rename {src => anyclip/src}/shared/lib/head.tsx (100%) rename {src => anyclip/src}/shared/lib/hooks-client-context.shared-runtime.ts (100%) rename {src => anyclip/src}/shared/lib/i18n/normalize-locale-path.ts (100%) create mode 100644 anyclip/src/shared/lib/image-blur-svg.ts rename {src => anyclip/src}/shared/lib/image-config-context.shared-runtime.ts (100%) rename {src => anyclip/src}/shared/lib/image-config.ts (100%) create mode 100644 anyclip/src/shared/lib/image-external.tsx create mode 100644 anyclip/src/shared/lib/image-loader.ts rename {src => anyclip/src}/shared/lib/is-plain-object.ts (100%) rename {src => anyclip/src}/shared/lib/lazy-dynamic/bailout-to-csr.ts (100%) create mode 100644 anyclip/src/shared/lib/loadable-context.shared-runtime.ts create mode 100644 anyclip/src/shared/lib/loadable.shared-runtime.tsx rename {src => anyclip/src}/shared/lib/mitt.ts (100%) rename {src => anyclip/src}/shared/lib/modern-browserslist-target.js (100%) rename {src => anyclip/src}/shared/lib/page-path/denormalize-page-path.ts (100%) rename {src => anyclip/src}/shared/lib/page-path/ensure-leading-slash.ts (100%) rename {src => anyclip/src}/shared/lib/page-path/normalize-path-sep.ts (100%) rename {src => anyclip/src}/shared/lib/router-context.shared-runtime.ts (100%) rename {src => anyclip/src}/shared/lib/router/adapters.tsx (100%) rename {src => anyclip/src}/shared/lib/router/router.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/add-locale.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/add-path-prefix.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/add-path-suffix.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/app-paths.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/as-path-to-search-params.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/compare-states.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/disable-smooth-scroll.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/format-next-pathname-info.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/format-url.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/get-asset-path-from-route.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/get-dynamic-param.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/get-next-pathname-info.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/html-bots.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/index.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/interception-routes.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/interpolate-as.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/is-bot.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/is-dynamic.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/is-local-url.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/omit.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/parse-path.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/parse-relative-url.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/parse-url.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/path-has-prefix.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/path-match.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/prepare-destination.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/querystring.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/remove-path-prefix.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/remove-trailing-slash.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/resolve-rewrites.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/route-match-utils.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/route-matcher.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/route-regex.ts (100%) rename {src => anyclip/src}/shared/lib/router/utils/sorted-routes.ts (100%) rename {src => anyclip/src}/shared/lib/runtime-config.external.ts (100%) rename {src => anyclip/src}/shared/lib/segment.ts (100%) create mode 100644 anyclip/src/shared/lib/server-inserted-html.shared-runtime.tsx rename {src => anyclip/src}/shared/lib/side-effect.tsx (100%) rename {src => anyclip/src}/shared/lib/utils.ts (100%) create mode 100644 anyclip/src/shared/lib/utils/error-once.ts rename {src => anyclip/src}/shared/lib/utils/warn-once.ts (100%) create mode 100644 anyclip/webpack/bootstrap.js create mode 100644 anyclip/webpack/runtime/chunk-loaded.js create mode 100644 anyclip/webpack/runtime/compat-get-default-export.js create mode 100644 anyclip/webpack/runtime/create-fake-namespace-object.js create mode 100644 anyclip/webpack/runtime/css-loading.js create mode 100644 anyclip/webpack/runtime/define-property-getters.js create mode 100644 anyclip/webpack/runtime/ensure-chunk.js create mode 100644 anyclip/webpack/runtime/get-javascript-chunk-filename.js create mode 100644 anyclip/webpack/runtime/get-mini-css-chunk-filename.js create mode 100644 anyclip/webpack/runtime/global.js create mode 100644 anyclip/webpack/runtime/hasOwnProperty-shorthand.js create mode 100644 anyclip/webpack/runtime/jsonp-chunk-loading.js create mode 100644 anyclip/webpack/runtime/load-script.js create mode 100644 anyclip/webpack/runtime/make-namespace-object.js create mode 100644 anyclip/webpack/runtime/node-module-decorator.js create mode 100644 anyclip/webpack/runtime/publicPath.js create mode 100644 anyclip/webpack/runtime/trusted-types-policy.js create mode 100644 anyclip/webpack/runtime/trusted-types-script-url.js create mode 100644 bun.lock create mode 100644 docs/auth.md create mode 100644 docs/monetization-api.md create mode 100644 package.json create mode 100644 scripts/auth.ts create mode 100644 scripts/crypto-subtle.ts create mode 100644 scripts/crypto.ts create mode 100644 scripts/download-sourcemaps.ts create mode 100644 scripts/extract-login-code.ts create mode 100644 scripts/extract-sources.ts create mode 100644 scripts/intercept-monetization.ts create mode 100644 scripts/test-auth-final.ts create mode 100644 scripts/update-urls.ts delete mode 100644 src/modules/common/Table/index.jsx delete mode 100644 src/modules/marketplace/common/Table/Table.module.scss delete mode 100644 src/modules/marketplace/common/Table/index.jsx create mode 100644 tsconfig.json delete mode 100644 vendor/node_modules/d3-array/src/ascending.js delete mode 100644 vendor/node_modules/d3-array/src/bisect.js delete mode 100644 vendor/node_modules/d3-array/src/bisector.js delete mode 100644 vendor/node_modules/d3-array/src/descending.js delete mode 100644 vendor/node_modules/d3-array/src/max.js delete mode 100644 vendor/node_modules/d3-array/src/min.js delete mode 100644 vendor/node_modules/d3-array/src/number.js delete mode 100644 vendor/node_modules/d3-array/src/quantile.js delete mode 100644 vendor/node_modules/d3-array/src/quickselect.js delete mode 100644 vendor/node_modules/d3-array/src/range.js delete mode 100644 vendor/node_modules/d3-array/src/sort.js delete mode 100644 vendor/node_modules/d3-array/src/ticks.js delete mode 100644 vendor/node_modules/d3-color/src/color.js delete mode 100644 vendor/node_modules/d3-color/src/define.js delete mode 100644 vendor/node_modules/d3-format/src/defaultLocale.js delete mode 100644 vendor/node_modules/d3-format/src/exponent.js delete mode 100644 vendor/node_modules/d3-format/src/formatDecimal.js delete mode 100644 vendor/node_modules/d3-format/src/formatGroup.js delete mode 100644 vendor/node_modules/d3-format/src/formatNumerals.js delete mode 100644 vendor/node_modules/d3-format/src/formatPrefixAuto.js delete mode 100644 vendor/node_modules/d3-format/src/formatRounded.js delete mode 100644 vendor/node_modules/d3-format/src/formatSpecifier.js delete mode 100644 vendor/node_modules/d3-format/src/formatTrim.js delete mode 100644 vendor/node_modules/d3-format/src/formatTypes.js delete mode 100644 vendor/node_modules/d3-format/src/identity.js delete mode 100644 vendor/node_modules/d3-format/src/locale.js delete mode 100644 vendor/node_modules/d3-format/src/precisionFixed.js delete mode 100644 vendor/node_modules/d3-format/src/precisionPrefix.js delete mode 100644 vendor/node_modules/d3-format/src/precisionRound.js delete mode 100644 vendor/node_modules/d3-interpolate/src/array.js delete mode 100644 vendor/node_modules/d3-interpolate/src/basis.js delete mode 100644 vendor/node_modules/d3-interpolate/src/basisClosed.js delete mode 100644 vendor/node_modules/d3-interpolate/src/color.js delete mode 100644 vendor/node_modules/d3-interpolate/src/constant.js delete mode 100644 vendor/node_modules/d3-interpolate/src/date.js delete mode 100644 vendor/node_modules/d3-interpolate/src/number.js delete mode 100644 vendor/node_modules/d3-interpolate/src/numberArray.js delete mode 100644 vendor/node_modules/d3-interpolate/src/object.js delete mode 100644 vendor/node_modules/d3-interpolate/src/piecewise.js delete mode 100644 vendor/node_modules/d3-interpolate/src/rgb.js delete mode 100644 vendor/node_modules/d3-interpolate/src/round.js delete mode 100644 vendor/node_modules/d3-interpolate/src/string.js delete mode 100644 vendor/node_modules/d3-interpolate/src/value.js delete mode 100644 vendor/node_modules/d3-path/src/path.js delete mode 100644 vendor/node_modules/d3-scale/src/band.js delete mode 100644 vendor/node_modules/d3-scale/src/constant.js delete mode 100644 vendor/node_modules/d3-scale/src/continuous.js delete mode 100644 vendor/node_modules/d3-scale/src/diverging.js delete mode 100644 vendor/node_modules/d3-scale/src/identity.js delete mode 100644 vendor/node_modules/d3-scale/src/index.js delete mode 100644 vendor/node_modules/d3-scale/src/init.js delete mode 100644 vendor/node_modules/d3-scale/src/linear.js delete mode 100644 vendor/node_modules/d3-scale/src/log.js delete mode 100644 vendor/node_modules/d3-scale/src/nice.js delete mode 100644 vendor/node_modules/d3-scale/src/number.js delete mode 100644 vendor/node_modules/d3-scale/src/ordinal.js delete mode 100644 vendor/node_modules/d3-scale/src/pow.js delete mode 100644 vendor/node_modules/d3-scale/src/quantile.js delete mode 100644 vendor/node_modules/d3-scale/src/quantize.js delete mode 100644 vendor/node_modules/d3-scale/src/radial.js delete mode 100644 vendor/node_modules/d3-scale/src/sequential.js delete mode 100644 vendor/node_modules/d3-scale/src/sequentialQuantile.js delete mode 100644 vendor/node_modules/d3-scale/src/symlog.js delete mode 100644 vendor/node_modules/d3-scale/src/threshold.js delete mode 100644 vendor/node_modules/d3-scale/src/tickFormat.js delete mode 100644 vendor/node_modules/d3-scale/src/time.js delete mode 100644 vendor/node_modules/d3-scale/src/utcTime.js delete mode 100644 vendor/node_modules/d3-shape/src/area.js delete mode 100644 vendor/node_modules/d3-shape/src/array.js delete mode 100644 vendor/node_modules/d3-shape/src/constant.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/basis.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/basisClosed.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/basisOpen.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/bump.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/linear.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/linearClosed.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/monotone.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/natural.js delete mode 100644 vendor/node_modules/d3-shape/src/curve/step.js delete mode 100644 vendor/node_modules/d3-shape/src/line.js delete mode 100644 vendor/node_modules/d3-shape/src/math.js delete mode 100644 vendor/node_modules/d3-shape/src/noop.js delete mode 100644 vendor/node_modules/d3-shape/src/offset/expand.js delete mode 100644 vendor/node_modules/d3-shape/src/offset/none.js delete mode 100644 vendor/node_modules/d3-shape/src/offset/silhouette.js delete mode 100644 vendor/node_modules/d3-shape/src/offset/wiggle.js delete mode 100644 vendor/node_modules/d3-shape/src/order/none.js delete mode 100644 vendor/node_modules/d3-shape/src/path.js delete mode 100644 vendor/node_modules/d3-shape/src/point.js delete mode 100644 vendor/node_modules/d3-shape/src/stack.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/asterisk.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/circle.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/cross.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/diamond.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/diamond2.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/plus.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/square.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/square2.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/star.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/times.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/triangle.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/triangle2.js delete mode 100644 vendor/node_modules/d3-shape/src/symbol/wye.js delete mode 100644 vendor/node_modules/d3-time-format/src/defaultLocale.js delete mode 100644 vendor/node_modules/d3-time-format/src/locale.js delete mode 100644 vendor/node_modules/d3-time/src/day.js delete mode 100644 vendor/node_modules/d3-time/src/duration.js delete mode 100644 vendor/node_modules/d3-time/src/hour.js delete mode 100644 vendor/node_modules/d3-time/src/interval.js delete mode 100644 vendor/node_modules/d3-time/src/millisecond.js delete mode 100644 vendor/node_modules/d3-time/src/minute.js delete mode 100644 vendor/node_modules/d3-time/src/month.js delete mode 100644 vendor/node_modules/d3-time/src/second.js delete mode 100644 vendor/node_modules/d3-time/src/ticks.js delete mode 100644 vendor/node_modules/d3-time/src/week.js delete mode 100644 vendor/node_modules/d3-time/src/year.js delete mode 100644 vendor/node_modules/dayjs/plugin/isSameOrAfter.js delete mode 100644 vendor/node_modules/decimal.js-light/decimal.js delete mode 100644 vendor/node_modules/dnd-kit/accessibility/dist/accessibility.esm.js delete mode 100644 vendor/node_modules/dnd-kit/core/dist/core.esm.js delete mode 100644 vendor/node_modules/dnd-kit/utilities/dist/utilities.esm.js delete mode 100644 vendor/node_modules/eventemitter3/index.js delete mode 100644 vendor/node_modules/fast-equals/dist/esm/index.mjs delete mode 100644 vendor/node_modules/internmap/src/index.js delete mode 100644 vendor/node_modules/lodash/_DataView.js delete mode 100644 vendor/node_modules/lodash/_Hash.js delete mode 100644 vendor/node_modules/lodash/_ListCache.js delete mode 100644 vendor/node_modules/lodash/_Map.js delete mode 100644 vendor/node_modules/lodash/_MapCache.js delete mode 100644 vendor/node_modules/lodash/_Promise.js delete mode 100644 vendor/node_modules/lodash/_Set.js delete mode 100644 vendor/node_modules/lodash/_SetCache.js delete mode 100644 vendor/node_modules/lodash/_Stack.js delete mode 100644 vendor/node_modules/lodash/_Symbol.js delete mode 100644 vendor/node_modules/lodash/_Uint8Array.js delete mode 100644 vendor/node_modules/lodash/_WeakMap.js delete mode 100644 vendor/node_modules/lodash/_apply.js delete mode 100644 vendor/node_modules/lodash/_arrayEvery.js delete mode 100644 vendor/node_modules/lodash/_arrayFilter.js delete mode 100644 vendor/node_modules/lodash/_arrayIncludes.js delete mode 100644 vendor/node_modules/lodash/_arrayIncludesWith.js delete mode 100644 vendor/node_modules/lodash/_arrayLikeKeys.js delete mode 100644 vendor/node_modules/lodash/_arrayMap.js delete mode 100644 vendor/node_modules/lodash/_arrayPush.js delete mode 100644 vendor/node_modules/lodash/_arraySome.js delete mode 100644 vendor/node_modules/lodash/_asciiToArray.js delete mode 100644 vendor/node_modules/lodash/_assocIndexOf.js delete mode 100644 vendor/node_modules/lodash/_baseAssignValue.js delete mode 100644 vendor/node_modules/lodash/_baseEach.js delete mode 100644 vendor/node_modules/lodash/_baseEvery.js delete mode 100644 vendor/node_modules/lodash/_baseExtremum.js delete mode 100644 vendor/node_modules/lodash/_baseFindIndex.js delete mode 100644 vendor/node_modules/lodash/_baseFlatten.js delete mode 100644 vendor/node_modules/lodash/_baseFor.js delete mode 100644 vendor/node_modules/lodash/_baseForOwn.js delete mode 100644 vendor/node_modules/lodash/_baseGet.js delete mode 100644 vendor/node_modules/lodash/_baseGetAllKeys.js delete mode 100644 vendor/node_modules/lodash/_baseGetTag.js delete mode 100644 vendor/node_modules/lodash/_baseGt.js delete mode 100644 vendor/node_modules/lodash/_baseHasIn.js delete mode 100644 vendor/node_modules/lodash/_baseIndexOf.js delete mode 100644 vendor/node_modules/lodash/_baseIsArguments.js delete mode 100644 vendor/node_modules/lodash/_baseIsEqual.js delete mode 100644 vendor/node_modules/lodash/_baseIsEqualDeep.js delete mode 100644 vendor/node_modules/lodash/_baseIsMatch.js delete mode 100644 vendor/node_modules/lodash/_baseIsNaN.js delete mode 100644 vendor/node_modules/lodash/_baseIsNative.js delete mode 100644 vendor/node_modules/lodash/_baseIsTypedArray.js delete mode 100644 vendor/node_modules/lodash/_baseIteratee.js delete mode 100644 vendor/node_modules/lodash/_baseKeys.js delete mode 100644 vendor/node_modules/lodash/_baseLt.js delete mode 100644 vendor/node_modules/lodash/_baseMap.js delete mode 100644 vendor/node_modules/lodash/_baseMatches.js delete mode 100644 vendor/node_modules/lodash/_baseMatchesProperty.js delete mode 100644 vendor/node_modules/lodash/_baseOrderBy.js delete mode 100644 vendor/node_modules/lodash/_baseProperty.js delete mode 100644 vendor/node_modules/lodash/_basePropertyDeep.js delete mode 100644 vendor/node_modules/lodash/_baseRange.js delete mode 100644 vendor/node_modules/lodash/_baseRest.js delete mode 100644 vendor/node_modules/lodash/_baseSetToString.js delete mode 100644 vendor/node_modules/lodash/_baseSlice.js delete mode 100644 vendor/node_modules/lodash/_baseSome.js delete mode 100644 vendor/node_modules/lodash/_baseSortBy.js delete mode 100644 vendor/node_modules/lodash/_baseTimes.js delete mode 100644 vendor/node_modules/lodash/_baseToString.js delete mode 100644 vendor/node_modules/lodash/_baseTrim.js delete mode 100644 vendor/node_modules/lodash/_baseUnary.js delete mode 100644 vendor/node_modules/lodash/_baseUniq.js delete mode 100644 vendor/node_modules/lodash/_cacheHas.js delete mode 100644 vendor/node_modules/lodash/_castPath.js delete mode 100644 vendor/node_modules/lodash/_castSlice.js delete mode 100644 vendor/node_modules/lodash/_compareAscending.js delete mode 100644 vendor/node_modules/lodash/_compareMultiple.js delete mode 100644 vendor/node_modules/lodash/_coreJsData.js delete mode 100644 vendor/node_modules/lodash/_createBaseEach.js delete mode 100644 vendor/node_modules/lodash/_createBaseFor.js delete mode 100644 vendor/node_modules/lodash/_createCaseFirst.js delete mode 100644 vendor/node_modules/lodash/_createFind.js delete mode 100644 vendor/node_modules/lodash/_createRange.js delete mode 100644 vendor/node_modules/lodash/_createSet.js delete mode 100644 vendor/node_modules/lodash/_defineProperty.js delete mode 100644 vendor/node_modules/lodash/_equalArrays.js delete mode 100644 vendor/node_modules/lodash/_equalByTag.js delete mode 100644 vendor/node_modules/lodash/_equalObjects.js delete mode 100644 vendor/node_modules/lodash/_freeGlobal.js delete mode 100644 vendor/node_modules/lodash/_getAllKeys.js delete mode 100644 vendor/node_modules/lodash/_getMapData.js delete mode 100644 vendor/node_modules/lodash/_getMatchData.js delete mode 100644 vendor/node_modules/lodash/_getNative.js delete mode 100644 vendor/node_modules/lodash/_getPrototype.js delete mode 100644 vendor/node_modules/lodash/_getRawTag.js delete mode 100644 vendor/node_modules/lodash/_getSymbols.js delete mode 100644 vendor/node_modules/lodash/_getTag.js delete mode 100644 vendor/node_modules/lodash/_getValue.js delete mode 100644 vendor/node_modules/lodash/_hasPath.js delete mode 100644 vendor/node_modules/lodash/_hasUnicode.js delete mode 100644 vendor/node_modules/lodash/_hashClear.js delete mode 100644 vendor/node_modules/lodash/_hashDelete.js delete mode 100644 vendor/node_modules/lodash/_hashGet.js delete mode 100644 vendor/node_modules/lodash/_hashHas.js delete mode 100644 vendor/node_modules/lodash/_hashSet.js delete mode 100644 vendor/node_modules/lodash/_isFlattenable.js delete mode 100644 vendor/node_modules/lodash/_isIndex.js delete mode 100644 vendor/node_modules/lodash/_isIterateeCall.js delete mode 100644 vendor/node_modules/lodash/_isKey.js delete mode 100644 vendor/node_modules/lodash/_isKeyable.js delete mode 100644 vendor/node_modules/lodash/_isMasked.js delete mode 100644 vendor/node_modules/lodash/_isPrototype.js delete mode 100644 vendor/node_modules/lodash/_isStrictComparable.js delete mode 100644 vendor/node_modules/lodash/_listCacheClear.js delete mode 100644 vendor/node_modules/lodash/_listCacheDelete.js delete mode 100644 vendor/node_modules/lodash/_listCacheGet.js delete mode 100644 vendor/node_modules/lodash/_listCacheHas.js delete mode 100644 vendor/node_modules/lodash/_listCacheSet.js delete mode 100644 vendor/node_modules/lodash/_mapCacheClear.js delete mode 100644 vendor/node_modules/lodash/_mapCacheDelete.js delete mode 100644 vendor/node_modules/lodash/_mapCacheGet.js delete mode 100644 vendor/node_modules/lodash/_mapCacheHas.js delete mode 100644 vendor/node_modules/lodash/_mapCacheSet.js delete mode 100644 vendor/node_modules/lodash/_mapToArray.js delete mode 100644 vendor/node_modules/lodash/_matchesStrictComparable.js delete mode 100644 vendor/node_modules/lodash/_memoizeCapped.js delete mode 100644 vendor/node_modules/lodash/_nativeCreate.js delete mode 100644 vendor/node_modules/lodash/_nativeKeys.js delete mode 100644 vendor/node_modules/lodash/_nodeUtil.js delete mode 100644 vendor/node_modules/lodash/_objectToString.js delete mode 100644 vendor/node_modules/lodash/_overArg.js delete mode 100644 vendor/node_modules/lodash/_overRest.js delete mode 100644 vendor/node_modules/lodash/_root.js delete mode 100644 vendor/node_modules/lodash/_setCacheAdd.js delete mode 100644 vendor/node_modules/lodash/_setCacheHas.js delete mode 100644 vendor/node_modules/lodash/_setToArray.js delete mode 100644 vendor/node_modules/lodash/_setToString.js delete mode 100644 vendor/node_modules/lodash/_shortOut.js delete mode 100644 vendor/node_modules/lodash/_stackClear.js delete mode 100644 vendor/node_modules/lodash/_stackDelete.js delete mode 100644 vendor/node_modules/lodash/_stackGet.js delete mode 100644 vendor/node_modules/lodash/_stackHas.js delete mode 100644 vendor/node_modules/lodash/_stackSet.js delete mode 100644 vendor/node_modules/lodash/_strictIndexOf.js delete mode 100644 vendor/node_modules/lodash/_stringToArray.js delete mode 100644 vendor/node_modules/lodash/_stringToPath.js delete mode 100644 vendor/node_modules/lodash/_toKey.js delete mode 100644 vendor/node_modules/lodash/_toSource.js delete mode 100644 vendor/node_modules/lodash/_trimmedEndIndex.js delete mode 100644 vendor/node_modules/lodash/_unicodeToArray.js delete mode 100644 vendor/node_modules/lodash/constant.js delete mode 100644 vendor/node_modules/lodash/debounce.js delete mode 100644 vendor/node_modules/lodash/eq.js delete mode 100644 vendor/node_modules/lodash/every.js delete mode 100644 vendor/node_modules/lodash/find.js delete mode 100644 vendor/node_modules/lodash/findIndex.js delete mode 100644 vendor/node_modules/lodash/flatMap.js delete mode 100644 vendor/node_modules/lodash/get.js delete mode 100644 vendor/node_modules/lodash/hasIn.js delete mode 100644 vendor/node_modules/lodash/identity.js delete mode 100644 vendor/node_modules/lodash/isArguments.js delete mode 100644 vendor/node_modules/lodash/isArray.js delete mode 100644 vendor/node_modules/lodash/isArrayLike.js delete mode 100644 vendor/node_modules/lodash/isBoolean.js delete mode 100644 vendor/node_modules/lodash/isBuffer.js delete mode 100644 vendor/node_modules/lodash/isEqual.js delete mode 100644 vendor/node_modules/lodash/isFunction.js delete mode 100644 vendor/node_modules/lodash/isLength.js delete mode 100644 vendor/node_modules/lodash/isNaN.js delete mode 100644 vendor/node_modules/lodash/isNil.js delete mode 100644 vendor/node_modules/lodash/isNumber.js delete mode 100644 vendor/node_modules/lodash/isObject.js delete mode 100644 vendor/node_modules/lodash/isObjectLike.js delete mode 100644 vendor/node_modules/lodash/isPlainObject.js delete mode 100644 vendor/node_modules/lodash/isString.js delete mode 100644 vendor/node_modules/lodash/isSymbol.js delete mode 100644 vendor/node_modules/lodash/isTypedArray.js delete mode 100644 vendor/node_modules/lodash/keys.js delete mode 100644 vendor/node_modules/lodash/last.js delete mode 100644 vendor/node_modules/lodash/map.js delete mode 100644 vendor/node_modules/lodash/mapValues.js delete mode 100644 vendor/node_modules/lodash/max.js delete mode 100644 vendor/node_modules/lodash/maxBy.js delete mode 100644 vendor/node_modules/lodash/memoize.js delete mode 100644 vendor/node_modules/lodash/min.js delete mode 100644 vendor/node_modules/lodash/minBy.js delete mode 100644 vendor/node_modules/lodash/noop.js delete mode 100644 vendor/node_modules/lodash/now.js delete mode 100644 vendor/node_modules/lodash/property.js delete mode 100644 vendor/node_modules/lodash/range.js delete mode 100644 vendor/node_modules/lodash/some.js delete mode 100644 vendor/node_modules/lodash/sortBy.js delete mode 100644 vendor/node_modules/lodash/stubArray.js delete mode 100644 vendor/node_modules/lodash/stubFalse.js delete mode 100644 vendor/node_modules/lodash/throttle.js delete mode 100644 vendor/node_modules/lodash/toFinite.js delete mode 100644 vendor/node_modules/lodash/toInteger.js delete mode 100644 vendor/node_modules/lodash/toNumber.js delete mode 100644 vendor/node_modules/lodash/toString.js delete mode 100644 vendor/node_modules/lodash/uniqBy.js delete mode 100644 vendor/node_modules/lodash/upperFirst.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/AccessTime.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Add.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/AddCircleOutlineRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/AddRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/AddToQueueRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ArchiveRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ArrowBack.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ArrowDownward.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ArrowDropDown.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ArrowUpward.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/AutoAwesome.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/CalendarToday.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/CheckCircle.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/CheckOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/CheckRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Checklist.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ChevronRightRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Circle.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/CloseOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/CloudUploadOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ContentCopy.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ContentCopyRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ContentCutRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/CropSquareOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/DeleteForever.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/DeleteForeverRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/DeleteRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Description.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Devices.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Download.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/DownloadRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/DragIndicator.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/EditOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/EnergySavingsLeafRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ExpandMoreRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ExploreOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/FileDownloadRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/FileUploadRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/FilterAltRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Fullscreen.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/FullscreenExit.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Groups.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/GroupsRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/HelpOutlineOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ImageRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Info.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Inventory.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Inventory2Outlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Inventory2Rounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/KeyboardArrowLeftOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/KeyboardArrowRightOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/KeyboardArrowUpRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Language.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/LibraryAddCheckOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Lock.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/LockOpen.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/LockOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/LockPersonRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/MoreHorizOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/MoreVertRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/NearMeOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/NotInterestedOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PauseRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PersonAddAlt1.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PersonAddAltOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PersonAddOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PictureAsPdfRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PlayArrowRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PlayCircleOutline.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PlayCircleRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PlaylistAddRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/PublicRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Publish.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/QueuePlayNextRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Refresh.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/RemoveCircle.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/RemoveOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/RemoveRedEyeOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/SearchRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/SendRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ShareRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/SpeedRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/StickyNote2Rounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/SyncOutlined.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/TheatersRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/ThumbUpRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/TokenRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/TrendingDown.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/TrendingUp.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/TrendingUpRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/UploadRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/Visibility.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/VisibilityRounded.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/VolumeDown.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/VolumeMute.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/VolumeOff.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/VolumeUp.js delete mode 100644 vendor/node_modules/mui/icons-material/esm/WarningAmberRounded.js delete mode 100644 vendor/node_modules/mui/material/esm/useMediaQuery/index.js delete mode 100644 vendor/node_modules/mui/system/esm/useMediaQuery/useMediaQuery.js delete mode 100644 vendor/node_modules/next/dist/build/deployment-id.js delete mode 100644 vendor/node_modules/next/dist/build/polyfills/polyfill-module.js delete mode 100644 vendor/node_modules/next/dist/build/polyfills/process.js delete mode 100644 vendor/node_modules/next/dist/compiled/cookie/index.js delete mode 100644 vendor/node_modules/next/dist/compiled/path-to-regexp/index.js delete mode 100644 vendor/node_modules/next/dist/compiled/process/browser.js delete mode 100644 vendor/node_modules/next/dist/lib/constants.js delete mode 100644 vendor/node_modules/next/dist/lib/is-api-route.js delete mode 100644 vendor/node_modules/next/dist/lib/is-error.js delete mode 100644 vendor/node_modules/next/dist/lib/require-instrumentation-client.js delete mode 100644 vendor/node_modules/next/dist/lib/route-pattern-normalizer.js delete mode 100644 vendor/node_modules/next/dist/server/api-utils/get-cookie-parser.js delete mode 100644 vendor/node_modules/react-dom/cjs/react-dom-client.production.js delete mode 100644 vendor/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js delete mode 100644 vendor/node_modules/react-dom/cjs/react-dom-server.browser.production.js delete mode 100644 vendor/node_modules/react-dom/cjs/react-dom.production.js delete mode 100644 vendor/node_modules/react-dom/client.js delete mode 100644 vendor/node_modules/react-dom/index.js delete mode 100644 vendor/node_modules/react-dom/server.browser.js delete mode 100644 vendor/node_modules/react-smooth/es6/Animate.js delete mode 100644 vendor/node_modules/react-smooth/es6/AnimateManager.js delete mode 100644 vendor/node_modules/react-smooth/es6/configUpdate.js delete mode 100644 vendor/node_modules/react-smooth/es6/easing.js delete mode 100644 vendor/node_modules/react-smooth/es6/index.js delete mode 100644 vendor/node_modules/react-smooth/es6/setRafTimeout.js delete mode 100644 vendor/node_modules/react-smooth/es6/util.js delete mode 100644 vendor/node_modules/react/cjs/react-jsx-runtime.production.js delete mode 100644 vendor/node_modules/react/cjs/react.production.js delete mode 100644 vendor/node_modules/react/index.js delete mode 100644 vendor/node_modules/react/jsx-runtime.js delete mode 100644 vendor/node_modules/recharts-scale/es6/getNiceTickValues.js delete mode 100644 vendor/node_modules/recharts-scale/es6/index.js delete mode 100644 vendor/node_modules/recharts-scale/es6/util/arithmetic.js delete mode 100644 vendor/node_modules/recharts-scale/es6/util/utils.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/Area.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/Bar.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/Brush.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/CartesianAxis.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/CartesianGrid.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/ErrorBar.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/Line.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/ReferenceArea.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/ReferenceDot.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/ReferenceLine.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/Scatter.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/XAxis.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/YAxis.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/ZAxis.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/getEquidistantTicks.js delete mode 100644 vendor/node_modules/recharts/es6/cartesian/getTicks.js delete mode 100644 vendor/node_modules/recharts/es6/chart/AccessibilityManager.js delete mode 100644 vendor/node_modules/recharts/es6/chart/AreaChart.js delete mode 100644 vendor/node_modules/recharts/es6/chart/ComposedChart.js delete mode 100644 vendor/node_modules/recharts/es6/chart/LineChart.js delete mode 100644 vendor/node_modules/recharts/es6/chart/PieChart.js delete mode 100644 vendor/node_modules/recharts/es6/chart/generateCategoricalChart.js delete mode 100644 vendor/node_modules/recharts/es6/component/Cell.js delete mode 100644 vendor/node_modules/recharts/es6/component/Cursor.js delete mode 100644 vendor/node_modules/recharts/es6/component/DefaultLegendContent.js delete mode 100644 vendor/node_modules/recharts/es6/component/DefaultTooltipContent.js delete mode 100644 vendor/node_modules/recharts/es6/component/Label.js delete mode 100644 vendor/node_modules/recharts/es6/component/LabelList.js delete mode 100644 vendor/node_modules/recharts/es6/component/Legend.js delete mode 100644 vendor/node_modules/recharts/es6/component/ResponsiveContainer.js delete mode 100644 vendor/node_modules/recharts/es6/component/Text.js delete mode 100644 vendor/node_modules/recharts/es6/component/Tooltip.js delete mode 100644 vendor/node_modules/recharts/es6/component/TooltipBoundingBox.js delete mode 100644 vendor/node_modules/recharts/es6/container/Layer.js delete mode 100644 vendor/node_modules/recharts/es6/container/Surface.js delete mode 100644 vendor/node_modules/recharts/es6/context/chartLayoutContext.js delete mode 100644 vendor/node_modules/recharts/es6/polar/Pie.js delete mode 100644 vendor/node_modules/recharts/es6/polar/PolarAngleAxis.js delete mode 100644 vendor/node_modules/recharts/es6/polar/PolarRadiusAxis.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Cross.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Curve.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Dot.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Polygon.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Rectangle.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Sector.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Symbols.js delete mode 100644 vendor/node_modules/recharts/es6/shape/Trapezoid.js delete mode 100644 vendor/node_modules/recharts/es6/util/ActiveShapeUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/BarUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/CartesianUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/ChartUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/CssPrefixUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/DOMUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/DataUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/DetectReferenceElementsDomain.js delete mode 100644 vendor/node_modules/recharts/es6/util/Events.js delete mode 100644 vendor/node_modules/recharts/es6/util/Global.js delete mode 100644 vendor/node_modules/recharts/es6/util/IfOverflowMatches.js delete mode 100644 vendor/node_modules/recharts/es6/util/LogUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/PolarUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/ReactUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/ReduceCSSCalc.js delete mode 100644 vendor/node_modules/recharts/es6/util/ScatterUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/ShallowEqual.js delete mode 100644 vendor/node_modules/recharts/es6/util/TickUtils.js delete mode 100644 vendor/node_modules/recharts/es6/util/calculateViewBox.js delete mode 100644 vendor/node_modules/recharts/es6/util/cursor/getCursorPoints.js delete mode 100644 vendor/node_modules/recharts/es6/util/cursor/getCursorRectangle.js delete mode 100644 vendor/node_modules/recharts/es6/util/cursor/getRadialCursorPoints.js delete mode 100644 vendor/node_modules/recharts/es6/util/getEveryNthWithCondition.js delete mode 100644 vendor/node_modules/recharts/es6/util/getLegendProps.js delete mode 100644 vendor/node_modules/recharts/es6/util/isDomainSpecifiedByUser.js delete mode 100644 vendor/node_modules/recharts/es6/util/payload/getUniqPayload.js delete mode 100644 vendor/node_modules/recharts/es6/util/tooltip/translate.js delete mode 100644 vendor/node_modules/recharts/es6/util/types.js delete mode 100644 vendor/node_modules/scheduler/cjs/scheduler.production.js delete mode 100644 vendor/node_modules/scheduler/index.js delete mode 100644 vendor/node_modules/swc/helpers/esm/_define_property.js delete mode 100644 vendor/node_modules/swc/helpers/esm/_interop_require_default.js delete mode 100644 vendor/node_modules/swc/helpers/esm/_interop_require_wildcard.js delete mode 100644 vendor/node_modules/swc/helpers/esm/_object_spread.js delete mode 100644 vendor/node_modules/swc/helpers/esm/_object_spread_props.js delete mode 100644 vendor/node_modules/swc/helpers/esm/_object_without_properties.js delete mode 100644 vendor/node_modules/swc/helpers/esm/_object_without_properties_loose.js delete mode 100644 vendor/node_modules/tiny-invariant/dist/esm/tiny-invariant.js delete mode 100644 vendor/node_modules/victory-vendor/es/d3-scale.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..daf644f --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +ANYCLIP_EMAIL=your-email@example.com +ANYCLIP_PASSWORD=your-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d903ca9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +captured-apis.json +session.json + +# Raw sourcemaps (large binary files) +sourcemaps/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2796b59 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,151 @@ +--- +description: AnyClip integration tools - use Bun, understand the scripts +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: true +--- + +# Project: AnyClip Integration + +Tools for integrating with AnyClip's Video Manager API and extracting source code from sourcemaps. + +## Key Scripts + +| Script | Purpose | +|--------|---------| +| `scripts/auth.ts` | Authenticate with AnyClip, returns session cookies | +| `scripts/crypto-subtle.ts` | Password encryption (AES-256-GCM, matches AnyClip's client) | +| `scripts/update-urls.ts` | Fetch JS URLs from build manifest → urls.txt | +| `scripts/download-sourcemaps.ts` | Download .map files for URLs in urls.txt | +| `scripts/extract-sources.ts` | Extract source from sourcemaps → anyclip/ | + +## Authentication Flow + +1. Password encrypted client-side with AES-256-GCM (salt: `$2b$04$wwky7rvtr6BFNaCqntwyie`) +2. POST to `videomanager-api.anyclip.com/public/auth/login` → returns `anyclip_2020` cookie + JWT +3. POST to `videomanager.anyclip.com/api/auth/login` → returns `session` cookie +4. Both cookies required for authenticated requests + +## Source Extraction Workflow + +```bash +bun scripts/update-urls.ts # Scrape build manifest for JS URLs +bun scripts/download-sourcemaps.ts # Download .map files +bun scripts/extract-sources.ts # Extract to anyclip/ +``` + +## Directory Structure + +- `anyclip/` - Extracted source (committed) +- `sourcemaps/` - Raw .map files (gitignored) +- `urls.txt` - JS URLs to download +- `session.json` - Auth session (gitignored) + +--- + +# Bun Usage + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/README.md b/README.md index 941046b..0ee947f 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,78 @@ -# AnyClip Video Manager - Extracted Source +# AnyClip Integration -Source code extracted from sourcemaps of `videomanager.anyclip.com`. +Tools for integrating with AnyClip's Video Manager API. -## Overview +## Setup -Next.js application for video content management, analytics, and publishing. - -## Structure - -``` -├── src/ -│ ├── modules/ # Feature modules (business logic) -│ ├── pages/ # Next.js page components -│ ├── client/ # Client-side utilities -│ ├── shared/ # Shared libraries -│ └── assets/ -├── pages/ # Root Next.js pages (_app.tsx, _error.tsx) -├── client/ # Next.js client runtime -├── vendor/ # Bundled node_modules -└── sourcemaps/ # Original .map files +```bash +cp .env.example .env +# Edit .env with your AnyClip credentials +bun install ``` -## Modules (`src/modules/`) +## Project Structure -| Module | Description | -|--------|-------------| -| `analytics/` | Dashboards - monetization, video performance, custom reports | -| `editorial/` | Video editing - tagging, search, bulk actions, video details | -| `publishing/` | Content publishing and destination management | -| `marketplace/` | Marketplace accounts and dashboard | -| `xRay/` | X-Ray - campaigns, creatives, line items | -| `hubs/` | Content hubs management | -| `users/` | User management | -| `invitations/` | User invitation system | -| `forms/` | Form builder/management | -| `uploaderNew/` | Video upload functionality | -| `userRulesSettings/` | User rules and settings | -| `layout/` | App layout and Redux state | -| `common/` | Shared components - forms, tables, lists, tag selectors | +``` +├── anyclip/ # Extracted source code (from sourcemaps) +├── docs/ # Documentation +├── scripts/ # CLI tools +├── sourcemaps/ # Raw .map files (gitignored) +└── urls.txt # JS file URLs to download +``` -## Pages (`src/pages/`) +## Scripts -- `/analytics` - Analytics dashboard -- `/studio` - Studio interface -- `/personal-settings` - User settings -- `/hubs`, `/users`, `/invitations`, `/forms` - Management pages -- `/x-ray/*` - Campaign, creative, and line item analytics +### Authentication -## Tech Stack +```bash +# Login and save session to session.json +bun scripts/auth.ts +``` -- Next.js, React, TypeScript -- Redux (state management) -- Material-UI (components) -- Victory/D3 (charts) +Programmatic usage: + +```typescript +import { login, getAuthHeaders } from './scripts/auth'; + +const session = await login(email, password); +const headers = getAuthHeaders(session); +// session.cookies contains both required cookies +``` + +See [docs/auth.md](docs/auth.md) for details on the two-step auth flow. + +### Source Extraction + +Extract AnyClip's frontend source from public sourcemaps: + +```bash +# 1. Update urls.txt from build manifest +bun scripts/update-urls.ts + +# 2. Download sourcemaps for all URLs +bun scripts/download-sourcemaps.ts + +# 3. Extract source files to anyclip/ +bun scripts/extract-sources.ts +``` + +Or run all three: + +```bash +bun scripts/update-urls.ts && bun scripts/download-sourcemaps.ts && bun scripts/extract-sources.ts +``` + +### Script Options + +**extract-sources.ts:** +```bash +bun scripts/extract-sources.ts [options] + --output, -o Output directory (default: anyclip) + --input, -i Sourcemaps directory (default: sourcemaps) + --verbose, -v Verbose output + --no-clean Don't delete output directory first +``` + +## Documentation + +- [docs/auth.md](docs/auth.md) - Authentication system (two-step flow, cookies, encryption) diff --git a/client/add-base-path.ts b/anyclip/client/add-base-path.ts similarity index 100% rename from client/add-base-path.ts rename to anyclip/client/add-base-path.ts diff --git a/client/add-locale.ts b/anyclip/client/add-locale.ts similarity index 100% rename from client/add-locale.ts rename to anyclip/client/add-locale.ts diff --git a/client/detect-domain-locale.ts b/anyclip/client/detect-domain-locale.ts similarity index 100% rename from client/detect-domain-locale.ts rename to anyclip/client/detect-domain-locale.ts diff --git a/anyclip/client/get-domain-locale.ts b/anyclip/client/get-domain-locale.ts new file mode 100644 index 0000000..225a74d --- /dev/null +++ b/anyclip/client/get-domain-locale.ts @@ -0,0 +1,35 @@ +import type { DomainLocale } from '../server/config' +import type { normalizeLocalePath as NormalizeFn } from './normalize-locale-path' +import type { detectDomainLocale as DetectFn } from './detect-domain-locale' +import { normalizePathTrailingSlash } from './normalize-trailing-slash' + +const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || '' + +export function getDomainLocale( + path: string, + locale?: string | false, + locales?: readonly string[], + domainLocales?: readonly DomainLocale[] +) { + if (process.env.__NEXT_I18N_SUPPORT) { + const normalizeLocalePath: typeof NormalizeFn = ( + require('./normalize-locale-path') as typeof import('./normalize-locale-path') + ).normalizeLocalePath + const detectDomainLocale: typeof DetectFn = ( + require('./detect-domain-locale') as typeof import('./detect-domain-locale') + ).detectDomainLocale + + const target = locale || normalizeLocalePath(path, locales).detectedLocale + const domain = detectDomainLocale(domainLocales, undefined, target) + if (domain) { + const proto = `http${domain.http ? '' : 's'}://` + const finalLocale = target === domain.defaultLocale ? '' : `/${target}` + return `${proto}${domain.domain}${normalizePathTrailingSlash( + `${basePath}${finalLocale}${path}` + )}` + } + return false + } else { + return false + } +} diff --git a/client/has-base-path.ts b/anyclip/client/has-base-path.ts similarity index 100% rename from client/has-base-path.ts rename to anyclip/client/has-base-path.ts diff --git a/client/head-manager.ts b/anyclip/client/head-manager.ts similarity index 100% rename from client/head-manager.ts rename to anyclip/client/head-manager.ts diff --git a/anyclip/client/image-component.tsx b/anyclip/client/image-component.tsx new file mode 100644 index 0000000..ca1666a --- /dev/null +++ b/anyclip/client/image-component.tsx @@ -0,0 +1,421 @@ +'use client' + +import React, { + useRef, + useEffect, + useCallback, + useContext, + useMemo, + useState, + forwardRef, + use, +} from 'react' +import ReactDOM from 'react-dom' +import Head from '../shared/lib/head' +import { getImgProps } from '../shared/lib/get-img-props' +import type { + ImageProps, + ImgProps, + OnLoad, + OnLoadingComplete, + PlaceholderValue, +} from '../shared/lib/get-img-props' +import type { + ImageConfigComplete, + ImageLoaderProps, +} from '../shared/lib/image-config' +import { imageConfigDefault } from '../shared/lib/image-config' +import { ImageConfigContext } from '../shared/lib/image-config-context.shared-runtime' +import { warnOnce } from '../shared/lib/utils/warn-once' +import { RouterContext } from '../shared/lib/router-context.shared-runtime' + +// This is replaced by webpack alias +import defaultLoader from 'next/dist/shared/lib/image-loader' +import { useMergedRef } from './use-merged-ref' + +// This is replaced by webpack define plugin +const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete + +if (typeof window === 'undefined') { + ;(globalThis as any).__NEXT_IMAGE_IMPORTED = true +} + +export type { ImageLoaderProps } +export type ImageLoader = (p: ImageLoaderProps) => string + +type ImgElementWithDataProp = HTMLImageElement & { + 'data-loaded-src': string | undefined +} + +type ImageElementProps = ImgProps & { + unoptimized: boolean + placeholder: PlaceholderValue + onLoadRef: React.MutableRefObject + onLoadingCompleteRef: React.MutableRefObject + setBlurComplete: (b: boolean) => void + setShowAltText: (b: boolean) => void + sizesInput: string | undefined +} + +// See https://stackoverflow.com/q/39777833/266535 for why we use this ref +// handler instead of the img's onLoad attribute. +function handleLoading( + img: ImgElementWithDataProp, + placeholder: PlaceholderValue, + onLoadRef: React.MutableRefObject, + onLoadingCompleteRef: React.MutableRefObject, + setBlurComplete: (b: boolean) => void, + unoptimized: boolean, + sizesInput: string | undefined +) { + const src = img?.src + if (!img || img['data-loaded-src'] === src) { + return + } + img['data-loaded-src'] = src + const p = 'decode' in img ? img.decode() : Promise.resolve() + p.catch(() => {}).then(() => { + if (!img.parentElement || !img.isConnected) { + // Exit early in case of race condition: + // - onload() is called + // - decode() is called but incomplete + // - unmount is called + // - decode() completes + return + } + if (placeholder !== 'empty') { + setBlurComplete(true) + } + if (onLoadRef?.current) { + // Since we don't have the SyntheticEvent here, + // we must create one with the same shape. + // See https://reactjs.org/docs/events.html + const event = new Event('load') + Object.defineProperty(event, 'target', { writable: false, value: img }) + let prevented = false + let stopped = false + onLoadRef.current({ + ...event, + nativeEvent: event, + currentTarget: img, + target: img, + isDefaultPrevented: () => prevented, + isPropagationStopped: () => stopped, + persist: () => {}, + preventDefault: () => { + prevented = true + event.preventDefault() + }, + stopPropagation: () => { + stopped = true + event.stopPropagation() + }, + }) + } + if (onLoadingCompleteRef?.current) { + onLoadingCompleteRef.current(img) + } + if (process.env.NODE_ENV !== 'production') { + const origSrc = new URL(src, 'http://n').searchParams.get('url') || src + if (img.getAttribute('data-nimg') === 'fill') { + if (!unoptimized && (!sizesInput || sizesInput === '100vw')) { + let widthViewportRatio = + img.getBoundingClientRect().width / window.innerWidth + if (widthViewportRatio < 0.6) { + if (sizesInput === '100vw') { + warnOnce( + `Image with src "${origSrc}" has "fill" prop and "sizes" prop of "100vw", but image is not rendered at full viewport width. Please adjust "sizes" to improve page performance. Read more: https://nextjs.org/docs/api-reference/next/image#sizes` + ) + } else { + warnOnce( + `Image with src "${origSrc}" has "fill" but is missing "sizes" prop. Please add it to improve page performance. Read more: https://nextjs.org/docs/api-reference/next/image#sizes` + ) + } + } + } + if (img.parentElement) { + const { position } = window.getComputedStyle(img.parentElement) + const valid = ['absolute', 'fixed', 'relative'] + if (!valid.includes(position)) { + warnOnce( + `Image with src "${origSrc}" has "fill" and parent element with invalid "position". Provided "${position}" should be one of ${valid + .map(String) + .join(',')}.` + ) + } + } + if (img.height === 0) { + warnOnce( + `Image with src "${origSrc}" has "fill" and a height value of 0. This is likely because the parent element of the image has not been styled to have a set height.` + ) + } + } + + const heightModified = + img.height.toString() !== img.getAttribute('height') + const widthModified = img.width.toString() !== img.getAttribute('width') + if ( + (heightModified && !widthModified) || + (!heightModified && widthModified) + ) { + warnOnce( + `Image with src "${origSrc}" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio.` + ) + } + } + }) +} + +function getDynamicProps( + fetchPriority?: string +): Record { + if (Boolean(use)) { + // In React 19.0.0 or newer, we must use camelCase + // prop to avoid "Warning: Invalid DOM property". + // See https://github.com/facebook/react/pull/25927 + return { fetchPriority } + } + // In React 18.2.0 or older, we must use lowercase prop + // to avoid "Warning: Invalid DOM property". + return { fetchpriority: fetchPriority } +} + +const ImageElement = forwardRef( + ( + { + src, + srcSet, + sizes, + height, + width, + decoding, + className, + style, + fetchPriority, + placeholder, + loading, + unoptimized, + fill, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + setShowAltText, + sizesInput, + onLoad, + onError, + ...rest + }, + forwardedRef + ) => { + const ownRef = useCallback( + (img: ImgElementWithDataProp | null) => { + if (!img) { + return + } + if (onError) { + // If the image has an error before react hydrates, then the error is lost. + // The workaround is to wait until the image is mounted which is after hydration, + // then we set the src again to trigger the error handler (if there was an error). + // eslint-disable-next-line no-self-assign + img.src = img.src + } + if (process.env.NODE_ENV !== 'production') { + if (!src) { + console.error(`Image is missing required "src" property:`, img) + } + if (img.getAttribute('alt') === null) { + console.error( + `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.` + ) + } + } + if (img.complete) { + handleLoading( + img, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + unoptimized, + sizesInput + ) + } + }, + [ + src, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + onError, + unoptimized, + sizesInput, + ] + ) + + const ref = useMergedRef(forwardedRef, ownRef) + + return ( + { + const img = event.currentTarget as ImgElementWithDataProp + handleLoading( + img, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + unoptimized, + sizesInput + ) + }} + onError={(event) => { + // if the real image fails to load, this will ensure "alt" is visible + setShowAltText(true) + if (placeholder !== 'empty') { + // If the real image fails to load, this will still remove the placeholder. + setBlurComplete(true) + } + if (onError) { + onError(event) + } + }} + /> + ) + } +) + +function ImagePreload({ + isAppRouter, + imgAttributes, +}: { + isAppRouter: boolean + imgAttributes: ImgProps +}) { + const opts: ReactDOM.PreloadOptions = { + as: 'image', + imageSrcSet: imgAttributes.srcSet, + imageSizes: imgAttributes.sizes, + crossOrigin: imgAttributes.crossOrigin, + referrerPolicy: imgAttributes.referrerPolicy, + ...getDynamicProps(imgAttributes.fetchPriority), + } + + if (isAppRouter && ReactDOM.preload) { + ReactDOM.preload(imgAttributes.src, opts) + return null + } + + return ( + + + + ) +} + +/** + * The `Image` component is used to optimize images. + * + * Read more: [Next.js docs: `Image`](https://nextjs.org/docs/app/api-reference/components/image) + */ +export const Image = forwardRef( + (props, forwardedRef) => { + const pagesRouter = useContext(RouterContext) + // We're in the app directory if there is no pages router. + const isAppRouter = !pagesRouter + + const configContext = useContext(ImageConfigContext) + const config = useMemo(() => { + const c = configEnv || configContext || imageConfigDefault + const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b) + const deviceSizes = c.deviceSizes.sort((a, b) => a - b) + const qualities = c.qualities?.sort((a, b) => a - b) + return { ...c, allSizes, deviceSizes, qualities } + }, [configContext]) + + const { onLoad, onLoadingComplete } = props + const onLoadRef = useRef(onLoad) + + useEffect(() => { + onLoadRef.current = onLoad + }, [onLoad]) + + const onLoadingCompleteRef = useRef(onLoadingComplete) + + useEffect(() => { + onLoadingCompleteRef.current = onLoadingComplete + }, [onLoadingComplete]) + + const [blurComplete, setBlurComplete] = useState(false) + const [showAltText, setShowAltText] = useState(false) + + const { props: imgAttributes, meta: imgMeta } = getImgProps(props, { + defaultLoader, + imgConf: config, + blurComplete, + showAltText, + }) + + return ( + <> + { + + } + {imgMeta.priority ? ( + + ) : null} + + ) + } +) diff --git a/client/index.tsx b/anyclip/client/index.tsx similarity index 100% rename from client/index.tsx rename to anyclip/client/index.tsx diff --git a/anyclip/client/link.tsx b/anyclip/client/link.tsx new file mode 100644 index 0000000..f76d879 --- /dev/null +++ b/anyclip/client/link.tsx @@ -0,0 +1,717 @@ +'use client' + +import type { + NextRouter, + PrefetchOptions as RouterPrefetchOptions, +} from '../shared/lib/router/router' + +import React, { createContext, useContext } from 'react' +import type { UrlObject } from 'url' +import { resolveHref } from './resolve-href' +import { isLocalURL } from '../shared/lib/router/utils/is-local-url' +import { formatUrl } from '../shared/lib/router/utils/format-url' +import { isAbsoluteUrl } from '../shared/lib/utils' +import { addLocale } from './add-locale' +import { RouterContext } from '../shared/lib/router-context.shared-runtime' +import type { AppRouterInstance } from '../shared/lib/app-router-context.shared-runtime' +import { useIntersection } from './use-intersection' +import { getDomainLocale } from './get-domain-locale' +import { addBasePath } from './add-base-path' +import { useMergedRef } from './use-merged-ref' +import { errorOnce } from '../shared/lib/utils/error-once' + +type Url = string | UrlObject +type RequiredKeys = { + [K in keyof T]-?: {} extends Pick ? never : K +}[keyof T] +type OptionalKeys = { + [K in keyof T]-?: {} extends Pick ? K : never +}[keyof T] + +type OnNavigateEventHandler = (event: { preventDefault: () => void }) => void + +type InternalLinkProps = { + /** + * The path or URL to navigate to. It can also be an object. + * + * @example https://nextjs.org/docs/api-reference/next/link#with-url-object + */ + href: Url + /** + * Optional decorator for the path that will be shown in the browser URL bar. Before Next.js 9.5.3 this was used for dynamic routes, check our [previous docs](https://github.com/vercel/next.js/blob/v9.5.2/docs/api-reference/next/link.md#dynamic-routes) to see how it worked. Note: when this path differs from the one provided in `href` the previous `href`/`as` behavior is used as shown in the [previous docs](https://github.com/vercel/next.js/blob/v9.5.2/docs/api-reference/next/link.md#dynamic-routes). + */ + as?: Url + /** + * Replace the current `history` state instead of adding a new url into the stack. + * + * @defaultValue `false` + */ + replace?: boolean + /** + * Whether to override the default scroll behavior + * + * @example https://nextjs.org/docs/api-reference/next/link#disable-scrolling-to-the-top-of-the-page + * + * @defaultValue `true` + */ + scroll?: boolean + /** + * Update the path of the current page without rerunning [`getStaticProps`](https://nextjs.org/docs/pages/building-your-application/data-fetching/get-static-props), [`getServerSideProps`](https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props) or [`getInitialProps`](/docs/pages/api-reference/functions/get-initial-props). + * + * @defaultValue `false` + */ + shallow?: boolean + /** + * Forces `Link` to send the `href` property to its child. + * + * @defaultValue `false` + */ + passHref?: boolean + /** + * Prefetch the page in the background. + * Any `` that is in the viewport (initially or through scroll) will be prefetched. + * Prefetch can be disabled by passing `prefetch={false}`. Prefetching is only enabled in production. + * + * In App Router: + * - "auto", null, undefined (default): For statically generated pages, this will prefetch the full React Server Component data. For dynamic pages, this will prefetch up to the nearest route segment with a [`loading.js`](https://nextjs.org/docs/app/api-reference/file-conventions/loading) file. If there is no loading file, it will not fetch the full tree to avoid fetching too much data. + * - `true`: This will prefetch the full React Server Component data for all route segments, regardless of whether they contain a segment with `loading.js`. + * - `false`: This will not prefetch any data, even on hover. + * + * In Pages Router: + * - `true` (default): The full route & its data will be prefetched. + * - `false`: Prefetching will not happen when entering the viewport, but will still happen on hover. + * @defaultValue `true` (pages router) or `null` (app router) + */ + prefetch?: boolean | 'auto' | null | 'unstable_forceStale' + /** + * The active locale is automatically prepended. `locale` allows for providing a different locale. + * When `false` `href` has to include the locale as the default behavior is disabled. + * Note: This is only available in the Pages Router. + */ + locale?: string | false + /** + * Enable legacy link behavior. + * @deprecated This will be removed in v16 + * @defaultValue `false` + * @see https://github.com/vercel/next.js/commit/489e65ed98544e69b0afd7e0cfc3f9f6c2b803b7 + */ + legacyBehavior?: boolean + /** + * Optional event handler for when the mouse pointer is moved onto Link + */ + onMouseEnter?: React.MouseEventHandler + /** + * Optional event handler for when Link is touched. + */ + onTouchStart?: React.TouchEventHandler + /** + * Optional event handler for when Link is clicked. + */ + onClick?: React.MouseEventHandler + /** + * Optional event handler for when the `` is navigated. + */ + onNavigate?: OnNavigateEventHandler +} + +// TODO-APP: Include the full set of Anchor props +// adding this to the publicly exported type currently breaks existing apps + +// `RouteInferType` is a stub here to avoid breaking `typedRoutes` when the type +// isn't generated yet. It will be replaced when type generation runs. +// WARNING: This should be an interface to prevent TypeScript from inlining it +// in declarations of libraries dependending on Next.js. +// Not trivial to reproduce so only convert to an interface when needed. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface LinkProps extends InternalLinkProps {} +type LinkPropsRequired = RequiredKeys +type LinkPropsOptional = OptionalKeys + +const prefetched = new Set() + +type PrefetchOptions = RouterPrefetchOptions & { + /** + * bypassPrefetchedCheck will bypass the check to see if the `href` has + * already been fetched. + */ + bypassPrefetchedCheck?: boolean +} + +function prefetch( + router: NextRouter, + href: string, + as: string, + options: PrefetchOptions +): void { + if (typeof window === 'undefined') { + return + } + + if (!isLocalURL(href)) { + return + } + + // We should only dedupe requests when experimental.optimisticClientCache is + // disabled. + if (!options.bypassPrefetchedCheck) { + const locale = + // Let the link's locale prop override the default router locale. + typeof options.locale !== 'undefined' + ? options.locale + : // Otherwise fallback to the router's locale. + 'locale' in router + ? router.locale + : undefined + + const prefetchedKey = href + '%' + as + '%' + locale + + // If we've already fetched the key, then don't prefetch it again! + if (prefetched.has(prefetchedKey)) { + return + } + + // Mark this URL as prefetched. + prefetched.add(prefetchedKey) + } + + // Prefetch the JSON page if asked (only in the client) + // We need to handle a prefetch error here since we may be + // loading with priority which can reject but we don't + // want to force navigation since this is only a prefetch + router.prefetch(href, as, options).catch((err) => { + if (process.env.NODE_ENV !== 'production') { + // rethrow to show invalid URL errors + throw err + } + }) +} + +function isModifiedEvent(event: React.MouseEvent): boolean { + const eventTarget = event.currentTarget as HTMLAnchorElement | SVGAElement + const target = eventTarget.getAttribute('target') + return ( + (target && target !== '_self') || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || // triggers resource download + (event.nativeEvent && event.nativeEvent.which === 2) + ) +} + +function linkClicked( + e: React.MouseEvent, + router: NextRouter | AppRouterInstance, + href: string, + as: string, + replace?: boolean, + shallow?: boolean, + scroll?: boolean, + locale?: string | false, + onNavigate?: OnNavigateEventHandler +): void { + const { nodeName } = e.currentTarget + + // anchors inside an svg have a lowercase nodeName + const isAnchorNodeName = nodeName.toUpperCase() === 'A' + + if ( + (isAnchorNodeName && isModifiedEvent(e)) || + e.currentTarget.hasAttribute('download') + ) { + // ignore click for browser’s default behavior + return + } + + if (!isLocalURL(href)) { + if (replace) { + // browser default behavior does not replace the history state + // so we need to do it manually + e.preventDefault() + location.replace(href) + } + + // ignore click for browser’s default behavior + return + } + + e.preventDefault() + + const navigate = () => { + if (onNavigate) { + let isDefaultPrevented = false + + onNavigate({ + preventDefault: () => { + isDefaultPrevented = true + }, + }) + + if (isDefaultPrevented) { + return + } + } + + // If the router is an NextRouter instance it will have `beforePopState` + const routerScroll = scroll ?? true + if ('beforePopState' in router) { + router[replace ? 'replace' : 'push'](href, as, { + shallow, + locale, + scroll: routerScroll, + }) + } else { + router[replace ? 'replace' : 'push'](as || href, { + scroll: routerScroll, + }) + } + } + + navigate() +} + +type LinkPropsReal = React.PropsWithChildren< + Omit, keyof LinkProps> & + LinkProps +> + +function formatStringOrUrl(urlObjOrString: UrlObject | string): string { + if (typeof urlObjOrString === 'string') { + return urlObjOrString + } + + return formatUrl(urlObjOrString) +} + +/** + * A React component that extends the HTML `` element to provide [prefetching](https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#2-prefetching) + * and client-side navigation between routes. + * + * It is the primary way to navigate between routes in Next.js. + * + * Read more: [Next.js docs: ``](https://nextjs.org/docs/app/api-reference/components/link) + */ +const Link = React.forwardRef( + function LinkComponent(props, forwardedRef) { + let children: React.ReactNode + + const { + href: hrefProp, + as: asProp, + children: childrenProp, + prefetch: prefetchProp = null, + passHref, + replace, + shallow, + scroll, + locale, + onClick, + onNavigate, + onMouseEnter: onMouseEnterProp, + onTouchStart: onTouchStartProp, + legacyBehavior = false, + ...restProps + } = props + + children = childrenProp + + if ( + legacyBehavior && + (typeof children === 'string' || typeof children === 'number') + ) { + children = {children} + } + + const router = React.useContext(RouterContext) + + const prefetchEnabled = prefetchProp !== false + + if (process.env.NODE_ENV !== 'production') { + function createPropError(args: { + key: string + expected: string + actual: string + }) { + return new Error( + `Failed prop type: The prop \`${args.key}\` expects a ${args.expected} in \`\`, but got \`${args.actual}\` instead.` + + (typeof window !== 'undefined' + ? // TODO: Remove this addendum if Owner Stacks are available + "\nOpen your browser's console to view the Component stack trace." + : '') + ) + } + + // TypeScript trick for type-guarding: + const requiredPropsGuard: Record = { + href: true, + } as const + const requiredProps: LinkPropsRequired[] = Object.keys( + requiredPropsGuard + ) as LinkPropsRequired[] + requiredProps.forEach((key: LinkPropsRequired) => { + if (key === 'href') { + if ( + props[key] == null || + (typeof props[key] !== 'string' && typeof props[key] !== 'object') + ) { + throw createPropError({ + key, + expected: '`string` or `object`', + actual: props[key] === null ? 'null' : typeof props[key], + }) + } + } else { + // TypeScript trick for type-guarding: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = key + } + }) + + // TypeScript trick for type-guarding: + const optionalPropsGuard: Record = { + as: true, + replace: true, + scroll: true, + shallow: true, + passHref: true, + prefetch: true, + locale: true, + onClick: true, + onMouseEnter: true, + onTouchStart: true, + legacyBehavior: true, + onNavigate: true, + } as const + const optionalProps: LinkPropsOptional[] = Object.keys( + optionalPropsGuard + ) as LinkPropsOptional[] + optionalProps.forEach((key: LinkPropsOptional) => { + const valType = typeof props[key] + + if (key === 'as') { + if (props[key] && valType !== 'string' && valType !== 'object') { + throw createPropError({ + key, + expected: '`string` or `object`', + actual: valType, + }) + } + } else if (key === 'locale') { + if (props[key] && valType !== 'string') { + throw createPropError({ + key, + expected: '`string`', + actual: valType, + }) + } + } else if ( + key === 'onClick' || + key === 'onMouseEnter' || + key === 'onTouchStart' || + key === 'onNavigate' + ) { + if (props[key] && valType !== 'function') { + throw createPropError({ + key, + expected: '`function`', + actual: valType, + }) + } + } else if ( + key === 'replace' || + key === 'scroll' || + key === 'shallow' || + key === 'passHref' || + key === 'legacyBehavior' + ) { + if (props[key] != null && valType !== 'boolean') { + throw createPropError({ + key, + expected: '`boolean`', + actual: valType, + }) + } + } else if (key === 'prefetch') { + if ( + props[key] != null && + valType !== 'boolean' && + props[key] !== 'auto' + ) { + throw createPropError({ + key, + expected: '`boolean | "auto"`', + actual: valType, + }) + } + } else { + // TypeScript trick for type-guarding: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = key + } + }) + } + + const { href, as } = React.useMemo(() => { + if (!router) { + const resolvedHref = formatStringOrUrl(hrefProp) + return { + href: resolvedHref, + as: asProp ? formatStringOrUrl(asProp) : resolvedHref, + } + } + + const [resolvedHref, resolvedAs] = resolveHref(router, hrefProp, true) + + return { + href: resolvedHref, + as: asProp ? resolveHref(router, asProp) : resolvedAs || resolvedHref, + } + }, [router, hrefProp, asProp]) + + const previousHref = React.useRef(href) + const previousAs = React.useRef(as) + + // This will return the first child, if multiple are provided it will throw an error + let child: any + if (legacyBehavior) { + if (process.env.NODE_ENV === 'development') { + if (onClick) { + console.warn( + `"onClick" was passed to with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onClick be set on the child of next/link` + ) + } + if (onMouseEnterProp) { + console.warn( + `"onMouseEnter" was passed to with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onMouseEnter be set on the child of next/link` + ) + } + try { + child = React.Children.only(children) + } catch (err) { + if (!children) { + throw new Error( + `No children were passed to with \`href\` of \`${hrefProp}\` but one child is required https://nextjs.org/docs/messages/link-no-children` + ) + } + throw new Error( + `Multiple children were passed to with \`href\` of \`${hrefProp}\` but only one child is supported https://nextjs.org/docs/messages/link-multiple-children` + + (typeof window !== 'undefined' + ? " \nOpen your browser's console to view the Component stack trace." + : '') + ) + } + } else { + child = React.Children.only(children) + } + } else { + if (process.env.NODE_ENV === 'development') { + if ((children as any)?.type === 'a') { + throw new Error( + 'Invalid with child. Please remove or use .\nLearn more: https://nextjs.org/docs/messages/invalid-new-link-with-extra-anchor' + ) + } + } + } + + const childRef: any = legacyBehavior + ? child && typeof child === 'object' && child.ref + : forwardedRef + + const [setIntersectionRef, isVisible, resetVisible] = useIntersection({ + rootMargin: '200px', + }) + + const setIntersectionWithResetRef = React.useCallback( + (el: Element | null) => { + // Before the link getting observed, check if visible state need to be reset + if (previousAs.current !== as || previousHref.current !== href) { + resetVisible() + previousAs.current = as + previousHref.current = href + } + + setIntersectionRef(el) + }, + [as, href, resetVisible, setIntersectionRef] + ) + + const setRef = useMergedRef(setIntersectionWithResetRef, childRef) + + // Prefetch the URL if we haven't already and it's visible. + React.useEffect(() => { + // in dev, we only prefetch on hover to avoid wasting resources as the prefetch will trigger compiling the page. + if (process.env.NODE_ENV !== 'production') { + return + } + + if (!router) { + return + } + + // If we don't need to prefetch the URL, don't do prefetch. + if (!isVisible || !prefetchEnabled) { + return + } + + // Prefetch the URL. + prefetch(router, href, as, { locale }) + }, [as, href, isVisible, locale, prefetchEnabled, router?.locale, router]) + + const childProps: { + onTouchStart?: React.TouchEventHandler + onMouseEnter: React.MouseEventHandler + onClick: React.MouseEventHandler + href?: string + ref?: any + } = { + ref: setRef, + onClick(e) { + if (process.env.NODE_ENV !== 'production') { + if (!e) { + throw new Error( + `Component rendered inside next/link has to pass click event to "onClick" prop.` + ) + } + } + + if (!legacyBehavior && typeof onClick === 'function') { + onClick(e) + } + + if ( + legacyBehavior && + child.props && + typeof child.props.onClick === 'function' + ) { + child.props.onClick(e) + } + + if (!router) { + return + } + + if (e.defaultPrevented) { + return + } + + linkClicked( + e, + router, + href, + as, + replace, + shallow, + scroll, + locale, + onNavigate + ) + }, + onMouseEnter(e) { + if (!legacyBehavior && typeof onMouseEnterProp === 'function') { + onMouseEnterProp(e) + } + + if ( + legacyBehavior && + child.props && + typeof child.props.onMouseEnter === 'function' + ) { + child.props.onMouseEnter(e) + } + + if (!router) { + return + } + + prefetch(router, href, as, { + locale, + priority: true, + // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642} + bypassPrefetchedCheck: true, + }) + }, + onTouchStart: process.env.__NEXT_LINK_NO_TOUCH_START + ? undefined + : function onTouchStart(e) { + if (!legacyBehavior && typeof onTouchStartProp === 'function') { + onTouchStartProp(e) + } + + if ( + legacyBehavior && + child.props && + typeof child.props.onTouchStart === 'function' + ) { + child.props.onTouchStart(e) + } + + if (!router) { + return + } + + prefetch(router, href, as, { + locale, + priority: true, + // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642} + bypassPrefetchedCheck: true, + }) + }, + } + + // If child is an tag and doesn't have a href attribute, or if the 'passHref' property is + // defined, we specify the current 'href', so that repetition is not needed by the user. + // If the url is absolute, we can bypass the logic to prepend the domain and locale. + if (isAbsoluteUrl(as)) { + childProps.href = as + } else if ( + !legacyBehavior || + passHref || + (child.type === 'a' && !('href' in child.props)) + ) { + const curLocale = typeof locale !== 'undefined' ? locale : router?.locale + + // we only render domain locales if we are currently on a domain locale + // so that locale links are still visitable in development/preview envs + const localeDomain = + router?.isLocaleDomain && + getDomainLocale(as, curLocale, router?.locales, router?.domainLocales) + + childProps.href = + localeDomain || + addBasePath(addLocale(as, curLocale, router?.defaultLocale)) + } + + if (legacyBehavior) { + if (process.env.NODE_ENV === 'development') { + errorOnce( + '`legacyBehavior` is deprecated and will be removed in a future ' + + 'release. A codemod is available to upgrade your components:\n\n' + + 'npx @next/codemod@latest new-link .\n\n' + + 'Learn more: https://nextjs.org/docs/app/building-your-application/upgrading/codemods#remove-a-tags-from-link-components' + ) + } + return React.cloneElement(child, childProps) + } + + return ( + + {children} + + ) + } +) + +const LinkStatusContext = createContext<{ + pending: boolean +}>({ + // We do not support link status in the Pages Router, so we always return false + pending: false, +}) + +export const useLinkStatus = () => { + // This behaviour is like React's useFormStatus. When the component is not under + // a
tag, it will get the default value, instead of throwing an error. + return useContext(LinkStatusContext) +} + +export default Link diff --git a/client/next.ts b/anyclip/client/next.ts similarity index 100% rename from client/next.ts rename to anyclip/client/next.ts diff --git a/client/normalize-trailing-slash.ts b/anyclip/client/normalize-trailing-slash.ts similarity index 100% rename from client/normalize-trailing-slash.ts rename to anyclip/client/normalize-trailing-slash.ts diff --git a/client/page-loader.ts b/anyclip/client/page-loader.ts similarity index 100% rename from client/page-loader.ts rename to anyclip/client/page-loader.ts diff --git a/client/remove-base-path.ts b/anyclip/client/remove-base-path.ts similarity index 100% rename from client/remove-base-path.ts rename to anyclip/client/remove-base-path.ts diff --git a/client/remove-locale.ts b/anyclip/client/remove-locale.ts similarity index 100% rename from client/remove-locale.ts rename to anyclip/client/remove-locale.ts diff --git a/client/request-idle-callback.ts b/anyclip/client/request-idle-callback.ts similarity index 100% rename from client/request-idle-callback.ts rename to anyclip/client/request-idle-callback.ts diff --git a/client/resolve-href.ts b/anyclip/client/resolve-href.ts similarity index 100% rename from client/resolve-href.ts rename to anyclip/client/resolve-href.ts diff --git a/client/route-announcer.tsx b/anyclip/client/route-announcer.tsx similarity index 100% rename from client/route-announcer.tsx rename to anyclip/client/route-announcer.tsx diff --git a/client/route-loader.ts b/anyclip/client/route-loader.ts similarity index 100% rename from client/route-loader.ts rename to anyclip/client/route-loader.ts diff --git a/client/router.ts b/anyclip/client/router.ts similarity index 100% rename from client/router.ts rename to anyclip/client/router.ts diff --git a/client/script.tsx b/anyclip/client/script.tsx similarity index 100% rename from client/script.tsx rename to anyclip/client/script.tsx diff --git a/client/set-attributes-from-props.ts b/anyclip/client/set-attributes-from-props.ts similarity index 100% rename from client/set-attributes-from-props.ts rename to anyclip/client/set-attributes-from-props.ts diff --git a/client/trusted-types.ts b/anyclip/client/trusted-types.ts similarity index 100% rename from client/trusted-types.ts rename to anyclip/client/trusted-types.ts diff --git a/anyclip/client/use-intersection.tsx b/anyclip/client/use-intersection.tsx new file mode 100644 index 0000000..116198c --- /dev/null +++ b/anyclip/client/use-intersection.tsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + requestIdleCallback, + cancelIdleCallback, +} from './request-idle-callback' + +type UseIntersectionObserverInit = Pick< + IntersectionObserverInit, + 'rootMargin' | 'root' +> + +type UseIntersection = { disabled?: boolean } & UseIntersectionObserverInit & { + rootRef?: React.RefObject | null + } +type ObserveCallback = (isVisible: boolean) => void +type Identifier = { + root: Element | Document | null + margin: string +} +type Observer = { + id: Identifier + observer: IntersectionObserver + elements: Map +} + +const hasIntersectionObserver = typeof IntersectionObserver === 'function' + +const observers = new Map() +const idList: Identifier[] = [] + +function createObserver(options: UseIntersectionObserverInit): Observer { + const id = { + root: options.root || null, + margin: options.rootMargin || '', + } + const existing = idList.find( + (obj) => obj.root === id.root && obj.margin === id.margin + ) + let instance: Observer | undefined + + if (existing) { + instance = observers.get(existing) + if (instance) { + return instance + } + } + + const elements = new Map() + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const callback = elements.get(entry.target) + const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 + if (callback && isVisible) { + callback(isVisible) + } + }) + }, options) + instance = { + id, + observer, + elements, + } + + idList.push(id) + observers.set(id, instance) + return instance +} + +function observe( + element: Element, + callback: ObserveCallback, + options: UseIntersectionObserverInit +): () => void { + const { id, observer, elements } = createObserver(options) + elements.set(element, callback) + + observer.observe(element) + return function unobserve(): void { + elements.delete(element) + observer.unobserve(element) + + // Destroy observer when there's nothing left to watch: + if (elements.size === 0) { + observer.disconnect() + observers.delete(id) + const index = idList.findIndex( + (obj) => obj.root === id.root && obj.margin === id.margin + ) + if (index > -1) { + idList.splice(index, 1) + } + } + } +} + +export function useIntersection({ + rootRef, + rootMargin, + disabled, +}: UseIntersection): [(element: T | null) => void, boolean, () => void] { + const isDisabled: boolean = disabled || !hasIntersectionObserver + + const [visible, setVisible] = useState(false) + const elementRef = useRef(null) + const setElement = useCallback((element: T | null) => { + elementRef.current = element + }, []) + + useEffect(() => { + if (hasIntersectionObserver) { + if (isDisabled || visible) return + + const element = elementRef.current + if (element && element.tagName) { + const unobserve = observe( + element, + (isVisible) => isVisible && setVisible(isVisible), + { root: rootRef?.current, rootMargin } + ) + + return unobserve + } + } else { + if (!visible) { + const idleCallback = requestIdleCallback(() => setVisible(true)) + return () => cancelIdleCallback(idleCallback) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDisabled, rootMargin, rootRef, visible, elementRef.current]) + + const resetVisible = useCallback(() => { + setVisible(false) + }, []) + + return [setElement, visible, resetVisible] +} diff --git a/anyclip/client/use-merged-ref.ts b/anyclip/client/use-merged-ref.ts new file mode 100644 index 0000000..c06beb4 --- /dev/null +++ b/anyclip/client/use-merged-ref.ts @@ -0,0 +1,67 @@ +import { useCallback, useRef, type Ref } from 'react' + +// This is a compatibility hook to support React 18 and 19 refs. +// In 19, a cleanup function from refs may be returned. +// In 18, returning a cleanup function creates a warning. +// Since we take userspace refs, we don't know ahead of time if a cleanup function will be returned. +// This implements cleanup functions with the old behavior in 18. +// We know refs are always called alternating with `null` and then `T`. +// So a call with `null` means we need to call the previous cleanup functions. +export function useMergedRef( + refA: Ref, + refB: Ref +): Ref { + const cleanupA = useRef<(() => void) | null>(null) + const cleanupB = useRef<(() => void) | null>(null) + + // NOTE: In theory, we could skip the wrapping if only one of the refs is non-null. + // (this happens often if the user doesn't pass a ref to Link/Form/Image) + // But this can cause us to leak a cleanup-ref into user code (e.g. via ``), + // and the user might pass that ref into ref-merging library that doesn't support cleanup refs + // (because it hasn't been updated for React 19) + // which can then cause things to blow up, because a cleanup-returning ref gets called with `null`. + // So in practice, it's safer to be defensive and always wrap the ref, even on React 19. + return useCallback( + (current: TElement | null): void => { + if (current === null) { + const cleanupFnA = cleanupA.current + if (cleanupFnA) { + cleanupA.current = null + cleanupFnA() + } + const cleanupFnB = cleanupB.current + if (cleanupFnB) { + cleanupB.current = null + cleanupFnB() + } + } else { + if (refA) { + cleanupA.current = applyRef(refA, current) + } + if (refB) { + cleanupB.current = applyRef(refB, current) + } + } + }, + [refA, refB] + ) +} + +function applyRef( + refA: NonNullable>, + current: TElement +) { + if (typeof refA === 'function') { + const cleanup = refA(current) + if (typeof cleanup === 'function') { + return cleanup + } else { + return () => refA(null) + } + } else { + refA.current = current + return () => { + refA.current = null + } + } +} diff --git a/client/webpack.ts b/anyclip/client/webpack.ts similarity index 100% rename from client/webpack.ts rename to anyclip/client/webpack.ts diff --git a/client/with-router.tsx b/anyclip/client/with-router.tsx similarity index 100% rename from client/with-router.tsx rename to anyclip/client/with-router.tsx diff --git a/pages/_app.tsx b/anyclip/pages/_app.tsx similarity index 100% rename from pages/_app.tsx rename to anyclip/pages/_app.tsx diff --git a/pages/_error.tsx b/anyclip/pages/_error.tsx similarity index 100% rename from pages/_error.tsx rename to anyclip/pages/_error.tsx diff --git a/src/assets/img/empty.svg b/anyclip/src/assets/img/empty.svg similarity index 100% rename from src/assets/img/empty.svg rename to anyclip/src/assets/img/empty.svg diff --git a/anyclip/src/assets/img/favicon/android-chrome-192x192-1621329404738.png b/anyclip/src/assets/img/favicon/android-chrome-192x192-1621329404738.png new file mode 100644 index 0000000..bb07a49 --- /dev/null +++ b/anyclip/src/assets/img/favicon/android-chrome-192x192-1621329404738.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/android-chrome-192x192-1621329404738.9a2c292c.png","height":192,"width":192,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAMAAADz0U65AAAAOVBMVEVnZ2fIyMj////Ozs7///+UlJTX19deXl7u7u5MaXEwMDD///8CAgLV1dWSkpKgoKBUVFSvr6+Li4vsoX4EAAAADHRSTlPy/DD2LbPu/LIA+S9SUaEGAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAP0lEQVR4nC3LUQrAIAwE0dVEE+smau9/2CL0b+AxGOqCpgO1p5Go8LSIhw1isU4hIDty3fD3p9lzk5gY6uXuH0i+AhIzA+vTAAAAAElFTkSuQmCC","blurWidth":8,"blurHeight":8}; \ No newline at end of file diff --git a/anyclip/src/assets/img/favicon/android-chrome-512x512-1621329404738.png b/anyclip/src/assets/img/favicon/android-chrome-512x512-1621329404738.png new file mode 100644 index 0000000..633b8c0 --- /dev/null +++ b/anyclip/src/assets/img/favicon/android-chrome-512x512-1621329404738.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/android-chrome-512x512-1621329404738.18838b10.png","height":512,"width":512,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAMAAADz0U65AAAAP1BMVEX///8wMDBlZWWZmZlnZ2deXl7X19fKysrv7+9MaXH///9hYWGPj4+QkJACAgLV1dWTk5Ofn59VVVWvr6+MjIwGk5XMAAAADnRSTlMv+eyx9v3v+rIALfm1spwOkf4AAAAJcEhZcwAACxMAAAsTAQCanBgAAAA/SURBVHicJcpbDoAgDAXRq/aJ2hZk/2sl6t8kZ+AwpQsOSHHEDlidmVscEM7+aBB0ZPU3bPJPkBrf7M2U7uYLUJgCVDLd5W0AAAAASUVORK5CYII=","blurWidth":8,"blurHeight":8}; \ No newline at end of file diff --git a/anyclip/src/assets/img/favicon/apple-touch-icon-1621329404738.png b/anyclip/src/assets/img/favicon/apple-touch-icon-1621329404738.png new file mode 100644 index 0000000..c137fa3 --- /dev/null +++ b/anyclip/src/assets/img/favicon/apple-touch-icon-1621329404738.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/apple-touch-icon-1621329404738.718b739d.png","height":180,"width":180,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAMAAADz0U65AAAAQlBMVEVkZGTv7+9XV1fMzMxdXV3////X19f///9MaXGUlJQvLy/Jycnt7e1qamrIyMj///8CAgLW1taRkZGdnZ2wsLBQUFBGzLF2AAAAEHRSTlPrsv71+y/uLQCv+f6v9+ksuK1TXgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAD9JREFUeJw1ykkSwCAIBdFvHICoEVDvf1UrC3dd9RpMIXYhRi7+qCIjeDJ7VVCSzVUViNt8/tEu0Z15tA9CfABaOgKVMNAZMgAAAABJRU5ErkJggg==","blurWidth":8,"blurHeight":8}; \ No newline at end of file diff --git a/anyclip/src/assets/img/favicon/favicon-16x16-1621329404738.png b/anyclip/src/assets/img/favicon/favicon-16x16-1621329404738.png new file mode 100644 index 0000000..d29fdd7 --- /dev/null +++ b/anyclip/src/assets/img/favicon/favicon-16x16-1621329404738.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/favicon-16x16-1621329404738.428e8ba6.png","height":16,"width":16,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAMAAADz0U65AAAAQlBMVEVmZmb///+Ojo4vLy/V1dVeXl7////JyclMaXHs7Oz///+VlZX////q6uoCAgLV1dWSkpJcXFyhoaFTU1Ovr6+KiooSl+HfAAAADnRSTlPzLbT57/kv+wCzLrEssSJz7GwAAAAJcEhZcwAACxMAAAsTAQCanBgAAABBSURBVHicLctRDoAgDATRRSotiNsCev+rGhL/JnkZWKqKIxmKxCBRUON0z7wgw+ejBHR5zB33+1OTWCQarO+92wdWAwJ7fte5tAAAAABJRU5ErkJggg==","blurWidth":8,"blurHeight":8}; \ No newline at end of file diff --git a/anyclip/src/assets/img/favicon/favicon-32x32-1621329404738.png b/anyclip/src/assets/img/favicon/favicon-32x32-1621329404738.png new file mode 100644 index 0000000..0724e19 --- /dev/null +++ b/anyclip/src/assets/img/favicon/favicon-32x32-1621329404738.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/favicon-32x32-1621329404738.a0439f9c.png","height":32,"width":32,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAMAAADz0U65AAAAOVBMVEX////Ly8uPj4+ZmZnV1dVMaXH///9nZ2ddXV3t7e0vLy8uLi4CAgLV1dWQkJCgoKDHx8dUVFSwsLDm6qi1AAAADHRSTlMu97Sx8QAw8/2y+vpPqVL3AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAPklEQVR4nDXLQQ6AIAwF0VEKBf0t4v0PazRxN8nLUBmb71SaZZe8MbJHHCpYj3lfcmxFzjc+OlXAckkO//4ATbECJt5kAZwAAAAASUVORK5CYII=","blurWidth":8,"blurHeight":8}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/blue.png b/anyclip/src/assets/img/form-banner/blue.png new file mode 100644 index 0000000..513d39d --- /dev/null +++ b/anyclip/src/assets/img/form-banner/blue.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/blue.355e1246.png","height":80,"width":1920,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAABlBMVEUzjMs8ks+XDtMVAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAEUlEQVR4nGNgYGBgZGRkZAAAABcABdzbLC4AAAAASUVORK5CYII=","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/coffee.png b/anyclip/src/assets/img/form-banner/coffee.png new file mode 100644 index 0000000..4907983 --- /dev/null +++ b/anyclip/src/assets/img/form-banner/coffee.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/coffee.7bec6fe0.png","height":80,"width":1920,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAACVBMVEXV5OTDzsytsKlKK1TwAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEUlEQVR4nGNgYGBgYmRgYAAAABcABNbHRhYAAAAASUVORK5CYII=","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/desk.png b/anyclip/src/assets/img/form-banner/desk.png new file mode 100644 index 0000000..5388252 --- /dev/null +++ b/anyclip/src/assets/img/form-banner/desk.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/desk.0278fd77.png","height":80,"width":1920,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAACVBMVEWUpKair7Cwvb2EUuI8AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEUlEQVR4nGNgYGBgYmRgYAAAABcABNbHRhYAAAAASUVORK5CYII=","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/flower.png b/anyclip/src/assets/img/form-banner/flower.png new file mode 100644 index 0000000..000e50e --- /dev/null +++ b/anyclip/src/assets/img/form-banner/flower.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/flower.0669d83d.png","height":80,"width":1536,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAACVBMVEW327O0yq2+rafkqDzXAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEUlEQVR4nGNgYGBkYmRgYAAAAB0ABRyOgNQAAAAASUVORK5CYII=","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/green.png b/anyclip/src/assets/img/form-banner/green.png new file mode 100644 index 0000000..9bc4366 --- /dev/null +++ b/anyclip/src/assets/img/form-banner/green.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/green.356a9894.png","height":80,"width":1920,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAADFBMVEV5y69jwJ5au5JuxqgswQcvAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAEUlEQVR4nGNgYGBgZmRkYgIAACUACkFmRE0AAAAASUVORK5CYII=","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/notebook.png b/anyclip/src/assets/img/form-banner/notebook.png new file mode 100644 index 0000000..d7e2d7a --- /dev/null +++ b/anyclip/src/assets/img/form-banner/notebook.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/notebook.81224792.png","height":80,"width":1920,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAABlBMVEX0zWny3J+HstVbAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEUlEQVR4nGNgYGBgZGBgYAAAAA4AAnyhWpcAAAAASUVORK5CYII=","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/pink.png b/anyclip/src/assets/img/form-banner/pink.png new file mode 100644 index 0000000..3d8b6b2 --- /dev/null +++ b/anyclip/src/assets/img/form-banner/pink.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/pink.9180c2dd.png","height":80,"width":1920,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAACVBMVEX9pLT9rrv9wMa2bv0rAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAEUlEQVR4nGNgZGBiYmRkYAAAAC4ACOhUCY8AAAAASUVORK5CYII=","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/anyclip/src/assets/img/form-banner/textureblue.png b/anyclip/src/assets/img/form-banner/textureblue.png new file mode 100644 index 0000000..f647471 --- /dev/null +++ b/anyclip/src/assets/img/form-banner/textureblue.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/textureblue.77c9842d.png","height":80,"width":1920,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAABlBMVEViyspgycnqWTS1AAAACXBIWXMAAAsTAAALEwEAmpwYAAAADUlEQVR4nGNgYGQAAwAAEAACr3pF0AAAAABJRU5ErkJggg==","blurWidth":8,"blurHeight":1}; \ No newline at end of file diff --git a/src/assets/img/logo-symbol.png b/anyclip/src/assets/img/logo-symbol.png similarity index 100% rename from src/assets/img/logo-symbol.png rename to anyclip/src/assets/img/logo-symbol.png diff --git a/src/assets/img/logo-text.png b/anyclip/src/assets/img/logo-text.png similarity index 100% rename from src/assets/img/logo-text.png rename to anyclip/src/assets/img/logo-text.png diff --git a/anyclip/src/assets/img/logo.png b/anyclip/src/assets/img/logo.png new file mode 100644 index 0000000..6df31fc --- /dev/null +++ b/anyclip/src/assets/img/logo.png @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/logo.564beaa1.png","height":128,"width":507,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAACCAMAAABSSm3fAAAACVBMVEX///////////+OSuX+AAAAA3RSTlMlMVUZrOIcAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAF0lEQVR4nGNgZGJgZGRgYGBgZALRjAwAAIEADMddC4AAAAAASUVORK5CYII=","blurWidth":8,"blurHeight":2}; \ No newline at end of file diff --git a/src/assets/img/no-image-portrait.svg b/anyclip/src/assets/img/no-image-portrait.svg similarity index 100% rename from src/assets/img/no-image-portrait.svg rename to anyclip/src/assets/img/no-image-portrait.svg diff --git a/anyclip/src/assets/img/source-icons/instagram.svg b/anyclip/src/assets/img/source-icons/instagram.svg new file mode 100644 index 0000000..48c5302 --- /dev/null +++ b/anyclip/src/assets/img/source-icons/instagram.svg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/instagram.f8cc4a31.svg","height":21,"width":21,"blurWidth":0,"blurHeight":0}; \ No newline at end of file diff --git a/anyclip/src/assets/img/source-icons/mrss.svg b/anyclip/src/assets/img/source-icons/mrss.svg new file mode 100644 index 0000000..47f2254 --- /dev/null +++ b/anyclip/src/assets/img/source-icons/mrss.svg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/mrss.245f1778.svg","height":21,"width":21,"blurWidth":0,"blurHeight":0}; \ No newline at end of file diff --git a/anyclip/src/assets/img/source-icons/ms_stream.svg b/anyclip/src/assets/img/source-icons/ms_stream.svg new file mode 100644 index 0000000..71158e4 --- /dev/null +++ b/anyclip/src/assets/img/source-icons/ms_stream.svg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/ms_stream.9514a9d7.svg","height":21,"width":21,"blurWidth":0,"blurHeight":0}; \ No newline at end of file diff --git a/anyclip/src/assets/img/source-icons/sharepoint.svg b/anyclip/src/assets/img/source-icons/sharepoint.svg new file mode 100644 index 0000000..25338ae --- /dev/null +++ b/anyclip/src/assets/img/source-icons/sharepoint.svg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/sharepoint.44a77c14.svg","height":21,"width":21,"blurWidth":0,"blurHeight":0}; \ No newline at end of file diff --git a/anyclip/src/assets/img/source-icons/teams.svg b/anyclip/src/assets/img/source-icons/teams.svg new file mode 100644 index 0000000..784d3e1 --- /dev/null +++ b/anyclip/src/assets/img/source-icons/teams.svg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/teams.ab654a7b.svg","height":21,"width":21,"blurWidth":0,"blurHeight":0}; \ No newline at end of file diff --git a/anyclip/src/assets/img/source-icons/tiktok.svg b/anyclip/src/assets/img/source-icons/tiktok.svg new file mode 100644 index 0000000..267741a --- /dev/null +++ b/anyclip/src/assets/img/source-icons/tiktok.svg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/tiktok.04e40fb5.svg","height":21,"width":21,"blurWidth":0,"blurHeight":0}; \ No newline at end of file diff --git a/anyclip/src/assets/img/source-icons/zoom.svg b/anyclip/src/assets/img/source-icons/zoom.svg new file mode 100644 index 0000000..023d2ce --- /dev/null +++ b/anyclip/src/assets/img/source-icons/zoom.svg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/zoom.53b9653f.svg","height":21,"width":21,"blurWidth":0,"blurHeight":0}; \ No newline at end of file diff --git a/anyclip/src/assets/img/upload/default-img-audio-thumbnail.jpg b/anyclip/src/assets/img/upload/default-img-audio-thumbnail.jpg new file mode 100644 index 0000000..6a817a6 --- /dev/null +++ b/anyclip/src/assets/img/upload/default-img-audio-thumbnail.jpg @@ -0,0 +1 @@ +export default {"src":"/_next/static/media/default-img-audio-thumbnail.0f14be28.jpg","height":672,"width":1196,"blurDataURL":"data:image/jpeg;base64,/9j/2wBDAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/2wBDAQoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/wgARCAAEAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAACOg//EABoQAAICAwAAAAAAAAAAAAAAABETACECEjH/2gAIAQEAAT8AZktdDY8uf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Af//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Af//Z","blurWidth":8,"blurHeight":4}; \ No newline at end of file diff --git a/anyclip/src/client/components/forbidden.ts b/anyclip/src/client/components/forbidden.ts new file mode 100644 index 0000000..4e52b4f --- /dev/null +++ b/anyclip/src/client/components/forbidden.ts @@ -0,0 +1,33 @@ +import { + HTTP_ERROR_FALLBACK_ERROR_CODE, + type HTTPAccessFallbackError, +} from './http-access-fallback/http-access-fallback' + +// TODO: Add `forbidden` docs +/** + * @experimental + * This function allows you to render the [forbidden.js file](https://nextjs.org/docs/app/api-reference/file-conventions/forbidden) + * within a route segment as well as inject a tag. + * + * `forbidden()` can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * Read more: [Next.js Docs: `forbidden`](https://nextjs.org/docs/app/api-reference/functions/forbidden) + */ + +const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};403` + +export function forbidden(): never { + if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) { + throw new Error( + `\`forbidden()\` is experimental and only allowed to be enabled when \`experimental.authInterrupts\` is enabled.` + ) + } + + // eslint-disable-next-line no-throw-literal + const error = new Error(DIGEST) as HTTPAccessFallbackError + ;(error as HTTPAccessFallbackError).digest = DIGEST + throw error +} diff --git a/src/client/components/http-access-fallback/http-access-fallback.ts b/anyclip/src/client/components/http-access-fallback/http-access-fallback.ts similarity index 100% rename from src/client/components/http-access-fallback/http-access-fallback.ts rename to anyclip/src/client/components/http-access-fallback/http-access-fallback.ts diff --git a/src/client/components/is-next-router-error.ts b/anyclip/src/client/components/is-next-router-error.ts similarity index 100% rename from src/client/components/is-next-router-error.ts rename to anyclip/src/client/components/is-next-router-error.ts diff --git a/anyclip/src/client/components/navigation.react-server.ts b/anyclip/src/client/components/navigation.react-server.ts new file mode 100644 index 0000000..eb7a009 --- /dev/null +++ b/anyclip/src/client/components/navigation.react-server.ts @@ -0,0 +1,41 @@ +/** @internal */ +class ReadonlyURLSearchParamsError extends Error { + constructor() { + super( + 'Method unavailable on `ReadonlyURLSearchParams`. Read more: https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams' + ) + } +} + +class ReadonlyURLSearchParams extends URLSearchParams { + /** @deprecated Method unavailable on `ReadonlyURLSearchParams`. Read more: https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams */ + append() { + throw new ReadonlyURLSearchParamsError() + } + /** @deprecated Method unavailable on `ReadonlyURLSearchParams`. Read more: https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams */ + delete() { + throw new ReadonlyURLSearchParamsError() + } + /** @deprecated Method unavailable on `ReadonlyURLSearchParams`. Read more: https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams */ + set() { + throw new ReadonlyURLSearchParamsError() + } + /** @deprecated Method unavailable on `ReadonlyURLSearchParams`. Read more: https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams */ + sort() { + throw new ReadonlyURLSearchParamsError() + } +} + +export function unstable_isUnrecognizedActionError(): boolean { + throw new Error( + '`unstable_isUnrecognizedActionError` can only be used on the client.' + ) +} + +export { redirect, permanentRedirect } from './redirect' +export { RedirectType } from './redirect-error' +export { notFound } from './not-found' +export { forbidden } from './forbidden' +export { unauthorized } from './unauthorized' +export { unstable_rethrow } from './unstable-rethrow' +export { ReadonlyURLSearchParams } diff --git a/anyclip/src/client/components/navigation.ts b/anyclip/src/client/components/navigation.ts new file mode 100644 index 0000000..5ba95f3 --- /dev/null +++ b/anyclip/src/client/components/navigation.ts @@ -0,0 +1,287 @@ +import type { FlightRouterState } from '../../server/app-render/types' +import type { Params } from '../../server/request/params' + +import { useContext, useMemo } from 'react' +import { + AppRouterContext, + LayoutRouterContext, + type AppRouterInstance, +} from '../../shared/lib/app-router-context.shared-runtime' +import { + SearchParamsContext, + PathnameContext, + PathParamsContext, +} from '../../shared/lib/hooks-client-context.shared-runtime' +import { getSegmentValue } from './router-reducer/reducers/get-segment-value' +import { PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment' +import { ReadonlyURLSearchParams } from './navigation.react-server' + +const useDynamicRouteParams = + typeof window === 'undefined' + ? ( + require('../../server/app-render/dynamic-rendering') as typeof import('../../server/app-render/dynamic-rendering') + ).useDynamicRouteParams + : undefined + +/** + * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook + * that lets you *read* the current URL's search parameters. + * + * Learn more about [`URLSearchParams` on MDN](https://developer.mozilla.org/docs/Web/API/URLSearchParams) + * + * @example + * ```ts + * "use client" + * import { useSearchParams } from 'next/navigation' + * + * export default function Page() { + * const searchParams = useSearchParams() + * searchParams.get('foo') // returns 'bar' when ?foo=bar + * // ... + * } + * ``` + * + * Read more: [Next.js Docs: `useSearchParams`](https://nextjs.org/docs/app/api-reference/functions/use-search-params) + */ +// Client components API +export function useSearchParams(): ReadonlyURLSearchParams { + const searchParams = useContext(SearchParamsContext) + + // In the case where this is `null`, the compat types added in + // `next-env.d.ts` will add a new overload that changes the return type to + // include `null`. + const readonlySearchParams = useMemo(() => { + if (!searchParams) { + // When the router is not ready in pages, we won't have the search params + // available. + return null + } + + return new ReadonlyURLSearchParams(searchParams) + }, [searchParams]) as ReadonlyURLSearchParams + + if (typeof window === 'undefined') { + // AsyncLocalStorage should not be included in the client bundle. + const { bailoutToClientRendering } = + require('./bailout-to-client-rendering') as typeof import('./bailout-to-client-rendering') + // TODO-APP: handle dynamic = 'force-static' here and on the client + bailoutToClientRendering('useSearchParams()') + } + + return readonlySearchParams +} + +/** + * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook + * that lets you read the current URL's pathname. + * + * @example + * ```ts + * "use client" + * import { usePathname } from 'next/navigation' + * + * export default function Page() { + * const pathname = usePathname() // returns "/dashboard" on /dashboard?foo=bar + * // ... + * } + * ``` + * + * Read more: [Next.js Docs: `usePathname`](https://nextjs.org/docs/app/api-reference/functions/use-pathname) + */ +// Client components API +export function usePathname(): string { + useDynamicRouteParams?.('usePathname()') + + // In the case where this is `null`, the compat types added in `next-env.d.ts` + // will add a new overload that changes the return type to include `null`. + return useContext(PathnameContext) as string +} + +// Client components API +export { + ServerInsertedHTMLContext, + useServerInsertedHTML, +} from '../../shared/lib/server-inserted-html.shared-runtime' + +/** + * + * This hook allows you to programmatically change routes inside [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components). + * + * @example + * ```ts + * "use client" + * import { useRouter } from 'next/navigation' + * + * export default function Page() { + * const router = useRouter() + * // ... + * router.push('/dashboard') // Navigate to /dashboard + * } + * ``` + * + * Read more: [Next.js Docs: `useRouter`](https://nextjs.org/docs/app/api-reference/functions/use-router) + */ +// Client components API +export function useRouter(): AppRouterInstance { + const router = useContext(AppRouterContext) + if (router === null) { + throw new Error('invariant expected app router to be mounted') + } + + return router +} + +/** + * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook + * that lets you read a route's dynamic params filled in by the current URL. + * + * @example + * ```ts + * "use client" + * import { useParams } from 'next/navigation' + * + * export default function Page() { + * // on /dashboard/[team] where pathname is /dashboard/nextjs + * const { team } = useParams() // team === "nextjs" + * } + * ``` + * + * Read more: [Next.js Docs: `useParams`](https://nextjs.org/docs/app/api-reference/functions/use-params) + */ +// Client components API +export function useParams(): T { + useDynamicRouteParams?.('useParams()') + + return useContext(PathParamsContext) as T +} + +/** Get the canonical parameters from the current level to the leaf node. */ +// Client components API +function getSelectedLayoutSegmentPath( + tree: FlightRouterState, + parallelRouteKey: string, + first = true, + segmentPath: string[] = [] +): string[] { + let node: FlightRouterState + if (first) { + // Use the provided parallel route key on the first parallel route + node = tree[1][parallelRouteKey] + } else { + // After first parallel route prefer children, if there's no children pick the first parallel route. + const parallelRoutes = tree[1] + node = parallelRoutes.children ?? Object.values(parallelRoutes)[0] + } + + if (!node) return segmentPath + const segment = node[0] + + let segmentValue = getSegmentValue(segment) + + if (!segmentValue || segmentValue.startsWith(PAGE_SEGMENT_KEY)) { + return segmentPath + } + + segmentPath.push(segmentValue) + + return getSelectedLayoutSegmentPath( + node, + parallelRouteKey, + false, + segmentPath + ) +} + +/** + * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook + * that lets you read the active route segments **below** the Layout it is called from. + * + * @example + * ```ts + * 'use client' + * + * import { useSelectedLayoutSegments } from 'next/navigation' + * + * export default function ExampleClientComponent() { + * const segments = useSelectedLayoutSegments() + * + * return ( + *
    + * {segments.map((segment, index) => ( + *
  • {segment}
  • + * ))} + *
+ * ) + * } + * ``` + * + * Read more: [Next.js Docs: `useSelectedLayoutSegments`](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segments) + */ +// Client components API +export function useSelectedLayoutSegments( + parallelRouteKey: string = 'children' +): string[] { + useDynamicRouteParams?.('useSelectedLayoutSegments()') + + const context = useContext(LayoutRouterContext) + // @ts-expect-error This only happens in `pages`. Type is overwritten in navigation.d.ts + if (!context) return null + + return getSelectedLayoutSegmentPath(context.parentTree, parallelRouteKey) +} + +/** + * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook + * that lets you read the active route segment **one level below** the Layout it is called from. + * + * @example + * ```ts + * 'use client' + * import { useSelectedLayoutSegment } from 'next/navigation' + * + * export default function ExampleClientComponent() { + * const segment = useSelectedLayoutSegment() + * + * return

Active segment: {segment}

+ * } + * ``` + * + * Read more: [Next.js Docs: `useSelectedLayoutSegment`](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) + */ +// Client components API +export function useSelectedLayoutSegment( + parallelRouteKey: string = 'children' +): string | null { + useDynamicRouteParams?.('useSelectedLayoutSegment()') + + const selectedLayoutSegments = useSelectedLayoutSegments(parallelRouteKey) + + if (!selectedLayoutSegments || selectedLayoutSegments.length === 0) { + return null + } + + const selectedLayoutSegment = + parallelRouteKey === 'children' + ? selectedLayoutSegments[0] + : selectedLayoutSegments[selectedLayoutSegments.length - 1] + + // if the default slot is showing, we return null since it's not technically "selected" (it's a fallback) + // and returning an internal value like `__DEFAULT__` would be confusing. + return selectedLayoutSegment === DEFAULT_SEGMENT_KEY + ? null + : selectedLayoutSegment +} + +export { unstable_isUnrecognizedActionError } from './unrecognized-action-error' + +// Shared components APIs +export { + notFound, + forbidden, + unauthorized, + redirect, + permanentRedirect, + RedirectType, + ReadonlyURLSearchParams, + unstable_rethrow, +} from './navigation.react-server' diff --git a/anyclip/src/client/components/not-found.ts b/anyclip/src/client/components/not-found.ts new file mode 100644 index 0000000..7c49aed --- /dev/null +++ b/anyclip/src/client/components/not-found.ts @@ -0,0 +1,29 @@ +import { + HTTP_ERROR_FALLBACK_ERROR_CODE, + type HTTPAccessFallbackError, +} from './http-access-fallback/http-access-fallback' + +/** + * This function allows you to render the [not-found.js file](https://nextjs.org/docs/app/api-reference/file-conventions/not-found) + * within a route segment as well as inject a tag. + * + * `notFound()` can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * - In a Server Component, this will insert a `` meta tag and set the status code to 404. + * - In a Route Handler or Server Action, it will serve a 404 to the caller. + * + * Read more: [Next.js Docs: `notFound`](https://nextjs.org/docs/app/api-reference/functions/not-found) + */ + +const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};404` + +export function notFound(): never { + // eslint-disable-next-line no-throw-literal + const error = new Error(DIGEST) as HTTPAccessFallbackError + ;(error as HTTPAccessFallbackError).digest = DIGEST + + throw error +} diff --git a/src/client/components/redirect-error.ts b/anyclip/src/client/components/redirect-error.ts similarity index 100% rename from src/client/components/redirect-error.ts rename to anyclip/src/client/components/redirect-error.ts diff --git a/src/client/components/redirect-status-code.ts b/anyclip/src/client/components/redirect-status-code.ts similarity index 100% rename from src/client/components/redirect-status-code.ts rename to anyclip/src/client/components/redirect-status-code.ts diff --git a/anyclip/src/client/components/redirect.ts b/anyclip/src/client/components/redirect.ts new file mode 100644 index 0000000..d5db399 --- /dev/null +++ b/anyclip/src/client/components/redirect.ts @@ -0,0 +1,99 @@ +import { RedirectStatusCode } from './redirect-status-code' +import { + RedirectType, + type RedirectError, + isRedirectError, + REDIRECT_ERROR_CODE, +} from './redirect-error' + +const actionAsyncStorage = + typeof window === 'undefined' + ? ( + require('../../server/app-render/action-async-storage.external') as typeof import('../../server/app-render/action-async-storage.external') + ).actionAsyncStorage + : undefined + +export function getRedirectError( + url: string, + type: RedirectType, + statusCode: RedirectStatusCode = RedirectStatusCode.TemporaryRedirect +): RedirectError { + const error = new Error(REDIRECT_ERROR_CODE) as RedirectError + error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${statusCode};` + return error +} + +/** + * This function allows you to redirect the user to another URL. It can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * - In a Server Component, this will insert a meta tag to redirect the user to the target page. + * - In a Route Handler or Server Action, it will serve a 307/303 to the caller. + * - In a Server Action, type defaults to 'push' and 'replace' elsewhere. + * + * Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect) + */ +export function redirect( + /** The URL to redirect to */ + url: string, + type?: RedirectType +): never { + type ??= actionAsyncStorage?.getStore()?.isAction + ? RedirectType.push + : RedirectType.replace + + throw getRedirectError(url, type, RedirectStatusCode.TemporaryRedirect) +} + +/** + * This function allows you to redirect the user to another URL. It can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * - In a Server Component, this will insert a meta tag to redirect the user to the target page. + * - In a Route Handler or Server Action, it will serve a 308/303 to the caller. + * + * Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect) + */ +export function permanentRedirect( + /** The URL to redirect to */ + url: string, + type: RedirectType = RedirectType.replace +): never { + throw getRedirectError(url, type, RedirectStatusCode.PermanentRedirect) +} + +/** + * Returns the encoded URL from the error if it's a RedirectError, null + * otherwise. Note that this does not validate the URL returned. + * + * @param error the error that may be a redirect error + * @return the url if the error was a redirect error + */ +export function getURLFromRedirectError(error: RedirectError): string +export function getURLFromRedirectError(error: unknown): string | null { + if (!isRedirectError(error)) return null + + // Slices off the beginning of the digest that contains the code and the + // separating ';'. + return error.digest.split(';').slice(2, -2).join(';') +} + +export function getRedirectTypeFromError(error: RedirectError): RedirectType { + if (!isRedirectError(error)) { + throw new Error('Not a redirect error') + } + + return error.digest.split(';', 2)[1] as RedirectType +} + +export function getRedirectStatusCodeFromError(error: RedirectError): number { + if (!isRedirectError(error)) { + throw new Error('Not a redirect error') + } + + return Number(error.digest.split(';').at(-2)) +} diff --git a/anyclip/src/client/components/router-reducer/reducers/get-segment-value.ts b/anyclip/src/client/components/router-reducer/reducers/get-segment-value.ts new file mode 100644 index 0000000..2ec8269 --- /dev/null +++ b/anyclip/src/client/components/router-reducer/reducers/get-segment-value.ts @@ -0,0 +1,5 @@ +import type { Segment } from '../../../../server/app-render/types' + +export function getSegmentValue(segment: Segment) { + return Array.isArray(segment) ? segment[1] : segment +} diff --git a/anyclip/src/client/components/unauthorized.ts b/anyclip/src/client/components/unauthorized.ts new file mode 100644 index 0000000..59c8aeb --- /dev/null +++ b/anyclip/src/client/components/unauthorized.ts @@ -0,0 +1,34 @@ +import { + HTTP_ERROR_FALLBACK_ERROR_CODE, + type HTTPAccessFallbackError, +} from './http-access-fallback/http-access-fallback' + +// TODO: Add `unauthorized` docs +/** + * @experimental + * This function allows you to render the [unauthorized.js file](https://nextjs.org/docs/app/api-reference/file-conventions/unauthorized) + * within a route segment as well as inject a tag. + * + * `unauthorized()` can be used in + * [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components), + * [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and + * [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). + * + * + * Read more: [Next.js Docs: `unauthorized`](https://nextjs.org/docs/app/api-reference/functions/unauthorized) + */ + +const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};401` + +export function unauthorized(): never { + if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) { + throw new Error( + `\`unauthorized()\` is experimental and only allowed to be used when \`experimental.authInterrupts\` is enabled.` + ) + } + + // eslint-disable-next-line no-throw-literal + const error = new Error(DIGEST) as HTTPAccessFallbackError + ;(error as HTTPAccessFallbackError).digest = DIGEST + throw error +} diff --git a/anyclip/src/client/components/unrecognized-action-error.ts b/anyclip/src/client/components/unrecognized-action-error.ts new file mode 100644 index 0000000..33b6dc6 --- /dev/null +++ b/anyclip/src/client/components/unrecognized-action-error.ts @@ -0,0 +1,34 @@ +export class UnrecognizedActionError extends Error { + constructor(...args: ConstructorParameters) { + super(...args) + this.name = 'UnrecognizedActionError' + } +} + +/** + * Check whether a server action call failed because the server action was not recognized by the server. + * This can happen if the client and the server are not from the same deployment. + * + * Example usage: + * ```ts + * try { + * await myServerAction(); + * } catch (err) { + * if (unstable_isUnrecognizedActionError(err)) { + * // The client is from a different deployment than the server. + * // Reloading the page will fix this mismatch. + * window.alert("Please refresh the page and try again"); + * return; + * } + * } + * ``` + * */ +export function unstable_isUnrecognizedActionError( + error: unknown +): error is UnrecognizedActionError { + return !!( + error && + typeof error === 'object' && + error instanceof UnrecognizedActionError + ) +} diff --git a/anyclip/src/client/components/unstable-rethrow.browser.ts b/anyclip/src/client/components/unstable-rethrow.browser.ts new file mode 100644 index 0000000..76aff5f --- /dev/null +++ b/anyclip/src/client/components/unstable-rethrow.browser.ts @@ -0,0 +1,12 @@ +import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr' +import { isNextRouterError } from './is-next-router-error' + +export function unstable_rethrow(error: unknown): void { + if (isNextRouterError(error) || isBailoutToCSRError(error)) { + throw error + } + + if (error instanceof Error && 'cause' in error) { + unstable_rethrow(error.cause) + } +} diff --git a/anyclip/src/client/components/unstable-rethrow.ts b/anyclip/src/client/components/unstable-rethrow.ts new file mode 100644 index 0000000..de82d99 --- /dev/null +++ b/anyclip/src/client/components/unstable-rethrow.ts @@ -0,0 +1,15 @@ +/** + * This function should be used to rethrow internal Next.js errors so that they can be handled by the framework. + * When wrapping an API that uses errors to interrupt control flow, you should use this function before you do any error handling. + * This function will rethrow the error if it is a Next.js error so it can be handled, otherwise it will do nothing. + * + * Read more: [Next.js Docs: `unstable_rethrow`](https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow) + */ +export const unstable_rethrow = + typeof window === 'undefined' + ? ( + require('./unstable-rethrow.server') as typeof import('./unstable-rethrow.server') + ).unstable_rethrow + : ( + require('./unstable-rethrow.browser') as typeof import('./unstable-rethrow.browser') + ).unstable_rethrow diff --git a/src/client/portal/index.tsx b/anyclip/src/client/portal/index.tsx similarity index 100% rename from src/client/portal/index.tsx rename to anyclip/src/client/portal/index.tsx diff --git a/src/client/react-client-callbacks/on-recoverable-error.ts b/anyclip/src/client/react-client-callbacks/on-recoverable-error.ts similarity index 100% rename from src/client/react-client-callbacks/on-recoverable-error.ts rename to anyclip/src/client/react-client-callbacks/on-recoverable-error.ts diff --git a/src/client/react-client-callbacks/report-global-error.ts b/anyclip/src/client/react-client-callbacks/report-global-error.ts similarity index 100% rename from src/client/react-client-callbacks/report-global-error.ts rename to anyclip/src/client/react-client-callbacks/report-global-error.ts diff --git a/src/client/tracing/tracer.ts b/anyclip/src/client/tracing/tracer.ts similarity index 100% rename from src/client/tracing/tracer.ts rename to anyclip/src/client/tracing/tracer.ts diff --git a/anyclip/src/graphql/services/_helpers/common.js b/anyclip/src/graphql/services/_helpers/common.js new file mode 100644 index 0000000..9ee697e --- /dev/null +++ b/anyclip/src/graphql/services/_helpers/common.js @@ -0,0 +1,14 @@ +export const getSafetyPath = (fileUrl) => + fileUrl + .replace(/\\/g, '/') + .split('graphql/services/') + .pop() + .replace(/\..*?$/, ''); + +export const createModuleNameFromMetaUrl = (fileUrl) => + fileUrl + .split(/graphql[\\/]services[\\/]/) + .pop() + .replace(/\..*?$/, '') + .replace(/[\\/]constants[\\/]index/, '') + .replace(/[\\/]/g, '_'); diff --git a/anyclip/src/graphql/services/accounts/constants/index.js b/anyclip/src/graphql/services/accounts/constants/index.js new file mode 100644 index 0000000..1fd65a1 --- /dev/null +++ b/anyclip/src/graphql/services/accounts/constants/index.js @@ -0,0 +1,14 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_ACCOUNTS = `${MODULE_NAME}_GET_ACCOUNTS`; +export const GET_ACCOUNT = `${MODULE_NAME}_GET_ACCOUNT`; +export const GET_SALESFORCE_DATA = `${MODULE_NAME}_GET_SALESFORCE_DATA`; +export const GET_CONTENT_OWNERS = `${MODULE_NAME}_GET_CONTENT_OWNERS`; +// mutation +export const UPDATE_ACCOUNT = `${MODULE_NAME}_UPDATE_ACCOUNT`; +export const CREATE_ACCOUNT = `${MODULE_NAME}_CREATE_ACCOUNT`; +export const UPDATE_CONTENT_OWNER = `${MODULE_NAME}_UPDATE_CONTENT_OWNER`; +export const DELETE_ALL_VIDEOS = `${MODULE_NAME}_DELETE_ALL_VIDEOS`; diff --git a/anyclip/src/graphql/services/accounts/types/payload/account.js b/anyclip/src/graphql/services/accounts/types/payload/account.js new file mode 100644 index 0000000..ec3040b --- /dev/null +++ b/anyclip/src/graphql/services/accounts/types/payload/account.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/accounts/types/payload/accounts.js b/anyclip/src/graphql/services/accounts/types/payload/accounts.js new file mode 100644 index 0000000..962334f --- /dev/null +++ b/anyclip/src/graphql/services/accounts/types/payload/accounts.js @@ -0,0 +1,29 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + type: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/accounts/types/payload/contentOwner.js b/anyclip/src/graphql/services/accounts/types/payload/contentOwner.js new file mode 100644 index 0000000..c763dda --- /dev/null +++ b/anyclip/src/graphql/services/accounts/types/payload/contentOwner.js @@ -0,0 +1,38 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + accountId: { + type: GraphQLString, + }, + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + status: { + type: GraphQLInt, + }, + type: { + type: GraphQLInt, + }, + comments: { + type: GraphQLString, + }, + salesforceId: { + type: GraphQLString, + }, + callToAction: { + type: GraphQLInt, + }, + isPublic: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/accounts/types/payload/contentOwners.js b/anyclip/src/graphql/services/accounts/types/payload/contentOwners.js new file mode 100644 index 0000000..01a954d --- /dev/null +++ b/anyclip/src/graphql/services/accounts/types/payload/contentOwners.js @@ -0,0 +1,26 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + accountId: { + type: GraphQLString, + }, + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/accounts/types/payload/deleteAllVideos.js b/anyclip/src/graphql/services/accounts/types/payload/deleteAllVideos.js new file mode 100644 index 0000000..ec3040b --- /dev/null +++ b/anyclip/src/graphql/services/accounts/types/payload/deleteAllVideos.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/accounts/types/payload/item.js b/anyclip/src/graphql/services/accounts/types/payload/item.js new file mode 100644 index 0000000..7c3d3ea --- /dev/null +++ b/anyclip/src/graphql/services/accounts/types/payload/item.js @@ -0,0 +1,211 @@ +import { GraphQLBoolean, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLString, + }, + name: { + type: GraphQLString, + }, + salesforceId: { + type: GraphQLString, + }, + type: { + type: GraphQLString, + }, + accountManager: { + type: GraphQLString, + }, + accountManagerEmail: { + type: GraphQLString, + }, + publisherDemand: { + type: GraphQLBoolean, + }, + adServingFeeBusinessModel: { + type: GraphQLString, + }, + adServingFeeDisplay: { + type: GraphQLFloat, + }, + adServingFeeVideo: { + type: GraphQLFloat, + }, + salesManager: { + type: GraphQLString, + }, + customLogoUrl: { + type: GraphQLString, + }, + avatarUrl: { + type: GraphQLString, + }, + avatar: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountsAvatar`, + fields: { + base64Url: { + type: GraphQLString, + }, + mimeType: { + type: GraphQLString, + }, + }, + }), + }, + customLoadingMessage: { + type: GraphQLString, + }, + customLoginPageUrl: { + type: GraphQLString, + }, + subdomain: { + type: GraphQLString, + }, + publisherRevShare: { + type: GraphQLInt, + }, + expenses: { + type: GraphQLInt, + }, + usersLimit: { + type: GraphQLInt, + }, + videoDuplicatesBy: { + type: GraphQLString, + }, + accountsFeatures: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountsFeatures`, + fields: { + id: { + type: GraphQLInt, + }, + value: { + type: GraphQLBoolean, + }, + }, + }), + ), + }, + accountDashboards: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountDashboards`, + fields: { + general: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountDashboardsGeneral`, + fields: { + id: { + type: GraphQLInt, + }, + uiName: { + type: GraphQLString, + }, + enabled: { + type: GraphQLBoolean, + }, + order: { + type: GraphQLInt, + }, + tooltip: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountDashboardsGeneralTooltip`, + fields: { + info: { + type: GraphQLString, + }, + link: { + type: GraphQLString, + }, + linkTitle: { + type: GraphQLString, + }, + }, + }), + }, + }, + }), + ), + }, + custom: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountDashboardsCustom`, + fields: { + id: { + type: GraphQLInt, + }, + uiName: { + type: GraphQLString, + }, + enabled: { + type: GraphQLBoolean, + }, + order: { + type: GraphQLInt, + }, + lookerReportId: { + type: GraphQLString, + }, + icon: { + type: GraphQLString, + }, + tooltip: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountDashboardsCustomTooltip`, + fields: { + info: { + type: GraphQLString, + }, + link: { + type: GraphQLString, + }, + linkTitle: { + type: GraphQLString, + }, + }, + }), + }, + publisherCustomAnalytics: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accountDashboardsCustomPublisherCustomAnalytics`, + fields: { + accountId: { + type: GraphQLInt, + }, + action: { + type: GraphQLString, + }, + analyticsId: { + type: GraphQLInt, + }, + id: { + type: GraphQLInt, + }, + publisherId: { + type: GraphQLInt, + }, + publisherIds: { + type: new GraphQLList(GraphQLInt), + }, + }, + }), + }, + }, + }), + ), + }, + }, + }), + }, + }, +}); diff --git a/anyclip/src/graphql/services/accounts/types/payload/salesForce.js b/anyclip/src/graphql/services/accounts/types/payload/salesForce.js new file mode 100644 index 0000000..ec3040b --- /dev/null +++ b/anyclip/src/graphql/services/accounts/types/payload/salesForce.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/adsServers/constants/index.js b/anyclip/src/graphql/services/adsServers/constants/index.js new file mode 100644 index 0000000..a539736 --- /dev/null +++ b/anyclip/src/graphql/services/adsServers/constants/index.js @@ -0,0 +1,5 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export const BULK_CHANGE_STATUS_ACTION = `${MODULE_NAME}_BULK_CHANGE_STATUS_ACTION`; diff --git a/anyclip/src/graphql/services/adsServers/types/input/itemCreate.js b/anyclip/src/graphql/services/adsServers/types/input/itemCreate.js new file mode 100644 index 0000000..b7f68d3 --- /dev/null +++ b/anyclip/src/graphql/services/adsServers/types/input/itemCreate.js @@ -0,0 +1,39 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +export const ITEM_INPUT_TYPE_NAME = 'AdServersPCNInputType'; + +const CreateInputType = new GraphQLInputObjectType({ + name: ITEM_INPUT_TYPE_NAME, + description: 'Module AdServersPCN Create Input Type', + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + status: { + type: GraphQLInt, + }, + url: { + type: GraphQLString, + }, + placementId: { + type: GraphQLInt, + }, + macro: { + type: GraphQLString, + }, + comments: { + type: GraphQLString, + }, + isDefaultForPlayerType: { + type: GraphQLBoolean, + }, + playerTypesIds: { + type: new GraphQLList(GraphQLInt), + }, + }, +}); + +export default CreateInputType; diff --git a/anyclip/src/graphql/services/adsServers/types/payload/bulkChangeStatusAction.js b/anyclip/src/graphql/services/adsServers/types/payload/bulkChangeStatusAction.js new file mode 100644 index 0000000..e0634c8 --- /dev/null +++ b/anyclip/src/graphql/services/adsServers/types/payload/bulkChangeStatusAction.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + ids: { + type: new GraphQLList(GraphQLInt), + }, + status: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/advertisers/constants/index.js b/anyclip/src/graphql/services/advertisers/constants/index.js new file mode 100644 index 0000000..5a5514c --- /dev/null +++ b/anyclip/src/graphql/services/advertisers/constants/index.js @@ -0,0 +1,12 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_ADVERTISERS = `${MODULE_NAME}_GET_ADVERTISERS`; +export const GET_ADVERTISER = `${MODULE_NAME}_GET_ADVERTISER`; +export const GET_ADVERTISER_DM_ACCOUNTS_OPTIONS = `${MODULE_NAME}GET_ADVERTISER_DM_ACCOUNTS_OPTIONS`; + +// mutations +export const CREATE_ADVERTISER = `${MODULE_NAME}_CREATE_ADVERTISER`; +export const UPDATE_ADVERTISER = `${MODULE_NAME}_UPDATE_ADVERTISER`; diff --git a/anyclip/src/graphql/services/advertisers/types/payload/advertiser.js b/anyclip/src/graphql/services/advertisers/types/payload/advertiser.js new file mode 100644 index 0000000..77a6557 --- /dev/null +++ b/anyclip/src/graphql/services/advertisers/types/payload/advertiser.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLInt } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/advertisers/types/payload/advertisers.js b/anyclip/src/graphql/services/advertisers/types/payload/advertisers.js new file mode 100644 index 0000000..5f462e6 --- /dev/null +++ b/anyclip/src/graphql/services/advertisers/types/payload/advertisers.js @@ -0,0 +1,26 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/advertisers/types/payload/item.js b/anyclip/src/graphql/services/advertisers/types/payload/item.js new file mode 100644 index 0000000..52b2c2d --- /dev/null +++ b/anyclip/src/graphql/services/advertisers/types/payload/item.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + demandAccountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/constants/tagLog.js b/anyclip/src/graphql/services/aiWorkbench/constants/tagLog.js new file mode 100644 index 0000000..4363c00 --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/constants/tagLog.js @@ -0,0 +1,9 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_AIW_TAGLOG_DATA = `${MODULE_NAME}_GET_AIW_TAGLOG_DATA`; +export const GET_AIW_TAGLOG_TAG_INFO = `${MODULE_NAME}_GET_AIW_TAGLOG_TAG_INFO`; +// mutations +export const UPSERT_TAGS = `${MODULE_NAME}_UPSERT_TAGS`; diff --git a/anyclip/src/graphql/services/aiWorkbench/constants/thumbnail.js b/anyclip/src/graphql/services/aiWorkbench/constants/thumbnail.js new file mode 100644 index 0000000..7c880ae --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/constants/thumbnail.js @@ -0,0 +1,11 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_AIW_THUMBNAIL_DATA = `${MODULE_NAME}_GET_AIW_THUMBNAIL_DATA`; +// mutations +export const GENERATE_AIW_THUMBNAIL_OPTIONS = `${MODULE_NAME}_GENERATE_AIW_THUMBNAIL_OPTIONS`; +export const SET_AIW_THUMBNAIL_DRAFT = `${MODULE_NAME}_SET_AIW_THUMBNAIL_DRAFT`; +export const PUBLISH_AIW_THUMBNAIL = `${MODULE_NAME}_PUBLISH_AIW_THUMBNAIL`; +export const SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME = `${MODULE_NAME}_SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME`; diff --git a/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/data.js b/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/data.js new file mode 100644 index 0000000..ba4d37a --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/data.js @@ -0,0 +1,23 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const GET_AIW_TAGLOG_DATA_PAYLOAD = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: GET_AIW_TAGLOG_DATA_PAYLOAD, + fields: { + videoId: { + type: GraphQLString, + }, + startTime: { + type: GraphQLInt, + }, + next: { + type: GraphQLInt, + }, + previous: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/tagInfo.js b/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/tagInfo.js new file mode 100644 index 0000000..87c6f75 --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/tagInfo.js @@ -0,0 +1,32 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const GET_AIW_TAGLOG_TAG_INFO_PAYLOAD = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: GET_AIW_TAGLOG_TAG_INFO_PAYLOAD, + fields: { + videoId: { + type: GraphQLString, + }, + tagId: { + type: GraphQLString, + }, + uid: { + type: GraphQLString, + }, + labelId: { + type: GraphQLString, + }, + labelValue: { + type: GraphQLString, + }, + text: { + type: GraphQLString, + }, + isIab: { + type: GraphQLBoolean, + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/upserTag.js b/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/upserTag.js new file mode 100644 index 0000000..ff0b2e6 --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/tagLog/payload/upserTag.js @@ -0,0 +1,47 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../../_helpers/common'; + +export const UPSERT_TAGS_PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: UPSERT_TAGS_PAYLOAD_NAME, + fields: { + videoId: { + type: GraphQLString, + }, + logRowUid: { + type: GraphQLString, + }, + startTime: { + type: GraphQLInt, + }, + keywords: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${UPSERT_TAGS_PAYLOAD_NAME}_keywords`, + fields: { + category: { + type: GraphQLString, + }, + value: { + type: GraphQLString, + }, + id: { + type: GraphQLString, + }, + labelId: { + type: GraphQLString, + }, + labelName: { + type: GraphQLString, + }, + color: { + type: GraphQLString, + }, + }, + }), + ), + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/generateThubnailOptions.js b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/generateThubnailOptions.js new file mode 100644 index 0000000..11d3403 --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/generateThubnailOptions.js @@ -0,0 +1,17 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const GENERATE_AIW_THUMBNAIL_OPTIONS_PAYLOAD = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: GENERATE_AIW_THUMBNAIL_OPTIONS_PAYLOAD, + fields: { + videoId: { + type: GraphQLString, + }, + notifyByEmail: { + type: GraphQLBoolean, + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/publishThumbnail.js b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/publishThumbnail.js new file mode 100644 index 0000000..cabf31c --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/publishThumbnail.js @@ -0,0 +1,17 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const PUBLISH_AIW_THUMBNAIL_PAYLOAD = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PUBLISH_AIW_THUMBNAIL_PAYLOAD, + fields: { + videoId: { + type: GraphQLString, + }, + publish: { + type: GraphQLBoolean, + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailDarft.js b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailDarft.js new file mode 100644 index 0000000..c3817bf --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailDarft.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const SET_AIW_THUMBNAIL_DRAFT_PAYLOAD = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: SET_AIW_THUMBNAIL_DRAFT_PAYLOAD, + fields: { + videoId: { + type: GraphQLString, + }, + uid: { + type: GraphQLString, + }, + thumbnail: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailFromVideoFrame.js b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailFromVideoFrame.js new file mode 100644 index 0000000..5bff447 --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailFromVideoFrame.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME_PAYLOAD = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME_PAYLOAD, + fields: { + videoId: { + type: GraphQLString, + }, + timestamp: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/thumbnail.js b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/thumbnail.js new file mode 100644 index 0000000..dc85698 --- /dev/null +++ b/anyclip/src/graphql/services/aiWorkbench/types/thumbnail/payload/thumbnail.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const GET_AIW_THUMBNAIL_PAYLOAD = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: GET_AIW_THUMBNAIL_PAYLOAD, + fields: { + videoId: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/analyticsRevenueOverview/constants/index.ts b/anyclip/src/graphql/services/analyticsRevenueOverview/constants/index.ts new file mode 100644 index 0000000..0dc8c01 --- /dev/null +++ b/anyclip/src/graphql/services/analyticsRevenueOverview/constants/index.ts @@ -0,0 +1,6 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_LIST = `${MODULE_NAME}_GET_LIST`; diff --git a/anyclip/src/graphql/services/analyticsRevenueOverview/types/payload/list.ts b/anyclip/src/graphql/services/analyticsRevenueOverview/types/payload/list.ts new file mode 100644 index 0000000..e20469a --- /dev/null +++ b/anyclip/src/graphql/services/analyticsRevenueOverview/types/payload/list.ts @@ -0,0 +1,73 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + timezone: { + type: GraphQLString, + }, + adFormat: { + type: GraphQLString, + }, + demandSources: { + type: new GraphQLList(GraphQLString), + }, + widgetIds: { + type: new GraphQLList(GraphQLString), + }, + countries: { + type: new GraphQLList(GraphQLString), + }, + domains: { + type: new GraphQLList(GraphQLString), + }, + devices: { + type: new GraphQLList(GraphQLString), + }, + range: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_ranges`, + fields: { + stringFrom: { + type: GraphQLString, + }, + stringTo: { + type: GraphQLString, + }, + timezone: { + type: GraphQLString, + }, + }, + }), + }, + interval: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_interval`, + fields: { + unit: { + type: GraphQLString, + }, + value: { + type: GraphQLInt, + }, + }, + }), + }, + }, +}); diff --git a/anyclip/src/graphql/services/configuration/resolvers/iab.js b/anyclip/src/graphql/services/configuration/resolvers/iab.js new file mode 100644 index 0000000..1a512df --- /dev/null +++ b/anyclip/src/graphql/services/configuration/resolvers/iab.js @@ -0,0 +1,2976 @@ +const IAB_CONFIG = { + iabVersion: 'IAB_V2', + categories: [ + { + id: '1', + name: 'Automotive', + highlight: true, + categories: [ + { + id: '1.1', + name: 'Auto Body Styles', + categories: [ + { + id: '1.1.1', + name: 'Commercial Trucks', + highlight: true, + }, + { + id: '1.1.2', + name: 'Convertible', + highlight: true, + }, + { + id: '1.1.3', + name: 'Coupe', + highlight: true, + }, + { + id: '1.1.4', + name: 'Crossover', + highlight: true, + }, + { + id: '1.1.5', + name: 'Hatchback', + highlight: true, + }, + { + id: '1.1.6', + name: 'Microcar', + highlight: true, + }, + { + id: '1.1.7', + name: 'Minivan', + highlight: true, + }, + { + id: '1.1.8', + name: 'Off-Road Vehicles', + highlight: true, + }, + { + id: '1.1.9', + name: 'Pickup Trucks', + highlight: true, + }, + { + id: '1.1.10', + name: 'Sedan', + highlight: true, + }, + { + id: '1.1.11', + name: 'Station Wagon', + highlight: true, + }, + { + id: '1.1.12', + name: 'SUV', + highlight: true, + }, + { + id: '1.1.13', + name: 'Van', + highlight: true, + }, + ], + }, + { + id: '1.2', + name: 'Auto Buying and Selling', + }, + { + id: '1.3', + name: 'Auto Insurance', + }, + { + id: '1.4', + name: 'Auto Parts', + }, + { + id: '1.5', + name: 'Auto Recalls', + }, + { + id: '1.6', + name: 'Auto Repair', + }, + { + id: '1.7', + name: 'Auto Safety', + }, + { + id: '1.8', + name: 'Auto Shows', + }, + { + id: '1.9', + name: 'Auto Technology', + categories: [ + { + id: '1.9.1', + name: 'Auto Infotainment Technologies', + }, + { + id: '1.9.2', + name: 'Auto Navigation Systems', + }, + { + id: '1.9.3', + name: 'Auto Safety Technologies', + }, + ], + }, + { + id: '1.10', + name: 'Auto Type', + categories: [ + { + id: '1.10.1', + name: 'Budget Cars', + }, + { + id: '1.10.2', + name: 'Certified Pre-Owned Cars', + }, + { + id: '1.10.3', + name: 'Classic Cars', + }, + { + id: '1.10.4', + name: 'Concept Cars', + }, + { + id: '1.10.5', + name: 'Driverless Cars', + }, + { + id: '1.10.6', + name: 'Green Vehicles', + }, + ], + }, + { + id: '1.11', + name: 'Car Culture', + }, + { + id: '1.12', + name: 'Dash Cam Videos', + }, + { + id: '1.13', + name: 'Motorcycles', + }, + { + id: '1.14', + name: 'Road-Side Assistance', + }, + { + id: '1.15', + name: 'Scooters', + }, + ], + }, + { + id: '2', + name: 'Books and Literature', + categories: [ + { + id: '2.1', + name: 'Art and Photography Books', + }, + { + id: '2.2', + name: 'Biographies', + }, + { + id: '2.3', + name: "Children's Literature", + }, + { + id: '2.4', + name: 'Comics and Graphic Novels', + }, + { + id: '2.5', + name: 'Cookbooks', + }, + { + id: '2.6', + name: 'Fiction', + }, + { + id: '2.7', + name: 'Poetry', + }, + { + id: '2.8', + name: 'Travel Books', + }, + { + id: '2.9', + name: 'Young Adult Literature', + }, + ], + }, + { + id: '3', + name: 'Business and Finance', + highlight: true, + categories: [ + { + id: '3.1', + name: 'Business', + highlight: true, + categories: [ + { + id: '3.1.1', + name: 'Business Accounting & Finance', + }, + { + id: '3.1.2', + name: 'Business Administration', + }, + { + id: '3.1.3', + name: 'Business Banking & Finance', + categories: [ + { + id: '3.1.3.1', + name: 'Angel Investment', + }, + { + id: '3.1.3.2', + name: 'Bankruptcy', + }, + { + id: '3.1.3.3', + name: 'Business Loans', + }, + { + id: '3.1.3.4', + name: 'Debt Factoring & Invoice Discounting', + }, + { + id: '3.1.3.5', + name: 'Mergers and Acquisitions', + }, + { + id: '3.1.3.6', + name: 'Private Equity', + }, + { + id: '3.1.3.7', + name: 'Sale & Lease Back', + }, + { + id: '3.1.3.8', + name: 'Venture Capital', + }, + ], + }, + { + id: '3.1.4', + name: 'Business I.T.', + }, + { + id: '3.1.5', + name: 'Business Operations', + }, + { + id: '3.1.6', + name: 'Consumer Issues', + categories: [ + { + id: '3.1.6.1', + name: 'Recalls', + }, + ], + }, + { + id: '3.1.7', + name: 'Executive Leadership & Management', + }, + { + id: '3.1.8', + name: 'Government Business', + }, + { + id: '3.1.9', + name: 'Green Solutions', + }, + { + id: '3.1.10', + name: 'Human Resources', + }, + { + id: '3.1.11', + name: 'Large Business', + }, + { + id: '3.1.12', + name: 'Logistics', + }, + { + id: '3.1.13', + name: 'Marketing and Advertising', + }, + { + id: '3.1.14', + name: 'Sales', + }, + { + id: '3.1.15', + name: 'Small and Medium-sized Business', + }, + { + id: '3.1.16', + name: 'Startups', + }, + ], + }, + { + id: '3.2', + name: 'Economy', + highlight: true, + categories: [ + { + id: '3.2.1', + name: 'Commodities', + }, + { + id: '3.2.2', + name: 'Currencies', + }, + { + id: '3.2.3', + name: 'Financial Crisis', + }, + { + id: '3.2.4', + name: 'Financial Reform', + }, + { + id: '3.2.5', + name: 'Financial Regulation', + }, + { + id: '3.2.6', + name: 'Gasoline Prices', + }, + { + id: '3.2.7', + name: 'Housing Market', + }, + { + id: '3.2.8', + name: 'Interest Rates', + }, + { + id: '3.2.9', + name: 'Job Market', + }, + ], + }, + { + id: '3.3', + name: 'Industries', + highlight: true, + categories: [ + { + id: '3.3.1', + name: 'Advertising Industry', + }, + { + id: '3.3.2', + name: 'Agriculture', + highlight: true, + }, + { + id: '3.3.3', + name: 'Apparel Industry', + }, + { + id: '3.3.4', + name: 'Automotive Industry', + highlight: true, + }, + { + id: '3.3.5', + name: 'Aviation Industry', + }, + { + id: '3.3.6', + name: 'Biotech and Biomedical Industry', + }, + { + id: '3.3.7', + name: 'Civil Engineering Industry', + }, + { + id: '3.3.8', + name: 'Construction Industry', + }, + { + id: '3.3.9', + name: 'Defense Industry', + }, + { + id: '3.3.10', + name: 'Education industry', + }, + { + id: '3.3.11', + name: 'Entertainment Industry', + highlight: true, + }, + { + id: '3.3.12', + name: 'Environmental Services Industry', + }, + { + id: '3.3.13', + name: 'Financial Industry', + }, + { + id: '3.3.14', + name: 'Food Industry', + }, + { + id: '3.3.15', + name: 'Healthcare Industry', + }, + { + id: '3.3.16', + name: 'Hospitality Industry', + }, + { + id: '3.3.17', + name: 'Information Services Industry', + }, + { + id: '3.3.18', + name: 'Legal Services Industry', + }, + { + id: '3.3.19', + name: 'Logistics and Transportation Industry', + }, + { + id: '3.3.20', + name: 'Management Consulting Industry', + }, + { + id: '3.3.21', + name: 'Manufacturing Industry', + }, + { + id: '3.3.22', + name: 'Mechanical and Industrial Engineering Industry', + }, + { + id: '3.3.23', + name: 'Media Industry', + highlight: true, + }, + { + id: '3.3.24', + name: 'Metals Industry', + }, + { + id: '3.3.25', + name: 'Non-Profit Organizations', + }, + { + id: '3.3.26', + name: 'Pharmaceutical Industry', + }, + { + id: '3.3.27', + name: 'Power and Energy Industry', + }, + { + id: '3.3.28', + name: 'Publishing Industry', + }, + { + id: '3.3.29', + name: 'Real Estate Industry', + }, + { + id: '3.3.30', + name: 'Retail Industry', + highlight: true, + }, + { + id: '3.3.31', + name: 'Technology Industry', + highlight: true, + }, + { + id: '3.3.32', + name: 'Telecommunications Industry', + }, + ], + }, + ], + }, + { + id: '4', + name: 'Careers', + categories: [ + { + id: '4.1', + name: 'Apprenticeships', + }, + { + id: '4.2', + name: 'Career Advice', + }, + { + id: '4.3', + name: 'Career Planning', + }, + { + id: '4.4', + name: 'Job Search', + categories: [ + { + id: '4.4.1', + name: 'Job Fairs', + }, + { + id: '4.4.2', + name: 'Resume Writing and Advice', + }, + ], + }, + { + id: '4.5', + name: 'Telecommuting', + }, + { + id: '4.6', + name: 'Vocational Training', + }, + ], + }, + { + id: '5', + name: 'Education', + categories: [ + { + id: '5.1', + name: 'Adult Education', + }, + { + id: '5.2', + name: 'College Education', + categories: [ + { + id: '5.2.1', + name: 'College Planning', + }, + { + id: '5.2.2', + name: 'Postgraduate Education', + categories: [ + { + id: '5.2.2.1', + name: 'Professional School', + }, + ], + }, + { + id: '5.2.3', + name: 'Undergraduate Education', + }, + ], + }, + { + id: '5.3', + name: 'Early Childhood Education', + }, + { + id: '5.4', + name: 'Educational Assessment', + categories: [ + { + id: '5.4.1', + name: 'Standardized Testing', + }, + ], + }, + { + id: '5.5', + name: 'Homeschooling', + }, + { + id: '5.6', + name: 'Homework and Study', + }, + { + id: '5.7', + name: 'Language Learning', + }, + { + id: '5.8', + name: 'Online Education', + }, + { + id: '5.9', + name: 'Primary Education', + }, + { + id: '5.10', + name: 'Private School', + }, + { + id: '5.11', + name: 'Secondary Education', + }, + { + id: '5.12', + name: 'Special Education', + }, + ], + }, + { + id: '6', + name: 'Events and Attractions', + highlight: true, + categories: [ + { + id: '6.1', + name: 'Amusement and Theme Parks', + highlight: true, + }, + { + id: '6.2', + name: 'Awards Shows', + highlight: true, + }, + { + id: '6.3', + name: 'Bars & Restaurants', + }, + { + id: '6.4', + name: 'Business Expos & Conferences', + }, + { + id: '6.5', + name: 'Casinos & Gambling', + }, + { + id: '6.6', + name: 'Cinemas and Events', + }, + { + id: '6.7', + name: 'Comedy Events', + }, + { + id: '6.8', + name: 'Concerts & Music Events', + }, + { + id: '6.9', + name: 'Fan Conventions', + }, + { + id: '6.10', + name: 'Fashion Events', + highlight: true, + }, + { + id: '6.11', + name: 'Historic Site and Landmark Tours', + highlight: true, + }, + { + id: '6.12', + name: 'Malls & Shopping Centers', + highlight: true, + }, + { + id: '6.13', + name: 'Museums & Galleries', + highlight: true, + }, + { + id: '6.14', + name: 'Musicals', + highlight: true, + }, + { + id: '6.15', + name: 'National & Civic Holidays', + highlight: true, + }, + { + id: '6.16', + name: 'Nightclubs', + highlight: true, + }, + { + id: '6.17', + name: 'Outdoor Activities', + highlight: true, + }, + { + id: '6.18', + name: 'Parks & Nature', + highlight: true, + }, + { + id: '6.19', + name: 'Party Supplies and Decorations', + highlight: true, + }, + { + id: '6.20', + name: 'Personal Celebrations & Life Events', + categories: [ + { + id: '6.20.1', + name: 'Anniversary', + }, + { + id: '6.20.2', + name: 'Baby Shower', + }, + { + id: '6.20.3', + name: 'Bachelor Party', + }, + { + id: '6.20.4', + name: 'Bachelorette Party', + }, + { + id: '6.20.5', + name: 'Birth', + }, + { + id: '6.20.6', + name: 'Birthday', + }, + { + id: '6.20.7', + name: 'Funeral', + }, + { + id: '6.20.8', + name: 'Graduation', + }, + { + id: '6.20.9', + name: 'Prom', + }, + { + id: '6.20.10', + name: 'Wedding', + }, + ], + }, + { + id: '6.21', + name: 'Political Event', + }, + { + id: '6.22', + name: 'Religious Events', + }, + { + id: '6.23', + name: 'Sporting Events', + }, + { + id: '6.24', + name: 'Theater Venues and Events', + }, + { + id: '6.25', + name: 'Zoos & Aquariums', + }, + ], + }, + { + id: '7', + name: 'Family and Relationships', + highlight: true, + categories: [ + { + id: '7.1', + name: 'Bereavement', + }, + { + id: '7.2', + name: 'Dating', + }, + { + id: '7.3', + name: 'Divorce', + }, + { + id: '7.4', + name: 'Eldercare', + }, + { + id: '7.5', + name: 'Marriage and Civil Unions', + }, + { + id: '7.6', + name: 'Parenting', + categories: [ + { + id: '7.6.1', + name: 'Adoption and Fostering', + }, + { + id: '7.6.2', + name: 'Daycare and Pre-School', + }, + { + id: '7.6.3', + name: 'Internet Safety', + }, + { + id: '7.6.4', + name: 'Parenting Babies and Toddlers', + }, + { + id: '7.6.5', + name: 'Parenting Children Aged 4-11', + }, + { + id: '7.6.6', + name: 'Parenting Teens', + }, + { + id: '7.6.7', + name: 'Pregnancy', + }, + { + id: '7.6.8', + name: 'Special Needs Kids', + }, + ], + }, + { + id: '7.7', + name: 'Single Life', + }, + ], + }, + { + id: '8', + name: 'Fine Art', + categories: [ + { + id: '8.1', + name: 'Costume', + }, + { + id: '8.2', + name: 'Dance', + }, + { + id: '8.3', + name: 'Design', + }, + { + id: '8.4', + name: 'Digital Arts', + }, + { + id: '8.6', + name: 'Fine Art Photography', + }, + { + id: '8.7', + name: 'Modern Art', + }, + { + id: '8.8', + name: 'Opera', + }, + { + id: '8.9', + name: 'Theater', + }, + ], + }, + { + id: '9', + name: 'Food & Drink', + highlight: true, + categories: [ + { + id: '9.1', + name: 'Alcoholic Beverages', + }, + { + id: '9.2', + name: 'Barbecues and Grilling', + }, + { + id: '9.3', + name: 'Cooking', + }, + { + id: '9.4', + name: 'Desserts and Baking', + }, + { + id: '9.5', + name: 'Dining Out', + }, + { + id: '9.6', + name: 'Food Allergies', + }, + { + id: '9.7', + name: 'Food Movements', + }, + { + id: '9.8', + name: 'Healthy Cooking and Eating', + }, + { + id: '9.9', + name: 'Non-Alcoholic Beverages', + }, + { + id: '9.10', + name: 'Vegan Diets', + }, + { + id: '9.11', + name: 'Vegetarian Diets', + }, + { + id: '9.12', + name: 'World Cuisines', + }, + ], + }, + { + id: '10', + name: 'Healthy Living', + highlight: true, + categories: [ + { + id: '10.1', + name: "Children's Health", + }, + { + id: '10.2', + name: 'Fitness and Exercise', + categories: [ + { + id: '10.2.1', + name: 'Participant Sports', + }, + { + id: '10.2.2', + name: 'Running and Jogging', + }, + ], + }, + { + id: '10.3', + name: "Men's Health", + }, + { + id: '10.4', + name: 'Nutrition', + }, + { + id: '10.5', + name: 'Senior Health', + }, + { + id: '10.6', + name: 'Weight Loss', + }, + { + id: '10.7', + name: 'Wellness', + categories: [ + { + id: '10.7.1', + name: 'Alternative Medicine', + }, + { + id: '10.7.2', + name: 'Physical Therapy', + }, + { + id: '10.7.3', + name: 'Smoking Cessation', + }, + ], + }, + { + id: '10.8', + name: "Women's Health", + }, + ], + }, + { + id: '11', + name: 'Hobbies & Interests', + highlight: true, + categories: [ + { + id: '11.1', + name: 'Antiquing and Antiques', + }, + { + id: '11.2', + name: 'Arts and Crafts', + categories: [ + { + id: '11.2.1', + name: 'Beadwork', + }, + { + id: '11.2.2', + name: 'Candle and Soap Making', + }, + { + id: '11.2.3', + name: 'Drawing and Sketching', + }, + { + id: '11.2.4', + name: 'Jewelry Making', + }, + { + id: '11.2.5', + name: 'Needlework', + }, + { + id: '11.2.6', + name: 'Painting', + }, + { + id: '11.2.7', + name: 'Photography', + }, + { + id: '11.2.8', + name: 'Scrapbooking', + }, + { + id: '11.2.9', + name: 'Woodworking', + }, + ], + }, + { + id: '11.3', + name: 'Beekeeping', + }, + { + id: '11.4', + name: 'Birdwatching', + }, + { + id: '11.5', + name: 'Cigars', + }, + { + id: '11.6', + name: 'Collecting', + categories: [ + { + id: '11.6.1', + name: 'Comic Books', + }, + { + id: '11.6.2', + name: 'Stamps and Coins', + }, + ], + }, + { + id: '11.7', + name: 'Content Production', + categories: [ + { + id: '11.7.1', + name: 'Audio Production', + }, + { + id: '11.7.2', + name: 'Freelance Writing', + }, + { + id: '11.7.3', + name: 'Screenwriting', + }, + { + id: '11.7.4', + name: 'Video Production', + }, + ], + }, + { + id: '11.8', + name: 'Games and Puzzles', + categories: [ + { + id: '11.8.1', + name: 'Board Games and Puzzles', + }, + { + id: '11.8.2', + name: 'Card Games', + }, + { + id: '11.8.3', + name: 'Roleplaying Games', + }, + ], + }, + { + id: '11.9', + name: 'Genealogy and Ancestry', + }, + { + id: '11.10', + name: 'Magic and Illusion', + }, + { + id: '11.11', + name: 'Model Toys', + }, + { + id: '11.12', + name: 'Musical Instruments', + }, + { + id: '11.13', + name: 'Paranormal Phenomena', + }, + { + id: '11.14', + name: 'Radio Control', + }, + { + id: '11.15', + name: 'Sci-fi and Fantasy', + }, + { + id: '11.16', + name: 'Workshops and Classes', + }, + ], + }, + { + id: '12', + name: 'Home & Garden', + categories: [ + { + id: '12.1', + name: 'Gardening', + }, + { + id: '12.2', + name: 'Home Appliances', + }, + { + id: '12.3', + name: 'Home Entertaining', + }, + { + id: '12.4', + name: 'Home Improvement', + }, + { + id: '12.5', + name: 'Home Security', + }, + { + id: '12.6', + name: 'Indoor Environmental Quality', + }, + { + id: '12.7', + name: 'Interior Decorating', + }, + { + id: '12.8', + name: 'Landscaping', + }, + { + id: '12.9', + name: 'Outdoor Decorating', + }, + { + id: '12.10', + name: 'Remodeling & Construction', + }, + { + id: '12.11', + name: 'Smart Home', + }, + ], + }, + { + id: '13', + name: 'Medical Health', + highlight: true, + categories: [ + { + id: '13.1', + name: 'Diseases and Conditions', + highlight: true, + categories: [ + { + id: '13.1.1', + name: 'Allergies', + }, + { + id: '13.1.2', + name: 'Blood Disorders', + }, + { + id: '13.1.3', + name: 'Bone and Joint Conditions', + }, + { + id: '13.1.4', + name: 'Brain and Nervous System Disorders', + }, + { + id: '13.1.5', + name: 'Cancer', + }, + { + id: '13.1.6', + name: 'Cold and Flu', + }, + { + id: '13.1.7', + name: 'Dental Health', + }, + { + id: '13.1.8', + name: 'Diabetes', + }, + { + id: '13.1.9', + name: 'Digestive Disorders', + }, + { + id: '13.1.10', + name: 'Ear, Nose and Throat Conditions', + }, + { + id: '13.1.11', + name: 'Endocrine and Metabolic Diseases', + categories: [ + { + id: '13.1.11.1', + name: 'Hormonal Disorders', + }, + { + id: '13.1.11.2', + name: 'Menopause', + }, + { + id: '13.1.11.3', + name: 'Thyroid Disorders', + }, + ], + }, + { + id: '13.1.12', + name: 'Eye and Vision Conditions', + }, + { + id: '13.1.13', + name: 'Foot Health', + }, + { + id: '13.1.14', + name: 'Heart and Cardiovascular Diseases', + }, + { + id: '13.1.15', + name: 'Infectious Diseases', + highlight: true, + }, + { + id: '13.1.16', + name: 'Injuries', + categories: [ + { + id: '13.1.16.1', + name: 'First Aid', + }, + ], + }, + { + id: '13.1.17', + name: 'Lung and Respiratory Health', + }, + { + id: '13.1.18', + name: 'Mental Health', + }, + { + id: '13.1.19', + name: 'Reproductive Health', + categories: [ + { + id: '13.1.19.1', + name: 'Birth Control', + }, + { + id: '13.1.19.2', + name: 'Infertility', + }, + { + id: '13.1.19.3', + name: 'Pregnancy', + }, + ], + }, + { + id: '13.1.20', + name: 'Sexual Health', + categories: [ + { + id: '13.1.20.1', + name: 'Sexual Conditions', + }, + ], + }, + { + id: '13.1.21', + name: 'Skin and Dermatology', + }, + { + id: '13.1.22', + name: 'Sleep Disorders', + }, + { + id: '13.1.23', + name: 'Substance Abuse', + }, + ], + }, + { + id: '13.2', + name: 'Medical Tests', + }, + { + id: '13.3', + name: 'Pharmaceutical Drugs', + }, + { + id: '13.4', + name: 'Surgery', + }, + { + id: '13.5', + name: 'Vaccines', + }, + ], + }, + { + id: '14', + name: 'Movies', + highlight: true, + categories: [ + { + id: '14.1', + name: 'Action and Adventure Movies', + }, + { + id: '14.2', + name: 'Animation Movies', + }, + { + id: '14.3', + name: 'Comedy Movies', + }, + { + id: '14.4', + name: 'Crime and Mystery Movies', + }, + { + id: '14.5', + name: 'Documentary Movies', + }, + { + id: '14.6', + name: 'Drama Movies', + }, + { + id: '14.7', + name: 'Family and Children Movies', + }, + { + id: '14.8', + name: 'Fantasy Movies', + }, + { + id: '14.9', + name: 'Horror Movies', + }, + { + id: '14.10', + name: 'Romance Movies', + }, + { + id: '14.11', + name: 'Science Fiction Movies', + }, + { + id: '14.12', + name: 'Special Interest Movies', + }, + ], + }, + { + id: '15', + name: 'Music and Audio', + highlight: true, + categories: [ + { + id: '15.1', + name: 'Adult Contemporary Music', + }, + { + id: '15.2', + name: 'Arts Podcasts', + }, + { + id: '15.3', + name: 'Blues', + }, + { + id: '15.4', + name: 'Business News Radio', + }, + { + id: '15.5', + name: 'Business Podcasts', + }, + { + id: '15.6', + name: "Children's Music", + }, + { + id: '15.7', + name: 'Classic Hits', + }, + { + id: '15.8', + name: 'Classical Music', + }, + { + id: '15.9', + name: 'College Radio', + }, + { + id: '15.10', + name: 'Comedy Podcasts', + }, + { + id: '15.11', + name: 'Comedy Radio', + }, + { + id: '15.12', + name: 'Contemporary Hits', + }, + { + id: '15.13', + name: 'Country Music', + }, + { + id: '15.14', + name: 'Dance and Electronic Music', + }, + { + id: '15.15', + name: 'Education Podcasts', + }, + { + id: '15.16', + name: 'Folk Music', + }, + { + id: '15.17', + name: 'Games & Hobbies Podcasts', + }, + { + id: '15.18', + name: 'Gospel Music', + }, + { + id: '15.19', + name: 'Health Podcasts', + }, + { + id: '15.20', + name: 'Hip Hop Music', + }, + { + id: '15.21', + name: 'Inspirational Music', + }, + { + id: '15.22', + name: 'International Music', + }, + { + id: '15.23', + name: 'Jazz', + }, + { + id: '15.24', + name: 'Kids & Family Podcasts', + }, + { + id: '15.25', + name: 'News & Politics Podcasts', + }, + { + id: '15.26', + name: 'Oldies Music', + }, + { + id: '15.27', + name: 'Public Radio', + }, + { + id: '15.28', + name: 'Reggae', + }, + { + id: '15.29', + name: 'Religion & Spirituality Podcasts', + }, + { + id: '15.30', + name: 'Religious Music', + }, + { + id: '15.31', + name: 'Rhythm and Blues', + }, + { + id: '15.32', + name: 'Rock Music', + categories: [ + { + id: '15.32.1', + name: 'Album-oriented Rock', + }, + { + id: '15.32.2', + name: 'Alternative Rock', + }, + { + id: '15.32.3', + name: 'Classic Rock', + }, + { + id: '15.32.4', + name: 'Hard Rock', + }, + ], + }, + { + id: '15.33', + name: 'Science & Medicine Podcasts', + }, + { + id: '15.34', + name: 'Society & Culture Podcasts', + }, + { + id: '15.35', + name: 'Soundtracks', + }, + { + id: '15.36', + name: 'Sports & Recreation Podcasts', + }, + { + id: '15.37', + name: 'Sports Play-by-Play', + }, + { + id: '15.38', + name: 'Sports Talk Radio', + }, + { + id: '15.39', + name: 'Talk Radio', + categories: [ + { + id: '15.39.1', + name: 'All-news radio', + }, + { + id: '15.39.2', + name: 'Educational Radio', + }, + { + id: '15.39.3', + name: 'News/Talk', + }, + ], + }, + { + id: '15.40', + name: 'Technology Podcasts', + }, + { + id: '15.41', + name: 'TV & Film Podcasts', + }, + { + id: '15.42', + name: 'Urban Adult Contemporary Music', + }, + { + id: '15.43', + name: 'Urban Contemporary Music', + }, + ], + }, + { + id: '16', + name: 'News and Politics', + highlight: true, + categories: [ + { + id: '16.1', + name: 'Crime', + highlight: true, + }, + { + id: '16.2', + name: 'Disasters', + highlight: true, + }, + { + id: '16.3', + name: 'International News', + }, + { + id: '16.4', + name: 'Law', + }, + { + id: '16.5', + name: 'Local News', + }, + { + id: '16.6', + name: 'National News', + }, + { + id: '16.7', + name: 'Politics', + highlight: true, + categories: [ + { + id: '16.7.1', + name: 'Elections', + }, + { + id: '16.7.2', + name: 'Political Issues', + }, + { + id: '16.7.3', + name: 'War and Conflicts', + highlight: true, + }, + ], + }, + { + id: '16.8', + name: 'Weather', + }, + ], + }, + { + id: '17', + name: 'Personal Finance', + categories: [ + { + id: '17.1', + name: 'Consumer Banking', + }, + { + id: '17.2', + name: 'Financial Assistance', + categories: [ + { + id: '17.2.1', + name: 'Government Support and Welfare', + }, + { + id: '17.2.2', + name: 'Student Financial Aid', + }, + ], + }, + { + id: '17.3', + name: 'Financial Planning', + }, + { + id: '17.4', + name: 'Frugal Living', + }, + { + id: '17.5', + name: 'Insurance', + categories: [ + { + id: '17.5.1', + name: 'Health Insurance', + }, + { + id: '17.5.2', + name: 'Home Insurance', + }, + { + id: '17.5.3', + name: 'Life Insurance', + }, + { + id: '17.5.4', + name: 'Motor Insurance', + }, + { + id: '17.5.5', + name: 'Pet Insurance', + }, + { + id: '17.5.6', + name: 'Travel Insurance', + }, + ], + }, + { + id: '17.6', + name: 'Personal Debt', + categories: [ + { + id: '17.6.1', + name: 'Credit Cards', + }, + { + id: '17.6.2', + name: 'Home Financing', + }, + { + id: '17.6.3', + name: 'Personal Loans', + }, + { + id: '17.6.4', + name: 'Student Loans', + }, + ], + }, + { + id: '17.7', + name: 'Personal Investing', + categories: [ + { + id: '17.7.1', + name: 'Hedge Funds', + }, + { + id: '17.7.2', + name: 'Mutual Funds', + }, + { + id: '17.7.3', + name: 'Options', + }, + { + id: '17.7.4', + name: 'Stocks and Bonds', + }, + ], + }, + { + id: '17.8', + name: 'Personal Taxes', + }, + { + id: '17.9', + name: 'Retirement Planning', + }, + ], + }, + { + id: '18', + name: 'Pets', + categories: [ + { + id: '18.1', + name: 'Birds', + }, + { + id: '18.2', + name: 'Cats', + }, + { + id: '18.3', + name: 'Dogs', + }, + { + id: '18.4', + name: 'Fish and Aquariums', + }, + { + id: '18.5', + name: 'Large Animals', + }, + { + id: '18.6', + name: 'Pet Adoptions', + }, + { + id: '18.7', + name: 'Reptiles', + }, + { + id: '18.8', + name: 'Veterinary Medicine', + }, + ], + }, + { + id: '19', + name: 'Pop Culture', + highlight: true, + categories: [ + { + id: '19.1', + name: 'Celebrity Deaths', + highlight: true, + }, + { + id: '19.2', + name: 'Celebrity Families', + highlight: true, + }, + { + id: '19.3', + name: 'Celebrity Homes', + highlight: true, + }, + { + id: '19.4', + name: 'Celebrity Pregnancy', + highlight: true, + }, + { + id: '19.5', + name: 'Celebrity Relationships', + highlight: true, + }, + { + id: '19.6', + name: 'Celebrity Scandal', + highlight: true, + }, + { + id: '19.7', + name: 'Celebrity Style', + highlight: true, + }, + ], + }, + { + id: '20', + name: 'Real Estate', + categories: [ + { + id: '20.1', + name: 'Apartments', + }, + { + id: '20.2', + name: 'Developmental Sites', + }, + { + id: '20.3', + name: 'Hotel Properties', + }, + { + id: '20.4', + name: 'Houses', + }, + { + id: '20.5', + name: 'Industrial Property', + }, + { + id: '20.6', + name: 'Land and Farms', + }, + { + id: '20.7', + name: 'Office Property', + }, + { + id: '20.8', + name: 'Real Estate Buying and Selling', + }, + { + id: '20.9', + name: 'Real Estate Renting and Leasing', + }, + { + id: '20.10', + name: 'Retail Property', + }, + { + id: '20.11', + name: 'Vacation Properties', + }, + ], + }, + { + id: '21', + name: 'Religion & Spirituality', + categories: [ + { + id: '21.1', + name: 'Agnosticism', + }, + { + id: '21.2', + name: 'Astrology', + }, + { + id: '21.3', + name: 'Atheism', + }, + { + id: '21.4', + name: 'Buddhism', + }, + { + id: '21.5', + name: 'Christianity', + }, + { + id: '21.6', + name: 'Hinduism', + }, + { + id: '21.7', + name: 'Islam', + }, + { + id: '21.8', + name: 'Judaism', + }, + { + id: '21.9', + name: 'Sikhism', + }, + { + id: '21.10', + name: 'Spirituality', + }, + ], + }, + { + id: '22', + name: 'Science', + categories: [ + { + id: '22.1', + name: 'Biological Sciences', + }, + { + id: '22.2', + name: 'Chemistry', + }, + { + id: '22.3', + name: 'Environment', + }, + { + id: '22.4', + name: 'Genetics', + }, + { + id: '22.5', + name: 'Geography', + }, + { + id: '22.6', + name: 'Geology', + }, + { + id: '22.7', + name: 'Physics', + }, + { + id: '22.8', + name: 'Space and Astronomy', + }, + ], + }, + { + id: '23', + name: 'Shopping', + categories: [ + { + id: '23.1', + name: 'Couponing', + }, + ], + }, + { + id: '24', + name: 'Sports', + highlight: true, + categories: [ + { + id: '24.1', + name: 'American Football', + highlight: true, + }, + { + id: '24.2', + name: 'Australian Rules Football', + }, + { + id: '24.3', + name: 'Auto Racing', + categories: [ + { + id: '24.3.1', + name: 'Motorcycle Sports', + }, + ], + }, + { + id: '24.4', + name: 'Badminton', + }, + { + id: '24.5', + name: 'Baseball', + highlight: true, + }, + { + id: '24.6', + name: 'Basketball', + highlight: true, + }, + { + id: '24.7', + name: 'Beach Volleyball', + }, + { + id: '24.8', + name: 'Bodybuilding', + }, + { + id: '24.9', + name: 'Bowling', + }, + { + id: '24.10', + name: 'Boxing', + }, + { + id: '24.11', + name: 'Cheerleading', + }, + { + id: '24.12', + name: 'College Sports', + }, + { + id: '24.13', + name: 'Cricket', + }, + { + id: '24.14', + name: 'Cycling', + }, + { + id: '24.15', + name: 'Darts', + }, + { + id: '24.16', + name: 'Disabled Sports', + }, + { + id: '24.17', + name: 'Diving', + }, + { + id: '24.18', + name: 'Equine Sports', + categories: [ + { + id: '24.18.1', + name: 'Horse Racing', + }, + ], + }, + { + id: '24.19', + name: 'Extreme Sports', + categories: [ + { + id: '24.19.1', + name: 'Canoeing and Kayaking', + }, + { + id: '24.19.2', + name: 'Climbing', + }, + { + id: '24.19.3', + name: 'Paintball', + }, + { + id: '24.19.4', + name: 'Scuba Diving', + }, + { + id: '24.19.5', + name: 'Skateboarding', + }, + { + id: '24.19.6', + name: 'Snowboarding', + }, + { + id: '24.19.7', + name: 'Surfing and Bodyboarding', + }, + { + id: '24.19.8', + name: 'Waterskiing and Wakeboarding', + }, + ], + }, + { + id: '24.20', + name: 'Fantasy Sports', + }, + { + id: '24.21', + name: 'Field Hockey', + }, + { + id: '24.22', + name: 'Figure Skating', + }, + { + id: '24.23', + name: 'Fishing Sports', + }, + { + id: '24.24', + name: 'Golf', + highlight: true, + }, + { + id: '24.25', + name: 'Gymnastics', + }, + { + id: '24.26', + name: 'Hunting and Shooting', + }, + { + id: '24.27', + name: 'Ice Hockey', + highlight: true, + }, + { + id: '24.28', + name: 'Inline Skating', + }, + { + id: '24.29', + name: 'Lacrosse', + }, + { + id: '24.30', + name: 'Martial Arts', + }, + { + id: '24.31', + name: 'Olympic Sports', + categories: [ + { + id: '24.31.1', + name: 'Summer Olympic Sports', + }, + { + id: '24.31.2', + name: 'Winter Olympic Sports', + }, + ], + }, + { + id: '24.32', + name: 'Poker and Professional Gambling', + }, + { + id: '24.33', + name: 'Rodeo', + }, + { + id: '24.34', + name: 'Rowing', + }, + { + id: '24.35', + name: 'Rugby', + categories: [ + { + id: '24.35.1', + name: 'Rugby League', + }, + { + id: '24.35.2', + name: 'Rugby Union', + }, + ], + }, + { + id: '24.36', + name: 'Sailing', + }, + { + id: '24.37', + name: 'Skiing', + }, + { + id: '24.38', + name: 'Snooker/Pool/Billiards', + }, + { + id: '24.39', + name: 'Soccer', + highlight: true, + }, + { + id: '24.40', + name: 'Softball', + }, + { + id: '24.41', + name: 'Squash', + }, + { + id: '24.42', + name: 'Swimming', + }, + { + id: '24.43', + name: 'Table Tennis', + }, + { + id: '24.44', + name: 'Tennis', + }, + { + id: '24.45', + name: 'Track and Field', + }, + { + id: '24.46', + name: 'Volleyball', + }, + { + id: '24.47', + name: 'Walking', + }, + { + id: '24.48', + name: 'Water Polo', + }, + { + id: '24.49', + name: 'Weightlifting', + }, + { + id: '24.50', + name: 'Wrestling', + highlight: true, + }, + ], + }, + { + id: '25', + name: 'Style & Fashion', + highlight: true, + categories: [ + { + id: '25.1', + name: 'Beauty', + categories: [ + { + id: '25.1.1', + name: 'Hair Care', + }, + { + id: '25.1.2', + name: 'Makeup and Accessories', + }, + { + id: '25.1.3', + name: 'Nail Care', + }, + { + id: '25.1.4', + name: 'Natural and Organic Beauty', + }, + { + id: '25.1.5', + name: 'Perfume and Fragrance', + }, + { + id: '25.1.6', + name: 'Skin Care', + }, + ], + }, + { + id: '25.2', + name: 'Body Art', + }, + { + id: '25.3', + name: "Children's Clothing", + }, + { + id: '25.4', + name: 'Designer Clothing', + }, + { + id: '25.5', + name: 'Fashion Trends', + }, + { + id: '25.6', + name: 'High Fashion', + }, + { + id: '25.7', + name: "Men's Fashion", + categories: [ + { + id: '25.7.1', + name: "Men's Accessories", + categories: [ + { + id: '25.7.1.1', + name: "Men's Jewelry and Watches", + }, + ], + }, + { + id: '25.7.2', + name: "Men's Clothing", + categories: [ + { + id: '25.7.2.1', + name: "Men's Business Wear", + }, + { + id: '25.7.2.2', + name: "Men's Casual Wear", + }, + { + id: '25.7.2.3', + name: "Men's Formal Wear", + }, + { + id: '25.7.2.4', + name: "Men's Outerwear", + }, + { + id: '25.7.2.5', + name: "Men's Sportswear", + }, + { + id: '25.7.2.6', + name: "Men's Underwear and Sleepwear", + }, + ], + }, + { + id: '25.7.3', + name: "Men's Shoes and Footwear", + }, + ], + }, + { + id: '25.8', + name: 'Personal Care', + categories: [ + { + id: '25.8.1', + name: 'Bath and Shower', + }, + { + id: '25.8.2', + name: 'Deodorant and Antiperspirant', + }, + { + id: '25.8.3', + name: 'Oral care', + }, + { + id: '25.8.4', + name: 'Shaving', + }, + ], + }, + { + id: '25.9', + name: 'Street Style', + }, + { + id: '25.10', + name: "Women's Fashion", + categories: [ + { + id: '25.10.1', + name: "Women's Accessories", + categories: [ + { + id: '25.10.1.1', + name: "Women's Glasses", + }, + { + id: '25.10.1.2', + name: "Women's Handbags and Wallets", + }, + { + id: '25.10.1.3', + name: "Women's Hats and Scarves", + }, + { + id: '25.10.1.4', + name: "Women's Jewelry and Watches", + }, + ], + }, + { + id: '25.10.2', + name: "Women's Clothing", + categories: [ + { + id: '25.10.2.1', + name: "Women's Business Wear", + }, + { + id: '25.10.2.2', + name: "Women's Casual Wear", + }, + { + id: '25.10.2.3', + name: "Women's Formal Wear", + }, + { + id: '25.10.2.4', + name: "Women's Intimates and Sleepwear", + }, + { + id: '25.10.2.5', + name: "Women's Outerwear", + }, + { + id: '25.10.2.6', + name: "Women's Sportswear", + }, + ], + }, + { + id: '25.10.3', + name: "Women's Shoes and Footwear", + }, + ], + }, + ], + }, + { + id: '26', + name: 'Technology & Computing', + highlight: true, + categories: [ + { + id: '26.1', + name: 'Artificial Intelligence', + }, + { + id: '26.2', + name: 'Augmented Reality', + }, + { + id: '26.3', + name: 'Computing', + categories: [ + { + id: '26.3.1', + name: 'Computer Networking', + }, + { + id: '26.3.2', + name: 'Computer Peripherals', + }, + { + id: '26.3.3', + name: 'Computer Software and Applications', + categories: [ + { + id: '26.3.3.1', + name: '3-D Graphics', + }, + { + id: '26.3.3.2', + name: 'Antivirus Software', + }, + { + id: '26.3.3.3', + name: 'Browsers', + }, + { + id: '26.3.3.4', + name: 'Computer Animation', + }, + { + id: '26.3.3.5', + name: 'Databases', + }, + { + id: '26.3.3.6', + name: 'Desktop Publishing', + }, + { + id: '26.3.3.7', + name: 'Digital Audio', + }, + { + id: '26.3.3.8', + name: 'Graphics Software', + }, + { + id: '26.3.3.9', + name: 'Operating Systems', + }, + { + id: '26.3.3.10', + name: 'Photo Editing Software', + }, + { + id: '26.3.3.11', + name: 'Shareware and Freeware', + }, + { + id: '26.3.3.12', + name: 'Video Software', + }, + { + id: '26.3.3.13', + name: 'Web Conferencing', + }, + ], + }, + { + id: '26.3.4', + name: 'Data Storage and Warehousing', + }, + { + id: '26.3.5', + name: 'Desktops', + }, + { + id: '26.3.6', + name: 'Information and Network Security', + }, + { + id: '26.3.7', + name: 'Internet', + highlight: true, + categories: [ + { + id: '26.3.7.1', + name: 'Cloud Computing', + }, + { + id: '26.3.7.2', + name: 'Email', + }, + { + id: '26.3.7.3', + name: 'Internet for Beginners', + }, + { + id: '26.3.7.4', + name: 'Internet of Things', + }, + { + id: '26.3.7.5', + name: 'IT and Internet Support', + }, + { + id: '26.3.7.6', + name: 'Search', + }, + { + id: '26.3.7.7', + name: 'Social Networking', + highlight: true, + }, + { + id: '26.3.7.8', + name: 'Web Conferencing', + }, + { + id: '26.3.7.9', + name: 'Web Design and HTML', + }, + { + id: '26.3.7.10', + name: 'Web Development', + }, + { + id: '26.3.7.11', + name: 'Web Hosting', + }, + ], + }, + { + id: '26.3.8', + name: 'Laptops', + }, + { + id: '26.3.9', + name: 'Programming Languages', + }, + ], + }, + { + id: '26.4', + name: 'Consumer Electronics', + categories: [ + { + id: '26.4.1', + name: 'Cameras and Camcorders', + }, + { + id: '26.4.2', + name: 'Home Entertainment Systems', + }, + { + id: '26.4.3', + name: 'Smartphones', + }, + { + id: '26.4.4', + name: 'Tablets and E-readers', + }, + { + id: '26.4.5', + name: 'Wearable Technology', + }, + ], + }, + { + id: '26.5', + name: 'Robotics', + }, + { + id: '26.6', + name: 'Virtual Reality', + }, + ], + }, + { + id: '27', + name: 'Television', + highlight: true, + categories: [ + { + id: '27.1', + name: 'Animation TV', + }, + { + id: '27.2', + name: "Children's TV", + }, + { + id: '27.3', + name: 'Comedy TV', + }, + { + id: '27.4', + name: 'Drama TV', + }, + { + id: '27.5', + name: 'Factual TV', + }, + { + id: '27.6', + name: 'Holiday TV', + }, + { + id: '27.7', + name: 'Music TV', + }, + { + id: '27.8', + name: 'Reality TV', + }, + { + id: '27.9', + name: 'Science Fiction TV', + }, + { + id: '27.10', + name: 'Soap Opera TV', + }, + { + id: '27.11', + name: 'Special Interest TV', + }, + { + id: '27.12', + name: 'Sports TV', + }, + ], + }, + { + id: '28', + name: 'Travel', + categories: [ + { + id: '28.1', + name: 'Travel Accessories', + }, + { + id: '28.2', + name: 'Travel Locations', + categories: [ + { + id: '28.2.1', + name: 'Africa Travel', + }, + { + id: '28.2.2', + name: 'Asia Travel', + }, + { + id: '28.2.3', + name: 'Australia and Oceania Travel', + }, + { + id: '28.2.4', + name: 'Europe Travel', + }, + { + id: '28.2.5', + name: 'North America Travel', + }, + { + id: '28.2.6', + name: 'Polar Travel', + }, + { + id: '28.2.7', + name: 'South America Travel', + }, + ], + }, + { + id: '28.3', + name: 'Travel Preparation', + }, + { + id: '28.4', + name: 'Travel Type', + categories: [ + { + id: '28.4.1', + name: 'Adventure Travel', + }, + { + id: '28.4.2', + name: 'Air Travel', + }, + { + id: '28.4.3', + name: 'Beach Travel', + }, + { + id: '28.4.4', + name: 'Bed & Breakfasts', + }, + { + id: '28.4.5', + name: 'Budget Travel', + }, + { + id: '28.4.6', + name: 'Business Travel', + }, + { + id: '28.4.7', + name: 'Camping', + }, + { + id: '28.4.8', + name: 'Cruises', + }, + { + id: '28.4.9', + name: 'Day Trips', + }, + { + id: '28.4.10', + name: 'Family Travel', + }, + { + id: '28.4.11', + name: 'Honeymoons and Getaways', + }, + { + id: '28.4.12', + name: 'Hotels and Motels', + }, + { + id: '28.4.13', + name: 'Rail Travel', + }, + { + id: '28.4.14', + name: 'Road Trips', + }, + { + id: '28.4.15', + name: 'Spas', + }, + ], + }, + ], + }, + { + id: '29', + name: 'Video Gaming', + highlight: true, + categories: [ + { + id: '29.1', + name: 'Console Games', + }, + { + id: '29.2', + name: 'eSports', + }, + { + id: '29.3', + name: 'Mobile Games', + }, + { + id: '29.4', + name: 'PC Games', + }, + { + id: '29.5', + name: 'Video Game Genres', + categories: [ + { + id: '29.5.1', + name: 'Action Video Games', + }, + { + id: '29.5.2', + name: 'Action-Adventure Video Games', + }, + { + id: '29.5.3', + name: 'Adventure Video Games', + }, + { + id: '29.5.4', + name: 'Casual Games', + }, + { + id: '29.5.5', + name: 'Educational Video Games', + }, + { + id: '29.5.6', + name: 'Exercise and Fitness Video Games', + }, + { + id: '29.5.7', + name: 'MMOs', + }, + { + id: '29.5.8', + name: 'Music and Party Video Games', + }, + { + id: '29.5.9', + name: 'Puzzle Video Games', + }, + { + id: '29.5.10', + name: 'Role-Playing Video Games', + }, + { + id: '29.5.11', + name: 'Simulation Video Games', + }, + { + id: '29.5.12', + name: 'Sports Video Games', + }, + { + id: '29.5.13', + name: 'Strategy Video Games', + }, + ], + }, + ], + }, + ], +}; + +const iabResolver = () => IAB_CONFIG.categories; + +export default iabResolver; diff --git a/anyclip/src/graphql/services/contentOwners/constants/index.js b/anyclip/src/graphql/services/contentOwners/constants/index.js new file mode 100644 index 0000000..55b4ae1 --- /dev/null +++ b/anyclip/src/graphql/services/contentOwners/constants/index.js @@ -0,0 +1,13 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_CONTENT_OWNERS = `${MODULE_NAME}_GET_CONTENT_OWNERS`; +export const GET_ACCOUNTS_OPTIONS = `${MODULE_NAME}_GET_USER_ACCOUNTS_OPTIONS`; +export const GET_ITEM = `${MODULE_NAME}_GET_ITEM`; + +// mutations +export const BULK_ACTION_DISABLE_OR_ACTIVE = `${MODULE_NAME}_BULK_ACTION_DISABLE_OR_ACTIVE`; +export const CREATE_ITEM = `${MODULE_NAME}_CREATE_ITEM`; +export const UPDATE_ITEM = `${MODULE_NAME}_UPDATE_ITEM`; diff --git a/anyclip/src/graphql/services/contentOwners/types/payload/accounts.js b/anyclip/src/graphql/services/contentOwners/types/payload/accounts.js new file mode 100644 index 0000000..b797cbc --- /dev/null +++ b/anyclip/src/graphql/services/contentOwners/types/payload/accounts.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + filtersValues: { + type: new GraphQLList(GraphQLString), + }, + }, +}); diff --git a/anyclip/src/graphql/services/contentOwners/types/payload/bulkActionDisableOrActive.js b/anyclip/src/graphql/services/contentOwners/types/payload/bulkActionDisableOrActive.js new file mode 100644 index 0000000..e0634c8 --- /dev/null +++ b/anyclip/src/graphql/services/contentOwners/types/payload/bulkActionDisableOrActive.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + ids: { + type: new GraphQLList(GraphQLInt), + }, + status: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/contentOwners/types/payload/contentOwners.js b/anyclip/src/graphql/services/contentOwners/types/payload/contentOwners.js new file mode 100644 index 0000000..b997266 --- /dev/null +++ b/anyclip/src/graphql/services/contentOwners/types/payload/contentOwners.js @@ -0,0 +1,29 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + status: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/contentOwners/types/payload/item.js b/anyclip/src/graphql/services/contentOwners/types/payload/item.js new file mode 100644 index 0000000..5f40f2b --- /dev/null +++ b/anyclip/src/graphql/services/contentOwners/types/payload/item.js @@ -0,0 +1,44 @@ +import { GraphQLBoolean, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + salesforceId: { + type: GraphQLString, + }, + comments: { + type: GraphQLString, + }, + playFromCo: { + type: GraphQLBoolean, + }, + callToAction: { + type: GraphQLBoolean, + }, + type: { + type: GraphQLInt, + }, + status: { + type: GraphQLInt, + }, + isPublic: { + type: GraphQLInt, + }, + feedPriority: { + type: GraphQLFloat, + }, + accountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/customReports/constants/index.js b/anyclip/src/graphql/services/customReports/constants/index.js new file mode 100644 index 0000000..aef4041 --- /dev/null +++ b/anyclip/src/graphql/services/customReports/constants/index.js @@ -0,0 +1,13 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_LIST = `${MODULE_NAME}_GET_LIST`; +export const GET_ITEM = `${MODULE_NAME}_GET_ITEM`; + +export const GET_ACCOUNTS = `${MODULE_NAME}_GET_ACCOUNTS`; + +// mutations +export const CREATE_ITEM = `${MODULE_NAME}_CREATE_ITEM`; +export const UPDATE_ITEM = `${MODULE_NAME}_UPDATE_ITEM`; diff --git a/anyclip/src/graphql/services/customReports/types/payload/accounts.js b/anyclip/src/graphql/services/customReports/types/payload/accounts.js new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/customReports/types/payload/accounts.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/customReports/types/payload/item.js b/anyclip/src/graphql/services/customReports/types/payload/item.js new file mode 100644 index 0000000..663dc5e --- /dev/null +++ b/anyclip/src/graphql/services/customReports/types/payload/item.js @@ -0,0 +1,41 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +const CreateInputType = new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + description: 'Module CustomReportPCN Create Input Type', + fields: { + id: { + type: GraphQLInt, + }, + accountId: { + type: GraphQLInt, + }, + allSites: { + type: GraphQLInt, + }, + description: { + type: GraphQLString, + }, + enabled: { + type: GraphQLInt, + }, + icon: { + type: GraphQLString, + }, + lookerReportId: { + type: GraphQLString, + }, + publisherIds: { + type: new GraphQLList(GraphQLInt), + }, + uiName: { + type: GraphQLString, + }, + }, +}); + +export default CreateInputType; diff --git a/anyclip/src/graphql/services/customReports/types/payload/list.js b/anyclip/src/graphql/services/customReports/types/payload/list.js new file mode 100644 index 0000000..ff39c90 --- /dev/null +++ b/anyclip/src/graphql/services/customReports/types/payload/list.js @@ -0,0 +1,32 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + enabled: { + type: GraphQLBoolean, + }, + searchText: { + type: GraphQLString, + }, + accountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/feeds/constants/index.ts b/anyclip/src/graphql/services/feeds/constants/index.ts new file mode 100644 index 0000000..729daec --- /dev/null +++ b/anyclip/src/graphql/services/feeds/constants/index.ts @@ -0,0 +1,20 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME: string = createModuleNameFromMetaUrl(import.meta.url); + +// self server list queries +export const GET_SELF_SERVE_FEEDS = `${MODULE_NAME}_GET_SELF_SERVE_FEEDS`; +export const IMPORT_ARCHIVED_SELF_SERVE_FEEDS = `${MODULE_NAME}_IMPORT_ARCHIVED_SELF_SERVE_FEEDS`; + +// feed item queries +export const GET_FEED_ITEM = `${MODULE_NAME}_GET_FEED_ITEM`; +export const GET_FEED_METADATA = `${MODULE_NAME}_GET_FEED_METADATA`; +export const GET_FEED_ACCOUNT_OPTIONS = `${MODULE_NAME}_GET_FEED_ACCOUNT_OPTIONS`; +export const GET_FEED_HUBS_OPTIONS = `${MODULE_NAME}_GET_FEED_HUBS_OPTIONS`; +export const GET_FEED_OWNERS_OPTIONS = `${MODULE_NAME}_GET_FEED_OWNERS_OPTIONS`; +export const GET_OAUTH_CLIENT_ID = `${MODULE_NAME}_GET_OAUTH_CLIENT_ID`; +export const GET_OAUTH_TOKEN = `${MODULE_NAME}_GET_OAUTH_TOKEN`; + +// feed item mutations +export const UPDATE_FEED_ITEM = `${MODULE_NAME}_UPDATE_FEED_ITEM`; +export const CREATE_FEED_ITEM = `${MODULE_NAME}_CREATE_FEED_ITEM`; diff --git a/anyclip/src/graphql/services/feeds/types/payload/accounts.ts b/anyclip/src/graphql/services/feeds/types/payload/accounts.ts new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/feeds/types/payload/accounts.ts @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/feeds/types/payload/feedItem.ts b/anyclip/src/graphql/services/feeds/types/payload/feedItem.ts new file mode 100644 index 0000000..27f4126 --- /dev/null +++ b/anyclip/src/graphql/services/feeds/types/payload/feedItem.ts @@ -0,0 +1,253 @@ +import { GraphQLBoolean, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + token: { + type: GraphQLString, + }, + code: { + type: GraphQLString, + }, + accountId: { + type: GraphQLInt, + }, + type: { + type: GraphQLString, + }, + name: { + type: GraphQLString, + }, + description: { + type: GraphQLString, + }, + lang: { + type: GraphQLString, + }, + url: { + type: GraphQLString, + }, + speechToTextProvider: { + type: GraphQLString, + }, + auth_method: { + type: GraphQLString, + }, + priorityVerification: { + type: GraphQLBoolean, + }, + feedPriority: { + type: GraphQLFloat, + }, + resolution: { + type: GraphQLString, + }, + schedule_type: { + type: GraphQLString, + }, + createClip: { + type: GraphQLBoolean, + }, + immediateAvailability: { + type: GraphQLBoolean, + }, + skipTaggingForLongClips: { + type: GraphQLBoolean, + }, + maxDurationForTagging: { + type: GraphQLInt, + }, + accessLevel: { + type: GraphQLString, + }, + accessAllPublishers: { + type: GraphQLBoolean, + }, + parseKeywordsToLabels: { + type: GraphQLBoolean, + }, + iabCategories: { + type: GraphQLString, + }, + accessVideoOwnerId: { + type: GraphQLInt, + }, + accessPublishers: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}accessPublishers`, + fields: { + publisherId: { + type: GraphQLInt, + }, + }, + }), + ), + }, + user: { + type: GraphQLString, + }, + password: { + type: GraphQLString, + }, + use_for_download: { + type: GraphQLBoolean, + }, + schedule_status: { + type: GraphQLBoolean, + }, + schedule_value: { + type: GraphQLString, + }, + schedule_frequency: { + type: GraphQLInt, + }, + defaultTimezoneEnabled: { + type: GraphQLBoolean, + }, + defaultTimezone: { + type: GraphQLString, + }, + isEvergreenFeed: { + type: GraphQLBoolean, + }, + isRestricted: { + type: GraphQLBoolean, + }, + keywords: { + type: GraphQLString, + }, + maxDuration: { + type: GraphQLInt, + }, + importPlot: { + type: GraphQLBoolean, + }, + videoFileType: { + type: GraphQLString, + }, + fileSelection: { + type: GraphQLString, + }, + resolutionValue: { + type: GraphQLInt, + }, + minResolutionValue: { + type: GraphQLInt, + }, + bitrate: { + type: GraphQLInt, + }, + minBitrate: { + type: GraphQLInt, + }, + importCCFromMrssFile: { + type: GraphQLBoolean, + }, + processVideoVersions: { + type: GraphQLBoolean, + }, + versionAttributeName: { + type: GraphQLString, + }, + automationScript: { + type: GraphQLString, + }, + platformModels: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}platformModels`, + fields: { + config: { + type: GraphQLString, + }, + enabled: { + type: GraphQLBoolean, + }, + model: { + type: GraphQLString, + }, + platform: { + type: GraphQLString, + }, + }, + }), + ), + }, + + contentXmlStructure: { + type: GraphQLString, + }, + thumbnailsXmlStructure: { + type: GraphQLString, + }, + keywords_parser_type: { + type: GraphQLString, + }, + maxStories: { + type: GraphQLInt, + }, + videoDuration: { + type: GraphQLInt, + }, + aspectRatio: { + type: GraphQLString, + }, + fit: { + type: GraphQLString, + }, + videoMaxZoom: { + type: GraphQLInt, + }, + jsRendering: { + type: GraphQLBoolean, + }, + loadFromLastDays: { + type: GraphQLInt, + }, + discardLongClips: { + type: GraphQLBoolean, + }, + fillLandingPage: { + type: GraphQLBoolean, + }, + status: { + type: GraphQLInt, + }, + source: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}source`, + fields: { + deleteAfterImport: { + type: GraphQLBoolean, + }, + importParticipantsNames: { + type: GraphQLBoolean, + }, + }, + }), + }, + youtubeContentType: { + type: GraphQLString, + }, + youTubeChannelId: { + type: GraphQLString, + }, + youTubeLoadFromDate: { + type: GraphQLString, + }, + contentType: { + type: GraphQLString, + }, + importShortsThumbnail: { + type: GraphQLBoolean, + }, + }, +}); diff --git a/anyclip/src/graphql/services/feeds/types/payload/hubs.ts b/anyclip/src/graphql/services/feeds/types/payload/hubs.ts new file mode 100644 index 0000000..e819a35 --- /dev/null +++ b/anyclip/src/graphql/services/feeds/types/payload/hubs.ts @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + accountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/feeds/types/payload/oAuthClient.ts b/anyclip/src/graphql/services/feeds/types/payload/oAuthClient.ts new file mode 100644 index 0000000..3f04803 --- /dev/null +++ b/anyclip/src/graphql/services/feeds/types/payload/oAuthClient.ts @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + type: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/feeds/types/payload/owners.ts b/anyclip/src/graphql/services/feeds/types/payload/owners.ts new file mode 100644 index 0000000..e819a35 --- /dev/null +++ b/anyclip/src/graphql/services/feeds/types/payload/owners.ts @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + accountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/feeds/types/payload/selfserve/importArchived.ts b/anyclip/src/graphql/services/feeds/types/payload/selfserve/importArchived.ts new file mode 100644 index 0000000..28f1c0b --- /dev/null +++ b/anyclip/src/graphql/services/feeds/types/payload/selfserve/importArchived.ts @@ -0,0 +1,20 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + feedId: { + type: GraphQLInt, + }, + date: { + type: GraphQLString, + }, + isEmailNotification: { + type: GraphQLBoolean, + }, + }, +}); diff --git a/anyclip/src/graphql/services/feeds/types/payload/selfserve/list.ts b/anyclip/src/graphql/services/feeds/types/payload/selfserve/list.ts new file mode 100644 index 0000000..46c5ad9 --- /dev/null +++ b/anyclip/src/graphql/services/feeds/types/payload/selfserve/list.ts @@ -0,0 +1,35 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../../_helpers/common'; + +export const PAYLOAD_NAME: string = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + searchIn: { + type: new GraphQLList(GraphQLString), + }, + filters: { + type: new GraphQLList(GraphQLString), + }, + filtersValues: { + type: new GraphQLList(GraphQLInt), + }, + }, +}); diff --git a/anyclip/src/graphql/services/hubs/types/input/itemCreate.js b/anyclip/src/graphql/services/hubs/types/input/itemCreate.js new file mode 100644 index 0000000..ab60559 --- /dev/null +++ b/anyclip/src/graphql/services/hubs/types/input/itemCreate.js @@ -0,0 +1,128 @@ +import { GraphQLBoolean, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +const RuleType = new GraphQLInputObjectType({ + name: 'HubRuleType', + description: 'HubRuleType', + fields: { + cleanOrder: { + type: GraphQLInt, + }, + cleanUrl: { + type: GraphQLString, + }, + cleanUrlRuleType: { + type: GraphQLString, + }, + cleanUrlRuleValue: { + type: GraphQLString, + }, + id: { + type: GraphQLInt, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); + +export const ITEM_INPUT_TYPE_NAME = 'ModuleHubInputType'; + +const ItemInputType = new GraphQLInputObjectType({ + name: ITEM_INPUT_TYPE_NAME, + description: 'Module Hub Create Input Type', + fields: { + id: { + type: GraphQLInt, + }, + accountId: { + type: GraphQLInt, + }, + accounts: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: 'ModuleHubSyndicatedAccount', + description: 'ModuleHubSyndicatedAccount', + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + salesforceId: { + type: GraphQLString, + }, + }, + }), + ), + }, + cleanUrl: { + type: new GraphQLList(RuleType), + }, + cpm: { + type: GraphQLFloat, + }, + ddSpecificPublisher: { + type: GraphQLBoolean, + }, + defaultRule: { + type: RuleType, + }, + domains: { + type: new GraphQLList(GraphQLString), + }, + firstLook: { + type: GraphQLBoolean, + }, + includePublicContent: { + type: GraphQLBoolean, + }, + monetization: { + type: GraphQLBoolean, + }, + name: { + type: GraphQLString, + }, + pageRefresh: { + type: GraphQLBoolean, + }, + publish: { + type: GraphQLBoolean, + }, + refreshIntervalMinutes: { + type: GraphQLInt, + }, + refreshTimesLimit: { + type: GraphQLInt, + }, + selfService: { + type: GraphQLBoolean, + }, + mpSelfService: { + type: GraphQLBoolean, + }, + demandAccountId: { + type: GraphQLInt, + }, + templatePlayerLumN: { + type: GraphQLInt, + }, + templatePlayerLumNAmp: { + type: GraphQLInt, + }, + templatePlayerLumX: { + type: GraphQLInt, + }, + templatePlayerLumXAmp: { + type: GraphQLInt, + }, + templatePlayerVertical: { + type: GraphQLInt, + }, + templatePlayerOutstream: { + type: GraphQLInt, + }, + }, +}); + +export default ItemInputType; diff --git a/anyclip/src/graphql/services/invitations/constants/index.js b/anyclip/src/graphql/services/invitations/constants/index.js new file mode 100644 index 0000000..27daa53 --- /dev/null +++ b/anyclip/src/graphql/services/invitations/constants/index.js @@ -0,0 +1,10 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_INVITATIONS = `${MODULE_NAME}_GET_INVITATIONS`; +export const GET_ACCOUNTS = `${MODULE_NAME}_GET_ACCOUNTS`; + +// mutations +export const REVOKE_INVITATION = `${MODULE_NAME}_REVOKE_INVITATION`; diff --git a/anyclip/src/graphql/services/invitations/types/payload/account.js b/anyclip/src/graphql/services/invitations/types/payload/account.js new file mode 100644 index 0000000..d2a5306 --- /dev/null +++ b/anyclip/src/graphql/services/invitations/types/payload/account.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + filtersValues: { + type: new GraphQLList(GraphQLString), + }, + }, +}); diff --git a/anyclip/src/graphql/services/invitations/types/payload/item.js b/anyclip/src/graphql/services/invitations/types/payload/item.js new file mode 100644 index 0000000..40cbdf1 --- /dev/null +++ b/anyclip/src/graphql/services/invitations/types/payload/item.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLInt } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/invitations/types/payload/list.js b/anyclip/src/graphql/services/invitations/types/payload/list.js new file mode 100644 index 0000000..8c48aef --- /dev/null +++ b/anyclip/src/graphql/services/invitations/types/payload/list.js @@ -0,0 +1,35 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '@/graphql/services/_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + enabled: { + type: GraphQLBoolean, + }, + searchText: { + type: GraphQLString, + }, + status: { + type: GraphQLString, + }, + accountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/notifications/constants/index.js b/anyclip/src/graphql/services/notifications/constants/index.js new file mode 100644 index 0000000..8ff87af --- /dev/null +++ b/anyclip/src/graphql/services/notifications/constants/index.js @@ -0,0 +1,6 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export const GET_ITEM = `${MODULE_NAME}_GET_ITEM`; +export const SAVE_ITEM = `${MODULE_NAME}_SAVE_ITEM`; diff --git a/anyclip/src/graphql/services/notifications/types/payload/item.js b/anyclip/src/graphql/services/notifications/types/payload/item.js new file mode 100644 index 0000000..8574622 --- /dev/null +++ b/anyclip/src/graphql/services/notifications/types/payload/item.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_ITEM = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_ITEM, + fields: { + overallFailure: { + type: GraphQLInt, + }, + popup: { + type: GraphQLInt, + }, + message: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/onlineHelp/constants/index.js b/anyclip/src/graphql/services/onlineHelp/constants/index.js new file mode 100644 index 0000000..ab4026c --- /dev/null +++ b/anyclip/src/graphql/services/onlineHelp/constants/index.js @@ -0,0 +1,12 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_CONFIGURATIONS = `${MODULE_NAME}_GET_CONFIGURATIONS`; +export const GET_CONFIGURATION = `${MODULE_NAME}_GET_CONFIGURATION`; + +// mutations +export const CREATE_CONFIGURATION = `${MODULE_NAME}_CREATE_CONFIGURATION`; +export const UPDATE_CONFIGURATION = `${MODULE_NAME}_UPDATE_CONFIGURATION`; +export const DELETE_CONFIGURATION = `${MODULE_NAME}_DELETE_CONFIGURATION`; diff --git a/anyclip/src/graphql/services/onlineHelp/types/payload/configuration.js b/anyclip/src/graphql/services/onlineHelp/types/payload/configuration.js new file mode 100644 index 0000000..77a6557 --- /dev/null +++ b/anyclip/src/graphql/services/onlineHelp/types/payload/configuration.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLInt } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/onlineHelp/types/payload/configurations.js b/anyclip/src/graphql/services/onlineHelp/types/payload/configurations.js new file mode 100644 index 0000000..5f462e6 --- /dev/null +++ b/anyclip/src/graphql/services/onlineHelp/types/payload/configurations.js @@ -0,0 +1,26 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/onlineHelp/types/payload/item.js b/anyclip/src/graphql/services/onlineHelp/types/payload/item.js new file mode 100644 index 0000000..f696630 --- /dev/null +++ b/anyclip/src/graphql/services/onlineHelp/types/payload/item.js @@ -0,0 +1,23 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + suffix: { + type: GraphQLString, + }, + helpPageUrl: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/permissions/constatnts/index.js b/anyclip/src/graphql/services/permissions/constatnts/index.js new file mode 100644 index 0000000..652aaab --- /dev/null +++ b/anyclip/src/graphql/services/permissions/constatnts/index.js @@ -0,0 +1,6 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_DATA = `${MODULE_NAME}_GET_DATA`; diff --git a/anyclip/src/graphql/services/rolesPermissions/constants/index.js b/anyclip/src/graphql/services/rolesPermissions/constants/index.js new file mode 100644 index 0000000..3d2479a --- /dev/null +++ b/anyclip/src/graphql/services/rolesPermissions/constants/index.js @@ -0,0 +1,14 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_ROLE_LIST = `${MODULE_NAME}_GET_ROLE_LIST`; +export const GET_ROLE_ACCOUNTS = `${MODULE_NAME}_GET_ROLE_ACCOUNTS`; +export const GET_ROLE_ITEM = `${MODULE_NAME}_GET_ROLE_ITEM`; + +// mutation +export const UPDATE_ROLE_ITEM = `${MODULE_NAME}_UPDATE_ROLE_ITEM`; +export const CREATE_ROLE_ITEM = `${MODULE_NAME}_CREATE_ROLE_ITEM`; +export const UPDATE_ROLE_MODULE_METADATA = `${MODULE_NAME}_UPDATE_ROLE_MODULE_METADATA`; +export const UPDATE_PERMISSION_METADATA = `${MODULE_NAME}_UPDATE_PERMISSION_METADATA`; diff --git a/anyclip/src/graphql/services/rolesPermissions/types/payload/account.js b/anyclip/src/graphql/services/rolesPermissions/types/payload/account.js new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/rolesPermissions/types/payload/account.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/rolesPermissions/types/payload/permissionMetadata.js b/anyclip/src/graphql/services/rolesPermissions/types/payload/permissionMetadata.js new file mode 100644 index 0000000..3b48c1c --- /dev/null +++ b/anyclip/src/graphql/services/rolesPermissions/types/payload/permissionMetadata.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + displayName: { + type: GraphQLString, + }, + description: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/rolesPermissions/types/payload/roleItem.js b/anyclip/src/graphql/services/rolesPermissions/types/payload/roleItem.js new file mode 100644 index 0000000..c048964 --- /dev/null +++ b/anyclip/src/graphql/services/rolesPermissions/types/payload/roleItem.js @@ -0,0 +1,35 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + accountId: { + type: GraphQLInt, + }, + displayName: { + type: GraphQLString, + }, + defaultPage: { + type: GraphQLString, + }, + type: { + type: GraphQLString, + }, + readOnlyInSelfServe: { + type: GraphQLBoolean, + }, + visibleInSelfServe: { + type: GraphQLBoolean, + }, + permissions: { + type: new GraphQLList(GraphQLString), + }, + }, +}); diff --git a/anyclip/src/graphql/services/rolesPermissions/types/payload/roleList.js b/anyclip/src/graphql/services/rolesPermissions/types/payload/roleList.js new file mode 100644 index 0000000..551aadc --- /dev/null +++ b/anyclip/src/graphql/services/rolesPermissions/types/payload/roleList.js @@ -0,0 +1,32 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_GET_ROLE_LIST = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_GET_ROLE_LIST, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + type: { + type: GraphQLString, + }, + searchText: { + type: GraphQLString, + }, + accountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/rolesPermissions/types/payload/roleModuleMetadata.js b/anyclip/src/graphql/services/rolesPermissions/types/payload/roleModuleMetadata.js new file mode 100644 index 0000000..e424eb3 --- /dev/null +++ b/anyclip/src/graphql/services/rolesPermissions/types/payload/roleModuleMetadata.js @@ -0,0 +1,38 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + displayName: { + type: GraphQLString, + }, + description: { + type: GraphQLString, + }, + permissions: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_permissionsModules_permissions`, + fields: { + id: { + type: GraphQLInt, + }, + displayName: { + type: GraphQLString, + }, + description: { + type: GraphQLString, + }, + }, + }), + ), + }, + }, +}); diff --git a/anyclip/src/graphql/services/sso/constants/index.js b/anyclip/src/graphql/services/sso/constants/index.js new file mode 100644 index 0000000..2ae9e6b --- /dev/null +++ b/anyclip/src/graphql/services/sso/constants/index.js @@ -0,0 +1,13 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_SSO_ITEMS = `${MODULE_NAME}_GET_SSO_ITEMS`; +export const GET_SSO_ITEM = `${MODULE_NAME}_GET_SSO_ITEM`; +export const GET_SSO_HUB_OPTIONS = `${MODULE_NAME}_GET_SSO_HUB_OPTIONS`; + +// mutation +export const UPDATE_SSO_ITEM = `${MODULE_NAME}_UPDATE_SSO_ITEMS`; +export const CREATE_SSO_ITEM = `${MODULE_NAME}_CREATE_SSO_ITEM`; +export const CHANGE_SSO_STATUS = `${MODULE_NAME}_CHANGE_SSO_STATUS`; diff --git a/anyclip/src/graphql/services/sso/types/payload/hub.js b/anyclip/src/graphql/services/sso/types/payload/hub.js new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/sso/types/payload/hub.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/sso/types/payload/ssoGetItem.js b/anyclip/src/graphql/services/sso/types/payload/ssoGetItem.js new file mode 100644 index 0000000..29f3ec5 --- /dev/null +++ b/anyclip/src/graphql/services/sso/types/payload/ssoGetItem.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_GET_SSO_ITEM = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_GET_SSO_ITEM, + fields: { + id: { + type: GraphQLInt, + }, + provider: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/sso/types/payload/ssoList.js b/anyclip/src/graphql/services/sso/types/payload/ssoList.js new file mode 100644 index 0000000..fc05bbc --- /dev/null +++ b/anyclip/src/graphql/services/sso/types/payload/ssoList.js @@ -0,0 +1,23 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_GET_SSO_ITEMS = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_GET_SSO_ITEMS, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/sso/types/payload/ssoUpsertItem.js b/anyclip/src/graphql/services/sso/types/payload/ssoUpsertItem.js new file mode 100644 index 0000000..60925b6 --- /dev/null +++ b/anyclip/src/graphql/services/sso/types/payload/ssoUpsertItem.js @@ -0,0 +1,89 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_UPSERT_SSO_ITEM = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_UPSERT_SSO_ITEM, + fields: { + id: { + type: GraphQLInt, + }, + displayName: { + type: GraphQLString, + }, + allDomains: { + type: GraphQLBoolean, + }, + onlyExisting: { + type: GraphQLBoolean, + }, + allHubs: { + type: GraphQLBoolean, + }, + provider: { + type: GraphQLString, + }, + domains: { + type: new GraphQLList(GraphQLString), + }, + metadataURL: { + type: GraphQLString, + }, + ssoDefaultHubs: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_UPSERT_SSO_ITEM}_ssoDefaultHubs`, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + }, + }), + ), + }, + ssoCustomAttributes: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_UPSERT_SSO_ITEM}_ssoCustomAttributes`, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + allHubs: { + type: GraphQLBoolean, + }, + status: { + type: GraphQLBoolean, + }, + ssoCustomAttributesHubs: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_UPSERT_SSO_ITEM}_ssoCustomAttributesHubs`, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + }, + }), + ), + }, + ssoCustomAttributesKeys: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + }, + }), + ), + }, + }, +}); diff --git a/anyclip/src/graphql/services/sso/types/payload/status.js b/anyclip/src/graphql/services/sso/types/payload/status.js new file mode 100644 index 0000000..e0634c8 --- /dev/null +++ b/anyclip/src/graphql/services/sso/types/payload/status.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + ids: { + type: new GraphQLList(GraphQLInt), + }, + status: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/constants/index.js b/anyclip/src/graphql/services/users/constants/index.js new file mode 100644 index 0000000..4dbcab6 --- /dev/null +++ b/anyclip/src/graphql/services/users/constants/index.js @@ -0,0 +1,22 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_USERS = `${MODULE_NAME}_GET_USERS`; +export const GET_USER = `${MODULE_NAME}_GET_USER`; +export const GET_USER_ROLE_OPTIONS = `${MODULE_NAME}_GET_USER_ROLE_OPTIONS`; +export const GET_USER_TIMEZONE_OPTIONS = `${MODULE_NAME}_GET_USER_TIMEZONE_OPTIONS`; +export const GET_API_SET = `${MODULE_NAME}_GET_API_SET`; +export const GET_USER_ACCOUNTS_OPTIONS = `${MODULE_NAME}_GET_USER_ACCOUNTS_OPTIONS`; +export const GET_DEPARTMENT_OPTIONS = `${MODULE_NAME}_GET_DEPARTMENT_OPTIONS`; +export const GET_CONTENT_OWNERS_OPTIONS = `${MODULE_NAME}_GET_CONTENT_OWNERS_OPTIONS`; + +// mutations +export const IMPERSONATE_USER = `${MODULE_NAME}_IMPERSONATE_USER`; +export const CREATE_USER = `${MODULE_NAME}_CREATE_USER`; +export const UPDATE_USER = `${MODULE_NAME}_UPDATE_USER`; +export const GENERATE_TOKEN = `${MODULE_NAME}_GENERATE_TOKEN`; +export const RESET_PASSWORD = `${MODULE_NAME}_RESET_PASSWORD`; +export const BULK_USER_ACTIONS = `${MODULE_NAME}_BULK_USER_ACTIONS`; +export const CREATE_DEPARTMENT = `${MODULE_NAME}_CREATE_DEPARTMENT`; diff --git a/anyclip/src/graphql/services/users/types/payload/accounts.js b/anyclip/src/graphql/services/users/types/payload/accounts.js new file mode 100644 index 0000000..b797cbc --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/accounts.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + filtersValues: { + type: new GraphQLList(GraphQLString), + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/apiToken.js b/anyclip/src/graphql/services/users/types/payload/apiToken.js new file mode 100644 index 0000000..0ccac49 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/apiToken.js @@ -0,0 +1,26 @@ +import { GraphQLBoolean, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + userId: { + type: GraphQLInt, + }, + accountId: { + type: GraphQLInt, + }, + expirationDate: { + type: GraphQLFloat, + }, + resources: { + type: new GraphQLList(GraphQLString), + }, + isExpirationDateActive: { + type: GraphQLBoolean, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/bulkActions.js b/anyclip/src/graphql/services/users/types/payload/bulkActions.js new file mode 100644 index 0000000..90a8c00 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/bulkActions.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + field: { + type: GraphQLString, + }, + ids: { + type: new GraphQLList(GraphQLInt), + }, + value: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/contentOwners.js b/anyclip/src/graphql/services/users/types/payload/contentOwners.js new file mode 100644 index 0000000..b797cbc --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/contentOwners.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + filtersValues: { + type: new GraphQLList(GraphQLString), + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/department.js b/anyclip/src/graphql/services/users/types/payload/department.js new file mode 100644 index 0000000..7445500 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/department.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + accountId: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/departmentList.js b/anyclip/src/graphql/services/users/types/payload/departmentList.js new file mode 100644 index 0000000..2278d34 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/departmentList.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + accountId: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/item.js b/anyclip/src/graphql/services/users/types/payload/item.js new file mode 100644 index 0000000..0502790 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/item.js @@ -0,0 +1,69 @@ +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + accountId: { + type: GraphQLInt, + }, + firstName: { + type: GraphQLString, + }, + lastName: { + type: GraphQLString, + }, + enable2fa: { + type: GraphQLBoolean, + }, + roleId: { + type: GraphQLInt, + }, + timezone: { + type: GraphQLString, + }, + department: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_department`, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + }, + }), + }, + departmentId: { + type: GraphQLInt, + }, + contentOwnerId: { + type: GraphQLInt, + }, + defaultHub: { + type: GraphQLInt, + }, + publisherIds: { + type: new GraphQLList(GraphQLInt), + }, + allSites: { + type: GraphQLBoolean, + }, + email: { + type: GraphQLString, + }, + status: { + type: GraphQLInt, + }, + sendInvite: { + type: GraphQLBoolean, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/itemDetails.js b/anyclip/src/graphql/services/users/types/payload/itemDetails.js new file mode 100644 index 0000000..77a6557 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/itemDetails.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLInt } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/list.js b/anyclip/src/graphql/services/users/types/payload/list.js new file mode 100644 index 0000000..eadc722 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/list.js @@ -0,0 +1,38 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + status: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + roleId: { + type: GraphQLInt, + }, + departmentId: { + type: GraphQLInt, + }, + accountId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/users/types/payload/resetPassword.js b/anyclip/src/graphql/services/users/types/payload/resetPassword.js new file mode 100644 index 0000000..5506084 --- /dev/null +++ b/anyclip/src/graphql/services/users/types/payload/resetPassword.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + email: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/videoBulkActions/constants/index.js b/anyclip/src/graphql/services/videoBulkActions/constants/index.js new file mode 100644 index 0000000..2519175 --- /dev/null +++ b/anyclip/src/graphql/services/videoBulkActions/constants/index.js @@ -0,0 +1,7 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_USERS_FOR_SHARE_ACTION = `${MODULE_NAME}_GET_USERS_FOR_SHARE_ACTION`; +export const GET_HUBS_FOR_SHARE_ACTION = `${MODULE_NAME}_GET_HUBS_FOR_SHARE_ACTION`; diff --git a/anyclip/src/graphql/services/videoBulkActions/types/payload/hub.js b/anyclip/src/graphql/services/videoBulkActions/types/payload/hub.js new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/videoBulkActions/types/payload/hub.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/videoBulkActions/types/payload/user.js b/anyclip/src/graphql/services/videoBulkActions/types/payload/user.js new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/videoBulkActions/types/payload/user.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCampaings/constants/index.js b/anyclip/src/graphql/services/xRayCampaings/constants/index.js new file mode 100644 index 0000000..bbc5841 --- /dev/null +++ b/anyclip/src/graphql/services/xRayCampaings/constants/index.js @@ -0,0 +1,15 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_XRAY_CAMPAIGNS = `${MODULE_NAME}_GET_XRAY_CAMPAIGNS`; +export const GET_XRAY_CAMPAIGN_ITEM = `${MODULE_NAME}_GET_XRAY_CAMPAIGN_ITEM`; +export const GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS = `${MODULE_NAME}_GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS`; +export const GET_XRAY_CAMPAIGNS_HUB_OPTIONS = `${MODULE_NAME}_GET_XRAY_HUB_ADVERTISER_OPTIONS`; + +// mutation +export const ARCHIVE_XRAY_CAMPAIGN = `${MODULE_NAME}_ARCHIVE_XRAY_CAMPAIGN`; +export const CREATE_XRAY_CAMPAIGN = `${MODULE_NAME}_CREATE_XRAY_CAMPAIGN`; +export const UPDATE_XRAY_CAMPAIGN = `${MODULE_NAME}_UPDATE_XRAY_CAMPAIGN`; +export const CREATE_XRAY_CAMPAIGN_ADVERTISER_BRAND = `${MODULE_NAME}_CREATE_XRAY_CAMPAIGN_ADVERTISER_BRAND`; diff --git a/anyclip/src/graphql/services/xRayCampaings/types/payload/advertiser.js b/anyclip/src/graphql/services/xRayCampaings/types/payload/advertiser.js new file mode 100644 index 0000000..b619a77 --- /dev/null +++ b/anyclip/src/graphql/services/xRayCampaings/types/payload/advertiser.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + search: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCampaings/types/payload/advertiserItem.js b/anyclip/src/graphql/services/xRayCampaings/types/payload/advertiserItem.js new file mode 100644 index 0000000..cd781c4 --- /dev/null +++ b/anyclip/src/graphql/services/xRayCampaings/types/payload/advertiserItem.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + name: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCampaings/types/payload/archive.js b/anyclip/src/graphql/services/xRayCampaings/types/payload/archive.js new file mode 100644 index 0000000..77a6557 --- /dev/null +++ b/anyclip/src/graphql/services/xRayCampaings/types/payload/archive.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLInt } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCampaings/types/payload/hub.js b/anyclip/src/graphql/services/xRayCampaings/types/payload/hub.js new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/xRayCampaings/types/payload/hub.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaignItem.js b/anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaignItem.js new file mode 100644 index 0000000..728476e --- /dev/null +++ b/anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaignItem.js @@ -0,0 +1,23 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + advertisingBrandId: { + type: GraphQLInt, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaigns.js b/anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaigns.js new file mode 100644 index 0000000..c061a8f --- /dev/null +++ b/anyclip/src/graphql/services/xRayCampaings/types/payload/xRayCampaigns.js @@ -0,0 +1,35 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_GET_XRAY_CAMPAIGNS = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_GET_XRAY_CAMPAIGNS, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + status: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + advertisingBrandId: { + type: GraphQLInt, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCreatives/constants/index.js b/anyclip/src/graphql/services/xRayCreatives/constants/index.js new file mode 100644 index 0000000..9133e44 --- /dev/null +++ b/anyclip/src/graphql/services/xRayCreatives/constants/index.js @@ -0,0 +1,13 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_XRAY_CREATIVES = `${MODULE_NAME}_GET_XRAY_CREATIVES`; +export const GET_XRAY_CREATIVES_HUB_OPTIONS = `${MODULE_NAME}_GET_XRAY_CREATIVES_HUB_OPTIONS`; +export const GET_XRAY_CREATIVE_ITEM = `${MODULE_NAME}_GET_XRAY_CREATIVE_ITEM`; + +// mutations +export const ARCHIVE_XRAY_CREATIVE = `${MODULE_NAME}_ARCHIVE_XRAY_CREATIVE`; +export const UPDATE_XRAY_CREATIVE = `${MODULE_NAME}_UPDATE_XRAY_CREATIVE`; +export const CREATE_XRAY_CREATIVE = `${MODULE_NAME}_CREATE_XRAY_CREATIVE`; diff --git a/anyclip/src/graphql/services/xRayCreatives/types/payload/archive.js b/anyclip/src/graphql/services/xRayCreatives/types/payload/archive.js new file mode 100644 index 0000000..77a6557 --- /dev/null +++ b/anyclip/src/graphql/services/xRayCreatives/types/payload/archive.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLInt } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreativeItem.js b/anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreativeItem.js new file mode 100644 index 0000000..de0682f --- /dev/null +++ b/anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreativeItem.js @@ -0,0 +1,50 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + copyId: { + type: GraphQLInt, + }, + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + title: { + type: GraphQLString, + }, + image: { + type: GraphQLString, + }, + buttonLabel1: { + type: GraphQLString, + }, + buttonUrl1: { + type: GraphQLString, + }, + buttonLabel2: { + type: GraphQLString, + }, + buttonUrl2: { + type: GraphQLString, + }, + impressionTracker: { + type: GraphQLString, + }, + clickTracker1: { + type: GraphQLString, + }, + clickTracker2: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreatives.js b/anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreatives.js new file mode 100644 index 0000000..e3f39e4 --- /dev/null +++ b/anyclip/src/graphql/services/xRayCreatives/types/payload/xRayCreatives.js @@ -0,0 +1,32 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_GET_XRAY_CREATIVES = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_GET_XRAY_CREATIVES, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + status: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/constants/index.js b/anyclip/src/graphql/services/xRayLineItems/constants/index.js new file mode 100644 index 0000000..46655bf --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/constants/index.js @@ -0,0 +1,24 @@ +import { createModuleNameFromMetaUrl } from '../../_helpers/common'; + +const MODULE_NAME = createModuleNameFromMetaUrl(import.meta.url); + +// queries +export const GET_XRAY_LINE_ITEMS = `${MODULE_NAME}_GET_XRAY_LINE_ITEMS`; +export const GET_XRAY_LINE_ITEMS_HUB_OPTIONS = `${MODULE_NAME}_GET_XRAY_HUB_ADVERTISER_OPTIONS`; +export const GET_XRAY_LINE_ITEM_CREATIVES_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_CREATIVES_OPTIONS`; +export const GET_XRAY_LINE_ITEM = `${MODULE_NAME}_GET_XRAY_LINE_ITEM`; +export const GET_XRAY_LINE_TIMEZONE_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_TIMEZONE_OPTIONS`; +export const GET_XRAY_LINE_ITEM_WATCH_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_WATCH_OPTIONS`; +export const GET_XRAY_LINE_ITEM_DOMAIN_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_DOMAIN_OPTIONS`; +export const GET_XRAY_LINE_ITEM_GEO_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_GEO_OPTIONS`; +export const GET_XRAY_LINE_ITEM_PLAYER_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_PLAYER_OPTIONS`; +export const GET_XRAY_LINE_ITEM_VIDEO_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_VIDEO_OPTIONS`; +export const GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS`; +export const GET_XRAY_LINE_ITEM_LABEL_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_LABEL_OPTIONS`; +export const GET_XRAY_LINE_ITEM_BRAND_SAFETY_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEM_BRAND_SAFETY_OPTIONS`; +export const GET_XRAY_LINE_ITEMS_CAMPAINGS_OPTIONS = `${MODULE_NAME}_GET_XRAY_LINE_ITEMS_CAMPAINGS_OPTIONS`; + +// mutation +export const ARCHIVE_XRAY_LINE_ITEM = `${MODULE_NAME}_ARCHIVE_XRAY_LINE_ITEM`; +export const CREATE_XRAY_LINE_ITEM = `${MODULE_NAME}_CREATE_XRAY_LINE_ITEM`; +export const UPDATE_XRAY_LINE_ITEM = `${MODULE_NAME}_UPDATE_XRAY_LINE_ITEM`; diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/archive.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/archive.js new file mode 100644 index 0000000..77a6557 --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/archive.js @@ -0,0 +1,14 @@ +import { GraphQLInputObjectType, GraphQLInt } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/brandSafety.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/brandSafety.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/brandSafety.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/campain.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/campain.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/campain.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/creative.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/creative.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/creative.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/domain.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/domain.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/domain.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/hub.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/hub.js new file mode 100644 index 0000000..948a4fe --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/hub.js @@ -0,0 +1,17 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/label.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/label.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/label.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/player.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/player.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/player.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/taxonomy.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/taxonomy.js new file mode 100644 index 0000000..c28e84c --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/taxonomy.js @@ -0,0 +1,23 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + category: { + type: GraphQLString, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/video.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/video.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/video.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/watch.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/watch.js new file mode 100644 index 0000000..6f5ad9a --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/watch.js @@ -0,0 +1,20 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + pageSize: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemList.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemList.js new file mode 100644 index 0000000..f039581 --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemList.js @@ -0,0 +1,41 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + publisherId: { + type: GraphQLInt, + }, + xrayCampaignId: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + status: { + type: GraphQLInt, + }, + priority: { + type: GraphQLInt, + }, + xrayCreatives: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayCreatives`, + fields: { + id: { + type: GraphQLInt, + }, + }, + }), + ), + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemUpsert.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemUpsert.js new file mode 100644 index 0000000..8042aaf --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemUpsert.js @@ -0,0 +1,302 @@ +import { GraphQLBoolean, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_NAME = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_NAME, + fields: { + id: { + type: GraphQLInt, + }, + publisherId: { + type: GraphQLInt, + }, + xrayCampaignId: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + status: { + type: GraphQLInt, + }, + priority: { + type: GraphQLInt, + }, + xrayCreatives: { + type: new GraphQLList(GraphQLInt), + }, + startTime: { + type: GraphQLFloat, + }, + endTime: { + type: GraphQLFloat, + }, + timezoneId: { + type: GraphQLInt, + }, + rate: { + type: GraphQLInt, + }, + impressionsBudget: { + type: GraphQLInt, + }, + spendBudget: { + type: GraphQLInt, + }, + watchId: { + type: GraphQLInt, + }, + watchChannelId: { + type: GraphQLInt, + }, + playerId: { + type: GraphQLInt, + }, + videoName: { + type: GraphQLString, + }, + videoUid: { + type: GraphQLString, + }, + videoFromTimestamp: { + type: GraphQLInt, + }, + videoToTimestamp: { + type: GraphQLInt, + }, + targeting: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargeting`, + fields: { + domains: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingDomains`, + fields: { + include: { + type: new GraphQLList(GraphQLString), + }, + exclude: { + type: new GraphQLList(GraphQLString), + }, + }, + }), + }, + devices: { + type: new GraphQLList(GraphQLInt), + }, + geography: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingGeography`, + fields: { + include: { + type: new GraphQLList(GraphQLInt), + }, + exclude: { + type: new GraphQLList(GraphQLInt), + }, + }, + }), + }, + inview: { + type: GraphQLBoolean, + }, + notInview: { + type: GraphQLBoolean, + }, + iabCategories: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingIabCategories`, + fields: { + include: { + type: new GraphQLList(GraphQLString), + }, + exclude: { + type: new GraphQLList(GraphQLString), + }, + }, + }), + }, + people: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingPeople`, + fields: { + include: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingPeopleInclude`, + fields: { + name: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + exclude: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingPeopleExclude`, + fields: { + name: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + }, + }), + }, + brands: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingBrands`, + fields: { + include: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingBrandsInclude`, + fields: { + name: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + exclude: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingBrandsExclude`, + fields: { + name: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + }, + }), + }, + keywords: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingKeywords`, + fields: { + include: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingKeywordsInclude`, + fields: { + name: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + exclude: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingKeywordsExclude`, + fields: { + name: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + }, + }), + }, + labels: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingLabels`, + fields: { + include: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingLabelsInclude`, + fields: { + name: { + type: GraphQLString, + }, + value: { + type: GraphQLString, + }, + color: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + exclude: { + type: new GraphQLList( + new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingLabelsExclude`, + fields: { + name: { + type: GraphQLString, + }, + value: { + type: GraphQLString, + }, + color: { + type: GraphQLString, + }, + taxonomyId: { + type: GraphQLString, + }, + }, + }), + ), + }, + }, + }), + }, + brandSafety: { + type: new GraphQLInputObjectType({ + name: `${PAYLOAD_NAME}_xrayLineItemTargetingBrandSafety`, + fields: { + include: { + type: new GraphQLList(GraphQLInt), + }, + exclude: { + type: new GraphQLList(GraphQLInt), + }, + }, + }), + }, + playerSizes: { + type: new GraphQLList(GraphQLInt), + }, + }, + }), + }, + }, +}); diff --git a/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemsList.js b/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemsList.js new file mode 100644 index 0000000..9daced3 --- /dev/null +++ b/anyclip/src/graphql/services/xRayLineItems/types/payload/xRayLineItemsList.js @@ -0,0 +1,35 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import { createModuleNameFromMetaUrl } from '../../../_helpers/common'; + +export const PAYLOAD_GET_XRAY_LINE_ITEMS = createModuleNameFromMetaUrl(import.meta.url); + +export default new GraphQLInputObjectType({ + name: PAYLOAD_GET_XRAY_LINE_ITEMS, + fields: { + sortBy: { + type: GraphQLString, + }, + sortOrder: { + type: GraphQLString, + }, + page: { + type: GraphQLInt, + }, + pageSize: { + type: GraphQLInt, + }, + status: { + type: GraphQLInt, + }, + searchText: { + type: GraphQLString, + }, + publisherId: { + type: GraphQLInt, + }, + xrayCampaignId: { + type: GraphQLInt, + }, + }, +}); diff --git a/anyclip/src/modules/@common/ActionAutocomplete/ActionAutocomplete.module.scss b/anyclip/src/modules/@common/ActionAutocomplete/ActionAutocomplete.module.scss new file mode 100644 index 0000000..bfedd14 --- /dev/null +++ b/anyclip/src/modules/@common/ActionAutocomplete/ActionAutocomplete.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Group___indent":"ActionAutocomplete_Group___indent___IfPt","GroupLabel":"ActionAutocomplete_GroupLabel__qTAJU"}; \ No newline at end of file diff --git a/anyclip/src/modules/@common/ActionAutocomplete/index.jsx b/anyclip/src/modules/@common/ActionAutocomplete/index.jsx new file mode 100644 index 0000000..d0834e6 --- /dev/null +++ b/anyclip/src/modules/@common/ActionAutocomplete/index.jsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { autocompleteClasses } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { TagSelector } from '@/modules/@common/TagSelector'; +import { List, ListItem, ListSubheader, Stack, Typography } from '@/mui/components'; + +import styles from './ActionAutocomplete.module.scss'; + +function ActionAutocomplete({ + onInputChange = () => {}, + onOpen = null, + onChange = null, + value = [], + groupBy = null, + disabled = false, + size = 'medium', + placeholder = null, + ...props +}) { + const theme = useTheme(); + const [list, setList] = useState(value || []); + + useEffect(() => setList(value || []), [value]); + + const renderGroup = (params) => { + const [name, , color] = (params.group || '').split('|'); + + const optionList = ( + + {params.children} + + ); + + return params.group ? ( + + + + + {name} + + + {optionList} + + + ) : ( + optionList + ); + }; + + return ( + !list.some((item) => item.value === option.value)) ?? []} + placeholder={placeholder} + onOpen={() => { + if (onOpen) { + onOpen(''); + } + }} + onChange={(e, tags) => { + onChange(tags); + setList(tags); + }} + onInputChange={(e) => { + if (onInputChange) { + onInputChange(e.target.value); + } + }} + /> + ); +} + +ActionAutocomplete.propTypes = { + id: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + onInputChange: PropTypes.func, + onChange: PropTypes.func, + onOpen: PropTypes.func, + value: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + }), + ), + groupBy: PropTypes.func, + isFilterDirty: PropTypes.bool, + disabled: PropTypes.bool, + size: PropTypes.oneOf(['xSmall', 'small', 'medium', 'large']), + placeholder: PropTypes.string, +}; + +export default ActionAutocomplete; diff --git a/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionIAB/index.jsx b/anyclip/src/modules/@common/ActionIAB/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionIAB/index.jsx rename to anyclip/src/modules/@common/ActionIAB/index.jsx diff --git a/anyclip/src/modules/@common/EmbedCodePopup/EmbedCodePopup.module.scss b/anyclip/src/modules/@common/EmbedCodePopup/EmbedCodePopup.module.scss new file mode 100644 index 0000000..eae49f2 --- /dev/null +++ b/anyclip/src/modules/@common/EmbedCodePopup/EmbedCodePopup.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Input":"EmbedCodePopup_Input__icX1e","Code":"EmbedCodePopup_Code__gbkXl"}; \ No newline at end of file diff --git a/src/modules/common/EmbedCodePopup/constants/index.ts b/anyclip/src/modules/@common/EmbedCodePopup/constants/index.ts similarity index 100% rename from src/modules/common/EmbedCodePopup/constants/index.ts rename to anyclip/src/modules/@common/EmbedCodePopup/constants/index.ts diff --git a/anyclip/src/modules/@common/EmbedCodePopup/index.tsx b/anyclip/src/modules/@common/EmbedCodePopup/index.tsx new file mode 100644 index 0000000..02663ff --- /dev/null +++ b/anyclip/src/modules/@common/EmbedCodePopup/index.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { InfoOutlined } from '@mui/icons-material'; + +import { ASPECT_RATIOS, EMBED_CODE_TYPES } from '@/modules/@common/EmbedCodePopup/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { getIsWPVideoMode } from '@/modules/@common/app/helpers'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + MenuItem, + Select, + Stack, + Switch, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './EmbedCodePopup.module.scss'; + +type PlayerType = { + label: string; + value: string | number; +}; + +type AspectRatioType = { + value: string; +}; + +type EmbedCodeType = { + value: string; +}; + +type Props = { + embedCode: string; + handleAspectRatioChange: (aspectRatio: AspectRatioType | undefined) => void; + handleEmbedCodeTypeChange: (embedCodeType: EmbedCodeType | undefined) => void; + handlePlayerChange: (player: PlayerType | undefined) => void; + isAspectRatioDisabled?: boolean; + isCopyDisabled?: boolean; + isPlayerDisabled?: boolean; + isInProcessing?: boolean; + onClose: () => void; + players: PlayerType[]; + selectedAspectRatio?: AspectRatioType; + selectedEmbedCodeType?: EmbedCodeType; + selectedPlayer?: PlayerType; + title?: string; + thumbnail?: string; + showAspectRatio?: boolean; + showEmbedCodeTypes?: boolean; + showVideoObjectSwitch?: boolean; + videoObjectChecked?: boolean; + videoObjectOnChange: (event: React.ChangeEvent) => void; + disableVideoObjectSwitch?: boolean; +}; + +function EmbedCodePopup({ + onClose, + embedCode = '', + handleAspectRatioChange = () => {}, + handleEmbedCodeTypeChange = () => {}, + handlePlayerChange = () => {}, + isAspectRatioDisabled = false, + isCopyDisabled = false, + isPlayerDisabled = false, + title = 'Video Embed Code', + isInProcessing = false, + thumbnail = '', + players = [], + selectedPlayer, + selectedAspectRatio = ASPECT_RATIOS[0], + selectedEmbedCodeType = EMBED_CODE_TYPES[0], + showAspectRatio = true, + showEmbedCodeTypes = false, + showVideoObjectSwitch = false, + videoObjectChecked = false, + videoObjectOnChange = () => {}, + disableVideoObjectSwitch = false, +}: Props) { + const [isWPVideoMode, setIsWPVideoMode] = useState(false); + + useEffect(() => { + setIsWPVideoMode(getIsWPVideoMode()); + }, []); + + const dispatch = useDispatch(); + + const aspectRatio = ASPECT_RATIOS.find(({ value }) => value === selectedAspectRatio?.value)?.value ?? ''; + + const embedCodeType = EMBED_CODE_TYPES.find(({ value }) => value === selectedEmbedCodeType?.value)?.value ?? ''; + + const handleCopySourceCode = () => { + copyToClipboard(embedCode).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Embed code copied to clipboard', + }), + ); + }); + onClose(); + }; + + const handleSendMessage = () => { + const data = { + type: 'ac_editorial_message', + embed: { + displayEmbedCode: true, + embedCode, + }, + thumbnail, + }; + window.parent.postMessage(data, '*'); + }; + + return ( + + {title} + + + {(!!players.length || showAspectRatio || showVideoObjectSwitch || showEmbedCodeTypes) && ( + + {!!players.length && ( + + )} + {showAspectRatio && ( + + )} + {showEmbedCodeTypes && ( + + )} + {showVideoObjectSwitch && ( + + + } + labelPlacement="start" + label="Include SEO" + /> + + + + + )} + + )} + {!isWPVideoMode && ( + + + Copy Embed Code into Your CMS + + + {embedCode} + + {isInProcessing && ( + <> + + Processing video. It will be available for playback in a few minutes + + + You may use the embed code, the video will play on page once processing is completed + + + )} + + )} + + + + + + + + ); +} + +export default EmbedCodePopup; diff --git a/anyclip/src/modules/@common/Empty/Empty.module.scss b/anyclip/src/modules/@common/Empty/Empty.module.scss new file mode 100644 index 0000000..c926d6e --- /dev/null +++ b/anyclip/src/modules/@common/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Root":"Empty_Root__hPC4U","Root___withBorder":"Empty_Root___withBorder__CEGm6","Root___withOpacity":"Empty_Root___withOpacity__FJci9","Icon":"Empty_Icon__7pWSI"}; \ No newline at end of file diff --git a/anyclip/src/modules/@common/Empty/Empty.tsx b/anyclip/src/modules/@common/Empty/Empty.tsx new file mode 100644 index 0000000..82c5bd8 --- /dev/null +++ b/anyclip/src/modules/@common/Empty/Empty.tsx @@ -0,0 +1,33 @@ +import React, { ReactElement, ReactNode } from 'react'; +import classNames from 'clsx'; + +import { Stack } from '@/mui/components'; + +import styles from './Empty.module.scss'; + +type Props = { + withBorder: boolean; + withOpacity: boolean; + icon: ReactElement; + children: ReactNode; +}; + +function Empty({ withBorder = false, withOpacity = false, icon, children }: Props) { + return ( + + + {icon} + + {children} + + ); +} + +export default Empty; diff --git a/src/modules/common/Form/Form/Form.module.scss b/anyclip/src/modules/@common/Form/Form/Form.module.scss similarity index 100% rename from src/modules/common/Form/Form/Form.module.scss rename to anyclip/src/modules/@common/Form/Form/Form.module.scss diff --git a/src/modules/common/Form/Form/Form.tsx b/anyclip/src/modules/@common/Form/Form/Form.tsx similarity index 100% rename from src/modules/common/Form/Form/Form.tsx rename to anyclip/src/modules/@common/Form/Form/Form.tsx diff --git a/src/modules/common/Form/FormContent/FormContent.jsx b/anyclip/src/modules/@common/Form/FormContent/FormContent.jsx similarity index 100% rename from src/modules/common/Form/FormContent/FormContent.jsx rename to anyclip/src/modules/@common/Form/FormContent/FormContent.jsx diff --git a/src/modules/common/Form/FormContent/FormContent.module.scss b/anyclip/src/modules/@common/Form/FormContent/FormContent.module.scss similarity index 100% rename from src/modules/common/Form/FormContent/FormContent.module.scss rename to anyclip/src/modules/@common/Form/FormContent/FormContent.module.scss diff --git a/src/modules/common/Form/FormGroup/FormGroup.jsx b/anyclip/src/modules/@common/Form/FormGroup/FormGroup.jsx similarity index 100% rename from src/modules/common/Form/FormGroup/FormGroup.jsx rename to anyclip/src/modules/@common/Form/FormGroup/FormGroup.jsx diff --git a/src/modules/common/Form/FormGroup/FormGroup.module.scss b/anyclip/src/modules/@common/Form/FormGroup/FormGroup.module.scss similarity index 100% rename from src/modules/common/Form/FormGroup/FormGroup.module.scss rename to anyclip/src/modules/@common/Form/FormGroup/FormGroup.module.scss diff --git a/src/modules/common/Form/FormGroupTitle/FormGroupTitle.jsx b/anyclip/src/modules/@common/Form/FormGroupTitle/FormGroupTitle.jsx similarity index 100% rename from src/modules/common/Form/FormGroupTitle/FormGroupTitle.jsx rename to anyclip/src/modules/@common/Form/FormGroupTitle/FormGroupTitle.jsx diff --git a/src/modules/common/Form/FormGroupTitle/FormGroupTitle.module.scss b/anyclip/src/modules/@common/Form/FormGroupTitle/FormGroupTitle.module.scss similarity index 100% rename from src/modules/common/Form/FormGroupTitle/FormGroupTitle.module.scss rename to anyclip/src/modules/@common/Form/FormGroupTitle/FormGroupTitle.module.scss diff --git a/src/modules/common/Form/FormImageUploader/FormImageUploader.jsx b/anyclip/src/modules/@common/Form/FormImageUploader/FormImageUploader.jsx similarity index 100% rename from src/modules/common/Form/FormImageUploader/FormImageUploader.jsx rename to anyclip/src/modules/@common/Form/FormImageUploader/FormImageUploader.jsx diff --git a/src/modules/common/Form/FormImageUploader/FormImageUploader.module.scss b/anyclip/src/modules/@common/Form/FormImageUploader/FormImageUploader.module.scss similarity index 100% rename from src/modules/common/Form/FormImageUploader/FormImageUploader.module.scss rename to anyclip/src/modules/@common/Form/FormImageUploader/FormImageUploader.module.scss diff --git a/src/modules/common/Form/FormRow/FormRow.jsx b/anyclip/src/modules/@common/Form/FormRow/FormRow.jsx similarity index 100% rename from src/modules/common/Form/FormRow/FormRow.jsx rename to anyclip/src/modules/@common/Form/FormRow/FormRow.jsx diff --git a/src/modules/common/Form/FormRow/components/Label/Label.jsx b/anyclip/src/modules/@common/Form/FormRow/components/Label/Label.jsx similarity index 100% rename from src/modules/common/Form/FormRow/components/Label/Label.jsx rename to anyclip/src/modules/@common/Form/FormRow/components/Label/Label.jsx diff --git a/src/modules/common/Form/FormRow/components/Label/Label.module.scss b/anyclip/src/modules/@common/Form/FormRow/components/Label/Label.module.scss similarity index 100% rename from src/modules/common/Form/FormRow/components/Label/Label.module.scss rename to anyclip/src/modules/@common/Form/FormRow/components/Label/Label.module.scss diff --git a/src/modules/common/Form/FormRow/components/Value/Value.jsx b/anyclip/src/modules/@common/Form/FormRow/components/Value/Value.jsx similarity index 100% rename from src/modules/common/Form/FormRow/components/Value/Value.jsx rename to anyclip/src/modules/@common/Form/FormRow/components/Value/Value.jsx diff --git a/src/modules/common/Form/FormRow/components/Value/Value.module.scss b/anyclip/src/modules/@common/Form/FormRow/components/Value/Value.module.scss similarity index 100% rename from src/modules/common/Form/FormRow/components/Value/Value.module.scss rename to anyclip/src/modules/@common/Form/FormRow/components/Value/Value.module.scss diff --git a/src/modules/common/Form/FormRowItem/FormRowItem.jsx b/anyclip/src/modules/@common/Form/FormRowItem/FormRowItem.jsx similarity index 100% rename from src/modules/common/Form/FormRowItem/FormRowItem.jsx rename to anyclip/src/modules/@common/Form/FormRowItem/FormRowItem.jsx diff --git a/src/modules/common/Form/FormRowItem/FormRowItem.module.scss b/anyclip/src/modules/@common/Form/FormRowItem/FormRowItem.module.scss similarity index 100% rename from src/modules/common/Form/FormRowItem/FormRowItem.module.scss rename to anyclip/src/modules/@common/Form/FormRowItem/FormRowItem.module.scss diff --git a/src/modules/common/Form/FormSection/FormSection.jsx b/anyclip/src/modules/@common/Form/FormSection/FormSection.jsx similarity index 100% rename from src/modules/common/Form/FormSection/FormSection.jsx rename to anyclip/src/modules/@common/Form/FormSection/FormSection.jsx diff --git a/src/modules/common/Form/FormSection/FormSection.module.scss b/anyclip/src/modules/@common/Form/FormSection/FormSection.module.scss similarity index 100% rename from src/modules/common/Form/FormSection/FormSection.module.scss rename to anyclip/src/modules/@common/Form/FormSection/FormSection.module.scss diff --git a/anyclip/src/modules/@common/Form/constants/index.js b/anyclip/src/modules/@common/Form/constants/index.js new file mode 100644 index 0000000..95d9d4d --- /dev/null +++ b/anyclip/src/modules/@common/Form/constants/index.js @@ -0,0 +1,4 @@ +export const SCROLL_POSITION_DELTA = 48; +export const ITEM_SPACE = 2; +export const ROW_SPACE = 4; +export const REDUX_ERROR_PROP_NAME = '$__error__$'; diff --git a/src/modules/common/Form/helpers/hooks.js b/anyclip/src/modules/@common/Form/helpers/hooks.js similarity index 100% rename from src/modules/common/Form/helpers/hooks.js rename to anyclip/src/modules/@common/Form/helpers/hooks.js diff --git a/anyclip/src/modules/@common/Form/helpers/index.js b/anyclip/src/modules/@common/Form/helpers/index.js new file mode 100644 index 0000000..580de02 --- /dev/null +++ b/anyclip/src/modules/@common/Form/helpers/index.js @@ -0,0 +1,65 @@ +import { REDUX_ERROR_PROP_NAME } from '@/modules/@common/Form/constants'; + +const flatErrorsKeys = (obj, parentKey = '', result = {}) => { + for (const key in obj) { + if (Array.isArray(obj[key])) { + if (obj[key].length > 0 && typeof obj[key][0] === 'object') { + obj[key].forEach((item, index) => { + flatErrorsKeys(item, `${parentKey}.${key}.${index}`, result); + }); + } else { + // eslint-disable-next-line no-param-reassign + result[`${parentKey}.${key}`] = obj[key]; + } + } else if (typeof obj[key] === 'object' && obj[key] !== null) { + flatErrorsKeys(obj[key], `${parentKey}.${key}`, result); + } else { + // eslint-disable-next-line no-param-reassign + result[`${parentKey}.${key}`] = obj[key]; + } + } + + return result; +}; + +export const getErrorFieldsList = (validationResult) => { + const errors = []; + + validationResult.forEach((item) => { + if (item.dynamic) { + Object.entries(flatErrorsKeys(item[REDUX_ERROR_PROP_NAME], item.fieldName)).forEach(([fieldName, value]) => { + if (value) { + errors.push({ + ...item, + fieldName, + [REDUX_ERROR_PROP_NAME]: value, + }); + } + }); + } else if (item[REDUX_ERROR_PROP_NAME]) { + errors.push(item); + } + }); + + return errors; +}; + +export const getRequiredInputProps = ({ fieldName, label, error = false, helperText = '' }) => ({ + required: true, + label: label ?? 'Required', + name: fieldName, + error: !!error, + helperText: helperText || '', +}); + +export const getInputPropsByName = (scheme, fieldArray, customLabel) => { + const fieldName = fieldArray.join('.'); + const errorData = getErrorFieldsList(scheme).find((error) => error.fieldName === fieldName) ?? {}; + + return getRequiredInputProps({ + label: customLabel, + fieldName, + error: !!errorData[REDUX_ERROR_PROP_NAME], + helperText: errorData[REDUX_ERROR_PROP_NAME], + }); +}; diff --git a/src/modules/common/Form/index.js b/anyclip/src/modules/@common/Form/index.js similarity index 100% rename from src/modules/common/Form/index.js rename to anyclip/src/modules/@common/Form/index.js diff --git a/anyclip/src/modules/@common/Form/redux/selectors/index.js b/anyclip/src/modules/@common/Form/redux/selectors/index.js new file mode 100644 index 0000000..b56834e --- /dev/null +++ b/anyclip/src/modules/@common/Form/redux/selectors/index.js @@ -0,0 +1,6 @@ +export default function createFormSelector(formReduxKey, nameSpace) { + return { + getScrollField: (state) => state[nameSpace][formReduxKey].scrollField, + schemeSelector: (state) => state[nameSpace][formReduxKey].scheme, + }; +} diff --git a/anyclip/src/modules/@common/Form/redux/slices/index.js b/anyclip/src/modules/@common/Form/redux/slices/index.js new file mode 100644 index 0000000..0842a3b --- /dev/null +++ b/anyclip/src/modules/@common/Form/redux/slices/index.js @@ -0,0 +1,102 @@ +import { REDUX_ERROR_PROP_NAME } from '@/modules/@common/Form/constants'; + +import { getErrorFieldsList } from '@/modules/@common/Form/helpers'; + +export default function createFormSlice(formReduxKey, initialScheme) { + const validateSingleField = (schemeFieldName, value, state, ...other) => { + const field = initialScheme.find((schemeField) => schemeField.fieldName === schemeFieldName); + + return { + ...field, + validation: undefined, + [REDUX_ERROR_PROP_NAME]: field.validation(value, state, ...other), + }; + }; + + const validateFields = (schemeFieldName, state, ...other) => { + const validation = initialScheme.map((field) => + schemeFieldName.includes(field.fieldName) + ? validateSingleField( + field.fieldName, + field.fieldName.split('.').reduce((acc, key) => { + if (acc !== null && acc !== undefined && Object.prototype.hasOwnProperty.call(acc, key)) { + return acc[key]; + } + console.warn(`You try to take value from ${field.fieldName} which doesnt exist in store`); + return null; + }, state), + state, + ...other, + ) + : { + ...field, + validation: undefined, + [REDUX_ERROR_PROP_NAME]: false, + }, + ); + + return { + validation, + errorList: getErrorFieldsList(validation), + }; + }; + + return { + validateSingleField, + validateFields, + state: { + [formReduxKey]: { + scrollField: { + name: null, + }, + scheme: validateFields([]).validation, + }, + }, + actions: { + setScrollToFieldAction: (state, action) => { + state[formReduxKey].scrollField = { + name: action.payload, + }; + }, + updateValidationSchemeAction: (state, action) => { + action.payload.forEach((newError) => { + const findIndex = state[formReduxKey].scheme.findIndex((error) => error.fieldName === newError.fieldName); + + if (findIndex !== -1) { + const targetError = state[formReduxKey].scheme[findIndex]; + + if (Object.entries(targetError).some(([key, value]) => value !== newError[key])) { + state[formReduxKey].scheme[findIndex] = { + ...newError, + }; + } + } + }); + }, + removeErrorByFieldNameAction: (state, action) => { + const [parentKey, ...nestedKeys] = action.payload; + const current = state[formReduxKey].scheme.find((schemeField) => parentKey === schemeField.fieldName); + + if (current) { + if (!nestedKeys.length) { + current[REDUX_ERROR_PROP_NAME] = ''; + } else { + nestedKeys.reduce((acc, key, index, array) => { + if (acc !== null && acc !== undefined && Object.prototype.hasOwnProperty.call(acc, key)) { + if (index >= array.length - 1) { + acc[key] = ''; + + return null; + } + + return acc[key]; + } + + return null; + }, current[REDUX_ERROR_PROP_NAME]); + } + } + }, + }, + }; +} diff --git a/src/modules/common/List/List.module.scss b/anyclip/src/modules/@common/List/List.module.scss similarity index 100% rename from src/modules/common/List/List.module.scss rename to anyclip/src/modules/@common/List/List.module.scss diff --git a/src/modules/common/List/index.tsx b/anyclip/src/modules/@common/List/index.tsx similarity index 100% rename from src/modules/common/List/index.tsx rename to anyclip/src/modules/@common/List/index.tsx diff --git a/anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.module.scss b/anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.module.scss new file mode 100644 index 0000000..1e2a7de --- /dev/null +++ b/anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"MultiAutocomplete":"MultiAutocomplete_MultiAutocomplete__tX6LI","TagsRoot":"MultiAutocomplete_TagsRoot__FeMRf","TagsRoot___blur":"MultiAutocomplete_TagsRoot___blur__EfKsB","InputRoot___blur":"MultiAutocomplete_InputRoot___blur__ba_rm","Input":"MultiAutocomplete_Input__D8MD0","ListItem___all":"MultiAutocomplete_ListItem___all__T3wjU"}; \ No newline at end of file diff --git a/anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.tsx b/anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.tsx new file mode 100644 index 0000000..f73ed57 --- /dev/null +++ b/anyclip/src/modules/@common/MultiAutocomplete/MultiAutocomplete.tsx @@ -0,0 +1,222 @@ +import React, { ComponentProps, CSSProperties, forwardRef, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import classNames from 'clsx'; +// eslint-disable-next-line no-restricted-imports +import { Autocomplete, autocompleteClasses, Checkbox, createFilterOptions, inputAdornmentClasses } from '@mui/material'; + +import { largeSize, mediumSize, smallSize, xSmallSize } from '@/mui/components/constants'; +import { SIZE_LARGE, SIZE_MEDIUM, SIZE_SMALL, SIZE_X_SMALL, SPACING } from '@/mui/constants'; + +import { autoSuggestHighlight } from '@/mui/helpers'; + +import { Box, Button, Chip } from '@/mui/components'; +import ListItem from '@/mui/components/ListItem/ListItem'; +import ListItemIcon from '@/mui/components/ListItemIcon/ListItemIcon'; +import ListItemText from '@/mui/components/ListItemText/ListItemText'; +import Stack from '@/mui/components/Stack/Stack'; + +import styles from './MultiAutocomplete.module.scss'; + +const SELECT_ALL_KEY = '$$selectAllOptions'; + +type BaseOption = { label: string; value: string; [SELECT_ALL_KEY]?: boolean }; + +type Props = Omit, 'options' | 'value'> & { + value: BaseOption[]; + options: BaseOption[]; + autoSuggest?: boolean; +}; + +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +const fixedHeight = { + [SIZE_X_SMALL]: xSmallSize, + [SIZE_SMALL]: smallSize, + [SIZE_MEDIUM]: mediumSize, + [SIZE_LARGE]: largeSize, +}; + +const MultiAutocomplete = forwardRef( + ( + { + disableCloseOnSelect = true, + autoSuggest = true, + value, + options, + size = 'small', + onChange, + onOpen, + onClose, + classes = {}, + className, + renderInput, + ...props + }, + ref, + ) => { + const autocompleteRef = useRef(null); + const [adornmentWidth, setAdornmentWidth] = useState(0); + const [inFocus, setInFocus] = useState(false); + const $tagsContainer = useRef(null); + + useIsomorphicLayoutEffect(() => { + const checkWidth = () => { + const aWidth = + ( + autocompleteRef.current?.querySelector(`.${inputAdornmentClasses.positionEnd}`) || + autocompleteRef.current?.querySelector(`.${autocompleteClasses.endAdornment}`) + )?.clientWidth || 0; + + setAdornmentWidth(aWidth + SPACING * 2); + }; + + checkWidth(); + + const timer = window.setInterval(() => checkWidth(), 1000); + + return () => { + clearInterval(timer); + }; + }, [autocompleteRef.current]); + + const allSelected = options.every((option) => value.some((selected) => selected.value === option.value)); + + return ( + { + if (typeof ref === 'function') { + ref(node as HTMLElement); + } else if (ref) { + ref.current = node as HTMLElement; + } + autocompleteRef.current = node as HTMLElement; + }} + classes={{ + ...classes, + input: styles.Input, + inputRoot: classNames(styles.InputRoot, { + [styles.InputRoot___blur]: !inFocus, + }), + }} + renderValue={(values$, getTagProps) => { + const values = values$ as BaseOption[]; + + return ( + + {values.map((value$, index) => { + const showTag = inFocus || (!inFocus && !index); + + return showTag ? ( + + ) : undefined; + })} + {!inFocus && values.length > 1 && } + + ); + }} + options={options} + multiple + value={value} + disableCloseOnSelect={disableCloseOnSelect} + size={size} + className={classNames(styles.MultiAutocomplete, className)} + filterOptions={(options$, params) => { + const filter = createFilterOptions(); + const filtered = filter(options$, params); + + return !options$.length + ? [...filtered] + : [...filtered, { label: 'Select All...', value: '', [SELECT_ALL_KEY]: true }]; + }} + onChange={(event, newOptions$, reason, ...args) => { + let newOptions = newOptions$ as BaseOption[]; + + if (newOptions.find((option) => option[SELECT_ALL_KEY])) { + newOptions = [...(options as BaseOption[])]; + } + + if (reason === 'selectOption' && $tagsContainer.current) { + $tagsContainer.current.scrollTop = $tagsContainer.current.scrollHeight; + } + + if (onChange) { + onChange(event, newOptions, reason, ...args); + } + }} + getOptionLabel={(option$) => (option$ as BaseOption).label} + isOptionEqualToValue={(option, v) => (option as BaseOption).value === (v as BaseOption).value} + renderOption={(props$, option$, { inputValue, selected }) => { + const option = option$ as BaseOption; + + return ( + + {option[SELECT_ALL_KEY] ? ( + + + + ) : ( + + + + + { + const Tag = part.highlight ? 'b' : React.Fragment; + + return {part.text}; + }) + : option.label + } + slotProps={{ + primary: { + variant: 'body2', + }, + }} + /> + + )} + + ); + }} + renderInput={renderInput} + onOpen={(...args) => { + setInFocus(true); + + if (onOpen) { + onOpen(...args); + } + }} + onClose={(...args) => { + setInFocus(false); + + if (onClose) { + onClose(...args); + } + }} + {...props} + /> + ); + }, +); + +export default MultiAutocomplete; diff --git a/anyclip/src/modules/@common/PlayerWidget/helpers/index.js b/anyclip/src/modules/@common/PlayerWidget/helpers/index.js new file mode 100644 index 0000000..ed2ce79 --- /dev/null +++ b/anyclip/src/modules/@common/PlayerWidget/helpers/index.js @@ -0,0 +1,59 @@ +import { createContext, useContext } from 'react'; + +import { mountTag } from '@/modules/@common/app/helpers'; + +export const getPlayerEndpoint = () => process.env.APP_PLAYER_WIDGET_URL; + +export const getPlaylistApiEndpoint = () => process.env.APP_PLAYER_PLAYLIST_URL; + +export const getPlayerConfigCdnEndpoint = (publisherSlug, playerName) => { + const configUrl = process.env.APP_PLAYER_CONFIG_URL; + + return `${configUrl}${publisherSlug}/${playerName}/conf.js?cb=${Date.now()}`; +}; + +export const getDirtyEvalObjectFromString = (response) => + // eslint-disable-next-line no-eval + eval(`(function(){ return ${response.replace(/ac_lre_conf =/, '')}})()`); + +const providers = {}; +export const setContext = (instanceKey) => { + if (!providers[instanceKey]) { + providers[instanceKey] = createContext({}); + } +}; +export const getContext = (instanceKey) => providers[instanceKey]; +export const deleteContext = (instanceKey) => { + delete providers[instanceKey]; +}; + +export const getCustomWidgetId = (instanceKey) => `${instanceKey}==[${Date.now().toString(36)}]`; + +export const usePlayerWidget = (instanceKey) => useContext(providers[instanceKey]); + +export const fetchConfig = (publisherSlug, playerName, confOverride) => + new Promise((resolve, reject) => { + const script = mountTag('script', { + props: { + src: getPlayerConfigCdnEndpoint(publisherSlug, playerName), + }, + appendContainer: document.body, + withoutUnmount: true, + }); + + script.addEventListener('load', () => { + window.ac_lre_conf_override = { + ...confOverride({ ...window.ac_lre_conf }), + lre_export_for_demo: true, + lre_playlistApiEndpoint: getPlaylistApiEndpoint(), + }; + + script.remove(); + resolve(); + }); + + script.addEventListener('error', () => { + script.remove(); + reject(); + }); + }); diff --git a/src/modules/common/Table/components/TableCellActions/TableCellActions.jsx b/anyclip/src/modules/@common/Table/components/TableCellActions/TableCellActions.jsx similarity index 100% rename from src/modules/common/Table/components/TableCellActions/TableCellActions.jsx rename to anyclip/src/modules/@common/Table/components/TableCellActions/TableCellActions.jsx diff --git a/src/modules/common/Table/components/TableCellActions/TableCellActions.module.scss b/anyclip/src/modules/@common/Table/components/TableCellActions/TableCellActions.module.scss similarity index 100% rename from src/modules/common/Table/components/TableCellActions/TableCellActions.module.scss rename to anyclip/src/modules/@common/Table/components/TableCellActions/TableCellActions.module.scss diff --git a/anyclip/src/modules/@common/Table/hooks/useLocalPagination.js b/anyclip/src/modules/@common/Table/hooks/useLocalPagination.js new file mode 100644 index 0000000..76fbe61 --- /dev/null +++ b/anyclip/src/modules/@common/Table/hooks/useLocalPagination.js @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +const getStartIndex = (itemsPerPage, currentPage) => itemsPerPage * (currentPage - 1); +const getEndIndex = (itemsPerPage, currentPage) => itemsPerPage * currentPage; + +// usage on data rows.slice(startIndex, endIndex) +const useLocalPagination = (itemsPerPage, currentPage = 1) => { + const [currentPage$, setCurrentPage] = useState(currentPage); + const [itemsPerPage$, setItemsPerPage] = useState(itemsPerPage); + const [startIndex$, setStartIndex] = useState(getStartIndex(itemsPerPage, currentPage)); + const [endIndex$, setEndIndex] = useState(getEndIndex(itemsPerPage, currentPage)); + + useEffect(() => { + setStartIndex(getStartIndex(itemsPerPage$, currentPage$)); + setEndIndex(getEndIndex(itemsPerPage$, currentPage$)); + }, [itemsPerPage$, currentPage$]); + + return { + startIndex: startIndex$, + endIndex: endIndex$, + currentPage: currentPage$, + itemsPerPage: itemsPerPage$, + setCurrentPage, + setItemsPerPage, + }; +}; + +export default useLocalPagination; diff --git a/anyclip/src/modules/@common/Table/index.jsx b/anyclip/src/modules/@common/Table/index.jsx new file mode 100644 index 0000000..f7e3734 --- /dev/null +++ b/anyclip/src/modules/@common/Table/index.jsx @@ -0,0 +1,215 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; + +import TableCellActions$ from './components/TableCellActions/TableCellActions'; +import { + Checkbox, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableScroll, + TableSortLabel, + Tooltip, +} from '@/mui/components'; + +function CommonTable({ + size = 'large', + sortOrder = SORT_ASC, + selected = [], + enableBulkActions = false, + bulkActionsIndeterminate = undefined, + bulkActionsChecked = undefined, + inverseScroll = false, + paginationHide = false, + stickyHeader = false, + onSelectDeselectAllRows = (v) => v, + onSelectDeselectRow = (v) => v, + onFilter = (v) => v, + ...props +}) { + return ( + + + + + + {enableBulkActions && ( + + { + onSelectDeselectAllRows(checked); + }} + /> + + )} + + {props.configHeaders.map((header) => { + let { padding } = header; + + if (!padding && !header.label) { + padding = 'checkbox'; + } + + return ( + + + {header.sortable ? ( + + onFilter({ + sortBy: header.id, + sortOrder: sortOrder === SORT_ASC ? SORT_DESC : SORT_ASC, + page: 1, + }) + } + > + {header.label} + + ) : ( +
{header.label}
+ )} +
+
+ ); + })} +
+ {props.configSubHeaders?.length > 0 && ( + + {enableBulkActions && ( + + { + onSelectDeselectAllRows(checked); + }} + /> + + )} + + {props.configSubHeaders.map((header) => { + let { padding } = header; + + if (!padding && !header.label) { + padding = 'checkbox'; + } + + return ( + + + {header.sortable ? ( + + onFilter({ + sortBy: header.id, + sortOrder: sortOrder === SORT_ASC ? SORT_DESC : SORT_ASC, + page: 1, + }) + } + > + {header.label} + + ) : ( +
{header.label}
+ )} +
+
+ ); + })} +
+ )} +
+ {props.data.map((row) => props.configRenderRow(row, selected, onSelectDeselectRow))} +
+
+ {props.totalCount > 0 && !paginationHide && ( + onFilter({ page: selectedPage })} + onRowsPerPageChange={(event) => + onFilter({ + pageSize: parseInt(event.target.value, 10), + page: 1, + }) + } + /> + )} +
+ ); +} + +CommonTable.propTypes = { + size: PropTypes.oneOf(['small', 'large']), + configHeaders: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + configSubHeaders: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + enableBulkActions: PropTypes.bool, + bulkActionsIndeterminate: PropTypes.bool, + bulkActionsChecked: PropTypes.bool, + inverseScroll: PropTypes.bool, + configRenderRow: PropTypes.func.isRequired, + selected: PropTypes.arrayOf(PropTypes.number), + data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + sortBy: PropTypes.string.isRequired, + sortOrder: PropTypes.oneOf([SORT_ASC, SORT_DESC]), + totalCount: PropTypes.number.isRequired, + page: PropTypes.number.isRequired, + rowsPerPage: PropTypes.number.isRequired, + paginationHide: PropTypes.bool, + stickyHeader: PropTypes.bool, + + onSelectDeselectAllRows: PropTypes.func, + onSelectDeselectRow: PropTypes.func, + onFilter: PropTypes.func, +}; + +export const TableCellActions = TableCellActions$; + +export default CommonTable; diff --git a/anyclip/src/modules/@common/Table/redux/epics/index.js b/anyclip/src/modules/@common/Table/redux/epics/index.js new file mode 100644 index 0000000..e10fecc --- /dev/null +++ b/anyclip/src/modules/@common/Table/redux/epics/index.js @@ -0,0 +1,44 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { gqlRequest } from '@/modules/@common/request'; + +export default function createEpicGetData({ + gqlQuery, + triggerActionType, + processBodyRequest, + processResponse, + setTableAction, +}) { + return (action$, state$) => + action$.pipe( + ofType(triggerActionType), + switchMap((action) => { + const stream$ = gqlRequest({ + query: gqlQuery, + variables: processBodyRequest(state$.value, action.payload), + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = processResponse(response); + + return of( + setTableAction({ + data: data.records, + totalCount: data.recordsTotal, + // value for know how many relevant rows there are in DB for sure when to show Empty table Screen + allRecordsCount: data.allRecordsCount || data.recordsTotal, + isLoading: false, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setTableAction({ isLoading: true })), stream$); + }), + ); +} diff --git a/anyclip/src/modules/@common/Table/redux/selectors/index.js b/anyclip/src/modules/@common/Table/redux/selectors/index.js new file mode 100644 index 0000000..90b693a --- /dev/null +++ b/anyclip/src/modules/@common/Table/redux/selectors/index.js @@ -0,0 +1,13 @@ +export default function createTableSelector(tableReduxKey, nameSpace) { + return { + dataSelector: (state) => state[nameSpace][tableReduxKey].data, + pageSelector: (state) => state[nameSpace][tableReduxKey].page, + pageSizeSelector: (state) => state[nameSpace][tableReduxKey].pageSize, + totalCountSelector: (state) => state[nameSpace][tableReduxKey].totalCount, + allRecordsCountSelector: (state) => state[nameSpace][tableReduxKey].allRecordsCount, + sortBySelector: (state) => state[nameSpace][tableReduxKey].sortBy, + sortOrderSelector: (state) => state[nameSpace][tableReduxKey].sortOrder, + selectedSelector: (state) => state[nameSpace][tableReduxKey].selected, + isLoadingSelector: (state) => state[nameSpace][tableReduxKey].isLoading, + }; +} diff --git a/anyclip/src/modules/@common/Table/redux/slices/index.js b/anyclip/src/modules/@common/Table/redux/slices/index.js new file mode 100644 index 0000000..cf13cc8 --- /dev/null +++ b/anyclip/src/modules/@common/Table/redux/slices/index.js @@ -0,0 +1,27 @@ +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +export default function createTableSlice(tableReduxKey, params) { + return { + state: { + // todo: move to ts + [tableReduxKey]: { + data: null, + page: params.page ?? 1, + pageSize: params.pageSize ?? 10, + totalCount: 0, + sortBy: params.sortBy, + sortOrder: params.sortOrder ?? SORT_DESC, + selected: [], + isLoading: false, + }, + }, + actions: { + getTableDataAction: (state) => state, + setTableAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[tableReduxKey][key] = action.payload[key]; + }); + }, + }, + }; +} diff --git a/src/modules/common/TagSelector/TagIabSelector/TagIabSelector.module.scss b/anyclip/src/modules/@common/TagSelector/TagIabSelector/TagIabSelector.module.scss similarity index 100% rename from src/modules/common/TagSelector/TagIabSelector/TagIabSelector.module.scss rename to anyclip/src/modules/@common/TagSelector/TagIabSelector/TagIabSelector.module.scss diff --git a/src/modules/common/TagSelector/TagIabSelector/TagIabSelector.tsx b/anyclip/src/modules/@common/TagSelector/TagIabSelector/TagIabSelector.tsx similarity index 100% rename from src/modules/common/TagSelector/TagIabSelector/TagIabSelector.tsx rename to anyclip/src/modules/@common/TagSelector/TagIabSelector/TagIabSelector.tsx diff --git a/src/modules/common/TagSelector/TagSelector/TagSelector.tsx b/anyclip/src/modules/@common/TagSelector/TagSelector/TagSelector.tsx similarity index 100% rename from src/modules/common/TagSelector/TagSelector/TagSelector.tsx rename to anyclip/src/modules/@common/TagSelector/TagSelector/TagSelector.tsx diff --git a/src/modules/common/TagSelector/components/StateSelect/StateSelect.module.scss b/anyclip/src/modules/@common/TagSelector/components/StateSelect/StateSelect.module.scss similarity index 100% rename from src/modules/common/TagSelector/components/StateSelect/StateSelect.module.scss rename to anyclip/src/modules/@common/TagSelector/components/StateSelect/StateSelect.module.scss diff --git a/src/modules/common/TagSelector/components/StateSelect/StateSelect.tsx b/anyclip/src/modules/@common/TagSelector/components/StateSelect/StateSelect.tsx similarity index 100% rename from src/modules/common/TagSelector/components/StateSelect/StateSelect.tsx rename to anyclip/src/modules/@common/TagSelector/components/StateSelect/StateSelect.tsx diff --git a/src/modules/common/TagSelector/components/TagList/TagList.module.scss b/anyclip/src/modules/@common/TagSelector/components/TagList/TagList.module.scss similarity index 100% rename from src/modules/common/TagSelector/components/TagList/TagList.module.scss rename to anyclip/src/modules/@common/TagSelector/components/TagList/TagList.module.scss diff --git a/src/modules/common/TagSelector/components/TagList/TagList.tsx b/anyclip/src/modules/@common/TagSelector/components/TagList/TagList.tsx similarity index 100% rename from src/modules/common/TagSelector/components/TagList/TagList.tsx rename to anyclip/src/modules/@common/TagSelector/components/TagList/TagList.tsx diff --git a/src/modules/common/TagSelector/constants/index.ts b/anyclip/src/modules/@common/TagSelector/constants/index.ts similarity index 100% rename from src/modules/common/TagSelector/constants/index.ts rename to anyclip/src/modules/@common/TagSelector/constants/index.ts diff --git a/src/modules/common/TagSelector/index.ts b/anyclip/src/modules/@common/TagSelector/index.ts similarity index 100% rename from src/modules/common/TagSelector/index.ts rename to anyclip/src/modules/@common/TagSelector/index.ts diff --git a/anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.module.scss b/anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.module.scss new file mode 100644 index 0000000..a163c49 --- /dev/null +++ b/anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"ViewportDraggable_Container__GvrPw"}; \ No newline at end of file diff --git a/anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.tsx b/anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.tsx new file mode 100644 index 0000000..5157ddc --- /dev/null +++ b/anyclip/src/modules/@common/ViewportDraggable/ViewportDraggable.tsx @@ -0,0 +1,118 @@ +import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import classNames from 'clsx'; + +import { getNumberInRange } from '@/modules/@common/helpers/number'; + +import styles from './ViewportDraggable.module.scss'; + +type Props = { + className?: string; + children: ReactNode; +}; + +type PositionType = { + x: number; + y: number; +}; + +const SENSITIVE_DISTANCE = 20; + +function ViewportDraggable({ className = '', children }: Props) { + const ref = useRef(null); + const [draggable, setDraggable] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const target = ref.current as HTMLDivElement; + + let axis: PositionType | null = null; + let initialPos: PositionType | null = null; + let dragDistance = 0; + + const { offsetTop, offsetLeft } = target; + + const fnStartDrag = (e: MouseEvent) => { + if (e.button === 0) { + setDraggable(true); + } + }; + + const fn = (event: MouseEvent) => { + const { clientWidth, clientHeight } = target; + + if (!axis) { + axis = { + x: event.clientX, + y: event.clientY, + }; + } + + setPosition((prev) => { + if (!initialPos) { + initialPos = prev; + } + + const newX = getNumberInRange( + initialPos.x + event.clientX - (axis?.x ?? 0), + -offsetLeft, + window.innerWidth - offsetLeft - clientWidth, + ); + const newY = getNumberInRange( + initialPos.y + event.clientY - (axis?.y ?? 0), + -offsetTop, + window.innerHeight - offsetTop - clientHeight, + ); + + dragDistance = Math.sqrt((newX - initialPos.x) ** 2 + (newY - initialPos.y) ** 2); + + return { x: newX, y: newY }; + }); + }; + + const fnEnd = (event: MouseEvent) => { + document.removeEventListener('mousemove', fn); + document.removeEventListener('mouseup', fnEnd); + + setDraggable(false); + + if (dragDistance > SENSITIVE_DISTANCE) { + event.stopPropagation(); + } + }; + + if (draggable) { + document.addEventListener('mousemove', fn); + document.addEventListener('mouseup', fnEnd); + } else { + target.addEventListener('mousedown', fnStartDrag); + } + + return () => { + document.removeEventListener('mousemove', fn); + document.removeEventListener('mouseup', fnEnd); + target.removeEventListener('mousedown', fnStartDrag); + }; + }, [draggable]); + + return ( +
{ + if (draggable) { + e.stopPropagation(); + } + }} + > + {children} +
+ ); +} + +export default ViewportDraggable; diff --git a/anyclip/src/modules/@common/acl/constants/index.ts b/anyclip/src/modules/@common/acl/constants/index.ts new file mode 100644 index 0000000..68b7fec --- /dev/null +++ b/anyclip/src/modules/@common/acl/constants/index.ts @@ -0,0 +1,242 @@ +// https://anyclip.atlassian.net/wiki/spaces/RD/pages/575537482/PCN+Tagging+Tool+-+User+roles+and+permissions + +/** + * Display name: Add scene + * Description: Allows a user to add new scenes. + * If no permission: Hide "Add Scene" button. Disable "Ctrl" + "Space" key shortcut. + * @type {string} + */ +export const ADD_SCENE = 'weavo_add_scene'; + +/** + * Display name: Edit scene + * Description: Allows a user to edit scenes. + * If no permission: Hide icons for editing scenes. + * @type {string} + */ +export const EDIT_SCENE = 'weavo_edit_scene'; + +/** + * Display name: Delete scene + * Description: Allows a user to delete scenes. + * If no permission: Hide icons for deleting scenes. + * @type {string} + */ +export const DELETE_SCENE = 'weavo_delete_scene'; + +/** + * Display name: Add clip + * Description: Allows a user to add new clip. + * If no permission: Hide "Add Clip" button. + * @type {string} + */ +export const ADD_CLIP = 'weavo_add_clip'; + +/** + * Display name: Copy clip distribution data + * Description: Allows a user to copy clip distribution data to clipboard. + * If no permission: Disallows a user to copy clip distribution data to clipboard. + * @type {string} + */ +export const COPY_CLIP_DISTRIBUTION_DATA = 'weavo_copy_clip_distribution_data'; + +/** + * Display name: Add tag + * Description: Allows a user to add new tags. + * If no permission: Hide "Add Tag" button. Disable "T" key shortcut. + * @type {string} + */ +export const ADD_TAG = 'weavo_add_tag'; + +/** + * Display name: Edit tag + * Description: Allows a user to edit tags. + * If no permission: Hide icons for editing tags. + * @type {string} + */ +export const EDIT_TAG = 'weavo_edit_tag'; + +/** + * Display name: Delete tag + * Description: Allows a user to delete tags. + * If no permission: Hide icons for deleting tags. + * @type {string} + */ +export const DELETE_TAG = 'weavo_delete_tag'; + +/** + * Display name: Duplicate tag + * Description: Allows a user to duplicate tags. + * If no permission: Hide icons for duplicating tags. + * @type {string} + */ +export const DUPLICATE_TAG = 'weavo_duplicate_tag'; + +/** + * Display name: View short video + * Description: Allows a user to view short videos + * If no permission: No drop-down option to switch to Short videos view. + * @type {string} + */ +export const VIEW_SHORT_VIDEO = 'weavo_view_clip'; + +/** + * Display name: Replace tag keywords + * Description: Allows a user to replace tag keywords + * If no permission: Hide replace tag keywords button from tagger menu + * @type {string} + */ +export const REPLACE_TAG_KEYWORDS = 'weavo_replace_tag_keywords'; + +/** + * Display name: Manage Content Owners + * Description: Allows a user to manage Content Owners + * If no permission: User unable create/update/delete Content Owners + * @type {string} + */ + +export const PCN_POST_NOTIFICATIONS = 'post/notifications'; +export const PCN_GET_PARTNERS_ACCOUNTS = 'get/accounts'; +export const PCN_GET_ADMIN = 'get/permissions'; + +export const PCN_GET_FEEDS = 'get/feeds'; +export const PCN_GET_SELF_SERVE_SOURCES = 'get/self-serve-sources'; + +export const PCN_GET_CUSTOM_REPORTS = 'get/custom-reports'; +export const PCN_POST_CUSTOM_REPORTS = 'port/custom-reports'; +export const PCN_PUT_CUSTOM_REPORTS = 'put/custom-reports'; +export const PCN_DELETE_CUSTOM_REPORTS = 'delete/custom-reports'; + +export const PCN_GET_USERS = 'get/users'; +export const PCN_PUT_USERS = 'put/users'; +export const PCN_POST_USERS = 'post/users'; +export const PCN_DELETE_USERS = 'delete/users'; + +export const PCN_GET_ONLINE_HELP = 'get/online-help'; +export const PCN_PUT_ONLINE_HELP = 'put/online-help'; +export const PCN_POST_ONLINE_HELP = 'post/online-help'; +export const PCN_DELETE_ONLINE_HELP = 'delete/online-help'; + +export const PCN_GET_PUBLISHER = 'get/publisher'; +export const PCN_POST_PUBLISHER = 'post/publisher'; +export const PCN_PUT_PUBLISHER = 'put/publisher'; +export const PCN_GET_ADVERTISERS = 'get/advertisers'; + +export const PCN_GET_PLAYER = 'get/player'; +export const PCN_GET_INVENTORY = 'get/inventory'; +export const PCN_GET_ANALYTICS = 'get/analytics'; +export const PCN_GET_PEOPLE = 'get/people'; +export const PCN_GET_ENTITIES = 'get/entities'; + +export const PCN_GET_LIVE_EVENTS = 'get/live-event'; +export const PCN_POST_LIVE_EVENTS = 'post/live-event'; +export const PCN_PUT_LIVE_EVENTS = 'put/live-event'; +export const PCN_DELETE_LIVE_EVENTS = 'delete/live-event'; + +export const PCN_GET_PLAYER_NEW = 'get/player-new'; +export const PCN_POST_PLAYER_NEW = 'post/player-new'; +export const PCN_PUT_PLAYER_NEW = 'put/player-new'; +export const PCN_DELETE_PLAYER_NEW = 'delete/player-new'; + +export const PCN_GET_DESTINATIONS = 'get/publish-destinations'; +export const PCN_PUT_DESTINATIONS = 'put/publish-destinations'; +export const PCN_POST_DESTINATIONS = 'post/publish-destinations'; +export const PCN_DELETE_DESTINATION = 'delete/publish-destinations'; + +export const PCN_INVITATIONS_SHOW_TABLE = 'invitations-show-table'; +export const PCN_GET_INVITATIONS = 'get/invitations'; +export const PCN_GET_X_RAY_CAMPAIGNS = 'get/x-ray'; +export const PCN_GET_X_RAY_CREATIVES = 'get/x-ray'; +export const PCN_GET_X_RAY_LINE_ITEMS = 'get/x-ray'; +export const PCN_GET_DEMAND = 'get/demand'; +export const PCN_GET_SUPPLY = 'get/supply'; +export const PCN_GET_RESTORE = 'get/restore'; +export const PCN_GET_DOMAIN = 'get/domain'; +export const PCN_GET_MARKETPLACE_DASHBOARD = 'get/marketplace'; +export const PCN_GET_MARKETPLACE_SELF_SERVE = 'get/marketplace-self-serve'; + +export const PCN_GET_FORMS_EDITOR = 'get/forms'; +export const PCN_POST_FORMS_EDITOR = 'post/forms'; +export const PCN_PUT_FORMS_EDITOR = 'put/forms'; +export const PCN_DELETE_FORMS_EDITOR = 'delete/forms'; + +export const PCN_FORMS_GET_REPORT = 'get/formReportRequests'; + +export const PCN_GET_MANAGE_WATCH = 'get/watch'; +export const PCN_POST_MANAGE_WATCH = 'post/watch'; +export const PCN_PUT_MANAGE_WATCH = 'put/watch'; +export const PCN_DELETE_MANAGE_WATCH = 'delete/watch'; + +export const PCN_GET_HOSTED_WATCH = 'get/hosted-watch'; +export const PCN_POST_HOSTED_WATCH = 'post/hosted-watch'; +export const PCN_PUT_HOSTED_WATCH = 'put/hosted-watch'; +export const PCN_DELETE_HOSTED_WATCH = 'delete/hosted-watch'; + +export const PCN_HOSTED_WATCH_SHOW_HUB_SELECTOR = 'hosted-watch-show-hub-selector'; +export const PCN_HOSTED_WATCH_SHOW_WATCHES = 'hosted-watch-show-watches'; +export const PCN_HOSTED_WATCH_SHOW_MY_WATCH = 'hosted-watch-show-my-watch'; +export const PCN_HOSTED_WATCH_SHOW_SHARED_WITH_YOU = 'hosted-watch-show-shared-with-you'; + +export const MANAGE_CONTENT_OWNERS = 'weavo_manage_content_owners'; + +// analytics +export const ANALYTICS_VIDEO_CONTENT_PERFORMANCE = 'analytics-video-external'; +export const ANALYTICS_LIVE_EVENTS_DASHBOARD = 'analytics-live-events'; +export const ANALYTICS_MONETIZATION_DASHBOARD = 'analytics-monetization'; +export const ANALYTICS_CUSTOM_REPORTS = 'analytics-custom-reports'; +// rules settings +export const GET_PERSONAL_SETTING = 'get/personal-settings'; +export const POST_PERSONAL_SETTING = 'post/personal-settings'; +export const PUT_PERSONAL_SETTING = 'put/personal-settings'; +export const DELETE_PERSONAL_SETTING = 'delete/personal-settings'; + +export const SSO_PAGE = 'view_sso_tab'; + +export const GET_FORM_TEMPLATES = 'get/form-templates'; +export const POST_FORM_TEMPLATES = 'post/form-templates'; +export const PUT_FORM_TEMPLATES = 'put/form-templates'; +export const DELETE_FORM_TEMPLATES = 'delete/form-templates'; + +export const WATCH_CONFIGURATION_CUSTOM_SETTING = 'watch-custom-settings'; +export const WATCH_CONFIGURATION_MY_WATCH_TOGGLE = 'watch-toggle-mywatch'; + +export const WEAVO_ANALYTICS_SHOW_ACCOUNT = 'weavo_analytics_show_account'; +export const WEAVO_VIEW_ROLES_PERMISSIONS = 'view_roles_permissions'; + +export const PCN_GET_PLAYLIST = 'get/editorial'; +export const PCN_POST_PLAYLIST = 'post/editorial'; +export const PCN_PUT_PLAYLIST = 'put/editorial'; +export const PCN_DELETE_PLAYLIST = 'delete/editorial'; + +export const PCN_GET_LUMINOUS_CONFIG = 'anyclip-config-luminous-config'; + +export const ACCOUNT_BULK_DELETE_VIDEOS = 'account-bulk-delete-videos'; + +// Video permissions +export const PCN_GET_VIDEO = 'get/video'; +export const PCN_POST_VIDEO = 'post/video'; +export const PCN_PUT_VIDEO = 'put/video'; +export const VIDEO_ADMIN = 'video-admin'; +export const VIDEO_VERSION_UPLOAD = 'video-version-upload'; +export const VIDEO_ACCESS_TARGETING = 'video-access-targeting'; +export const VIDEO_EDIT_SOURCE = 'video-edit-source'; +export const VIDEO_SHOW_CONTENT_OWNER_NAME = 'video-show-source-content-owner-name'; +export const VIDEO_ALL_TAB = 'video-all-tab'; +export const VIDEO_MY_TAB = 'video-my-tab'; +export const VIDEO_SHARED_TAB = 'video-shared-tab'; +export const VIDEO_FILTERS_INTERNAL_BUSINESS = 'video-filters-internal-business'; +export const VIDEO_FILTERS_ALL = 'video-filters-all'; +export const VIDEO_INSIGHTS = 'video-view-insights'; +export const VIDEO_EMBED = 'video-embed'; +export const VIDEO_INFO_TAB = 'video-info-tab'; +export const VIDEO_ADVANCED_TAB = 'video-advanced-tab'; +export const VIDEO_SLIDES = 'video-slides'; +export const VIDEO_SEARCH_CONTENT_OWNERS = 'video-search-content-owners'; +export const VIDEO_SCREEN_RECORDING_TMP = 'weavo_screen_webcam_recording'; +export const VIDEO_TAG_INFO = 'video-tag-info'; +export const VIDEO_DOWNLOAD_ANALYSIS_ALL = 'weavo_download_video_analysis_any'; +export const USERS_GENERATE_USER_TOKEN = 'generate_user_token'; +export const VIDEO_SPONSORED_CONTENT = 'video-sponsored-content'; + +// Self Service Player permissions +export const SELF_SERV_PLAYER_ACCESS_MONETIZATION = 'self-serve-player-access-monetization'; diff --git a/anyclip/src/modules/@common/app/ErrorBoundary.tsx b/anyclip/src/modules/@common/app/ErrorBoundary.tsx new file mode 100644 index 0000000..9e63b05 --- /dev/null +++ b/anyclip/src/modules/@common/app/ErrorBoundary.tsx @@ -0,0 +1,33 @@ +import React, { Component, ReactNode } from 'react'; + +import ErrorComponent from '@/modules/@common/app/components/error'; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError && this.state.error) { + return process.env.NODE_ENV === 'production' ? :
{this.state.error.message}
; + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/anyclip/src/modules/@common/app/GoogleAnalyticsProvider.tsx b/anyclip/src/modules/@common/app/GoogleAnalyticsProvider.tsx new file mode 100644 index 0000000..ac59e1a --- /dev/null +++ b/anyclip/src/modules/@common/app/GoogleAnalyticsProvider.tsx @@ -0,0 +1,66 @@ +import React, { ReactNode, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; + +import { + getUserAccountIdSelector, + getUserIdSelector, + getUserRoleSelector, +} from '@/modules/@common/user/redux/selectors'; + +const GA_TAG = 'G-260ZB3V811'; + +type Props = { + children: ReactNode; +}; + +function GoogleAnalyticsProvider({ children }: Props) { + const router = useRouter(); + const isProduction = process.env.NODE_ENV === 'production'; + + const userId = useSelector(getUserIdSelector); + const userAccountId = useSelector(getUserAccountIdSelector); + const { id: roleId, name: roleName, type: roleType } = useSelector(getUserRoleSelector); + const userEnv = process.env.APP_CONFIG_ENV; + + useEffect(() => { + const gtagAvailable = typeof window !== 'undefined' && typeof window.gtag === 'function'; + + if (isProduction && userId && gtagAvailable) { + window.gtag('config', GA_TAG); + + window.gtag('set', { + dimension1: `${userId}`, + dimension2: `${roleName} (${roleId})`, + dimension3: roleType, + dimension4: `${userAccountId}`, + dimension5: `${userEnv}`, + location: window.location.href + window.location.hash, + }); + + window.gtag('event', 'pageview'); + } + }, [router.pathname, userId, roleId, roleName, roleType, userAccountId]); + + return ( + <> + {isProduction && ( + <> + + + )} + {children} + + ); +} + +export default GoogleAnalyticsProvider; diff --git a/anyclip/src/modules/@common/app/IntercomProvider.jsx b/anyclip/src/modules/@common/app/IntercomProvider.jsx new file mode 100644 index 0000000..d5d4db4 --- /dev/null +++ b/anyclip/src/modules/@common/app/IntercomProvider.jsx @@ -0,0 +1,156 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { OpenWithRounded } from '@mui/icons-material'; + +import { + getIntercomUserHashSelector, + getUserAccountIdSelector, + getUserAccountSelector, + getUserEmailSelector, + getUserFullNameSelector, + getUserRoleSelector, +} from '@/modules/@common/user/redux/selectors'; + +import ViewportDraggable from '@/modules/@common/ViewportDraggable/ViewportDraggable'; +import { Button } from '@/mui/components'; +import { CustomIntercomChat } from '@/mui/components/CustomIcon'; + +import styles from './IntercomProvider.module.scss'; + +const intercomComponentId = 'custom_intercom_launcher_id'; + +function IntercomProvider(props) { + const router = useRouter(); + const [intercomInitialized, setIntercomInitialized] = useState(false); + const [intercomBooted, setIntercomBooted] = useState(false); + const intercomKey = process.env.APP_INTERCOM_KEY; + const intercomExcludePaths = process.env.APP_INTERCOM_EXCLUDE_PATHS.split(','); + + const userRole = useSelector(getUserRoleSelector); + const userEmail = useSelector(getUserEmailSelector); + const userFullName = useSelector(getUserFullNameSelector); + const userAccount = useSelector(getUserAccountSelector); + const userAccountId = useSelector(getUserAccountIdSelector); + const userHash = useSelector(getIntercomUserHashSelector); + + const bootIntercom = () => { + window.Intercom('boot', { + app_id: intercomKey, + hide_default_launcher: true, + custom_launcher_selector: `#${intercomComponentId}`, + api_base: 'https://api-iam.intercom.io', + email: userEmail, + user_hash: userHash || '', + name: userFullName, + role: `${userRole?.name}`, + company: { + company_id: userAccountId, + name: `${userAccount?.name}`, + type: `${userAccount?.type}`, + }, + }); + setIntercomBooted(true); + }; + + const shutdownIntercom = () => { + window.Intercom('shutdown'); + setIntercomBooted(false); + }; + + useEffect(() => { + if (intercomInitialized) { + if (intercomBooted && intercomExcludePaths.some((path) => router?.asPath.split('?')[0].includes(path))) { + shutdownIntercom(); + } + if ( + userAccountId && + !intercomBooted && + !intercomExcludePaths.some((path) => router?.asPath.split('?')[0].includes(path)) + ) { + bootIntercom(); + } + } + }, [router]); + + useEffect(() => { + if (intercomKey) { + (function () { + const w = window; + const ic = w.Intercom; + if (typeof ic === 'function') { + ic('reattach_activator'); + ic('update', w.intercomSettings); + } else { + const d = document; + // eslint-disable-next-line func-style + const i = function () { + // eslint-disable-next-line prefer-rest-params + i.c(arguments); + }; + i.q = []; + i.c = function (args) { + i.q.push(args); + }; + w.Intercom = i; + // eslint-disable-next-line func-style + const l = function () { + const s = d.createElement('script'); + s.type = 'text/javascript'; + s.async = true; + s.src = `https://widget.intercom.io/widget/${intercomKey}`; + const x = d.getElementsByTagName('script')[0]; + x.parentNode.insertBefore(s, x); + }; + if (document.readyState === 'complete') { + l(); + } else if (w.attachEvent) { + w.attachEvent('onload', l); + } else { + w.addEventListener('load', l, false); + } + } + })(); + setIntercomInitialized(true); + } + }, [intercomKey]); + + useEffect(() => { + if ( + userAccountId && + intercomKey && + intercomInitialized && + !intercomExcludePaths.some((path) => router?.asPath.split('?')[0].includes(path)) + ) { + bootIntercom(); + } + }, [intercomInitialized]); + + return ( + <> + {props.children} + {intercomBooted && ( + + + + )} + + + + ); +} + +Page.propTypes = { + onClick: PropTypes.func, + linkText: PropTypes.node, + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, +}; + +export default Page; diff --git a/anyclip/src/modules/@common/app/components/page.module.scss b/anyclip/src/modules/@common/app/components/page.module.scss new file mode 100644 index 0000000..e298111 --- /dev/null +++ b/anyclip/src/modules/@common/app/components/page.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"page_Wrapper__3ITeM","Img":"page_Img__3G47i","LinkBlock":"page_LinkBlock__ZH_sV"}; \ No newline at end of file diff --git a/anyclip/src/modules/@common/app/constants/index.js b/anyclip/src/modules/@common/app/constants/index.js new file mode 100644 index 0000000..ade5166 --- /dev/null +++ b/anyclip/src/modules/@common/app/constants/index.js @@ -0,0 +1,4 @@ +export const RECORD_MODES = { + SCREEN: 'Desktop', + CAMERA: 'Camera', +}; diff --git a/anyclip/src/modules/@common/app/helpers/index.js b/anyclip/src/modules/@common/app/helpers/index.js new file mode 100644 index 0000000..62f44e2 --- /dev/null +++ b/anyclip/src/modules/@common/app/helpers/index.js @@ -0,0 +1,60 @@ +export const getIsWordpressMode = () => + typeof window !== 'undefined' && + window.self !== window.top && + new URLSearchParams(window.location.search).get('mode') === 'wp'; + +export const getIsWPVideoMode = () => { + if (typeof window === 'undefined') { + return false; + } + const modeFromParams = new URLSearchParams(window.location.search).get('mode'); + const mode = + modeFromParams && modeFromParams.indexOf('?') > 0 + ? modeFromParams.slice(0, modeFromParams.indexOf('?')) + : modeFromParams; + return window.self !== window.top && mode === 'wp_video'; +}; + +const mountedTagList = []; + +export const createTag = (tagName, props = {}, attrs = {}) => { + const tag = document.createElement(tagName); + + Object.entries(props).forEach(([key, value]) => { + tag[key] = value; + }); + + Object.entries(attrs).forEach(([key, value]) => { + tag.setAttribute(key, `${value}`); + }); + + return tag; +}; + +export const mountTag = ( + tagName, + { props = {}, attrs = {}, appendContainer = document.head, withoutUnmount = false }, +) => { + const tag = createTag(tagName, props, attrs); + + if (!withoutUnmount) { + mountedTagList.push(tag); + } + + appendContainer.appendChild(tag); + + return tag; +}; + +const gaIteration = (...args) => { + if (window.ga) { + window.ga(...args); + } else { + setTimeout(() => gaIteration(...args), 500); + } +}; +export const ga = gaIteration; + +export const unmountTag = (domElementReferrer) => domElementReferrer?.remove(); + +export const unmountAllTags = () => mountedTagList.forEach(unmountTag); diff --git a/anyclip/src/modules/@common/ccFiles/constants/index.js b/anyclip/src/modules/@common/ccFiles/constants/index.js new file mode 100644 index 0000000..1b7e643 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/constants/index.js @@ -0,0 +1,29 @@ +export const TYPE_AUTO = 'AUTO'; +export const TYPE_MANUAL = 'MANUAL'; +export const TYPES = { + [TYPE_AUTO]: { id: TYPE_AUTO, name: 'Auto' }, + [TYPE_MANUAL]: { id: TYPE_MANUAL, name: 'Manual' }, +}; + +export const AUTO_TRANSLATE_UNAVAILABLE_TYPES = { + GOOGLE_TRANSLATE: 'GOOGLE_TRANSLATE', + MANUAL_TRANSLATE: 'API', + DEEPGRAM: 'S2T_DEEPGRAM', +}; + +export const RUN_CONTEXT = { + IN_TAB: 'InTab', + IN_VIDEO_UPLOAD: 'InVideoUpload', +}; + +export const CC_FILES_STATUS = { + ready: 'READY', + failed: 'FAILED', + processing: 'PROCESSING', +}; + +export const CC_FILES_STATUS_LABEL = { + [CC_FILES_STATUS.ready]: 'Ready', + [CC_FILES_STATUS.failed]: 'Failed', + [CC_FILES_STATUS.processing]: 'Processing', +}; diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/addCcFile.js b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/addCcFile.js new file mode 100644 index 0000000..cfa4f20 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/addCcFile.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ccFilesSelector, runContextSelector } from '../../selectors'; +import { addCcFileAction, setCcFilesAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation AddCcFilesToVideo( + $videoId: String! + $file: String! + $lang: String! + ) { + addCcFilesToVideo( + videoId: $videoId + file: $file + lang: $lang + ) { + file + lang + langName + sizeInBytes + source + version + state + operationId + } + } +`; + +const getResponse = ({ data: { addCcFilesToVideo } }) => addCcFilesToVideo; + +export default (action$, state$) => + action$.pipe( + ofType(addCcFileAction.type), + switchMap((action) => { + const ccFiles = ccFilesSelector(state$.value); + const runContext = runContextSelector(state$.value); + + const variables = { + videoId: action.payload.videoId, + file: action.payload.file, + lang: action.payload.lang, + }; + + const stream$ = gqlRequest({ query, variables }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + + // replace opened file from response + const currentCcFiles = ccFiles[runContext]; + const ccFilesToUpdate = currentCcFiles.map((ccFile) => { + if (ccFile.file === action.payload.file) { + return data; + } + + return ccFile; + }); + + return of(setCcFilesAction(ccFilesToUpdate)); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/checkCcFileState.js b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/checkCcFileState.js new file mode 100644 index 0000000..75848c4 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/checkCcFileState.js @@ -0,0 +1,85 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { checkCcFilesStateAction, getCcFilesAction } from '../../slices'; +import { checkCcFileStateMonitoringIsActiveSelector } from '@/modules/@common/ccFiles/redux/selectors'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query CCTranslateMonitoring($videoId: String!) { + ccTranslateMonitoring(videoId: $videoId) { + jobs { + videoId + state + progress + } + } + } +`; + +const getResponse = ({ data: { ccTranslateMonitoring } }) => ccTranslateMonitoring?.jobs[0]; + +const MONITORING_DELAY = 5000; + +const MONITORING_STATUS = { + STARTED: 'STARTED', + PROCESSING: 'PROCESSING', + DONE: 'DONE', + ERROR: 'ERROR', + FAILED: 'FAILED', +}; + +export default (action$, state$) => + action$.pipe( + ofType(checkCcFilesStateAction.type), + switchMap(({ payload }) => { + const { videoId } = payload; + + const checkCcFileStateMonitoringIsActive = checkCcFileStateMonitoringIsActiveSelector(state$.value); + + if (!checkCcFileStateMonitoringIsActive) { + return EMPTY; + } + + const stream$ = gqlRequest({ query, variables: { videoId } }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const job = getResponse(response); + + switch (job?.state) { + case MONITORING_STATUS.DONE: { + return concat(of(checkCcFilesStateAction({ videoId, isActive: false }))); + } + case MONITORING_STATUS.STARTED: + case MONITORING_STATUS.PROCESSING: { + return concat(of(checkCcFilesStateAction({ videoId, isActive: true })).pipe(delay(MONITORING_DELAY))); + } + case MONITORING_STATUS.ERROR: + case MONITORING_STATUS.FAILED: { + return concat( + of(checkCcFilesStateAction({ videoId, isActive: false })), + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'CC translate is failed', + }), + ), + ); + } + default: { + return EMPTY; + } + } + } + + return EMPTY; + }), + ); + + return concat(of(getCcFilesAction(videoId)), stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/deleteCcFile.js b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/deleteCcFile.js new file mode 100644 index 0000000..dbf6ed1 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/deleteCcFile.js @@ -0,0 +1,72 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ccFilesSelector, runContextSelector } from '../../selectors'; +import { deleteCcFileAction, setCcFilesAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation DeleteCcFilesFromVideo( + $videoId: String! + $file: String + $lang: String + $langName: String + $sizeInBytes: Int + $source: String + $version: String + $state: String + $operationId: String + $shouldDeleteAll: Boolean + ) { + deleteCcFilesFromVideo( + videoId: $videoId + file: $file + lang: $lang + langName: $langName + sizeInBytes: $sizeInBytes + source: $source + version: $version + state: $state + operationId: $operationId + shouldDeleteAll: $shouldDeleteAll + ) { + complete + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deleteCcFileAction.type), + switchMap((action) => { + const ccFiles = ccFilesSelector(state$.value); + const runContext = runContextSelector(state$.value); + + const shouldDeleteAll = !(action.payload?.file && action.payload?.lang); + + const variables = { ...action.payload }; + + if (shouldDeleteAll) { + variables.shouldDeleteAll = true; + } + + const stream$ = gqlRequest({ query, variables }).pipe( + switchMap((response) => { + if (!response.errors.length) { + // replace opened file from response + const currentCcFiles = ccFiles[runContext]; + const ccFilesToUpdate = shouldDeleteAll + ? [] + : currentCcFiles.filter((ccFile) => ccFile.file !== action.payload.file); + + return of(setCcFilesAction(ccFilesToUpdate)); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/getCcFiles.js b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/getCcFiles.js new file mode 100644 index 0000000..3ac29ab --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/ccFilesInVideoTab/getCcFiles.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { CC_FILES_STATUS } from '@/modules/@common/ccFiles/constants'; + +import { checkCcFilesStateAction, getCcFilesAction, setCcFilesAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetCcFilesByVideoId( + $videoId: String! + ) { + getCcFilesByVideoId( + videoId: $videoId + ) { + data { + file + lang + langName + sizeInBytes + source + version + state + operationId + } + } + } +`; + +const getResponse = ({ data: { getCcFilesByVideoId } }) => getCcFilesByVideoId.data; + +const MONITORING_DELAY = 5000; + +export default (action$) => + action$.pipe( + ofType(getCcFilesAction.type), + switchMap((action) => { + const variables = { videoId: action.payload }; + + const stream$ = gqlRequest({ query, variables }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const hasFilesInProgressState = data.some((ccFile) => ccFile.state === CC_FILES_STATUS.processing); + const actions = [of(setCcFilesAction(data))]; + + if (hasFilesInProgressState) { + actions.push( + of( + checkCcFilesStateAction({ + videoId: action.payload, + isActive: true, + }), + ).pipe(delay(MONITORING_DELAY)), + ); + } + + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/getCCTotalSegments.js b/anyclip/src/modules/@common/ccFiles/redux/epics/getCCTotalSegments.js new file mode 100644 index 0000000..637e98f --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/getCCTotalSegments.js @@ -0,0 +1,39 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCcTotalSegmentsAction, setCcTotalSegmentsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getCcTotalSegments($videoId: String!) { + getCcTotalSegments(videoId: $videoId) { + totalCount + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCcTotalSegmentsAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setCcTotalSegmentsAction(data?.getCcTotalSegments?.totalCount))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/getLanguages.js b/anyclip/src/modules/@common/ccFiles/redux/epics/getLanguages.js new file mode 100644 index 0000000..0f387a4 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/getLanguages.js @@ -0,0 +1,40 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { languagesSelector } from '../selectors'; +import { loadLanguagesAction, setLanguagesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ccLangs { + ccLangs { + id + name + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(loadLanguagesAction.type), + filter(() => { + const languages = languagesSelector(state$.value); + return !languages.length; + }), + switchMap(() => { + const stream$ = gqlRequest({ query }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setLanguagesAction(data.ccLangs))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/getTranslateLanguages.js b/anyclip/src/modules/@common/ccFiles/redux/epics/getTranslateLanguages.js new file mode 100644 index 0000000..fe58b12 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/getTranslateLanguages.js @@ -0,0 +1,35 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { loadTranslateLanguagesAction, setTranslateLanguagesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getTranslateLanguages { + getTranslateLanguages { + id + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(loadTranslateLanguagesAction.type), + switchMap(() => { + const stream$ = gqlRequest({ query }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setTranslateLanguagesAction(data.getTranslateLanguages))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/getUploadUrl.js b/anyclip/src/modules/@common/ccFiles/redux/epics/getUploadUrl.js new file mode 100644 index 0000000..2646e4b --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/getUploadUrl.js @@ -0,0 +1,74 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { RUN_CONTEXT } from '@/modules/@common/ccFiles/constants'; + +import { runContextSelector } from '../selectors'; +import { + replaceUploadProcessingAction, + replaceUploadStartAction, + uploadProcessingAction, + uploadStartAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; +import { ownerSelector } from '@/modules/uploaderNew/redux/selectors'; + +const queryGQL = ` + query Query($name: String, $filename: String!, $thumbnail: String, $contentOwnerId: Float!) { + S3UploadLink(name: $name, filename: $filename, thumbnail: $thumbnail, contentOwnerId: $contentOwnerId) { + uploadUrl + downloadUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(uploadStartAction.type, replaceUploadStartAction.type), + switchMap((action) => { + const runContext = runContextSelector(state$.value); + + const { file } = action.payload; + const filename = file.name; + let contentOwnerId = null; + + if (runContext === RUN_CONTEXT.IN_TAB) { + const selectedVideo = selectedVideoSelector(state$.value); + contentOwnerId = selectedVideo.contentOwner; + } + + if (runContext === RUN_CONTEXT.IN_VIDEO_UPLOAD) { + const owner = ownerSelector(state$.value); + contentOwnerId = owner?.value; + } + + const requestParams = { + contentOwnerId, + filename, + }; + + const stream$ = gqlRequest({ query: queryGQL, variables: requestParams }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length && data.S3UploadLink) { + const params = { ...data.S3UploadLink, file }; + const processingAction = + action.type === uploadStartAction.type ? uploadProcessingAction : replaceUploadProcessingAction; + + if (action.type === replaceUploadStartAction.type) { + params.ccFileForReplace = action.payload.ccFileForReplace; + } + + actions.push(of(processingAction(params))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/index.js b/anyclip/src/modules/@common/ccFiles/redux/epics/index.js new file mode 100644 index 0000000..bb56be0 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/index.js @@ -0,0 +1,25 @@ +import { combineEpics } from 'redux-observable'; + +import addCcFiles from './ccFilesInVideoTab/addCcFile'; +import checkCcFileState from './ccFilesInVideoTab/checkCcFileState'; +import deleteCcFile from './ccFilesInVideoTab/deleteCcFile'; +import getCcFiles from './ccFilesInVideoTab/getCcFiles'; +import getCCTotalSegments from './getCCTotalSegments'; +import getLanguages from './getLanguages'; +import getTranslateLanguages from './getTranslateLanguages'; +import getUploadUrl from './getUploadUrl'; +import setAutoTranslate from './setAutoTranslate'; +import upload from './upload'; + +export default combineEpics( + setAutoTranslate, + getTranslateLanguages, + getUploadUrl, + getLanguages, + getCCTotalSegments, + upload, + getCcFiles, + addCcFiles, + deleteCcFile, + checkCcFileState, +); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/setAutoTranslate.js b/anyclip/src/modules/@common/ccFiles/redux/epics/setAutoTranslate.js new file mode 100644 index 0000000..273752a --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/setAutoTranslate.js @@ -0,0 +1,60 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + checkCcFilesStateAction, + showAutoTranslateInfoDialogAction, + translateLanguagesProcessingAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation translateLanguagesUpdate( + $videoId: String!, + $targetLanguages: [String], + ) { + translateLanguagesUpdate( + videoId: $videoId, + targetLanguages: $targetLanguages, + ) { + status + } + } +`; + +export default (action$) => + action$.pipe( + ofType(translateLanguagesProcessingAction.type), + switchMap((action) => { + const { videoId, targetLanguages } = action.payload; + + const requestParams = { + videoId, + targetLanguages, + force: true, + }; + + const stream$ = gqlRequest({ query: queryGQL, variables: requestParams }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(showAutoTranslateInfoDialogAction(true)), + of( + checkCcFilesStateAction({ + videoId: action.payload.videoId, + isActive: true, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/epics/upload.js b/anyclip/src/modules/@common/ccFiles/redux/epics/upload.js new file mode 100644 index 0000000..5f54e4d --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/epics/upload.js @@ -0,0 +1,54 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + appendUploadedFileAction, + replaceAppendUploadedFileAction, + replaceUploadProcessingAction, + uploadProcessingAction, +} from '../slices'; +import { uploadS3 } from '@/modules/@common/request'; + +export default (action$) => + action$.pipe( + ofType(uploadProcessingAction.type, replaceUploadProcessingAction.type), + switchMap((action) => { + const { uploadUrl, downloadUrl, file } = action.payload; + + const stream$ = uploadS3(uploadUrl, file).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + if (action.type === uploadProcessingAction.type) { + actions.push( + of( + appendUploadedFileAction({ + name: file.name, + file: downloadUrl, + }), + ), + ); + } + + if (action.type === replaceUploadProcessingAction.type) { + actions.push( + of( + replaceAppendUploadedFileAction({ + name: file.name, + file: downloadUrl, + ccFileForReplace: action.payload.ccFileForReplace, + }), + ), + ); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/ccFiles/redux/selectors/index.js b/anyclip/src/modules/@common/ccFiles/redux/selectors/index.js new file mode 100644 index 0000000..32d54f0 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/selectors/index.js @@ -0,0 +1,13 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const runContextSelector = (state) => state[nameSpace].runContext; +export const ccFilesSelector = (state) => state[nameSpace].ccFiles; +export const languagesSelector = (state) => state[nameSpace].languages; +export const totalSegmentsSelector = (state) => state[nameSpace].totalSegments; +export const autoTranslateLanguagesSelector = (state) => state[nameSpace].autoTranslateLanguages; +export const isShowAutoTranslateInfoDialogSelector = (state) => state[nameSpace].isShowAutoTranslateInfoDialog; + +export const checkCcFileStateMonitoringIsActiveSelector = (state) => + state[nameSpace].checkCcFileStateMonitoringIsActive; diff --git a/anyclip/src/modules/@common/ccFiles/redux/slices/index.js b/anyclip/src/modules/@common/ccFiles/redux/slices/index.js new file mode 100644 index 0000000..618fbb1 --- /dev/null +++ b/anyclip/src/modules/@common/ccFiles/redux/slices/index.js @@ -0,0 +1,119 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { RUN_CONTEXT, TYPE_MANUAL } from '../../constants'; + +const initialState = { + runContext: RUN_CONTEXT.IN_TAB, + // ccFiles shape + // { name?, file, lang, version, source, isFormItem?, toApproveAfterUpload?, original? } + ccFiles: { + [RUN_CONTEXT.IN_TAB]: [], + [RUN_CONTEXT.IN_VIDEO_UPLOAD]: [], + }, + languages: [], + totalSegments: 0, + autoTranslateLanguages: [], + isShowAutoTranslateInfoDialog: false, + checkCcFileStateMonitoringIsActive: false, +}; + +export const slice = createSlice({ + name: '@@COMMON/CC_FILES', + initialState, + + reducers: { + getCcTotalSegmentsAction: (state) => state, + uploadStartAction: (state) => state, + uploadProcessingAction: (state) => state, + replaceUploadStartAction: (state) => state, + replaceUploadProcessingAction: (state) => state, + loadLanguagesAction: (state) => state, + loadTranslateLanguagesAction: (state) => state, + translateLanguagesProcessingAction: (state) => state, + getCcFilesAction: (state) => state, + addCcFileAction: (state) => state, + deleteCcFileAction: (state) => state, + setCcFilesAction: (state, action) => { + state.ccFiles[state.runContext] = action.payload; + }, + setCcTotalSegmentsAction: (state, action) => { + state.totalSegments = action.payload || initialState.totalSegments; + }, + setLanguagesAction: (state, action) => { + state.languages = action.payload; + }, + setTranslateLanguagesAction: (state, action) => { + state.autoTranslateLanguages = action.payload; + }, + appendUploadedFileAction: (state, action) => { + const { name, file, fileObject = null } = action.payload; + state.ccFiles[state.runContext] = [].concat(state.ccFiles[state.runContext], { + name, + file, + lang: null, + version: TYPE_MANUAL, + isFormItem: true, + toApproveAfterUpload: true, + fileObject, + }); + }, + replaceAppendUploadedFileAction: (state, action) => { + const { name, file, ccFileForReplace } = action.payload; + state.ccFiles[state.runContext] = state.ccFiles[state.runContext].map((cc) => + cc.file === ccFileForReplace.file + ? { + name, + file, + lang: null, + version: TYPE_MANUAL, + isFormItem: true, + original: ccFileForReplace, + } + : cc, + ); + }, + setRunContextAction: (state, action) => { + state.runContext = action.payload; + }, + resetCcFilesInVideoUploadAction: (state) => { + state.ccFiles[RUN_CONTEXT.IN_VIDEO_UPLOAD] = []; + }, + showAutoTranslateInfoDialogAction: (state, action) => { + state.isShowAutoTranslateInfoDialog = action.payload; + }, + clearAction: (state) => { + state.ccFiles[state.runContext] = []; + state.runContext = RUN_CONTEXT.IN_TAB; + }, + checkCcFilesStateAction: (state, action) => { + state.checkCcFileStateMonitoringIsActive = action.payload.isActive; + }, + }, +}); + +export const { + addCcFileAction, + appendUploadedFileAction, + checkCcFilesStateAction, + clearAction, + deleteCcFileAction, + getCcFilesAction, + getCcTotalSegmentsAction, + loadLanguagesAction, + loadTranslateLanguagesAction, + replaceAppendUploadedFileAction, + replaceUploadProcessingAction, + replaceUploadStartAction, + resetCcFilesInVideoUploadAction, + setCcFilesAction, + setCcTotalSegmentsAction, + setLanguagesAction, + setRunContextAction, + setTranslateLanguagesAction, + showAutoTranslateInfoDialogAction, + translateLanguagesProcessingAction, + uploadProcessingAction, + uploadStartAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/components/PCNProxy/PCNProxy.module.scss b/anyclip/src/modules/@common/components/PCNProxy/PCNProxy.module.scss new file mode 100644 index 0000000..738ab19 --- /dev/null +++ b/anyclip/src/modules/@common/components/PCNProxy/PCNProxy.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"PCNProxy_Wrapper__vWB5p","Wrapper___loading":"PCNProxy_Wrapper___loading__00njx"}; \ No newline at end of file diff --git a/anyclip/src/modules/@common/components/PCNProxy/PCNProxy.tsx b/anyclip/src/modules/@common/components/PCNProxy/PCNProxy.tsx new file mode 100644 index 0000000..7a02a58 --- /dev/null +++ b/anyclip/src/modules/@common/components/PCNProxy/PCNProxy.tsx @@ -0,0 +1,64 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import classNames from 'clsx'; + +import { getIAB } from '@/modules/@common/iab/helpers'; +import { getToken } from '@/modules/@common/token/helpers'; +import { getUserDataSelector } from '@/modules/@common/user/redux/selectors'; +import { redirectToSupportedNewFeedForm } from '@/modules/feeds/Editor/helpers/injectNewFeedFormUtil'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import store from '@/modules/@common/store'; + +import styles from './PCNProxy.module.scss'; + +const containerId = 'pcn-root'; + +function PCNProxy() { + const user = useSelector(getUserDataSelector); + const renderApp = !!user && !!Object.keys(user).length; + + if (typeof window !== 'undefined') { + // @ts-expect-error proxy notification for old components + window.PCNProxy = { + token: getToken(), + user, + iab: getIAB(), + // @ts-expect-error proxy notification for old components + redirectToSupportedNewFeedForm: (...args) => redirectToSupportedNewFeedForm(...args), + // @ts-expect-error proxy notification for old components + showNotificationAction: (...args) => store.current.dispatch(showNotificationAction(...args)), + }; + } + + useEffect(() => { + if (renderApp) { + document.documentElement.classList.add('pcn-page'); + + const script = document.createElement('script'); + script.src = '/api/pcn-legacy/embed-script'; + script.type = 'module'; + + document.getElementById(containerId)!.appendChild(script); + + // @ts-expect-error proxy notification for old components + window.PCNInitRoot?.({ + token: getToken(), + user, + iab: getIAB(), + }); + } + + return () => { + document.documentElement.classList.remove('pcn-page'); + }; + }, [renderApp]); + + return ( +
+ {renderApp ?
: null} +
+ ); +} + +export default PCNProxy; diff --git a/src/modules/common/components/ReactList/ReactList.jsx b/anyclip/src/modules/@common/components/ReactList/ReactList.jsx similarity index 100% rename from src/modules/common/components/ReactList/ReactList.jsx rename to anyclip/src/modules/@common/components/ReactList/ReactList.jsx diff --git a/src/modules/common/components/ReactList/ReactList.module.scss b/anyclip/src/modules/@common/components/ReactList/ReactList.module.scss similarity index 100% rename from src/modules/common/components/ReactList/ReactList.module.scss rename to anyclip/src/modules/@common/components/ReactList/ReactList.module.scss diff --git a/anyclip/src/modules/@common/constants/account.ts b/anyclip/src/modules/@common/constants/account.ts new file mode 100644 index 0000000..141c4fa --- /dev/null +++ b/anyclip/src/modules/@common/constants/account.ts @@ -0,0 +1,6 @@ +export const TYPE_BUSINESS = 'BUSINESS'; +export const TYPE_PUBLISHER = 'PUBLISHER'; +export const TYPE_DEMAND = 'DEMAND'; +export const TYPE_SYNDICATION = 'SYNDICATION'; +export const TYPE_CONTENT_OWNER = 'CONTENT_OWNER'; +export const TYPE_VAST = 'VAST'; diff --git a/anyclip/src/modules/@common/constants/aspectRatios.ts b/anyclip/src/modules/@common/constants/aspectRatios.ts new file mode 100644 index 0000000..5553f27 --- /dev/null +++ b/anyclip/src/modules/@common/constants/aspectRatios.ts @@ -0,0 +1,14 @@ +export const ASPECT_RATIO_16_9_LABEL = '16:9'; +export const ASPECT_RATIO_16_9_VALUE = 'ASPECT_RATIO_16x9'; + +export const ASPECT_RATIO_9_16_LABEL = '9:16'; +export const ASPECT_RATIO_9_16_VALUE = 'ASPECT_RATIO_9x16'; + +export const ASPECT_RATIO_4_3_LABEL = '4:3'; +export const ASPECT_RATIO_4_3_VALUE = 'ASPECT_RATIO_4x3'; + +export const ASPECT_RATIO_3_4_LABEL = '3:4'; +export const ASPECT_RATIO_3_4_VALUE = 'ASPECT_RATIO_3x4'; + +export const ASPECT_RATIO_1_1_LABEL = '1:1'; +export const ASPECT_RATIO_1_1_VALUE = 'ASPECT_RATIO_1x1'; diff --git a/anyclip/src/modules/@common/constants/db.ts b/anyclip/src/modules/@common/constants/db.ts new file mode 100644 index 0000000..ab4c8ce --- /dev/null +++ b/anyclip/src/modules/@common/constants/db.ts @@ -0,0 +1 @@ +export const MAX_INT_DB = 7680; diff --git a/anyclip/src/modules/@common/constants/embedTypes.ts b/anyclip/src/modules/@common/constants/embedTypes.ts new file mode 100644 index 0000000..61b698f --- /dev/null +++ b/anyclip/src/modules/@common/constants/embedTypes.ts @@ -0,0 +1,5 @@ +export const EMBED_CODE_TYPE_DIRECT_VALUE = 'deOutstream'; +export const EMBED_CODE_TYPE_DIRECT_LABEL = 'Direct Embed'; + +export const EMBED_CODE_TYPE_DFP_GAM_VALUE = 'dfpOutstream'; +export const EMBED_CODE_TYPE_DFP_GAM_LABEL = 'DFP/GAM'; diff --git a/anyclip/src/modules/@common/constants/errors.ts b/anyclip/src/modules/@common/constants/errors.ts new file mode 100644 index 0000000..55467da --- /dev/null +++ b/anyclip/src/modules/@common/constants/errors.ts @@ -0,0 +1,9 @@ +export const ERROR_WRONG_OTP = 'WRONG_OTP'; +export const ERROR_OTP_ALREADY_USED = 'OTP_ALREADY_USED'; +export const ERROR_OTP_HAS_EXPIRED = 'OTP_HAS_EXPIRED'; +export const ERROR_TWO_FA_IS_REQUIRED = 'TWO_FA_IS_REQUIRED'; +export const ERROR_CANT_USE_PASSWORDLESS_AUTH = 'CANT_USE_PASSWORDLESS_AUTH'; +export const ERROR_TOO_MANY_ATTEMPTS = 'TOO_MANY_ATTEMPTS'; +export const ERROR_USER_NOT_VALID = 'USER_NOT_VALID'; + +export const ERROR_UI_LINK_WAS_EXPIRED = 'UI_LINK_WAS_EXPIRED'; diff --git a/anyclip/src/modules/@common/constants/file.ts b/anyclip/src/modules/@common/constants/file.ts new file mode 100644 index 0000000..1c7cf92 --- /dev/null +++ b/anyclip/src/modules/@common/constants/file.ts @@ -0,0 +1,7 @@ +export const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB in B + +export const MAX_THUMBNAIL_SIZE = 5 * 1024 * 1024; // 5MB in B + +export const LUMINOUS_SOURCE_TYPE = 'LUMINOUS'; +export const EDITORIAL_SOURCE_TYPE = 'EDITORIAL'; +export const AUDIO_SOURCE_TYPE = 'AUDIO'; diff --git a/anyclip/src/modules/@common/constants/index.ts b/anyclip/src/modules/@common/constants/index.ts new file mode 100644 index 0000000..84ec2e2 --- /dev/null +++ b/anyclip/src/modules/@common/constants/index.ts @@ -0,0 +1,34 @@ +export const TYPE_MRSS = 'MRSS'; +export const TYPE_CSV = 'CSV'; +export const TYPE_YOUTUBE = 'YOUTUBE'; +export const TYPE_S3 = 'S3'; +export const TYPE_INSTAGRAM = 'INSTAGRAM'; +export const TYPE_RSS = 'RSS'; +export const TYPE_VIDEO_API = 'VIDEO_API'; +export const TYPE_STORY_API = 'STORY_API'; +export const TYPE_LIVE = 'LIVE'; +export const TYPE_VIMEO = 'VIMEO'; +export const TYPE_MANUAL = 'MANUAL'; +export const TYPE_SITEMAP = 'SITEMAP'; +export const TYPE_ZOOM = 'ZOOM'; + +export const FEED_TYPES = [ + TYPE_MRSS, + TYPE_CSV, + TYPE_YOUTUBE, + TYPE_S3, + TYPE_INSTAGRAM, + TYPE_RSS, + TYPE_VIDEO_API, + TYPE_STORY_API, + TYPE_LIVE, + TYPE_VIMEO, + TYPE_MANUAL, + TYPE_SITEMAP, + TYPE_ZOOM, +]; + +export const MONITORING_JOB_TYPES = { + soundtrackAnalysis: 'SPEECH_RECOGNITION', + closedCaptionAnalysis: 'CLOSED_CAPTIONS', +}; diff --git a/src/modules/common/constants/keyCodes.ts b/anyclip/src/modules/@common/constants/keyCodes.ts similarity index 100% rename from src/modules/common/constants/keyCodes.ts rename to anyclip/src/modules/@common/constants/keyCodes.ts diff --git a/anyclip/src/modules/@common/constants/mapApiError.ts b/anyclip/src/modules/@common/constants/mapApiError.ts new file mode 100644 index 0000000..8bd276d --- /dev/null +++ b/anyclip/src/modules/@common/constants/mapApiError.ts @@ -0,0 +1,43 @@ +const ERRORS = [ + { + pattern: 'Video with this [(video.name or mataData.refID) and video.ownerId] already exist', + message: 'A video with this name already exists. Rename the video in order to save it', + }, + { + pattern: 'Validation errors. device: size must be between 1 and 2147483647', + message: 'Please select one of the available device types you wish to target', + }, + { + pattern: 'Label with this [label.name and label.contentOwnerId] already exist.', + message: 'The custom category with the name "[categoryName]" already exists. Please use another name', + useReplaceMetaObjectKey: 'categoryName', + }, + { + pattern: '"image" is not allowed to be empty', + message: 'Image cannot be empty', + }, + { + pattern: "Duplicate value '1'''. Value must be unique.", + message: 'An X-Ray Creative with this name already exists. Rename X-Ray Creative name in order to save it', + }, +]; + +export const mapApiError = (replaceMetaObject: { [key: string]: string }) => (errorsFromApi: string[]) => { + const mappedError = ERRORS.find((errorForMap) => + errorsFromApi.some((error) => error.startsWith(errorForMap.pattern)), + ); + + if (mappedError) { + const message = mappedError?.useReplaceMetaObjectKey + ? mappedError.message.replace( + `[${mappedError.useReplaceMetaObjectKey}]`, + replaceMetaObject[mappedError.useReplaceMetaObjectKey], + ) + : mappedError.message; + return message; + } + + return errorsFromApi; +}; + +export default ERRORS; diff --git a/anyclip/src/modules/@common/constants/playerTypes.ts b/anyclip/src/modules/@common/constants/playerTypes.ts new file mode 100644 index 0000000..3c268db --- /dev/null +++ b/anyclip/src/modules/@common/constants/playerTypes.ts @@ -0,0 +1,21 @@ +export const TYPE_INTELLIGENT = 1; +export const TYPE_STORIES = 2; +export const TYPE_WATCH = 3; +export const TYPE_INTELLIGENT_AMP = 4; +export const TYPE_STORIES_AMP = 5; +export const TYPE_LIVE = 6; +export const TYPE_MOBILE_SDK = 7; +export const TYPE_OUTSTREAM = 8; +export const TYPE_WATCH_INLINE = 9; +export const TYPE_VERTICAL = 10; + +export const TYPE_INTELLIGENT_NAME = 'Intelligent'; +export const TYPE_STORIES_NAME = 'Stories'; +export const TYPE_WATCH_NAME = 'Watch'; +export const TYPE_INTELLIGENT_AMP_NAME = 'Intelligent - AMP'; +export const TYPE_STORIES_AMP_NAME = 'Stories - AMP'; +export const TYPE_LIVE_NAME = 'Live'; +export const TYPE_MOBILE_SDK_NAME = 'Mobile SDK'; +export const TYPE_OUTSTREAM_NAME = 'Outstream'; +export const TYPE_WATCH_INLINE_NAME = 'Watch Inline'; +export const TYPE_VERTICAL_NAME = 'Vertical'; diff --git a/anyclip/src/modules/@common/constants/sort.ts b/anyclip/src/modules/@common/constants/sort.ts new file mode 100644 index 0000000..fd53d8b --- /dev/null +++ b/anyclip/src/modules/@common/constants/sort.ts @@ -0,0 +1,2 @@ +export const SORT_ASC = 'asc'; +export const SORT_DESC = 'desc'; diff --git a/anyclip/src/modules/@common/constants/validation.ts b/anyclip/src/modules/@common/constants/validation.ts new file mode 100644 index 0000000..4f3bad1 --- /dev/null +++ b/anyclip/src/modules/@common/constants/validation.ts @@ -0,0 +1,5 @@ +export const emailRegExp = + // eslint-disable-next-line max-len + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i; + +export const passwordRegExp = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[- .!"#%&,+*:;<=>@_~'`}{?^$|()/\\\\[\]]).{8,}$/; diff --git a/anyclip/src/modules/@common/dnd/SortableItem/SortableItem.tsx b/anyclip/src/modules/@common/dnd/SortableItem/SortableItem.tsx new file mode 100644 index 0000000..016ec91 --- /dev/null +++ b/anyclip/src/modules/@common/dnd/SortableItem/SortableItem.tsx @@ -0,0 +1,49 @@ +import { ReactNode } from 'react'; +import type { DraggableAttributes } from '@dnd-kit/core'; +import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; +import { useSortable } from '@dnd-kit/sortable'; +import type { Transform } from '@dnd-kit/utilities'; +import { CSS } from '@dnd-kit/utilities'; + +type RenderProps = { + dndAttributes: DraggableAttributes; + dndListeners?: SyntheticListenerMap; + dndSetNodeRef: (node: HTMLElement | null) => void; + dndTransform: Transform | null; + dndTransition?: string; + dndStyle: { + transform?: string; + transition?: string; + }; + dndIsDragging: boolean; +}; + +type Props = { + value: string | number; + children: (props: RenderProps) => ReactNode; + isDragDisabled?: boolean; +}; + +function SortableItem({ value, children, isDragDisabled = false }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: value, + disabled: isDragDisabled, + }); + + const style = { + transform: CSS.Translate.toString(transform), + transition: transition || undefined, + }; + + return children({ + dndAttributes: attributes, + dndListeners: listeners, + dndSetNodeRef: setNodeRef, + dndTransform: transform, + dndTransition: transition, + dndStyle: style, + dndIsDragging: isDragging, + }); +} + +export default SortableItem; diff --git a/anyclip/src/modules/@common/envs/constants/index.ts b/anyclip/src/modules/@common/envs/constants/index.ts new file mode 100644 index 0000000..ccb36b7 --- /dev/null +++ b/anyclip/src/modules/@common/envs/constants/index.ts @@ -0,0 +1,31 @@ +export const PASS_CRYPTO_SALT = '$2b$04$wwky7rvtr6BFNaCqntwyie'; +export const NO_PERMISSION_MESSAGE = 'No permission'; +export const TOKEN_EXPIRED_MESSAGE = 'Token expired'; +export const TOKEN_REQUIRED_MESSAGE = 'Token required'; + +export const publicEnvsList = [ + 'APP_DWH_SEO_S3_BUCKET', + 'APP_ENV_BASE_URL', + 'APP_API_TIMEOUT', + 'APP_CLEAR_TIMEOUT', + 'APP_CONFIG_ENV', + 'APP_FB_CLIENT_ID', + 'APP_G_CLIENT_ID', + 'APP_INTERCOM_EXCLUDE_PATHS', + 'APP_INTERCOM_HMAC', + 'APP_INTERCOM_KEY', + 'APP_PCN_API_BASE_URL_FE', + 'APP_PLAYER_CONFIG_URL', + 'APP_PLAYER_FORM_BUILDER', + 'APP_PLAYER_PLAYLIST_URL', + 'APP_PLAYER_PUBLISHER', + 'APP_PLAYER_WIDGET', + 'APP_PLAYER_WIDGET_URL', + 'APP_REACT_APP_MUI_KEY', + 'APP_REACT_APP_TINYMCE_KEY', + 'APP_REQUEST_TIMEOUT', + 'APP_SLIDE_TIME_SHIFT', + 'APP_VIDEO_TARGETING_END_DATE_SHIFT', + 'APP_WATCH_DEFAULT_SELECTED_ACCOUNT', + 'APP_WATCH_SCRIPT_PATH', +]; diff --git a/anyclip/src/modules/@common/gql/queries/allPublishers.js b/anyclip/src/modules/@common/gql/queries/allPublishers.js new file mode 100644 index 0000000..b2b8cd4 --- /dev/null +++ b/anyclip/src/modules/@common/gql/queries/allPublishers.js @@ -0,0 +1,20 @@ +const allPublishersGQL = ` + query AllPublishers( + $watchEnabledOnly: Boolean!, + $removeDisabled: Boolean!, + $removeLimit: Boolean!, + $formEnabledOnly: Boolean + ) { + allPublishers( + watchEnabledOnly: $watchEnabledOnly, + removeDisabled: $removeDisabled, + removeLimit: $removeLimit, + formEnabledOnly: $formEnabledOnly + ) { + id + name + } + } +`; + +export default allPublishersGQL; diff --git a/anyclip/src/modules/@common/gql/queries/init.js b/anyclip/src/modules/@common/gql/queries/init.js new file mode 100644 index 0000000..e06d0ad --- /dev/null +++ b/anyclip/src/modules/@common/gql/queries/init.js @@ -0,0 +1,13 @@ +const init = ` + query InitQuery { + iab { + data + } + videoLangs { + id + name + } + } +`; + +export default init; diff --git a/anyclip/src/modules/@common/gql/queries/userInit.js b/anyclip/src/modules/@common/gql/queries/userInit.js new file mode 100644 index 0000000..8461cee --- /dev/null +++ b/anyclip/src/modules/@common/gql/queries/userInit.js @@ -0,0 +1,63 @@ +const userInit = ` + query UserQuery { + user { + id + email + status + firstName + lastName + role { + id, + name, + displayName, + type, + defaultPage + } + permissions + contentOwnerId + publisherId + publisherIds + contentOwners { + contentOwnerId + publisherOwnsContent + name + status + isPublic + } + timezone + accountId + account { + name + type + customLoginPageUrl + accountsFeatures { + name + value + } + subdomain + publishers { + id + name + accountId + } + avatarUrl + customLogoUrl + + expenses + publisherRevShare + } + slug + intercomUserHash + updateDate + } + iab { + data + } + videoLangs { + id + name + } + } +`; + +export default userInit; diff --git a/anyclip/src/modules/@common/gql/queries/videoById.js b/anyclip/src/modules/@common/gql/queries/videoById.js new file mode 100644 index 0000000..b0bf87f --- /dev/null +++ b/anyclip/src/modules/@common/gql/queries/videoById.js @@ -0,0 +1,184 @@ +const videoById = ` +query VideoDetailsQuery($uid: String!) { + video(id: $uid) { + aspectRatio + ccUrl + ccFiles { + file + lang + langName + version + source + } + contentOwner + contentOwnerName + contentOwnerIsPublic + created + defaultVideoUrl + distributionId + evergreen + feedDescription + feedSource + feedSourceId + landingPageLink + lang + name + originalName + origin + plot + notes + publisherLink + refId + releaseYear + scenesTotalCount + score + status + thumbnailUrl + type + uid + updated + videoCreationDate + videoLength + videoParent + videoUrl + videoParentName + approval { + status + updated + refUserId + firstName + lastName + } + label { + labelId + name + value + color + } + thumbnailFiles { + width + height + file + sizeInBytes + } + videoFiles { + width + height + file + sizeInBytes + } + videoFileHls { + file + sizeInBytes + } + sourceVideoFile { + width + height + file + sizeInBytes + } + keywords { + category + value + probability + version + id + } + joinedKeywords { + category + value + probability + version + id + } + iab { + data + } + details { + keywords { + category + value + probability + version + id + } + iab { + data + } + metaData { + uid + url + refId + type + studio + genres + releaseDate + actors + length + plot + contentRating + thumbnailFile { + bucket + key + url + } + sourceThumbnailUrl + } + } + access { + level + sites { + id + name + } + users { + id + role + name + email + firstName + lastName + } + } + highlights + highlightsPublished + slides + slidesPublished + accountId + sponsored { + primaryText + buttonLabel + secondaryText + timeToAppear + advertiserId + advertiserName + advertiserLogo + sponsored + } + } + metaDataSearch(videoId: $uid) { + totalCount + metaData { + uid + url + refId + type + studio + genres + releaseDate + actors + length + plot + contentRating + thumbnailFile { + bucket + key + url + } + sourceThumbnailUrl + } + } + } +`; + +export default videoById; diff --git a/anyclip/src/modules/@common/gql/queries/videoUpdate.js b/anyclip/src/modules/@common/gql/queries/videoUpdate.js new file mode 100644 index 0000000..f93efda --- /dev/null +++ b/anyclip/src/modules/@common/gql/queries/videoUpdate.js @@ -0,0 +1,137 @@ +const queryVideoUpdateGQL = ` + mutation videoUpdateMutation( + $id: String!, + $video: VideoInputType!) { + videoUpdate( + id: $id, + video: $video) { + aspectRatio + ccUrl + ccFiles { + file + lang + version + source + } + contentOwner + contentOwnerName + created + defaultVideoUrl + distributionId + evergreen + feedDescription + feedSource + landingPageLink + lang + name + originalName + plot + publisherLink + refId + releaseYear + scenesTotalCount + score + status + thumbnailUrl + type + uid + updated + videoCreationDate + videoLength + videoParent + videoUrl + notes + sponsored { + primaryText + buttonLabel + secondaryText + timeToAppear + advertiserId + advertiserName + advertiserLogo + sponsored + } + approval { + status + updated + refUserId + firstName + lastName + } + label { + labelId + name + value + color + } + thumbnailFiles { + width + height + file + sizeInBytes + } + videoFiles { + width + height + file + sizeInBytes + } + sourceVideoFile { + width + height + file + sizeInBytes + } + keywords { + category + value + probability + version + id + } + joinedKeywords { + category + value + probability + version + id + } + iab { + data + } + details { + keywords { + category + value + probability + version + id + } + iab { + data + } + metaData { + uid + url + refId + type + studio + genres + releaseDate + actors + length + plot + contentRating + thumbnailFile { + bucket + key + url + } + sourceThumbnailUrl + } + } + } + } +`; + +export default queryVideoUpdateGQL; diff --git a/anyclip/src/modules/@common/gql/queries/videos.js b/anyclip/src/modules/@common/gql/queries/videos.js new file mode 100644 index 0000000..565dcfb --- /dev/null +++ b/anyclip/src/modules/@common/gql/queries/videos.js @@ -0,0 +1,313 @@ +const videos = ` +query VideosQuery( + $names: [String], + $keywords: [KeywordInternalObject], + $labels:[LabelInternalObject], + $feedSources: [FeedInternalObject], + $feedDescription: [FeedInternalObject], + $filterSites: [SitesInternalObject], + $iab: [IabInternalObject], + $language: [LanguageInternalObject], + $ranges: [RangesInternalObject], + $owners: [String], + $query: String, + $queryIds: Boolean, + $videoIds: [String], + $videoAffiliation: String, + $typeValue: String, + $stateValue: String, + $verificationValue: String, + $distributionValue: String, + $sortType: String, + $sortOrder: String, + $page: Int, + $size: Int, + $evergreen: Boolean, + $origins: [originsObject], + $timeZone: String, + $timeFrame: String, + $notes: String, + $publishPlatform: String, + $publishStatus: String, + $includeMaxPerformance: Boolean, + $videoTab: String, + $withTotalCounters: Boolean, + $targetingStatus: String, + $sponsored: String, + ) { + videoSearch ( + names: $names, + keywords: $keywords, + labels: $labels, + feedSources: $feedSources, + feedDescription: $feedDescription, + filterSites: $filterSites, + iab: $iab, + owners: $owners, + language: $language, + ranges: $ranges, + query: $query, + queryIds: $queryIds, + videoIds: $videoIds, + videoAffiliation: $videoAffiliation, + typeValue: $typeValue, + stateValue: $stateValue, + verificationValue: $verificationValue, + distributionValue: $distributionValue, + sortType: $sortType, + sortOrder: $sortOrder, + page: $page, + size: $size, + evergreen: $evergreen, + origins: $origins, + timeZone: $timeZone, + timeFrame: $timeFrame, + notes: $notes, + publishPlatform: $publishPlatform, + publishStatus: $publishStatus, + includeMaxPerformance: $includeMaxPerformance, + videoTab: $videoTab, + withTotalCounters: $withTotalCounters, + targetingStatus: $targetingStatus, + sponsored: $sponsored + ) { + totalCount + counter { + all + own + shared + } + videos { + uid + access { + level + users { + id + role + name + email + firstName + lastName + } + sites { + id + name + } + } + feedSourceId + refId + distributionId + type + status + name + plot + lang + originalName + origin + created + updated + videoUrl + ccUrl + ccFiles { + file + lang + version + source + } + thumbnailUrl + defaultVideoUrl + releaseYear + contentOwner + contentOwnerName + contentOwnerIsPublic + videoCreationDate + videoLength + videoParent + publisherLink + landingPageLink + aspectRatio + evergreen + notes + approval { + status + updated + refUserId + firstName + lastName + } + scenesTotalCount + score + playbackImmediately + feedSource + feedDescription + label { + labelId + name + value + color + } + thumbnailFiles { + width + height + file + sizeInBytes + } + videoFiles { + width + height + file + sizeInBytes + } + videoFileHls { + file + sizeInBytes + } + sourceVideoFile { + width + height + file + sizeInBytes + } + keywords { + category + value + probability + version + id + } + joinedKeywords { + category + value + probability + version + id + } + iab { + data + } + details { + keywords { + category + value + probability + version + id + } + iab { + data + } + metaData { + uid + url + refId + type + studio + genres + releaseDate + actors + length + plot + contentRating + thumbnailFile { + bucket + key + url + } + sourceThumbnailUrl + } + } + performance { + completed + engagement + avgPlayback + s20VideoViews + minutesViewed + uniqueUsers + recommendationPlaylistRatio + views + likes + shares + } + trending + mediaPlatformPerformance { + youtube { + views + likes + comments + } + facebook { + views + likes + comments + shares + } + } + insights { + views + likes + comments + shares + } + performanceTotal { + numberOfViews + likes + shares + } + highlights + highlightsPublished + targetingStatus + accountId + sponsored { + primaryText + buttonLabel + secondaryText + timeToAppear + advertiserId + advertiserName + advertiserLogo + sponsored + } + } + performance { + completed + engagement + avgPlayback + s20VideoViews + minutesViewed + uniqueUsers + recommendationPlaylistRatio + views + likes + shares + trending + insights { + views + likes + comments + shares + } + } + maxPerformance { + completed + engagement + avgPlayback + s20VideoViews + minutesViewed + uniqueUsers + recommendationPlaylistRatio + views + likes + shares + trending + insights { + views + likes + comments + shares + } + } + } + } +`; + +export default videos; diff --git a/anyclip/src/modules/@common/gql/redux/epics/index.js b/anyclip/src/modules/@common/gql/redux/epics/index.js new file mode 100644 index 0000000..d6cbb45 --- /dev/null +++ b/anyclip/src/modules/@common/gql/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import initEpic from './init'; + +export default combineEpics(initEpic); diff --git a/anyclip/src/modules/@common/gql/redux/epics/init.js b/anyclip/src/modules/@common/gql/redux/epics/init.js new file mode 100644 index 0000000..1913651 --- /dev/null +++ b/anyclip/src/modules/@common/gql/redux/epics/init.js @@ -0,0 +1,36 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + initErrorAction, + initErrorEventAction, + initRequestEventAction, + initResponseAction, + initResponseEventAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +import { init as initQuery } from '../../queries'; + +export default (action$) => + action$.pipe( + ofType(initRequestEventAction.type), + switchMap(() => { + const stream$ = gqlRequest({ query: initQuery }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(initResponseAction(data)), of(initResponseEventAction())); + } else { + actions.push(of(initErrorAction(errors[0])), of(initErrorEventAction())); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/gql/redux/selectors/index.js b/anyclip/src/modules/@common/gql/redux/selectors/index.js new file mode 100644 index 0000000..7cc9268 --- /dev/null +++ b/anyclip/src/modules/@common/gql/redux/selectors/index.js @@ -0,0 +1,12 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const logRequestSelector = (state) => state[nameSpace].logRequest; +export const logResponseSelector = (state) => state[nameSpace].logResponse; +export const logFilteredResponseSelector = (state) => state[nameSpace].logFilteredResponse; +export const barRequestSelector = (state) => state[nameSpace].barRequest; +export const barResponseSelector = (state) => state[nameSpace].barResponse; +export const barFilteredResponseSelector = (state) => state[nameSpace].barFilteredResponse; +export const videosRequestSelector = (state) => state[nameSpace].videosRequest; +export const initResponseSelector = (state) => state[nameSpace].initResponse; diff --git a/anyclip/src/modules/@common/gql/redux/slices/index.js b/anyclip/src/modules/@common/gql/redux/slices/index.js new file mode 100644 index 0000000..181ef2a --- /dev/null +++ b/anyclip/src/modules/@common/gql/redux/slices/index.js @@ -0,0 +1,99 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + logRequest: null, + logResponse: null, + logError: null, + logFilteredResponse: null, + + barRequest: null, + barResponse: null, + barError: null, + barFilteredResponse: null, + + videosRequest: null, + + initResponse: null, + initError: null, +}; + +export const slice = createSlice({ + name: '@@COMMON/GQL', + initialState, + + reducers: { + clearAction: (state) => state, + logRequestEventAction: (state) => state, + logResponseEventAction: (state) => state, + logErrorEventAction: (state) => state, + logFilteredRequestEventAction: (state) => state, + logFilteredResponseEventAction: (state) => state, + barRequestEventAction: (state) => state, + barResponseEventAction: (state) => state, + barErrorEventAction: (state) => state, + barFilteredRequestEventAction: (state) => state, + barFilteredResponseEventAction: (state) => state, + initRequestEventAction: (state) => state, + initResponseEventAction: (state) => state, + initErrorEventAction: (state) => state, + logRequestAction: (state, action) => { + state.logRequest = action.payload || initialState.logRequest; + }, + logResponseAction: (state, action) => { + state.logResponse = action.payload || initialState.logResponse; + }, + logErrorAction: (state, action) => { + state.logError = action.payload || initialState.logError; + }, + logFilteredResponseAction: (state, action) => { + state.logFilteredResponse = action.payload || initialState.logFilteredResponse; + }, + barRequestAction: (state, action) => { + state.barRequest = action.payload || initialState.barRequest; + }, + barResponseAction: (state, action) => { + state.barResponse = action.payload || initialState.barResponse; + }, + barErrorAction: (state, action) => { + state.barError = action.payload || initialState.barError; + }, + barFilteredResponseAction: (state, action) => { + state.barFilteredResponse = action.payload || initialState.barFilteredResponse; + }, + initResponseAction: (state, action) => { + state.initResponse = action.payload || initialState.initResponse; + }, + initErrorAction: (state, action) => { + state.initError = action.payload || initialState.initError; + }, + }, +}); + +export const { + barErrorAction, + barErrorEventAction, + barFilteredRequestEventAction, + barFilteredResponseAction, + barFilteredResponseEventAction, + barRequestAction, + barRequestEventAction, + barResponseAction, + barResponseEventAction, + clearAction, + initErrorAction, + initErrorEventAction, + initRequestEventAction, + initResponseAction, + initResponseEventAction, + logErrorAction, + logErrorEventAction, + logFilteredRequestEventAction, + logFilteredResponseAction, + logFilteredResponseEventAction, + logRequestAction, + logRequestEventAction, + logResponseAction, + logResponseEventAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/helpers/copy.ts b/anyclip/src/modules/@common/helpers/copy.ts new file mode 100644 index 0000000..ec0d692 --- /dev/null +++ b/anyclip/src/modules/@common/helpers/copy.ts @@ -0,0 +1,23 @@ +const copyToClipboard = async (text: string): Promise => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + + return true; + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + + return true; + } catch { + return false; + } +}; + +export default copyToClipboard; diff --git a/anyclip/src/modules/@common/helpers/events.ts b/anyclip/src/modules/@common/helpers/events.ts new file mode 100644 index 0000000..02bf590 --- /dev/null +++ b/anyclip/src/modules/@common/helpers/events.ts @@ -0,0 +1,58 @@ +type Func = (...args: unknown[]) => void; +type ThrottledFunction = Func & { clear: () => void }; + +/** + * Creates a throttled version of a function that only invokes the function at most once per specified time limit. + * @param func - The function to throttle. + * @param limit - The time limit in milliseconds. + * @returns A throttled function with a `clear` method. + */ +export const throttle = (func: Func, limit: number): ThrottledFunction => { + let lastFunc: number | undefined; + let lastRan: number | undefined; + + const fn: ThrottledFunction = (...args: unknown[]) => { + if (!lastRan) { + func(...args); + lastRan = Date.now(); + } else { + clearTimeout(lastFunc); + lastFunc = window.setTimeout( + () => { + if (Date.now() - lastRan! >= limit) { + func(...args); + lastRan = Date.now(); + } + }, + limit - (Date.now() - lastRan!), + ); + } + }; + + fn.clear = () => clearTimeout(lastFunc); + + return fn; +}; + +export function debounce void>( + func: T, + delay: number, +): { (...args: Parameters): void; clear: () => void } { + let timeoutId: number | undefined; + + const debouncedFunction = (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = window.setTimeout(() => func(...args), delay); + }; + + debouncedFunction.clear = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + return debouncedFunction; +} diff --git a/anyclip/src/modules/@common/helpers/featureFlags.ts b/anyclip/src/modules/@common/helpers/featureFlags.ts new file mode 100644 index 0000000..1a143af --- /dev/null +++ b/anyclip/src/modules/@common/helpers/featureFlags.ts @@ -0,0 +1,22 @@ +if (typeof window !== 'undefined') { + window.featureFlags = window.featureFlags || {}; +} + +const defaultFeatureFlags = { + logs: false, + showThemeToggle: false, + watchConfigurationHiddenFields: false, + aiWorkbenchHighlightsCreateVideo: false, + showOldFormsInFeedModule: false, +}; + +export const featureFlags = new Proxy(defaultFeatureFlags, { + get(target, prop) { + const key = prop as keyof typeof window.featureFlags; + return typeof window !== 'undefined' + ? (window.featureFlags || {})[key] || window.localStorage.getItem(key) + : target[key]; + }, +}); + +export default {}; diff --git a/anyclip/src/modules/@common/helpers/file-saver.ts b/anyclip/src/modules/@common/helpers/file-saver.ts new file mode 100644 index 0000000..f9e505e --- /dev/null +++ b/anyclip/src/modules/@common/helpers/file-saver.ts @@ -0,0 +1,104 @@ +function corsEnabled(url: string): boolean { + const xhr = new XMLHttpRequest(); + xhr.open('HEAD', url, false); + try { + xhr.send(); + } catch { + return false; + } + return xhr.status >= 200 && xhr.status <= 299; +} + +export const saveBlobAsFile = (blob: Blob | string, name: string = 'download'): void => { + const URL = window.URL || window.webkitURL; + const a = document.createElement('a'); + a.download = name; + a.rel = 'noopener'; + + if (typeof blob === 'string') { + a.href = blob; + if (a.origin !== window.location.origin && corsEnabled(a.href)) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', blob); + xhr.responseType = 'blob'; + xhr.onload = () => saveBlobAsFile(xhr.response, name); + xhr.onerror = () => console.error('Could not download file'); + xhr.send(); + } else { + a.dispatchEvent(new MouseEvent('click')); + } + } else { + a.href = URL.createObjectURL(blob); + setTimeout(() => URL.revokeObjectURL(a.href), 40000); // 40 seconds + setTimeout(() => a.dispatchEvent(new MouseEvent('click')), 0); + } +}; + +export async function downloadFileByUrl( + href: string, + name: string | null | undefined, + subscribe?: { + onLoad?: (state: boolean) => void; + onProcess?: (progress: number) => void; + onSuccess?: () => void; + onError?: () => void; + }, +) { + const nameWithExtension = href.split('/').pop()!; + const splitDefaultName = nameWithExtension.split('.'); + const extension = splitDefaultName.pop(); + const defaultName = splitDefaultName.join('.'); + + const fileName = `${name || defaultName}.${extension}`; + + try { + const streamSaver = (await import('streamsaver')).default; + streamSaver.mitm = '/streamsaver/mitm.html'; + + const resp = await fetch(href, { cache: 'no-store' }); + if (!resp.ok || !resp.body) { + throw new Error('Fetch failed or no body'); + } + + const totalHeader = resp.headers.get('content-length'); + const total = totalHeader ? Number(totalHeader) : undefined; + + const fileStream = streamSaver.createWriteStream(fileName, total ? { size: total } : undefined); + + if (typeof fileStream.getWriter !== 'function' && typeof resp.body.pipeTo === 'function') { + subscribe?.onLoad?.(true); + await resp.body.pipeTo(fileStream); + subscribe?.onLoad?.(false); + subscribe?.onSuccess?.(); + return; + } + + const writer = fileStream.getWriter(); + const reader = resp.body.getReader(); + let loaded = 0; + + subscribe?.onLoad?.(true); + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + + if (done) { + break; + } + + loaded += value.byteLength; + + if (subscribe?.onProcess) { + subscribe?.onProcess(total ? Math.round((loaded / total) * 100) : -1); + } + + // eslint-disable-next-line no-await-in-loop + await writer.write(value); + } + await writer.close(); + subscribe?.onLoad?.(false); + subscribe?.onSuccess?.(); + } catch { + subscribe?.onError?.(); + } +} diff --git a/anyclip/src/modules/@common/helpers/format.ts b/anyclip/src/modules/@common/helpers/format.ts new file mode 100644 index 0000000..88643eb --- /dev/null +++ b/anyclip/src/modules/@common/helpers/format.ts @@ -0,0 +1,28 @@ +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import utcPlugin from 'dayjs/plugin/utc'; + +dayjs.extend(utcPlugin); +dayjs.extend(durationPlugin); + +export const getFileName = (fileName: string, extension: string, customDate: Date) => { + const date = customDate || new Date(); + + return `${fileName}-${dayjs(date).format('DD-MM-YYYY')}.${extension}`; +}; + +export const formatTagLogTime = (timestamp: number) => { + let totalMinutes = Math.floor(dayjs.duration(timestamp).asMinutes()).toString(); + + if (totalMinutes.length === 1) { + totalMinutes = `00${totalMinutes}`; + } + + if (totalMinutes.length === 2) { + totalMinutes = `0${totalMinutes}`; + } + + return `${totalMinutes}:${dayjs(timestamp).utc().format('ss:SSS').toString()}`; +}; + +export default {}; diff --git a/src/modules/common/helpers/hooks/useTitle.ts b/anyclip/src/modules/@common/helpers/hooks/useTitle.ts similarity index 100% rename from src/modules/common/helpers/hooks/useTitle.ts rename to anyclip/src/modules/@common/helpers/hooks/useTitle.ts diff --git a/anyclip/src/modules/@common/helpers/index.ts b/anyclip/src/modules/@common/helpers/index.ts new file mode 100644 index 0000000..a44e6f0 --- /dev/null +++ b/anyclip/src/modules/@common/helpers/index.ts @@ -0,0 +1,106 @@ +export const deepClone = (item: T): T => { + if (item === null || typeof item !== 'object') { + return item; // Return the value if item is not an object + } + + // Handle Date + if (item instanceof Date) { + return new Date(item.getTime()) as T; + } + + // Handle Array + if (Array.isArray(item)) { + const copy = []; + for (let i = 0; i < item.length; i += 1) { + copy[i] = deepClone(item[i]); + } + return copy as T; + } + + // Handle Object + if (item instanceof Object) { + const copy: { [key: string]: unknown } = {}; + + for (const key in item) { + if (Object.prototype.hasOwnProperty.call(item, key)) { + copy[key] = deepClone(item[key]); + } + } + return copy as T; + } + + // Handle Map + if ((item as unknown) instanceof Map) { + const copy = new Map(); + (item as []).forEach((value, key) => { + copy.set(key, deepClone(value)); + }); + return copy as T; + } + + // Handle Set + if ((item as unknown) instanceof Set) { + const copy = new Set(); + (item as []).forEach((value) => { + copy.add(deepClone(value)); + }); + return copy as T; + } + + throw new Error('Unsupported type!'); +}; + +export const isEqual = (obj1: unknown, obj2: unknown): boolean => { + const getType = (obj: unknown) => Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); + + const areArraysEqual = (obj1$: [], obj2$: []) => + obj1$.length === obj2$.length && obj1$.every((val1, index) => isEqual(val1, obj2$[index])); + + const areObjectsEqual = (obj1$: { [key: string]: unknown }, obj2$: { [key: string]: unknown }) => { + const obj1Keys = Object.keys(obj1$); + const obj2Keys = Object.keys(obj2$); + + return ( + obj1Keys.length === obj2Keys.length && + obj1Keys.every( + // eslint-disable-next-line no-prototype-builtins + (key) => obj2$.hasOwnProperty(key) && isEqual(obj1$[key] as unknown, obj2$[key] as unknown), + ) + ); + }; + + const areFunctionsEqual = (obj1$: () => void, obj2$: () => void) => obj1$.toString() === obj2$.toString(); + const arePrimitivesEqual = (obj1$: unknown, obj2$: unknown) => + obj1$ === obj2$ || (Number.isNaN(obj1$) && Number.isNaN(obj2$)); + + const type = getType(obj1); + + if (type !== getType(obj2)) { + return false; + } + + if (type === 'array') { + return areArraysEqual(obj1 as [], obj2 as []); + } + + if (type === 'object') { + return areObjectsEqual(obj1 as { [key: string]: unknown }, obj2 as { [key: string]: unknown }); + } + + if (type === 'function') { + return areFunctionsEqual(obj1 as () => void, obj2 as () => void); + } + + return arePrimitivesEqual(obj1, obj2); +}; + +// todo: replace static slice name to dynamic link to unique folder structure +export const getSliceName = (metaUrl: string) => { + const rawName = metaUrl + .replace(/[\\/]/g, '/') + .split('src/modules/') + .pop()! + .replace(/\.[^/.]+$/, ''); + + return `@@${rawName}`; +}; diff --git a/anyclip/src/modules/@common/helpers/number.ts b/anyclip/src/modules/@common/helpers/number.ts new file mode 100644 index 0000000..fc4b334 --- /dev/null +++ b/anyclip/src/modules/@common/helpers/number.ts @@ -0,0 +1,27 @@ +export const isInRange = (value: number, min = 0, max = Infinity) => value >= min && value <= max; + +export const getNumberInRange = (value: number, min = 0, max = Infinity) => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + + return value; +}; + +const siSymbolList = ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi']; +export const abbreviateNumber = (number: number, digitsAfterDot = 1, returnSeparately = false) => { + const float = parseFloat(`${number}`); + const tier = Math.floor(Math.log10(parseInt(`${Math.abs(float)}`, 10) || 1) / 3); + const scaled = (float / 1000 ** tier).toFixed(digitsAfterDot).replace(/\.0+$/, ''); + + if (returnSeparately) { + return { result: scaled, tier: siSymbolList[tier] }; + } + + return `${scaled}${siSymbolList[tier]}`; +}; + +export const isNumber = (value: unknown) => typeof value === 'number' && !Number.isNaN(value); diff --git a/anyclip/src/modules/@common/helpers/string.ts b/anyclip/src/modules/@common/helpers/string.ts new file mode 100644 index 0000000..f7e4958 --- /dev/null +++ b/anyclip/src/modules/@common/helpers/string.ts @@ -0,0 +1,41 @@ +export const getDataId = (string: string) => + string.toLowerCase().replace(/([.+_])/g, (match: string) => (match === '_' ? '-' : '')); + +export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + +const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; +const reHasRegExpChar = RegExp(reRegExpChar.source); +export const escapeRegExp = (string: string) => + string && reHasRegExpChar.test(string) ? string.replace(reRegExpChar, '\\$&') : string || ''; + +const urlRegExp = new RegExp( + '^(https?:\\/\\/)' + + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}' + + '|((\\d{1,3}\\.){3}\\d{1,3}))' + + '(:\\d+)?' + + '(\\/[-a-z\\d%_.~+]*)*' + + '(\\?[;&a-z\\d%_.~+=\\[\\]-]*)?' + + '(#\\S*)?$', + 'i', +); + +export const isValidUrl = (string: string) => { + if (!urlRegExp.test(string)) { + return false; + } + try { + const url = new URL(string); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +export const isValidUrlEmptyAllowed = (string: string) => { + if (!string) { + return true; + } + return isValidUrl(string); +}; + +export default {}; diff --git a/anyclip/src/modules/@common/helpers/time.ts b/anyclip/src/modules/@common/helpers/time.ts new file mode 100644 index 0000000..c89a46c --- /dev/null +++ b/anyclip/src/modules/@common/helpers/time.ts @@ -0,0 +1,69 @@ +const MS = 1000; +const SS = 1; +const MM = 60 * SS; +const HH = 60 * MM; + +const allowedTimeParts = ['hhh', 'hh', 'h', 'mm', 'm', 'ss', 's', 'SSS', 'SS', 'S']; +const allowedTimePatterns = new RegExp(`\\b(${allowedTimeParts.join('|')})\\b`, 'g'); + +const timeMap = (time: number) => { + const hour = Math.floor(time / MS / HH).toString(); + const minute = Math.floor((time / MS / MM) % MM).toString(); + const second = Math.floor((time / MS) % MM).toString(); + const milliSecond = time % MS; + + return { + hhh: hour.padStart(3, '0'), + hh: hour.padStart(2, '0'), + h: hour, + mm: minute.padStart(2, '0'), + m: minute, + ss: second.padStart(2, '0'), + s: second, + SSS: Math.floor(milliSecond).toString().padStart(3, '0'), + SS: Math.floor(milliSecond / 10) + .toString() + .padStart(2, '0'), + S: Math.floor(milliSecond / 100) + .toString() + .padStart(1, '0'), + }; +}; + +/** + * @param time number, example 1234567890 + * @param pattern string, example 'hhh:mm:ss:SSS' + * @returns string, example '342:56:07:890' + */ +export const getFormattedTime = (time: number, pattern: string) => { + const timeMapValue = timeMap(time); + return pattern.replace(allowedTimePatterns, (part: string) => timeMapValue[part as keyof typeof timeMapValue]); +}; + +export const getDuration = (time = 0, withMS = false) => { + const { h, mm, m, ss, s, SSS } = timeMap(time); + + const res = []; + + if (h === '0') { + if (m === '0') { + if (withMS) { + res.push(s); + } else { + res.push('0', ss); + } + } else { + res.push(m, ss); + } + } else { + res.push(h, mm, ss); + } + + if (withMS) { + res.push(SSS); + } + + return res.join(':'); +}; + +export default {}; diff --git a/anyclip/src/modules/@common/helpers/videoLangs.ts b/anyclip/src/modules/@common/helpers/videoLangs.ts new file mode 100644 index 0000000..e0b0dbb --- /dev/null +++ b/anyclip/src/modules/@common/helpers/videoLangs.ts @@ -0,0 +1,18 @@ +export type LanguageType = { + id: string; + name: string; +}; + +let languageList: LanguageType[] = []; + +export const setVideoLangs = (data: LanguageType[]) => { + languageList = data?.sort((a, b) => a.name.localeCompare(b.name)) ?? []; +}; + +export const getVideoLangs = () => languageList.slice(); + +export const getLanguageOptions = () => + languageList.map((lang) => ({ + label: lang.name, + value: lang.id, + })); diff --git a/anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.module.scss b/anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.module.scss new file mode 100644 index 0000000..822e9c9 --- /dev/null +++ b/anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"DropdownButton":"IabSelector_DropdownButton__bwXB5","NoOptions":"IabSelector_NoOptions__VCbWr"}; \ No newline at end of file diff --git a/anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.tsx b/anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.tsx new file mode 100644 index 0000000..ea81118 --- /dev/null +++ b/anyclip/src/modules/@common/iab/components/IabSelector/IabSelector.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import type { AutocompleteChangeReason } from '@mui/material'; + +import { SIZE_MEDIUM, SIZE_SMALL } from '@/mui/constants'; + +import { ShapeProp } from '@/mui/types'; + +import { getIAB } from '@/modules/@common/iab/helpers'; +import { createFlatTreeMap, getAggregateSelectedIds, NodeType, TreeMapperType } from '@/mui/helpers/treeView'; + +import { Autocomplete, TextField, TreeView } from '@/mui/components'; + +import styles from './IabSelector.module.scss'; + +const treeMapper = { + label: 'name', + children: 'categories', +}; + +type Props = { + size?: typeof SIZE_SMALL | typeof SIZE_MEDIUM; + value?: string[]; + shape?: ShapeProp; + placeholder?: string; + onChange: ( + event: React.SyntheticEvent, + option: unknown | unknown[] | object[], + reason: AutocompleteChangeReason | null, + ) => void; + onInputChange?: (event: React.SyntheticEvent, query: string) => void; + onOpen?: () => void; + onClose?: () => void; +}; + +function IabSelector({ + size = 'medium', + value = [], + shape, + placeholder, + onChange, + onInputChange, + onOpen, + onClose, + ...restProps +}: Props) { + const [searchText, setSearchText] = useState(''); + const [flatTree, setFlatTree] = useState( + createFlatTreeMap(getIAB() as NodeType[], treeMapper as TreeMapperType, value), + ); + + useEffect(() => { + setFlatTree(createFlatTreeMap(getIAB() as NodeType[], treeMapper as TreeMapperType, value)); + }, [value]); + + const tagList = useMemo( + () => + getAggregateSelectedIds(flatTree).map((nodeId) => { + const node = flatTree.get(nodeId); + + return { + label: node.label, + value: node.id, + id: node.id, + include: true, + }; + }), + [flatTree], + ); + + return ( + x} + isOptionEqualToValue={() => false} + inputValue={searchText} + options={[]} + optionLabelKey="label" + optionValueKey="value" + classes={{ + noOptions: styles.NoOptions, + }} + onChange={(event, tags, reason) => { + setFlatTree((prevState) => { + const hasChanges = false; + const copiedMap = new Map(prevState); + const tags$ = tags as Array<{ id: string }>; + + copiedMap.forEach((node) => { + copiedMap.set(node.id, { + ...node, + selected: tags$.some(({ id }) => node.id === id), + }); + }); + + onChange( + event, + [ + ...getAggregateSelectedIds(copiedMap).map((nodeId) => { + const node = copiedMap.get(nodeId); + + return { + ...node, + }; + }), + ], + reason, + ); + + return hasChanges ? copiedMap : prevState; + }); + }} + onInputChange={(event, query) => { + setSearchText(query); + + if (onInputChange) { + onInputChange(event, query); + } + }} + renderInput={(params) => } + noOptionsText={ + { + const primaryTree = flatTree; + + const copiedMap = new Map(); + + primaryTree.forEach((node) => { + copiedMap.set(node.id, { + ...node, + selected: nodeIds.includes(node.id), + }); + }); + + setFlatTree(copiedMap); + + onChange( + event, + [ + ...getAggregateSelectedIds(copiedMap).map((nodeId) => { + const node = copiedMap.get(nodeId); + + return { + ...node, + }; + }), + ], + null, + ); + }} + searchText={searchText} + /> + } + /> + ); +} + +export default IabSelector; diff --git a/anyclip/src/modules/@common/iab/helpers/index.ts b/anyclip/src/modules/@common/iab/helpers/index.ts new file mode 100644 index 0000000..07ab5fa --- /dev/null +++ b/anyclip/src/modules/@common/iab/helpers/index.ts @@ -0,0 +1,206 @@ +import iabResolver from '../../../../graphql/services/configuration/resolvers/iab'; + +export type IABNodeType = { + id: string; + name: string; + highlight?: boolean; + probability?: number; + version?: string; + categories?: IABNodeType[]; +}; + +let iabList: IABNodeType[] = []; + +if (process.env.NODE_ENV === 'development') { + iabList = iabResolver(); +} + +export const setIAB = (data: string) => { + iabList = JSON.parse(data); +}; + +export const getIAB = () => iabList; + +export const iabFindLeafNodes = (rootNode: IABNodeType) => { + const leafNodes: IABNodeType[] = []; + + function findRecursively(node: IABNodeType) { + if (node.categories?.length) { + node.categories.forEach((childNode) => findRecursively(childNode)); + } else if (node.id && node.name) { + leafNodes.push(node); + } + } + + findRecursively(rootNode); + + return leafNodes; +}; + +export const findNodes = (iabCategories: IABNodeType[], ids: string[] = []) => { + const nodes: IABNodeType[] = []; + + const allNodes = () => nodes.length === ids.length; + + const findNode = (categories: IABNodeType[]) => { + categories.forEach((category) => { + if (ids.includes(category.id)) { + nodes.push(category); + } + if (!allNodes() && category.categories?.length) { + findNode(category.categories); + } + }); + }; + + findNode(iabCategories); + + return nodes; +}; + +export const splitTree = (tree: IABNodeType) => { + const items: IABNodeType[] = []; + const parents: IABNodeType[] = []; + + const findNode = (categories: IABNodeType[]) => { + categories.forEach((category) => { + if (category.categories?.length) { + parents.push(category); + findNode(category.categories); + } else { + items.push(category); + } + }); + }; + + if (tree?.categories) { + findNode(tree.categories); + } + + return [items, parents]; +}; + +export const mapCategories = (categories: IABNodeType[]) => + categories.map(({ id, name, version, probability }) => ({ + id, + name, + version, + probability, + })); + +export const mergeTree = (tree: IABNodeType, items: IABNodeType[], parents: IABNodeType[]) => { + const treeList: { [key: string]: IABNodeType } = {}; + + const findNode = (categories: IABNodeType[]) => { + categories.forEach((category) => { + treeList[category.id] = category; + if (category.categories?.length) { + findNode(category.categories); + } + }); + }; + + if (tree) { + findNode(tree.categories as IABNodeType[]); + } + + const newItems = mapCategories(items.map((item) => treeList[item.id] || item)); + const newParents = mapCategories(parents.map((item) => treeList[item.id] || item)); + + return [newItems, newParents]; +}; + +export const getTreeByChildItems = (items: IABNodeType[]) => { + if (!items?.length) { + return []; + } + + const treeList: { [key: string]: IABNodeType } = {}; + const treeListParentMapping: { [key: string]: string | null } = {}; + const requestIABsTree: { [key: string]: IABNodeType } = {}; + + const findNode = (categories: IABNodeType[], parentNodeId: string | null) => { + categories.forEach((category) => { + treeList[category.id] = category; + + treeListParentMapping[category.id] = parentNodeId; + + if (category.categories?.length) { + findNode(category.categories, category.id); + } + }); + }; + + findNode(getIAB(), null); + + const getParentTree = (id: string) => { + const parentId = treeListParentMapping[id]; + + if (parentId) { + const parentIAB = treeList[parentId]; + + if (!requestIABsTree[parentId]) { + requestIABsTree[parentId] = { + id: parentIAB.id, + name: parentIAB.name, + probability: parentIAB.probability, + version: parentIAB.version, + }; + } + + getParentTree(parentIAB.id); + } + }; + + items.forEach((iab) => { + requestIABsTree[iab.id] = { + id: iab.id, + name: iab.name, + probability: iab.probability, + version: iab.version, + }; + }); + + items.forEach(({ id }) => getParentTree(id)); + + return Object.keys(requestIABsTree) + .reduce((acc, key) => { + const rawIAB = requestIABsTree[key]; + + return acc.concat( + Object.keys(rawIAB).reduce( + (acc$, key$) => + rawIAB[key$ as keyof IABNodeType] !== undefined + ? { ...acc$, [key$]: rawIAB[key$ as keyof IABNodeType] } + : acc$, + {} as IABNodeType, + ), + ); + }, [] as IABNodeType[]) + .sort((a, b) => a.id.localeCompare(b.id)); +}; + +export const iabFlatToTree = (iab: IABNodeType[]) => { + const map: { [key: number | string]: number | string } = {}; + const roots = []; + const array: Array<{ parentId: string } & IABNodeType> = iab.map((o) => ({ + ...o, + parentId: o.id?.split('.').slice(0, -1).join('.'), + })); + + for (let i = 0; i < array.length; i += 1) { + map[array[i].id] = i; + array[i].categories = []; + } + + for (let i = 0; i < array.length; i += 1) { + const node = array[i]; + if (node.parentId) { + (array[map[node.parentId] as number].categories as IABNodeType[]).push(node); + } else { + roots.push(node); + } + } + + return roots; +}; diff --git a/anyclip/src/modules/@common/init/redux/epics/error.js b/anyclip/src/modules/@common/init/redux/epics/error.js new file mode 100644 index 0000000..ebc45c5 --- /dev/null +++ b/anyclip/src/modules/@common/init/redux/epics/error.js @@ -0,0 +1,13 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { errorEventAction } from '../slices'; +import { initErrorAction, initErrorEventAction } from '@/modules/@common/gql/redux/slices'; +import { logOutEventAction } from '@/modules/@common/token/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(initErrorEventAction.type), + switchMap(() => concat(of(logOutEventAction()), of(initErrorAction()), of(errorEventAction()))), + ); diff --git a/anyclip/src/modules/@common/init/redux/epics/index.js b/anyclip/src/modules/@common/init/redux/epics/index.js new file mode 100644 index 0000000..d17f4ac --- /dev/null +++ b/anyclip/src/modules/@common/init/redux/epics/index.js @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import errorEpic from './error'; +import requestEpic from './request'; +import responseEpic from './response'; + +export default combineEpics(requestEpic, responseEpic, errorEpic); diff --git a/anyclip/src/modules/@common/init/redux/epics/request.js b/anyclip/src/modules/@common/init/redux/epics/request.js new file mode 100644 index 0000000..a9f7f12 --- /dev/null +++ b/anyclip/src/modules/@common/init/redux/epics/request.js @@ -0,0 +1,12 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { initEventAction } from '../slices'; +import { initRequestEventAction } from '@/modules/@common/gql/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(initEventAction.type), + switchMap(() => concat(of(initRequestEventAction()))), + ); diff --git a/anyclip/src/modules/@common/init/redux/epics/response.js b/anyclip/src/modules/@common/init/redux/epics/response.js new file mode 100644 index 0000000..61df606 --- /dev/null +++ b/anyclip/src/modules/@common/init/redux/epics/response.js @@ -0,0 +1,27 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { lastUpdateAction } from '../slices'; +import { initResponseSelector } from '@/modules/@common/gql/redux/selectors'; +import { initResponseAction, initResponseEventAction } from '@/modules/@common/gql/redux/slices'; +import { setVideoLangs } from '@/modules/@common/helpers/videoLangs'; +import { setIAB } from '@/modules/@common/iab/helpers'; + +export default (action$, state$) => + action$.pipe( + ofType(initResponseEventAction.type), + switchMap(() => { + const initResponse = initResponseSelector(state$.value); + + const { + iab: { data: iab }, + videoLangs, + } = initResponse; + + setIAB(iab); + setVideoLangs(videoLangs); + + return concat(of(lastUpdateAction(new Date())), of(initResponseAction())); + }), + ); diff --git a/anyclip/src/modules/@common/init/redux/slices/index.js b/anyclip/src/modules/@common/init/redux/slices/index.js new file mode 100644 index 0000000..e29768d --- /dev/null +++ b/anyclip/src/modules/@common/init/redux/slices/index.js @@ -0,0 +1,25 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + lastUpdate: null, +}; + +export const slice = createSlice({ + name: '@@COMMON/INIT', + initialState, + + reducers: { + initEventAction: (state) => state, + requestEventAction: (state) => state, + responseEventAction: (state) => state, + errorEventAction: (state) => state, + lastUpdateAction: (state, action) => { + state.lastUpdate = action.payload || initialState.lastUpdate; + }, + }, +}); + +export const { errorEventAction, initEventAction, lastUpdateAction, requestEventAction, responseEventAction } = + slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/location/redux/epics/addQueries.js b/anyclip/src/modules/@common/location/redux/epics/addQueries.js new file mode 100644 index 0000000..5316097 --- /dev/null +++ b/anyclip/src/modules/@common/location/redux/epics/addQueries.js @@ -0,0 +1,31 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { filter, tap } from 'rxjs/operators'; + +import queue from '../helpers/queue'; +import { addQueriesAction } from '../slices'; +import { isEqual } from '@/modules/@common/helpers'; + +export default (action$) => + action$.pipe( + ofType(addQueriesAction.type), + filter((action) => { + const addQueries = action.payload; + const prevQueries = Router.query; + + return !isEqual(prevQueries, addQueries); + }), + tap((action) => { + const addQueries = action.payload; + + queue.items.push({ + pathname: Router.pathname, + getQuery: (routerQuery) => ({ ...routerQuery, ...addQueries }), + }); + + if (!queue.isInProcessing) { + queue.runProcessing(); + } + }), + filter(() => false), + ); diff --git a/anyclip/src/modules/@common/location/redux/epics/index.js b/anyclip/src/modules/@common/location/redux/epics/index.js new file mode 100644 index 0000000..2b6d97f --- /dev/null +++ b/anyclip/src/modules/@common/location/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import addQueries from './addQueries'; +import removeQueries from './removeQueries'; + +export default combineEpics(addQueries, removeQueries); diff --git a/anyclip/src/modules/@common/location/redux/epics/removeQueries.js b/anyclip/src/modules/@common/location/redux/epics/removeQueries.js new file mode 100644 index 0000000..99fae17 --- /dev/null +++ b/anyclip/src/modules/@common/location/redux/epics/removeQueries.js @@ -0,0 +1,56 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { filter, tap } from 'rxjs/operators'; + +import queue from '../helpers/queue'; +import { removeQueriesAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(removeQueriesAction.type), + filter((action) => { + const key = action.payload; + const prevQueries = Router.query; + + if (typeof key === 'string') { + return key && prevQueries[key]; + } + + return true; + }), + tap((action) => { + const removeQueries = action.payload; + + if (typeof removeQueries === 'string') { + queue.items.push({ + pathname: Router.pathname, + getQuery: (routerQuery) => { + const { [removeQueries]: omit, ...newQueries } = routerQuery; + return newQueries; + }, + }); + } + + if (Array.isArray(removeQueries)) { + queue.items.push({ + pathname: Router.pathname, + getQuery: (routerQuery) => + Object.keys(routerQuery).reduce( + (acc, cur) => + removeQueries.includes(cur) + ? acc + : { + ...acc, + [cur]: routerQuery[cur], + }, + {}, + ), + }); + } + + if (!queue.isInProcessing) { + queue.runProcessing(); + } + }), + filter(() => false), + ); diff --git a/anyclip/src/modules/@common/location/redux/helpers/queue.js b/anyclip/src/modules/@common/location/redux/helpers/queue.js new file mode 100644 index 0000000..36c799e --- /dev/null +++ b/anyclip/src/modules/@common/location/redux/helpers/queue.js @@ -0,0 +1,38 @@ +import Router from 'next/router'; +import { parse, stringify } from 'qs'; + +const queue = { + items: [], + isInProcessing: false, + runProcessing: () => { + queue.isInProcessing = true; + if (queue.items.length) { + const qItems = queue.items.reverse(); + const qItem = qItems.pop(); + + // need parse next router to object as we do in prev version with history + const queryParsedFromNextRouterQuery = parse(stringify(Router.query)); + + const url = `${qItem.pathname}?${stringify(qItem.getQuery(queryParsedFromNextRouterQuery), { encode: false })}`; + + const promise = new Promise((resolve, reject) => { + const currentPathName = Router.pathname; + + if (currentPathName === qItem.pathname) { + Router.push(url, undefined, { shallow: true }).then(resolve).catch(reject); + } else { + resolve(); + } + }); + + promise.then(queue.runProcessing).catch(() => { + queue.items = []; + queue.isInProcessing = false; + }); + } else { + queue.isInProcessing = false; + } + }, +}; + +export default queue; diff --git a/anyclip/src/modules/@common/location/redux/slices/index.js b/anyclip/src/modules/@common/location/redux/slices/index.js new file mode 100644 index 0000000..7b68d4c --- /dev/null +++ b/anyclip/src/modules/@common/location/redux/slices/index.js @@ -0,0 +1,36 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + location: null, + addQueries: { key: null, value: null }, + removeQueries: '', +}; + +export const slice = createSlice({ + name: '@@COMMON/LOCATION', + initialState, + + reducers: { + locationLoadedEventAction: (state) => state, + locationChangedEventAction: (state) => state, + locationAction: (state, action) => { + state.location = action.payload || initialState.location; + }, + addQueriesAction: (state, action) => { + state.addQueries = action.payload || initialState.addQueries; + }, + removeQueriesAction: (state, action) => { + state.removeQueries = action.payload || initialState.removeQueries; + }, + }, +}); + +export const { + addQueriesAction, + locationAction, + locationChangedEventAction, + locationLoadedEventAction, + removeQueriesAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/monitoring/helpers/monitoring.js b/anyclip/src/modules/@common/monitoring/helpers/monitoring.js new file mode 100644 index 0000000..d3c1fea --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/helpers/monitoring.js @@ -0,0 +1,23 @@ +export const isTimeouted = (job, time = Date.now()) => { + const duration1 = 1000 * 60 * 20; // 20 min + const duration2 = 1000 * 60 * 60 * 4; // 4 hours + + if (job.type === 'TAGGING' || job.type === 'SCENE_RECOGNITION') { + return time - job.updateTime > duration1; + } + + if (job.type === 'VIDEO_ENCODING') { + return time - job.updateTime > duration2; + } + + if (job.type === 'VIDEO_PROCESSING' || job.type === 'VIDEO_CHILD_PROCESSING') { + return time - job.updateTime > duration1 + duration2; + } + + return false; +}; + +export const isDone = (job) => job.state === 'DONE'; +export const isError = (job) => job.state === 'ERROR'; +export const isFinished = (job) => isDone(job) || isError(job) || isTimeouted(job); +export const hasUnfinishedJobs = (jobs) => jobs.some((job) => !isFinished(job)); diff --git a/anyclip/src/modules/@common/monitoring/redux/epics/index.js b/anyclip/src/modules/@common/monitoring/redux/epics/index.js new file mode 100644 index 0000000..02d456d --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import monitoringEpic from './monitoring'; +import monitoringEndEpic from './monitoringEnd'; +import monitoringRepeatEpic from './monitoringRepeat'; +import monitoringRunEpic from './monitoringRun'; + +export default combineEpics(monitoringEpic, monitoringRunEpic, monitoringRepeatEpic, monitoringEndEpic); diff --git a/anyclip/src/modules/@common/monitoring/redux/epics/monitoring.js b/anyclip/src/modules/@common/monitoring/redux/epics/monitoring.js new file mode 100644 index 0000000..37480c7 --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/redux/epics/monitoring.js @@ -0,0 +1,23 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { dataSelector } from '../selectors'; +import { dataAction, runAction, startAction } from '../slices'; + +const monitoringEpic = (action$, state$) => + action$.pipe( + ofType(startAction.type), + filter(({ payload }) => { + const monitoringData = dataSelector(state$.value); + + return !monitoringData[payload]; + }), + switchMap(({ payload }) => { + const monitoringData = dataSelector(state$.value); + + return concat(of(dataAction({ ...monitoringData, ...{ [payload]: [] } })), of(runAction(payload))); + }), + ); + +export default monitoringEpic; diff --git a/anyclip/src/modules/@common/monitoring/redux/epics/monitoringEnd.js b/anyclip/src/modules/@common/monitoring/redux/epics/monitoringEnd.js new file mode 100644 index 0000000..1cdaa1d --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/redux/epics/monitoringEnd.js @@ -0,0 +1,23 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, mergeMap } from 'rxjs/operators'; + +import { dataSelector } from '../selectors'; +import { dataAction, endAction } from '../slices'; + +const monitoringEpic = (action$, state$) => + action$.pipe( + ofType(endAction.type), + delay(10000), + mergeMap(({ payload }) => { + const monitoringData = dataSelector(state$.value); + + const videoDataCollection = { ...monitoringData }; + + delete videoDataCollection[payload]; + + return concat(of(dataAction(videoDataCollection))); + }), + ); + +export default monitoringEpic; diff --git a/anyclip/src/modules/@common/monitoring/redux/epics/monitoringRepeat.js b/anyclip/src/modules/@common/monitoring/redux/epics/monitoringRepeat.js new file mode 100644 index 0000000..3624dae --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/redux/epics/monitoringRepeat.js @@ -0,0 +1,20 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, filter, mergeMap } from 'rxjs/operators'; + +import { EDITORIAL_PAGE } from '@/modules/@common/router/constants'; + +import { hasUnfinishedJobs } from '../../helpers/monitoring'; +import { endAction, repeatAction, runAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(repeatAction.type), + delay(+process.env.APP_REQUEST_TIMEOUT), + filter(() => window.location.pathname === EDITORIAL_PAGE), + mergeMap(({ payload }) => { + const { videoId, jobs } = payload.repeat; + + return concat(of(hasUnfinishedJobs(jobs) ? runAction(videoId) : endAction(videoId))); + }), + ); diff --git a/anyclip/src/modules/@common/monitoring/redux/epics/monitoringRun.js b/anyclip/src/modules/@common/monitoring/redux/epics/monitoringRun.js new file mode 100644 index 0000000..bf73ef3 --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/redux/epics/monitoringRun.js @@ -0,0 +1,91 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import { dataSelector } from '../selectors'; +import { dataAction, endAction, repeatAction, runAction } from '../slices'; +import { hasUnfinishedJobs } from '@/modules/@common/monitoring/helpers/monitoring'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query MonitoringQuery($videoId: String!, $size: Float!) { + monitoring(videoId: $videoId, size: $size) { + jobs { + videoId + childId + childType + type + state + message + startTime + updateTime + progress + properties { + key + value + } + } + } + } +`; + +const jobTypes = [ + 'VIDEO_PROCESSING', + 'VIDEO_CHILD_PROCESSING', + 'TAGGING', + 'SCENE_RECOGNITION', + 'VIDEO_ENCODING', + 'CLOSED_CAPTIONS', + 'THUMBNAIL_PROCESSING', +]; + +const getFilteredJobs = (jobs) => jobs.filter((job) => jobTypes.includes(job.type)); + +const monitoringEpic = (action$, state$) => + action$.pipe( + ofType(runAction.type), + mergeMap(({ payload }) => { + const monitoringData = dataSelector(state$.value); + const videoId = payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId, + size: 1000, + }, + }).pipe( + switchMap(({ data, errors }) => { + if (errors.length) { + throw new Error(errors[0]); + } + + const actions = []; + + if (!errors.length) { + const filteredJobs = getFilteredJobs(data.monitoring.jobs); + const isFinishedMonitoring = !hasUnfinishedJobs(filteredJobs); + const updatedJobData = { ...monitoringData, ...{ [videoId]: filteredJobs } }; + + actions.push( + of(dataAction(updatedJobData)), + of( + isFinishedMonitoring + ? endAction(videoId) + : repeatAction({ + videoId, + jobs: filteredJobs, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default monitoringEpic; diff --git a/anyclip/src/modules/@common/monitoring/redux/selectors/index.js b/anyclip/src/modules/@common/monitoring/redux/selectors/index.js new file mode 100644 index 0000000..789a2c4 --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/redux/selectors/index.js @@ -0,0 +1,9 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const startSelector = (state) => state[nameSpace].start; +export const runSelector = (state) => state[nameSpace].run; +export const repeatSelector = (state) => state[nameSpace].repeat; +export const endSelector = (state) => state[nameSpace].end; +export const dataSelector = (state) => state[nameSpace].data; diff --git a/anyclip/src/modules/@common/monitoring/redux/slices/index.js b/anyclip/src/modules/@common/monitoring/redux/slices/index.js new file mode 100644 index 0000000..eca30d4 --- /dev/null +++ b/anyclip/src/modules/@common/monitoring/redux/slices/index.js @@ -0,0 +1,36 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + start: null, + run: null, + repeat: null, + end: null, + data: {}, +}; + +export const slice = createSlice({ + name: '@@COMMON/MONITORING', + initialState, + + reducers: { + startAction: (state, action) => { + state.start = action.payload; + }, + runAction: (state, action) => { + state.run = action.payload; + }, + repeatAction: (state, action) => { + state.repeat = action.payload; + }, + endAction: (state, action) => { + state.end = action.payload; + }, + dataAction: (state, action) => { + state.data = action.payload; + }, + }, +}); + +export const { dataAction, endAction, repeatAction, runAction, startAction } = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/notify/constants/index.js b/anyclip/src/modules/@common/notify/constants/index.js new file mode 100644 index 0000000..b6aca79 --- /dev/null +++ b/anyclip/src/modules/@common/notify/constants/index.js @@ -0,0 +1,4 @@ +export const TYPE_ERROR = 'error'; +export const TYPE_WARNING = 'warning'; +export const TYPE_SUCCESS = 'success'; +export const TYPE_INFO = 'info'; diff --git a/anyclip/src/modules/@common/notify/redux/selectors/index.js b/anyclip/src/modules/@common/notify/redux/selectors/index.js new file mode 100644 index 0000000..835e8e8 --- /dev/null +++ b/anyclip/src/modules/@common/notify/redux/selectors/index.js @@ -0,0 +1,7 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const notifications = (state) => state[nameSpace].notifications; + +export default {}; diff --git a/anyclip/src/modules/@common/notify/redux/slices/index.ts b/anyclip/src/modules/@common/notify/redux/slices/index.ts new file mode 100644 index 0000000..03e6eea --- /dev/null +++ b/anyclip/src/modules/@common/notify/redux/slices/index.ts @@ -0,0 +1,65 @@ +import type { AlertColor, SnackbarOrigin } from '@mui/material'; +import { createSlice } from '@reduxjs/toolkit'; + +import { TYPE_INFO } from '@/modules/@common/notify/constants'; + +export type SnackBarMessageType = { + key: string; + message: string; + duration?: number; + variant: AlertColor; + onClose?: () => void; + dismissed?: boolean; + position?: SnackbarOrigin; +}; + +type NotificationType = { + key: string; + dismissed: boolean; +}; + +type stateType = { + notifications: NotificationType[]; +}; + +const initialState: stateType = { + notifications: [], +}; + +/** https://immerjs.github.io/immer/update-patterns/ */ +export const slice = createSlice({ + name: '@@SNACKBAR', + initialState, + reducers: { + notifyAction: (state, action) => { + state.notifications.push({ + ...action.payload, + key: `${action.payload.key ?? Math.random().toString(36).slice(2)}`, + // todo: action.payload.type is the backward compatible property + variant: action.payload.variant ?? action.payload.type ?? TYPE_INFO, + dismissed: false, + }); + }, + closeNotifyAction: (state, action) => { + const stringKey = `${action.payload}`; + + state.notifications.forEach((notification) => { + // eslint-disable-next-line no-param-reassign + notification.dismissed = !action.payload || notification.key === stringKey; + }); + }, + removeSnackbarAction: (state, action) => { + const stringKey = `${action.payload}`; + + const index = state.notifications.findIndex((notification) => notification.key === stringKey); + + if (index !== -1) { + state.notifications.splice(index, 1); + } + }, + }, +}); + +export const { notifyAction, closeNotifyAction, removeSnackbarAction } = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/request/constants/index.js b/anyclip/src/modules/@common/request/constants/index.js new file mode 100644 index 0000000..3a94393 --- /dev/null +++ b/anyclip/src/modules/@common/request/constants/index.js @@ -0,0 +1,3 @@ +export const GRAPHQL_URI = '/api/graphql'; + +export default {}; diff --git a/anyclip/src/modules/@common/request/index.js b/anyclip/src/modules/@common/request/index.js new file mode 100644 index 0000000..13f8e8b --- /dev/null +++ b/anyclip/src/modules/@common/request/index.js @@ -0,0 +1,225 @@ +import { defer, EMPTY, from, of } from 'rxjs'; +import { catchError, finalize } from 'rxjs/operators'; + +import { USER_UPDATED_HEADER } from '../user/constants'; +import { NO_PERMISSION_MESSAGE, TOKEN_EXPIRED_MESSAGE } from '@/modules/@common/envs/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { GRAPHQL_URI } from '@/modules/@common/request/constants'; +import { TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE } from '@/modules/@common/token/constants'; + +import { getUserUpdatedTime } from '../user/redux/selectors'; +import { getUserDataAction } from '../user/redux/slices'; +import { featureFlags } from '@/modules/@common/helpers/featureFlags'; +import { requestEventAction, responseEventAction } from '@/modules/@common/request/redux/slices'; +import { getToken, getTokenCookieName, getTokenCookieValue } from '@/modules/@common/token/helpers'; +import { logOutEventAction } from '@/modules/@common/token/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import store from '@/modules/@common/store'; + +export const parseErrorMessage = (errorObject, mapError) => { + if (errorObject?.response) { + const dataErrorMessage = errorObject.response.data?.errors?.[0]?.message; + + if (dataErrorMessage) { + return [dataErrorMessage]; + } + + const errors = Array.isArray(errorObject.response.details) + ? errorObject.response.details.map((detail) => { + const mainMessagePart = errorObject.response.message ?? errorObject.message; + const source = detail.source ? `${detail.source}: ` : ''; + + return `${mainMessagePart} ${source}${detail.message}`; + }) + : [ + errorObject.response.message || + errorObject.response.error?.message || + errorObject.response.error || + errorObject.message, + ]; + + return mapError ? mapError(errors) : errors; + } + + return errorObject?.message ?? 'Connection error'; +}; + +const FAILED_TO_FETCH_MESSAGE = 'Failed to fetch'; + +const tokenExpiredReg = new RegExp(`${TOKEN_EXPIRED_MESSAGE}`, 'i'); +const noPermissionsReg = new RegExp(`${NO_PERMISSION_MESSAGE}`, 'i'); +const failedToFetchReg = new RegExp(`${FAILED_TO_FETCH_MESSAGE}`, 'i'); + +const checkUserDataUpdates = (userLastUpdateHeader) => { + const state = store.current.getState(); + + const userUpdatedDate = getUserUpdatedTime(state); + + if (userUpdatedDate !== userLastUpdateHeader) { + store.current.dispatch(getUserDataAction()); + } +}; + +export const processResponse = (cSettings, data = {}) => { + const settings = { + showNotificationMessage: true, + mapError: (error) => error, + ...cSettings, + }; + + store.current.dispatch(responseEventAction()); + + const errors = data.errors ?? []; + + if (errors.length) { + const tokenWasExpired = errors.some(({ message }) => tokenExpiredReg.test(message)); + const noPermission = errors.some(({ message }) => noPermissionsReg.test(message)); + const failedToFetch = errors.some(({ message }) => failedToFetchReg.test(message)); + + if (tokenWasExpired || failedToFetch || errors[0]?.statusCode === 401 || errors[0]?.response?.status === 401) { + store.current.dispatch( + showNotificationAction({ + key: TOKEN_EXPIRED_MESSAGE, + type: TYPE_ERROR, + message: TOKEN_EXPIRED_MESSAGE, + }), + ); + + store.current.dispatch(logOutEventAction()); + } else if (settings.showNotificationMessage) { + if (noPermission) { + store.current.dispatch( + showNotificationAction({ + key: NO_PERMISSION_MESSAGE, + type: TYPE_ERROR, + message: NO_PERMISSION_MESSAGE, + }), + ); + } else { + const errorMessage = parseErrorMessage(errors[0], settings.mapError); + + store.current.dispatch( + showNotificationAction({ + key: errorMessage, + type: TYPE_ERROR, + message: errorMessage, + }), + ); + } + } + } + + return { + data: data?.data, + ...data, + errors, + }; +}; + +export const uploadS3 = (uploadUrl, file, cSettings) => { + let controller = null; + + return defer(() => { + if (controller) { + controller.abort(); + } + + controller = new AbortController(); + + return fetch(uploadUrl, { + method: 'PUT', + body: file, + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({ + errors: [`HTTP error ${response.status}: ${response.statusText}`], + })); + return processResponse(cSettings, errorData); + } + + const data = await response.json().catch(() => ({})); // Handle empty response + return processResponse(cSettings, data); + }) + .catch((error) => { + const errorData = { errors: [error.message || error] }; + return processResponse(cSettings, errorData); + }); + }); +}; + +export const gqlRequest = (body, settings) => { + let controller = null; + + const bodyRequest = { + query: body.query.trim(), + variables: body.variables ?? {}, + }; + + return defer(() => { + if (controller) { + controller.abort(); + } + + controller = new AbortController(); + + store.current.dispatch(requestEventAction()); + + let headers = { + Accept: 'application/json', + Authorization: getToken() || '', + 'Content-Type': 'application/json', + }; + + const tokenCookieName = getTokenCookieName(); + const tokenCookieValue = getTokenCookieValue(); + + if (tokenCookieName && tokenCookieValue) { + headers = { + ...headers, + [TOKEN_COOKIE_NAME]: tokenCookieName, + [TOKEN_COOKIE_VALUE]: tokenCookieValue, + }; + } + + if (featureFlags.logs) { + headers.aclog = true; + // eslint-disable-next-line no-console + console.log('Request log: ', bodyRequest); + } + + const promise = fetch(GRAPHQL_URI, { + method: 'POST', + mode: 'cors', + headers, + credentials: 'include', + body: JSON.stringify(bodyRequest), + signal: controller.signal, + redirect: 'error', + }).then(async (response) => { + const data = (await response.json()) || {}; + + const userLastUpdate = response.headers.get(USER_UPDATED_HEADER); + + if (userLastUpdate) { + checkUserDataUpdates(userLastUpdate); + } + + return processResponse(settings, data); + }); + + // Convert to observable; cancel on unsubscribe; hide AbortError + return from(promise).pipe( + catchError((error) => { + if (error?.name === 'AbortError') return EMPTY; // silent cancel + return of(processResponse(settings, { errors: [error] })); + }), + finalize(() => { + // ensure inflight request is aborted on switchMap unsubscribe + if (controller && !controller.signal.aborted) controller.abort('switchMap unsubscribe'); + }), + ); + }); +}; diff --git a/anyclip/src/modules/@common/request/redux/slices/index.js b/anyclip/src/modules/@common/request/redux/slices/index.js new file mode 100644 index 0000000..07d8303 --- /dev/null +++ b/anyclip/src/modules/@common/request/redux/slices/index.js @@ -0,0 +1,22 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + errorEvent: null, +}; + +export const slice = createSlice({ + name: '@@COMMON/REQUEST', + initialState, + + reducers: { + requestEventAction: (state) => state, + responseEventAction: (state) => state, + errorEventAction: (state, action) => { + state.errorEvent = action.payload; + }, + }, +}); + +export const { errorEventAction, requestEventAction, responseEventAction } = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/router/constants/index.ts b/anyclip/src/modules/@common/router/constants/index.ts new file mode 100644 index 0000000..c0db73c --- /dev/null +++ b/anyclip/src/modules/@common/router/constants/index.ts @@ -0,0 +1,377 @@ +import * as permissions from '@/modules/@common/acl/constants'; + +export const ROOT_PAGE = { + path: '/', + permissions: null, +}; + +export const USERS_PAGE = { + path: '/users', + permissions: [permissions.PCN_GET_USERS], +}; + +export const ONLINE_HELP_CONFIG_PAGE = { + path: '/online-help', + permissions: [permissions.PCN_GET_ONLINE_HELP], +}; + +export const PARTNERS_ACCOUNTS_PAGE = { + path: '/accounts', + permissions: [permissions.PCN_GET_PARTNERS_ACCOUNTS], +}; + +export const CONFIG_PAGE = { + path: '/config', + permissions: [permissions.PCN_GET_LUMINOUS_CONFIG], +}; + +export const CUSTOM_REPORTS_PAGE = { + path: '/custom-reports', + permissions: [permissions.PCN_GET_CUSTOM_REPORTS], +}; + +export const NOTIFICATIONS_PAGE = { + path: '/notifications', + permissions: [permissions.PCN_POST_NOTIFICATIONS], +}; + +export const PERMISSIONS_PAGE = { + path: '/permissions', + permissions: [permissions.PCN_GET_ADMIN], +}; + +export const ROLES_PERMISSIONS_PAGE = { + path: '/roles-permissions', + permissions: [permissions.WEAVO_VIEW_ROLES_PERMISSIONS], +}; + +export const CONTENT_OWNERS_PAGE = { + path: '/content-owners', + permissions: [permissions.MANAGE_CONTENT_OWNERS], +}; + +export const FEEDS_PAGE = { + path: '/feeds', + permissions: [permissions.PCN_GET_FEEDS, permissions.PCN_GET_SELF_SERVE_SOURCES], +}; + +export const PUBLISHER_PAGE = { + path: '/publisher', + permissions: [permissions.PCN_GET_PUBLISHER], +}; + +export const ADVERTISERS_PAGE = { + path: '/advertisers', + permissions: [permissions.PCN_GET_ADVERTISERS], +}; + +export const INVENTORY_PAGE = { + path: '/inventory', + permissions: [permissions.PCN_GET_INVENTORY], +}; + +export const AD_SERVERS_PAGE = { + path: '/ad-servers', + permissions: [permissions.PCN_GET_INVENTORY], +}; + +export const PLAYER_PCN_PAGE = { + path: '/player-old', + permissions: [permissions.PCN_GET_PLAYER], +}; + +export const LIVE_PAGE = { + path: '/live', + permissions: [permissions.PCN_GET_LIVE_EVENTS], +}; + +export const LIVE_NEW_PAGE = { + path: '/live/new', + permissions: [permissions.PCN_GET_LIVE_EVENTS], +}; + +export const LIVE_EDIT_PAGE = { + path: '/live/:id', + permissions: [permissions.PCN_GET_LIVE_EVENTS], +}; + +export const EDITORIAL_PAGE = { + path: '/studio', + permissions: [permissions.PCN_GET_VIDEO], +}; + +export const HOSTED_WATCH = { + path: '/watch/:watchId?', + permissions: [permissions.PCN_GET_HOSTED_WATCH], +}; + +export const HOSTED_WATCH_PAGE = { + path: '/watch', + permissions: HOSTED_WATCH.permissions.slice(), +}; + +export const ENTITIES_PAGE = { + path: '/entities', + permissions: [permissions.PCN_GET_ENTITIES], +}; + +export const INVITATIONS_PAGE = { + path: '/invitations', + permissions: [permissions.PCN_INVITATIONS_SHOW_TABLE, permissions.PCN_GET_INVITATIONS], +}; + +export const PUBLISHING_PAGE = { + path: '/publishing', + permissions: [permissions.PCN_GET_DESTINATIONS], +}; + +export const PUBLISHING_NEW_PAGE = { + path: '/publishing/new/:platform', + permissions: [permissions.PCN_GET_DESTINATIONS], +}; + +export const PUBLISHING_EDIT_PAGE = { + path: '/publishing/:id', + permissions: [permissions.PCN_GET_DESTINATIONS], +}; + +export const X_RAY_CAMPAIGNS_PAGE = { + path: '/x-ray/campaigns', + permissions: [permissions.PCN_GET_X_RAY_CAMPAIGNS], +}; + +export const X_RAY_CREATIVES_PAGE = { + path: '/x-ray/creatives', + permissions: [permissions.PCN_GET_X_RAY_CREATIVES], +}; + +export const X_RAY_LINE_ITEMS_PAGE = { + path: '/x-ray/line-items', + permissions: [permissions.PCN_GET_X_RAY_LINE_ITEMS], +}; + +export const LOGOUT_PAGE = { + path: '/logout', + permissions: null, +}; + +export const LOGIN_PAGE = { + path: '/login', + permissions: null, +}; + +export const PASSWORD_LESS_LOGIN_PAGE = { + path: '/passwordless-login', + permissions: null, +}; + +export const PASSWORD_LESS_LOGIN_CODE_PAGE = { + path: '/passwordless-login-code', + permissions: null, +}; + +export const USER_AUTH_ERROR_PAGE = { + path: '/user-auth-error', + permissions: null, +}; + +export const ACTIVATION_GUEST_PAGE = { + path: '/activation-guest', + permissions: null, +}; + +export const FORGOT_PASSWORD_PAGE = { + path: '/forgot-password', + permissions: null, +}; + +export const RESET_PASSWORD_PAGE = { + path: '/reset-password', + permissions: null, +}; + +export const CREATE_PASSWORD_PAGE = { + path: '/create-password', + permissions: null, +}; + +export const SIGN_IN_PAGE = { + path: '/signin', + permissions: null, +}; + +export const NOT_FOUND_PAGE = { + path: '/404', + permissions: null, +}; + +export const NOT_PERMITTED_PAGE = { + path: '/403', + permissions: null, +}; + +export const SUPPLY_LIST_PAGE = { + path: '/supply', + permissions: [ + permissions.PCN_GET_SUPPLY, + permissions.PCN_GET_MARKETPLACE_DASHBOARD, + permissions.PCN_GET_MARKETPLACE_SELF_SERVE, + ], +}; + +export const SUPPLY_PAGE = { + path: '/supply/:accountId?/(sites)?/:siteId?/(tags)?/:tagId?', + permissions: [ + permissions.PCN_GET_SUPPLY, + permissions.PCN_GET_MARKETPLACE_DASHBOARD, + permissions.PCN_GET_MARKETPLACE_SELF_SERVE, + ], +}; + +export const DEMAND_LIST_PAGE = { + path: '/demand', + permissions: [ + permissions.PCN_GET_DEMAND, + permissions.PCN_GET_MARKETPLACE_DASHBOARD, + permissions.PCN_GET_MARKETPLACE_SELF_SERVE, + ], +}; + +export const DEMAND_PAGE = { + path: '/demand/:accountId?/(advertisers)?/:advertiserId?/(tags)?/:tagId?', + permissions: [ + permissions.PCN_GET_DEMAND, + permissions.PCN_GET_MARKETPLACE_DASHBOARD, + permissions.PCN_GET_MARKETPLACE_SELF_SERVE, + ], +}; + +export const KEY_LIST_PAGE = { + path: '/key-lists', + permissions: [permissions.PCN_GET_MARKETPLACE_DASHBOARD, permissions.PCN_GET_MARKETPLACE_SELF_SERVE], +}; + +export const KEY_LIST_FORM_PAGE = { + path: '/key-lists/:id', + permissions: [permissions.PCN_GET_MARKETPLACE_DASHBOARD, permissions.PCN_GET_MARKETPLACE_SELF_SERVE], +}; + +export const MARKETPLACE_HB_CONNECTORS_PAGE = { + path: '/hb-connectors', + permissions: [permissions.PCN_GET_INVENTORY], +}; + +export const MARKETPLACE_HB_CONNECTOR_FORM_PAGE = { + path: '/hb-connectors/:id', + permissions: [permissions.PCN_GET_INVENTORY], +}; + +export const MARKETPLACE_DASHBOARD_PAGE = { + path: '/marketplace-dashboard', + permissions: [permissions.PCN_GET_MARKETPLACE_DASHBOARD], +}; + +export const MARKETPLACE_SELF_SERVE = { + path: '/demand', + permissions: [permissions.PCN_GET_MARKETPLACE_SELF_SERVE], +}; + +export const FORMS_NEW_PAGE = { + path: '/forms/new', + permissions: [permissions.PCN_POST_FORMS_EDITOR], +}; + +export const FORMS_EDIT_PAGE = { + path: '/forms/:id/(duplicate)?', + permissions: [permissions.PCN_GET_FORMS_EDITOR], +}; + +export const FORMS = { + path: '/forms', + permissions: [permissions.PCN_GET_FORMS_EDITOR], +}; + +export const FORM_TEMPLATES = { + path: '/form-templates', + permissions: [permissions.GET_FORM_TEMPLATES], +}; + +export const FORM_TEMPLATE_NEW_PAGE = { + path: '/form-templates/new', + permissions: [permissions.POST_FORM_TEMPLATES], +}; + +export const FORM_TEMPLATE_EDIT_PAGE = { + path: '/form-templates/:id/(duplicate)?', + permissions: [permissions.GET_FORM_TEMPLATES], +}; + +export const ANALYTICS_PAGE = { + path: '/analytics', + permissions: [permissions.PCN_GET_ANALYTICS], +}; + +export const ANALYTICS_VIDEO_CONTENT_PERFORMANCE = { + path: '/analytics-new/video-content-performance', + permissions: [permissions.ANALYTICS_VIDEO_CONTENT_PERFORMANCE], +}; + +export const ANALYTICS_LIVE_DASHBOARD = { + path: '/analytics-new/live-events-past', + permissions: [permissions.ANALYTICS_LIVE_EVENTS_DASHBOARD], +}; + +export const ANALYTICS_MONETIZATION_DASHBOARD = { + path: '/analytics-new/monetization', + permissions: [permissions.ANALYTICS_MONETIZATION_DASHBOARD], +}; + +export const ANALYTICS_REVENUE_OVERVIEW = { + path: '/analytics-new/revenue-overview', + permissions: [permissions.ANALYTICS_MONETIZATION_DASHBOARD], +}; + +export const ANALYTICS_CUSTOM_REPORTS = { + path: '/custom-reports-new', + permissions: [permissions.ANALYTICS_CUSTOM_REPORTS], +}; + +export const USER_RULES_SETTINGS = { + path: '/personal-settings', + permissions: [permissions.GET_PERSONAL_SETTING], +}; + +export const SSO_PAGE = { + path: '/sso', + permissions: [permissions.SSO_PAGE], +}; + +export const SSO_LOGIN_PAGE = { + path: '/sso-login', + permissions: null, +}; + +export const PLAYER_PAGE = { + path: '/player', + permissions: [permissions.PCN_GET_PLAYER_NEW], +}; + +export const PLAYER_EDITOR = { + path: '/player/:playerTypeId/(id)?', + permissions: [permissions.PCN_POST_PLAYER_NEW], +}; + +export const PLAYER_EDITOR_COPY = { + path: '/player/:playerTypeId/new?copy=(id)?', + permissions: [permissions.PCN_POST_PLAYER_NEW], +}; + +export const HUBS_PAGE = { + path: '/hubs', + permissions: [permissions.PCN_GET_PUBLISHER], +}; + +export const HUB_EDITOR = { + path: '/hubs/(id)?', + permissions: [permissions.PCN_GET_PUBLISHER], +}; diff --git a/src/modules/common/router/constants/mapping.ts b/anyclip/src/modules/@common/router/constants/mapping.ts similarity index 100% rename from src/modules/common/router/constants/mapping.ts rename to anyclip/src/modules/@common/router/constants/mapping.ts diff --git a/anyclip/src/modules/@common/router/helpers/getRoutesAllowed.ts b/anyclip/src/modules/@common/router/helpers/getRoutesAllowed.ts new file mode 100644 index 0000000..255ddd0 --- /dev/null +++ b/anyclip/src/modules/@common/router/helpers/getRoutesAllowed.ts @@ -0,0 +1,25 @@ +import * as routes from '../constants'; + +type MappedRouteType = { + path: string; + permissions: string[] | null; +}; + +let mappedRoutes: MappedRouteType[]; + +function getAppRoutes() { + if (!mappedRoutes) { + mappedRoutes = Object.values(routes).map((route) => ({ + ...route, + path: route.path.split('/')[1], + })); + } + + return mappedRoutes; +} + +export default function getRoutesAllowed(permissions: string[]) { + return getAppRoutes() + .filter((route) => route?.permissions?.some((permission: string) => permissions?.includes(permission))) + .map((route) => route.path); +} diff --git a/anyclip/src/modules/@common/storage/constants/index.ts b/anyclip/src/modules/@common/storage/constants/index.ts new file mode 100644 index 0000000..85d8f81 --- /dev/null +++ b/anyclip/src/modules/@common/storage/constants/index.ts @@ -0,0 +1 @@ +export const REDIRECT_URL_STORAGE_NAME = 'REDIRECT_URL_STORAGE_NAME'; diff --git a/anyclip/src/modules/@common/storage/helpers/index.ts b/anyclip/src/modules/@common/storage/helpers/index.ts new file mode 100644 index 0000000..1a59bef --- /dev/null +++ b/anyclip/src/modules/@common/storage/helpers/index.ts @@ -0,0 +1,47 @@ +import { persistPermanentStorageObjects } from './persistPermanentStorageObjects'; + +const browserStorage = typeof window !== 'undefined' ? window.localStorage : null; + +export const setBrowserStorageItem = (item: string, data: string) => browserStorage?.setItem(item, data); + +export const getBrowserStorageItem = (item: string) => browserStorage?.getItem(item) ?? null; + +export const removeBrowserStorageItem = (item: string) => browserStorage?.removeItem(item); + +export const clearBrowserStorage = () => browserStorage?.clear(); + +const storage: { [key: string]: string | null } = {}; + +export const setStorageItem = (item: string, data: string) => { + storage[item] = data; +}; + +export const getStorageItem = (item: string) => storage[item] || getBrowserStorageItem(item) || null; + +export const removeStorageItem = (item: string) => { + storage[item] = null; + removeBrowserStorageItem(item); +}; + +const deleteStorageKeys = () => { + Object.keys(storage).forEach((item) => { + delete storage[item]; + }); + clearBrowserStorage(); +}; + +export const clearStorage = () => { + persistPermanentStorageObjects(deleteStorageKeys); +}; + +const sessionStorage = typeof window !== 'undefined' ? window.sessionStorage : null; + +export const setSessionStorageItem = (item: string, data: string) => sessionStorage?.setItem(item, data); + +export const getSessionStorageItem = (item: string) => sessionStorage?.getItem(item) ?? null; + +export const removeSessionStorageItem = (item: string) => sessionStorage?.removeItem(item); + +export const clearSessionStorage = () => sessionStorage?.clear(); + +export default storage; diff --git a/anyclip/src/modules/@common/storage/helpers/persistPermanentStorageObjects.ts b/anyclip/src/modules/@common/storage/helpers/persistPermanentStorageObjects.ts new file mode 100644 index 0000000..d11d000 --- /dev/null +++ b/anyclip/src/modules/@common/storage/helpers/persistPermanentStorageObjects.ts @@ -0,0 +1,13 @@ +import ssoEmailStorageManager from '@/modules/auth/SsoLogin/helpers/ssoEmailStorageManager'; +import selfServeDisclaimerModalManager from '@/modules/marketplace/accounts/helpers/disclaimerModal'; + +export const persistPermanentStorageObjects = (clearCallback: () => void) => { + const savedSsoEmails = ssoEmailStorageManager.getEmails(); + const selfServeDisclaimerModalInfo = selfServeDisclaimerModalManager.getSelfServeDisclaimerModalShowInfo(); + + clearCallback(); + ssoEmailStorageManager.setEmails(savedSsoEmails); + if (selfServeDisclaimerModalInfo) { + selfServeDisclaimerModalManager.setSelfServeDisclaimerModalShowInfo(); + } +}; diff --git a/anyclip/src/modules/@common/store/helpers.ts b/anyclip/src/modules/@common/store/helpers.ts new file mode 100644 index 0000000..4e85e1f --- /dev/null +++ b/anyclip/src/modules/@common/store/helpers.ts @@ -0,0 +1,14 @@ +export function applyPartial(state: T, patch: Partial, skipKeys: (keyof T)[] = []) { + for (const key in patch) { + if (!skipKeys.includes(key as keyof T) && patch[key] !== undefined) { + state[key as keyof T] = patch[key]!; + } + } +} + +export type GraphQLResponse = { + data: { + [key in T]: EntityList; + }; + errors: unknown[]; +}; diff --git a/anyclip/src/modules/@common/store/hooks.ts b/anyclip/src/modules/@common/store/hooks.ts new file mode 100644 index 0000000..3f948a3 --- /dev/null +++ b/anyclip/src/modules/@common/store/hooks.ts @@ -0,0 +1,7 @@ +import { useDispatch, useSelector, useStore } from 'react-redux'; + +import type { AppDispatch, RootState, StoreType } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); +export const useAppStore = useStore.withTypes(); diff --git a/anyclip/src/modules/@common/store/index.ts b/anyclip/src/modules/@common/store/index.ts new file mode 100644 index 0000000..1554d79 --- /dev/null +++ b/anyclip/src/modules/@common/store/index.ts @@ -0,0 +1,9 @@ +const store: { current: StoreType } = { + current: {} as StoreType, +}; + +export const setExternalStore = (store$: StoreType) => { + store.current = store$; +}; + +export default store; diff --git a/anyclip/src/modules/@common/store/store.ts b/anyclip/src/modules/@common/store/store.ts new file mode 100644 index 0000000..b23c086 --- /dev/null +++ b/anyclip/src/modules/@common/store/store.ts @@ -0,0 +1,29 @@ +import type { Action } from 'redux'; +import { createEpicMiddleware } from 'redux-observable'; +import { configureStore } from '@reduxjs/toolkit'; + +import { setExternalStore } from '@/modules/@common/store/index'; +import rootEpic from '../../../rootEpics'; +import rootReducer from '../../../rootReducer'; + +export type RootState = ReturnType; + +export const epicMiddleware = createEpicMiddleware(); + +const store = configureStore({ + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + // todo: must be removed when project migrate to slices instead of reducers + immutableCheck: false, + serializableCheck: false, + }).concat(epicMiddleware), + devTools: true, +}); + +epicMiddleware.run(rootEpic); + +setExternalStore(store); + +export type StoreType = typeof store; +export type AppDispatch = typeof store.dispatch; diff --git a/anyclip/src/modules/@common/token/constants/index.ts b/anyclip/src/modules/@common/token/constants/index.ts new file mode 100644 index 0000000..77e8ee3 --- /dev/null +++ b/anyclip/src/modules/@common/token/constants/index.ts @@ -0,0 +1,5 @@ +export const TOKEN = 'TOKEN'; +export const IMPERSONATED = 'IMPERSONATED'; +export const URL = 'URL'; +export const TOKEN_COOKIE_NAME = 'tcname'; +export const TOKEN_COOKIE_VALUE = 'tcvalue'; diff --git a/anyclip/src/modules/@common/token/helpers/index.ts b/anyclip/src/modules/@common/token/helpers/index.ts new file mode 100644 index 0000000..0e5f210 --- /dev/null +++ b/anyclip/src/modules/@common/token/helpers/index.ts @@ -0,0 +1,51 @@ +import { IMPERSONATED, TOKEN, TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE, URL } from '../constants'; +import { + ACTIVATION_GUEST_PAGE, + CREATE_PASSWORD_PAGE, + FORGOT_PASSWORD_PAGE, + LOGIN_PAGE, + PASSWORD_LESS_LOGIN_CODE_PAGE, + PASSWORD_LESS_LOGIN_PAGE, + RESET_PASSWORD_PAGE, + SSO_LOGIN_PAGE, + USER_AUTH_ERROR_PAGE, +} from '@/modules/@common/router/constants'; + +import { + getBrowserStorageItem, + getStorageItem, + removeStorageItem, + setBrowserStorageItem, + setStorageItem, +} from '@/modules/@common/storage/helpers'; + +export const setToken = (data: string) => setStorageItem(TOKEN, data); +export const getToken = () => getStorageItem(TOKEN); + +export const setImpersonated = (impersonated: string) => setStorageItem(IMPERSONATED, impersonated); +// todo: remove getBrowserStorage & replace input types +export const getImpersonated = () => getStorageItem(IMPERSONATED) === '1'; + +export const setTokenCookieName = (data: string) => setBrowserStorageItem(TOKEN_COOKIE_NAME, data); +export const getTokenCookieName = () => getBrowserStorageItem(TOKEN_COOKIE_NAME); +export const setTokenCookieValue = (data: string) => setBrowserStorageItem(TOKEN_COOKIE_VALUE, data); +export const getTokenCookieValue = () => getBrowserStorageItem(TOKEN_COOKIE_VALUE); + +export const removeToken = () => removeStorageItem(TOKEN); + +export const getUrl = () => getStorageItem(URL); + +export const removeUrl = () => removeStorageItem(URL); + +export const isLoginPage = (pathname: string) => + [ + LOGIN_PAGE.path, + SSO_LOGIN_PAGE.path, + RESET_PASSWORD_PAGE.path, + CREATE_PASSWORD_PAGE.path, + FORGOT_PASSWORD_PAGE.path, + ACTIVATION_GUEST_PAGE.path, + PASSWORD_LESS_LOGIN_PAGE.path, + PASSWORD_LESS_LOGIN_CODE_PAGE.path, + USER_AUTH_ERROR_PAGE.path, + ].some((path) => pathname.includes(path)); diff --git a/anyclip/src/modules/@common/token/redux/epics/cancelImpersonation.ts b/anyclip/src/modules/@common/token/redux/epics/cancelImpersonation.ts new file mode 100644 index 0000000..c6e7be1 --- /dev/null +++ b/anyclip/src/modules/@common/token/redux/epics/cancelImpersonation.ts @@ -0,0 +1,82 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, Observable, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { USERS_PAGE } from '@/modules/@common/router/constants'; +import { TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE } from '@/modules/@common/token/constants'; +import { AUTH_URL } from '@/modules/auth/common/constants/auth'; + +import { cancelImpersonationAction, logOutEventAction } from '../slices'; +import { clearStorage } from '@/modules/@common/storage/helpers'; +import { getToken, setTokenCookieName, setTokenCookieValue } from '@/modules/@common/token/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +type ResponseType = { + cookieName: string; + cookieValue: string; +}; + +export default (action$: Observable) => + action$.pipe( + ofType(cancelImpersonationAction.type), + switchMap(() => { + const adminToken = getToken(); + + const stream$: Observable = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/private/users/cancelImpersonation`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: getToken(), + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + adminToken, + }), + }).pipe( + switchMap(({ response }) => { + clearStorage(); + + if (response?.cookieName) { + setTokenCookieName(response.cookieName); + setTokenCookieValue(response.cookieValue); + } + + return ajax({ + method: 'POST', + url: `${AUTH_URL}`, + crossDomain: true, + withCredentials: true, + body: { + [TOKEN_COOKIE_NAME]: response?.cookieName, + [TOKEN_COOKIE_VALUE]: response?.cookieValue, + }, + }).pipe( + switchMap(() => { + window.location.replace(`${window.location.origin}${USERS_PAGE.path}`); + + return EMPTY; + }), + ); + }), + catchError(({ response }) => + concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + of(logOutEventAction()), + ), + ), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/@common/token/redux/epics/index.ts b/anyclip/src/modules/@common/token/redux/epics/index.ts new file mode 100644 index 0000000..e6286ef --- /dev/null +++ b/anyclip/src/modules/@common/token/redux/epics/index.ts @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import cancelImpersonation from './cancelImpersonation'; +import loggedInEpic from './loggedIn'; +import logOutEpic from './logOut'; + +export default combineEpics(logOutEpic, loggedInEpic, cancelImpersonation); diff --git a/anyclip/src/modules/@common/token/redux/epics/logOut.ts b/anyclip/src/modules/@common/token/redux/epics/logOut.ts new file mode 100644 index 0000000..42a9c90 --- /dev/null +++ b/anyclip/src/modules/@common/token/redux/epics/logOut.ts @@ -0,0 +1,77 @@ +import Router from 'next/router'; +import { ofType, StateObservable } from 'redux-observable'; +import { concat, EMPTY, Observable, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { exhaustMap, filter, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { LOGIN_PAGE } from '@/modules/@common/router/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; + +import { getToken, isLoginPage } from '../../helpers'; +import { logOutEventAction } from '../slices'; +import { clearStorage, setSessionStorageItem } from '@/modules/@common/storage/helpers'; +import { getUserAccountSelector } from '@/modules/@common/user/redux/selectors'; + +const clearLocalStorage = () => { + clearStorage(); +}; + +type LogOutEventType = { + payload: { + logOutPage: string; + logoutFrom: string; + }; +}; + +export default (action$: Observable, state$: unknown) => + action$.pipe( + ofType(logOutEventAction.type), + filter(() => !isLoginPage(Router.pathname) && !window.ac_redirecting), + exhaustMap(({ payload: logOutEvent }: LogOutEventType) => { + const state$$ = state$ as StateObservable; + const userAccount = getUserAccountSelector(state$$.value); + const accountCustomLoginPage = userAccount?.customLoginPageUrl; + + const token = getToken(); + + clearLocalStorage(); + + const authStream = ajax({ + method: 'POST', + url: '/api/auth/logout', + crossDomain: true, + withCredentials: true, + headers: { + Authorization: token, + }, + }).pipe( + switchMap(() => { + const pushLink = window.location.href; + const isLogOutRedirect = !!logOutEvent?.logOutPage; + + const paramsHistory = { + pathname: LOGIN_PAGE.path, + from: isLogOutRedirect ? logOutEvent.logoutFrom : pushLink, + }; + + if (accountCustomLoginPage) { + // prevent double logout execution + window.ac_redirecting = true; + + window.location.replace(`${accountCustomLoginPage}?olp=${encodeURIComponent(paramsHistory.from)}`); + } else if (isLogOutRedirect) { + setSessionStorageItem(REDIRECT_URL_STORAGE_NAME, paramsHistory.from); + window.location.replace(`${paramsHistory.pathname}?redirectTo=${paramsHistory.from}`); + } else { + setSessionStorageItem(REDIRECT_URL_STORAGE_NAME, paramsHistory.from); + window.location.replace(paramsHistory.pathname); + } + + return EMPTY; + }), + ); + + return concat(of({ type: 'RESET_STORE' }), authStream); + }), + ); diff --git a/anyclip/src/modules/@common/token/redux/epics/loggedIn.ts b/anyclip/src/modules/@common/token/redux/epics/loggedIn.ts new file mode 100644 index 0000000..a5c4898 --- /dev/null +++ b/anyclip/src/modules/@common/token/redux/epics/loggedIn.ts @@ -0,0 +1,20 @@ +import Router from 'next/router'; +import { parse, stringify } from 'qs'; +import { ofType } from 'redux-observable'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { loggedInEventAction } from '../slices'; + +export default (action$: Observable) => + action$.pipe( + ofType(loggedInEventAction.type), + tap(() => { + const url = parse(window.location.search, { ignoreQueryPrefix: true }); + + delete url.token; + + Router.push(`${window.location.pathname}?${stringify(url)}`); + }), + ); diff --git a/anyclip/src/modules/@common/token/redux/slices/index.ts b/anyclip/src/modules/@common/token/redux/slices/index.ts new file mode 100644 index 0000000..32350ec --- /dev/null +++ b/anyclip/src/modules/@common/token/redux/slices/index.ts @@ -0,0 +1,23 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +const initialState = {}; + +export const slice = createSlice({ + name: '@@COMMON/TOKEN', + initialState, + + reducers: { + logOutEventAction: (state, action: PayloadAction<{ logOutPage?: boolean; logoutFrom?: string } | undefined>) => { + if (action.payload) { + return state; + } + return state; + }, + loggedInEventAction: (state) => state, + cancelImpersonationAction: (state) => state, + }, +}); + +export const { cancelImpersonationAction, loggedInEventAction, logOutEventAction } = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/user/constants/index.ts b/anyclip/src/modules/@common/user/constants/index.ts new file mode 100644 index 0000000..8b518a1 --- /dev/null +++ b/anyclip/src/modules/@common/user/constants/index.ts @@ -0,0 +1,3 @@ +export const USER_ENV = 'USER_ENV'; +export const USER_PREFERENCES = 'USER_PREFERENCES'; +export const USER_UPDATED_HEADER = 'User-Updated'; diff --git a/anyclip/src/modules/@common/user/constants/roles.ts b/anyclip/src/modules/@common/user/constants/roles.ts new file mode 100644 index 0000000..3301767 --- /dev/null +++ b/anyclip/src/modules/@common/user/constants/roles.ts @@ -0,0 +1 @@ +export const HUB_ADMIN_ROLE = 'hub_admin'; diff --git a/anyclip/src/modules/@common/user/constants/rolesType.ts b/anyclip/src/modules/@common/user/constants/rolesType.ts new file mode 100644 index 0000000..ac902cf --- /dev/null +++ b/anyclip/src/modules/@common/user/constants/rolesType.ts @@ -0,0 +1,19 @@ +/* + User which don't have account and is not part of anyclip stuff. + */ +export const EXTERNAL = 'EXTERNAL'; + +/* + User which don't have account, but is part of anyclip stuff (admin, adops) + */ +export const INTERNAL = 'INTERNAL'; + +/* + Is account associated (custom accounts roles are going to have account id) + */ +export const ACCOUNT = 'ACCOUNT'; + +/* + Is account associated + */ +export const API = 'API'; diff --git a/anyclip/src/modules/@common/user/helpers/index.ts b/anyclip/src/modules/@common/user/helpers/index.ts new file mode 100644 index 0000000..081c526 --- /dev/null +++ b/anyclip/src/modules/@common/user/helpers/index.ts @@ -0,0 +1,58 @@ +import { parse, stringify } from 'qs'; + +import { USER_PREFERENCES } from '../constants'; + +import { ContentOwnerType } from '@/modules/@common/user/types'; + +import { getStorageItem, removeStorageItem, setBrowserStorageItem } from '@/modules/@common/storage/helpers'; + +export const removeUser = () => { + removeStorageItem(USER_PREFERENCES); +}; + +export const setUser = (token: string) => { + const postData = { + type: 'ac_editor_login', + token, + }; + + if (typeof window !== 'undefined') { + window.parent.postMessage(postData, '*'); + } +}; + +//---------------------- + +export const hasPermission = (permission: string, permissions: string[]) => permissions.includes(permission); + +export const isOwnContent = (contentOwnerId: string, userContentOwners: ContentOwnerType[]) => + userContentOwners.some((item) => item.contentOwnerId === contentOwnerId && item.publisherOwnsContent); + +export const getUserPreferences = () => JSON.parse(getStorageItem(USER_PREFERENCES) as string); + +export const setUserPreferences = (nameSpace: string, params: { [key: string]: string }) => { + const preferences = getUserPreferences(); + setBrowserStorageItem( + USER_PREFERENCES, + JSON.stringify({ + ...preferences, + [nameSpace]: params, + }), + ); +}; + +const getUrl = (pathname: string, search: string, hash: string) => { + const url = parse(search, { ignoreQueryPrefix: true }); + + delete url.token; + + const questionMark = stringify(url) ? '?' : ''; + + return `${pathname}${questionMark}${stringify(url)}${hash}`; +}; + +export const getUserRedirectUrl = () => { + const { pathname, search, hash } = window.location; + + return pathname === '/' ? '/' : getUrl(pathname, search, hash); +}; diff --git a/anyclip/src/modules/@common/user/helpers/initUser.ts b/anyclip/src/modules/@common/user/helpers/initUser.ts new file mode 100644 index 0000000..d265c90 --- /dev/null +++ b/anyclip/src/modules/@common/user/helpers/initUser.ts @@ -0,0 +1,37 @@ +import { UserType } from '@/modules/@common/user/types'; + +import { setUserDataAction } from '../redux/slices'; +import { LanguageType, setVideoLangs } from '@/modules/@common/helpers/videoLangs'; +import { setIAB } from '@/modules/@common/iab/helpers'; +import { setImpersonated, setToken } from '@/modules/@common/token/helpers'; + +import store from '@/modules/@common/store'; + +type AuthDataType = { + user: { + iab: { + data: string; + }; + videoLangs: LanguageType[]; + user: UserType; + }; + token: string; + impersonated: boolean; +}; + +async function initUser(authData: AuthDataType) { + const { + iab: { data: iab }, + videoLangs, + } = authData.user; + + setIAB(iab); + setVideoLangs(videoLangs); + + store.current.dispatch(setUserDataAction(authData.user.user)); + + setToken(authData.token); + setImpersonated(authData.impersonated ? '1' : ''); +} + +export default initUser; diff --git a/anyclip/src/modules/@common/user/redux/epics/getUserData.ts b/anyclip/src/modules/@common/user/redux/epics/getUserData.ts new file mode 100644 index 0000000..c6dc8e6 --- /dev/null +++ b/anyclip/src/modules/@common/user/redux/epics/getUserData.ts @@ -0,0 +1,40 @@ +import { ofType } from 'redux-observable'; +import { concat, Observable, of } from 'rxjs'; +import { exhaustMap, filter, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { UserType } from '@/modules/@common/user/types'; + +import { getUserDataAction, setUserDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { logOutEventAction } from '@/modules/@common/token/redux/slices'; + +import { userInit as userQuery } from '../../../gql/queries'; + +const getResponse = ({ data: { user } }: { data: { user: UserType } }) => user; + +export default (action$: Observable) => + action$.pipe( + ofType(getUserDataAction.type), + filter(() => !!getToken()), + exhaustMap(() => + gqlRequest({ + query: userQuery, + }).pipe( + switchMap((data) => { + const response = getResponse(data); + + const actions = []; + + if (response) { + actions.push(of(setUserDataAction(response))); + } else { + actions.push(of(logOutEventAction())); + } + + return concat(...actions); + }), + ), + ), + ); diff --git a/anyclip/src/modules/@common/user/redux/epics/index.ts b/anyclip/src/modules/@common/user/redux/epics/index.ts new file mode 100644 index 0000000..5a3a764 --- /dev/null +++ b/anyclip/src/modules/@common/user/redux/epics/index.ts @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import getUserData from './getUserData'; + +export default combineEpics(getUserData); diff --git a/anyclip/src/modules/@common/user/redux/selectors/index.ts b/anyclip/src/modules/@common/user/redux/selectors/index.ts new file mode 100644 index 0000000..dc8fd2a --- /dev/null +++ b/anyclip/src/modules/@common/user/redux/selectors/index.ts @@ -0,0 +1,44 @@ +import { UserRoleType } from '@/modules/@common/user/types'; + +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const getUserDataSelector = (state: RootState) => state[nameSpace].userData ?? {}; + +export const getUserPermissionsSelector = (state: RootState) => state[nameSpace].userData?.permissions ?? []; + +export const getUserRoleSelector = (state: RootState) => state[nameSpace].userData?.role ?? ({} as UserRoleType); + +export const getUserIdSelector = (state: RootState) => state[nameSpace].userData?.id ?? null; +export const getUserTimezoneSelector = (state: RootState) => state[nameSpace].userData?.timezone; +export const getUserFirstNameSelector = (state: RootState) => state[nameSpace].userData?.firstName ?? 'User'; +export const getUserLastNameSelector = (state: RootState) => state[nameSpace].userData?.lastName ?? ''; +export const getUserFullNameSelector = (state: RootState) => + `${getUserFirstNameSelector(state)} ${getUserLastNameSelector(state)}`; +export const getUserEmailSelector = (state: RootState) => state[nameSpace].userData?.email ?? null; + +export const getUserUpdatedTime = (state: RootState) => state[nameSpace].userData?.updateDate ?? null; + +export const getUserContentOwnersSelector = (state: RootState) => state[nameSpace].userData?.contentOwners ?? []; +export const getUserContentOwnerIdsSelector = (state: RootState) => + state[nameSpace].userData?.contentOwners?.map(({ contentOwnerId }) => contentOwnerId) ?? []; + +export const getUserAccountSelector = (state: RootState) => state[nameSpace].userData?.account ?? null; +export const getUserAccountIdSelector = (state: RootState) => state[nameSpace].userData?.accountId ?? null; + +export const getPublisherIdsSelector = (state: RootState) => state[nameSpace].userData?.publisherIds ?? null; + +export const getPredefinedContentOwnerSelector = (state: RootState) => + state[nameSpace].userData?.contentOwnerId ?? null; + +export const getOwnsContentSelector = (state: RootState) => + getUserContentOwnersSelector(state) + .filter((owner) => owner.publisherOwnsContent) + .map((owner) => owner.contentOwnerId); + +export const getUserRoleTypeSelector = (state: RootState) => getUserRoleSelector(state)?.type ?? null; + +export const getIntercomUserHashSelector = (state: RootState) => state[nameSpace].userData?.intercomUserHash ?? null; + +export const userDataForWatch = (state: RootState) => state[nameSpace].userData; diff --git a/anyclip/src/modules/@common/user/redux/slices/index.ts b/anyclip/src/modules/@common/user/redux/slices/index.ts new file mode 100644 index 0000000..dd21621 --- /dev/null +++ b/anyclip/src/modules/@common/user/redux/slices/index.ts @@ -0,0 +1,32 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +import { UserType } from '@/modules/@common/user/types'; + +type StateType = { + lastUpdate?: string | null; + isUserActivated: boolean; + userData?: UserType | null; +}; + +const initialState: StateType = { + lastUpdate: null, + isUserActivated: false, + userData: null, +}; + +export const slice = createSlice({ + name: '@@COMMON/USER', + initialState, + + reducers: { + getUserDataAction: (state) => state, + setUserDataAction: (state, action: PayloadAction) => { + state.userData = action.payload; + }, + }, +}); + +export const { getUserDataAction, setUserDataAction } = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/@common/watch/constants/index.ts b/anyclip/src/modules/@common/watch/constants/index.ts new file mode 100644 index 0000000..cc7a346 --- /dev/null +++ b/anyclip/src/modules/@common/watch/constants/index.ts @@ -0,0 +1,7 @@ +export const WATCH_SHARED_DATA_KEY = '__WATCH_SHARED_DATA__'; +export const WATCH_TRIGGER_FUNCTION_KEY = '$$__AC_WATCH_MAIN_TRIGGER_FUNCTION__$$'; +export const IFRAME_PREVIEW_CONFIG_OVERRIDE = '__IFRAME_PREVIEW_CONFIG_OVERRIDE__'; +export const TOKEN_KEY = 'TOKEN'; +export const USER_KEY = 'USER'; + +export default {}; diff --git a/anyclip/src/modules/@common/watch/helpers/index.ts b/anyclip/src/modules/@common/watch/helpers/index.ts new file mode 100644 index 0000000..34a1418 --- /dev/null +++ b/anyclip/src/modules/@common/watch/helpers/index.ts @@ -0,0 +1,16 @@ +import { TOKEN_KEY, USER_KEY, WATCH_SHARED_DATA_KEY } from '@/modules/@common/watch/constants'; + +import { UserType } from '@/modules/@common/user/types'; + +export const setInternalWatchData = ({ token, user }: { token: string; user: UserType }) => ` + window.${WATCH_SHARED_DATA_KEY} = { + ${TOKEN_KEY}: '${token}', + ${USER_KEY}: ${JSON.stringify(user) + .replace(//g, '\\u003e') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029')} + } + `; + +export default {}; diff --git a/anyclip/src/modules/accounts/Editor/components/Editor.jsx b/anyclip/src/modules/accounts/Editor/components/Editor.jsx new file mode 100644 index 0000000..c2b9076 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Editor.jsx @@ -0,0 +1,269 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; +import { DeleteOutlined } from '@mui/icons-material'; + +import { TAB_CONTENT, TAB_DASHBOARDS, TAB_DETAILS, TAB_FEATURES } from '../constants'; +import { ACCOUNT_BULK_DELETE_VIDEOS } from '@/modules/@common/acl/constants'; +import { ACCOUNT_TYPE_PUBLISHER } from '@/modules/accounts/List/constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + deleteAllVideos, + getDataAction as getContentOwnersDataAction, + getItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import ContentTab from './Tabs/ContentTab/ContentTab'; +import DashboardsTab from './Tabs/DashboardsTab/DashboardsTab'; +import DetailsTab from './Tabs/DetailsTab/DetailsTab'; +import FeaturesTab from './Tabs/FeaturesTab/FeaturesTab'; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + Tab, + TabContent, + Tabs, + Typography, +} from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const dispatch = useDispatch(); + const router = useRouter(); + const store = useStore(); + + const [isShowDeleteVideoConfirm, setIsShowDeleteVideoConfirm] = useState(false); + const [isConfirmDeleteVideo, setIsConfirmDeleteVideo] = useState(false); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const featuresUi = useSelector(selectors.featuresUiSelector); + const dashboardGeneral = useSelector(selectors.dashboardsGeneralSelector); + const dashboardCustom = useSelector(selectors.dashboardsCustomSelector); + const name = useSelector(selectors.nameSelector); + const type = useSelector(selectors.typeSelector); + + const { id } = router.query; + + const canDeleteAllVideos = hasPermission(ACCOUNT_BULK_DELETE_VIDEOS, userPermissions) && id !== 'new'; + + const tabs = [ + { + title: 'General', + id: TAB_DETAILS, + content: DetailsTab, + }, + { + title: 'Features', + id: TAB_FEATURES, + content: FeaturesTab, + disabled: !featuresUi?.length, + }, + { + title: 'Dashboards', + id: TAB_DASHBOARDS, + content: DashboardsTab, + disabled: !(dashboardGeneral?.length || dashboardCustom?.length), + }, + { + title: 'Content', + id: TAB_CONTENT, + content: ContentTab, + disabled: id === 'new', + }, + ]; + + const handleCloseDeleteConfirm = () => { + setIsShowDeleteVideoConfirm(false); + setIsConfirmDeleteVideo(false); + }; + + const handleDeleteAllVideo = () => { + dispatch(deleteAllVideos(id)); + handleCloseDeleteConfirm(); + }; + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .filter(({ fieldName }) => { + if (type !== ACCOUNT_TYPE_PUBLISHER) { + return !['publisherRevShare', 'expenses'].includes(fieldName); + } + + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id !== 'new') { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + useEffect(() => { + dispatch(setInitialAction()); + dispatch(getItemAction(id)); + if (id !== 'new') { + dispatch(getContentOwnersDataAction(id)); + } + }, [id]); + + useEffect(() => () => dispatch(setInitialAction()), []); + + return ( +
+ + + {id === 'new' ? 'New Account' : `${name} > Settings`} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + {canDeleteAllVideos && ( + <> + + + + )} + + + + + + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + {tab.id !== TAB_CONTENT && ( + + + + )} + {tab.id === TAB_CONTENT && } + + ); + })} + + + + {isShowDeleteVideoConfirm && ( + + Video Delete Confirmation + + + + Are you sure you want to delete all videos for {name} account? + + + This action will delete all video files for this account and cannot be undone. + + + + + + + + Confirm + + setIsConfirmDeleteVideo(!isConfirmDeleteVideo)} + /> + + + + + + + + + )} +
+ ); +} + +export default Editor; diff --git a/anyclip/src/modules/accounts/Editor/components/Editor.module.scss b/anyclip/src/modules/accounts/Editor/components/Editor.module.scss new file mode 100644 index 0000000..3fa9428 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__bVK4H","Title":"Editor_Title__iym3p","Controls":"Editor_Controls__JBvgC","Tabs":"Editor_Tabs__8uEtQ","ConfirmCheckbox":"Editor_ConfirmCheckbox__lbYEk"}; \ No newline at end of file diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.jsx b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.jsx new file mode 100644 index 0000000..fb8cf07 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.jsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TABLE_HEADERS } from '../../../constants'; + +import * as selectors from '../../../redux/selectors'; +import { getDataAction, setTableAction, updateContentOwner } from '../../../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonTable from '@/modules/@common/Table'; +import Row from './components/Row/Row'; + +import styles from './ContentTab.module.scss'; + +function Table() { + const dispatch = useDispatch(); + const router = useRouter(); + + const [editRowData, setEditRowData] = useState({ + id: null, + salesforceId: null, + status: null, + isPublic: null, + comments: null, + callToAction: null, + }); + + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + + const { id } = router.query; + + const handleFilter = (filter) => { + const { page: page$, pageSize: pageSize$ } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + page: page$, + pageSize: pageSize$, + }), + ), + ); + + dispatch(getDataAction(id)); + }; + + const handleEditRow = (rowData) => setEditRowData((prevRowData) => ({ ...prevRowData, ...rowData })); + + const handleUpdateRow = () => { + dispatch( + updateContentOwner({ + ...editRowData, + accountId: id, + }), + ); + }; + + useEffect(() => { + handleEditRow({ id: null }); + }, [data]); + + return ( +
+ ( + + )} + data={data || []} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onFilter={handleFilter} + /> +
+ ); +} + +export default Table; diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.module.scss b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.module.scss new file mode 100644 index 0000000..3749cf9 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/ContentTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Root":"ContentTab_Root__Z9YKU"}; \ No newline at end of file diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.jsx b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.jsx new file mode 100644 index 0000000..a17037e --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.jsx @@ -0,0 +1,135 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { CheckOutlined, ClearOutlined, EditOutlined } from '@mui/icons-material'; + +import { INTERNAL } from '@/modules/@common/user/constants/rolesType'; + +import { getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; + +import { IconButton, MenuItem, Select, Stack, Switch, TableCell, TableRow, TextField } from '@/mui/components'; + +import styles from './Row.module.scss'; + +const ACCESS_LEVEL_OPTIONS = [ + { label: 'Hub', value: 0 }, + { label: 'Syndication', value: 1 }, +]; + +const SALES_FORCE_ID_MAX_LENGTH = 15; + +function Row(props) { + const isEditMode = props.editRowData.id && props.editRowData.id === props.row.id; + const isInternalUser = useSelector(getUserRoleTypeSelector) === INTERNAL; + + const handleOpenEditMode = () => props.onEditRowData(props.row); + const handleCloseEditMode = () => props.onEditRowData({ id: null }); + const handleUpdateRow = () => props.onUpdateRow(); + + return ( + + +
{props.row.name}
+
+ +
+ {!isEditMode && props.row.salesforceId} + {isEditMode && ( + props.onEditRowData({ salesforceId: e.target.value })} + /> + )} +
+
+ +
+ + props.onEditRowData({ + status: e.target.checked ? 1 : 0, + }) + } + /> +
+
+ +
+ {!isEditMode && <>{`${props.row.isPublic ? 'Syndication' : 'Hub'}`}} + {isEditMode && ( + + )} +
+
+ +
+ {!isEditMode && props.row.comments} + {isEditMode && ( + props.onEditRowData({ comments: e.target.value })} + /> + )} +
+
+ +
+ + props.onEditRowData({ + callToAction: e.target.checked ? 1 : 0, + }) + } + /> +
+
+ + {isInternalUser && ( +
+ {!isEditMode && ( + + + + )} + + {isEditMode && ( + + + + + + + + + )} +
+ )} +
+
+ ); +} + +export default Row; diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.module.scss b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.module.scss new file mode 100644 index 0000000..f3571fc --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/ContentTab/components/Row/Row.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"NoWrap":"Row_NoWrap__g9_HK"}; \ No newline at end of file diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.jsx b/anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.jsx new file mode 100644 index 0000000..739849e --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.jsx @@ -0,0 +1,207 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { DragIndicatorRounded, InfoOutlined } from '@mui/icons-material'; + +import * as selectors from '../../../redux/selectors'; +import { setAction } from '../../../redux/slices'; + +import SortableItem from '@/modules/@common/dnd/SortableItem/SortableItem'; +import { FormGroup, FormGroupTitle, FormRowItem, useFormSettings } from '@/modules/@common/Form'; +import { IconButton, Link, Paper, Stack, Switch, Tooltip, Typography } from '@/mui/components'; + +import styles from './DashboardsTab.module.scss'; + +const DASHBOARDS_GENERAL_TYPE = 'dashboardsGeneral'; +const DASHBOARDS_CUSTOM_TYPE = 'dashboardsCustom'; + +function TooltipEnhanced({ tooltip }) { + if (!tooltip?.info) return null; + + const { info, link, linkTitle } = tooltip; + + return ( + + {info} + {link && ( + + {` ${linkTitle}`} + + )} +
+ } + > + + + ); +} + +TooltipEnhanced.propTypes = { + tooltip: PropTypes.shape({ + info: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + linkTitle: PropTypes.string.isRequired, + }), +}; + +function DashboardSection(props) { + const { size } = useFormSettings(); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + if (!props.dashboards.length) { + return null; + } + + return ( + <> + + + {props.title} + + + + + + + props.onDragEnd(event, props.type)} + > + dashboard.id)} + strategy={verticalListSortingStrategy} + > + {props.dashboards.map((dashboard) => ( + + {(sortableItemProps) => ( + + + + + + + + + props.onChange({ + id: dashboard.id, + value: target.checked, + type: props.type, + }) + } + /> + + {dashboard.uiName} + + + + + + + )} + + ))} + + + + + ); +} + +DashboardSection.propTypes = { + title: PropTypes.string, + dashboards: PropTypes.arrayOf( + PropTypes.shape({ + enabled: PropTypes.bool, + id: PropTypes.string.isRequired, + checked: PropTypes.bool, + type: PropTypes.string, + uiName: PropTypes.string, + tooltip: PropTypes.shape({ + info: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + linkTitle: PropTypes.string.isRequired, + }), + }), + ).isRequired, + onChange: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, + onDragEnd: PropTypes.func.isRequired, +}; + +function DashboardsTab() { + const dispatch = useDispatch(); + const dashboardGeneral = useSelector(selectors.dashboardsGeneralSelector); + const dashboardCustom = useSelector(selectors.dashboardsCustomSelector); + + const dashboardsMapByType = { + [DASHBOARDS_GENERAL_TYPE]: dashboardGeneral, + [DASHBOARDS_CUSTOM_TYPE]: dashboardCustom, + }; + + const handleChange = ({ id, value, type }) => { + const dashboards = dashboardsMapByType[type]; + const updatedDashboards = dashboards.map((entry) => (entry.id === id ? { ...entry, enabled: value } : entry)); + + dispatch(setAction({ [type]: updatedDashboards })); + }; + + const handleDragEnd = ({ active, over }, type) => { + const dashboards = dashboardsMapByType[type]; + const findIndexById = (id) => dashboards.findIndex((item) => item.id === id); + + const prevIndex = findIndexById(active.id); + const newIndex = findIndexById(over.id); + + if (prevIndex === -1 || newIndex === -1) return; + + const reorderedDashboards = arrayMove(dashboards, prevIndex, newIndex).map((entity, index) => ({ + ...entity, + order: index + 1, + })); + + dispatch(setAction({ [type]: reorderedDashboards })); + }; + + return ( + <> + + + + ); +} + +export default DashboardsTab; diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.module.scss b/anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.module.scss new file mode 100644 index 0000000..ee58e95 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/DashboardsTab/DashboardsTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"PaperWrapper":"DashboardsTab_PaperWrapper__hBB4Y","ItemWrapper":"DashboardsTab_ItemWrapper__Affik"}; \ No newline at end of file diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.jsx b/anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.jsx new file mode 100644 index 0000000..bac033d --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.jsx @@ -0,0 +1,429 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import CachedIcon from '@mui/icons-material/Cached'; + +import { + ACCOUNT_OPTIONS, + ACCOUNT_TYPE_ALL, + ACCOUNT_TYPE_DEMAND, + ACCOUNT_TYPE_PUBLISHER, + FILTER_VIDEO_DUPLICATES_BY_OPTIONS, +} from '../../../../List/constants'; +import { FEE_BUSINESS_MODEL_IMPRESSIONS_CPM, FEE_BUSINESS_MODEL_OPTIONS } from '../../../constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import * as selectors from '../../../redux/selectors'; +import { getSalesforceDataAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { FormImageUploader, FormRow, useFormSettings } from '@/modules/@common/Form'; +import { CircularProgress, IconButton, InputAdornment, MenuItem, Select, Switch, TextField } from '@/mui/components'; +import { CustomSalesForce } from '@/mui/components/CustomIcon'; + +import styles from './DetailsTab.module.scss'; + +const accountOptions = ACCOUNT_OPTIONS.filter((account) => account.value !== ACCOUNT_TYPE_ALL); + +const FIELD_CUSTOM_LOADING_MAX_LENGTH_NAME = 15; +const LOGO_MAX_SIZE = 1024 * 1024; // 1 MB +const MAX_WIDTH = 1000; +const MAX_HEIGHT = 1000; + +async function validateImageDimensions(file) { + return new Promise((resolve) => { + const img = new Image(); + const reader = new FileReader(); + + reader.onload = (e) => { + img.src = e.target.result; + }; + + img.onload = () => { + const width = img.naturalWidth; + const height = img.naturalHeight; + const isValid = !(width >= MAX_WIDTH && height >= MAX_HEIGHT); + resolve(isValid); + }; + + reader.readAsDataURL(file); + }); +} + +function DetailsTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const salesforceId = useSelector(selectors.salesforceIdSelector); + const salesforceInstanceUrl = useSelector(selectors.salesforceInstanceUrlSelector); + const isSalesforceDataLoading = useSelector(selectors.isSalesforceDataLoadingSelector); + const name = useSelector(selectors.nameSelector); + const salesManager = useSelector(selectors.salesManagerSelector); + const accountManager = useSelector(selectors.accountManagerSelector); + const accountManagerEmail = useSelector(selectors.accountManagerEmailSelector); + const type = useSelector(selectors.typeSelector); + const avatarUrl = useSelector(selectors.avatarUrlSelector); + const customLogoUrl = useSelector(selectors.customLogoUrlSelector); + const customLoadingMessage = useSelector(selectors.customLoadingMessageSelector); + const customLoginPageUrl = useSelector(selectors.customLoginPageUrlSelector); + const subdomain = useSelector(selectors.subdomainSelector); + const publisherRevShare = useSelector(selectors.publisherRevShareSelector); + const expenses = useSelector(selectors.expensesSelector); + const usersLimit = useSelector(selectors.usersLimitSelector); + const videoDuplicatesBy = useSelector(selectors.videoDuplicatesBy); + + const publisherDemand = useSelector(selectors.publisherDemandSelector); + const adServingFeeBusinessModel = useSelector(selectors.adServingFeeBusinessModelSelector); + const adServingFeeDisplay = useSelector(selectors.adServingFeeDisplaySelector); + const adServingFeeVideo = useSelector(selectors.adServingFeeVideoSelector); + + const scheme = useSelector(selectors.schemeSelector); + + const id = parseInt(router.query.id, 10); + const isReadOnly = !!id; + + const handleSetState = (state) => dispatch(setAction(state)); + const handleGetSalesforceDataAction = () => dispatch(getSalesforceDataAction()); + const handleGotoSalesforceAccountPage = () => { + const instanceUrlPrefix = salesforceInstanceUrl.slice(0, salesforceInstanceUrl.indexOf('.')); + window.open(`${instanceUrlPrefix}.lightning.force.com/lightning/r/Account/${salesforceId}/view`); + }; + + return ( + <> + + + handleSetState({ + salesforceId: e.target.value, + name: '', + salesManager: '', + accountManager: '', + accountManagerEmail: '', + type: '', + accountDashboards: null, + }) + } + InputProps={{ + endAdornment: ( + + + {isSalesforceDataLoading ? : } + + + ), + }} + {...getInputPropsByName(scheme, ['salesforceId'])} + onFocus={() => dispatch(removeErrorByPropAction(['salesforceId']))} + /> + + + + + + + + ), + }} + onChange={(e) => handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + {type === ACCOUNT_TYPE_DEMAND && ( + <> + + + handleSetState({ + publisherDemand: e.target.checked, + }) + } + /> + + + {publisherDemand && ( + <> + + + + + + handleSetState({ adServingFeeVideo: parseFloat(e.target.value) })} + {...getInputPropsByName(scheme, ['adServingFeeVideo'])} + onFocus={() => dispatch(removeErrorByPropAction(['adServingFeeVideo']))} + /> + + + + handleSetState({ adServingFeeDisplay: parseFloat(e.target.value) })} + {...getInputPropsByName(scheme, ['adServingFeeDisplay'])} + onFocus={() => dispatch(removeErrorByPropAction(['adServingFeeDisplay']))} + /> + + + )} + + )} + + handleSetState({ salesManager: e.target.value })} + /> + + + handleSetState({ accountManager: e.target.value })} + /> + + + handleSetState({ accountManagerEmail: e.target.value })} + /> + + + + + + { + const allowedFormats = ['image/jpeg', 'image/png']; + + // Check image dimensions + const isValidDimensions = await validateImageDimensions(file); + if (!isValidDimensions) { + dispatch( + showNotificationAction({ + key: 'Invalid image dimensions', + type: TYPE_ERROR, + message: `File has the wrong dimensions. It must be less than ${MAX_WIDTH}px by ${MAX_HEIGHT}px`, + }), + ); + return false; + } + + if (!allowedFormats.includes(file.type)) { + dispatch( + showNotificationAction({ + key: 'The file type is not supported', + type: TYPE_ERROR, + message: 'The file type is not supported', + }), + ); + return false; + } + + if (file.size >= LOGO_MAX_SIZE) { + dispatch( + showNotificationAction({ + key: 'File is too large!', + type: TYPE_ERROR, + message: 'File is too large! Max size is 1Mb', + }), + ); + return false; + } + + return true; + }} + onLoad={(event, file, fileResult) => { + handleSetState({ + avatarUrl: fileResult, + avatar: { + base64Url: fileResult, + mimeType: file.type, + }, + }); + }} + onError={(event, file, error) => { + dispatch( + showNotificationAction({ + key: error, + type: TYPE_ERROR, + message: error, + }), + ); + }} + onRemove={() => { + handleSetState({ + avatarUrl: '', + avatar: { base64Url: '', mimeType: '' }, + }); + }} + /> + + + handleSetState({ customLogoUrl: e.target.value })} + {...getInputPropsByName(scheme, ['customLogoUrl'])} + onFocus={() => dispatch(removeErrorByPropAction(['customLogoUrl']))} + label="" + /> + + + handleSetState({ customLoadingMessage: e.target.value })} + inputProps={{ maxLength: FIELD_CUSTOM_LOADING_MAX_LENGTH_NAME }} + /> + + + handleSetState({ customLoginPageUrl: e.target.value })} + {...getInputPropsByName(scheme, ['customLoginPageUrl'])} + onFocus={() => dispatch(removeErrorByPropAction(['customLoginPageUrl']))} + label="" + /> + + + handleSetState({ subdomain: e.target.value })} + /> + + {type === ACCOUNT_TYPE_PUBLISHER && ( + <> + + handleSetState({ publisherRevShare: e.target.value })} + {...getInputPropsByName(scheme, ['publisherRevShare'])} + onFocus={() => dispatch(removeErrorByPropAction(['publisherRevShare']))} + /> + + + handleSetState({ expenses: e.target.value })} + {...getInputPropsByName(scheme, ['expenses'])} + onFocus={() => dispatch(removeErrorByPropAction(['expenses']))} + /> + + + )} + + handleSetState({ usersLimit: e.target.value })} + {...getInputPropsByName(scheme, ['usersLimit'])} + onFocus={() => dispatch(removeErrorByPropAction(['usersLimit']))} + /> + + + + + + ); +} + +export default DetailsTab; diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.module.scss b/anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.module.scss new file mode 100644 index 0000000..f728431 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/DetailsTab/DetailsTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SalesForceIcon":"DetailsTab_SalesForceIcon__SkDGs"}; \ No newline at end of file diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.jsx b/anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.jsx new file mode 100644 index 0000000..fa020a8 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.jsx @@ -0,0 +1,216 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { InfoOutlined } from '@mui/icons-material'; + +import * as selectors from '../../../redux/selectors'; +import { setAction } from '../../../redux/slices'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './FeaturesTab.module.scss'; + +const findDependFeatureIdByCurrentId = (featuresDependsFieldData, targetValue) => + Object.entries(featuresDependsFieldData).find(([, value]) => value === targetValue)?.[0]; + +function FeaturesTab() { + const dispatch = useDispatch(); + + const featuresUi = useSelector(selectors.featuresUiSelector); + const featuresData = useSelector(selectors.featuresDataSelector); + const featuresDependsFieldData = useSelector(selectors.featuresDependsFieldDataSelector); + const [removeConfirmationDialog, setRemoveConfirmationDialog] = useState(null); + const [confirmMessage, setConfirmMessage] = useState(null); + const [pendingChange, setPendingChange] = React.useState(null); // { id, value } + + const handleStateChange = ({ id, value }) => { + const updatedFeatures = { ...featuresData }; + const disableDependents = (featureId) => { + let dependentFeatureId = featuresDependsFieldData[featureId]; + while (dependentFeatureId) { + if (updatedFeatures[dependentFeatureId] !== false) { + updatedFeatures[dependentFeatureId] = false; + } + dependentFeatureId = featuresDependsFieldData[dependentFeatureId]; + } + }; + + if (!value) { + disableDependents(id); + } + + updatedFeatures[id] = value; + + dispatch(setAction({ featuresData: updatedFeatures })); + }; + + // Show confirmation ONLY when disabling sponsoredContent; otherwise apply immediately + const maybeConfirm = ({ id, name, value, title }) => { + if (!value && name === 'sponsoredContent') { + setConfirmMessage( + <> + Disabling {title} will permanently remove all related settings from your channel and playlist + configuration forms. +
+ These settings cannot be restored once removed. +
+ Are you sure you want to proceed? + , + ); + // we are about to go from true -> false; store for confirm + setPendingChange({ id, value: false }); + setRemoveConfirmationDialog(true); + return; + } + + // All other cases → apply immediately + handleStateChange({ id, value }); + }; + + const handleConfirm = () => { + if (pendingChange) { + handleStateChange(pendingChange); // apply false (and disable dependents) + } + setRemoveConfirmationDialog(false); + setPendingChange(null); + setConfirmMessage(null); + }; + + const handleCancel = () => { + // Explicitly restore sponsoredContent to TRUE if we were disabling it. + if (pendingChange?.id) { + const updated = { ...featuresData, [pendingChange.id]: true }; + dispatch(setAction({ featuresData: updated })); + } + setRemoveConfirmationDialog(false); + setPendingChange(null); + setConfirmMessage(null); + }; + + return ( + + + + + Category + Feature + Status + + + + {featuresUi?.map((entity) => ( + + + + {entity.categoryName} + + + + + + {entity.features.map((feature) => ( + + + {feature.title} + + + {feature.name !== 'secureVideoPlayback' && feature.featureTooltipText && ( + + + + )} + + {feature.name === 'secureVideoPlayback' && ( + + Please use with caution!
+ Enabling this feature significantly increases the load on the Traffic Manager, especially + for watches containing a large number of videos. +
+ This may result in empty playlists being returned if the pods are not properly scaled.{' '} +
+ Consider increasing the resources (CPU/memory) allocated to the Traffic Manager pods to + ensure stable performance. + + } + > + +
+ )} +
+ ))} +
+
+ + + + {entity.features.map((feature) => { + const dependFeatureValue = + featuresData[findDependFeatureIdByCurrentId(featuresDependsFieldData, feature.id)]; + const disabled = feature.blockedByFeature && !dependFeatureValue; + + return ( + + maybeConfirm({ + id: feature.id, + value: target.checked, + name: feature.name, + title: feature.title, + }) + } + /> + ); + })} + + +
+ ))} +
+
+ {removeConfirmationDialog && ( + + setRemoveConfirmationDialog(null)}>Warning + {confirmMessage} + + + + + + )} +
+ ); +} + +export default FeaturesTab; diff --git a/anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.module.scss b/anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.module.scss new file mode 100644 index 0000000..b8161da --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/components/Tabs/FeaturesTab/FeaturesTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Root":"FeaturesTab_Root__JPYIT","FeatureTitleRoot":"FeaturesTab_FeatureTitleRoot__SecGT"}; \ No newline at end of file diff --git a/anyclip/src/modules/accounts/Editor/constants/index.js b/anyclip/src/modules/accounts/Editor/constants/index.js new file mode 100644 index 0000000..9d1acd4 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/constants/index.js @@ -0,0 +1,50 @@ +export const TAB_DETAILS = 'details'; +export const TAB_FEATURES = 'features'; +export const TAB_DASHBOARDS = 'dashboards'; +export const TAB_CONTENT = 'content'; + +export const REDUX_FIELD_NAME = 'commonForm'; + +export const FEE_BUSINESS_MODEL_IMPRESSIONS_CPM = 'IMPRESSIONS_CPM'; +export const FEE_BUSINESS_MODEL_RAV_SHARE = 'REV_SHARE'; + +export const FEE_BUSINESS_MODEL_OPTIONS = [ + { label: 'Impression CPM', value: FEE_BUSINESS_MODEL_IMPRESSIONS_CPM }, + { label: 'Rev-Share', value: FEE_BUSINESS_MODEL_RAV_SHARE }, +]; + +// content owner table +export const TABLE_REDUX_FIELD_NAME = 'contentOwnersTable'; +export const ROWS_PER_PAGE_DEFAULT = 15; +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_HEADERS = [ + { + id: 'name', + label: 'Content Owner Name', + }, + { + id: 'salesforceId', + label: 'Salesforce Opportunity Id', + }, + { + id: 'status', + label: 'Status', + }, + { + id: 'isPublic', + label: 'Access Level', + }, + { + id: 'comments', + label: 'Comments', + }, + { + id: 'callToAction', + label: 'Display Call to Action', + }, + { + id: '', + label: '', + }, +]; diff --git a/anyclip/src/modules/accounts/Editor/helpers/buildRequestBody.js b/anyclip/src/modules/accounts/Editor/helpers/buildRequestBody.js new file mode 100644 index 0000000..19529c1 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/helpers/buildRequestBody.js @@ -0,0 +1,91 @@ +import * as selectors from '../redux/selectors'; +import { isNumber } from '@/modules/@common/helpers/number'; + +function getValueFromState(state$) { + return { + salesforceId: selectors.salesforceIdSelector(state$), + name: selectors.nameSelector(state$), + publisherDemand: selectors.publisherDemandSelector(state$), + adServingFeeBusinessModel: selectors.adServingFeeBusinessModelSelector(state$), + adServingFeeDisplay: selectors.adServingFeeDisplaySelector(state$), + adServingFeeVideo: selectors.adServingFeeVideoSelector(state$), + salesManager: selectors.salesManagerSelector(state$), + accountManager: selectors.accountManagerSelector(state$), + accountManagerEmail: selectors.accountManagerEmailSelector(state$), + type: selectors.typeSelector(state$), + avatarUrl: selectors.avatarUrlSelector(state$), + avatar: selectors.avatarSelector(state$), + customLogoUrl: selectors.customLogoUrlSelector(state$), + customLoadingMessage: selectors.customLoadingMessageSelector(state$), + customLoginPageUrl: selectors.customLoginPageUrlSelector(state$), + subdomain: selectors.subdomainSelector(state$), + publisherRevShare: selectors.publisherRevShareSelector(state$), + expenses: selectors.expensesSelector(state$), + usersLimit: selectors.usersLimitSelector(state$), + featuresData: selectors.featuresDataSelector(state$), + dashboardsGeneral: selectors.dashboardsGeneralSelector(state$), + dashboardsCustom: selectors.dashboardsCustomSelector(state$), + videoDuplicatesBy: selectors.videoDuplicatesBy(state$), + }; +} + +function buildRequestBodyBase(stateValue) { + const body = { + type: stateValue.type, + usersLimit: +stateValue.usersLimit, + subdomain: stateValue.subdomain, + publisherRevShare: isNumber(parseInt(stateValue.publisherRevShare, 10)) + ? parseInt(stateValue.publisherRevShare, 10) + : null, + expenses: isNumber(parseInt(stateValue.expenses, 10)) ? parseInt(stateValue.expenses, 10) : null, + customLogoUrl: stateValue.customLogoUrl || null, + customLoginPageUrl: stateValue.customLoginPageUrl || null, + customLoadingMessage: stateValue.customLoadingMessage || null, + accountsFeatures: Object.keys(stateValue.featuresData).map((key) => ({ + id: +key, + value: Boolean(stateValue.featuresData[key]), + })), + accountDashboards: {}, + avatar: stateValue.avatar?.base64Url ? stateValue.avatar : null, + adServingFeeBusinessModel: stateValue.adServingFeeBusinessModel, + adServingFeeVideo: Number.isNaN(+stateValue.adServingFeeVideo) ? null : +stateValue.adServingFeeVideo, + adServingFeeDisplay: Number.isNaN(+stateValue.adServingFeeDisplay) ? null : +stateValue.adServingFeeDisplay, + videoDuplicatesBy: stateValue.videoDuplicatesBy || null, + }; + + if (stateValue.avatarUrl) { + body.avatarUrl = stateValue.avatarUrl; + } + + if (stateValue.dashboardsCustom?.length) { + body.accountDashboards.custom = stateValue.dashboardsCustom.map((entity) => ({ + ...entity, + enabled: Boolean(entity.enabled), + })); + } + + if (stateValue.dashboardsGeneral?.length) { + body.accountDashboards.general = stateValue.dashboardsGeneral.map((entity) => ({ + ...entity, + enabled: Boolean(entity.enabled), + })); + } + + return body; +} + +export function buildUpdateRequestBody(state$) { + const stateValue = getValueFromState(state$); + return { + ...buildRequestBodyBase(stateValue), + }; +} + +export function buildCreateRequestBody(state$) { + const stateValue = getValueFromState(state$); + return { + ...buildRequestBodyBase(stateValue), + salesforceId: stateValue.salesforceId, + publisherDemand: stateValue.publisherDemand, + }; +} diff --git a/anyclip/src/modules/accounts/Editor/helpers/validationScheme.js b/anyclip/src/modules/accounts/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..a301d24 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/helpers/validationScheme.js @@ -0,0 +1,150 @@ +import { FEE_BUSINESS_MODEL_IMPRESSIONS_CPM, FEE_BUSINESS_MODEL_RAV_SHARE, TAB_DETAILS } from '../constants'; + +const VALID_URL_REGEX = + // eslint-disable-next-line max-len + /^(https?:\/\/)((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=[\]-]*)?(#\S*)?$/; + +// validation logic getting from +// src/pcn/src/utils/stringUtils.js +const isValidURL = (string = '') => { + if (!VALID_URL_REGEX.test(string)) { + return false; + } + + try { + const { protocol } = new URL(string); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +const validateAdsServerFee = (value, field) => { + const isEmpty = typeof value !== 'number' || Number.isNaN(value); + + if (!field.publisherDemand) { + return ''; + } + if (field.adServingFeeBusinessModel === FEE_BUSINESS_MODEL_IMPRESSIONS_CPM) { + if (isEmpty) { + return 'Field cannot be empty'; + } + if (value > 9.99) { + return 'Must be at most 9.99'; + } + } + if (field.adServingFeeBusinessModel === FEE_BUSINESS_MODEL_RAV_SHARE) { + if (isEmpty) { + return 'Field cannot be empty'; + } + if (value < 0.01) { + return 'Must be at least 0.01'; + } + if (value > 100) { + return 'Must be at most 100'; + } + } + + return ''; +}; + +export const validationScheme = [ + { + fieldName: 'salesforceId', + tabId: TAB_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'adServingFeeVideo', + tabId: TAB_DETAILS, + validation: validateAdsServerFee, + }, + { + fieldName: 'adServingFeeDisplay', + tabId: TAB_DETAILS, + validation: validateAdsServerFee, + }, + { + fieldName: 'customLogoUrl', + tabId: TAB_DETAILS, + validation: (value) => { + if (value && !isValidURL(value)) { + return 'Field should be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'customLoginPageUrl', + tabId: TAB_DETAILS, + validation: (value) => { + if (value && !isValidURL(value)) { + return 'Field should be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'usersLimit', + tabId: TAB_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (+value > 9999999) { + return 'Field must be less than or equal to 9999999'; + } + + return ''; + }, + }, + { + fieldName: 'publisherRevShare', + tabId: TAB_DETAILS, + validation: (value) => { + if (value !== 0 && !value) { + return 'Field cannot be empty'; + } + if (!Number.isInteger(Number(value))) { + return 'Must be an integer'; + } + if (value < 0) { + return 'Must be at least 0'; + } + if (value > 100) { + return 'Must be at most 100'; + } + + return ''; + }, + }, + { + fieldName: 'expenses', + tabId: TAB_DETAILS, + validation: (value) => { + if (value !== 0 && !value) { + return 'Field cannot be empty'; + } + if (!Number.isInteger(Number(value))) { + return 'Must be an integer'; + } + if (value < 0) { + return 'Must be at least 0'; + } + if (value > 100) { + return 'Must be at most 100'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/createItem.js b/anyclip/src/modules/accounts/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..c5bb36d --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/createItem.js @@ -0,0 +1,68 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_ACCOUNT } from '@/graphql/services/accounts/constants'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/item'; + +import { buildCreateRequestBody } from '../../helpers/buildRequestBody'; +import { salesforceIdSelector, subdomainSelector } from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_ACCOUNT}($payload: ${PAYLOAD_NAME}) { + ${CREATE_ACCOUNT}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(({ payload }) => { + const body = buildCreateRequestBody(state$.value); + const salesforceId = salesforceIdSelector(state$.value); + const subdomain = subdomainSelector(state$.value); + + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: payload, + ...body, + }, + }, + }, + { showNotificationMessage: false }, + ).pipe( + switchMap((response) => { + const error = response.errors?.[0]; + + if (!error) { + Router.push('/accounts'); + return of(showNotificationAction({ type: TYPE_SUCCESS, message: 'Account created' })); + } + + const message = + error?.response?.status === 409 + ? `An Account with Salesforce Account Id + ${salesforceId} ${subdomain ? `or with Subdomain ${subdomain}` : ''} already exists` + : "Can't create account"; + + return of( + showNotificationAction({ + type: TYPE_ERROR, + message, + }), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/deleteAllVideos.js b/anyclip/src/modules/accounts/Editor/redux/epics/deleteAllVideos.js new file mode 100644 index 0000000..cdb0177 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/deleteAllVideos.js @@ -0,0 +1,41 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { DELETE_ALL_VIDEOS } from '@/graphql/services/accounts/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/deleteAllVideos'; + +import { deleteAllVideos } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${DELETE_ALL_VIDEOS}($payload: ${PAYLOAD_NAME}) { + ${DELETE_ALL_VIDEOS}(payload: $payload) +}`; + +export default (action$) => + action$.pipe( + ofType(deleteAllVideos.type), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: payload, + }, + }, + }).pipe(switchMap(() => EMPTY)); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Initiated a deletion of all account videos', + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/getContentOwners.js b/anyclip/src/modules/accounts/Editor/redux/epics/getContentOwners.js new file mode 100644 index 0000000..23e3ed1 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/getContentOwners.js @@ -0,0 +1,46 @@ +import { GET_CONTENT_OWNERS } from '@/graphql/services/accounts/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/contentOwners'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_CONTENT_OWNERS}($payload: ${PAYLOAD_NAME}) { + ${GET_CONTENT_OWNERS}(payload: $payload) { + records { + id + name + salesforceId + status + isPublic + comments + callToAction + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state, actionPayload) => ({ + payload: { + accountId: actionPayload, + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + }, + }), + processResponse: ({ data }) => { + const contentOwners = data[GET_CONTENT_OWNERS]; + + return { + records: contentOwners.records, + recordsTotal: contentOwners.recordsTotal, + allRecordsCount: contentOwners.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/getItem.js b/anyclip/src/modules/accounts/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..7432ab1 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/getItem.js @@ -0,0 +1,216 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ACCOUNT } from '@/graphql/services/accounts/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/account'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryItem = ` + query ${GET_ACCOUNT}($payload: ${PAYLOAD_NAME}) { + ${GET_ACCOUNT}(payload: $payload) { + salesforceInstanceUrl + salesforceId + name + publisherDemand + adServingFeeBusinessModel + adServingFeeDisplay + adServingFeeVideo + salesManager + accountManager + accountManagerEmail + type + avatarUrl + customLogoUrl + customLoadingMessage + customLoginPageUrl + subdomain + publisherRevShare + expenses + usersLimit + videoDuplicatesBy + accountsFeatures { + value + accountsFeatures { + blockedByFeature + category + defaultValue + featureTooltipText + id + name + noFlagOff + order + status + title + } + } + accountDashboards { + general { + id + uiName + enabled + order + tooltip { + info + link + linkTitle + } + } + custom { + id + uiName + enabled + order + lookerReportId + icon + publisherCustomAnalytics { + accountId + action + id + publisherId + publisherIds + } + } + } + } + } +`; + +const queryFeature = ` + query ${GET_ACCOUNT}($payload: ${PAYLOAD_NAME}) { + ${GET_ACCOUNT}(payload: $payload) { + salesforceInstanceUrl + accountsFeatures { + value + accountsFeatures { + blockedByFeature + category + defaultValue + featureTooltipText + id + name + noFlagOff + order + status + title + } + } + } + } +`; + +// shape is [{ categoryName: 'featureCategoryName', features: [{ObjectOfFeature}] }] +function transformFeaturesToUiShape(data) { + const accountsFeatures = data + .filter((item) => !!item.accountsFeatures.status) + .map((item) => item.accountsFeatures || []) + .sort((a, b) => a.order - b.order); + + const groupedByCategory = accountsFeatures.reduce((acc, feature) => { + const { category } = feature; + if (!acc[category]) { + acc[category] = { categoryName: category, features: [feature] }; + } else { + acc[category].features.push(feature); + } + return acc; + }, {}); + + return Object.values(groupedByCategory).map((group) => ({ + ...group, + })); +} + +// shape for form data is { featureId1: featureValue1, etc } +function transformFeaturesToDataShape(data) { + return Object.fromEntries(data.map(({ accountsFeatures: { id }, value }) => [id, value])); +} + +// shape for related filed { featureId1: featureId2, etc }. +// That means when featureId1 eq false need to disable featureId2 +function transformFeaturesToDependFieldShape(data) { + const depend = data.map((feature) => { + const dependField = data.find( + (entity) => entity.accountsFeatures.blockedByFeature === feature.accountsFeatures.name, + ); + return [feature.accountsFeatures.id, dependField?.accountsFeatures.id]; + }); + return Object.fromEntries(depend); +} + +const getResponse = ({ data }) => { + const account = data[GET_ACCOUNT]; + + return { + ...account, + }; +}; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query: action.payload === 'new' ? queryFeature : queryItem, + variables: { + payload: { + id: action.payload, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open account for edit", + }), + ), + ); + + Router.push('/accounts'); + } else { + const { accountsFeatures, accountDashboards, ...data } = getResponse(response); + + const featuresUi = transformFeaturesToUiShape(accountsFeatures); + const featuresData = transformFeaturesToDataShape(accountsFeatures); + const featuresDependsFieldData = transformFeaturesToDependFieldShape(accountsFeatures); + + const dashboardsGeneral = accountDashboards?.general?.sort((a, b) => a.order - b.order) || []; + const dashboardsCustom = accountDashboards?.custom?.sort((a, b) => a.order - b.order) || []; + + actions.push( + of( + setAction({ + ...data, + featuresUi, + featuresData, + featuresDependsFieldData, + dashboardsGeneral, + dashboardsCustom, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/getSalesforceData.js b/anyclip/src/modules/accounts/Editor/redux/epics/getSalesforceData.js new file mode 100644 index 0000000..442c410 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/getSalesforceData.js @@ -0,0 +1,101 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_SALESFORCE_DATA } from '@/graphql/services/accounts/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/salesForce'; + +import * as selectors from '../selectors'; +import { getSalesforceDataAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_SALESFORCE_DATA}($payload: ${PAYLOAD_NAME}) { + ${GET_SALESFORCE_DATA}(payload: $payload) { + name + salesManager + accountManager + accountManagerEmail + type + accountDashboards { + general { + id + uiName + enabled + order + tooltip { + info + link + linkTitle + } + } + custom { + id + uiName + enabled + order + tooltip { + info + link + linkTitle + } + } + } + } + } +`; + +const getResponse = ({ data }) => { + const res = data[GET_SALESFORCE_DATA]; + + return { + ...res, + }; +}; + +export default (action$, state$) => + action$.pipe( + ofType(getSalesforceDataAction.type), + switchMap(() => { + const salesforceId = selectors.salesforceIdSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: salesforceId, + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const { accountDashboards, ...data } = getResponse(response); + + const dashboardsGeneral = accountDashboards?.general?.sort((a, b) => a.order - b.order) || []; + const dashboardsCustom = accountDashboards?.custom?.sort((a, b) => a.order - b.order) || []; + + actions.push( + of( + setAction({ + ...data, + dashboardsGeneral, + dashboardsCustom, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of(setAction({ isSalesforceDataLoading: true })), + stream$, + of(setAction({ isSalesforceDataLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/index.js b/anyclip/src/modules/accounts/Editor/redux/epics/index.js new file mode 100644 index 0000000..9dc442b --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import deleteAllVideos from './deleteAllVideos'; +import getContentOwners from './getContentOwners'; +import getItem from './getItem'; +import getSalesforceData from './getSalesforceData'; +import updateContentOwner from './updateContentOwner'; +import updateItem from './updateItem'; + +export default combineEpics( + getItem, + createItem, + updateItem, + getSalesforceData, + getContentOwners, + updateContentOwner, + deleteAllVideos, +); diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/updateContentOwner.js b/anyclip/src/modules/accounts/Editor/redux/epics/updateContentOwner.js new file mode 100644 index 0000000..f04ba09 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/updateContentOwner.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_CONTENT_OWNER } from '@/graphql/services/accounts/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/contentOwner'; + +import { getDataAction as getContentOwnerDataAction, updateContentOwner } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_CONTENT_OWNER}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_CONTENT_OWNER}(payload: $payload) { + id + } +}`; + +export default (action$) => + action$.pipe( + ofType(updateContentOwner.type), + switchMap((action) => { + const { callback, ...payload } = action.payload; + const stream$ = gqlRequest({ + query, + variables: { + payload, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of(getContentOwnerDataAction(payload.accountId)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Content owner updated', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/accounts/Editor/redux/epics/updateItem.js b/anyclip/src/modules/accounts/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..e3d325b --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/epics/updateItem.js @@ -0,0 +1,63 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_ACCOUNT } from '@/graphql/services/accounts/constants'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/item'; + +import { buildUpdateRequestBody } from '../../helpers/buildRequestBody'; +import { subdomainSelector } from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_ACCOUNT}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_ACCOUNT}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap(({ payload }) => { + const body = buildUpdateRequestBody(state$.value); + const subdomain = subdomainSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: payload, + ...body, + }, + }, + }).pipe( + switchMap((response) => { + const error = response.errors?.[0]; + + if (!error) { + Router.push('/accounts'); + return of(showNotificationAction({ type: TYPE_SUCCESS, message: 'Account updated' })); + } + + const message = + error?.response?.status === 409 + ? `An Account with Subdomain ${subdomain} already exists` + : "Can't update account"; + + return of( + showNotificationAction({ + type: TYPE_ERROR, + message, + }), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/accounts/Editor/redux/selectors/index.js b/anyclip/src/modules/accounts/Editor/redux/selectors/index.js new file mode 100644 index 0000000..f2c2f58 --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/selectors/index.js @@ -0,0 +1,59 @@ +import { REDUX_FIELD_NAME, TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; + +// Account Details +export const salesforceInstanceUrlSelector = (state) => state[nameSpace].salesforceInstanceUrl; +export const salesforceIdSelector = (state) => state[nameSpace].salesforceId; +export const nameSelector = (state) => state[nameSpace].name; +export const salesManagerSelector = (state) => state[nameSpace].salesManager; +export const accountManagerSelector = (state) => state[nameSpace].accountManager; +export const accountManagerEmailSelector = (state) => state[nameSpace].accountManagerEmail; +export const typeSelector = (state) => state[nameSpace].type; +export const avatarUrlSelector = (state) => state[nameSpace].avatarUrl; +export const avatarSelector = (state) => state[nameSpace].avatar; +export const customLogoUrlSelector = (state) => state[nameSpace].customLogoUrl; +export const customLoadingMessageSelector = (state) => state[nameSpace].customLoadingMessage; +export const customLoginPageUrlSelector = (state) => state[nameSpace].customLoginPageUrl; +export const subdomainSelector = (state) => state[nameSpace].subdomain; +export const publisherRevShareSelector = (state) => state[nameSpace].publisherRevShare; +export const expensesSelector = (state) => state[nameSpace].expenses; +export const usersLimitSelector = (state) => state[nameSpace].usersLimit; +export const publisherDemandSelector = (state) => state[nameSpace].publisherDemand; +export const adServingFeeBusinessModelSelector = (state) => state[nameSpace].adServingFeeBusinessModel; +export const adServingFeeDisplaySelector = (state) => state[nameSpace].adServingFeeDisplay; +export const adServingFeeVideoSelector = (state) => state[nameSpace].adServingFeeVideo; +export const videoDuplicatesBy = (state) => state[nameSpace].videoDuplicatesBy; + +// Features +export const featuresUiSelector = (state) => state[nameSpace].featuresUi; +export const featuresDataSelector = (state) => state[nameSpace].featuresData; +export const featuresDependsFieldDataSelector = (state) => state[nameSpace].featuresDependsFieldData; + +// Dashboards +export const dashboardsGeneralSelector = (state) => state[nameSpace].dashboardsGeneral; +export const dashboardsCustomSelector = (state) => state[nameSpace].dashboardsCustom; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; +export const isSalesforceDataLoadingSelector = (state) => state[nameSpace].isSalesforceDataLoading; + +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; + +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + isLoadingSelector, + sortBySelector, + sortOrderSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); diff --git a/anyclip/src/modules/accounts/Editor/redux/slices/index.js b/anyclip/src/modules/accounts/Editor/redux/slices/index.js new file mode 100644 index 0000000..d37f28b --- /dev/null +++ b/anyclip/src/modules/accounts/Editor/redux/slices/index.js @@ -0,0 +1,120 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + FEE_BUSINESS_MODEL_IMPRESSIONS_CPM, + REDUX_FIELD_NAME, + ROWS_PER_PAGE_DEFAULT, + TAB_DETAILS, + TABLE_REDUX_FIELD_NAME, + TABLE_SORT_BY, +} from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + salesforceInstanceUrl: '', + salesforceId: '', + name: '', + salesManager: '', + accountManager: '', + accountManagerEmail: '', + type: '', + publisherDemand: false, + adServingFeeBusinessModel: FEE_BUSINESS_MODEL_IMPRESSIONS_CPM, + adServingFeeDisplay: null, + adServingFeeVideo: null, + avatarUrl: '', + avatar: { + base64Url: '', + mimeType: '', + }, + customLogoUrl: '', + customLoadingMessage: '', + customLoginPageUrl: '', + subdomain: '', + publisherRevShare: null, + expenses: null, + usersLimit: 100, + videoDuplicatesBy: 'Title', + activeTabId: TAB_DETAILS, + + featuresUi: [], + featuresData: null, + featuresDependsFieldData: null, + + dashboardsGeneral: [], + dashboardsCustom: [], + + isSalesforceDataLoading: false, + + ...formSlice.state, + // table + ...tableSlice.state, +}; + +export const slice = createSlice({ + name: '@@ACCOUNTS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: (state) => { + Object.entries(initialState).forEach(([key, value]) => { + state[key] = value; + }); + }, + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + getSalesforceDataAction: (state) => state, + getItemAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + updateContentOwner: (state) => state, + deleteAllVideos: (state) => state, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + }, +}); + +export const { + setAction, + setInitialAction, + setActiveTabIdAction, + getSalesforceDataAction, + getItemAction, + createItemAction, + updateItemAction, + updateContentOwner, + deleteAllVideos, + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, + + getDataAction, + setTableAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/accounts/List/components/List.jsx b/anyclip/src/modules/accounts/List/components/List.jsx new file mode 100644 index 0000000..d21bf94 --- /dev/null +++ b/anyclip/src/modules/accounts/List/components/List.jsx @@ -0,0 +1,201 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { AddRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; + +import { ACCOUNT_OPTIONS, ACCOUNT_TYPE_ALL, SEARCH_TEXT_MAX_LENGTH, TABLE_HEADERS } from '../constants'; + +import * as selectors from '../redux/selectors'; +import { getDataAction, setAction, setTableAction } from '../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable from '@/modules/@common/Table'; +import { + Autocomplete, + Button, + Divider, + IconButton, + InputAdornment, + Link, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, + UserAvatar, +} from '@/mui/components'; + +import styles from './List.module.scss'; + +const getLabelFromValue = (options, value) => options.find((option) => option.value === value)?.label; + +const getAvatarPropsFromRows = ({ avatarUrl, name }) => + avatarUrl + ? { src: avatarUrl } + : { + firstName: name.split(' ')[0], + lastName: name.split(' ').pop(), + }; + +function List() { + const dispatch = useDispatch(); + const router = useRouter(); + + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + + const search = useSelector(selectors.searchSelector); + const accountType = useSelector(selectors.accountTypeSelector); + + const shouldShowEmpty = false; + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + +
+ handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
+ + + + + + s.value === accountType) ?? ACCOUNT_TYPE_ALL} + options={ACCOUNT_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ accountType: selected$?.value ?? ACCOUNT_TYPE_ALL, page: 1 })} + renderInput={(params) => } + /> + + } + renderActions={ + + + + + + } + > + ( + router.push(`/accounts/${row.id}`)}> + +
{row.id}
+
+ +
+ + + {row.name} + +
+
+ +
{row.salesforceId}
+
+ +
{getLabelFromValue(ACCOUNT_OPTIONS, row.type)}
+
+ +
+ e.stopPropagation()} + > + {row.sitesAmount} + +
+
+ +
{row.contentOwners?.length}
+
+ +
{row.updatedBy}
+
+ + +
{dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
+
+
+ )} + data={data || []} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onFilter={handleFilter} + /> +
+ ); +} + +export default List; diff --git a/anyclip/src/modules/accounts/List/components/List.module.scss b/anyclip/src/modules/accounts/List/components/List.module.scss new file mode 100644 index 0000000..2b7ebed --- /dev/null +++ b/anyclip/src/modules/accounts/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Row":"List_Row__xnw4B","NoWrap":"List_NoWrap__aHvSU","SearchField":"List_SearchField__NuJsi","Select":"List_Select__aABDB"}; \ No newline at end of file diff --git a/anyclip/src/modules/accounts/List/constants/index.js b/anyclip/src/modules/accounts/List/constants/index.js new file mode 100644 index 0000000..3ba1379 --- /dev/null +++ b/anyclip/src/modules/accounts/List/constants/index.js @@ -0,0 +1,72 @@ +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; +export const ROWS_PER_PAGE_DEFAULT = 15; +export const TABLE_SORT_BY = 'updatedAt'; +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const TABLE_HEADERS = [ + { + id: 'id', + label: 'Id', + width: '100', + sortable: true, + }, + { + id: 'name', + label: 'Account Name', + sortable: true, + }, + { + id: 'salesforceId', + label: 'Salesforce Account Id', + width: '200', + sortable: true, + }, + { + id: 'type', + label: 'Type', + width: '100', + sortable: true, + }, + { + id: 'sitesAmount', + label: 'Hubs', + width: '80', + }, + { + id: 'contentOwners', + label: 'Content', + width: '80', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + }, + { + id: 'updatedAt', + label: 'Updated Date', + width: '150', + sortable: true, + }, +]; + +// Account +export const ACCOUNT_TYPE_ALL = null; +export const ACCOUNT_TYPE_PUBLISHER = 'PUBLISHER'; +export const ACCOUNT_TYPE_BUSINESS = 'BUSINESS'; +export const ACCOUNT_TYPE_DEMAND = 'DEMAND'; +export const ACCOUNT_TYPE_SYNDICATION = 'SYNDICATION'; +export const ACCOUNT_TYPE_VAST = 'VAST'; + +export const ACCOUNT_OPTIONS = [ + { label: 'Publisher', value: ACCOUNT_TYPE_PUBLISHER }, + { label: 'Business', value: ACCOUNT_TYPE_BUSINESS }, + { label: 'Demand', value: ACCOUNT_TYPE_DEMAND }, + { label: 'Syndication', value: ACCOUNT_TYPE_SYNDICATION }, + { label: 'VAST', value: ACCOUNT_TYPE_VAST }, +]; + +export const FILTER_VIDEO_DUPLICATES_BY_OPTIONS = [ + { label: 'Title', value: 'Title' }, + { label: 'GUID', value: 'GUID' }, +]; diff --git a/anyclip/src/modules/accounts/List/redux/epics/getData.js b/anyclip/src/modules/accounts/List/redux/epics/getData.js new file mode 100644 index 0000000..5bd2c20 --- /dev/null +++ b/anyclip/src/modules/accounts/List/redux/epics/getData.js @@ -0,0 +1,64 @@ +import { ACCOUNT_TYPE_ALL } from '../../constants'; +import { GET_ACCOUNTS } from '@/graphql/services/accounts/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/accounts/types/payload/accounts'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_ACCOUNTS}($payload: ${PAYLOAD_NAME}) { + ${GET_ACCOUNTS}(payload: $payload) { + records { + id + name + avatarUrl + salesforceId + type + sitesAmount + contentOwners { + id + name + } + updatedBy + updatedAt + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const accountType = selectors.accountTypeSelector(state); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (accountType !== ACCOUNT_TYPE_ALL) { + variables.type = accountType; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const accounts = data[GET_ACCOUNTS]; + + return { + records: accounts.records, + recordsTotal: accounts.recordsTotal, + allRecordsCount: accounts.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/accounts/List/redux/epics/index.js b/anyclip/src/modules/accounts/List/redux/epics/index.js new file mode 100644 index 0000000..1775aad --- /dev/null +++ b/anyclip/src/modules/accounts/List/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import getData from './getData'; + +export default combineEpics(getData); diff --git a/anyclip/src/modules/accounts/List/redux/selectors/index.js b/anyclip/src/modules/accounts/List/redux/selectors/index.js new file mode 100644 index 0000000..8c154a7 --- /dev/null +++ b/anyclip/src/modules/accounts/List/redux/selectors/index.js @@ -0,0 +1,21 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; +export const accountTypeSelector = (state) => state[nameSpace].accountType; diff --git a/anyclip/src/modules/accounts/List/redux/slices/index.js b/anyclip/src/modules/accounts/List/redux/slices/index.js new file mode 100644 index 0000000..6ff0393 --- /dev/null +++ b/anyclip/src/modules/accounts/List/redux/slices/index.js @@ -0,0 +1,39 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ACCOUNT_TYPE_ALL, ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + accountType: ACCOUNT_TYPE_ALL, +}; + +export const slice = createSlice({ + name: '@@ACCOUNTS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + }, +}); + +export const { getDataAction, setTableAction, setAction } = slice.actions; diff --git a/anyclip/src/modules/adServers/Editor/components/Editor.jsx b/anyclip/src/modules/adServers/Editor/components/Editor.jsx new file mode 100644 index 0000000..67d31d3 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/components/Editor.jsx @@ -0,0 +1,135 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_GENERAL } from '@/modules/adServers/Editor/constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + getPlayerTypesOptionsAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const name = useSelector(selectors.nameSelector); + + const id = parseInt(router.query.id, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const handleSave = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + useEffect(() => { + dispatch(getPlayerTypesOptionsAction()); + + if (id) { + dispatch(getItemAction({ id })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + return ( + + + + {id ? `${name} > Settings` : 'New Ad Server'} + + + + {tabs.length > 1 && ( + + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
+ + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
+
+ ); +} + +export default Editor; diff --git a/anyclip/src/modules/adServers/Editor/components/Editor.module.scss b/anyclip/src/modules/adServers/Editor/components/Editor.module.scss new file mode 100644 index 0000000..8fb9708 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Root":"Editor_Root__Z9BCP","Title":"Editor_Title__yOS5A","Controls":"Editor_Controls__jmENv","Tabs":"Editor_Tabs__lEK5R"}; \ No newline at end of file diff --git a/anyclip/src/modules/adServers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/adServers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..48a5010 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TYPE_ALL, TYPES_OPTIONS } from '../../../../List/constants'; + +import * as selectors from '../../../redux/selectors'; +import { removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, MenuItem, Select, Switch, TextField, Typography } from '@/mui/components'; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const name = useSelector(selectors.nameSelector); + const url = useSelector(selectors.urlSelector); + const status = useSelector(selectors.statusSelector); + const placementId = useSelector(selectors.placementIdSelector); + const isDefaultForPlayerType = useSelector(selectors.isDefaultForPlayerTypeSelector); + const playerTypesValue = useSelector(selectors.playerTypesValueSelector); + const playerTypesOptions = useSelector(selectors.playerTypesOptionsSelector); + const macro = useSelector(selectors.macroSelector); + const comments = useSelector(selectors.commentsSelector); + const scheme = useSelector(selectors.schemeSelector); + + const id = parseInt(router.query.id, 10); + + const typesOptions = TYPES_OPTIONS.filter((option) => option.value !== TYPE_ALL); + + const handleSetState = (state) => dispatch(setAction(state)); + + const NAME_MAX_LENGTH = 100; + const COMMENT_MAX_LENGTH = 255; + + return ( + <> + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + inputProps={{ maxLength: NAME_MAX_LENGTH }} + /> + + + handleSetState({ status: e.target.checked ? 1 : 0 })} + /> + + + handleSetState({ url: e.target.value })} + {...getInputPropsByName(scheme, ['url'])} + onFocus={() => dispatch(removeErrorByPropAction(['url']))} + /> + + + + + + handleSetState({ isDefaultForPlayerType: !isDefaultForPlayerType })} + /> + + + } + onChange={(e, playerTypesIds$) => + handleSetState({ + playerTypesIds: playerTypesIds$.map((player) => player.id), + }) + } + /> + + + Enter ad server macro including question mark and placeholder for + {/* eslint-disable-next-line no-template-curly-in-string */} + {" id ${ID} followed by AnyClip's macro (e.g: w=$[width]&h=$[height])"} + + } + > + handleSetState({ macro: e.target.value })} + {...getInputPropsByName(scheme, ['macro'])} + onFocus={() => dispatch(removeErrorByPropAction(['macro']))} + helperText={ + <> + + Click here + {' '} + to see list of available macros + + } + /> + + + handleSetState({ comments: e.target.value })} + inputProps={{ maxLength: COMMENT_MAX_LENGTH }} + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/adServers/Editor/constants/index.js b/anyclip/src/modules/adServers/Editor/constants/index.js new file mode 100644 index 0000000..2a8a9a2 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/constants/index.js @@ -0,0 +1,2 @@ +export const FORM_REDUX_FIELD_NAME = 'commonAdServersForm'; +export const TAB_GENERAL = 'general'; diff --git a/anyclip/src/modules/adServers/Editor/helpers/validationScheme.js b/anyclip/src/modules/adServers/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..0945fa7 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/helpers/validationScheme.js @@ -0,0 +1,43 @@ +import { TAB_GENERAL } from '../constants'; + +const urlRegex = /^(https?):\/\/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)/; +const macrosRegex = /(.*(\${ID}).*)/; +const macrosQuestionMarkRegex = /(.*(\?).*)/; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => (!value ? 'Filed cant be empty' : ''), + }, + { + fieldName: 'url', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cant be empty'; + } + + if (!urlRegex.test(value)) { + return 'Please provide correct url'; + } + + return ''; + }, + }, + { + fieldName: 'macro', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cant be empty'; + } + + if (!(macrosRegex.test(value) && macrosQuestionMarkRegex.test(value))) { + return 'Please provide correct macros'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/adServers/Editor/redux/epics/createItem.js b/anyclip/src/modules/adServers/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..5b41adf --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/redux/epics/createItem.js @@ -0,0 +1,80 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { ITEM_INPUT_TYPE_NAME } from '../../../../../graphql/services/adsServers/types/input/itemCreate'; + +import { + commentsSelector, + isDefaultForPlayerTypeSelector, + macroSelector, + nameSelector, + placementIdSelector, + playerTypesIdsSelector, + statusSelector, + urlSelector, +} from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` +mutation createAdServersPCN($entity: ${ITEM_INPUT_TYPE_NAME}) { + createAdServersPCN(entity: $entity) { + id + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const name = nameSelector(state$.value); + const status = statusSelector(state$.value); + const url = urlSelector(state$.value); + const placementId = placementIdSelector(state$.value); + const isDefaultForPlayerType = isDefaultForPlayerTypeSelector(state$.value); + const playerTypesIds = playerTypesIdsSelector(state$.value); + const macro = macroSelector(state$.value); + const comments = commentsSelector(state$.value); + + const entity = { + name, + status, + url, + placementId, + isDefaultForPlayerType, + playerTypesIds: isDefaultForPlayerType ? playerTypesIds : [], + macro, + comments, + }; + + const stream$ = gqlRequest({ + query, + variables: { + entity, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/ad-servers'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Ad Server created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/adServers/Editor/redux/epics/getItem.js b/anyclip/src/modules/adServers/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..8775475 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/redux/epics/getItem.js @@ -0,0 +1,75 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query getAdServerPCNById( + $id: Int! + ) { + getAdServerPCNById( + id: $id + ) { + id + name + status + url + placementId + isDefaultForPlayerType + playerTypesIds + macro + comments + } + } +`; + +const getResponse = ({ data: { getAdServerPCNById } }) => getAdServerPCNById; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query, + variables: { + id: action.payload.id, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open dashboard for edit", + }), + ), + ); + + Router.push('/ad-servers'); + } else { + const data = getResponse(response); + + actions.push(of(setAction(data))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/adServers/Editor/redux/epics/getPlayerTypesOptions.js b/anyclip/src/modules/adServers/Editor/redux/epics/getPlayerTypesOptions.js new file mode 100644 index 0000000..1c124f9 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/redux/epics/getPlayerTypesOptions.js @@ -0,0 +1,54 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPlayerTypesOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getAdServersPCNPlayerTypesOptions { + getAdServersPCNPlayerTypesOptions { + id + name + } + } +`; + +const getResponse = ({ data: { getAdServersPCNPlayerTypesOptions } }) => getAdServersPCNPlayerTypesOptions; + +export default (action$) => + action$.pipe( + ofType(getPlayerTypesOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const playerTypesOptions = getResponse(response); + + actions.push( + of( + setAction({ + playerTypesOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + playerTypesOptions: [], + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/adServers/Editor/redux/epics/index.js b/anyclip/src/modules/adServers/Editor/redux/epics/index.js new file mode 100644 index 0000000..ef4f77c --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getItem from './getItem'; +import getPlayerTypesOptions from './getPlayerTypesOptions'; +import updateItem from './updateItem'; + +export default combineEpics(getItem, getPlayerTypesOptions, createItem, updateItem); diff --git a/anyclip/src/modules/adServers/Editor/redux/epics/updateItem.js b/anyclip/src/modules/adServers/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..6cc4c91 --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/redux/epics/updateItem.js @@ -0,0 +1,78 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { ITEM_INPUT_TYPE_NAME } from '../../../../../graphql/services/adsServers/types/input/itemCreate'; + +import { + commentsSelector, + isDefaultForPlayerTypeSelector, + macroSelector, + nameSelector, + playerTypesIdsSelector, + statusSelector, + urlSelector, +} from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` +mutation updateAdServersPCN($entity: ${ITEM_INPUT_TYPE_NAME}) { + updateAdServersPCN(entity: $entity) { + id + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const name = nameSelector(state$.value); + const status = statusSelector(state$.value); + const url = urlSelector(state$.value); + const isDefaultForPlayerType = isDefaultForPlayerTypeSelector(state$.value); + const playerTypesIds = playerTypesIdsSelector(state$.value); + const macro = macroSelector(state$.value); + const comments = commentsSelector(state$.value); + + const entity = { + id: action.payload, + name, + status, + url, + isDefaultForPlayerType, + playerTypesIds: isDefaultForPlayerType ? playerTypesIds : [], + macro, + comments, + }; + + const stream$ = gqlRequest({ + query, + variables: { + entity, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/ad-servers'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Ad Server updated', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/adServers/Editor/redux/selectors/index.js b/anyclip/src/modules/adServers/Editor/redux/selectors/index.js new file mode 100644 index 0000000..e2f154b --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/redux/selectors/index.js @@ -0,0 +1,28 @@ +import { FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(FORM_REDUX_FIELD_NAME, nameSpace); + +export const nameSelector = (state) => state[nameSpace].name; +export const statusSelector = (state) => state[nameSpace].status; +export const urlSelector = (state) => state[nameSpace].url; +export const placementIdSelector = (state) => state[nameSpace].placementId; +export const isDefaultForPlayerTypeSelector = (state) => state[nameSpace].isDefaultForPlayerType; +export const playerTypesIdsSelector = (state) => state[nameSpace].playerTypesIds; +export const playerTypesOptionsSelector = (state) => state[nameSpace].playerTypesOptions; +export const playerTypesValueSelector = (state) => { + const playerTypesIds = playerTypesIdsSelector(state); + const playerTypesOptions = playerTypesOptionsSelector(state); + return playerTypesIds.map((playerId) => playerTypesOptions.find(({ id }) => id === playerId)); +}; +export const macroSelector = (state) => state[nameSpace].macro; +export const commentsSelector = (state) => state[nameSpace].comments; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +// forms +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/adServers/Editor/redux/slices/index.js b/anyclip/src/modules/adServers/Editor/redux/slices/index.js new file mode 100644 index 0000000..4a552dc --- /dev/null +++ b/anyclip/src/modules/adServers/Editor/redux/slices/index.js @@ -0,0 +1,62 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { STATUSES_ACTIVE, TYPE_VIDEO } from '../../../List/constants'; +import { FORM_REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(FORM_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + name: '', + status: STATUSES_ACTIVE, + url: '', + placementId: TYPE_VIDEO, + isDefaultForPlayerType: false, + playerTypesIds: [], + playerTypesOptions: [], + macro: '', + comments: '', + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@AD_SERVERS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ ...initialState }), + getPlayerTypesOptionsAction: (state) => state, + getItemAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getPlayerTypesOptionsAction, + getItemAction, + createItemAction, + updateItemAction, + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/adServers/List/components/List.jsx b/anyclip/src/modules/adServers/List/components/List.jsx new file mode 100644 index 0000000..cdc0eea --- /dev/null +++ b/anyclip/src/modules/adServers/List/components/List.jsx @@ -0,0 +1,325 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { AddRounded, ExpandMoreRounded, FilterAltRounded, SearchRounded, Visibility } from '@mui/icons-material'; + +import { + SEARCH_TEXT_MAX_LENGTH, + STATUSES_ACTIVE, + STATUSES_ALL, + STATUSES_INACTIVE, + STATUSES_OPTIONS, + TABLE_HEADERS, + TYPES_OPTIONS, +} from '../constants'; + +import * as selectors from '../redux/selectors'; +import { bulkChangeStatusAction, getDataAction, setAction, setTableAction } from '../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable from '@/modules/@common/Table'; +import MacrosModal from './MacrosModal/MacrosModal'; +import { + Autocomplete, + Button, + Checkbox, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + Select, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './List.module.scss'; + +const getLabelFromValue = (options, value) => options.find((option) => option.value === value)?.label; + +function List() { + const dispatch = useDispatch(); + const router = useRouter(); + + const [showMacros, setShowMacros] = useState(null); + const [actions, setActions] = useState(null); + + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + const selected = useSelector(selectors.selectedSelector); + + const search = useSelector(selectors.searchSelector); + const status = useSelector(selectors.statusSelector); + const type = useSelector(selectors.typeSelector); + + const shouldShowEmpty = false; + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + const handleSelectDeselectAllRows = (checked) => { + dispatch( + setTableAction({ + selected: checked ? data.map((r) => r.id) : [], + }), + ); + }; + + const handleSelectDeselectRow = (rowId) => { + const selectedIndex = selected.indexOf(rowId); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, rowId); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + dispatch( + setTableAction({ + selected: newSelected, + }), + ); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + <> + + + {actions && ( + setActions('')}> + { + dispatch(bulkChangeStatusAction(STATUSES_ACTIVE)); + setActions(''); + }} + > + Active + + { + dispatch(bulkChangeStatusAction(STATUSES_INACTIVE)); + setActions(''); + }} + > + Inactive + + + )} +
+ handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
+ + + + + s.value === status) ?? STATUSES_ALL} + options={STATUSES_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ status: selected$?.value ?? STATUSES_ALL, page: 1 })} + renderInput={(params) => } + /> + + + } + renderActions={ + + + + + + } + > + { + const isItemSelected = selectedRows.includes(row.id); + const statusLabel = getLabelFromValue(STATUSES_OPTIONS, row.status); + const typeLabel = getLabelFromValue(TYPES_OPTIONS, row.placementId); + return ( + router.push(`/ad-servers/${row.id}`)} + > + + { + e.stopPropagation(); + onSelectDeselectRow(row.id); + }} + /> + + + +
{row.name}
+
+ + +
{row.url}
+
+ + +
{typeLabel}
+
+ + +
+
+
+ + +
{statusLabel}
+
+ + +
{row.comments}
+
+ + +
{row.updatedBy}
+
+ + +
{dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
+
+
+ ); + }} + data={data || []} + selected={selected} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onSelectDeselectAllRows={handleSelectDeselectAllRows} + onSelectDeselectRow={handleSelectDeselectRow} + onFilter={handleFilter} + /> +
+ {showMacros && ( + setShowMacros(null)} + /> + )} + + ); +} + +export default List; diff --git a/anyclip/src/modules/adServers/List/components/List.module.scss b/anyclip/src/modules/adServers/List/components/List.module.scss new file mode 100644 index 0000000..9153b93 --- /dev/null +++ b/anyclip/src/modules/adServers/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Row":"List_Row__wofRz","NoWrap":"List_NoWrap__l_OEs","SearchField":"List_SearchField__Z_2te","Select":"List_Select__7bUa_"}; \ No newline at end of file diff --git a/anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.jsx b/anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.jsx new file mode 100644 index 0000000..63e844a --- /dev/null +++ b/anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@/mui/components'; + +import styles from './MacrosModal.module.scss'; + +function MacrosModal(props) { + return ( + + {`${props.title} Macros`} + + + {props.macros} + + + + + + + ); +} + +MacrosModal.propTypes = { + open: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + macros: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default MacrosModal; diff --git a/anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.module.scss b/anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.module.scss new file mode 100644 index 0000000..7675d21 --- /dev/null +++ b/anyclip/src/modules/adServers/List/components/MacrosModal/MacrosModal.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Macros":"MacrosModal_Macros__6N2Oy"}; \ No newline at end of file diff --git a/anyclip/src/modules/adServers/List/constants/index.js b/anyclip/src/modules/adServers/List/constants/index.js new file mode 100644 index 0000000..47ac4f6 --- /dev/null +++ b/anyclip/src/modules/adServers/List/constants/index.js @@ -0,0 +1,73 @@ +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; +export const ROWS_PER_PAGE_DEFAULT = 15; +export const TABLE_SORT_BY = 'updatedAt'; +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const TABLE_HEADERS = [ + { + id: 'name', + label: 'Name', + width: '200', + sortable: true, + }, + { + id: 'url', + label: 'Tag URL', + width: '200', + }, + { + id: 'placementId', + label: 'Type', + width: '200', + }, + { + id: 'macro', + label: 'Macros', + width: '200', + align: 'center', + }, + { + id: 'status', + label: 'Status', + width: '200', + }, + { + id: 'comments', + label: 'Comments', + width: '200', + }, + { + id: 'updatedBy', + label: 'Updated By', + width: '200', + }, + { + id: 'updatedAt', + label: 'Updated At', + width: '200', + sortable: true, + }, +]; + +// Status Select +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = 0; + +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Inactive', value: STATUSES_INACTIVE }, +]; + +// Type Select +export const TYPE_ALL = 'all'; +export const TYPE_VIDEO = 1; +export const TYPE_DISPLAY = 2; +export const TYPE_DISPLAY_FALLBACK = 3; + +export const TYPES_OPTIONS = [ + { label: 'All Types', value: TYPE_ALL }, + { label: 'Video', value: TYPE_VIDEO }, + { label: 'Display', value: TYPE_DISPLAY }, + { label: 'Display Fallback', value: TYPE_DISPLAY_FALLBACK }, +]; diff --git a/anyclip/src/modules/adServers/List/redux/epics/bulkChangeStatusAction.js b/anyclip/src/modules/adServers/List/redux/epics/bulkChangeStatusAction.js new file mode 100644 index 0000000..715300c --- /dev/null +++ b/anyclip/src/modules/adServers/List/redux/epics/bulkChangeStatusAction.js @@ -0,0 +1,67 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, filter, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { BULK_CHANGE_STATUS_ACTION } from '@/graphql/services/adsServers/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/adsServers/types/payload/bulkChangeStatusAction'; + +import * as selectors from '../selectors'; +import { bulkChangeStatusAction, getDataAction, setTableAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${BULK_CHANGE_STATUS_ACTION} ($payload: ${PAYLOAD_NAME}) { + ${BULK_CHANGE_STATUS_ACTION}(payload: $payload){ + ids + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(bulkChangeStatusAction.type), + filter(() => { + const selected = selectors.selectedSelector(state$.value); + + return selected.length > 0; + }), + switchMap((action) => { + const selected = selectors.selectedSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + ids: selected, + status: action.payload, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of(getDataAction()), + of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Action completed successfully', + }), + ), + of( + setTableAction({ + selected: [], + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/adServers/List/redux/epics/getData.js b/anyclip/src/modules/adServers/List/redux/epics/getData.js new file mode 100644 index 0000000..55cd31a --- /dev/null +++ b/anyclip/src/modules/adServers/List/redux/epics/getData.js @@ -0,0 +1,73 @@ +import { STATUSES_ALL, TYPE_ALL } from '../../constants'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query getAdServersPCN( + $page: Int + $pageSize: Int + $sortBy: String + $sortOrder: String + $searchText: String + $status: Int + $placementId: Int + ) { + getAdServersPCN( + page: $page + pageSize: $pageSize + sortBy: $sortBy + sortOrder: $sortOrder + searchText: $searchText + status: $status + placementId: $placementId + ) { + records { + id + name + url + placementId + macro + comments + updatedBy + updatedAt + status + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const type = selectors.typeSelector(state); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = status; + } + + if (type !== TYPE_ALL) { + variables.placementId = type; + } + + return variables; + }, + processResponse: ({ data: { getAdServersPCN } }) => ({ + records: getAdServersPCN.records, + recordsTotal: getAdServersPCN.recordsTotal, + allRecordsCount: getAdServersPCN.recordsTotal, + }), + setTableAction, +}); diff --git a/anyclip/src/modules/adServers/List/redux/epics/index.js b/anyclip/src/modules/adServers/List/redux/epics/index.js new file mode 100644 index 0000000..fbdee8a --- /dev/null +++ b/anyclip/src/modules/adServers/List/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import bulkChangeStatusAction from './bulkChangeStatusAction'; +import getData from './getData'; + +export default combineEpics(getData, bulkChangeStatusAction); diff --git a/anyclip/src/modules/adServers/List/redux/selectors/index.js b/anyclip/src/modules/adServers/List/redux/selectors/index.js new file mode 100644 index 0000000..8f688e9 --- /dev/null +++ b/anyclip/src/modules/adServers/List/redux/selectors/index.js @@ -0,0 +1,22 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; +export const statusSelector = (state) => state[nameSpace].status; +export const typeSelector = (state) => state[nameSpace].type; diff --git a/anyclip/src/modules/adServers/List/redux/slices/index.js b/anyclip/src/modules/adServers/List/redux/slices/index.js new file mode 100644 index 0000000..49b9453 --- /dev/null +++ b/anyclip/src/modules/adServers/List/redux/slices/index.js @@ -0,0 +1,41 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ALL, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY, TYPE_ALL } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + status: STATUSES_ALL, + type: TYPE_ALL, +}; + +export const slice = createSlice({ + name: '@@AD_SERVERS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + bulkChangeStatusAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, bulkChangeStatusAction } = slice.actions; diff --git a/anyclip/src/modules/advertisers/Editor/components/Editor.jsx b/anyclip/src/modules/advertisers/Editor/components/Editor.jsx new file mode 100644 index 0000000..ef2ef6c --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/components/Editor.jsx @@ -0,0 +1,141 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_GENERAL } from '../constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const name = useSelector(selectors.nameSelector); + const activeTabId = useSelector(selectors.activeTabIdSelector); + + const id = parseInt(router.query.id, 10); + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( +
+ + + {id ? `${name} > Settings` : 'New Advertiser'} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
+ + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
+
+ ); +} + +export default Editor; diff --git a/anyclip/src/modules/advertisers/Editor/components/Editor.module.scss b/anyclip/src/modules/advertisers/Editor/components/Editor.module.scss new file mode 100644 index 0000000..3d85e92 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__k0AAh","Title":"Editor_Title__IH5mw","Controls":"Editor_Controls__kgT2c","Tabs":"Editor_Tabs__3IuC9"}; \ No newline at end of file diff --git a/anyclip/src/modules/advertisers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/advertisers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..f37ca0f --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import * as selectors from '../../../redux/selectors'; +import { getAccountOptionsAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, TextField } from '@/mui/components'; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + // selectors + const name = useSelector(selectors.nameSelector); + const account = useSelector(selectors.accountSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + const scheme = useSelector(selectors.schemeSelector); + + const id = parseInt(router.query.id, 10); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + ( + dispatch(removeErrorByPropAction(['account']))} + /> + )} + filterSelectedOptions + onOpen={() => dispatch(getAccountOptionsAction())} + onChange={(e, account$) => + handleSetState({ + account: account$, + }) + } + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/advertisers/Editor/constants/index.js b/anyclip/src/modules/advertisers/Editor/constants/index.js new file mode 100644 index 0000000..78fb9e4 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/constants/index.js @@ -0,0 +1,3 @@ +export const TAB_GENERAL = 'general'; + +export const REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/advertisers/Editor/helpers/validationScheme.js b/anyclip/src/modules/advertisers/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..cf80d32 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/helpers/validationScheme.js @@ -0,0 +1,26 @@ +import { TAB_GENERAL } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'account', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/advertisers/Editor/redux/epics/createItem.js b/anyclip/src/modules/advertisers/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..926bc61 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/redux/epics/createItem.js @@ -0,0 +1,56 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_ADVERTISER } from '../../../../../graphql/services/advertisers/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/advertisers/types/payload/item'; + +import { accountSelector, nameSelector } from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_ADVERTISER}($payload: ${PAYLOAD_NAME}) { + ${CREATE_ADVERTISER}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const name = nameSelector(state$.value); + const account = accountSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + name, + demandAccountId: account.id, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/advertisers'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Advertiser created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/advertisers/Editor/redux/epics/getAccountOptions.js b/anyclip/src/modules/advertisers/Editor/redux/epics/getAccountOptions.js new file mode 100644 index 0000000..61b7365 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/redux/epics/getAccountOptions.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ADVERTISER_DM_ACCOUNTS_OPTIONS } from '../../../../../graphql/services/advertisers/constants'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_ADVERTISER_DM_ACCOUNTS_OPTIONS} { + ${GET_ADVERTISER_DM_ACCOUNTS_OPTIONS} { + id + name + } + } +`; + +const getResponse = ({ data }) => data[GET_ADVERTISER_DM_ACCOUNTS_OPTIONS]; + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push( + of( + setAction({ + accountOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + accountOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/advertisers/Editor/redux/epics/getItem.js b/anyclip/src/modules/advertisers/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..6df80b7 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/redux/epics/getItem.js @@ -0,0 +1,79 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ADVERTISER } from '../../../../../graphql/services/advertisers/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/advertisers/types/payload/advertiser'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_ADVERTISER}($payload: ${PAYLOAD_NAME}) { + ${GET_ADVERTISER}(payload: $payload) { + id + name + account { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_ADVERTISER]; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: action.payload.id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open advertiser for edit", + }), + ), + ); + + Router.push('/advertisers'); + } else { + const data = getResponse(response); + + actions.push( + of( + setAction({ + ...data, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/advertisers/Editor/redux/epics/index.js b/anyclip/src/modules/advertisers/Editor/redux/epics/index.js new file mode 100644 index 0000000..8cc23d3 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getAccountOptions from './getAccountOptions'; +import getItem from './getItem'; +import updateItem from './updateItem'; + +export default combineEpics(getItem, getAccountOptions, createItem, updateItem); diff --git a/anyclip/src/modules/advertisers/Editor/redux/epics/updateItem.js b/anyclip/src/modules/advertisers/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..65f4d86 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/redux/epics/updateItem.js @@ -0,0 +1,59 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_ADVERTISER } from '../../../../../graphql/services/advertisers/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/advertisers/types/payload/item'; + +import { nameSelector } from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` +mutation ${UPDATE_ADVERTISER}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_ADVERTISER}(payload: $payload) { + id + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const name = nameSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: action.payload, + name, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/advertisers'); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Advertiser updated', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/advertisers/Editor/redux/selectors/index.js b/anyclip/src/modules/advertisers/Editor/redux/selectors/index.js new file mode 100644 index 0000000..d859b99 --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/redux/selectors/index.js @@ -0,0 +1,18 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const idSelector = (state) => state[nameSpace].id; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +export const nameSelector = (state) => state[nameSpace].name; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; +// form +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/advertisers/Editor/redux/slices/index.js b/anyclip/src/modules/advertisers/Editor/redux/slices/index.js new file mode 100644 index 0000000..e6be0fb --- /dev/null +++ b/anyclip/src/modules/advertisers/Editor/redux/slices/index.js @@ -0,0 +1,64 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + name: '', + account: null, + accountOptions: null, + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@ADVERTISERS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getAccountOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getAccountOptionsAction, + createItemAction, + updateItemAction, + + setActiveTabIdAction, + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/advertisers/List/components/Empty/Empty.jsx b/anyclip/src/modules/advertisers/List/components/Empty/Empty.jsx new file mode 100644 index 0000000..fbe2f06 --- /dev/null +++ b/anyclip/src/modules/advertisers/List/components/Empty/Empty.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { AddRounded } from '@mui/icons-material'; + +import { Button, Grid, Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +function Empty() { + const router = useRouter(); + return ( + + + empty-logo + + Click below to create your first Advertiser + + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/advertisers/List/components/Empty/Empty.module.scss b/anyclip/src/modules/advertisers/List/components/Empty/Empty.module.scss new file mode 100644 index 0000000..8155c47 --- /dev/null +++ b/anyclip/src/modules/advertisers/List/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__3lOYZ","EmptyContent":"Empty_EmptyContent___BFrl"}; \ No newline at end of file diff --git a/anyclip/src/modules/advertisers/List/components/List.jsx b/anyclip/src/modules/advertisers/List/components/List.jsx new file mode 100644 index 0000000..487fd87 --- /dev/null +++ b/anyclip/src/modules/advertisers/List/components/List.jsx @@ -0,0 +1,150 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useRouter } from 'next/router'; +import { AddRounded, SearchRounded } from '@mui/icons-material'; + +import { SEARCH_TEXT_MAX_LENGTH } from '../constants'; + +import { getConfigHeaders } from '../helpers'; +import * as computedState from '../helpers/computedState'; +import * as selectors from '../redux/selectors'; +import { getDataAction, setAction, setTableAction } from '../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable from '@/modules/@common/Table'; +import Empty from './Empty/Empty'; +import { Button, IconButton, InputAdornment, Stack, TableCell, TableRow, TextField, Tooltip } from '@/mui/components'; + +import styles from './List.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); +function List() { + const router = useRouter(); + + const dispatch = useDispatch(); + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + + const search = useSelector(selectors.searchSelector); + + const shouldShowEmpty = useSelector(computedState.shouldShowEmpty); + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + +
+ handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
+ + } + renderActions={ + + + + } + > + {shouldShowEmpty ? ( + + ) : ( + ( + router.push(`/advertisers/${row.id}`)}> + +
{row.id}
+
+ +
{row.name}
+
+ +
{row.accountName}
+
+ +
{row.updatedBy}
+
+ +
{dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
+
+
+ )} + data={data || []} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onFilter={handleFilter} + /> + )} +
+ ); +} + +export default List; diff --git a/anyclip/src/modules/advertisers/List/components/List.module.scss b/anyclip/src/modules/advertisers/List/components/List.module.scss new file mode 100644 index 0000000..b6dc85d --- /dev/null +++ b/anyclip/src/modules/advertisers/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"ActionsSelect":"List_ActionsSelect__l08LW","SearchField":"List_SearchField__kUUNp","AccountSelect":"List_AccountSelect__z_cUI","RolesSelect":"List_RolesSelect__JjkZ_","StatusSelect":"List_StatusSelect__e1s0g","Row":"List_Row__hDJ9_","NoWrap":"List_NoWrap__KJjlk","Actions":"List_Actions__Ne4Vk"}; \ No newline at end of file diff --git a/anyclip/src/modules/advertisers/List/constants/index.js b/anyclip/src/modules/advertisers/List/constants/index.js new file mode 100644 index 0000000..e4754f5 --- /dev/null +++ b/anyclip/src/modules/advertisers/List/constants/index.js @@ -0,0 +1,6 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const ROWS_PER_PAGE_DEFAULT = 15; +export const TABLE_SORT_BY = 'updatedAt'; +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/advertisers/List/helpers/computedState.js b/anyclip/src/modules/advertisers/List/helpers/computedState.js new file mode 100644 index 0000000..ba68e8d --- /dev/null +++ b/anyclip/src/modules/advertisers/List/helpers/computedState.js @@ -0,0 +1,12 @@ +import * as selectors from '../redux/selectors'; + +export const shouldShowEmpty = (state) => { + const data = selectors.dataSelector(state); + const page = selectors.pageSelector(state); + const search = selectors.searchSelector(state); + const isLoading = selectors.isLoadingSelector(state); + + return !isLoading && Array.isArray(data) && !data.length && page === 1 && !search; +}; + +export default {}; diff --git a/anyclip/src/modules/advertisers/List/helpers/index.js b/anyclip/src/modules/advertisers/List/helpers/index.js new file mode 100644 index 0000000..dd2a7eb --- /dev/null +++ b/anyclip/src/modules/advertisers/List/helpers/index.js @@ -0,0 +1,32 @@ +export const getConfigHeaders = () => + [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '100', + }, + { + id: 'name', + label: 'Advertiser Name', + sortable: true, + width: '266', + }, + { + id: 'accountName', + label: 'Demand Account', + width: '115', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: '240', + }, + { + id: 'updatedAt', + label: 'Updated Date', + sortable: true, + width: '240', + }, + ].filter(Boolean); diff --git a/anyclip/src/modules/advertisers/List/redux/epics/getData.js b/anyclip/src/modules/advertisers/List/redux/epics/getData.js new file mode 100644 index 0000000..d744d1d --- /dev/null +++ b/anyclip/src/modules/advertisers/List/redux/epics/getData.js @@ -0,0 +1,51 @@ +import { GET_ADVERTISERS } from '../../../../../graphql/services/advertisers/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/advertisers/types/payload/advertisers'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_ADVERTISERS}($payload: ${PAYLOAD_NAME}) { + ${GET_ADVERTISERS}(payload: $payload) { + records { + id + name + accountName + salesforceId + updatedBy + updatedAt + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const users = data[GET_ADVERTISERS]; + + return { + records: users.records, + recordsTotal: users.recordsTotal, + allRecordsCount: users.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/advertisers/List/redux/epics/index.js b/anyclip/src/modules/advertisers/List/redux/epics/index.js new file mode 100644 index 0000000..1775aad --- /dev/null +++ b/anyclip/src/modules/advertisers/List/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import getData from './getData'; + +export default combineEpics(getData); diff --git a/anyclip/src/modules/advertisers/List/redux/selectors/index.js b/anyclip/src/modules/advertisers/List/redux/selectors/index.js new file mode 100644 index 0000000..6164565 --- /dev/null +++ b/anyclip/src/modules/advertisers/List/redux/selectors/index.js @@ -0,0 +1,20 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; diff --git a/anyclip/src/modules/advertisers/List/redux/slices/index.js b/anyclip/src/modules/advertisers/List/redux/slices/index.js new file mode 100644 index 0000000..d94b0ee --- /dev/null +++ b/anyclip/src/modules/advertisers/List/redux/slices/index.js @@ -0,0 +1,38 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', +}; + +export const slice = createSlice({ + name: '@@ADVERTISERS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + }, +}); + +export const { getDataAction, setTableAction, setAction } = slice.actions; diff --git a/src/modules/analytics/common/components/AreaGraph/CustomTooltip.jsx b/anyclip/src/modules/analytics/common/components/AreaGraph/CustomTooltip.jsx similarity index 100% rename from src/modules/analytics/common/components/AreaGraph/CustomTooltip.jsx rename to anyclip/src/modules/analytics/common/components/AreaGraph/CustomTooltip.jsx diff --git a/src/modules/analytics/common/components/AreaGraph/CustomTooltip.module.scss b/anyclip/src/modules/analytics/common/components/AreaGraph/CustomTooltip.module.scss similarity index 100% rename from src/modules/analytics/common/components/AreaGraph/CustomTooltip.module.scss rename to anyclip/src/modules/analytics/common/components/AreaGraph/CustomTooltip.module.scss diff --git a/src/modules/analytics/common/components/AreaGraph/index.jsx b/anyclip/src/modules/analytics/common/components/AreaGraph/index.jsx similarity index 100% rename from src/modules/analytics/common/components/AreaGraph/index.jsx rename to anyclip/src/modules/analytics/common/components/AreaGraph/index.jsx diff --git a/src/modules/analytics/common/components/DialogCalendarRange/DialogCalendarRange.module.scss b/anyclip/src/modules/analytics/common/components/DialogCalendarRange/DialogCalendarRange.module.scss similarity index 100% rename from src/modules/analytics/common/components/DialogCalendarRange/DialogCalendarRange.module.scss rename to anyclip/src/modules/analytics/common/components/DialogCalendarRange/DialogCalendarRange.module.scss diff --git a/src/modules/analytics/common/components/DialogCalendarRange/index.jsx b/anyclip/src/modules/analytics/common/components/DialogCalendarRange/index.jsx similarity index 100% rename from src/modules/analytics/common/components/DialogCalendarRange/index.jsx rename to anyclip/src/modules/analytics/common/components/DialogCalendarRange/index.jsx diff --git a/src/modules/analytics/common/components/GlobalStateEmpty/GlobalStateEmpty.module.scss b/anyclip/src/modules/analytics/common/components/GlobalStateEmpty/GlobalStateEmpty.module.scss similarity index 100% rename from src/modules/analytics/common/components/GlobalStateEmpty/GlobalStateEmpty.module.scss rename to anyclip/src/modules/analytics/common/components/GlobalStateEmpty/GlobalStateEmpty.module.scss diff --git a/src/modules/analytics/common/components/GlobalStateEmpty/img/img.png b/anyclip/src/modules/analytics/common/components/GlobalStateEmpty/img/img.png similarity index 100% rename from src/modules/analytics/common/components/GlobalStateEmpty/img/img.png rename to anyclip/src/modules/analytics/common/components/GlobalStateEmpty/img/img.png diff --git a/src/modules/analytics/common/components/GlobalStateEmpty/index.jsx b/anyclip/src/modules/analytics/common/components/GlobalStateEmpty/index.jsx similarity index 100% rename from src/modules/analytics/common/components/GlobalStateEmpty/index.jsx rename to anyclip/src/modules/analytics/common/components/GlobalStateEmpty/index.jsx diff --git a/src/modules/analytics/common/components/GlobalStateError/GlobalStateEmpty.module.scss b/anyclip/src/modules/analytics/common/components/GlobalStateError/GlobalStateEmpty.module.scss similarity index 100% rename from src/modules/analytics/common/components/GlobalStateError/GlobalStateEmpty.module.scss rename to anyclip/src/modules/analytics/common/components/GlobalStateError/GlobalStateEmpty.module.scss diff --git a/src/modules/analytics/common/components/GlobalStateError/index.jsx b/anyclip/src/modules/analytics/common/components/GlobalStateError/index.jsx similarity index 100% rename from src/modules/analytics/common/components/GlobalStateError/index.jsx rename to anyclip/src/modules/analytics/common/components/GlobalStateError/index.jsx diff --git a/src/modules/analytics/common/components/Header/Header.module.scss b/anyclip/src/modules/analytics/common/components/Header/Header.module.scss similarity index 100% rename from src/modules/analytics/common/components/Header/Header.module.scss rename to anyclip/src/modules/analytics/common/components/Header/Header.module.scss diff --git a/src/modules/analytics/common/components/Header/index.jsx b/anyclip/src/modules/analytics/common/components/Header/index.jsx similarity index 100% rename from src/modules/analytics/common/components/Header/index.jsx rename to anyclip/src/modules/analytics/common/components/Header/index.jsx diff --git a/src/modules/analytics/common/components/Layout/Layout.module.scss b/anyclip/src/modules/analytics/common/components/Layout/Layout.module.scss similarity index 100% rename from src/modules/analytics/common/components/Layout/Layout.module.scss rename to anyclip/src/modules/analytics/common/components/Layout/Layout.module.scss diff --git a/src/modules/analytics/common/components/Layout/index.jsx b/anyclip/src/modules/analytics/common/components/Layout/index.jsx similarity index 100% rename from src/modules/analytics/common/components/Layout/index.jsx rename to anyclip/src/modules/analytics/common/components/Layout/index.jsx diff --git a/src/modules/analytics/common/components/RoundItemContainer/RoundItemContainer.module.scss b/anyclip/src/modules/analytics/common/components/RoundItemContainer/RoundItemContainer.module.scss similarity index 100% rename from src/modules/analytics/common/components/RoundItemContainer/RoundItemContainer.module.scss rename to anyclip/src/modules/analytics/common/components/RoundItemContainer/RoundItemContainer.module.scss diff --git a/src/modules/analytics/common/components/RoundItemContainer/index.jsx b/anyclip/src/modules/analytics/common/components/RoundItemContainer/index.jsx similarity index 100% rename from src/modules/analytics/common/components/RoundItemContainer/index.jsx rename to anyclip/src/modules/analytics/common/components/RoundItemContainer/index.jsx diff --git a/src/modules/analytics/common/components/Stub/Stub.module.scss b/anyclip/src/modules/analytics/common/components/Stub/Stub.module.scss similarity index 100% rename from src/modules/analytics/common/components/Stub/Stub.module.scss rename to anyclip/src/modules/analytics/common/components/Stub/Stub.module.scss diff --git a/src/modules/analytics/common/components/Stub/img/imgError.png b/anyclip/src/modules/analytics/common/components/Stub/img/imgError.png similarity index 100% rename from src/modules/analytics/common/components/Stub/img/imgError.png rename to anyclip/src/modules/analytics/common/components/Stub/img/imgError.png diff --git a/src/modules/analytics/common/components/Stub/img/imgNoData.png b/anyclip/src/modules/analytics/common/components/Stub/img/imgNoData.png similarity index 100% rename from src/modules/analytics/common/components/Stub/img/imgNoData.png rename to anyclip/src/modules/analytics/common/components/Stub/img/imgNoData.png diff --git a/src/modules/analytics/common/components/Stub/index.jsx b/anyclip/src/modules/analytics/common/components/Stub/index.jsx similarity index 100% rename from src/modules/analytics/common/components/Stub/index.jsx rename to anyclip/src/modules/analytics/common/components/Stub/index.jsx diff --git a/src/modules/analytics/common/components/index.js b/anyclip/src/modules/analytics/common/components/index.js similarity index 100% rename from src/modules/analytics/common/components/index.js rename to anyclip/src/modules/analytics/common/components/index.js diff --git a/src/modules/analytics/common/components/muiCustomComponents/ConfirmDialog/index.jsx b/anyclip/src/modules/analytics/common/components/muiCustomComponents/ConfirmDialog/index.jsx similarity index 100% rename from src/modules/analytics/common/components/muiCustomComponents/ConfirmDialog/index.jsx rename to anyclip/src/modules/analytics/common/components/muiCustomComponents/ConfirmDialog/index.jsx diff --git a/anyclip/src/modules/analytics/common/constants/index.js b/anyclip/src/modules/analytics/common/constants/index.js new file mode 100644 index 0000000..54408dc --- /dev/null +++ b/anyclip/src/modules/analytics/common/constants/index.js @@ -0,0 +1,6 @@ +export const WIDGET_DISPLAY_STATE_DATA = 'HAS_DATA'; +export const WIDGET_DISPLAY_STATE_NO_DATA = 'HAS_NO_DATA'; +export const WIDGET_DISPLAY_STATE_ERROR = 'HAS_ERROR'; + +export const PDF_EXPORT_HIDE_CONTENT = 'content'; +export const PDF_EXPORT_HIDE_SCROLL = 'scroll'; diff --git a/anyclip/src/modules/analytics/common/helpers/index.js b/anyclip/src/modules/analytics/common/helpers/index.js new file mode 100644 index 0000000..87fe407 --- /dev/null +++ b/anyclip/src/modules/analytics/common/helpers/index.js @@ -0,0 +1,66 @@ +import { PDF_EXPORT_HIDE_CONTENT, PDF_EXPORT_HIDE_SCROLL } from '@/modules/analytics/common/constants'; + +export const html2pdf = async (fileName, DOMNode) => { + const cache = []; + + DOMNode.querySelectorAll('[data-pdf-export]').forEach((node) => { + const exportAttr = node.getAttribute('data-pdf-export'); + + if (exportAttr === PDF_EXPORT_HIDE_SCROLL) { + cache.push({ + node, + style: { + overflow: node.style.overflow ?? '', + }, + }); + + // eslint-disable-next-line no-param-reassign + node.style.overflow = 'hidden'; + } + + if (exportAttr === PDF_EXPORT_HIDE_CONTENT) { + cache.push({ + node, + style: { + opacity: node.style.opacity ?? '', + visibility: node.style.visibility ?? '', + }, + }); + // eslint-disable-next-line no-param-reassign + node.style.opacity = '0'; + // eslint-disable-next-line no-param-reassign + node.style.visibility = 'hidden'; + } + }); + + const { jsPDF: JsPDF } = await import('jspdf'); + + const { toJpeg } = await import('html-to-image'); + const dataUrl = await toJpeg(DOMNode); + + cache.forEach((item) => { + Object.entries(item.style).forEach(([key, value]) => { + // eslint-disable-next-line no-param-reassign + item.node.style[key] = value; + }); + }); + + const img = new Image(); + img.src = dataUrl; + + await new Promise((resolve) => { + img.onload = resolve; + }); + + const pdf = new JsPDF({ + orientation: 'landscape', + }); + const imgProps = pdf.getImageProperties(img); + const pdfWidth = pdf.internal.pageSize.getWidth(); + const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width; + + pdf.addImage(img, 'JPEG', 0, 0, pdfWidth, pdfHeight); + pdf.save(`${fileName}.pdf`); + + return dataUrl; +}; diff --git a/src/modules/analytics/customReports/components/CustomReports.module.scss b/anyclip/src/modules/analytics/customReports/components/CustomReports.module.scss similarity index 100% rename from src/modules/analytics/customReports/components/CustomReports.module.scss rename to anyclip/src/modules/analytics/customReports/components/CustomReports.module.scss diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/ReportSetup.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/ReportSetup.module.scss new file mode 100644 index 0000000..f888d5b --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/ReportSetup.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Layout":"ReportSetup_Layout__Rgr8I","Header":"ReportSetup_Header__lgQ6F","Main":"ReportSetup_Main__Lky7t","Info":"ReportSetup_Info__npTt5","PreviewWrap":"ReportSetup_PreviewWrap__TWHEo","PreviewWrap___empty":"ReportSetup_PreviewWrap___empty__DK8vl"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/CancelDialog/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/CancelDialog/index.jsx new file mode 100644 index 0000000..c9aa14b --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/CancelDialog/index.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function CancelDialog(props) { + return ( + + Confirm quitting + {"The changes you've made will be lost. Are you sure you want to quit?"} + + + + + + ); +} + +CancelDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default CancelDialog; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/DimensionItem.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/DimensionItem.module.scss new file mode 100644 index 0000000..dc5e927 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/DimensionItem.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"DimensionItem":"DimensionItem_DimensionItem__zoNEp","DimensionItem___disabled":"DimensionItem_DimensionItem___disabled__A_tHA","Label":"DimensionItem_Label__xH_Pe","Wrapper":"DimensionItem_Wrapper__43ogv","Wrapper___grabbing":"DimensionItem_Wrapper___grabbing__PYmpo"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/index.jsx new file mode 100644 index 0000000..c96d91f --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/DimensionItem/index.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { DragIndicator } from '@mui/icons-material'; + +import { Checkbox, Stack, Typography } from '@/mui/components'; + +import styles from './DimensionItem.module.scss'; + +function DimensionItem(props) { + return ( +
+ + + + + {props.label} + + + + +
+ ); +} + +DimensionItem.propTypes = { + label: PropTypes.string.isRequired, + checked: PropTypes.bool.isRequired, + isDragging: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, +}; + +export default DimensionItem; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/EmptyDialog/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/EmptyDialog/index.jsx new file mode 100644 index 0000000..20f75ea --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/EmptyDialog/index.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function EmptyDialog(props) { + return ( + + Cannot save report + Please select at least 1 metric + + + + + ); +} + +EmptyDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default EmptyDialog; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/Filters.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/Filters.module.scss new file mode 100644 index 0000000..f941ea7 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/Filters.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Filters":"Filters_Filters__z9nmF","Controls":"Filters_Controls__IW1XK","Divider":"Filters_Divider__aQvI5","Period":"Filters_Period__dAerq","FiltersValue":"Filters_FiltersValue__aVhtp","FiltersValuesButton":"Filters_FiltersValuesButton__vpD8a","FiltersValueIconCheck":"Filters_FiltersValueIconCheck__G23xX","FiltersValueIconRemove":"Filters_FiltersValueIconRemove__ro6_I"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/index.jsx new file mode 100644 index 0000000..11499b7 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Filters/index.jsx @@ -0,0 +1,239 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { CheckCircle, KeyboardArrowDown, KeyboardArrowUp, RemoveCircle, TuneOutlined } from '@mui/icons-material'; + +import { CALENDAR_FILTER, CALENDAR_FILTER_VALUE, FILTERS_LIST } from '../../../../constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { debounce } from '@/modules/@common/helpers/events'; + +import { DialogCalendarRange } from '@/modules/analytics/common/components'; +import FiltersDialog from '../FiltersDialog'; +import { Button, Chip, FormControl, InputLabel, MenuItem, Select } from '@/mui/components'; + +import styles from './Filters.module.scss'; + +function Filters() { + const dispatch = useDispatch(); + + const period = useSelector(selectors.periodSelector); + const periodCustomStartDate = useSelector(selectors.periodCustomStartDateSelector); + const periodCustomEndDate = useSelector(selectors.periodCustomEndDateSelector); + const isReadOnly = useSelector(selectors.isReadOnlySelector); + const filters = useSelector(selectors.filtersSelector); + + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [isFiltersDialogOpen, setIsFiltersDialogOpen] = useState(false); + const [isHideFilters, setIsHideFilters] = useState(false); + const [isShowingMore, setIsShowingMore] = useState(false); + + const ref = useRef(null); + + const filtersValues = FILTERS_LIST.reduce((acc, cur) => { + const valuesFromProps = filters[`${cur.name}_FILTER_VALUES`]; + const values = Array.isArray(valuesFromProps) ? valuesFromProps : [valuesFromProps]; // some filter is not multiple + if (valuesFromProps && values?.length) { + const fullInfoValues = values.map((item) => ({ + ...item, + action: filters[`${cur.name}_FILTER_ACTION`], + name: cur.name, + filterLabel: cur.label, + filterName: cur.name, + })); + return [...acc, ...fullInfoValues]; + } + return acc; + }, []); + + useEffect(() => { + setIsShowingMore(false); + }, [filtersValues?.length]); + + useEffect(() => { + let delayedCheck = null; + + const check = () => { + if (ref.current) { + const { offsetHeight, scrollHeight } = ref.current; + + setIsHideFilters(!!offsetHeight && !!scrollHeight && offsetHeight < scrollHeight); + } + }; + + check(); + + delayedCheck = debounce(check, 1000); + + window.addEventListener('resize', delayedCheck); + + return () => { + window.removeEventListener('resize', delayedCheck); + }; + }, [filtersValues?.length]); + + const handleFilterSetCalendar = (newPeriod) => { + dispatch( + actions.setFieldAction({ + period: newPeriod, + }), + ); + + if (newPeriod === CALENDAR_FILTER_VALUE.custom) { + setIsCalendarOpen(true); + } + }; + + const handleMenuItemClick = (itemValue) => { + if (itemValue === CALENDAR_FILTER_VALUE.custom) { + setIsCalendarOpen(true); + } + }; + + const handleDeleteFilter = (filter) => { + if (filter?.filterName) { + const prevValues = filters[`${filter.filterName}_FILTER_VALUES`]; + dispatch( + actions.setFieldAction({ + [`${filter.filterName}_FILTER_VALUES`]: prevValues.filter((item) => item.value !== filter.value), + }), + ); + } + }; + + return ( +
+
+ + +
+ + + Period + + +
+ + {!!filtersValues?.length && !isReadOnly && ( +
+
+ {filtersValues.map((item, index) => ( + + ) : ( + + ) + } + onDelete={() => { + handleDeleteFilter(item); + }} + label={`${item.filterLabel}: ${item.label}`} + /> + ))} +
+ {isHideFilters && ( + + )} +
+ )} + + { + setIsFiltersDialogOpen(false); + }} + onSubmit={() => { + setIsFiltersDialogOpen(false); + }} + /> + + { + setIsCalendarOpen(false); + dispatch( + actions.setFieldAction({ + period: CALENDAR_FILTER_VALUE.thisMonth, + }), + ); + }} + onDateSubmit={({ dateStart, dateEnd }) => { + dispatch( + actions.setFieldAction({ + periodCustomStartDate: dateStart, + periodCustomEndDate: dateEnd, + }), + ); + + setIsCalendarOpen(false); + }} + minDate={dayjs(new Date()).diff(dayjs(new Date(new Date().getFullYear() - 1, 0, 1)), 'day')} + /> +
+ ); +} + +export default Filters; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/FilterDialog.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/FilterDialog.module.scss new file mode 100644 index 0000000..97300fa --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/FilterDialog.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Filters":"FilterDialog_Filters__r4M1q"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/Filter.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/Filter.module.scss new file mode 100644 index 0000000..7e5f182 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/Filter.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Filter":"Filter_Filter__Bt5oA","Controls":"Filter_Controls__J6U6L","Select":"Filter_Select__uRvTA","Autocomplete":"Filter_Autocomplete__C0dn_"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/index.jsx new file mode 100644 index 0000000..644f653 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/components/Filter/index.jsx @@ -0,0 +1,103 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { CheckBoxRounded, IndeterminateCheckBoxRounded } from '@mui/icons-material'; + +import { Autocomplete, MenuItem, Select, Stack, TextField, Typography } from '@/mui/components'; + +import styles from './Filter.module.scss'; + +function Filter(props) { + return ( +
+ + {props.label} + + +
+ + + { + props.onChange(newValue); + }} + value={props.values} + renderInput={(params) => ( + { + if (props.onInputChange) { + props.onInputChange(e.target.value, props.name); + } + }} + variant="outlined" + fullWidth + /> + )} + limitTags={1} + disabled={props.disabled} + disableCloseOnSelect + filterSelectedOptions + /> +
+
+ ); +} + +Filter.propTypes = { + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + multiple: PropTypes.bool, + options: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + values: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + action: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, + onChangeAction: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, +}; + +export default Filter; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/index.jsx new file mode 100644 index 0000000..ace32b3 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/FiltersDialog/index.jsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; + +import { DEMAND_SOURCE_FILTER_NAME, FILTERS_LIST, HUB_FILTER_NAME } from '../../../../constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; + +import Filter from './components/Filter'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +import styles from './FilterDialog.module.scss'; + +function FiltersDialog(props) { + const dispatch = useDispatch(); + + const checkedMetrics = useSelector(selectors.checkedMetricsSelector); + const filters = useSelector(selectors.filtersSelector); + + const getDemandSources = (o) => dispatch(actions.getDemandSourcesAction(o)); + const getHubs = (o) => dispatch(actions.getHubsAction(o)); + + const FILTERS_ACTIONS = { + [DEMAND_SOURCE_FILTER_NAME]: getDemandSources, + [HUB_FILTER_NAME]: getHubs, + }; + + const getPrevState = () => + FILTERS_LIST.reduce( + (acc, cur) => ({ + ...acc, + [`${cur.name}_FILTER_VALUES`]: filters[`${cur.name}_FILTER_VALUES`], + [`${cur.name}_FILTER_ACTION`]: filters[`${cur.name}_FILTER_ACTION`], + }), + {}, + ); + + const [filtersState, setFiltersState] = useState(getPrevState()); + + useEffect(() => { + dispatch(actions.getDemandSourcesAction()); + dispatch(actions.getHubsAction()); + dispatch(actions.getCountriesAction()); + dispatch(actions.getUserPlayersAction()); + dispatch(actions.getUserDomainsAction()); + }, []); + + useEffect(() => { + if (props.open) { + setFiltersState(getPrevState()); + } + }, [props.open]); + + const handleSubmit = () => { + dispatch(actions.setFieldAction(filtersState)); + props.onSubmit(); + }; + + const handleInputChange = (value, filterName) => { + if (FILTERS_ACTIONS[filterName]) { + FILTERS_ACTIONS[filterName](value); + } + }; + + return ( + + Manage Filters + +
+ {FILTERS_LIST.map((item, index) => ( + { + setFiltersState({ + ...filtersState, + [`${item.name}_FILTER_VALUES`]: newValue, + }); + }} + onInputChange={handleInputChange} + onChangeAction={(newValue) => { + setFiltersState({ + ...filtersState, + [`${item.name}_FILTER_ACTION`]: newValue, + }); + }} + disabled={item.exceptMetrics?.some((value) => checkedMetrics?.includes(value))} + /> + ))} +
+
+ + + + + +
+ ); +} + +FiltersDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default FiltersDialog; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/MetricItem.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/MetricItem.module.scss new file mode 100644 index 0000000..7f4f4dc --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/MetricItem.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"MetricItem":"MetricItem_MetricItem__PiaLV","Accordion":"MetricItem_Accordion__KPfWQ","AccordionSummary":"MetricItem_AccordionSummary__rcJrc","AccordionSummary___disabled":"MetricItem_AccordionSummary___disabled__YJrDg","AccordionDetails":"MetricItem_AccordionDetails__nrule","AccordionDetails___disabled":"MetricItem_AccordionDetails___disabled__lRmbQ","Label":"MetricItem_Label__7Opsb","MetricItemMain":"MetricItem_MetricItemMain__zV1sY","SubMetricItem":"MetricItem_SubMetricItem__QV3OM"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/index.jsx new file mode 100644 index 0000000..4745809 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/MetricItem/index.jsx @@ -0,0 +1,140 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; + +import { Accordion, AccordionDetails, AccordionSummary, Checkbox, Typography } from '@/mui/components'; + +import styles from './MetricItem.module.scss'; + +function MetricItem(props) { + const subMetricValues = props.values?.map((value) => value.value); + const amountChecked = props.checkedMetrics.filter((item) => subMetricValues?.includes(item)) ?? []; + + const handleMetricItemChange = () => { + if (amountChecked?.length === subMetricValues?.length) { + props.setField({ + checkedMetrics: props.checkedMetrics.filter((item) => !subMetricValues.includes(item)), + }); + } else { + props.setField({ + checkedMetrics: [...new Set([...props.checkedMetrics, ...subMetricValues])], + }); + + // when select some metric (Ad Requesrs) needs to select some dimensions + if (props.selectWithDimensions?.length) { + props.selectWithDimensions.forEach((item) => { + props.checkDimension(item); + }); + } + } + }; + + const handleSubMetricItemChange = (value) => { + const isValueChecked = props.checkedMetrics?.includes(value); + if (isValueChecked) { + props.setField({ + checkedMetrics: props.checkedMetrics.filter((item) => item !== value), + }); + } else { + props.setField({ + checkedMetrics: [...props.checkedMetrics, value], + }); + } + }; + + return ( +
+ 1 ? props.expanded : false} + onChange={(event) => { + if (event.target.id !== 'metric-checkbox') { + props.onChangeAccordion(props.id); + } + }} + disabled={props.disabled} + > + 1 ? {} : { expandIcon: null })} + > +
+
+ 0 && amountChecked?.length < subMetricValues?.length} + onChange={handleMetricItemChange} + disabled={props.disabled} + /> + + + {props.label} + +
+ + {props.values?.length > 1 && ( + + {`${amountChecked?.length}/${props.values?.length}`} + + )} +
+
+ + {props.values.map((item, index) => ( +
+ { + handleSubMetricItemChange(item.value); + }} + disabled={props.disabled} + /> + + + {item.label} + +
+ ))} +
+
+
+ ); +} + +MetricItem.propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + expanded: PropTypes.bool.isRequired, + values: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + }), + ).isRequired, + checkedMetrics: PropTypes.arrayOf(PropTypes.string).isRequired, + selectWithDimensions: PropTypes.arrayOf(PropTypes.string).isRequired, + onChangeAccordion: PropTypes.func.isRequired, + setField: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, + checkDimension: PropTypes.func.isRequired, +}; + +export default MetricItem; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/Name.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/Name.module.scss new file mode 100644 index 0000000..319a1e5 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/Name.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Name":"Name_Name__Z2zMn","TextWrap":"Name_TextWrap__Ab79B","TextWrap___editMode":"Name_TextWrap___editMode__FueUF","Text":"Name_Text__iUYt7","Text___hidden":"Name_Text___hidden__adm0m","Textfield":"Name_Textfield__9BTS7","IconButton":"Name_IconButton__IUaqL"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/index.jsx new file mode 100644 index 0000000..0231e40 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Name/index.jsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { Check, Close, Edit } from '@mui/icons-material'; + +import { IconButton, TextField, Typography } from '@/mui/components'; + +import styles from './Name.module.scss'; + +function Name(props) { + const [isEditMode, setIsEditMode] = useState(false); + const [editingValue, setEditingValue] = useState(''); + + return ( +
+
+ + {props.isReadOnly ? `${props.name} (Read-Only)` : props.name} + + + {isEditMode && ( + { + setEditingValue(target.value.substring(0, 200)); + }} + variant="outlined" + size="medium" + /> + )} +
+ {!isEditMode && !props.isReadOnly && ( + { + setIsEditMode(true); + setEditingValue(props.name); + }} + > + + + )} + + {isEditMode && !props.isReadOnly && ( + <> + { + props.onChange(editingValue); + setIsEditMode(false); + }} + disabled={!editingValue?.length} + > + + + { + setIsEditMode(false); + }} + > + + + + )} +
+ ); +} + +Name.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + isReadOnly: PropTypes.bool.isRequired, +}; + +export default Name; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/Preview.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/Preview.module.scss new file mode 100644 index 0000000..0ff1d4a --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/Preview.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrap":"Preview_Wrap__M8ypu","Table":"Preview_Table__DXRsE","Row":"Preview_Row__jKAiJ","Cell":"Preview_Cell__Sm2kz","Item":"Preview_Item__UsEG2","Item___autoWidth":"Preview_Item___autoWidth__DpNNk","TitleContainer":"Preview_TitleContainer__D0jUZ","TitleContainer___empty":"Preview_TitleContainer___empty__iIPIa","Title":"Preview_Title__WM6LJ","Info":"Preview_Info__l_u3J","DimensionsRow":"Preview_DimensionsRow__3aXsP","DimensionsCell":"Preview_DimensionsCell__0PYJY"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/index.jsx new file mode 100644 index 0000000..62564e6 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/Preview/index.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { InfoOutlined } from '@mui/icons-material'; + +import { DIMENSIONS, METRICS_VALUES } from '../../../../constants'; + +import { combinations } from '../../../../helpers'; + +import { Tooltip, Typography } from '@/mui/components'; + +import styles from './Preview.module.scss'; + +function Preview(props) { + const dimensions = props.checkedDimensions?.length + ? combinations( + ...props.checkedDimensions.map((item) => [ + `${DIMENSIONS[item]?.previewTitle || DIMENSIONS[item]?.label} 1`, + `${DIMENSIONS[item]?.previewTitle || DIMENSIONS[item]?.label} 2`, + ]), + ) + : []; + + return ( +
+
+
+ {!!props.checkedDimensions?.length && ( +
+
+
+ + Dimensions + +
+
+ {dimensions?.map((row, index) => ( +
+ {row.map((cell) => ( +
+ + {cell} + +
+ ))} +
+ ))} +
+
+
+ )} + {props.checkedMetrics.map((item, index) => ( +
+
+
+ + {METRICS_VALUES[item].label} + +
+
+ + + +
+
+
+ ))} +
+
+
+ ); +} + +Preview.propTypes = { + checkedMetrics: PropTypes.arrayOf(PropTypes.string).isRequired, + checkedDimensions: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default Preview; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/ScheduleDialog.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/ScheduleDialog.module.scss new file mode 100644 index 0000000..7166afa --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/ScheduleDialog.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Content":"ScheduleDialog_Content__jtbWf","Filters":"ScheduleDialog_Filters__ZQQ4n","Filter":"ScheduleDialog_Filter__Ij0Km","Textfield":"ScheduleDialog_Textfield__twNsC","ChipsTitle":"ScheduleDialog_ChipsTitle__Ae9vO","Chips":"ScheduleDialog_Chips__jqjBS","Chip":"ScheduleDialog_Chip__GQsze","Hint":"ScheduleDialog_Hint__jL9_G","HintText":"ScheduleDialog_HintText__oitu6","Actions":"ScheduleDialog_Actions__QiSe_"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/index.jsx new file mode 100644 index 0000000..15c324e --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/ScheduleDialog/index.jsx @@ -0,0 +1,273 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; + +import { DAY_OPTIONS_WEEKLY, RECURRENCE_LIST, RECURRENCE_VALUES, TIME_OPTIONS } from '../../../../constants'; +import { KEY_ENTER } from '@/modules/@common/constants/keyCodes'; +import { emailRegExp } from '@/modules/@common/constants/validation'; + +import { getUserEmailSelector, getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import { + Autocomplete, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './ScheduleDialog.module.scss'; + +const DAY_OPTIONS = Array.from({ length: 31 }, (value, index) => index + 1).map((item) => ({ + label: item, + value: item, +})); + +function ScheduleDialog(props) { + const userEmail = useSelector(getUserEmailSelector); + const [scheduleType, setScheduleType] = useState(props.scheduleType); + const [scheduleDay, setScheduleDay] = useState(props.scheduleDay); + const [scheduleTime, setScheduleTime] = useState(props.scheduleTime); + const [recipients, setRecipients] = useState(props.recipients?.length ? props.recipients : [userEmail]); + const [email, setEmail] = useState(''); + const [isEmailError, setIsEmailError] = useState(false); + + const timezone = useSelector(getUserTimezoneSelector); + const dayList = scheduleType === RECURRENCE_VALUES.weekly.value ? DAY_OPTIONS_WEEKLY : DAY_OPTIONS; + + const setValuesFromProps = () => { + setScheduleType(props.scheduleType); + setScheduleDay(props.scheduleDay); + setScheduleTime(props.scheduleTime); + setRecipients(props.recipients?.length ? props.recipients : [userEmail]); + }; + + useEffect(() => { + if (props.open) { + setValuesFromProps(); + } + }, [props.open]); + + const handleScheduleTypeChange = (value) => { + setScheduleType(value); + + if (value === RECURRENCE_VALUES.daily.value) { + setScheduleDay(null); + setScheduleTime(TIME_OPTIONS[7].value); + } + + if (value === RECURRENCE_VALUES.weekly.value) { + setScheduleDay(DAY_OPTIONS_WEEKLY[0].value); + setScheduleTime(TIME_OPTIONS[7].value); + } + + if (value === RECURRENCE_VALUES.monthly.value) { + setScheduleDay(DAY_OPTIONS[0].value); + setScheduleTime(TIME_OPTIONS[7].value); + } + }; + + const handleDeleteRecipient = (recepient) => { + setRecipients(recipients.filter((item) => item !== recepient)); + }; + + const handleClose = () => { + props.onClose(); + setValuesFromProps(); + }; + + const clearAll = () => { + setScheduleType(null); + setScheduleDay(null); + setScheduleTime(null); + setRecipients([]); + }; + + const handleSubmit = () => { + props.setField({ + scheduleType, + scheduleDay, + scheduleTime, + recipients, + }); + props.onSubmit(); + props.onClose(); + }; + + return ( + + + {`Schedule ${props.name?.length ? `“${props.name}” ` : ''} delivery`} + + + + Please set the recepients and the schedule for this report. + + +
+ { + handleScheduleTypeChange(newValue?.value ?? null); + }} + disabled={!recipients?.length} + disableClearable + renderInput={(params) => { + const type = RECURRENCE_LIST.find((item) => item.value === scheduleType); + // eslint-disable-next-line no-param-reassign + params.inputProps.value = type?.label ?? ''; + return ; + }} + /> + + { + setScheduleDay(newValue?.value ?? null); + }} + disabled={!scheduleType || scheduleType === RECURRENCE_VALUES.daily.value || !recipients?.length} + isOptionEqualToValue={(option, value) => option.value === value} + disableClearable + renderInput={(params) => { + const day = dayList.find((item) => item.value === scheduleDay); + // eslint-disable-next-line no-param-reassign + params.inputProps.value = day?.label ?? ''; + + return ; + }} + /> + + { + setScheduleTime(newValue?.value ?? null); + }} + disabled={!scheduleType || !recipients?.length} + disableClearable + renderInput={(params) => { + const time = TIME_OPTIONS.find((item) => item.value === scheduleTime); + // eslint-disable-next-line no-param-reassign + params.inputProps.value = time?.label ?? ''; + + return ; + }} + /> +
+ + { + setIsEmailError(false); + setEmail(target.value); + }} + onKeyDown={({ keyCode, target }) => { + const values = target.value?.trim()?.split(/(?:,|;| )+/); + if (keyCode === KEY_ENTER) { + if (values.every((item) => emailRegExp.test(item))) { + setRecipients([...recipients, ...values]); + setEmail(''); + } else { + setIsEmailError(true); + } + } + }} + placeholder='Add up to 20 email addresses and press "Enter"' + label="Add Recipients" + variant="outlined" + size="small" + error={isEmailError} + /> + + + Current recipients: + + + {!!recipients?.length && ( +
+ {recipients.map((item, index) => ( + { + handleDeleteRecipient(item); + }} + label={item} + variant="filled" + color="neutral" + size="small" + shape="circular" + /> + ))} +
+ )} + +
+ + {`Sending time is set in your current time zone ${timezone}`} + + + The report will be delivered as an email with a CSV file attached. + + + Files over 10MB will be compressed to ZIP. + +
+
+ + + + + +
+ ); +} + +ScheduleDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + setField: PropTypes.func.isRequired, + name: PropTypes.string.isRequired, + scheduleType: PropTypes.string.isRequired, + scheduleDay: PropTypes.number.isRequired, + scheduleTime: PropTypes.number.isRequired, + recipients: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default ScheduleDialog; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/SideBar.module.scss b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/SideBar.module.scss new file mode 100644 index 0000000..0d9c6d9 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/SideBar.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SideBar":"SideBar_SideBar__abQKe","ListWrap":"SideBar_ListWrap__8LTgO","List":"SideBar_List__irfiA","Header":"SideBar_Header__ySmAS","TitleWrap":"SideBar_TitleWrap__sNQoc","Title":"SideBar_Title__Mo7Qj","ListContent":"SideBar_ListContent__Owhk0","MetricListItem":"SideBar_MetricListItem__WCiW6"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/index.jsx new file mode 100644 index 0000000..4dedd64 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/components/SideBar/index.jsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { AnalyticsOutlined, ViewInAr } from '@mui/icons-material'; + +import { METRICS_LIST } from '../../../../constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; + +import SortableItem from '@/modules/@common/dnd/SortableItem/SortableItem'; +import DimensionItem from '../DimensionItem'; +import MetricItem from '../MetricItem'; +import { Button, Paper, Typography } from '@/mui/components'; + +import styles from './SideBar.module.scss'; + +function SideBar() { + const dispatch = useDispatch(); + + const dimensionsList = useSelector(selectors.dimensionsListSelector); + const checkedMetrics = useSelector(selectors.checkedMetricsSelector); + const checkedDimensions = useSelector(selectors.checkedDimensionsSelector); + const isReadOnly = useSelector(selectors.isReadOnlySelector); + const filters = useSelector(selectors.filtersSelector); + + const setField = (o) => dispatch(actions.setFieldAction(o)); + + const [expandedMetrics, setExpandedMetrics] = useState([]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleChangeAccordion = (id) => { + if (expandedMetrics.includes(id)) { + setExpandedMetrics(expandedMetrics.filter((item) => item !== id)); + } else { + setExpandedMetrics([...expandedMetrics, id]); + } + }; + + const handleAllMetrics = () => { + if (expandedMetrics?.length === METRICS_LIST?.length) { + setExpandedMetrics([]); + } else { + setExpandedMetrics(METRICS_LIST.map((item) => item.id)); + } + }; + + const orderCheckedDimensionsByDimensionsList = (values, list) => + list?.filter((item) => values.includes(item.value))?.map((item) => item.value) ?? []; + + const handleDragEnd = ({ active, over }) => { + const getIndex = (neededIndex) => dimensionsList.findIndex((o) => o.value === neededIndex); + const oldIndex = getIndex(active.id); + const newIndex = getIndex(over?.id); + const newDimensionsList = arrayMove(dimensionsList, oldIndex, newIndex); + + // for correct in checkedDimensions + const newCheckedDimensions = orderCheckedDimensionsByDimensionsList(checkedDimensions, newDimensionsList); + + dispatch(actions.setFieldAction({ dimensionsList: newDimensionsList, checkedDimensions: newCheckedDimensions })); + }; + + const checkDimension = (value) => { + dispatch( + actions.setFieldAction({ + checkedDimensions: orderCheckedDimensionsByDimensionsList([...checkedDimensions, value], dimensionsList), + }), + ); + }; + + const handleDimensionsChange = (value, deselectWithMetrics) => { + const isValueChecked = checkedDimensions?.includes(value); + if (isValueChecked) { + dispatch( + actions.setFieldAction({ + checkedDimensions: checkedDimensions.filter((item) => item !== value), + }), + ); + + // when deselect some dimension (Demand Source) needs to select some metrics + if (deselectWithMetrics) { + dispatch( + actions.setFieldAction({ + checkedMetrics: checkedMetrics.filter((item) => !deselectWithMetrics.includes(item)), + }), + ); + } + } else { + checkDimension(value); + } + }; + + return ( + +
+
+
+
+ + + Metrics + +
+ + +
+ +
+ {METRICS_LIST.map((item, index) => ( + checkedDimensions?.includes(value)) || + item.exceptFilters?.some((filterName) => !!filters[`${filterName}_FILTER_VALUES`]?.length) || + item.exceptMetrics?.some((value) => checkedMetrics?.includes(value)) || + isReadOnly + } + /> + ))} +
+
+
+ +
+
+
+
+ + + Dimensions + +
+
+ +
+ + o.value) : []} + strategy={verticalListSortingStrategy} + > + {!!dimensionsList && + dimensionsList.map((item) => ( + + {(sortableItemProps) => ( +
+ { + handleDimensionsChange(item.value, item.deselectWithMetrics); + }} + disabled={ + item.exceptMetrics?.some((value) => checkedMetrics?.includes(value)) || + item.exceptDimensions?.some((value) => checkedDimensions?.includes(value)) || + isReadOnly + } + dndListeners={sortableItemProps.dndListeners} + /> +
+ )} +
+ ))} +
+
+
+
+
+
+ ); +} + +export default SideBar; diff --git a/anyclip/src/modules/analytics/customReports/components/ReportSetup/index.jsx b/anyclip/src/modules/analytics/customReports/components/ReportSetup/index.jsx new file mode 100644 index 0000000..ee206d7 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/components/ReportSetup/index.jsx @@ -0,0 +1,211 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import { useRouter } from 'next/router'; +import { UploadFile } from '@mui/icons-material'; + +import * as selectors from '../../redux/selectors'; +import * as actions from '../../redux/slices'; + +import Empty from '@/modules/@common/Empty/Empty'; +import { Layout } from '@/modules/analytics/common/components'; +import CancelDialog from './components/CancelDialog'; +import EmptyDialog from './components/EmptyDialog'; +import Filters from './components/Filters'; +import Name from './components/Name'; +import Preview from './components/Preview'; +import ScheduleDialog from './components/ScheduleDialog'; +import SideBar from './components/SideBar'; +import { Button, Divider, Stack, Typography } from '@/mui/components'; + +import styles from './ReportSetup.module.scss'; + +function ReportSetup() { + const dispatch = useDispatch(); + + const name = useSelector(selectors.nameSelector); + const scheduleType = useSelector(selectors.scheduleTypeSelector); + const scheduleDay = useSelector(selectors.scheduleDaySelector); + const scheduleTime = useSelector(selectors.scheduleTimeSelector); + const recipients = useSelector(selectors.recipientsSelector); + const checkedMetrics = useSelector(selectors.checkedMetricsSelector); + const checkedDimensions = useSelector(selectors.checkedDimensionsSelector); + const isReadOnly = useSelector(selectors.isReadOnlySelector); + const isDownloading = useSelector(selectors.isDownloadingSelector); + const isLoading = useSelector(selectors.isLoadingSelector); + + const setField = (o) => dispatch(actions.setFieldAction(o)); + + const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false); + const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [isEmptyDialogOpen, setIsEmptyDialogOpen] = useState(false); + const router = useRouter(); + const [id] = router.query.params; + const isCreateForm = id === 'new'; + const isDuplicate = router.query.duplicate; + + const clearForm = () => { + dispatch(actions.setDefaultSetupPageAction()); + }; + + useEffect(() => { + clearForm(); + if (!isCreateForm) { + dispatch(actions.getCustomReportByIdAction({ id: +id, isDuplicate })); + } + + return () => { + clearForm(); + }; + }, [isDuplicate]); + + const handleChangeName = (newName) => { + dispatch( + actions.setFieldAction({ + name: newName, + }), + ); + }; + + const handleSubmit = () => { + if (!checkedMetrics?.length) { + setIsEmptyDialogOpen(true); + return; + } + + if (isCreateForm || isDuplicate) { + dispatch(actions.createCustomReportAction()); + } else { + dispatch(actions.updateCustomReportAction({ id: +id })); + } + }; + + const isNotEmpty = !!checkedMetrics?.length || !!checkedDimensions?.length; + + const downloadButtonText = isDownloading ? 'Downloading...' : 'Download'; + + return ( + +
+ + + + + + + + + +
+ +
+ +
+ +
+ {isNotEmpty ? ( + + ) : ( + }> + <> + + Build your report + + + Use the panel on the left to manage report metrics and split them by dimensions + + + + )} +
+
+
+ + { + setIsScheduleDialogOpen(false); + }} + onSubmit={() => { + setIsScheduleDialogOpen(false); + handleSubmit(); + }} + name={name} + scheduleType={scheduleType} + scheduleDay={scheduleDay} + scheduleTime={scheduleTime} + recipients={recipients} + setField={setField} + /> + + { + setIsCancelDialogOpen(false); + }} + onSubmit={() => { + setIsCancelDialogOpen(false); + router.push('/custom-reports-new'); + }} + /> + + { + setIsEmptyDialogOpen(false); + }} + onSubmit={() => { + setIsEmptyDialogOpen(false); + }} + /> +
+ ); +} + +export default ReportSetup; diff --git a/src/modules/analytics/customReports/components/index.jsx b/anyclip/src/modules/analytics/customReports/components/index.jsx similarity index 100% rename from src/modules/analytics/customReports/components/index.jsx rename to anyclip/src/modules/analytics/customReports/components/index.jsx diff --git a/anyclip/src/modules/analytics/customReports/constants/index.js b/anyclip/src/modules/analytics/customReports/constants/index.js new file mode 100644 index 0000000..f813803 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/constants/index.js @@ -0,0 +1,1062 @@ +export const NAME_MAX_LENGTH = 200; + +export const DOWNLOAD_TIMEOUT = 20000; +export const MONITORING_DELAY = 5000; +export const MAX_DOWNLOAD_TRY_REQUESTS = DOWNLOAD_TIMEOUT / MONITORING_DELAY; + +export const TABLE_HEADERS = [ + { + id: 'name', + label: 'Name', + isSortable: true, + }, + { + id: 'name', + label: 'Owner', + isSortable: true, + }, + { + id: 'createdAt', + label: 'Created', + isSortable: true, + }, + { + id: 'updatedAt', + label: 'Updated', + isSortable: true, + }, + { + id: '', + label: '', + isSortable: false, + }, + { + id: 'lastDelivery', + label: 'Last Delivery', + isSortable: true, + }, + { + id: 'nextDelivery', + label: 'Next Delivery', + isSortable: true, + }, + { + id: '', + label: '', + isSortable: false, + }, +]; + +export const CALENDAR_FILTER_VALUE = { + today: { + label: 'Today', + value: 'TODAY', + }, + yesterday: { + label: 'Yesterday', + value: 'YESTERDAY', + }, + last7Days: { + label: 'Last 7 days', + value: 'LAST_7_DAYS', + }, + monthToDate: { + label: 'Month to Date', + value: 'MONTH_TO_DATE', + }, + lastMonth: { + label: 'Last Month', + value: 'LAST_MONTH', + }, + last30Days: { + label: 'Last 30 Days', + value: 'LAST_30_DAYS', + }, + thisQuarter: { + label: 'This Quarter', + value: 'THIS_QUARTER', + }, + lastQuarter: { + label: 'Last Quarter', + value: 'LAST_QUARTER', + }, + thisYear: { + label: 'This Year', + value: 'THIS_YEAR', + }, + lastYear: { + label: 'Last Year', + value: 'LAST_YEAR', + }, + custom: 'CUSTOM', +}; + +export const CALENDAR_FILTER = [ + { label: CALENDAR_FILTER_VALUE.today.label, value: CALENDAR_FILTER_VALUE.today.value }, + { label: CALENDAR_FILTER_VALUE.yesterday.label, value: CALENDAR_FILTER_VALUE.yesterday.value }, + { label: CALENDAR_FILTER_VALUE.last7Days.label, value: CALENDAR_FILTER_VALUE.last7Days.value }, + { label: CALENDAR_FILTER_VALUE.monthToDate.label, value: CALENDAR_FILTER_VALUE.monthToDate.value }, + { label: CALENDAR_FILTER_VALUE.lastMonth.label, value: CALENDAR_FILTER_VALUE.lastMonth.value }, + { label: CALENDAR_FILTER_VALUE.last30Days.label, value: CALENDAR_FILTER_VALUE.last30Days.value }, + { label: CALENDAR_FILTER_VALUE.thisQuarter.label, value: CALENDAR_FILTER_VALUE.thisQuarter.value }, + { label: CALENDAR_FILTER_VALUE.lastQuarter.label, value: CALENDAR_FILTER_VALUE.lastQuarter.value }, + { label: CALENDAR_FILTER_VALUE.thisYear.label, value: CALENDAR_FILTER_VALUE.thisYear.value }, + { label: CALENDAR_FILTER_VALUE.lastYear.label, value: CALENDAR_FILTER_VALUE.lastYear.value }, + { label: 'Custom', value: CALENDAR_FILTER_VALUE.custom }, +]; + +export const RECURRENCE_VALUES = { + daily: { + label: 'Daily', + value: 'DAILY', + }, + weekly: { + label: 'Weekly', + value: 'WEEKLY', + }, + monthly: { + label: 'Monthly', + value: 'MONTHLY', + }, +}; + +export const RECURRENCE_LIST = [ + { + label: RECURRENCE_VALUES.daily.label, + value: RECURRENCE_VALUES.daily.value, + }, + { + label: RECURRENCE_VALUES.weekly.label, + value: RECURRENCE_VALUES.weekly.value, + }, + { + label: RECURRENCE_VALUES.monthly.label, + value: RECURRENCE_VALUES.monthly.value, + }, +]; + +export const DAY_OPTIONS_WEEKLY = [ + { + label: 'Monday', + value: 1, + }, + { + label: 'Tuesday', + value: 2, + }, + { + label: 'Wednesday', + value: 3, + }, + { + label: 'Thursday', + value: 4, + }, + { + label: 'Friday', + value: 5, + }, + { + label: 'Saturday', + value: 6, + }, + { + label: 'Sunday', + value: 7, + }, +]; + +export const TIME_OPTIONS = [ + { + label: '12 AM', + value: 0, + }, + { + label: '1 AM', + value: 1, + }, + { + label: '2 AM', + value: 2, + }, + { + label: '3 AM', + value: 3, + }, + { + label: '4 AM', + value: 4, + }, + { + label: '5 AM', + value: 5, + }, + { + label: '6 AM', + value: 6, + }, + { + label: '7 AM', + value: 7, + }, + { + label: '8 AM', + value: 8, + }, + { + label: '9 AM', + value: 9, + }, + { + label: '10 AM', + value: 10, + }, + { + label: '11 AM', + value: 11, + }, + { + label: '12 PM', + value: 12, + }, + { + label: '1 PM', + value: 13, + }, + { + label: '2 PM', + value: 14, + }, + { + label: '3 PM', + value: 15, + }, + { + label: '4 PM', + value: 16, + }, + { + label: '5 PM', + value: 17, + }, + { + label: '6 PM', + value: 18, + }, + { + label: '7 PM', + value: 19, + }, + { + label: '8 PM', + value: 20, + }, + { + label: '9 PM', + value: 21, + }, + { + label: '10 PM', + value: 22, + }, + { + label: '11 PM', + value: 23, + }, +]; + +export const DEMAND_SOURCE_FILTER_NAME = 'DEMAND_SOURCE'; +export const AD_FORMAT_FILTER_NAME = 'AD_FORMAT'; +export const HUB_FILTER_NAME = 'HUB'; +export const PLAYER_NAME_FILTER_NAME = 'PLAYER_NAME'; +export const PLAYER_ID_FILTER_NAME = 'PLAYER_ID'; +export const PLAYER_TYPE_FILTER_NAME = 'PLAYER_TYPE'; +export const COUNTRY_FILTER_NAME = 'COUNTRY'; +export const DEVICE_FILTER_NAME = 'DEVICE'; +export const PLAYER_DOMAIN_FILTER_NAME = 'PLAYER_DOMAIN'; +export const EMBED_CODE_VARIANT_FILTER_NAME = 'EMBED_CODE_VARIANT'; + +export const AD_FORMAT_FILTER_OPTIONS = [ + { + label: 'Display', + value: 'DISPLAY', + }, + { + label: 'Video', + value: 'VIDEO', + }, +]; + +export const DEVICE_FILTER_OPTIONS = [ + { + label: 'Desktop', + value: 'DESKTOP', + }, + { + label: 'Mobile', + value: 'MOBILE', + }, + { + label: 'Other', + value: 'OTHER', + }, +]; + +export const EMBED_CODE_VARIANT_FILTER_OPTIONS = [ + { + label: 'None', + value: 'NONE', + }, + ...[...'0123456789abcdefghijklmnopqrstuvwxyz'].map((item) => ({ label: item.toUpperCase(), value: item })), +]; + +// key must be equals with value +export const DIMENSIONS = { + MONTH: { + label: 'Month', + value: 'MONTH', + previewTitle: 'MON YYYY', + }, + DAY: { + label: 'Day', + value: 'DAY', + previewTitle: 'MON DD YYYY', + }, + HOUR_OF_DAY: { + label: 'Hour of day', + value: 'HOUR_OF_DAY', + previewTitle: 'Hour', + }, + HOUR: { + label: 'Hour', + value: 'HOUR', + previewTitle: 'HH MON DD YYYY', + }, + DEMAND_SOURCE: { + label: 'Demand Source', + value: 'DEMAND_SOURCE', + }, + AD_FORMAT: { + label: 'Ad format', + value: 'AD_FORMAT', + }, + HUB: { + label: 'Hub', + value: 'HUB', + }, + PLAYER_NAME: { + label: 'Player name', + value: 'PLAYER_NAME', + }, + PLAYER_ID: { + label: 'Player ID', + value: 'PLAYER_ID', + }, + PLAYER_TYPE: { + label: 'Player type', + value: 'PLAYER_TYPE', + }, + COUNTRY: { + label: 'Country', + value: 'COUNTRY', + }, + DEVICE: { + label: 'Device', + value: 'DEVICE', + }, + PLAYER_DOMAIN: { + label: 'Player domain', + value: 'PLAYER_DOMAIN', + }, + EMBED_CODE_VARIANT: { + label: 'Embed code variant', + value: 'EMBED_CODE_VARIANT', + }, +}; + +// key must be equals with value +export const METRICS_VALUES = { + // playerData + PAGE_LOADS: { + label: 'Page Loads', + value: 'PAGE_LOADS', + tooltip: 'The number of times the player was loaded on a page', + }, + PLAYER_LOADS: { + label: 'Player Loads', + value: 'PLAYER_LOADS', + tooltip: 'The number of times the player was loaded on an ad blocker free page', + }, + PLAYER_AD_IMPRESSIONS: { + label: 'Player Ad Impressions', + value: 'PLAYER_AD_IMPRESSIONS', + tooltip: 'The number of times the player was loaded on a page and served at least one ad impression', + }, + VIEWABLE_PLAYER_LOADS: { + label: 'Viewable Player Loads', + value: 'VIEWABLE_PLAYER_LOADS', + tooltip: 'The number of times the player was loaded on an ad blocker free page and was in view', + }, + VIEWABLE_PLAYER_AD_IMPRESSIONS: { + label: 'Viewable Player Ad Impressions', + value: 'VIEWABLE_PLAYER_AD_IMPRESSIONS', + + tooltip: + 'The number of times the player was loaded on a page and served at least one ad impression which was in view', + }, + GROSS_PAGE_RPM: { + label: 'Gross Page RPM', + value: 'GROSS_PAGE_RPM', + + tooltip: + 'The total (gross) revenue generated by the AnyClip player for every 1,000 player loads, before the platform fee is deducted.', + }, + NET_PAGE_RPM: { + label: 'NET Page RPM', + value: 'NET_PAGE_RPM', + + tooltip: + 'The net revenue generated by the AnyClip player for every 1,000 player loads, after all platform fees and revenue shares are deducted.', + }, + PLAYER_FILL_RATE: { + label: 'Player Fill Rate', + value: 'PLAYER_FILL_RATE', + tooltip: 'Ratio of player ad impressions to player loads', + }, + PLAYER_VIEWABILITY: { + label: 'Player Viewability', + value: 'PLAYER_VIEWABILITY', + tooltip: 'Ratio of viewble player loads to all player loads', + }, + // videoAdData + VIDEO_AD_IMPRESSIONS: { + label: 'Video Ad Impressions', + value: 'VIDEO_AD_IMPRESSIONS', + tooltip: 'The number of video ads served', + }, + VIDEO_AD_CLICKS: { + label: 'Video Ad Clicks', + value: 'VIDEO_AD_CLICKS', + tooltip: 'The number of clicks on video ads', + }, + VIDEO_AD_RPM: { + label: 'Video Ad RPM', + value: 'VIDEO_AD_RPM', + tooltip: 'Ovarall video revenue generated per Video ad served', + }, + VIDEO_AD_FILL_RATE: { + label: 'Video Ad Fill Rate', + value: 'VIDEO_AD_FILL_RATE', + tooltip: 'Ratio of video ad requests to video ad impressions', + }, + VIDEO_AD_VIEWEABILITY: { + label: 'Video Ad Viewability', + value: 'VIDEO_AD_VIEWEABILITY', + tooltip: 'Ratio of viewble video ad impressions to all video ad Impressions', + }, + AD_WATCHED_25: { + label: '25% ad watched', + value: 'AD_WATCHED_25', + tooltip: '25% of an Ad was viewed', + }, + AD_WATCHED_50: { + label: '50% ad watched', + value: 'AD_WATCHED_50', + tooltip: '50% of an Ad was viewed', + }, + AD_WATCHED_75: { + label: '75% ad watched', + value: 'AD_WATCHED_75', + tooltip: '75% of an Ad was viewed', + }, + AD_WATCHED_100: { + label: '100% ad watched', + value: 'AD_WATCHED_100', + tooltip: 'The Ad was viewed completely', + }, + // displayAdData + DISPLAY_AD_IMPRESSIONS: { + label: 'Display Ad Impressions', + value: 'DISPLAY_AD_IMPRESSIONS', + tooltip: 'The number of display ads served', + }, + DISPLAY_AD_RPM: { + label: 'Display Ad RPM', + value: 'DISPLAY_AD_RPM', + tooltip: 'Overall display revenue generated per display ad served', + }, + DISPLAY_AD_FILL_RATE: { + label: 'Display Ad Fill Rate', + value: 'DISPLAY_AD_FILL_RATE', + tooltip: 'Ratio of display ad requests to display ad impressions', + }, + DISPLAY_AD_VIEWEABILITY: { + label: 'Display Ad Viewability', + value: 'DISPLAY_AD_VIEWEABILITY', + tooltip: 'Ratio of viewble display ad impressions to all display ad Impressions', + }, + // revenuesAndFees + REVENUE_OVERALL_TOTAL: { + label: 'Overall Revenue, Total', + value: 'REVENUE_OVERALL_TOTAL', + tooltip: 'Total revenue from all ad formats before platform fees are deducted', + }, + REVENUE_OVERALL_VIDEO: { + label: 'Overall Revenue, Video', + value: 'REVENUE_OVERALL_VIDEO', + tooltip: 'Total revenue from video ads before platform fees are deducted', + }, + REVENUE_OVERALL_DISPLAY: { + label: 'Overall Revenue, Display', + value: 'REVENUE_OVERALL_DISPLAY', + tooltip: 'Total revenue from display ads before platform fees are deducted', + }, + REVENUE_NET_TOTAL: { + label: 'Net Revenue, Total', + value: 'REVENUE_NET_TOTAL', + tooltip: 'Total revenue across all formats after platform fees and revenue share deductions', + }, + REVENUE_NET_VIDEO: { + label: 'Net Revenue, Video', + value: 'REVENUE_NET_VIDEO', + tooltip: 'Video ad revenue after platform fees and revenue share deductions', + }, + REVENUE_NET_DISPLAY: { + label: 'Net Revenue, Display', + value: 'REVENUE_NET_DISPLAY', + tooltip: 'Display ad revenue after platform fees and revenue share deductions', + }, + REVENUE_PD_TOTAL: { + label: 'Publisher Demand Revenue, Total', + value: 'REVENUE_PD_TOTAL', + tooltip: 'Total revenue generated by your demand sources across all ad formats', + }, + REVENUE_PD_VIDEO: { + label: 'Publisher Demand Revenue, Video', + value: 'REVENUE_PD_VIDEO', + tooltip: 'Revenue generated by your demand sources from video ads', + }, + REVENUE_PD_DISPLAY: { + label: 'Publisher Demand Revenue, Display', + value: 'REVENUE_PD_DISPLAY', + tooltip: 'Revenue generated by your demand sources from display ads', + }, + REVENUE_AD_TOTAL: { + label: 'AnyClip Demand Revenue, Total', + value: 'REVENUE_AD_TOTAL', + tooltip: 'Total revenue generated for you by AnyClip Demand (after revenue share) across all ad formats', + }, + REVENUE_AD_VIDEO: { + label: 'AnyClip Demand Revenue, Video', + value: 'REVENUE_AD_VIDEO', + tooltip: 'Revenue generated for you by AnyClip Demand from video ads (after revenue share)', + }, + REVENUE_AD_DISPLAY: { + label: 'AnyClip Demand Revenue, Display', + value: 'REVENUE_AD_DISPLAY', + tooltip: 'Revenue generated for you by AnyClip Demand from display ads (after revenue share)', + }, + PLATFORM_FEES_TOTAL: { + label: 'Platform Fees, Total', + value: 'PLATFORM_FEES_TOTAL', + tooltip: 'Total platform fees charged to you for delivering ads from your demand sources across all formats', + }, + PLATFORM_FEES_VIDEO: { + label: 'Platform Fees, Video', + value: 'PLATFORM_FEES_VIDEO', + tooltip: 'Total platform fees charged to you for delivering video ads from your demand sources', + }, + PLATFORM_FEES_DISPLAY: { + label: 'Platform Fees, Display', + value: 'PLATFORM_FEES_DISPLAY', + tooltip: 'Total platform fees charged to you for delivering dIsplay ads from your demand sources', + }, + + // playerEvents + CLIP_IMPRESSION: { + label: 'Clip Impression', + value: 'CLIP_IMPRESSION', + tooltip: 'The number of clip plays', + }, + CLIP_SEEK: { + label: 'Clip Seek', + value: 'CLIP_SEEK', + tooltip: 'The user clicked on the clip progress bar', + }, + FULLSCREEN: { + label: 'Fullscreen', + value: 'FULLSCREEN', + tooltip: 'The user clicked on the full screen button', + }, + MUTE: { + label: 'Mute', + value: 'MUTE', + tooltip: 'Every time there is a mute event (automated event as well)', + }, + PAUSE: { + label: 'Pause', + value: 'PAUSE', + tooltip: 'The number of times the user paused a video', + }, + PLAY: { + label: 'Play', + value: 'PLAY', + tooltip: 'The number of times the user started a video (excluding autoplay)', + }, + // clipCompletionRate + CLIP_WATCHED_25: { + label: '25% clip watched', + value: 'CLIP_WATCHED_25', + tooltip: '25% of a clip was viewed', + }, + CLIP_WATCHED_50: { + label: '50% clip watched', + value: 'CLIP_WATCHED_50', + tooltip: '50% of a clip was viewed', + }, + CLIP_WATCHED_75: { + label: '75% clip watched', + value: 'CLIP_WATCHED_75', + tooltip: '75% of a clip was viewed', + }, + CLIP_WATCHED_100: { + label: '100% clip watched', + value: 'CLIP_WATCHED_100', + tooltip: 'The Clip was viewed completely', + }, + // adRequests + DEMAND_AD_REQUESTS_VIDEO: { + label: 'Demand Ad Requests, Video', + value: 'DEMAND_AD_REQUESTS_VIDEO', + tooltip: 'The number of ad requests sent to demand sources for video ads', + }, + DEMAND_AD_REQUESTS_DISPLAY: { + label: 'Demand Ad Requests, Display', + value: 'DEMAND_AD_REQUESTS_DISPLAY', + tooltip: 'The number of ad requests sent to demand sources for display ads', + }, + // adOpportunities + SUPPLY_AD_OPPORTUNITIES_VIDEO: { + label: 'Supply Ad Opportunities, Video', + value: 'SUPPLY_AD_OPPORTUNITIES_VIDEO', + tooltip: 'The number of opportunities where a video ad could have been served', + }, + SUPPLY_AD_OPPORTUNITIES_DISPLAY: { + label: 'Supply Ad Opportunities, Display', + value: 'SUPPLY_AD_OPPORTUNITIES_DISPLAY', + tooltip: 'The number of opportunities where a display ad could have been served', + }, +}; + +export const METRICS = { + playerData: { + id: 'PLAYER_DATA', + label: 'Player Data', + values: [ + METRICS_VALUES.PAGE_LOADS, + METRICS_VALUES.PLAYER_LOADS, + METRICS_VALUES.PLAYER_AD_IMPRESSIONS, + METRICS_VALUES.VIEWABLE_PLAYER_LOADS, + METRICS_VALUES.VIEWABLE_PLAYER_AD_IMPRESSIONS, + METRICS_VALUES.GROSS_PAGE_RPM, + METRICS_VALUES.NET_PAGE_RPM, + METRICS_VALUES.PLAYER_FILL_RATE, + METRICS_VALUES.PLAYER_VIEWABILITY, + ], + }, + videoAdData: { + id: 'VIDEO_AD_DATA', + label: 'Video Ad Data', + values: [ + METRICS_VALUES.VIDEO_AD_IMPRESSIONS, + METRICS_VALUES.VIDEO_AD_CLICKS, + METRICS_VALUES.VIDEO_AD_RPM, + METRICS_VALUES.VIDEO_AD_FILL_RATE, + METRICS_VALUES.VIDEO_AD_VIEWEABILITY, + // hide by BIP-670 + // METRICS_VALUES.AD_WATCHED_25, + // METRICS_VALUES.AD_WATCHED_50, + // METRICS_VALUES.AD_WATCHED_75, + METRICS_VALUES.AD_WATCHED_100, + ], + }, + displayAdData: { + id: 'DISPLAY_AD_DATA', + label: 'Display Ad Data', + values: [ + METRICS_VALUES.DISPLAY_AD_IMPRESSIONS, + METRICS_VALUES.DISPLAY_AD_RPM, + METRICS_VALUES.DISPLAY_AD_FILL_RATE, + METRICS_VALUES.DISPLAY_AD_VIEWEABILITY, + ], + }, + revenuesAndFees: { + id: 'REVENUES_AND_FEES', + label: 'Revenues and Fees', + values: [ + METRICS_VALUES.REVENUE_OVERALL_TOTAL, + METRICS_VALUES.REVENUE_OVERALL_VIDEO, + METRICS_VALUES.REVENUE_OVERALL_DISPLAY, + METRICS_VALUES.REVENUE_NET_TOTAL, + METRICS_VALUES.REVENUE_NET_VIDEO, + METRICS_VALUES.REVENUE_NET_DISPLAY, + METRICS_VALUES.REVENUE_PD_TOTAL, + METRICS_VALUES.REVENUE_PD_VIDEO, + METRICS_VALUES.REVENUE_PD_DISPLAY, + METRICS_VALUES.REVENUE_AD_TOTAL, + METRICS_VALUES.REVENUE_AD_VIDEO, + METRICS_VALUES.REVENUE_AD_DISPLAY, + METRICS_VALUES.PLATFORM_FEES_TOTAL, + METRICS_VALUES.PLATFORM_FEES_VIDEO, + METRICS_VALUES.PLATFORM_FEES_DISPLAY, + ], + }, + playerEvents: { + id: 'PLAYER_EVENTS', + label: 'Player Events', + values: [ + METRICS_VALUES.CLIP_IMPRESSION, + METRICS_VALUES.CLIP_SEEK, + METRICS_VALUES.FULLSCREEN, + METRICS_VALUES.MUTE, + METRICS_VALUES.PAUSE, + METRICS_VALUES.PLAY, + ], + }, + clipCompletionRate: { + id: 'CLIP_COMPLETION_RATE', + label: 'Clip Completion Rate', + values: [ + METRICS_VALUES.CLIP_WATCHED_25, + METRICS_VALUES.CLIP_WATCHED_50, + METRICS_VALUES.CLIP_WATCHED_75, + METRICS_VALUES.CLIP_WATCHED_100, + ], + }, + adRequests: { + id: 'AD_REQUESTS', + label: 'Demand: Ad Requests', + values: [METRICS_VALUES.DEMAND_AD_REQUESTS_VIDEO, METRICS_VALUES.DEMAND_AD_REQUESTS_DISPLAY], + }, + adOpportunities: { + id: 'AD_OPPORTUNITIES', + label: 'Supply: Ad Opportunities', + values: [METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_VIDEO, METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_DISPLAY], + }, +}; + +export const METRICS_WITH_EXEPTIONS = { + playerData: { + ...METRICS.playerData, + exceptDimensions: [DIMENSIONS.DEMAND_SOURCE.value, DIMENSIONS.AD_FORMAT.value], + exceptFilters: [DEMAND_SOURCE_FILTER_NAME, AD_FORMAT_FILTER_NAME], + }, + videoAdData: { + ...METRICS.videoAdData, + }, + displayAdData: { + ...METRICS.displayAdData, + }, + revenuesAndFees: { + ...METRICS.revenuesAndFees, + }, + playerEvents: { + ...METRICS.playerEvents, + exceptDimensions: [DIMENSIONS.DEMAND_SOURCE.value, DIMENSIONS.AD_FORMAT.value], + exceptFilters: [DEMAND_SOURCE_FILTER_NAME, AD_FORMAT_FILTER_NAME], + }, + clipCompletionRate: { + ...METRICS.clipCompletionRate, + exceptDimensions: [DIMENSIONS.DEMAND_SOURCE.value, DIMENSIONS.AD_FORMAT.value], + exceptFilters: [DEMAND_SOURCE_FILTER_NAME, AD_FORMAT_FILTER_NAME], + }, + adRequests: { + ...METRICS.adRequests, + exceptMetrics: [ + // playerData + METRICS_VALUES.PAGE_LOADS.value, + METRICS_VALUES.PLAYER_LOADS.value, + METRICS_VALUES.PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.VIEWABLE_PLAYER_LOADS.value, + METRICS_VALUES.VIEWABLE_PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.GROSS_PAGE_RPM.value, + METRICS_VALUES.NET_PAGE_RPM.value, + METRICS_VALUES.PLAYER_FILL_RATE.value, + METRICS_VALUES.PLAYER_VIEWABILITY.value, + // playerEvents + METRICS_VALUES.CLIP_IMPRESSION.value, + METRICS_VALUES.CLIP_SEEK.value, + METRICS_VALUES.FULLSCREEN.value, + METRICS_VALUES.MUTE.value, + METRICS_VALUES.PAUSE.value, + METRICS_VALUES.PLAY.value, + // clipCompletionRate + METRICS_VALUES.CLIP_WATCHED_25.value, + METRICS_VALUES.CLIP_WATCHED_50.value, + METRICS_VALUES.CLIP_WATCHED_75.value, + METRICS_VALUES.CLIP_WATCHED_100.value, + // adOpportunities + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_VIDEO.value, + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_DISPLAY.value, + ], + selectWithDimensions: [DIMENSIONS.DEMAND_SOURCE.value], + }, + adOpportunities: { + ...METRICS.adOpportunities, + exceptDimensions: [DIMENSIONS.DEMAND_SOURCE.value, DIMENSIONS.AD_FORMAT.value], + exceptFilters: [DEMAND_SOURCE_FILTER_NAME, AD_FORMAT_FILTER_NAME], + }, +}; + +export const METRICS_LIST = [ + METRICS_WITH_EXEPTIONS.playerData, + METRICS_WITH_EXEPTIONS.videoAdData, + METRICS_WITH_EXEPTIONS.displayAdData, + METRICS_WITH_EXEPTIONS.revenuesAndFees, + METRICS_WITH_EXEPTIONS.playerEvents, + METRICS_WITH_EXEPTIONS.clipCompletionRate, + METRICS_WITH_EXEPTIONS.adRequests, + METRICS_WITH_EXEPTIONS.adOpportunities, +]; + +export const DIMENSIONS_WITH_EXEPTIONS = { + MONTH: { + ...DIMENSIONS.MONTH, + }, + DAY: { + ...DIMENSIONS.DAY, + }, + HOUR_OF_DAY: { + ...DIMENSIONS.HOUR_OF_DAY, + exceptDimensions: [DIMENSIONS.HOUR.value], + }, + HOUR: { + ...DIMENSIONS.HOUR, + exceptDimensions: [DIMENSIONS.HOUR_OF_DAY.value], + }, + DEMAND_SOURCE: { + ...DIMENSIONS.DEMAND_SOURCE, + exceptMetrics: [ + // playerData + METRICS_VALUES.PAGE_LOADS.value, + METRICS_VALUES.PLAYER_LOADS.value, + METRICS_VALUES.PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.VIEWABLE_PLAYER_LOADS.value, + METRICS_VALUES.VIEWABLE_PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.GROSS_PAGE_RPM.value, + METRICS_VALUES.NET_PAGE_RPM.value, + METRICS_VALUES.PLAYER_FILL_RATE.value, + METRICS_VALUES.PLAYER_VIEWABILITY.value, + // playerEvents + METRICS_VALUES.CLIP_IMPRESSION.value, + METRICS_VALUES.CLIP_SEEK.value, + METRICS_VALUES.FULLSCREEN.value, + METRICS_VALUES.MUTE.value, + METRICS_VALUES.PAUSE.value, + METRICS_VALUES.PLAY.value, + // clipCompletionRate + METRICS_VALUES.CLIP_WATCHED_25.value, + METRICS_VALUES.CLIP_WATCHED_50.value, + METRICS_VALUES.CLIP_WATCHED_75.value, + METRICS_VALUES.CLIP_WATCHED_100.value, + // adOpportunities + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_VIDEO.value, + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_DISPLAY.value, + ], + deselectWithMetrics: [ + METRICS_VALUES.DEMAND_AD_REQUESTS_VIDEO.value, + METRICS_VALUES.DEMAND_AD_REQUESTS_DISPLAY.value, + ], + }, + AD_FORMAT: { + ...DIMENSIONS.AD_FORMAT, + exceptMetrics: [ + // playerData + METRICS_VALUES.PAGE_LOADS.value, + METRICS_VALUES.PLAYER_LOADS.value, + METRICS_VALUES.PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.VIEWABLE_PLAYER_LOADS.value, + METRICS_VALUES.VIEWABLE_PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.GROSS_PAGE_RPM.value, + METRICS_VALUES.NET_PAGE_RPM.value, + METRICS_VALUES.PLAYER_FILL_RATE.value, + METRICS_VALUES.PLAYER_VIEWABILITY.value, + // playerEvents + METRICS_VALUES.CLIP_IMPRESSION.value, + METRICS_VALUES.CLIP_SEEK.value, + METRICS_VALUES.FULLSCREEN.value, + METRICS_VALUES.MUTE.value, + METRICS_VALUES.PAUSE.value, + METRICS_VALUES.PLAY.value, + // clipCompletionRate + METRICS_VALUES.CLIP_WATCHED_25.value, + METRICS_VALUES.CLIP_WATCHED_50.value, + METRICS_VALUES.CLIP_WATCHED_75.value, + METRICS_VALUES.CLIP_WATCHED_100.value, + // adOpportunities + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_VIDEO.value, + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_DISPLAY.value, + ], + }, + HUB: { + ...DIMENSIONS.HUB, + }, + PLAYER_NAME: { + ...DIMENSIONS.PLAYER_NAME, + }, + PLAYER_ID: { + ...DIMENSIONS.PLAYER_ID, + }, + PLAYER_TYPE: { + ...DIMENSIONS.PLAYER_TYPE, + }, + COUNTRY: { + ...DIMENSIONS.COUNTRY, + }, + DEVICE: { + ...DIMENSIONS.DEVICE, + }, + PLAYER_DOMAIN: { + ...DIMENSIONS.PLAYER_DOMAIN, + }, + EMBED_CODE_VARIANT: { + ...DIMENSIONS.EMBED_CODE_VARIANT, + }, +}; + +export const DIMENSIONS_LIST = [ + DIMENSIONS_WITH_EXEPTIONS.MONTH, + DIMENSIONS_WITH_EXEPTIONS.DAY, + DIMENSIONS_WITH_EXEPTIONS.HOUR_OF_DAY, + DIMENSIONS_WITH_EXEPTIONS.HOUR, + DIMENSIONS_WITH_EXEPTIONS.DEMAND_SOURCE, + DIMENSIONS_WITH_EXEPTIONS.AD_FORMAT, + DIMENSIONS_WITH_EXEPTIONS.HUB, + DIMENSIONS_WITH_EXEPTIONS.PLAYER_NAME, + DIMENSIONS_WITH_EXEPTIONS.PLAYER_ID, + DIMENSIONS_WITH_EXEPTIONS.PLAYER_TYPE, + DIMENSIONS_WITH_EXEPTIONS.COUNTRY, + DIMENSIONS_WITH_EXEPTIONS.DEVICE, + DIMENSIONS_WITH_EXEPTIONS.PLAYER_DOMAIN, + DIMENSIONS_WITH_EXEPTIONS.EMBED_CODE_VARIANT, +]; + +export const FILTERS = { + [DEMAND_SOURCE_FILTER_NAME]: { + label: 'Demand Source', + name: DEMAND_SOURCE_FILTER_NAME, + multiple: true, + exceptMetrics: [ + // playerData + METRICS_VALUES.PAGE_LOADS.value, + METRICS_VALUES.PLAYER_LOADS.value, + METRICS_VALUES.PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.VIEWABLE_PLAYER_LOADS.value, + METRICS_VALUES.VIEWABLE_PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.GROSS_PAGE_RPM.value, + METRICS_VALUES.NET_PAGE_RPM.value, + METRICS_VALUES.PLAYER_FILL_RATE.value, + METRICS_VALUES.PLAYER_VIEWABILITY.value, + // playerEvents + METRICS_VALUES.CLIP_IMPRESSION.value, + METRICS_VALUES.CLIP_SEEK.value, + METRICS_VALUES.FULLSCREEN.value, + METRICS_VALUES.MUTE.value, + METRICS_VALUES.PAUSE.value, + METRICS_VALUES.PLAY.value, + // clipCompletionRate + METRICS_VALUES.CLIP_WATCHED_25.value, + METRICS_VALUES.CLIP_WATCHED_50.value, + METRICS_VALUES.CLIP_WATCHED_75.value, + METRICS_VALUES.CLIP_WATCHED_100.value, + // adOpportunities + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_VIDEO.value, + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_DISPLAY.value, + ], + }, + [AD_FORMAT_FILTER_NAME]: { + label: 'Ad Format', + name: AD_FORMAT_FILTER_NAME, + multiple: true, + exceptMetrics: [ + // playerData + METRICS_VALUES.PAGE_LOADS.value, + METRICS_VALUES.PLAYER_LOADS.value, + METRICS_VALUES.PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.VIEWABLE_PLAYER_LOADS.value, + METRICS_VALUES.VIEWABLE_PLAYER_AD_IMPRESSIONS.value, + METRICS_VALUES.GROSS_PAGE_RPM.value, + METRICS_VALUES.NET_PAGE_RPM.value, + METRICS_VALUES.PLAYER_FILL_RATE.value, + METRICS_VALUES.PLAYER_VIEWABILITY.value, + // playerEvents + METRICS_VALUES.CLIP_IMPRESSION.value, + METRICS_VALUES.CLIP_SEEK.value, + METRICS_VALUES.FULLSCREEN.value, + METRICS_VALUES.MUTE.value, + METRICS_VALUES.PAUSE.value, + METRICS_VALUES.PLAY.value, + // clipCompletionRate + METRICS_VALUES.CLIP_WATCHED_25.value, + METRICS_VALUES.CLIP_WATCHED_50.value, + METRICS_VALUES.CLIP_WATCHED_75.value, + METRICS_VALUES.CLIP_WATCHED_100.value, + // adOpportunities + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_VIDEO.value, + METRICS_VALUES.SUPPLY_AD_OPPORTUNITIES_DISPLAY.value, + ], + }, + [HUB_FILTER_NAME]: { + label: 'Hub', + name: HUB_FILTER_NAME, + multiple: true, + }, + [PLAYER_NAME_FILTER_NAME]: { + label: 'Player Name', + name: PLAYER_NAME_FILTER_NAME, + multiple: true, + }, + [PLAYER_ID_FILTER_NAME]: { + label: 'Player ID', + name: PLAYER_ID_FILTER_NAME, + multiple: true, + }, + [PLAYER_TYPE_FILTER_NAME]: { + label: 'Player Type', + name: PLAYER_TYPE_FILTER_NAME, + multiple: true, + }, + [COUNTRY_FILTER_NAME]: { + label: 'Country', + name: COUNTRY_FILTER_NAME, + multiple: true, + }, + [DEVICE_FILTER_NAME]: { + label: 'Device', + name: DEVICE_FILTER_NAME, + multiple: true, + }, + [PLAYER_DOMAIN_FILTER_NAME]: { + label: 'Player Domain', + name: PLAYER_DOMAIN_FILTER_NAME, + multiple: true, + }, + [EMBED_CODE_VARIANT_FILTER_NAME]: { + label: 'Embed Code Variant', + name: EMBED_CODE_VARIANT_FILTER_NAME, + multiple: true, + }, +}; + +export const FILTERS_LIST = [ + FILTERS[DEMAND_SOURCE_FILTER_NAME], + FILTERS[AD_FORMAT_FILTER_NAME], + FILTERS[HUB_FILTER_NAME], + FILTERS[PLAYER_NAME_FILTER_NAME], + FILTERS[PLAYER_ID_FILTER_NAME], + FILTERS[PLAYER_TYPE_FILTER_NAME], + FILTERS[COUNTRY_FILTER_NAME], + FILTERS[DEVICE_FILTER_NAME], + FILTERS[PLAYER_DOMAIN_FILTER_NAME], + FILTERS[EMBED_CODE_VARIANT_FILTER_NAME], +]; diff --git a/anyclip/src/modules/analytics/customReports/helpers/createCustomReportRequestBody.js b/anyclip/src/modules/analytics/customReports/helpers/createCustomReportRequestBody.js new file mode 100644 index 0000000..419c7f9 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/helpers/createCustomReportRequestBody.js @@ -0,0 +1,82 @@ +import { CALENDAR_FILTER_VALUE, FILTERS_LIST, RECURRENCE_VALUES } from '../constants'; + +import * as selectors from '../redux/selectors'; + +const createCustomReportRequestBody = (state) => { + const name = selectors.nameSelector(state); + const period = selectors.periodSelector(state); + const periodCustomStartDate = selectors.periodCustomStartDateSelector(state); + const periodCustomEndDate = selectors.periodCustomEndDateSelector(state); + const checkedMetrics = selectors.checkedMetricsSelector(state); + const dimensionsList = selectors.dimensionsListSelector(state); + const checkedDimensions = selectors.checkedDimensionsSelector(state); + const scheduleType = selectors.scheduleTypeSelector(state); + const scheduleDay = selectors.scheduleDaySelector(state); + const scheduleTime = selectors.scheduleTimeSelector(state); + const recipients = selectors.recipientsSelector(state); + const filters = selectors.filtersSelector(state); + + const body = { + name, + period, + metrics: checkedMetrics?.map((item) => ({ name: item, value: true })), + dimensions: dimensionsList?.map((item, index) => ({ + name: item?.value, + value: checkedDimensions?.includes(item?.value), + order: index, + })), + }; + + if (period === CALENDAR_FILTER_VALUE.custom) { + body.periodCustomStartDate = periodCustomStartDate; + body.periodCustomEndDate = periodCustomEndDate; + } + + if (!scheduleType || !recipients?.length) { + body.scheduled = false; + } else if (scheduleType === RECURRENCE_VALUES.daily.value) { + body.scheduled = true; + body.scheduleType = scheduleType; + body.scheduleValue = { + time: scheduleTime, + }; + body.recipients = recipients; + } else if (scheduleType === RECURRENCE_VALUES.weekly.value) { + body.scheduled = true; + body.scheduleType = scheduleType; + body.scheduleValue = { + dayInWeek: scheduleDay, + time: scheduleTime, + }; + body.recipients = recipients; + } else { + body.scheduled = true; + body.scheduleType = scheduleType; + body.scheduleValue = { + dayInMonth: scheduleDay, + time: scheduleTime, + }; + body.recipients = recipients; + } + + const filtersBody = FILTERS_LIST.reduce((acc, cur) => { + const valuesFromProps = filters[`${cur.name}_FILTER_VALUES`]; + const values = Array.isArray(valuesFromProps) ? valuesFromProps : [valuesFromProps]; // some filter is not multiple + if (valuesFromProps && values?.length) { + const fullInfoValues = values.map((item) => ({ + action: filters[`${cur.name}_FILTER_ACTION`], + name: cur.name, + value: item.value?.toString(), + label: item.label, + })); + return [...acc, ...fullInfoValues]; + } + return acc; + }, []); + + body.filters = filtersBody; + + return body; +}; + +export default createCustomReportRequestBody; diff --git a/anyclip/src/modules/analytics/customReports/helpers/getStateFromCustomReportRequestBody.js b/anyclip/src/modules/analytics/customReports/helpers/getStateFromCustomReportRequestBody.js new file mode 100644 index 0000000..7aa8434 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/helpers/getStateFromCustomReportRequestBody.js @@ -0,0 +1,97 @@ +import { + CALENDAR_FILTER_VALUE, + DIMENSIONS_LIST, + DIMENSIONS_WITH_EXEPTIONS, + FILTERS, + HUB_FILTER_NAME, + METRICS_VALUES, + RECURRENCE_VALUES, +} from '../constants'; + +const getStateFromCustomReportRequestBody = ({ body, isDuplicate = false, userEmail, userPublisherIds }) => { + const { + name, + period, + periodCustomStartDate, + periodCustomEndDate, + metrics, + dimensions, + scheduled, + scheduleType, + scheduleValue, + recipients, + filters, + createdBy, + } = body; + + const state = { + name: isDuplicate ? `Copy of ${name}`.substring(0, 200) : name, + period, + checkedMetrics: metrics?.filter((item) => METRICS_VALUES[item.name])?.map((item) => item.name), + }; + + if (period === CALENDAR_FILTER_VALUE.custom) { + state.periodCustomStartDate = periodCustomStartDate; + state.periodCustomEndDate = periodCustomEndDate; + } + + if (scheduled && !isDuplicate) { + state.scheduleType = scheduleType ?? null; + state.scheduleTime = scheduleValue.time ?? null; + state.recipients = recipients ?? []; + + if (scheduleType === RECURRENCE_VALUES.weekly.value) { + state.scheduleDay = scheduleValue.dayInWeek ?? null; + } + + if (scheduleType === RECURRENCE_VALUES.monthly.value) { + state.scheduleDay = scheduleValue.dayInMonth ?? null; + } + } + + if (dimensions?.length) { + const sortedDimensions = dimensions.sort((a, b) => a.order - b.order); + const checkedDimensions = sortedDimensions?.filter((item) => item.value)?.map((item) => item.name); + + state.checkedDimensions = checkedDimensions; + + if (DIMENSIONS_LIST.length === sortedDimensions?.length) { + state.dimensionsList = sortedDimensions.map((item) => DIMENSIONS_WITH_EXEPTIONS[item.name]); + } + } + + if (filters?.length && !isDuplicate) { + filters.forEach((filter) => { + if (filter.name === HUB_FILTER_NAME && !userPublisherIds?.includes(+filter.value)) { + return; + } + + if (FILTERS[filter.name]?.multiple) { + state[`${filter.name}_FILTER_VALUES`] = [ + ...(state[`${filter.name}_FILTER_VALUES`] ?? []), + { + label: filter.label, + value: filter.value, + }, + ]; + } else { + state[`${filter.name}_FILTER_VALUES`] = { + label: filter.label, + value: filter.value, + }; + } + + state[`${filter.name}_FILTER_ACTION`] = filter.action; + }); + } + + if (createdBy !== userEmail && !isDuplicate) { + state.isReadOnly = true; + } else { + state.isReadOnly = false; + } + + return state; +}; + +export default getStateFromCustomReportRequestBody; diff --git a/anyclip/src/modules/analytics/customReports/helpers/index.js b/anyclip/src/modules/analytics/customReports/helpers/index.js new file mode 100644 index 0000000..7704716 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/helpers/index.js @@ -0,0 +1,21 @@ +export const combinations = (...arg) => { + const result = []; + const max = arg.length - 1; + + const helper = (arr, i) => { + for (let j = 0, l = arg[i].length; j < l; j += 1) { + const a = arr.slice(0); // clone arr + a.push(arg[i][j]); + if (i === max) { + result.push(a); + } else { + helper(a, i + 1); + } + } + }; + + helper([], 0); + return result; +}; + +export default combinations; diff --git a/src/modules/analytics/customReports/index.jsx b/anyclip/src/modules/analytics/customReports/index.jsx similarity index 100% rename from src/modules/analytics/customReports/index.jsx rename to anyclip/src/modules/analytics/customReports/index.jsx diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/checkDownloadReport.js b/anyclip/src/modules/analytics/customReports/redux/epics/checkDownloadReport.js new file mode 100644 index 0000000..8d75968 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/checkDownloadReport.js @@ -0,0 +1,138 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { MAX_DOWNLOAD_TRY_REQUESTS, MONITORING_DELAY } from '../../constants'; +import { TYPE_ERROR, TYPE_WARNING } from '@/modules/@common/notify/constants'; + +import * as selectors from '../selectors'; +import { checkDownloadReportAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const MONITORING_STATUS = { + PROCESSING: 'PROCESSING', + DONE: 'DONE', + ERROR: 'ERROR', +}; + +const query = ` + query CustomReportDownloadCheck( + $reportId: Int!, + $operationId: String! + ) { + customReportDownloadCheck( + reportId: $reportId, + operationId: $operationId + ) { + reportId + operationId + status + message + file + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(checkDownloadReportAction.type), + debounceTime(MONITORING_DELAY), + filter(({ payload }) => payload.reportId && payload.operationId), + switchMap(({ payload }) => { + const state = state$.value; + + const downloadReportTryRequestCounter = selectors.downloadReportTryRequestCounterSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + reportId: payload.reportId, + operationId: payload.operationId, + }, + }).pipe( + switchMap(({ data: { customReportDownloadCheck = {} }, errors }) => { + if (!errors.length) { + const { status, file, message } = customReportDownloadCheck; + + const isTryDownload = + status === MONITORING_STATUS.PROCESSING && downloadReportTryRequestCounter < MAX_DOWNLOAD_TRY_REQUESTS; + const isDownloadTryRequestOver = downloadReportTryRequestCounter >= MAX_DOWNLOAD_TRY_REQUESTS; + + switch (true) { + case status === MONITORING_STATUS.DONE: { + const link = document.createElement('a'); + link.href = file; + link.download = payload.fileName; + link.click(); + + return concat( + of( + setFieldAction({ + isDownloading: false, + downloadReportTryRequestCounter: 0, + }), + ), + ); + } + case isTryDownload: { + return concat( + of( + checkDownloadReportAction({ + reportId: payload.reportId, + operationId: payload.operationId, + fileName: payload.fileName, + }), + ), + of( + setFieldAction({ + downloadReportTryRequestCounter: downloadReportTryRequestCounter + 1, + }), + ), + ); + } + case isDownloadTryRequestOver: { + return concat( + of( + showNotificationAction({ + type: TYPE_WARNING, + message: 'Download is taking too long. We will email the report to you instead', + }), + ), + of( + setFieldAction({ + downloadReportTryRequestCounter: 0, + isDownloading: false, + }), + ), + ); + } + case status === MONITORING_STATUS.ERROR: { + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message, + }), + ), + of( + setFieldAction({ + isDownloading: false, + downloadReportTryRequestCounter: 0, + }), + ), + ); + } + default: { + return EMPTY; + } + } + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/createCustomReport.js b/anyclip/src/modules/analytics/customReports/redux/epics/createCustomReport.js new file mode 100644 index 0000000..9247ae6 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/createCustomReport.js @@ -0,0 +1,96 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createCustomReportRequestBody from '../../helpers/createCustomReportRequestBody'; +import { createCustomReportAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation CreateCustomReport( + $name: String, + $scheduled: Boolean, + $scheduleType: String, + $recipients: [String], + $scheduleValue: AnalyticsCustomReportScheduleValueInputType, + $period: String, + $periodCustomStartDate: String, + $periodCustomEndDate: String, + $dimensions: [AnalyticsCustomReportDimensionInputType], + $filters: [AnalyticsCustomReportFilterInputType], + $metrics: [AnalyticsCustomReportMetricInputType] + ) { + createCustomReport( + name: $name, + scheduled: $scheduled, + scheduleType: $scheduleType, + recipients: $recipients, + scheduleValue: $scheduleValue, + period: $period, + periodCustomStartDate: $periodCustomStartDate, + periodCustomEndDate: $periodCustomEndDate, + dimensions: $dimensions, + filters: $filters, + metrics: $metrics + ) { + id + name + scheduled + scheduleType + scheduleValue { + dayInWeek + dayInMonth + time + } + period + periodCustomStartDate + periodCustomEndDate + lastDelivery + nextDelivery + updatedBy + createdBy + updatedAt + createdAt + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createCustomReportAction.type), + switchMap(() => { + const state = state$.value; + const body = createCustomReportRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + ...body, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + Router.push(`/custom-reports-new/${data.createCustomReport?.id}`); + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Report saved', + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/deleteCustomReport.js b/anyclip/src/modules/analytics/customReports/redux/epics/deleteCustomReport.js new file mode 100644 index 0000000..11b932f --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/deleteCustomReport.js @@ -0,0 +1,43 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { deleteCustomReportAction, getCustomReportsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation deleteCustomReport( + $id: Int! + ) { + deleteCustomReport( + id: $id + ) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(deleteCustomReportAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(getCustomReportsAction())); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/getCountries.js b/anyclip/src/modules/analytics/customReports/redux/epics/getCountries.js new file mode 100644 index 0000000..bd4f4ab --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/getCountries.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { COUNTRY_FILTER_NAME } from '../../constants'; + +import { getCountriesAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query commonGeography { + commonGeography { + id + uiKey + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCountriesAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + setFieldAction({ + [`${COUNTRY_FILTER_NAME}_FILTER_OPTIONS`]: + data?.commonGeography?.map((item) => ({ + label: item.name, + value: item.uiKey, + })) ?? [], + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/getCustomReportById.js b/anyclip/src/modules/analytics/customReports/redux/epics/getCustomReportById.js new file mode 100644 index 0000000..6f158f9 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/getCustomReportById.js @@ -0,0 +1,98 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import getStateFromCustomReportRequestBody from '../../helpers/getStateFromCustomReportRequestBody'; +import { getCustomReportByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getPublisherIdsSelector, getUserEmailSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query customReportById( + $id: Int! + ) { + customReportById( + id: $id + ) { + id + name + scheduled + scheduleType + scheduleValue { + dayInWeek + dayInMonth + time + } + recipients + period + periodCustomStartDate + periodCustomEndDate + lastDelivery + nextDelivery + updatedBy + createdBy + updatedAt + createdAt + dimensions { + id + customReportId + name + value + order + } + filters { + id + customReportId + name + label + value + action + } + metrics { + id + customReportId + name + value + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getCustomReportByIdAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + const userEmail = getUserEmailSelector(state$.value); + const userPublisherIds = getPublisherIdsSelector(state$.value); + + if (!errors.length) { + actions.push( + of( + setFieldAction( + getStateFromCustomReportRequestBody({ + body: data.customReportById, + isDuplicate: payload.isDuplicate, + userEmail, + userPublisherIds, + }), + ), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/getCustomReports.js b/anyclip/src/modules/analytics/customReports/redux/epics/getCustomReports.js new file mode 100644 index 0000000..fb02a50 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/getCustomReports.js @@ -0,0 +1,106 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getCustomReportsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query customReports( + $pageSize: Int, + $page: Int, + $sortBy: String, + $sortOrder: String, + $searchText: String, + $searchIn: [String] + ) { + customReports( + pageSize: $pageSize, + page: $page, + sortBy: $sortBy, + sortOrder: $sortOrder, + searchText: $searchText, + searchIn: $searchIn + ) { + page + pageSize + recordsTotal + records { + id + name + scheduled + scheduleType + scheduleValue { + dayInWeek + dayInMonth + time + } + period + periodCustomStartDate + periodCustomEndDate + lastDelivery + nextDelivery + updatedBy + createdBy + updatedAt + createdAt + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getCustomReportsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap(() => { + const state = state$.value; + + const searchText = selectors.searchTextSelector(state); + const page = selectors.pageSelector(state); + const pageSize = selectors.pageSizeSelector(state); + const sortBy = selectors.sortBySelector(state); + const sortOrder = selectors.sortOrderSelector(state); + + let requestParams = { + page, + pageSize, + }; + + if (searchText?.length) { + requestParams = { + ...requestParams, + searchText, + }; + } + + if (sortBy) { + requestParams = { + ...requestParams, + sortBy, + sortOrder, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + searchIn: ['name', 'createdBy'], + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { records, recordsTotal } = data.customReports; + actions.push(of(setFieldAction({ records, recordsTotal }))); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/getDemandSources.js b/anyclip/src/modules/analytics/customReports/redux/epics/getDemandSources.js new file mode 100644 index 0000000..7ff0452 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/getDemandSources.js @@ -0,0 +1,78 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { DEMAND_SOURCE_FILTER_NAME } from '../../constants'; + +import { getDemandSourcesAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query adPerformanceDemandSources( + $devices: [String], + $domains: [String], + $countries: [String], + $dateRange: AnalyticsDateRangeInputType, + $interval: AnalyticsIntervalInputType, + $timezone: String, + $prefix: String, + $size: Int + ) { + adPerformanceDemandSources( + devices: $devices, + domains: $domains, + countries: $countries, + dateRange: $dateRange, + interval: $interval, + timezone: $timezone, + prefix: $prefix, + size: $size + ) { + rid + demandSources + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getDemandSourcesAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + prefix: action.payload, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + size: 100, + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { demandSources } = data.adPerformanceDemandSources; + + actions.push( + of( + setFieldAction({ + [`${DEMAND_SOURCE_FILTER_NAME}_FILTER_OPTIONS`]: + demandSources?.map((item) => ({ label: item, value: item })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/getHubs.js b/anyclip/src/modules/analytics/customReports/redux/epics/getHubs.js new file mode 100644 index 0000000..a0c102c --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/getHubs.js @@ -0,0 +1,64 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { HUB_FILTER_NAME } from '../../constants'; + +import { getHubsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getPublisherIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query AnalyticsPublishers( + $watchEnabledOnly: Boolean, + $removeDisabled: Boolean, + $removeLimit: Boolean, + $formEnabledOnly: Boolean + ) { + analyticsPublishers( + watchEnabledOnly: $watchEnabledOnly, + removeDisabled: $removeDisabled, + removeLimit: $removeLimit, + formEnabledOnly: $formEnabledOnly + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { analyticsPublishers } }) => + analyticsPublishers?.records?.map((publisher) => ({ value: publisher.id, label: publisher.name })) ?? []; + +export default (action$, state$) => + action$.pipe( + ofType(getHubsAction.type), + switchMap(() => { + const userPublisherIds = getPublisherIdsSelector(state$.value); + + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const hubs = getResponse(response); + actions.push( + of( + setFieldAction({ + [`${HUB_FILTER_NAME}_FILTER_OPTIONS`]: hubs.filter((hub) => userPublisherIds?.includes(hub.value)), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/getUserDomains.js b/anyclip/src/modules/analytics/customReports/redux/epics/getUserDomains.js new file mode 100644 index 0000000..89f33d8 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/getUserDomains.js @@ -0,0 +1,61 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { PLAYER_DOMAIN_FILTER_NAME } from '../../constants'; + +import { getUserDomainsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query analyticsUserDomains( + $searchText: String + ) { + analyticsUserDomains( + searchText: $searchText + ) { + id + domain + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getUserDomainsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + searchText: action.payload, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setFieldAction({ + [`${PLAYER_DOMAIN_FILTER_NAME}_FILTER_OPTIONS`]: + data.analyticsUserDomains?.map((item) => ({ label: item.domain, value: item.id })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/getUserPlayers.js b/anyclip/src/modules/analytics/customReports/redux/epics/getUserPlayers.js new file mode 100644 index 0000000..2d62abb --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/getUserPlayers.js @@ -0,0 +1,97 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { PLAYER_ID_FILTER_NAME, PLAYER_NAME_FILTER_NAME, PLAYER_TYPE_FILTER_NAME } from '../../constants'; + +import { getUserPlayersAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query analyticsUserPlayers( + $searchText: String + ) { + analyticsUserPlayers( + searchText: $searchText + ) { + id + name + alias + playerType { + id + name + } + publisherDomain { + id + domain + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getUserPlayersAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + searchText: action.payload, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length && data?.analyticsUserPlayers?.length) { + const fields = data.analyticsUserPlayers.reduce((acc, cur) => { + const isPlayerTypeAlreadyExists = acc?.[`${PLAYER_TYPE_FILTER_NAME}_FILTER_OPTIONS`]?.some( + (item) => item?.value === cur.playerType?.id, + ); + + const playerTypesOptions = isPlayerTypeAlreadyExists + ? (acc?.[`${PLAYER_TYPE_FILTER_NAME}_FILTER_OPTIONS`] ?? []) + : [ + ...(acc?.[`${PLAYER_TYPE_FILTER_NAME}_FILTER_OPTIONS`] ?? []), + { + label: cur.playerType.name, + value: cur.playerType.id, + }, + ]; + + return { + [`${PLAYER_NAME_FILTER_NAME}_FILTER_OPTIONS`]: [ + ...(acc?.[`${PLAYER_NAME_FILTER_NAME}_FILTER_OPTIONS`] ?? []), + { + label: cur.alias, + value: cur.alias, + }, + ], + [`${PLAYER_ID_FILTER_NAME}_FILTER_OPTIONS`]: [ + ...(acc?.[`${PLAYER_ID_FILTER_NAME}_FILTER_OPTIONS`] ?? []), + { + label: cur.name, + value: cur.name, + }, + ], + [`${PLAYER_TYPE_FILTER_NAME}_FILTER_OPTIONS`]: playerTypesOptions, + }; + }, {}); + + actions.push(of(setFieldAction(fields))); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/index.js b/anyclip/src/modules/analytics/customReports/redux/epics/index.js new file mode 100644 index 0000000..0e31d8f --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/index.js @@ -0,0 +1,29 @@ +import { combineEpics } from 'redux-observable'; + +import checkDownloadReport from './checkDownloadReport'; +import createCustomReport from './createCustomReport'; +import deleteCustomReport from './deleteCustomReport'; +import getCountries from './getCountries'; +import getCustomReportById from './getCustomReportById'; +import getCustomReports from './getCustomReports'; +import getDemandSources from './getDemandSources'; +import getHubs from './getHubs'; +import getUserDomains from './getUserDomains'; +import getUserPlayers from './getUserPlayers'; +import runDownloadReport from './runDownloadReport'; +import updateCustomReport from './updateCustomReport'; + +export default combineEpics( + createCustomReport, + updateCustomReport, + getCustomReportById, + getCustomReports, + deleteCustomReport, + getDemandSources, + getHubs, + getCountries, + getUserPlayers, + getUserDomains, + runDownloadReport, + checkDownloadReport, +); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/runDownloadReport.js b/anyclip/src/modules/analytics/customReports/redux/epics/runDownloadReport.js new file mode 100644 index 0000000..d9f658d --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/runDownloadReport.js @@ -0,0 +1,108 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { DOWNLOAD_TIMEOUT } from '../../constants'; + +import createCustomReportRequestBody from '../../helpers/createCustomReportRequestBody'; +import { checkDownloadReportAction, runDownloadReportAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + mutation CustomReportDownloadRun( + $id: Int!, + $timeZone: String, + $downloadTimeout: Int, + $requestFullInfo: Boolean, + $name: String, + $scheduled: Boolean, + $scheduleType: String, + $recipients: [String], + $scheduleValue: AnalyticsCustomReportScheduleValueInputType, + $period: String, + $periodCustomStartDate: String, + $periodCustomEndDate: String, + $dimensions: [AnalyticsCustomReportDimensionInputType], + $filters: [AnalyticsCustomReportFilterInputType], + $metrics: [AnalyticsCustomReportMetricInputType] + ) { + customReportDownloadRun( + id: $id, + timeZone: $timeZone, + downloadTimeout: $downloadTimeout, + requestFullInfo: $requestFullInfo, + name: $name, + scheduled: $scheduled, + scheduleType: $scheduleType, + recipients: $recipients, + scheduleValue: $scheduleValue, + period: $period, + periodCustomStartDate: $periodCustomStartDate, + periodCustomEndDate: $periodCustomEndDate, + dimensions: $dimensions, + filters: $filters, + metrics: $metrics + ) { + reportId + operationId + status + message + file + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(runDownloadReportAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const timezone = getUserTimezoneSelector(state$.value); + const state = state$.value; + + // eslint-disable-next-line no-useless-assignment + let body = {}; + + if (payload.requestFullInfo) { + body = { + requestFullInfo: payload.requestFullInfo, + }; + } else { + body = { + ...createCustomReportRequestBody(state), + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + timeZone: timezone, + downloadTimeout: DOWNLOAD_TIMEOUT, + ...body, + }, + }).pipe( + switchMap(({ data: { customReportDownloadRun = {} }, errors }) => { + let actions = []; + + if (!errors.length && customReportDownloadRun?.operationId && customReportDownloadRun?.reportId) { + const { operationId, reportId } = customReportDownloadRun; + actions = [ + of( + checkDownloadReportAction({ + operationId, + reportId, + fileName: payload.name, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isDownloading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/epics/updateCustomReport.js b/anyclip/src/modules/analytics/customReports/redux/epics/updateCustomReport.js new file mode 100644 index 0000000..37b8b0c --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/epics/updateCustomReport.js @@ -0,0 +1,98 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createCustomReportRequestBody from '../../helpers/createCustomReportRequestBody'; +import { setFieldAction, updateCustomReportAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateCustomReport( + $id: Int!, + $name: String, + $scheduled: Boolean, + $scheduleType: String, + $recipients: [String], + $scheduleValue: AnalyticsCustomReportScheduleValueInputType, + $period: String, + $periodCustomStartDate: String, + $periodCustomEndDate: String, + $dimensions: [AnalyticsCustomReportDimensionInputType], + $filters: [AnalyticsCustomReportFilterInputType], + $metrics: [AnalyticsCustomReportMetricInputType] + ) { + updateCustomReport( + id: $id, + name: $name, + scheduled: $scheduled, + scheduleType: $scheduleType, + recipients: $recipients, + scheduleValue: $scheduleValue, + period: $period, + periodCustomStartDate: $periodCustomStartDate, + periodCustomEndDate: $periodCustomEndDate, + dimensions: $dimensions, + filters: $filters, + metrics: $metrics + ) { + id + name + scheduled + scheduleType + scheduleValue { + dayInWeek + dayInMonth + time + } + period + periodCustomStartDate + periodCustomEndDate + lastDelivery + nextDelivery + updatedBy + createdBy + updatedAt + createdAt + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateCustomReportAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const state = state$.value; + const body = createCustomReportRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + ...body, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Report saved', + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/analytics/customReports/redux/selectors/index.js b/anyclip/src/modules/analytics/customReports/redux/selectors/index.js new file mode 100644 index 0000000..e545a84 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/selectors/index.js @@ -0,0 +1,42 @@ +import { FILTERS_LIST } from '../../constants'; + +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const nameSelector = (state$) => state$[nameSpace].name; +export const periodSelector = (state$) => state$[nameSpace].period; +export const periodCustomStartDateSelector = (state$) => state$[nameSpace].periodCustomStartDate; +export const periodCustomEndDateSelector = (state$) => state$[nameSpace].periodCustomEndDate; +export const scheduleTypeSelector = (state$) => state$[nameSpace].scheduleType; +export const scheduleDaySelector = (state$) => state$[nameSpace].scheduleDay; +export const scheduleTimeSelector = (state$) => state$[nameSpace].scheduleTime; +export const recipientsSelector = (state$) => state$[nameSpace].recipients; +export const dimensionsListSelector = (state$) => state$[nameSpace].dimensionsList; +export const checkedMetricsSelector = (state$) => state$[nameSpace].checkedMetrics; +export const checkedDimensionsSelector = (state$) => state$[nameSpace].checkedDimensions; +export const isReadOnlySelector = (state$) => state$[nameSpace].isReadOnly; +export const isDownloadingSelector = (state$) => state$[nameSpace].isDownloading; +export const isLoadingSelector = (state$) => state$[nameSpace].isLoading; +export const searchTextSelector = (state$) => state$[nameSpace].searchText; +export const pageSelector = (state$) => state$[nameSpace].page; +export const pageSizeSelector = (state$) => state$[nameSpace].pageSize; +export const sortBySelector = (state$) => state$[nameSpace].sortBy; +export const sortOrderSelector = (state$) => state$[nameSpace].sortOrder; +export const recordsSelector = (state$) => state$[nameSpace].records; +export const recordsTotalSelector = (state$) => state$[nameSpace].recordsTotal; +export const downloadReportTryRequestCounterSelector = (state$) => state$[nameSpace].downloadReportTryRequestCounter; + +export const filtersSelector = (state$) => { + const state = state$[nameSpace]; + + return FILTERS_LIST.reduce( + (acc, cur) => ({ + ...acc, + [`${cur.name}_FILTER_VALUES`]: state[`${cur.name}_FILTER_VALUES`], + [`${cur.name}_FILTER_OPTIONS`]: state[`${cur.name}_FILTER_OPTIONS`], + [`${cur.name}_FILTER_ACTION`]: state[`${cur.name}_FILTER_ACTION`], + }), + {}, + ); +}; diff --git a/anyclip/src/modules/analytics/customReports/redux/slices/index.js b/anyclip/src/modules/analytics/customReports/redux/slices/index.js new file mode 100644 index 0000000..0dd46d2 --- /dev/null +++ b/anyclip/src/modules/analytics/customReports/redux/slices/index.js @@ -0,0 +1,139 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + AD_FORMAT_FILTER_NAME, + AD_FORMAT_FILTER_OPTIONS, + CALENDAR_FILTER_VALUE, + COUNTRY_FILTER_NAME, + DEMAND_SOURCE_FILTER_NAME, + DEVICE_FILTER_NAME, + DEVICE_FILTER_OPTIONS, + DIMENSIONS_LIST, + EMBED_CODE_VARIANT_FILTER_NAME, + EMBED_CODE_VARIANT_FILTER_OPTIONS, + HUB_FILTER_NAME, + PLAYER_DOMAIN_FILTER_NAME, + PLAYER_ID_FILTER_NAME, + PLAYER_NAME_FILTER_NAME, + PLAYER_TYPE_FILTER_NAME, +} from '../../constants'; + +const SETUP_PAGE_INITIAL_STATE = { + name: 'New Custom Report', + period: CALENDAR_FILTER_VALUE.monthToDate.value, + periodCustomStartDate: null, + periodCustomEndDate: null, + scheduleType: null, + scheduleDay: null, + scheduleTime: null, + recipients: [], + dimensionsList: DIMENSIONS_LIST, + checkedMetrics: [], + checkedDimensions: [], + isReadOnly: false, + isDownloading: false, + // filters + [`${DEMAND_SOURCE_FILTER_NAME}_FILTER_VALUES`]: [], + [`${DEMAND_SOURCE_FILTER_NAME}_FILTER_OPTIONS`]: [], + [`${DEMAND_SOURCE_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${AD_FORMAT_FILTER_NAME}_FILTER_VALUES`]: [], + [`${AD_FORMAT_FILTER_NAME}_FILTER_OPTIONS`]: AD_FORMAT_FILTER_OPTIONS, + [`${AD_FORMAT_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${HUB_FILTER_NAME}_FILTER_VALUES`]: [], + [`${HUB_FILTER_NAME}_FILTER_OPTIONS`]: [], + [`${HUB_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${PLAYER_NAME_FILTER_NAME}_FILTER_VALUES`]: [], + [`${PLAYER_NAME_FILTER_NAME}_FILTER_OPTIONS`]: [], + [`${PLAYER_NAME_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${PLAYER_ID_FILTER_NAME}_FILTER_VALUES`]: [], + [`${PLAYER_ID_FILTER_NAME}_FILTER_OPTIONS`]: [], + [`${PLAYER_ID_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${PLAYER_TYPE_FILTER_NAME}_FILTER_VALUES`]: [], + [`${PLAYER_TYPE_FILTER_NAME}_FILTER_OPTIONS`]: [], + [`${PLAYER_TYPE_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${COUNTRY_FILTER_NAME}_FILTER_VALUES`]: [], + [`${COUNTRY_FILTER_NAME}_FILTER_OPTIONS`]: [], + [`${COUNTRY_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${DEVICE_FILTER_NAME}_FILTER_VALUES`]: [], + [`${DEVICE_FILTER_NAME}_FILTER_OPTIONS`]: DEVICE_FILTER_OPTIONS, + [`${DEVICE_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${PLAYER_DOMAIN_FILTER_NAME}_FILTER_VALUES`]: [], + [`${PLAYER_DOMAIN_FILTER_NAME}_FILTER_OPTIONS`]: [], + [`${PLAYER_DOMAIN_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', + + [`${EMBED_CODE_VARIANT_FILTER_NAME}_FILTER_VALUES`]: [], + [`${EMBED_CODE_VARIANT_FILTER_NAME}_FILTER_OPTIONS`]: EMBED_CODE_VARIANT_FILTER_OPTIONS, + [`${EMBED_CODE_VARIANT_FILTER_NAME}_FILTER_ACTION`]: 'INCLUDE', +}; + +const initialState = { + isLoading: false, + searchText: '', + page: 1, + pageSize: 15, + sortBy: 'createdAt', + sortOrder: 'DESC', + records: [], + recordsTotal: 0, + downloadReportTryRequestCounter: 0, + // setup page + ...SETUP_PAGE_INITIAL_STATE, +}; + +export const slice = createSlice({ + name: '@@customReportsNew/CUSTOM_REPORTS_NEW', + initialState, + + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setDefaultSetupPageAction: (state) => { + Object.keys(SETUP_PAGE_INITIAL_STATE).forEach((key) => { + state[key] = SETUP_PAGE_INITIAL_STATE[key]; + }); + }, + + createCustomReportAction: (state) => state, + updateCustomReportAction: (state) => state, + getCustomReportByIdAction: (state) => state, + deleteCustomReportAction: (state) => state, + getCustomReportsAction: (state) => state, + getDemandSourcesAction: (state) => state, + getHubsAction: (state) => state, + getCountriesAction: (state) => state, + getUserPlayersAction: (state) => state, + getUserDomainsAction: (state) => state, + runDownloadReportAction: (state) => state, + checkDownloadReportAction: (state) => state, + }, +}); + +export const { + setFieldAction, + setDefaultSetupPageAction, + createCustomReportAction, + updateCustomReportAction, + getCustomReportByIdAction, + deleteCustomReportAction, + getCustomReportsAction, + getDemandSourcesAction, + getHubsAction, + getCountriesAction, + getUserPlayersAction, + getUserDomainsAction, + runDownloadReportAction, + checkDownloadReportAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/analytics/general/components/Failure/Failure.module.scss b/anyclip/src/modules/analytics/general/components/Failure/Failure.module.scss similarity index 100% rename from src/modules/analytics/general/components/Failure/Failure.module.scss rename to anyclip/src/modules/analytics/general/components/Failure/Failure.module.scss diff --git a/src/modules/analytics/general/components/Failure/index.jsx b/anyclip/src/modules/analytics/general/components/Failure/index.jsx similarity index 100% rename from src/modules/analytics/general/components/Failure/index.jsx rename to anyclip/src/modules/analytics/general/components/Failure/index.jsx diff --git a/src/modules/analytics/general/components/General.module.scss b/anyclip/src/modules/analytics/general/components/General.module.scss similarity index 100% rename from src/modules/analytics/general/components/General.module.scss rename to anyclip/src/modules/analytics/general/components/General.module.scss diff --git a/src/modules/analytics/general/components/InfoNeedSelectAccount/InfoNeedSelectAccount.module.scss b/anyclip/src/modules/analytics/general/components/InfoNeedSelectAccount/InfoNeedSelectAccount.module.scss similarity index 100% rename from src/modules/analytics/general/components/InfoNeedSelectAccount/InfoNeedSelectAccount.module.scss rename to anyclip/src/modules/analytics/general/components/InfoNeedSelectAccount/InfoNeedSelectAccount.module.scss diff --git a/src/modules/analytics/general/components/InfoNeedSelectAccount/index.jsx b/anyclip/src/modules/analytics/general/components/InfoNeedSelectAccount/index.jsx similarity index 100% rename from src/modules/analytics/general/components/InfoNeedSelectAccount/index.jsx rename to anyclip/src/modules/analytics/general/components/InfoNeedSelectAccount/index.jsx diff --git a/src/modules/analytics/general/components/Loader/Loader.module.scss b/anyclip/src/modules/analytics/general/components/Loader/Loader.module.scss similarity index 100% rename from src/modules/analytics/general/components/Loader/Loader.module.scss rename to anyclip/src/modules/analytics/general/components/Loader/Loader.module.scss diff --git a/src/modules/analytics/general/components/Loader/index.jsx b/anyclip/src/modules/analytics/general/components/Loader/index.jsx similarity index 100% rename from src/modules/analytics/general/components/Loader/index.jsx rename to anyclip/src/modules/analytics/general/components/Loader/index.jsx diff --git a/src/modules/analytics/general/components/Menu/Menu.module.scss b/anyclip/src/modules/analytics/general/components/Menu/Menu.module.scss similarity index 100% rename from src/modules/analytics/general/components/Menu/Menu.module.scss rename to anyclip/src/modules/analytics/general/components/Menu/Menu.module.scss diff --git a/src/modules/analytics/general/components/Menu/index.jsx b/anyclip/src/modules/analytics/general/components/Menu/index.jsx similarity index 100% rename from src/modules/analytics/general/components/Menu/index.jsx rename to anyclip/src/modules/analytics/general/components/Menu/index.jsx diff --git a/src/modules/analytics/general/components/index.jsx b/anyclip/src/modules/analytics/general/components/index.jsx similarity index 100% rename from src/modules/analytics/general/components/index.jsx rename to anyclip/src/modules/analytics/general/components/index.jsx diff --git a/anyclip/src/modules/analytics/general/constants/index.js b/anyclip/src/modules/analytics/general/constants/index.js new file mode 100644 index 0000000..fb65ba9 --- /dev/null +++ b/anyclip/src/modules/analytics/general/constants/index.js @@ -0,0 +1,11 @@ +export const IN_HOUSE_ANALYTICS_SUB_STRING = 'anyclip_analytics'; + +export const IN_HOUSE_ITEMS_TYPE = { + videoPerformanceExternal: 'video_performance_external', + videoPerformanceInternal: 'video_performance_internal', + liveEvents: 'live_events', + monetization: 'monetization', + customReports: 'custom_reports', +}; + +export default IN_HOUSE_ANALYTICS_SUB_STRING; diff --git a/anyclip/src/modules/analytics/general/helpers/index.js b/anyclip/src/modules/analytics/general/helpers/index.js new file mode 100644 index 0000000..d260ec6 --- /dev/null +++ b/anyclip/src/modules/analytics/general/helpers/index.js @@ -0,0 +1,7 @@ +import { WEAVO_ANALYTICS_SHOW_ACCOUNT } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +export const canSelectAccount = (userPermissions) => hasPermission(WEAVO_ANALYTICS_SHOW_ACCOUNT, userPermissions); + +export default canSelectAccount; diff --git a/src/modules/analytics/general/index.jsx b/anyclip/src/modules/analytics/general/index.jsx similarity index 100% rename from src/modules/analytics/general/index.jsx rename to anyclip/src/modules/analytics/general/index.jsx diff --git a/anyclip/src/modules/analytics/general/redux/epics/getAccounts.js b/anyclip/src/modules/analytics/general/redux/epics/getAccounts.js new file mode 100644 index 0000000..6015723 --- /dev/null +++ b/anyclip/src/modules/analytics/general/redux/epics/getAccounts.js @@ -0,0 +1,70 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getAccountOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query analyticsAccounts( + $searchText: String, + $pageSize: Int + ) { + analyticsAccounts( + searchText: $searchText, + pageSize: $pageSize, + ) { + id + name + salesforceId + } + } +`; + +const getResponse = ({ data: { analyticsAccounts } }) => + analyticsAccounts.map((account) => ({ + value: account.salesforceId, + label: account.name, + salesforceId: account.salesforceId, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push( + of( + setFieldAction({ + accountOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + accountOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/analytics/general/redux/epics/getLookerUrl.js b/anyclip/src/modules/analytics/general/redux/epics/getLookerUrl.js new file mode 100644 index 0000000..97ff630 --- /dev/null +++ b/anyclip/src/modules/analytics/general/redux/epics/getLookerUrl.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getLookerUrlAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { canSelectAccount } from '@/modules/analytics/general/helpers'; + +const query = ` + query GetGeneralAnalyticsLookerUrl( + $reportId: String + $salesforceId: String + ) { + getGeneralAnalyticsLookerUrl( + reportId: $reportId + salesforceId: $salesforceId + ) { + url + } + } +`; + +const getResponse = ({ data: { getGeneralAnalyticsLookerUrl } }) => getGeneralAnalyticsLookerUrl; + +export default (action$, state$) => + action$.pipe( + ofType(getLookerUrlAction.type), + filter(() => + canSelectAccount(getUserPermissionsSelector(state$.value)) + ? !!selectors.salesforceIdSelector(state$.value) + : !!selectors.selectedAnalyticSelector(state$.value), + ), + switchMap(() => { + const state = state$.value; + + const menu = selectors.menuSelector(state); + const selectedAnalytic = selectors.selectedAnalyticSelector(state); + const salesforceId = selectors.salesforceIdSelector(state); + + const reportId = menu.find((o) => o.name === selectedAnalytic)?.lookerReportId; + + const stream$ = gqlRequest({ + query, + variables: { + reportId, + salesforceId, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + + return of( + setFieldAction({ + lookerReportUrl: data.url, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setFieldAction({ isLoadingUrl: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/general/redux/epics/getMenuItems.js b/anyclip/src/modules/analytics/general/redux/epics/getMenuItems.js new file mode 100644 index 0000000..f9f07d8 --- /dev/null +++ b/anyclip/src/modules/analytics/general/redux/epics/getMenuItems.js @@ -0,0 +1,46 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getMenuItemsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetGeneralAnalyticsMenuItems { + getGeneralAnalyticsMenuItems { + records { + name + lookerReportId + type + } + } + } +`; + +const getResponse = ({ data: { getGeneralAnalyticsMenuItems } }) => getGeneralAnalyticsMenuItems; + +export default (action$) => + action$.pipe( + ofType(getMenuItemsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + + return of( + setFieldAction({ + menu: data.records, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/general/redux/epics/getStatus.js b/anyclip/src/modules/analytics/general/redux/epics/getStatus.js new file mode 100644 index 0000000..c06eb1b --- /dev/null +++ b/anyclip/src/modules/analytics/general/redux/epics/getStatus.js @@ -0,0 +1,46 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getStatusAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetGeneralAnalyticsStatus { + getGeneralAnalyticsStatus { + overallFailure + popup + message + } + } +`; + +const getResponse = ({ data: { getGeneralAnalyticsStatus } }) => getGeneralAnalyticsStatus; + +export default (action$) => + action$.pipe( + ofType(getStatusAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + + return of( + setFieldAction({ + statusOverallFailure: !!data.overallFailure, + statusShowPopup: !!data.popup, + statusPopupMessage: data.message, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/general/redux/epics/index.js b/anyclip/src/modules/analytics/general/redux/epics/index.js new file mode 100644 index 0000000..0b39d74 --- /dev/null +++ b/anyclip/src/modules/analytics/general/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import getAccounts from './getAccounts'; +import getLookerUrl from './getLookerUrl'; +import getMenuItems from './getMenuItems'; +import getStatus from './getStatus'; + +export default combineEpics(getMenuItems, getAccounts, getLookerUrl, getStatus); diff --git a/anyclip/src/modules/analytics/general/redux/selectors/index.js b/anyclip/src/modules/analytics/general/redux/selectors/index.js new file mode 100644 index 0000000..37b0ec2 --- /dev/null +++ b/anyclip/src/modules/analytics/general/redux/selectors/index.js @@ -0,0 +1,71 @@ +import { stringify } from 'qs'; + +import { IN_HOUSE_ANALYTICS_SUB_STRING } from '../../constants'; + +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const accountSelector = (state$) => state$[nameSpace].account; +export const menuSelector = (state$) => state$[nameSpace].menu; +export const accountOptionsSelector = (state$) => state$[nameSpace].accountOptions; +export const storedInHouseMenuSettingsSelector = (state$) => state$[nameSpace].storedInHouseMenuSettings; +export const selectedAnalyticSelector = (state$) => state$[nameSpace].selectedAnalytic; +export const publisherNameSelector = (state$) => state$[nameSpace].publisherName; +export const salesforceIdSelector = (state$) => state$[nameSpace].salesforceId; +export const isLoadingUrlSelector = (state$) => state$[nameSpace].isLoadingUrl; +export const lookerReportUrlSelector = (state$) => state$[nameSpace].lookerReportUrl; +export const statusOverallFailureSelector = (state$) => state$[nameSpace].statusOverallFailure; +export const statusShowPopupSelector = (state$) => state$[nameSpace].statusShowPopup; +export const statusPopupMessageSelector = (state$) => state$[nameSpace].statusPopupMessage; + +export const getMenuSelector = (state$) => { + const account = accountSelector(state$); + const menu = menuSelector(state$); + const storedInHouseMenuSettings = storedInHouseMenuSettingsSelector(state$); + + const menuByType = menu.reduce( + (acc, value) => ({ + ...acc, + [value.type]: [].concat(acc[value.type] ?? [], value), + }), + {}, + ); + + const createLookerMenuItem = (item) => { + const queryParams = { selectedAnalytic: item.name }; + + if (account) { + queryParams.publisherName = account.label; + queryParams.salesforceId = account.salesforceId; + } + + return { + title: item.name, + href: `/analytics?${stringify(queryParams)}`, + }; + }; + + const createInHouseMenuItem = (item) => { + const [, type] = item.lookerReportId.split('::'); + return storedInHouseMenuSettings[type] ?? null; + }; + + const createMenuByType = (menuListByType) => { + if (!menuListByType) { + return []; + } + + const menuResult = menuListByType.map((menuItem) => { + const isInHouseMenuItem = menuItem.lookerReportId.includes(IN_HOUSE_ANALYTICS_SUB_STRING); + return isInHouseMenuItem ? createInHouseMenuItem(menuItem) : createLookerMenuItem(menuItem); + }); + + return menuResult.filter(Boolean); + }; + + return { + generalMenu: createMenuByType(menuByType.general), + customMenu: createMenuByType(menuByType.custom), + }; +}; diff --git a/anyclip/src/modules/analytics/general/redux/slices/index.js b/anyclip/src/modules/analytics/general/redux/slices/index.js new file mode 100644 index 0000000..a0cfe56 --- /dev/null +++ b/anyclip/src/modules/analytics/general/redux/slices/index.js @@ -0,0 +1,43 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + account: null, + accountOptions: null, + + menu: [], + storedInHouseMenuSettings: {}, + + selectedAnalytic: '', + publisherName: '', + salesforceId: '', + + isLoadingUrl: false, + lookerReportUrl: '', + + statusOverallFailure: undefined, + statusShowPopup: false, + statusPopupMessage: '', +}; + +export const slice = createSlice({ + name: '@@analyticsGeneral/ANALYTICS', + initialState, + + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + + getMenuItemsAction: (state) => state, + getAccountOptionsAction: (state) => state, + getLookerUrlAction: (state) => state, + getStatusAction: (state) => state, + }, +}); + +export const { setFieldAction, getMenuItemsAction, getAccountOptionsAction, getLookerUrlAction, getStatusAction } = + slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/Chart.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Chart/Chart.module.scss new file mode 100644 index 0000000..36c48cf --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/Chart.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Chart":"Chart_Chart___EQEX","ChartContent":"Chart_ChartContent__buCtw","Info":"Chart_Info__VHyaU","Chip":"Chart_Chip__EnzUe","InfoIcon":"Chart_InfoIcon__d8IMS","Control":"Chart_Control__YxFOD","SwitchWrap":"Chart_SwitchWrap__R4rIA","Switch":"Chart_Switch__5MOVF","ChartIcon":"Chart_ChartIcon__C_I6t","NoData":"Chart_NoData__JhVax","NoDataTitle":"Chart_NoDataTitle__Xu8FK","NoDataImageWrapper":"Chart_NoDataImageWrapper__5cMlx"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomActiveDot.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomActiveDot.jsx new file mode 100644 index 0000000..2b4f3ba --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomActiveDot.jsx @@ -0,0 +1,23 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Dot } from 'recharts'; + +function CustomActiveDot(props) { + useEffect(() => { + props.setActiveCoordinate({ x: props.cx, y: props.cy }); + + return () => { + props.setActiveCoordinate(null); + }; + }, []); + + return ; +} + +CustomActiveDot.propTypes = { + cx: PropTypes.number.isRequired, + cy: PropTypes.number.isRequired, + setActiveCoordinate: PropTypes.func.isRequired, +}; + +export default CustomActiveDot; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomCursor.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomCursor.jsx new file mode 100644 index 0000000..f350af0 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomCursor.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useTheme } from '@mui/material/styles'; + +function CustomCursor(props) { + const theme = useTheme(); + + const { x: x1 } = props.points[0]; + const { y: y2 } = props.points[1]; + + if (props.activeCoordinate?.y === null) { + return null; + } + + return ( + <> + + + + ); +} + +CustomCursor.propTypes = { + activeCoordinate: PropTypes.shape({ + x: PropTypes.number, + y: PropTypes.number, + }).isRequired, + width: PropTypes.number.isRequired, + points: PropTypes.arrayOf( + PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }), + ).isRequired, +}; + +export default CustomCursor; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomDot.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomDot.jsx new file mode 100644 index 0000000..b87c0f8 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomDot.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Dot } from 'recharts'; + +function CustomDot({ alwaysHide = false, ...props }) { + if (alwaysHide) { + return null; + } + + return props.payload?.showDot && ; +} + +CustomDot.propTypes = { + payload: PropTypes.shape({ + showDot: PropTypes.bool, + }).isRequired, + alwaysHide: PropTypes.bool, +}; + +export default CustomDot; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.jsx new file mode 100644 index 0000000..a244f5c --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; + +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import styles from './CustomTick.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +function CustomTick(props) { + const timezone = useSelector(getUserTimezoneSelector); + + if (props.payload.value === 'break') { + return ( + + + + {props.payload.value} + + + + ); + } + + const time = dayjs(props.payload.value).tz(timezone); + + return ( + + + + {time.format('HH:mm')} + + + {time.format('MMM DD')} + + + + ); +} + +CustomTick.propTypes = { + payload: PropTypes.shape({ + value: PropTypes.string.isRequired, + }).isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, +}; + +export default CustomTick; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.module.scss new file mode 100644 index 0000000..d151b10 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTick.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Break":"CustomTick_Break__NEFnU","Text":"CustomTick_Text__djHc7","TextDate":"CustomTick_TextDate__DU3IF"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.jsx new file mode 100644 index 0000000..ba59c47 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; + +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import styles from './CustomTooltip.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +function CustomTooltip({ formatter = null, ...props }) { + const timezone = useSelector(getUserTimezoneSelector); + + if (!props.active || !props.payload?.length) { + return null; + } + + const info = props.payload?.length === 1 ? props.payload?.[0] : props.payload?.[1]; // 0 is focus line + const name = info?.name; + const time = dayjs(info?.payload?.time).tz(timezone); + const value = info?.payload?.value; + const valueInFocus = info?.payload?.valueInFocus; + + return props.active && props.payload?.length ? ( +
+

{name}

+

{time.format('MMM DD, YYYY')}

+

{time.format('HH:mm')}

+ +

{`${props.title}: ${formatter ? formatter(value) : value}`}

+ +

{`In focus: ${formatter ? formatter(valueInFocus) : valueInFocus}`}

+
+ ) : null; +} + +CustomTooltip.propTypes = { + active: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + payload: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + formatter: PropTypes.func, +}; + +export default CustomTooltip; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.module.scss new file mode 100644 index 0000000..835df46 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/CustomTooltip.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Tooltip":"CustomTooltip_Tooltip__Dh_fc","TooltipText":"CustomTooltip_TooltipText__q4n9S","TooltipDate":"CustomTooltip_TooltipDate__rokmh"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Chart/index.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Chart/index.jsx new file mode 100644 index 0000000..c83be39 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Chart/index.jsx @@ -0,0 +1,246 @@ +import React, { Fragment, useState } from 'react'; +import PropTypes from 'prop-types'; +import Image from 'next/image'; +import { CartesianGrid, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { alpha } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + AccessTimeRounded, + HistoryToggleOffRounded, + InfoOutlined, + PeopleOutlineRounded, + TripOriginOutlined, +} from '@mui/icons-material'; + +import { ALPHA_400, ALPHA_500 } from '@/mui/constants/opacity'; + +import { abbreviateNumber } from '@/modules/@common/helpers/number'; + +import CustomActiveDot from './CustomActiveDot'; +import CustomCursor from './CustomCursor'; +import CustomDot from './CustomDot'; +import CustomTick from './CustomTick'; +import CustomTooltip from './CustomTooltip'; +import { Chip, Switch, Tooltip as MaterialTooltip, Typography } from '@/mui/components'; + +import img from '@/modules/analytics/common/components/Stub/img/imgNoData.png'; + +import styles from './Chart.module.scss'; + +function Chart(props) { + const theme = useTheme(); + + const icons = { + users: PeopleOutlineRounded, + totalMinutes: AccessTimeRounded, + averageMinutes: HistoryToggleOffRounded, + }; + + const [inFocus, setInFocus] = useState(false); + const [activeCoordinate, setActiveCoordinate] = useState(null); + + const xTicks = props.chartData.reduce((acc, item) => { + if (item.data?.length) { + const ticksToShow = item.data.filter((point) => point.showDot).map((point) => point.time); + + return [...acc, ...ticksToShow]; + } + return acc; + }, []); + + const TabIcon = icons[props.activeTab.key]; + + return ( +
+
+
+ {props.chartData.map((item, index) => ( + } + /> + ))} +
+ +
+
+ + In Focus + + + { + setInFocus(!inFocus); + }} + /> + + + + + + +
+
+ +
+
+
+
+ + + + + + + + + + + + : false} + /> + + { + if (tick < 1000) { + return Math.round(tick * 100) / 100; + } + return abbreviateNumber(tick); + }} + stroke={theme.palette.divider} + /> + + + } + cursor={} + /> + + {props.chartData.map((item, index) => { + const isLastDataItem = index === props.chartData.length - 1; + const references = item.data.filter( + (i, k) => i.showDot && !(isLastDataItem && k === item.data.length - 1), + ); + + return ( + + {/* line order is important for cursor component(lines to axis) */} + {inFocus && ( + } + activeDot={} + /> + )} + + } + activeDot={} + /> + + {references.map((ref) => ( + + ))} + + ); + })} + + +
+ + {!props.chartData?.length && ( +
+
+ No Data +
+ + + No Data... + + + + There is not enough data to display. Please change your request. + +
+ )} +
+ ); +} + +Chart.propTypes = { + chartData: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + data: PropTypes.arrayOf( + PropTypes.shape({ + time: PropTypes.number.isRequired, + value: PropTypes.number.isRequired, + valueInFocus: PropTypes.number.isRequired, + }), + ), + }), + ).isRequired, + activeTab: PropTypes.shape({ + id: PropTypes.string, + title: PropTypes.string, + key: PropTypes.string, + query: PropTypes.string, + tooltipTitle: PropTypes.string, + chartTooltipFormatter: PropTypes.func, + }).isRequired, +}; + +export default Chart; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Countries/Countries.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Countries/Countries.module.scss new file mode 100644 index 0000000..79ac83e --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Countries/Countries.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Countries":"Countries_Countries__g_vb9","CountriesTitle":"Countries_CountriesTitle__7r0sE","CountriesDescr":"Countries_CountriesDescr__HaXla","CountriesItem":"Countries_CountriesItem__hvrW6","CountriesName":"Countries_CountriesName__Q2zpb","CountriesValues":"Countries_CountriesValues__bUa4D","CountriesValue":"Countries_CountriesValue__LEgSF","CountriesPercent":"Countries_CountriesPercent__DYoHh","NoData":"Countries_NoData__BPdQJ","NoDataTitle":"Countries_NoDataTitle__I0dre","NoDataImageWrapper":"Countries_NoDataImageWrapper__rhJQw"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Countries/index.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Countries/index.jsx new file mode 100644 index 0000000..7f61fb3 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Countries/index.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Image from 'next/image'; + +import { PDF_EXPORT_HIDE_SCROLL } from '@/modules/analytics/common/constants'; + +import { abbreviateNumber } from '@/modules/@common/helpers/number'; + +import { Typography } from '@/mui/components'; + +import imgNoData from '@/modules/analytics/common/components/Stub/img/imgNoData.png'; + +import styles from './Countries.module.scss'; + +function Countries(props) { + return ( +
+ + Top Countries + + + by user sessions + + + {!!props.countries?.items?.length && ( +
+
+ + All Countries + + +
+ + {abbreviateNumber(props.countries.all)} + + + + 100% + +
+
+ + {props.countries?.items?.map((item) => ( +
+ + {item.name} + + +
+ + {abbreviateNumber(item.value)} + + + {`${item.percent}%`} + +
+
+ ))} +
+ )} + + {!props.countries?.items?.length && ( +
+
+ No Data +
+ + + No Data... + + + + No Data matching selected filters. + + + + Try adjusting your filters. + +
+ )} +
+ ); +} + +Countries.propTypes = { + countries: PropTypes.shape({ + all: PropTypes.number, + items: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + percent: PropTypes.number.isRequired, + }), + ), + }).isRequired, +}; + +export default Countries; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Devices/Devices.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Devices/Devices.module.scss new file mode 100644 index 0000000..09eaf89 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Devices/Devices.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Devices":"Devices_Devices__kDTzl","DevicesItem":"Devices_DevicesItem__aKiBv","DevicesAllWrap":"Devices_DevicesAllWrap__3P940","DevicesAll":"Devices_DevicesAll__ZWfI9","DevicesAll___empty":"Devices_DevicesAll___empty__dOy_5","Device":"Devices_Device__BJ9ga"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Devices/index.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Devices/index.jsx new file mode 100644 index 0000000..ada0c8c --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Devices/index.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { DEVICES_CONFIG } from '../../constants'; + +import { Typography } from '@/mui/components'; + +import styles from './Devices.module.scss'; + +function Devices(props) { + return ( +
+ {Object.keys(DEVICES_CONFIG).map((key) => ( +
+ + {DEVICES_CONFIG[key].title} + + + {props[key].all || props[key].all === 0 ? ( +
+ + {props[key].all} + + + % + +
+ ) : ( +
+ + No data... + +
+ )} + + {DEVICES_CONFIG[key].items.map((item) => ( +
+ + {item.title} + + + + {props[key][item.key] || props[key][item.key] === 0 ? `${props[key][item.key]}%` : '−'} + +
+ ))} +
+ ))} +
+ ); +} + +Devices.propTypes = { + desktop: PropTypes.shape({ + all: PropTypes.number, + medium: PropTypes.number, + large: PropTypes.number, + xLarge: PropTypes.number, + }).isRequired, + mobile: PropTypes.shape({ + all: PropTypes.number, + medium: PropTypes.number, + small: PropTypes.number, + xSmall: PropTypes.number, + }).isRequired, +}; + +export default Devices; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Filters/Filters.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Filters/Filters.module.scss new file mode 100644 index 0000000..b2e701b --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Filters/Filters.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Filters":"Filters_Filters__LTzkh","Date":"Filters_Date__TPois","Filter":"Filters_Filter__yPhy4","Total":"Filters_Total__OXhSH","TimerIcon":"Filters_TimerIcon__KZs5U"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Filters/index.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Filters/index.jsx new file mode 100644 index 0000000..2f48041 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Filters/index.jsx @@ -0,0 +1,154 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; +import { TimerOutlined } from '@mui/icons-material'; + +import { CALENDAR_FILTER, CALENDAR_FILTER_VALUE } from '../../constants'; + +import { autoSuggestHighlight } from '@/mui/helpers'; + +import { Autocomplete, Checkbox, MenuItem, Select, TextField, Typography } from '@/mui/components'; + +import styles from './Filters.module.scss'; + +function Filters(props) { + const totalTime = props.sessions?.reduce((partialSum, session) => partialSum + session.duration, 0) ?? 0; + + const renderOption = (props$, option, { inputValue, selected }, checkboxes) => ( +
  • + {checkboxes && } +
    + + {autoSuggestHighlight(inputValue, option.label).map((part) => { + const Tag = part.highlight ? 'b' : React.Fragment; + + return {part.text}; + })} + + + {option.time} + +
    +
  • + ); + + return ( +
    + + + { + props.handleEventChange(newValue); + }} + renderInput={(params) => ( + { + props.handleEventInputChange(e.target.value); + }} + /> + )} + /> + + renderOption(props$, option, value, true)} + options={props.sessionsOptions} + value={props.sessions} + onChange={(event, newValue) => { + props.handleSessionsChange(newValue); + }} + disabled={props.sessionsOptions?.length < 2} + renderInput={(params) => } + /> + +
    + + + + {`${totalTime}min`} + +
    +
    + ); +} + +Filters.propTypes = { + calendar: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({})]).isRequired, + calendarCustom: PropTypes.shape({ + dateStart: PropTypes.string, + dateEnd: PropTypes.string, + }).isRequired, + handleFilterSetCalendar: PropTypes.number.isRequired, + handleMenuItemClick: PropTypes.number.isRequired, + + eventsOptions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + time: PropTypes.number.isRequired, + }), + ).isRequired, + event: PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + time: PropTypes.number.isRequired, + }).isRequired, + handleEventChange: PropTypes.func.isRequired, + handleEventInputChange: PropTypes.func.isRequired, + + sessionsOptions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + time: PropTypes.number.isRequired, + }), + ).isRequired, + sessions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + time: PropTypes.number.isRequired, + }), + ).isRequired, + handleSessionsChange: PropTypes.func.isRequired, +}; + +export default Filters; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/LiveDashboard.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/LiveDashboard.module.scss new file mode 100644 index 0000000..565314b --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/LiveDashboard.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"LiveDashboardLayout":"LiveDashboard_LiveDashboardLayout__2nXgN","Header":"LiveDashboard_Header___VxdG","TitleWrap":"LiveDashboard_TitleWrap__UgmE6","TitleContent":"LiveDashboard_TitleContent__KCO8r","Title":"LiveDashboard_Title__yM3xs","InfoIcon":"LiveDashboard_InfoIcon__7Ly04","Filters":"LiveDashboard_Filters__n4lQr","Content":"LiveDashboard_Content__rPcP8","ChartSection":"LiveDashboard_ChartSection___F_FP","Chart":"LiveDashboard_Chart__DY69J","TablesSection":"LiveDashboard_TablesSection__nhH4p","Exports":"LiveDashboard_Exports__wHRBD","LoadingSpinner":"LiveDashboard_LoadingSpinner__RDGLq"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Tabs/Tabs.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Tabs/Tabs.module.scss new file mode 100644 index 0000000..5094697 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Tabs/Tabs.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Tabs":"Tabs_Tabs___61NX","Tab":"Tabs_Tab__jJCG6","Paper":"Tabs_Paper__W1p7S","Paper___leftside":"Tabs_Paper___leftside__gbeX8","Paper___active":"Tabs_Paper___active__FW5Nn","Paper___notActive":"Tabs_Paper___notActive__jlW2L","TabValue":"Tabs_TabValue__Rx6lL","TabValue___small":"Tabs_TabValue___small__fnI2v","TabValuePercent":"Tabs_TabValuePercent__7Lt0Y","InfoIconWrap":"Tabs_InfoIconWrap__McE8O"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Tabs/index.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Tabs/index.jsx new file mode 100644 index 0000000..1a299ad --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Tabs/index.jsx @@ -0,0 +1,113 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { InfoOutlined } from '@mui/icons-material'; + +import { TABS } from '../../constants'; + +import { abbreviateNumber } from '@/modules/@common/helpers/number'; + +import { Button, ButtonGroup, Paper, Tooltip, Typography } from '@/mui/components'; + +import styles from './Tabs.module.scss'; + +function Tabs(props) { + return ( + + {TABS.map((item, index) => { + const isActive = props.activeTab.id === item.id; + let result; + let tier = null; + + if (item.id === 'AVERAGE') { + result = Math.round(props[item.key] * 100) / 100; + } else { + const abbreviateObject = abbreviateNumber(props[item.key], 1, true); + // eslint-disable-next-line prefer-destructuring + result = abbreviateObject.result; + // eslint-disable-next-line prefer-destructuring + tier = abbreviateObject.tier; + } + + return ( + + ); + })} + + ); +} + +Tabs.propTypes = { + activeTab: PropTypes.shape({ + id: PropTypes.string, + title: PropTypes.string, + key: PropTypes.string, + query: PropTypes.string, + tooltipTitle: PropTypes.string, + chartTooltipFormatter: PropTypes.func, + }).isRequired, + handleTabClick: PropTypes.func.isRequired, +}; + +export default Tabs; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Timezone/Timezone.module.scss b/anyclip/src/modules/analytics/liveDashboard/components/Timezone/Timezone.module.scss new file mode 100644 index 0000000..0f96c58 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Timezone/Timezone.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Timezone":"Timezone_Timezone__MG8Fy","InfoIcon":"Timezone_InfoIcon___StQ5"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/liveDashboard/components/Timezone/index.jsx b/anyclip/src/modules/analytics/liveDashboard/components/Timezone/index.jsx new file mode 100644 index 0000000..16f3ff4 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/Timezone/index.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { InfoOutlined } from '@mui/icons-material'; + +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import { Tooltip, Typography } from '@/mui/components'; + +import styles from './Timezone.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +function Timezone() { + const timezone = useSelector(getUserTimezoneSelector); + const timezoneOffset = dayjs().tz(timezone).utcOffset() / 60; + return ( +
    + + + + + + + {`All time displayed in UTC ${timezoneOffset > 0 ? `+${timezoneOffset}` : timezoneOffset}`} + +
    + ); +} + +export default Timezone; diff --git a/anyclip/src/modules/analytics/liveDashboard/components/index.jsx b/anyclip/src/modules/analytics/liveDashboard/components/index.jsx new file mode 100644 index 0000000..abde652 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/components/index.jsx @@ -0,0 +1,262 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { HelpOutline, PictureAsPdfRounded } from '@mui/icons-material'; + +import { CALENDAR_FILTER_VALUE } from '../constants'; +import { PDF_EXPORT_HIDE_CONTENT } from '@/modules/analytics/common/constants'; + +import * as selectors from '../redux/selectors'; +import * as actions from '../redux/slices'; + +import { DialogCalendarRange, Layout } from '@/modules/analytics/common/components'; +import Chart from './Chart'; +import Countries from './Countries'; +import Devices from './Devices'; +import Filters from './Filters'; +import Tabs from './Tabs'; +import Timezone from './Timezone'; +import { CircularProgress, IconButton, Paper, Tooltip, Typography } from '@/mui/components'; +import { CustomCsvFilled } from '@/mui/components/CustomIcon'; + +import styles from './LiveDashboard.module.scss'; + +function LiveDashboard() { + const dispatch = useDispatch(); + + const calendar = useSelector(selectors.calendarSelector); + const calendarCustom = useSelector(selectors.calendarCustomSelector); + const eventsOptions = useSelector(selectors.eventsOptionsSelector); + const event = useSelector(selectors.eventSelector); + const sessionsOptions = useSelector(selectors.sessionsOptionsSelector); + const sessions = useSelector(selectors.sessionsSelector); + const activeTab = useSelector(selectors.activeTabSelector); + const users = useSelector(selectors.usersSelector); + const totalMinutes = useSelector(selectors.totalMinutesSelector); + const averageMinutes = useSelector(selectors.averageMinutesSelector); + const averageMinutesPercent = useSelector(selectors.averageMinutesPercentSelector); + const desktop = useSelector(selectors.desktopSelector); + const mobile = useSelector(selectors.mobileSelector); + const countries = useSelector(selectors.countriesSelector); + const chartData = useSelector(selectors.chartDataSelector); + const isExportPdfLoading = useSelector(selectors.isExportPdfLoadingSelector); + + const exportToCSV = (o) => dispatch(actions.exportToCSVAction(o)); + + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const contentRef = useRef(null); + + useEffect(() => { + dispatch(actions.getCountriesFullListAction()); + dispatch(actions.getLiveEventsAction()); + }, []); + + const handleExportToPDF = () => dispatch(actions.exportToPDFAction(contentRef.current)); + + const cleanData = () => { + dispatch( + actions.setFieldAction({ + users: null, + totalMinutes: null, + averageMinutes: null, + averageMinutesPercent: null, + desktop: { + all: null, + medium: null, + large: null, + xLarge: null, + }, + mobile: { + all: null, + medium: null, + small: null, + xSmall: null, + }, + countries: { + all: null, + items: [], + }, + chartData: [], + }), + ); + }; + + const handleFilterSetCalendar = (value) => { + dispatch( + actions.setFieldAction({ + calendar: value, + }), + ); + + if (value === CALENDAR_FILTER_VALUE.custom) { + setIsCalendarOpen(true); + return; + } + + dispatch( + actions.setFieldAction({ + event: null, + sessions: [], + }), + ); + + cleanData(); + + dispatch(actions.getLiveEventsAction()); + }; + + const handleMenuItemClick = (itemValue) => { + if (itemValue === CALENDAR_FILTER_VALUE.custom) { + setIsCalendarOpen(true); + } + }; + + const handleEventChange = (value) => { + dispatch( + actions.setFieldAction({ + event: value, + sessions: [], + sessionsOptions: [], + }), + ); + + if (!value) { + cleanData(); + } + + if (value) { + dispatch(actions.getLiveEventByIdAction({ id: value.value })); + } + }; + + const handleEventInputChange = (value) => { + dispatch(actions.getLiveEventsAction({ search: value })); + }; + + const handleSessionsChange = (value) => { + dispatch( + actions.setFieldAction({ + sessions: value, + }), + ); + + if (!value?.length) { + cleanData(); + } + + dispatch(actions.getLivePerformanceTotalsAction()); + dispatch(actions.getChartDataAction()); + }; + + const handleTabClick = (value) => { + dispatch(actions.setFieldAction({ activeTab: value })); + dispatch(actions.getChartDataAction()); + }; + + return ( + +
    +
    +
    + + Event Dashboard + + + + + + + +
    + +
    + {isExportPdfLoading && ( + + )} + + {!isExportPdfLoading && ( + + + + )} + + + + +
    +
    + +
    + +
    +
    + +
    +
    + + + + + +
    + +
    + + + + + +
    +
    + + setIsCalendarOpen(false)} + onDateSubmit={(dateRange) => { + dispatch( + actions.setFieldAction({ + calendarCustom: { + ...dateRange, + }, + event: null, + sessions: [], + }), + ); + + setIsCalendarOpen(false); + + dispatch(actions.getLiveEventsAction()); + }} + /> +
    + ); +} + +export default LiveDashboard; diff --git a/anyclip/src/modules/analytics/liveDashboard/constants/index.js b/anyclip/src/modules/analytics/liveDashboard/constants/index.js new file mode 100644 index 0000000..b0bdd81 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/constants/index.js @@ -0,0 +1,176 @@ +export const CALENDAR_FILTER_VALUE = { + any: { + stringFrom: 'now/d', + }, + yesterday: { + stringFrom: 'now-1d/d', + stringTo: 'now/d', + value: 'days', + }, + last7days: { + stringFrom: 'now-7d/d', + stringTo: 'now/d', + value: 'week', + }, + last30days: { + stringFrom: 'now-30d/d', + stringTo: 'now/d', + value: 'month', + }, + custom: 'Custom', +}; + +export const CALENDAR_FILTER = [ + { label: 'Any time', value: CALENDAR_FILTER_VALUE.any }, + { label: 'Past 24 hours', value: CALENDAR_FILTER_VALUE.yesterday }, + { label: 'Past week', value: CALENDAR_FILTER_VALUE.last7days }, + { label: 'Past month', value: CALENDAR_FILTER_VALUE.last30days }, + { label: 'Custom', value: CALENDAR_FILTER_VALUE.custom }, +]; + +export const DEVICES_CONFIG = { + desktop: { + title: 'Desktop', + items: [ + { + title: 'X Large', + key: 'xLarge', + }, + { + title: 'Large', + key: 'large', + }, + { + title: 'Medium & Smaller', + key: 'medium', + }, + ], + }, + mobile: { + title: 'Mobile', + items: [ + { + title: 'Medium', + key: 'medium', + }, + { + title: 'Small', + key: 'small', + }, + { + title: 'X Small', + key: 'xSmall', + }, + ], + }, +}; + +const queryUsersGQL = ` + query livePerformanceUsers( + $eventId: Int, + $scheduleSessionIds: [Int], + $inFocus: Boolean, + $interval: AnalyticsIntervalInputType, + $timezone: String + ) { + livePerformanceUsers( + eventId: $eventId, + scheduleSessionIds: $scheduleSessionIds, + inFocus: $inFocus, + interval: $interval, + timezone: $timezone + ) { + rid + data { + id + data { + time + users + usersInFocus + } + } + } + } +`; + +const queryMinutesTotalGQL = ` + query livePerformanceMinutesTotal( + $eventId: Int, + $scheduleSessionIds: [Int], + $inFocus: Boolean, + $interval: AnalyticsIntervalInputType, + $timezone: String + ) { + livePerformanceMinutesTotal( + eventId: $eventId, + scheduleSessionIds: $scheduleSessionIds, + inFocus: $inFocus, + interval: $interval, + timezone: $timezone + ) { + rid + data { + id + data { + time + minutesTotal + minutesTotalInFocus + } + } + } + } +`; + +const queryMinutesAvgGQL = ` + query livePerformanceMinutesAvg( + $eventId: Int, + $scheduleSessionIds: [Int], + $inFocus: Boolean, + $interval: AnalyticsIntervalInputType, + $timezone: String + ) { + livePerformanceMinutesAvg( + eventId: $eventId, + scheduleSessionIds: $scheduleSessionIds, + inFocus: $inFocus, + interval: $interval, + timezone: $timezone + ) { + rid + data { + id + data { + time + minutesAvg + minutesAvgInFocus + } + } + } + } +`; + +export const TABS = [ + { + title: 'Users', + id: 'USERS', + key: 'users', + tooltipTitle: 'Total quantity of unique viewing sessions within the selected duration', + query: queryUsersGQL, + }, + { + title: 'Total viewed minutes', + id: 'TOTAL', + key: 'totalMinutes', + tooltipTitle: 'Total number of minutes viewed in all sessions within the selected duration', + query: queryMinutesTotalGQL, + }, + { + title: 'Average viewed minutes', + id: 'AVERAGE', + key: 'averageMinutes', + tooltipTitle: 'The average quantity of minutes viewed in one session within the selected duration', + additionalKey: 'averageMinutesPercent', + query: queryMinutesAvgGQL, + chartTooltipFormatter: (value) => Math.round(value * 100) / 100, + }, +]; diff --git a/anyclip/src/modules/analytics/liveDashboard/helpers/exportCSV.js b/anyclip/src/modules/analytics/liveDashboard/helpers/exportCSV.js new file mode 100644 index 0000000..da57559 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/helpers/exportCSV.js @@ -0,0 +1,78 @@ +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +export const createTotal = ({ users, totalMinutes, averageMinutes, averageMinutesPercent }) => { + const head = ['Users', 'Total viewed minutes', 'Average viewed minutes', 'Average viewed minutes(percent)']; + + const body = [users, totalMinutes, averageMinutes, averageMinutesPercent]; + + const rows = [head, body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export const createDevices = ({ desktop, mobile }) => { + const head = ['Resolution', 'Desktop', 'Mobile']; + + const body = [ + ['All', desktop.all, mobile.all], + ['X Large', desktop.xLarge, '-'], + ['Large', desktop.large, '-'], + ['Medium & Smaller', desktop.medium, '-'], + ['Medium', '-', mobile.medium], + ['Small', '-', mobile.small], + ['X Small', '-', mobile.xSmall], + ]; + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export const createCountries = ({ countries: { all, items } }) => { + const head = ['Name', 'Amount', 'Percent']; + + const body = [['All Countries', all, '100%']]; + items.forEach(({ name, value, percent }) => { + body.push([`"${name.replace(/"/g, "'")}"`, value, `${percent}%`]); + }); + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export const createChart = ({ chartData, userTimezone }) => { + const head = ['Time']; + + const body = []; + chartData.forEach(({ name, data }, index) => { + head.push(name); + data.forEach(({ time, value }) => { + const timeValue = dayjs(time).tz(userTimezone); + const rowValues = Array.from({ length: chartData.length }, (_, i) => { + if (i === index) { + return value; + } + return ''; + }); + body.push([`"${time === '' ? 'Break' : timeValue.format('HH:mm MMM DD')}"`, ...rowValues]); + }); + }); + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; diff --git a/anyclip/src/modules/analytics/liveDashboard/helpers/index.js b/anyclip/src/modules/analytics/liveDashboard/helpers/index.js new file mode 100644 index 0000000..6b63fac --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/helpers/index.js @@ -0,0 +1,22 @@ +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +export const getTimeOption = (startTime, endTime, timezone) => { + const start = dayjs(startTime).tz(timezone); + + if (startTime && !endTime) { + return start.format('HH:mm MMM DD, YYYY'); + } + + const end = dayjs(endTime).tz(timezone); + + const startFormat = dayjs(start).isSame(end, 'day') ? 'HH:mm' : 'HH:mm | MMM DD, YYYY'; + + return `${start.format(startFormat)} — ${end.format('HH:mm | MMM DD, YYYY')}`; +}; + +export default getTimeOption; diff --git a/anyclip/src/modules/analytics/liveDashboard/index.jsx b/anyclip/src/modules/analytics/liveDashboard/index.jsx new file mode 100644 index 0000000..9379995 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/index.jsx @@ -0,0 +1,3 @@ +import AnalyticsLiveDashboard from './components'; + +export default AnalyticsLiveDashboard; diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToCSV.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToCSV.js new file mode 100644 index 0000000..c2b0a72 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToCSV.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { createChart, createCountries, createDevices, createTotal } from '../../helpers/exportCSV'; +import * as selectors from '../selectors'; +import { exportToCSVAction } from '../slices'; +import { saveBlobAsFile } from '@/modules/@common/helpers/file-saver'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(exportToCSVAction.type), + switchMap(() => { + const state = state$.value; + + const users = selectors.usersSelector(state); + const totalMinutes = selectors.totalMinutesSelector(state); + const averageMinutes = selectors.averageMinutesSelector(state); + const averageMinutesPercent = selectors.averageMinutesPercentSelector(state); + const desktop = selectors.desktopSelector(state); + const mobile = selectors.mobileSelector(state); + const countries = selectors.countriesSelector(state); + const chartData = selectors.chartDataSelector(state); + + const userTimezone = getUserTimezoneSelector(state$.value); + + const stream$ = defer(async () => { + const JSZip = (await import('jszip')).default; + + const zip = new JSZip(); + zip.file( + 'Total.csv', + createTotal({ + users, + totalMinutes, + averageMinutes, + averageMinutesPercent, + }), + ); + zip.file('Devices.csv', createDevices({ desktop, mobile })); + zip.file('Countries.csv', createCountries({ countries })); + zip.file('Chart.csv', createChart({ chartData, userTimezone })); + return zip.generateAsync({ type: 'blob' }); + }).pipe( + switchMap((content) => { + saveBlobAsFile(content, 'live-events-past.zip'); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Download was successful', + }), + ), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToPDF.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToPDF.js new file mode 100644 index 0000000..0922c19 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/exportToPDF.js @@ -0,0 +1,34 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { exportToPDFAction, setFieldAction } from '../slices'; +import { html2pdf } from '@/modules/analytics/common/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(exportToPDFAction.type), + switchMap((action) => { + const stream$ = defer(async () => html2pdf('live-dashboard', action.payload)).pipe( + switchMap(() => + concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Download was successful', + }), + ), + ), + ), + ); + + return concat( + of(setFieldAction({ isExportPdfLoading: true })), + stream$, + of(setFieldAction({ isExportPdfLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/getChartData.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getChartData.js new file mode 100644 index 0000000..b93faf3 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getChartData.js @@ -0,0 +1,103 @@ +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { + switchMap, + // filter, +} from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getChartDataAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +dayjs.extend(durationPlugin); + +const defineInterval = (sessions) => { + if (sessions.some((session) => dayjs.duration(session.endTime - session.startTime).as('MINUTES') <= 30)) { + return { + unit: 'MINUTES', + value: 1, + }; + } + + return { + unit: 'MINUTES', + value: 5, + }; +}; + +const prepareChartData = (response, sessions) => + response?.map((item) => { + const data = [...item.data]?.map((point, pointIndex) => { + const { users, usersInFocus, minutesTotal, minutesTotalInFocus, minutesAvg, minutesAvgInFocus } = point; + + if (pointIndex === 0 || pointIndex === item.data.length - 1) { + return { + time: +point.time, + value: users || minutesTotal || minutesAvg || 0, + valueInFocus: usersInFocus || minutesTotalInFocus || minutesAvgInFocus || 0, + showDot: true, + }; + } + + return { + time: +point.time, + value: users || minutesTotal || minutesAvg || 0, + valueInFocus: usersInFocus || minutesTotalInFocus || minutesAvgInFocus || 0, + }; + }); + + return { + ...item, + data, + name: sessions.find((session) => session.id === item.id)?.label ?? item.id, + }; + }) ?? []; + +export default (action$, state$) => + action$.pipe( + ofType(getChartDataAction.type), + switchMap(() => { + const state = state$.value; + + const activeTab = selectors.activeTabSelector(state); + const event = selectors.eventSelector(state); + const sessions = selectors.sessionsSelector(state); + + const interval = defineInterval(sessions); + + const stream$ = gqlRequest({ + query: activeTab.query, + variables: { + eventId: event?.value ?? null, + scheduleSessionIds: sessions?.map((session) => +session.value) ?? [], + interval, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const chartData = { + ...data.livePerformanceUsers, + ...data.livePerformanceMinutesTotal, + ...data.livePerformanceMinutesAvg, + }; + + actions.push( + of( + setFieldAction({ + chartData: prepareChartData(chartData.data, sessions), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/getCountriesFullList.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getCountriesFullList.js new file mode 100644 index 0000000..c40ef96 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getCountriesFullList.js @@ -0,0 +1,44 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCountriesFullListAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query commonGeography { + commonGeography { + id + uiKey + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCountriesFullListAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + setFieldAction({ + countriesFullList: data.commonGeography, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEventById.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEventById.js new file mode 100644 index 0000000..48b571f --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEventById.js @@ -0,0 +1,83 @@ +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getTimeOption } from '../../helpers'; +import { getChartDataAction, getLiveEventByIdAction, getLivePerformanceTotalsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +dayjs.extend(durationPlugin); + +const queryGQL = ` + query analyticsLiveEventById($id: Int!) { + analyticsLiveEventById(id: $id) { + id + title + startTime + endTime + durationIn + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getLiveEventByIdAction.type), + switchMap((action) => { + const timezone = getUserTimezoneSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: action.payload.id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { analyticsLiveEventById } = data; + + const liveEventSchedulesResult = + analyticsLiveEventById?.map((schedule) => { + const duration = dayjs.duration(schedule.endTime - schedule.startTime).as('MINUTES'); + + return { + ...schedule, + label: schedule.title, + value: schedule.id, + time: getTimeOption(schedule.startTime, schedule.endTime, timezone), + duration, + }; + }) ?? []; + + actions.push( + of( + setFieldAction({ + sessionsOptions: liveEventSchedulesResult, + sessions: liveEventSchedulesResult, + }), + ), + of( + getLivePerformanceTotalsAction({ + scheduleSessionIds: liveEventSchedulesResult?.map((item) => item.value) ?? [], + }), + ), + of( + getChartDataAction({ + scheduleSessionIds: liveEventSchedulesResult?.map((item) => item.value) ?? [], + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEvents.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEvents.js new file mode 100644 index 0000000..3d9dd3a --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLiveEvents.js @@ -0,0 +1,139 @@ +import dayjs from 'dayjs'; +import utcPlugin from 'dayjs/plugin/utc'; +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE } from '../../constants'; + +import { getTimeOption } from '../../helpers'; +import * as selectors from '../selectors'; +import { getLiveEventByIdAction, getLiveEventsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +dayjs.extend(utcPlugin); + +const queryGQL = ` + query analyticsLiveEvents( + $search: String, + $eventFilter: String, + $page: Int, + $pageSize: Int, + $sortBy: String, + $sortOrder: String, + $status: String, + $startTime: Float, + $endTime: Float + ) { + analyticsLiveEvents( + search: $search, + eventFilter: $eventFilter, + page: $page, + pageSize: $pageSize, + sortBy: $sortBy, + sortOrder: $sortOrder, + status: $status, + startTime: $startTime, + endTime: $endTime + ) { + total + page + pageSize + results { + id + name + timezone + startTime + endTime + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getLiveEventsAction.type), + debounce((action) => timer(action.payload?.search ? 1000 : 0)), + filter(() => !!getToken()), + switchMap((action) => { + const timezone = getUserTimezoneSelector(state$.value); + + const state = state$.value; + + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + + let requestParams = {}; + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + const startTime = dayjs(calendarCustom.dateStart).utc().valueOf(); + const endTime = dayjs(calendarCustom.dateEnd).utc().valueOf(); + + requestParams = { + startTime, + endTime, + }; + } else if (calendar.value) { + const startTime = dayjs().subtract(1, calendar.value).utc().valueOf(); + const endTime = dayjs().utc().valueOf(); + + requestParams = { + startTime, + endTime, + }; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...requestParams, + page: 1, + search: action.payload?.search, + pageSize: 500, + sortBy: 'endTime', + sortOrder: 'DESC', + eventFilter: 'PAST', + status: 'all', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { results } = data.analyticsLiveEvents; + + const eventsOptions = + results?.map((item) => ({ + label: item.name, + value: item.id, + time: getTimeOption(item.startTime, item.endTime, timezone), + })) ?? []; + + actions.push( + of( + setFieldAction({ + eventsOptions, + }), + ), + ); + + if (action.payload?.search === undefined && eventsOptions?.length) { + actions.push( + of( + setFieldAction({ + event: eventsOptions[0], + }), + ), + of(getLiveEventByIdAction({ id: eventsOptions[0].value })), + ); + } + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLivePerformanceTotals.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLivePerformanceTotals.js new file mode 100644 index 0000000..9bf4f6d --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/getLivePerformanceTotals.js @@ -0,0 +1,167 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { + switchMap, + // filter, +} from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getLivePerformanceTotalsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query livePerformanceTotals( + $eventId: Int, + $scheduleSessionIds: [Int], + $inFocus: Boolean + ) { + livePerformanceTotals( + eventId: $eventId, + scheduleSessionIds: $scheduleSessionIds, + inFocus: $inFocus + ) { + rid + users + minutesTotal + minutesAvg + minutesAvgPct + devices { + device + value + valuePct + } + resolutions { + MOBILE { + size + value + valuePct + } + DESKTOP { + size + value + valuePct + } + } + countries { + code + value + valuePct + } + countriesTotal + } + } +`; + +const findItems = (array, key, values) => + array?.reduce((acc, item) => { + const size = values.find((value) => item[key] === value); + if (size) { + return acc + item.valuePct; + } + return acc; + }, null); + +const roundValuePct = (value) => { + if (value) { + return Math.round(value); + } + return null; +}; + +export default (action$, state$) => + action$.pipe( + ofType(getLivePerformanceTotalsAction.type), + switchMap(() => { + const state = state$.value; + + const event = selectors.eventSelector(state); + const sessions = selectors.sessionsSelector(state); + const countriesFullList = selectors.countriesFullListSelector(state); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + eventId: event.value, + scheduleSessionIds: sessions?.map((session) => +session.value) ?? [], + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { + livePerformanceTotals: { + users, + minutesTotal, + minutesAvg, + minutesAvgPct, + devices = [], + resolutions, + countries = [], + countriesTotal, + }, + } = data; + + const desktop = findItems(devices, 'device', ['DESKTOP']); + + const desktopSizeMedium = findItems(resolutions?.DESKTOP, 'size', [ + 'SIZE_LT_240P', + 'SIZE_240P', + 'SIZE_360P', + 'SIZE_480P', + 'SIZE_720P', + ]); + const desktopSizeLarge = findItems(resolutions?.DESKTOP, 'size', ['SIZE_1080P']); + const desktopSizeXLarge = findItems(resolutions?.DESKTOP, 'size', ['SIZE_1440P']); + + const mobile = findItems(devices, 'device', ['MOBILE']); + + const mobileSizeMedium = findItems(resolutions?.MOBILE, 'size', [ + 'SIZE_480P', + 'SIZE_720P', + 'SIZE_1080P', + 'SIZE_1440P', + ]); + const mobileSizeSmall = findItems(resolutions?.MOBILE, 'size', ['SIZE_360P']); + const mobileSizeXSmall = findItems(resolutions?.MOBILE, 'size', ['SIZE_LT_240P', 'SIZE_240P']); + + actions.push( + of( + setFieldAction({ + users, + totalMinutes: minutesTotal, + averageMinutes: minutesAvg, + averageMinutesPercent: Math.round(minutesAvgPct * 100) / 100, + desktop: { + all: roundValuePct(desktop), + medium: roundValuePct(desktopSizeMedium), + large: roundValuePct(desktopSizeLarge), + xLarge: roundValuePct(desktopSizeXLarge), + }, + mobile: { + all: roundValuePct(mobile), + medium: roundValuePct(mobileSizeMedium), + small: roundValuePct(mobileSizeSmall), + xSmall: roundValuePct(mobileSizeXSmall), + }, + countries: { + all: countriesTotal, + items: + countries?.map((country) => ({ + name: countriesFullList.find((item) => item.uiKey === country.code)?.name ?? country.code, + value: country.value, + percent: Math.round(country.valuePct), + })) ?? [], + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/epics/index.js b/anyclip/src/modules/analytics/liveDashboard/redux/epics/index.js new file mode 100644 index 0000000..b0c042a --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import exportToCSV from './exportToCSV'; +import exportToPDF from './exportToPDF'; +import getChartData from './getChartData'; +import getCountriesFullList from './getCountriesFullList'; +import getLiveEventById from './getLiveEventById'; +import getLiveEvents from './getLiveEvents'; +import getLivePerformanceTotals from './getLivePerformanceTotals'; + +export default combineEpics( + getCountriesFullList, + getLiveEvents, + getLiveEventById, + getLivePerformanceTotals, + getChartData, + exportToPDF, + exportToCSV, +); diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/selectors/index.js b/anyclip/src/modules/analytics/liveDashboard/redux/selectors/index.js new file mode 100644 index 0000000..7eb8ba2 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/selectors/index.js @@ -0,0 +1,23 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const calendarSelector = (state$) => state$[nameSpace].calendar; +export const calendarCustomSelector = (state$) => state$[nameSpace].calendarCustom; +export const eventsSearchSelector = (state$) => state$[nameSpace].eventsSearch; +export const eventsOptionsSelector = (state$) => state$[nameSpace].eventsOptions; +export const eventSelector = (state$) => state$[nameSpace].event; +export const sessionsSearchSelector = (state$) => state$[nameSpace].sessionsSearch; +export const sessionsOptionsSelector = (state$) => state$[nameSpace].sessionsOptions; +export const sessionsSelector = (state$) => state$[nameSpace].sessions; +export const activeTabSelector = (state$) => state$[nameSpace].activeTab; +export const usersSelector = (state$) => state$[nameSpace].users; +export const totalMinutesSelector = (state$) => state$[nameSpace].totalMinutes; +export const averageMinutesSelector = (state$) => state$[nameSpace].averageMinutes; +export const averageMinutesPercentSelector = (state$) => state$[nameSpace].averageMinutesPercent; +export const desktopSelector = (state$) => state$[nameSpace].desktop; +export const mobileSelector = (state$) => state$[nameSpace].mobile; +export const countriesSelector = (state$) => state$[nameSpace].countries; +export const chartDataSelector = (state$) => state$[nameSpace].chartData; +export const countriesFullListSelector = (state$) => state$[nameSpace].countriesFullList; +export const isExportPdfLoadingSelector = (state$) => state$[nameSpace].isExportPdfLoading; diff --git a/anyclip/src/modules/analytics/liveDashboard/redux/slices/index.js b/anyclip/src/modules/analytics/liveDashboard/redux/slices/index.js new file mode 100644 index 0000000..e393f01 --- /dev/null +++ b/anyclip/src/modules/analytics/liveDashboard/redux/slices/index.js @@ -0,0 +1,75 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { CALENDAR_FILTER_VALUE, TABS } from '../../constants'; + +const initialState = { + calendar: CALENDAR_FILTER_VALUE.any, + calendarCustom: { + dateStart: null, + dateEnd: null, + }, + eventsSearch: '', + eventsOptions: [], + event: null, + sessionsSearch: '', + sessionsOptions: [], + sessions: [], + activeTab: TABS[0], + users: null, + totalMinutes: null, + averageMinutes: null, + averageMinutesPercent: null, + desktop: { + all: null, + medium: null, + large: null, + xLarge: null, + }, + mobile: { + all: null, + medium: null, + small: null, + xSmall: null, + }, + countries: { + all: null, + items: [], + }, + chartData: [], + countriesFullList: [], + isExportPdfLoading: false, +}; + +export const slice = createSlice({ + name: '@@analyticsLiveDashboard/ANALYTICS_LIVE_DASHBOARD', + initialState, + + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + + getCountriesFullListAction: (state) => state, + getLiveEventsAction: (state) => state, + getLiveEventByIdAction: (state) => state, + getLivePerformanceTotalsAction: (state) => state, + getChartDataAction: (state) => state, + exportToPDFAction: (state) => state, + exportToCSVAction: (state) => state, + }, +}); + +export const { + setFieldAction, + getCountriesFullListAction, + getLiveEventsAction, + getLiveEventByIdAction, + getLivePerformanceTotalsAction, + getChartDataAction, + exportToPDFAction, + exportToCSVAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/analytics/monetization/components/Card/Card.module.scss b/anyclip/src/modules/analytics/monetization/components/Card/Card.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Card/Card.module.scss rename to anyclip/src/modules/analytics/monetization/components/Card/Card.module.scss diff --git a/src/modules/analytics/monetization/components/Card/index.jsx b/anyclip/src/modules/analytics/monetization/components/Card/index.jsx similarity index 100% rename from src/modules/analytics/monetization/components/Card/index.jsx rename to anyclip/src/modules/analytics/monetization/components/Card/index.jsx diff --git a/src/modules/analytics/monetization/components/Chart/Chart.module.scss b/anyclip/src/modules/analytics/monetization/components/Chart/Chart.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Chart/Chart.module.scss rename to anyclip/src/modules/analytics/monetization/components/Chart/Chart.module.scss diff --git a/src/modules/analytics/monetization/components/Chart/CustomTick.jsx b/anyclip/src/modules/analytics/monetization/components/Chart/CustomTick.jsx similarity index 100% rename from src/modules/analytics/monetization/components/Chart/CustomTick.jsx rename to anyclip/src/modules/analytics/monetization/components/Chart/CustomTick.jsx diff --git a/src/modules/analytics/monetization/components/Chart/CustomTick.module.scss b/anyclip/src/modules/analytics/monetization/components/Chart/CustomTick.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Chart/CustomTick.module.scss rename to anyclip/src/modules/analytics/monetization/components/Chart/CustomTick.module.scss diff --git a/src/modules/analytics/monetization/components/Chart/CustomTooltip.jsx b/anyclip/src/modules/analytics/monetization/components/Chart/CustomTooltip.jsx similarity index 100% rename from src/modules/analytics/monetization/components/Chart/CustomTooltip.jsx rename to anyclip/src/modules/analytics/monetization/components/Chart/CustomTooltip.jsx diff --git a/src/modules/analytics/monetization/components/Chart/CustomTooltip.module.scss b/anyclip/src/modules/analytics/monetization/components/Chart/CustomTooltip.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Chart/CustomTooltip.module.scss rename to anyclip/src/modules/analytics/monetization/components/Chart/CustomTooltip.module.scss diff --git a/src/modules/analytics/monetization/components/Chart/index.jsx b/anyclip/src/modules/analytics/monetization/components/Chart/index.jsx similarity index 100% rename from src/modules/analytics/monetization/components/Chart/index.jsx rename to anyclip/src/modules/analytics/monetization/components/Chart/index.jsx diff --git a/src/modules/analytics/monetization/components/Countries/Countries.module.scss b/anyclip/src/modules/analytics/monetization/components/Countries/Countries.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Countries/Countries.module.scss rename to anyclip/src/modules/analytics/monetization/components/Countries/Countries.module.scss diff --git a/src/modules/analytics/monetization/components/Countries/index.jsx b/anyclip/src/modules/analytics/monetization/components/Countries/index.jsx similarity index 100% rename from src/modules/analytics/monetization/components/Countries/index.jsx rename to anyclip/src/modules/analytics/monetization/components/Countries/index.jsx diff --git a/src/modules/analytics/monetization/components/Device/Device.module.scss b/anyclip/src/modules/analytics/monetization/components/Device/Device.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Device/Device.module.scss rename to anyclip/src/modules/analytics/monetization/components/Device/Device.module.scss diff --git a/src/modules/analytics/monetization/components/Device/index.jsx b/anyclip/src/modules/analytics/monetization/components/Device/index.jsx similarity index 100% rename from src/modules/analytics/monetization/components/Device/index.jsx rename to anyclip/src/modules/analytics/monetization/components/Device/index.jsx diff --git a/src/modules/analytics/monetization/components/Filters/Filters.module.scss b/anyclip/src/modules/analytics/monetization/components/Filters/Filters.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Filters/Filters.module.scss rename to anyclip/src/modules/analytics/monetization/components/Filters/Filters.module.scss diff --git a/src/modules/analytics/monetization/components/Filters/components/SpecialPopper.tsx b/anyclip/src/modules/analytics/monetization/components/Filters/components/SpecialPopper.tsx similarity index 100% rename from src/modules/analytics/monetization/components/Filters/components/SpecialPopper.tsx rename to anyclip/src/modules/analytics/monetization/components/Filters/components/SpecialPopper.tsx diff --git a/src/modules/analytics/monetization/components/Filters/index.jsx b/anyclip/src/modules/analytics/monetization/components/Filters/index.jsx similarity index 100% rename from src/modules/analytics/monetization/components/Filters/index.jsx rename to anyclip/src/modules/analytics/monetization/components/Filters/index.jsx diff --git a/src/modules/analytics/monetization/components/Monetization.module.scss b/anyclip/src/modules/analytics/monetization/components/Monetization.module.scss similarity index 100% rename from src/modules/analytics/monetization/components/Monetization.module.scss rename to anyclip/src/modules/analytics/monetization/components/Monetization.module.scss diff --git a/src/modules/analytics/monetization/components/index.jsx b/anyclip/src/modules/analytics/monetization/components/index.jsx similarity index 100% rename from src/modules/analytics/monetization/components/index.jsx rename to anyclip/src/modules/analytics/monetization/components/index.jsx diff --git a/anyclip/src/modules/analytics/monetization/constants/index.js b/anyclip/src/modules/analytics/monetization/constants/index.js new file mode 100644 index 0000000..55f48f6 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/constants/index.js @@ -0,0 +1,925 @@ +import dayjs from 'dayjs'; +import weekOfYearPlugin from 'dayjs/plugin/weekOfYear'; + +dayjs.extend(weekOfYearPlugin); + +export const CALENDAR_FILTER_VALUE = { + thisMonth: { + label: 'This month', + dateRange: { + stringFrom: 'now/M', + }, + dateRangeComparison: { + stringFrom: 'now-1M/M', + stringTo: 'now-1M', + }, + ranges: [ + { + stringFrom: 'now/M', + }, + { + stringFrom: 'now-1M/M', + stringTo: 'now/M', + }, + ], + interval: { + unit: 'DAYS', + value: 1, + }, + xTickFormatter: (value) => value.format('DD'), + chipFormatter: (value) => value.format('MMMM YYYY'), + chartTooltipFormatter: (value) => value.format('ddd MMM DD'), + }, + previousMonth: { + label: 'Previous month', + dateRange: { + stringFrom: 'now-1M/M', + stringTo: 'now/M', + }, + dateRangeComparison: { + stringFrom: 'now-2M/M', + stringTo: 'now-1M/M', + }, + ranges: [ + { + stringFrom: 'now-1M/M', + stringTo: 'now/M', + }, + { + stringFrom: 'now-2M/M', + stringTo: 'now-1M/M', + }, + ], + interval: { + unit: 'DAYS', + value: 1, + }, + xTickFormatter: (value) => value.format('DD'), + chipFormatter: (value) => value.format('MMMM YYYY'), + chartTooltipFormatter: (value) => value.format('ddd MMM DD'), + }, + thisWeek: { + label: 'This week', + dateRange: { + stringFrom: 'now/w', + }, + dateRangeComparison: { + stringFrom: 'now-1w/w', + stringTo: 'now-1w', + }, + ranges: [ + { + stringFrom: 'now/w', + }, + { + stringFrom: 'now-1w/w', + stringTo: 'now/w', + }, + ], + interval: { + unit: 'DAYS', + value: 1, + }, + xTickFormatter: (value) => value.format('ddd'), + chipFormatter: (value) => `Week ${dayjs(value).week()}`, + chartTooltipFormatter: (value) => `${value.format('ddd MMM DD')}, Week ${dayjs(value).week()}`, + }, + today: { + label: 'Today', + dateRange: { + stringFrom: 'now/d', + }, + ranges: [ + { + stringFrom: 'now/d', + }, + { + stringFrom: 'now-1d/d', + stringTo: 'now/d', + }, + { + stringFrom: 'now-7d/d', + stringTo: 'now-6d/d', + }, + ], + interval: { + unit: 'HOURS', + value: 1, + }, + xTickFormatter: (value) => value.format('HH'), + chipFormatter: (value) => value.format('MMM DD'), + chartTooltipFormatter: (value) => value.format('ddd MMM DD'), + chartTooltipTitleFormatter: (value) => `${value.format('HH')}:00`, + }, + yesterday: { + label: 'Yesterday', + dateRange: { + stringFrom: 'now-1d/d', + stringTo: 'now/d', + }, + ranges: [ + { + stringFrom: 'now-1d/d', + stringTo: 'now/d', + }, + { + stringFrom: 'now-2d/d', + stringTo: 'now-1d/d', + }, + { + stringFrom: 'now-8d/d', + stringTo: 'now-7d/d', + }, + ], + interval: { + unit: 'HOURS', + value: 1, + }, + xTickFormatter: (value) => value.format('HH'), + chipFormatter: (value) => value.format('MMM DD'), + chartTooltipFormatter: (value) => value.format('ddd MMM DD'), + chartTooltipTitleFormatter: (value) => `${value.format('HH')}:00`, + }, + last7days: { + label: 'Last 7 days', + dateRange: { + stringFrom: 'now-6d/d', + }, + dateRangeComparison: { + stringFrom: 'now-13d/d', + stringTo: 'now-6d/d', + }, + ranges: [ + { + stringFrom: 'now-13d/d', + stringTo: 'now-6d/d', + }, + { + stringFrom: 'now-6d/d', + }, + ], + interval: { + unit: 'DAYS', + value: 1, + }, + xTickFormatter: (value) => value.format('ddd'), + chipFormatter: (valueFirst, valueLast) => + valueLast ? `${valueFirst.format('MMM DD')} - ${valueLast.format('MMM DD')}` : valueFirst.format('MMM DD'), + chartTooltipFormatter: (value) => value.format('ddd MMM DD'), + }, + last30days: { + label: 'Last 30 days', + dateRange: { + stringFrom: 'now-29d/d', + }, + dateRangeComparison: { + stringFrom: 'now-59d/d', + stringTo: 'now-29d/d', + }, + ranges: [ + { + stringFrom: 'now-29d/d', + }, + ], + interval: { + unit: 'DAYS', + value: 1, + }, + xTickFormatter: (value) => value.format('DD'), + chipFormatter: (valueFirst, valueLast) => + valueLast ? `${valueFirst.format('MMM DD')} - ${valueLast.format('MMM DD')}` : valueFirst.format('MMM DD'), + chartTooltipFormatter: (value) => value.format('ddd MMM DD'), + }, + custom: 'Custom', +}; + +export const CALENDAR_FILTER_CUSTOM = -1; +export const CALENDAR_FILTER_THIS_MONTH = 1; +export const CALENDAR_FILTER_PREVIOUS_MONTH = 2; +export const CALENDAR_FILTER_LAST_30_DAYS = 3; +export const CALENDAR_FILTER_THIS_WEEK = 4; +export const CALENDAR_FILTER_LAST_7_DAYS = 5; +export const CALENDAR_FILTER_TODAY = 6; +export const CALENDAR_FILTER_YESTERDAY = 7; + +export const CALENDAR_FILTER_ADAPTER = { + [CALENDAR_FILTER_THIS_MONTH]: { + range: { + stringFrom: 'now/M', + stringTo: undefined, + }, + interval: { + unit: 'DAYS', + value: 1, + }, + }, + [CALENDAR_FILTER_PREVIOUS_MONTH]: { + range: { + stringFrom: 'now-1M/M', + stringTo: 'now/M', + }, + interval: { + unit: 'DAYS', + value: 1, + }, + }, + [CALENDAR_FILTER_THIS_WEEK]: { + range: { + stringFrom: 'now/w', + stringTo: undefined, + }, + interval: { + unit: 'DAYS', + value: 1, + }, + }, + [CALENDAR_FILTER_TODAY]: { + range: { + stringFrom: 'now/d', + stringTo: undefined, + }, + interval: { + unit: 'HOURS', + value: 1, + }, + }, + [CALENDAR_FILTER_YESTERDAY]: { + range: { + stringFrom: 'now-1d/d', + stringTo: 'now/d', + }, + interval: { + unit: 'HOURS', + value: 1, + }, + }, + [CALENDAR_FILTER_LAST_7_DAYS]: { + range: { + stringFrom: 'now-6d/d', + stringTo: undefined, + }, + interval: { + unit: 'DAYS', + value: 1, + }, + }, + [CALENDAR_FILTER_LAST_30_DAYS]: { + range: { + stringFrom: 'now-29d/d', + stringTo: undefined, + }, + interval: { + unit: 'DAYS', + value: 1, + }, + }, + [CALENDAR_FILTER_CUSTOM]: { + range: { + stringFrom: '', + stringTo: undefined, + }, + interval: { + unit: '', + value: 0, + }, + }, +}; + +export const CALENDAR_FILTER_LIST = [ + CALENDAR_FILTER_THIS_MONTH, + CALENDAR_FILTER_PREVIOUS_MONTH, + CALENDAR_FILTER_LAST_30_DAYS, + CALENDAR_FILTER_THIS_WEEK, + CALENDAR_FILTER_LAST_7_DAYS, + CALENDAR_FILTER_TODAY, + CALENDAR_FILTER_YESTERDAY, + CALENDAR_FILTER_CUSTOM, +]; + +// todo: add filter props mapping +export const CALENDAR_FILTER_COLLECTION = [ + { + label: 'This month', + value: CALENDAR_FILTER_THIS_MONTH, + }, + { + label: 'Previous month', + value: CALENDAR_FILTER_PREVIOUS_MONTH, + }, + { + label: 'Last 30 days', + value: CALENDAR_FILTER_LAST_30_DAYS, + }, + { + label: 'This week', + value: CALENDAR_FILTER_THIS_WEEK, + }, + { + label: 'Last 7 days', + value: CALENDAR_FILTER_LAST_7_DAYS, + }, + { + label: 'Today', + value: CALENDAR_FILTER_TODAY, + }, + { + label: 'Yesterday', + value: CALENDAR_FILTER_YESTERDAY, + }, + { + label: 'Custom', + value: CALENDAR_FILTER_CUSTOM, + }, +]; + +export const CALENDAR_FILTER = [ + { label: CALENDAR_FILTER_VALUE.thisMonth.label, value: CALENDAR_FILTER_VALUE.thisMonth }, + { label: CALENDAR_FILTER_VALUE.previousMonth.label, value: CALENDAR_FILTER_VALUE.previousMonth }, + { label: CALENDAR_FILTER_VALUE.last30days.label, value: CALENDAR_FILTER_VALUE.last30days }, + { label: CALENDAR_FILTER_VALUE.thisWeek.label, value: CALENDAR_FILTER_VALUE.thisWeek }, + { label: CALENDAR_FILTER_VALUE.last7days.label, value: CALENDAR_FILTER_VALUE.last7days }, + { label: CALENDAR_FILTER_VALUE.today.label, value: CALENDAR_FILTER_VALUE.today }, + { label: CALENDAR_FILTER_VALUE.yesterday.label, value: CALENDAR_FILTER_VALUE.yesterday }, + { label: 'Custom', value: CALENDAR_FILTER_VALUE.custom }, +]; + +export const DEVICE_DESKTOP = 'DESKTOP'; +export const DEVICE_MOBILE = 'MOBILE'; +export const DEVICE_OTHER = 'OTHER'; + +export const DEVICE_LIST = [DEVICE_DESKTOP, DEVICE_MOBILE, DEVICE_OTHER]; +export const DEVICE_COLLECTION = [ + { label: 'Desktop', value: DEVICE_DESKTOP }, + { label: 'Mobile', value: DEVICE_MOBILE }, + { label: 'Other', value: DEVICE_OTHER }, +]; + +export const DEVICES = [ + { + label: 'Desktop', + value: 'DESKTOP', + }, + { + label: 'Mobile', + value: 'MOBILE', + }, + { + label: 'Other', + value: 'OTHER', + }, +]; + +export const AD_FORMAT_CONFIG_VIDEO = 'VIDEO'; +export const AD_FORMAT_CONFIG_DISPLAY = 'DISPLAY'; +export const AD_FORMAT_CONFIG_SPONSORED = 'SPONSORED'; +export const AD_FORMAT_CONFIG_ALL = ''; + +export const AD_FORMAT_LIST = [ + AD_FORMAT_CONFIG_VIDEO, + AD_FORMAT_CONFIG_DISPLAY, + AD_FORMAT_CONFIG_SPONSORED, + AD_FORMAT_CONFIG_ALL, +]; + +export const AD_FORMAT_COLLECTION = [ + { label: 'Video', value: AD_FORMAT_CONFIG_VIDEO }, + { label: 'Display', value: AD_FORMAT_CONFIG_DISPLAY }, + { label: 'Sponsored', value: AD_FORMAT_CONFIG_SPONSORED }, + { label: 'All Formats', value: AD_FORMAT_CONFIG_ALL }, +]; + +export const AD_FORMAT_CONFIG = { + video: { + label: 'Video', + value: 'VIDEO', + }, + display: { + label: 'Display', + value: 'DISPLAY', + }, + sponsored: { + label: 'Sponsored', + value: 'SPONSORED', + }, + all: { + label: 'All Formats', + value: null, + }, +}; + +export const AD_FORMATS = [ + AD_FORMAT_CONFIG.video, + AD_FORMAT_CONFIG.display, + AD_FORMAT_CONFIG.sponsored, + AD_FORMAT_CONFIG.all, +]; + +export const CARDS_FILTERS_ALL = [ + { + id: 'Revenue', + key: 'externalRevenues', + prefix: '$', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Revenue', + tooltip: 'Revenue generated by video ads within selected period, demand source, domain, devices, and country', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Revenue', + tooltip: + 'Revenue generated by display ads within selected period, demand source, domain, devices, and country', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: 'Revenue', + tooltip: + 'Revenue generated by sponsored ads within selected period, demand source, domain, devices, and country', + }; + } + + return { + title: 'Revenue', + tooltip: 'Revenue generated by all ads within selected period, demand source, domain, devices, and country', + }; + }, + }, + { + id: 'PlatformFees', + key: 'platformFee', + prefix: '$', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Platform Fees', + tooltip: 'Total platform fees charged to you for delivering video ads from your demand sources', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Platform Fees', + tooltip: 'Total platform fees charged to you for delivering display ads from your demand sources', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: 'Platform Fees', + tooltip: 'Total platform fees charged to you for delivering sponsored ads from your demand sources', + }; + } + + return { + title: 'Platform Fees', + tooltip: 'Total platform fees charged to you for delivering ads from your demand sources across all formats', + }; + }, + }, + { + id: 'netRevenue', + key: 'netRevenue', + prefix: '$', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'NET Revenue', + tooltip: 'Video ad revenue after platform fees and revenue share deductions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'NET Revenue', + tooltip: 'Display ad revenue after platform fees and revenue share deductions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: 'NET Revenue', + tooltip: 'Sponsored ad revenue after platform fees and revenue share deductions', + }; + } + return { + title: 'NET Revenue', + tooltip: 'Total revenue across all formats after platform fees and revenue share deductions', + }; + }, + }, + { + id: 'RPM', + key: 'rpm', + prefix: '$', + getCardConfig: ({ adFormat, demandSources }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Ad RPM', + tooltip: 'Revenue generated by Video ads for every 1000 Video ad impressions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Ad RPM', + tooltip: 'Revenue generated by Display ads for every 1000 Display ad impressions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: 'Ad RPM', + tooltip: 'Revenue generated by sponsored ads for every 1000 sponsored ad impressions', + key: demandSources.length ? 'cpm' : 'rpm', + }; + } + + return { + title: 'Page RPM', + tooltip: 'Revenue generated by display and video ads for every 1000 player loads', + }; + }, + }, + { + id: 'Viewability', + key: 'viewability', + postfix: '%', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Ad Viewability', + tooltip: 'Ratio of Viewable Video ad impressions to all Video ad impressions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Ad Viewability', + tooltip: 'Ratio of viewable Display ad impressions to all Display ad Impressions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: 'Ad Viewability', + tooltip: 'Ratio of Viewable sponsored ad impressions to all sponsored ad impressions', + }; + } + + return { + title: 'Player Viewability', + tooltip: 'Ratio of viewable player loads to all player loads', + }; + }, + }, + { + id: 'AdImpressions', + key: 'lreImpressions', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + key: 'adImpressions', + title: 'Ad Impressions', + tooltip: 'Number of Video ads served', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + key: 'adImpressions', + title: 'Ad Impressions', + tooltip: 'Number of Display ads served', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + key: 'adImpressions', + title: 'Ad Impressions', + tooltip: 'Number of sponsored ads served', + }; + } + + return { + title: 'Player Ad Impressions', + tooltip: 'Number of player loads that served at least 1 ad impression', + }; + }, + }, + { + id: 'FillRate', + key: 'fillRate', + postfix: '%', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Fill Rate', + tooltip: 'Ratio of Video ad impressions to video ad requests', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Fill Rate', + tooltip: 'Ratio of Display ad impressions to Display ad requests', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: '', + tooltip: '', + hide: true, + }; + } + + return { + title: 'Fill Rate', + tooltip: 'Ratio of player ad impressions to player loads', + }; + }, + }, + { + id: 'PlayerLoads', + key: 'lreOpportunities', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Player Loads', + tooltip: 'Number of times video player has been loaded in an ad blocker free page', + hide: true, + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Player Loads', + tooltip: 'Not available for Display ad format', + hide: true, + }; + } + + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: '', + tooltip: '', + hide: true, + }; + } + + return { + title: 'Player Loads', + tooltip: 'Number of times video player has been loaded in an ad blocker free page', + }; + }, + disableByAdFormat: (adFormat) => [AD_FORMAT_CONFIG.display.value].includes(adFormat), + }, + { + id: 'AdClicks', + key: 'adClicks', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: 'Ad Clicks', + tooltip: 'Number of sponsored ads clicks', + }; + } + + return { + title: '', + tooltip: '', + hide: true, + }; + }, + }, + { + id: 'Ctr', + key: 'ctr', + postfix: '%', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.sponsored.value) { + return { + title: 'CTR', + tooltip: 'Ratio of sponsored ad clicks to all sponsored ad impressions', + }; + } + + return { + title: '', + tooltip: '', + hide: true, + }; + }, + }, +]; + +export const CARDS_FILTERS_FOR_SOURCES = [ + { + id: 'Revenue', + key: 'externalRevenues', + prefix: '$', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Revenue', + tooltip: 'Revenue generated by video ads within selected period, demand source, domain, devices, and country', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Revenue', + tooltip: + 'Revenue generated by display ads within selected period, demand source, domain, devices, and country', + }; + } + + return { + title: 'Revenue', + tooltip: 'Revenue generated by all ads within selected period, demand source, domain, devices, and country', + }; + }, + }, + { + id: 'PlatformFees', + key: 'platformFee', + prefix: '$', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Platform Fees', + tooltip: 'Total platform fees charged to you for delivering video ads from your demand sources', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Platform Fees', + tooltip: 'Total platform fees charged to you for delivering display ads from your demand sources', + }; + } + + return { + title: 'Platform Fees', + tooltip: 'Total platform fees charged to you for delivering ads from your demand sources across all formats', + }; + }, + }, + { + id: 'netRevenue', + key: 'netRevenue', + prefix: '$', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'NET Revenue', + tooltip: 'Video ad revenue after platform fees and revenue share deductions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'NET Revenue', + tooltip: 'Display ad revenue after platform fees and revenue share deductions', + }; + } + + return { + title: 'NET Revenue', + tooltip: 'Total revenue across all formats after platform fees and revenue share deductions', + }; + }, + }, + { + id: 'CPM', + key: 'cpm', + prefix: '$', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Ad RPM', + tooltip: 'Revenue generated for every 1000 video ad impressions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Ad RPM', + tooltip: 'Revenue generated for every 1000 display ad impressions', + }; + } + + return { + title: 'Ad RPM', + tooltip: 'Only available for Video or Display ad formats separately.', + }; + }, + disableByAdFormat: (adFormat) => [AD_FORMAT_CONFIG.all.value].includes(adFormat), + }, + { + id: 'Viewability', + key: 'viewability', + postfix: '%', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Ad Viewability', + tooltip: 'Ratio of viewable video ad impressions to all video ad impressions', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Ad Viewability', + tooltip: 'Ratio of viewable display ad impressions to all display ad impressions', + }; + } + + return { + title: 'Ad Viewability', + tooltip: 'Only available for Video or Display ad formats separately.', + }; + }, + disableByAdFormat: (adFormat) => [AD_FORMAT_CONFIG.all.value].includes(adFormat), + }, + { + id: 'AdImpressions', + key: 'adImpressions', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Ad Impressions', + tooltip: 'Number of video ads played', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Ad Impressions', + tooltip: 'Number of display ads shown', + }; + } + + return { + title: 'Ad Impressions', + tooltip: 'Number of all ads shown', + }; + }, + }, + { + id: 'FillRate', + key: 'fillRate', + postfix: '%', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Fill Rate', + tooltip: 'Ratio of video ad impressions to video ad requests', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Fill Rate', + tooltip: 'Ratio of display ad impressions to display ad requests', + }; + } + + return { + title: 'Fill Rate', + tooltip: 'Only available for Video or Display ad formats separately.', + }; + }, + disableByAdFormat: (adFormat) => [AD_FORMAT_CONFIG.all.value].includes(adFormat), + }, + { + id: 'AdRequests', + key: 'adRequests', + getCardConfig: ({ adFormat }) => { + if (adFormat === AD_FORMAT_CONFIG.video.value) { + return { + title: 'Ad Requests', + tooltip: 'How many times have the players called for a video ad to be served', + }; + } + + if (adFormat === AD_FORMAT_CONFIG.display.value) { + return { + title: 'Ad Requests', + tooltip: 'How many times have the players called for a display ad to be served', + }; + } + + return { + title: 'Ad Requests', + tooltip: 'Only available for Video or Display ad formats separately.', + }; + }, + disableByAdFormat: (adFormat) => [AD_FORMAT_CONFIG.all.value].includes(adFormat), + }, +]; diff --git a/anyclip/src/modules/analytics/monetization/helpers/exportCSV.js b/anyclip/src/modules/analytics/monetization/helpers/exportCSV.js new file mode 100644 index 0000000..3a0f20d --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/helpers/exportCSV.js @@ -0,0 +1,88 @@ +export const createTotal = ({ totals, cards }) => { + const head = ['Name', 'Value', 'Percent']; + + const body = []; + cards.forEach(({ title, key, postfix }) => { + body.push([ + title, + totals?.total?.[key] + ? `"${Math.round((postfix ? totals.total[key] * 100 : totals.total[key]) * 100) / 100}"` + : '', + totals?.comparisonPct?.[`${key}Pct`] ? Math.round(totals.comparisonPct[`${key}Pct`] * 100) / 100 : '', + ]); + }); + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export const createDevices = ({ devices }) => { + const head = ['Device', 'Value', 'Percent']; + + const body = []; + devices.forEach(({ device, value, valuePct }) => { + body.push([`"${device.replace(/"/g, "'")}"`, Math.round(value * 100) / 100, Math.round(valuePct * 100) / 100]); + }); + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export const createCountries = ({ countries }) => { + const head = ['Name', 'Percent']; + + const body = []; + countries.forEach(({ name, valuePct }) => { + body.push([`"${name.replace(/"/g, "'")}"`, Math.round(valuePct * 100) / 100]); + }); + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export const createChart = ({ chartData, activeCardFilter }) => { + const head = ['Period']; + + const longestBuckets = chartData.reduce((acc, cur) => { + if (cur?.buckets?.length > acc.length) { + return cur.buckets; + } + + return acc; + }, []); + + const body = []; + + if (longestBuckets?.length) { + longestBuckets.forEach((item) => { + head.push(item.xTick); + }); + + chartData.forEach(({ chip, buckets }) => { + const rowValues = Array.from({ length: longestBuckets.length }, (_, i) => { + if (buckets[i]) { + const value = buckets[i][activeCardFilter.key]; + + return `"${Math.round((activeCardFilter?.postfix ? value * 100 : value) * 100) / 100}"`; + } + return ''; + }); + + body.push([`"${chip}"`, ...rowValues]); + }); + } + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; diff --git a/src/modules/analytics/monetization/index.jsx b/anyclip/src/modules/analytics/monetization/index.jsx similarity index 100% rename from src/modules/analytics/monetization/index.jsx rename to anyclip/src/modules/analytics/monetization/index.jsx diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/exportToCSV.js b/anyclip/src/modules/analytics/monetization/redux/epics/exportToCSV.js new file mode 100644 index 0000000..756a697 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/exportToCSV.js @@ -0,0 +1,70 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CARDS_FILTERS_ALL, CARDS_FILTERS_FOR_SOURCES } from '../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { createChart, createCountries, createDevices, createTotal } from '../../helpers/exportCSV'; +import * as selectors from '../selectors'; +import { exportToCSVAction } from '../slices'; +import { saveBlobAsFile } from '@/modules/@common/helpers/file-saver'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(exportToCSVAction.type), + switchMap(() => { + const state = state$.value; + + const totals = selectors.totalsSelector(state); + const chartData = selectors.chartDataSelector(state); + const activeCardFilter = selectors.activeCardFilterSelector(state); + const demandSources = selectors.demandSourcesSelector(state); + + const userTimezone = getUserTimezoneSelector(state$.value); + + const stream$ = defer(async () => { + const JSZip = (await import('jszip')).default; + + const zip = new JSZip(); + zip.file( + 'Total.csv', + createTotal({ + totals, + cards: demandSources?.length ? CARDS_FILTERS_FOR_SOURCES : CARDS_FILTERS_ALL, + }), + ); + + if (totals?.devices?.length) { + zip.file('Devices.csv', createDevices({ devices: totals.devices })); + } + + if (totals?.countries?.length) { + zip.file('Countries.csv', createCountries({ countries: totals.countries })); + } + + if (totals?.total?.[activeCardFilter.key] && chartData?.length) { + zip.file('Chart.csv', createChart({ chartData, activeCardFilter, userTimezone })); + } + + return zip.generateAsync({ type: 'blob' }); + }).pipe( + switchMap((content) => { + saveBlobAsFile(content, 'Monetization.zip'); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Download was successful', + }), + ), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/exportToPDF.js b/anyclip/src/modules/analytics/monetization/redux/epics/exportToPDF.js new file mode 100644 index 0000000..07f2f13 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/exportToPDF.js @@ -0,0 +1,34 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { exportToPDFAction, setFieldAction } from '../slices'; +import { html2pdf } from '@/modules/analytics/common/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(exportToPDFAction.type), + switchMap((action) => { + const stream$ = defer(async () => html2pdf('monetization', action.payload)).pipe( + switchMap(() => + concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Download was successful', + }), + ), + ), + ), + ); + + return concat( + of(setFieldAction({ isExportPdfLoading: true })), + stream$, + of(setFieldAction({ isExportPdfLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/getChartData.js b/anyclip/src/modules/analytics/monetization/redux/epics/getChartData.js new file mode 100644 index 0000000..b189b84 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/getChartData.js @@ -0,0 +1,200 @@ +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE } from '../../constants'; + +import * as selectors from '../selectors'; +import { getChartDataAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +const query = ` + query adPerformanceHistogram( + $countries: [String], + $devices: [String], + $domains: [String], + $demandSources: [String], + $widgetIds: [String], + $adFormat: String, + $ranges: [AnalyticsDateRangeInputType], + $interval: AnalyticsIntervalInputType, + $timezone: String + ) { + adPerformanceHistogram( + countries: $countries, + devices: $devices, + domains: $domains, + demandSources: $demandSources, + widgetIds: $widgetIds, + adFormat: $adFormat, + ranges: $ranges, + interval: $interval, + timezone: $timezone + ) { + rid + data { + name + buckets { + time + externalRevenues + platformFee + adImpressions + viewableImpressions + viewability + fillRate + adRequests + cpm + rpm + playerLoads + lreOpportunities + lreImpressions + netRevenue + ctr + adClicks + } + } + } + } +`; + +const prepareChartData = (data, timezone, calendar, calendarCustom) => + data.map((item) => { + const calendarConfig = calendar === CALENDAR_FILTER_VALUE.custom ? calendarCustom : calendar; + if (item?.buckets?.length) { + const chipTimeFirtsItem = dayjs(item?.buckets[0]?.time).tz(timezone); + const chipTimeLastItem = dayjs(item?.buckets?.at(-1)?.time).tz(timezone); + const buckets = item?.buckets?.map((bucket) => { + const time = dayjs(bucket.time).tz(timezone); + + return { + ...bucket, + xTick: calendarConfig.xTickFormatter(time), + }; + }); + + return { + ...item, + chip: chipTimeFirtsItem ? calendarConfig.chipFormatter(chipTimeFirtsItem, chipTimeLastItem) : item.name, + buckets, + }; + } + + return item; + }); + +export default (action$, state$) => + action$.pipe( + ofType(getChartDataAction.type), + switchMap(() => { + const state = state$.value; + + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const demandSources = selectors.demandSourcesSelector(state); + const players = selectors.playersSelector(state); + const domains = selectors.domainsSelector(state); + const countries = selectors.countriesSelector(state); + const devices = selectors.devicesSelector(state); + const adFormat = selectors.adFormatSelector(state); + + const timezone = getUserTimezoneSelector(state$.value); + + let requestParams = {}; + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + const { ranges, interval } = calendarCustom; + requestParams = { + ranges: ranges?.map((item) => ({ ...item, timezone })), + interval, + }; + } else if (calendar.dateRange) { + const { ranges, interval } = calendar; + requestParams = { + ranges: ranges?.map((item) => ({ ...item, timezone })), + interval, + }; + } + + if (demandSources?.length) { + requestParams = { + ...requestParams, + demandSources: demandSources.map((item) => item.value), + }; + } + + if (domains?.length) { + requestParams = { + ...requestParams, + domains: domains.map((item) => item.value), + }; + } + + if (countries?.length) { + requestParams = { + ...requestParams, + countries: countries.map((item) => item.value), + }; + } + + if (devices?.length) { + requestParams = { + ...requestParams, + devices: devices.map((item) => item.value), + }; + } + + if (players?.length) { + requestParams = { + ...requestParams, + widgetIds: players.map((item) => item.value), + }; + } + + if (adFormat) { + requestParams = { + ...requestParams, + adFormat, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const chartData = { + ...data.adPerformanceHistogram, + }; + + actions.push( + of( + setFieldAction({ + chartData: prepareChartData(chartData?.data ?? [], timezone, calendar, calendarCustom), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of(setFieldAction({ isChartDataLoading: true })), + stream$, + of(setFieldAction({ isChartDataLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/getCountries.js b/anyclip/src/modules/analytics/monetization/redux/epics/getCountries.js new file mode 100644 index 0000000..11a87da --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/getCountries.js @@ -0,0 +1,94 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getCountriesAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query adPerformanceCountries( + $devices: [String], + $domains: [String], + $demandSources: [String], + $dateRange: AnalyticsDateRangeInputType, + $interval: AnalyticsIntervalInputType, + $timezone: String, + $prefix: String, + $adFormat: String, + $size: Int + ) { + adPerformanceCountries( + devices: $devices, + domains: $domains, + demandSources: $demandSources, + dateRange: $dateRange, + interval: $interval, + timezone: $timezone, + prefix: $prefix, + adFormat: $adFormat, + size: $size + ) { + rid + countries + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getCountriesAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + + const countriesFullList = selectors.countriesFullListSelector(state); + const adFormat = selectors.adFormatSelector(state); + + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + prefix: action.payload, + }; + } + + if (adFormat) { + requestParams = { + ...requestParams, + adFormat, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + ...requestParams, + size: 250, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { countries } = data.adPerformanceCountries; + + actions.push( + of( + setFieldAction({ + countriesOptions: + countries?.map((code) => ({ + label: countriesFullList?.find((item) => item.uiKey === code)?.name ?? code, + value: code, + })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/getCountriesFullList.js b/anyclip/src/modules/analytics/monetization/redux/epics/getCountriesFullList.js new file mode 100644 index 0000000..343130b --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/getCountriesFullList.js @@ -0,0 +1,45 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCountriesAction, getCountriesFullListAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query commonGeography { + commonGeography { + id + uiKey + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCountriesFullListAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + setFieldAction({ + countriesFullList: data?.commonGeography ?? [], + }), + ), + of(getCountriesAction()), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/getDemandSources.js b/anyclip/src/modules/analytics/monetization/redux/epics/getDemandSources.js new file mode 100644 index 0000000..40a950a --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/getDemandSources.js @@ -0,0 +1,89 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getDemandSourcesAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query adPerformanceDemandSources( + $devices: [String], + $domains: [String], + $countries: [String], + $dateRange: AnalyticsDateRangeInputType, + $interval: AnalyticsIntervalInputType, + $timezone: String, + $prefix: String, + $adFormat: String, + $size: Int + ) { + adPerformanceDemandSources( + devices: $devices, + domains: $domains, + countries: $countries, + dateRange: $dateRange, + interval: $interval, + timezone: $timezone, + prefix: $prefix, + adFormat: $adFormat, + size: $size + ) { + rid + demandSources + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getDemandSourcesAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + + const adFormat = selectors.adFormatSelector(state); + + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + prefix: action.payload, + }; + } + + if (adFormat) { + requestParams = { + ...requestParams, + adFormat, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + size: 100, + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { demandSources } = data.adPerformanceDemandSources; + + actions.push( + of( + setFieldAction({ + demandSourcesOptions: demandSources?.map((item) => ({ label: item, value: item })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/getDomains.js b/anyclip/src/modules/analytics/monetization/redux/epics/getDomains.js new file mode 100644 index 0000000..8e37c09 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/getDomains.js @@ -0,0 +1,88 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getDomainsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query adPerformanceDomains( + $devices: [String], + $countries: [String], + $demandSources: [String], + $dateRange: AnalyticsDateRangeInputType, + $interval: AnalyticsIntervalInputType, + $timezone: String, + $prefix: String, + $adFormat: String, + $size: Int + ) { + adPerformanceDomains( + devices: $devices, + countries: $countries, + demandSources: $demandSources, + dateRange: $dateRange, + interval: $interval, + timezone: $timezone, + prefix: $prefix, + adFormat: $adFormat, + size: $size + ) { + rid + domains + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getDomainsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + + const adFormat = selectors.adFormatSelector(state); + + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + prefix: action.payload, + }; + } + + if (adFormat) { + requestParams = { + ...requestParams, + adFormat, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { domains } = data.adPerformanceDomains; + + actions.push( + of( + setFieldAction({ + domainsOptions: domains?.map((item) => ({ label: item, value: item })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/getPlayers.js b/anyclip/src/modules/analytics/monetization/redux/epics/getPlayers.js new file mode 100644 index 0000000..544387b --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/getPlayers.js @@ -0,0 +1,60 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getPlayersAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query analyticsAccountPlayers( + $searchText: String + ) { + analyticsAccountPlayers( + searchText: $searchText + ) { + id + name + alias + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlayersAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + searchText: action.payload, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setFieldAction({ + playersOptions: + data.analyticsAccountPlayers?.map((item) => ({ label: item.alias, value: item.name })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/getTotals.js b/anyclip/src/modules/analytics/monetization/redux/epics/getTotals.js new file mode 100644 index 0000000..40e5524 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/getTotals.js @@ -0,0 +1,214 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE } from '../../constants'; + +import * as selectors from '../selectors'; +import { getTotalsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query adPerformanceTotals( + $countries: [String], + $devices: [String], + $domains: [String], + $demandSources: [String], + $widgetIds: [String], + $adFormat: String, + $dateRange: AnalyticsDateRangeInputType, + $dateRangeComparison: AnalyticsDateRangeInputType + ) { + adPerformanceTotals( + countries: $countries, + devices: $devices, + domains: $domains, + demandSources: $demandSources, + widgetIds: $widgetIds, + adFormat: $adFormat, + dateRange: $dateRange, + dateRangeComparison: $dateRangeComparison + ) { + total { + externalRevenues + platformFee + adImpressions + viewableImpressions + viewability + fillRate + adRequests + cpm + rpm + playerLoads + lreOpportunities + lreImpressions + netRevenue + devicesExternalRevenues { + device + value + valuePct + } + ctr + adClicks + } + comparisonPct { + externalRevenuesPct + platformFeePct + adImpressionsPct + viewableImpressionsPct + viewabilityPct + fillRatePct + adRequestsPct + cpmPct + rpmPct + playerLoadsPct + lreOpportunitiesPct + lreImpressionsPct + netRevenuePct + devicesExternalRevenues { + device + value + valuePct + } + } + countries { + code + value + valuePct + } + hideComparisonNumbers + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getTotalsAction.type), + switchMap(() => { + const state = state$.value; + + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const demandSources = selectors.demandSourcesSelector(state); + const players = selectors.playersSelector(state); + const domains = selectors.domainsSelector(state); + const countries = selectors.countriesSelector(state); + const devices = selectors.devicesSelector(state); + const adFormat = selectors.adFormatSelector(state); + const countriesFullList = selectors.countriesFullListSelector(state); + + const timezone = getUserTimezoneSelector(state$.value); + + let requestParams = {}; + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + const { dateRange } = calendarCustom; + requestParams = { + dateRange: { ...dateRange, timezone }, + }; + } else if (calendar.dateRange) { + const { dateRange, dateRangeComparison } = calendar; + + if (dateRangeComparison) { + requestParams = { + ...requestParams, + dateRange: { ...dateRange, timezone }, + dateRangeComparison: { ...dateRangeComparison, timezone }, + }; + } else { + requestParams = { + ...requestParams, + dateRange: { ...dateRange, timezone }, + }; + } + } + + if (demandSources?.length) { + requestParams = { + ...requestParams, + demandSources: demandSources.map((item) => item.value), + }; + } + + if (domains?.length) { + requestParams = { + ...requestParams, + domains: domains.map((item) => item.value), + }; + } + + if (countries?.length) { + requestParams = { + ...requestParams, + countries: countries.map((item) => item.value), + }; + } + + if (devices?.length) { + requestParams = { + ...requestParams, + devices: devices.map((item) => item.value), + }; + } + + if (players?.length) { + requestParams = { + ...requestParams, + widgetIds: players.map((item) => item.value), + }; + } + + if (adFormat) { + requestParams = { + ...requestParams, + adFormat, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { + total = {}, + comparisonPct = {}, + countries: countriesResponse, + hideComparisonNumbers, + } = data.adPerformanceTotals; + + actions.push( + of( + setFieldAction({ + totals: { + total, + comparisonPct, + countries: + countriesResponse?.map((country) => ({ + ...country, + name: countriesFullList?.find((item) => item.uiKey === country.code)?.name ?? country.code, + })) ?? [], + devices: total?.devicesExternalRevenues ?? [], + hideComparisonNumbers, + }, + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat( + of(setFieldAction({ isTotalsLoading: true })), + stream$, + of(setFieldAction({ isTotalsLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/analytics/monetization/redux/epics/index.js b/anyclip/src/modules/analytics/monetization/redux/epics/index.js new file mode 100644 index 0000000..ba2d0ec --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/epics/index.js @@ -0,0 +1,23 @@ +import { combineEpics } from 'redux-observable'; + +import exportToCSV from './exportToCSV'; +import exportToPDF from './exportToPDF'; +import getChartData from './getChartData'; +import getCountries from './getCountries'; +import getCountriesFullList from './getCountriesFullList'; +import getDemandSources from './getDemandSources'; +import getDomains from './getDomains'; +import getPlayers from './getPlayers'; +import getTotals from './getTotals'; + +export default combineEpics( + exportToPDF, + exportToCSV, + getChartData, + getCountries, + getCountriesFullList, + getDemandSources, + getDomains, + getPlayers, + getTotals, +); diff --git a/anyclip/src/modules/analytics/monetization/redux/selectors/index.js b/anyclip/src/modules/analytics/monetization/redux/selectors/index.js new file mode 100644 index 0000000..13ff181 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/selectors/index.js @@ -0,0 +1,24 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const calendarSelector = (state$) => state$[nameSpace].calendar; +export const calendarCustomSelector = (state$) => state$[nameSpace].calendarCustom; +export const activeCardFilterSelector = (state$) => state$[nameSpace].activeCardFilter; +export const adFormatSelector = (state$) => state$[nameSpace].adFormat; +export const demandSourcesSelector = (state$) => state$[nameSpace].demandSources; +export const demandSourcesOptionsSelector = (state$) => state$[nameSpace].demandSourcesOptions; +export const playersSelector = (state$) => state$[nameSpace].players; +export const playersOptionsSelector = (state$) => state$[nameSpace].playersOptions; +export const domainsSelector = (state$) => state$[nameSpace].domains; +export const domainsOptionsSelector = (state$) => state$[nameSpace].domainsOptions; +export const countriesSelector = (state$) => state$[nameSpace].countries; +export const countriesOptionsSelector = (state$) => state$[nameSpace].countriesOptions; +export const devicesSelector = (state$) => state$[nameSpace].devices; +export const totalsSelector = (state$) => state$[nameSpace].totals; +export const chartDataSelector = (state$) => state$[nameSpace].chartData; +export const showFeesSelector = (state$) => state$[nameSpace].showFees; +export const countriesFullListSelector = (state$) => state$[nameSpace].countriesFullList; +export const isExportPdfLoadingSelector = (state$) => state$[nameSpace].isExportPdfLoading; +export const isTotalsLoadingSelector = (state$) => state$[nameSpace].isTotalsLoading; +export const isChartDataLoadingSelector = (state$) => state$[nameSpace].isChartDataLoading; diff --git a/anyclip/src/modules/analytics/monetization/redux/slices/index.js b/anyclip/src/modules/analytics/monetization/redux/slices/index.js new file mode 100644 index 0000000..0030284 --- /dev/null +++ b/anyclip/src/modules/analytics/monetization/redux/slices/index.js @@ -0,0 +1,105 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { AD_FORMAT_CONFIG, CALENDAR_FILTER_VALUE, CARDS_FILTERS_ALL } from '../../constants'; + +const initialState = { + calendar: CALENDAR_FILTER_VALUE.thisMonth, + calendarCustom: { + dateStart: null, + dateEnd: null, + }, + activeCardFilter: { + ...CARDS_FILTERS_ALL[0], + ...CARDS_FILTERS_ALL[0].getCardConfig({ adFormat: AD_FORMAT_CONFIG.all.value }), + }, + adFormat: AD_FORMAT_CONFIG.all.value, + demandSources: [], + demandSourcesOptions: [], + players: [], + playersOptions: [], + domains: [], + domainsOptions: [], + countries: [], + countriesOptions: [], + devices: [], + totals: { + total: { + externalRevenues: null, + platformFee: null, + adImpressions: null, + viewability: null, + fillRate: null, + adRequests: null, + cpm: null, + rpm: null, + playerLoads: null, + netRevenue: null, + ctr: null, + adClicks: null, + }, + comparisonPct: { + externalRevenuesPct: null, + platformFeePct: null, + adImpressionsPct: null, + viewabilityPct: null, + fillRatePct: null, + adRequestsPct: null, + cpmPct: null, + rpmPct: null, + playerLoadsPct: null, + netRevenuePct: null, + }, + devices: [], + countries: [], + hideComparisonNumbers: false, + }, + chartData: [], + showFees: false, + countriesFullList: [], + isExportPdfLoading: false, + isTotalsLoading: false, + isChartDataLoading: false, +}; + +export const slice = createSlice({ + name: '@@analyticsMonetizationDashboard/ANALYTICS_MONETIZATION_DASHBOARD', + initialState, + + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setDefaultStateAction: (state) => { + Object.keys(initialState).forEach((key) => { + state[key] = initialState[key]; + }); + }, + exportToPDFAction: (state) => state, + exportToCSVAction: (state) => state, + getCountriesFullListAction: (state) => state, + getCountriesAction: (state) => state, + getDemandSourcesAction: (state) => state, + getDomainsAction: (state) => state, + getPlayersAction: (state) => state, + getTotalsAction: (state) => state, + getChartDataAction: (state) => state, + }, +}); + +export const { + setFieldAction, + setDefaultStateAction, + exportToPDFAction, + exportToCSVAction, + getCountriesFullListAction, + getCountriesAction, + getDemandSourcesAction, + getDomainsAction, + getPlayersAction, + getTotalsAction, + getChartDataAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.module.scss b/anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.module.scss new file mode 100644 index 0000000..94d2a35 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__wHVRB","EmptyContent":"Empty_EmptyContent__3_PSt"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.tsx b/anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.tsx new file mode 100644 index 0000000..578d68e --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/components/Empty/Empty.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Image from 'next/image'; + +import { Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +function Empty() { + return ( + + + empty-logo + + No any data + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/components/List.module.scss b/anyclip/src/modules/analytics/revenueOverview/List/components/List.module.scss new file mode 100644 index 0000000..ee956d5 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Filter":"List_Filter__rgCnp","Filter___fixed":"List_Filter___fixed__BeYaB","FilterOption":"List_FilterOption__NPJPf","Cell":"List_Cell__Yq44Y","Cell___total":"List_Cell___total__97Ty2","IdCell":"List_IdCell__WAV0m"}; \ No newline at end of file diff --git a/anyclip/src/modules/analytics/revenueOverview/List/components/List.tsx b/anyclip/src/modules/analytics/revenueOverview/List/components/List.tsx new file mode 100644 index 0000000..9b3a215 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/components/List.tsx @@ -0,0 +1,433 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; + +import { + AD_FORMAT_COLLECTION, + CALENDAR_FILTER_COLLECTION, + CALENDAR_FILTER_CUSTOM, + CALENDAR_FILTER_THIS_MONTH, + DEVICE_COLLECTION, +} from '@/modules/analytics/monetization/constants'; + +import type { DataRecordType, StateFiltersType, StateTableSliceType } from '../types'; + +import { getConfigHeaders, getConfigSubHeaders } from '../helpers'; +import * as computedState from '../helpers/computedState'; +import * as selectors from '../redux/selectors'; +import { + exportToCsvAction, + getCountriesAction, + getCountriesFullListAction, + getDataAction, + getDemandSourcesAction, + getDomainsAction, + getPlayersAction, + setAction, + setTableAction, +} from '../redux/slices'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import MultiAutocomplete from '@/modules/@common/MultiAutocomplete/MultiAutocomplete'; +import CommonTable from '@/modules/@common/Table'; +import { DialogCalendarRange } from '@/modules/analytics/common/components'; +import SpecialPopper from '@/modules/analytics/monetization/components/Filters/components/SpecialPopper'; +import Empty from './Empty/Empty'; +import { + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, +} from '@/mui/components'; +import { CustomCsvFilled } from '@/mui/components/CustomIcon'; + +import styles from './List.module.scss'; + +type StateFlattenType = StateTableSliceType & StateFiltersType; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +function List() { + const dispatch = useDispatch(); + const userTimezone = useSelector(getUserTimezoneSelector); + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + const adFormat = useSelector(selectors.adFormatSelector); + const period = useSelector(selectors.periodSelector); + const customDateFrom = useSelector(selectors.customDateFromSelector); + const customDateTo = useSelector(selectors.customDateToSelector); + + const devices = useSelector(selectors.devicesSelector); + + const demandSources = useSelector(selectors.demandSourcesSelector); + const demandSourcesOptions = useSelector(selectors.demandSourcesOptionsSelector); + + const players = useSelector(selectors.playersSelector); + const playersOptions = useSelector(selectors.playersOptionsSelector); + + const countries = useSelector(selectors.countriesSelector); + const countriesOptions = useSelector(selectors.countriesOptionsSelector); + + const domains = useSelector(selectors.domainsSelector); + const domainsOptions = useSelector(selectors.domainsOptionsSelector); + + const shouldShowEmpty = useSelector(computedState.shouldShowEmpty); + const [customDateRangePicker, toggleCustomDateRangePicker] = useState(false); + + const handleFilter = (filter: Partial, withoutRequest = false) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + const res = omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }); + + dispatch(setTableAction(res)); + + dispatch(setAction({ ...mainState })); + + if (!withoutRequest) { + dispatch(getDataAction()); + } + }; + + useEffect(() => { + dispatch(getCountriesFullListAction()); + dispatch(getDataAction()); + }, []); + + const toLocaleMoneyString = (value?: number) => + typeof value === 'number' + ? `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + : ''; + + return ( + <> + + + Ad Format + + + + + Period + + + + { + handleFilter({ demandSources: newValue as StateFiltersType['demandSources'], page: 1 }); + }} + onInputChange={(event, value: string) => dispatch(getDemandSourcesAction(value))} + onOpen={() => dispatch(getDemandSourcesAction(''))} + renderInput={(params) => ( + + )} + slots={{ + popper: SpecialPopper, + }} + classes={{ + option: styles.FilterOption, + }} + /> + + { + handleFilter({ players: newValue as StateFiltersType['players'], page: 1 }); + }} + onInputChange={(event, value: string) => dispatch(getPlayersAction(value))} + onOpen={() => dispatch(getPlayersAction(''))} + renderInput={(params) => ( + + )} + slots={{ + popper: SpecialPopper, + }} + classes={{ + option: styles.FilterOption, + }} + /> + + { + handleFilter({ domains: newValue as StateFiltersType['domains'], page: 1 }); + }} + onInputChange={(event, value: string) => dispatch(getDomainsAction(value))} + onOpen={() => dispatch(getDomainsAction(''))} + renderInput={(params) => ( + + )} + slots={{ + popper: SpecialPopper, + }} + classes={{ + option: styles.FilterOption, + }} + /> + + { + handleFilter({ countries: newValue as StateFiltersType['domains'], page: 1 }); + }} + onInputChange={(event, value: string) => dispatch(getCountriesAction(value))} + onOpen={() => dispatch(getCountriesAction(''))} + renderInput={(params) => ( + + )} + slots={{ + popper: SpecialPopper, + }} + classes={{ + option: styles.FilterOption, + }} + /> + + { + handleFilter({ devices: newValue as StateFiltersType['devices'], page: 1 }); + }} + onInputChange={() => null} + renderInput={(params) => ( + + )} + slots={{ + popper: SpecialPopper, + }} + classes={{ + option: styles.FilterOption, + }} + /> + + } + renderActions={ + + dispatch(exportToCsvAction())}> + + + + } + > + {shouldShowEmpty ? ( + + ) : ( + ( + + +
    + {row.time ? dayjs(row.time).tz(userTimezone).format('MMM D, YYYY hh:mm A') : 'Total'} +
    +
    + +
    + {row.playerLoads} +
    +
    + +
    + {row.videoAdImpressions} +
    +
    + +
    + {row.displayAdImpressions} +
    +
    + +
    + {toLocaleMoneyString(row.publisherVideoRevenue + row.publisherDisplayRevenue)} +
    +
    + +
    + {toLocaleMoneyString(row.anyclipVideoRevenue + row.anyclipDisplayRevenue)} +
    +
    + +
    + {toLocaleMoneyString(row.platformFee)} +
    +
    + +
    + {toLocaleMoneyString(row.netRevenue)} +
    +
    + +
    + {toLocaleMoneyString(row.rpm)} +
    +
    +
    + )} + data={data || []} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onFilter={handleFilter} + /> + )} +
    + { + toggleCustomDateRangePicker(false); + + handleFilter({ period: CALENDAR_FILTER_THIS_MONTH, page: 1 }); + }} + onDateSubmit={({ dateStart, dateEnd }: { dateStart: string; dateEnd: string }) => { + toggleCustomDateRangePicker(false); + + handleFilter({ + customDateFrom: dateStart, + customDateTo: dateEnd, + page: 1, + }); + }} + /> + + ); +} + +export default List; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/constants/index.ts b/anyclip/src/modules/analytics/revenueOverview/List/constants/index.ts new file mode 100644 index 0000000..c23a3f2 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/constants/index.ts @@ -0,0 +1,5 @@ +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'time'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/helpers/computedState.ts b/anyclip/src/modules/analytics/revenueOverview/List/helpers/computedState.ts new file mode 100644 index 0000000..560f1eb --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/helpers/computedState.ts @@ -0,0 +1,11 @@ +import * as selectors from '../redux/selectors'; + +export const shouldShowEmpty = (state: RootState) => { + const data = selectors.dataSelector(state); + const page = selectors.pageSelector(state); + const isLoading = selectors.isLoadingSelector(state); + + return !isLoading && Array.isArray(data) && !data.length && page === 1; +}; + +export default {}; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/helpers/index.ts b/anyclip/src/modules/analytics/revenueOverview/List/helpers/index.ts new file mode 100644 index 0000000..d209c55 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/helpers/index.ts @@ -0,0 +1,93 @@ +export const getConfigHeaders = () => + [ + { + id: 'time', + label: 'Date', + sortable: true, + }, + { + id: 'playerLoads', + label: 'Player Loads', + align: 'center', + sortable: true, + }, + { + id: 'videoAdImpressions', + label: 'Ad Impressions', + align: 'center', + colspan: 2, + }, + { + id: 'publisherVideoRevenue', + label: 'Revenue', + align: 'center', + colspan: 2, + }, + { + id: 'platformFee', + label: 'Platform Fees', + align: 'center', + sortable: true, + }, + { + id: 'netRevenue', + label: 'Net Revenue', + align: 'center', + sortable: true, + }, + { + id: 'rpm', + label: 'Player Loads RPM', + align: 'center', + tooltip: 'Net Revenue/Player Loads', + sortable: true, + }, + ].filter(Boolean); + +export const getConfigSubHeaders = () => + [ + { + id: 'time', + label: '', + }, + { + id: 'playerLoads', + label: '', + }, + { + id: 'videoAdImpressions', + label: 'Video', + sortable: true, + align: 'right', + }, + { + id: 'displayAdImpressions', + label: 'Display', + sortable: true, + align: 'right', + }, + { + id: 'publisherVideoRevenue', + label: 'Publisher Demand', + sortable: true, + align: 'right', + }, + { + id: 'anyclipVideoRevenue', + label: 'Anyclip Demand', + sortable: true, + align: 'right', + }, + { + id: 'platformFee', + label: '', + }, + { + id: 'netRevenue', + label: '', + }, + { + id: 'rpm', + label: '', + }, + ].filter(Boolean); diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/exportToCsv.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/exportToCsv.ts new file mode 100644 index 0000000..9a17f1f --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/exportToCsv.ts @@ -0,0 +1,123 @@ +import dayjs from 'dayjs'; +import { type Epic } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { type DataRecordType } from '../../types'; + +import * as selectors from '../selectors'; +import { exportToCsvAction } from '../slices'; +import { saveBlobAsFile } from '@/modules/@common/helpers/file-saver'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +type Header = { + label: string; + id: keyof DataRecordType; +}; + +const epic: Epic = (action$, state$) => + action$.pipe( + filter( + (action): action is ReturnType => + action.type === exportToCsvAction.type && !!selectors.dataSelector(state$.value)?.length, + ), + switchMap(() => { + const state = state$.value; + const userTimezone = getUserTimezoneSelector(state$.value) ?? 'UTC'; + const data = selectors.dataSelector(state)!; + + const headers: Header[] = [ + { + label: 'Date', + id: 'time', + }, + { + label: 'Player Loads', + id: 'playerLoads', + }, + { + label: 'Ad Impressions Video', + id: 'videoAdImpressions', + }, + { + label: 'Ad Impressions Display', + id: 'displayAdImpressions', + }, + { + label: 'Publisher Demand Revenue', + id: 'publisherVideoRevenue', + }, + { + label: 'Anyclip Demand Revenue', + id: 'anyclipVideoRevenue', + }, + { + label: 'Platform Fees', + id: 'platformFee', + }, + { + label: 'Net Revenue', + id: 'netRevenue', + }, + { + label: 'Player Loads RPM', + id: 'rpm', + }, + ]; + + const dataCSV = [ + headers.map(({ label }) => label).join(','), + ...data.map((obj, dataIndex) => + headers + .map((h) => { + let value = obj[h.id] ?? ''; + + if (h.id === 'time') { + value = value ? dayjs(value).tz(userTimezone).format('MMM D, YYYY hh:mm A') : ''; + + if (dataIndex >= data.length - 1) { + value = 'Total'; + } + } else if (h.id === 'publisherVideoRevenue') { + value = (obj.publisherVideoRevenue || 0) + (obj.publisherDisplayRevenue || 0); + } else if (h.id === 'anyclipVideoRevenue') { + value = (obj.anyclipVideoRevenue || 0) + (obj.anyclipDisplayRevenue || 0); + } + + return JSON.stringify(value); + }) + .join(','), + ), + ].join('\n'); + + const stream$ = defer(async () => { + const JSZip = (await import('jszip')).default; + + const zip = new JSZip(); + zip.file('data.csv', dataCSV); + + return zip.generateAsync({ type: 'blob' }); + }).pipe( + switchMap((content) => { + saveBlobAsFile(content, `Revenue-overview.zip`); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Download was successful', + }), + ), + ); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountries.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountries.ts new file mode 100644 index 0000000..19c4ff0 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountries.ts @@ -0,0 +1,82 @@ +import { type Epic } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, mergeMap, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { adFormatSelector, countriesFullListSelector } from '../selectors'; +import { getCountriesAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query adPerformanceCountries( + $devices: [String], + $domains: [String], + $demandSources: [String], + $dateRange: AnalyticsDateRangeInputType, + $interval: AnalyticsIntervalInputType, + $timezone: String, + $prefix: String, + $adFormat: String, + $size: Int + ) { + adPerformanceCountries( + devices: $devices, + domains: $domains, + demandSources: $demandSources, + dateRange: $dateRange, + interval: $interval, + timezone: $timezone, + prefix: $prefix, + adFormat: $adFormat, + size: $size + ) { + rid + countries + } + } +`; + +const epic: Epic = (action$, state$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getCountriesAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + mergeMap((action) => { + const adFormat = adFormatSelector(state$.value); + + const stream = gqlRequest({ + query, + variables: { + size: 250, + prefix: action.payload || undefined, + adFormat: adFormat || undefined, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions$ = []; + + if (!errors.length) { + const countriesFullList = countriesFullListSelector(state$.value); + + const { countries } = data.adPerformanceCountries; + + actions$.push( + of( + setAction({ + countriesOptions: + countries?.map((code: string) => ({ + label: countriesFullList?.find((item) => item.uiKey === code)?.name ?? code, + value: code, + })) ?? [], + }), + ), + ); + } + return concat(...actions$); + }), + ); + + return concat(stream); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountriesFullList.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountriesFullList.ts new file mode 100644 index 0000000..0484ee7 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getCountriesFullList.ts @@ -0,0 +1,51 @@ +import { type Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { getCountriesFullListAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query commonGeography { + commonGeography { + id + uiKey + name + } + } +`; + +const epic: Epic = (action$) => + action$.pipe( + filter( + (action): action is ReturnType => + action.type === getCountriesFullListAction.type, + ), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: {}, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setAction({ + countriesFullList: data?.commonGeography ?? [], + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getData.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getData.ts new file mode 100644 index 0000000..4ad925b --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getData.ts @@ -0,0 +1,135 @@ +import dayjs from 'dayjs'; + +import { GET_LIST } from '@/graphql/services/analyticsRevenueOverview/constants'; +import { SORT_ASC } from '@/modules/@common/constants/sort'; +import { CALENDAR_FILTER_ADAPTER, CALENDAR_FILTER_CUSTOM } from '@/modules/analytics/monetization/constants'; + +import { DataRecordsType } from '../../types'; +import { PAYLOAD_NAME } from '@/graphql/services/analyticsRevenueOverview/types/payload/list'; + +import { + adFormatSelector, + countriesSelector, + customDateFromSelector, + customDateToSelector, + demandSourcesSelector, + devicesSelector, + domainsSelector, + pageSelector, + pageSizeSelector, + periodSelector, + playersSelector, + sortBySelector, + sortOrderSelector, +} from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import { PayloadType } from '@/graphql/services/analyticsRevenueOverview/resolvers/getListResolver'; +import type { RootState } from '@/modules/@common/store/store'; + +type DataType = Record< + string, + { + data: DataRecordsType; + total: number; + } +>; + +const gqlQuery = ` + query ${GET_LIST}($payload: ${PAYLOAD_NAME}) { + ${GET_LIST}(payload: $payload) { + total + data { + customId + platformFee + netRevenue + playerLoads + rpm + anyclipVideoAdImpressions + publisherVideoAdImpressions + anyclipDisplayAdImpressions + publisherDisplayAdImpressions + videoAdImpressions + displayAdImpressions + anyclipVideoRevenue + publisherVideoRevenue + anyclipDisplayRevenue + publisherDisplayRevenue + videoRevenue + displayRevenue + time + } + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state: RootState) => { + const timezone = getUserTimezoneSelector(state); + const period = periodSelector(state); + const customDateFrom = customDateFromSelector(state); + const customDateTo = customDateToSelector(state); + const demandSources = demandSourcesSelector(state); + const players = playersSelector(state); + const countries = countriesSelector(state); + const domains = domainsSelector(state); + const devices = devicesSelector(state); + + const filters = CALENDAR_FILTER_ADAPTER[period as keyof typeof CALENDAR_FILTER_ADAPTER]; + + const range = + period === CALENDAR_FILTER_CUSTOM + ? { + stringFrom: customDateFrom, + stringTo: customDateTo, + } + : { + stringFrom: filters.range.stringFrom, + stringTo: filters.range.stringTo, + }; + const interval = + period === CALENDAR_FILTER_CUSTOM + ? { + unit: dayjs(customDateTo).diff(customDateFrom, 'days') > 1 ? 'DAYS' : 'HOURS', + value: 1, + } + : filters.interval; + + const payload: PayloadType = { + timezone, + page: pageSelector(state) - 1, + pageSize: pageSizeSelector(state), + sortBy: sortBySelector(state), + sortOrder: sortOrderSelector(state) ?? SORT_ASC, + adFormat: adFormatSelector(state) || undefined, + demandSources: demandSources.length ? demandSources.map(({ value }) => value) : undefined, + widgetIds: players.length ? players.map(({ value }) => value) : undefined, + countries: countries.length ? countries.map(({ value }) => value) : undefined, + domains: domains.length ? domains.map(({ value }) => value) : undefined, + devices: devices.length ? devices.map(({ value }) => value) : undefined, + range: { + ...range, + timezone, + }, + interval, + }; + + return { + payload, + }; + }, + processResponse: ({ data }: { data: DataType }) => { + const res = data[GET_LIST]; + + return { + records: res.data, + recordsTotal: res.total, + allRecordsCount: res.total, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDemandSources.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDemandSources.ts new file mode 100644 index 0000000..e74e0e4 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDemandSources.ts @@ -0,0 +1,80 @@ +import { type Epic } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { adFormatSelector } from '../selectors'; +import { getDemandSourcesAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query adPerformanceDemandSources( + $devices: [String], + $domains: [String], + $countries: [String], + $dateRange: AnalyticsDateRangeInputType, + $interval: AnalyticsIntervalInputType, + $timezone: String, + $prefix: String, + $adFormat: String, + $size: Int + ) { + adPerformanceDemandSources( + devices: $devices, + domains: $domains, + countries: $countries, + dateRange: $dateRange, + interval: $interval, + timezone: $timezone, + prefix: $prefix, + adFormat: $adFormat, + size: $size + ) { + rid + demandSources + } + } +`; + +const epic: Epic = (action$, state$) => + action$.pipe( + filter( + (action): action is ReturnType => action.type === getDemandSourcesAction.type, + ), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + + const adFormat = adFormatSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + size: 100, + prefix: action.payload || undefined, + adFormat: adFormat || undefined, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { demandSources } = data.adPerformanceDemandSources; + + actions.push( + of( + setAction({ + demandSourcesOptions: demandSources?.map((item: string) => ({ label: item, value: item })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDomains.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDomains.ts new file mode 100644 index 0000000..e8ca725 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getDomains.ts @@ -0,0 +1,77 @@ +import type { Epic } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { adFormatSelector } from '../selectors'; +import { getDomainsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query adPerformanceDomains( + $devices: [String], + $countries: [String], + $demandSources: [String], + $dateRange: AnalyticsDateRangeInputType, + $interval: AnalyticsIntervalInputType, + $timezone: String, + $prefix: String, + $adFormat: String, + $size: Int + ) { + adPerformanceDomains( + devices: $devices, + countries: $countries, + demandSources: $demandSources, + dateRange: $dateRange, + interval: $interval, + timezone: $timezone, + prefix: $prefix, + adFormat: $adFormat, + size: $size + ) { + rid + domains + } + } +`; + +const epic: Epic = (action$, state$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getDomainsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + + const adFormat = adFormatSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + prefix: action.payload || undefined, + adFormat: adFormat || undefined, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { domains } = data.adPerformanceDomains; + + actions.push( + of( + setAction({ + domainsOptions: domains?.map((item: string) => ({ label: item, value: item })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getPlayers.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getPlayers.ts new file mode 100644 index 0000000..d945bac --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/getPlayers.ts @@ -0,0 +1,55 @@ +import { concat, Observable, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; +import type { Action } from '@reduxjs/toolkit'; + +import { getPlayersAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query analyticsAccountPlayers( + $searchText: String + ) { + analyticsAccountPlayers( + searchText: $searchText + ) { + id + name + alias + } + } +`; + +export default (action$: Observable) => + action$.pipe( + filter((action): action is ReturnType => action.type === getPlayersAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload || undefined, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setAction({ + playersOptions: + data.analyticsAccountPlayers?.map((item: { alias: string; name: string }) => ({ + label: item.alias, + value: item.name, + })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/index.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/index.ts new file mode 100644 index 0000000..3a0c86c --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/epics/index.ts @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import exportToCsv from './exportToCsv'; +import getCountries from './getCountries'; +import getCountriesFullList from './getCountriesFullList'; +import getData from './getData'; +import getDemandSources from './getDemandSources'; +import getDomains from './getDomains'; +import getPlayers from './getPlayers'; + +export default combineEpics( + getData, + getPlayers, + getDomains, + getDemandSources, + getCountriesFullList, + getCountries, + exportToCsv, +); diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/selectors/index.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/selectors/index.ts new file mode 100644 index 0000000..e949bac --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/selectors/index.ts @@ -0,0 +1,45 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; + +import type { DataRecordsType } from '../../types'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +import type { RootState } from '@/modules/@common/store/store'; + +type OrderType = typeof SORT_ASC | typeof SORT_DESC; + +const nameSpace = slice.name; +// table +export const tableSelectors = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +export const dataSelector = (state: RootState) => tableSelectors.dataSelector(state) as DataRecordsType; +export const pageSelector = (state: RootState) => tableSelectors.pageSelector(state) as number; +export const pageSizeSelector = (state: RootState) => tableSelectors.pageSizeSelector(state) as number; +export const totalCountSelector = (state: RootState) => tableSelectors.totalCountSelector(state) as number; +export const sortBySelector = (state: RootState) => tableSelectors.sortBySelector(state) as string; +export const sortOrderSelector = (state: RootState) => tableSelectors.sortOrderSelector(state) as OrderType; +export const isLoadingSelector = (state: RootState) => tableSelectors.isLoadingSelector(state) as boolean; +export const selectedSelector = (state: RootState) => tableSelectors.selectedSelector(state) as number[]; + +export const adFormatSelector = (state: RootState) => state[nameSpace].adFormat; +export const periodSelector = (state: RootState) => state[nameSpace].period; + +export const customDateFromSelector = (state: RootState) => state[nameSpace].customDateFrom; +export const customDateToSelector = (state: RootState) => state[nameSpace].customDateTo; + +export const devicesSelector = (state: RootState) => state[nameSpace].devices; + +export const demandSourcesSelector = (state: RootState) => state[nameSpace].demandSources; +export const demandSourcesOptionsSelector = (state: RootState) => state[nameSpace].demandSourcesOptions; + +export const playersSelector = (state: RootState) => state[nameSpace].players; +export const playersOptionsSelector = (state: RootState) => state[nameSpace].playersOptions; + +export const domainsSelector = (state: RootState) => state[nameSpace].domains; +export const domainsOptionsSelector = (state: RootState) => state[nameSpace].domainsOptions; + +export const countriesFullListSelector = (state: RootState) => state[nameSpace].countriesFullList; +export const countriesSelector = (state: RootState) => state[nameSpace].countries; +export const countriesOptionsSelector = (state: RootState) => state[nameSpace].countriesOptions; diff --git a/anyclip/src/modules/analytics/revenueOverview/List/redux/slices/index.ts b/anyclip/src/modules/analytics/revenueOverview/List/redux/slices/index.ts new file mode 100644 index 0000000..9a3a292 --- /dev/null +++ b/anyclip/src/modules/analytics/revenueOverview/List/redux/slices/index.ts @@ -0,0 +1,83 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; +import { AD_FORMAT_CONFIG_ALL, CALENDAR_FILTER_THIS_MONTH } from '@/modules/analytics/monetization/constants'; + +import type { StateTableSliceType, StateType } from '../../types'; + +import { applyPartial } from '@/modules/@common/store/helpers'; +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState: StateType = { + [TABLE_REDUX_FIELD_NAME]: { + ...(tableSlice.state as Record)[TABLE_REDUX_FIELD_NAME], + }, + + // filters + adFormat: AD_FORMAT_CONFIG_ALL, + period: CALENDAR_FILTER_THIS_MONTH, + + customDateFrom: '', + customDateTo: '', + + // todo: save only values but not label + value for all autocompletes + devices: [], + + demandSources: [], + demandSourcesOptions: [], + + players: [], + playersOptions: [], + + domains: [], + domainsOptions: [], + + countriesFullList: [], + countries: [], + countriesOptions: [], +}; + +export const slice = createSlice({ + name: '@@ANALYTICS/REVENUE_OVERVIEW', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setAction: (state: StateType, action: PayloadAction>) => { + applyPartial(state, action.payload, [TABLE_REDUX_FIELD_NAME]); + }, + setTableAction: (state: StateType, action: PayloadAction>) => + tableSlice.actions.setTableAction(state, action), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getPlayersAction: (state: StateType, action: PayloadAction) => state, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getDomainsAction: (state: StateType, action: PayloadAction) => state, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getCountriesFullListAction: (state: StateType, action: PayloadAction) => state, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getCountriesAction: (state: StateType, action: PayloadAction) => state, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getDemandSourcesAction: (state: StateType, action: PayloadAction) => state, + exportToCsvAction: (state: StateType) => state, + }, +}); + +export const { + getDataAction, + setTableAction, + setAction, + getPlayersAction, + getDomainsAction, + getCountriesAction, + getCountriesFullListAction, + getDemandSourcesAction, + exportToCsvAction, +} = slice.actions; diff --git a/src/modules/analytics/videoContentPerformance/components/TopSearches/TopSearches.module.scss b/anyclip/src/modules/analytics/videoContentPerformance/components/TopSearches/TopSearches.module.scss similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/TopSearches/TopSearches.module.scss rename to anyclip/src/modules/analytics/videoContentPerformance/components/TopSearches/TopSearches.module.scss diff --git a/src/modules/analytics/videoContentPerformance/components/TopSearches/index.jsx b/anyclip/src/modules/analytics/videoContentPerformance/components/TopSearches/index.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/TopSearches/index.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/components/TopSearches/index.jsx diff --git a/src/modules/analytics/videoContentPerformance/components/VideoContentPerfomance.module.scss b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoContentPerfomance.module.scss similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoContentPerfomance.module.scss rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoContentPerfomance.module.scss diff --git a/src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.jsx b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.jsx diff --git a/src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.module.scss b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.module.scss similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.module.scss rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/CustomTooltip.module.scss diff --git a/src/modules/analytics/videoContentPerformance/components/VideoGraph/VideoGraph.module.scss b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/VideoGraph.module.scss similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoGraph/VideoGraph.module.scss rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/VideoGraph.module.scss diff --git a/src/modules/analytics/videoContentPerformance/components/VideoGraph/index.jsx b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/index.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoGraph/index.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoGraph/index.jsx diff --git a/src/modules/analytics/videoContentPerformance/components/VideoSearch/VideoSearch.module.scss b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoSearch/VideoSearch.module.scss similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoSearch/VideoSearch.module.scss rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoSearch/VideoSearch.module.scss diff --git a/src/modules/analytics/videoContentPerformance/components/VideoSearch/index.jsx b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoSearch/index.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoSearch/index.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoSearch/index.jsx diff --git a/src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/TextTooltip.module.scss b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/TextTooltip.module.scss similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/TextTooltip.module.scss rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/TextTooltip.module.scss diff --git a/src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/index.jsx b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/index.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/index.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/TextTooltip/index.jsx diff --git a/src/modules/analytics/videoContentPerformance/components/VideoTable/VideoTable.module.scss b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/VideoTable.module.scss similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoTable/VideoTable.module.scss rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/VideoTable.module.scss diff --git a/src/modules/analytics/videoContentPerformance/components/VideoTable/index.jsx b/anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/index.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/VideoTable/index.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/components/VideoTable/index.jsx diff --git a/src/modules/analytics/videoContentPerformance/components/index.jsx b/anyclip/src/modules/analytics/videoContentPerformance/components/index.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/components/index.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/components/index.jsx diff --git a/anyclip/src/modules/analytics/videoContentPerformance/constants/index.js b/anyclip/src/modules/analytics/videoContentPerformance/constants/index.js new file mode 100644 index 0000000..9c27746 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/constants/index.js @@ -0,0 +1,167 @@ +import { abbreviateNumber } from '@/modules/@common/helpers/number'; + +export const ORIGIN_FILTER = [ + { label: 'All Origins', value: 'ALL' }, + { label: 'Direct Navigation ', value: 'playlist' }, + { label: 'Collaboration', value: 'share' }, + { label: 'Search', value: 'search' }, +]; + +export const INTERVAL = { + hours: 'HOURS', + days: 'DAYS', + weeks: 'WEEKS', +}; + +export const CALENDAR_FILTER_VALUE = { + today: { + stringFrom: 'now/d', + comparison: { + stringFrom: 'now-1d/d', + stringTo: 'now-1d/h', + }, + interval: INTERVAL.hours, + videoChartDataKey: 'hours', + tooltipDateFormatter: (value) => value.format('hh A'), + tooltipGraphDateFormatter: (value) => value.format('hh:mm A'), + }, + yesterday: { + stringFrom: 'now-1d/d', + stringTo: 'now/d', + comparison: { + stringFrom: 'now-2d/d', + stringTo: 'now-1d/d', + }, + interval: INTERVAL.hours, + videoChartDataKey: 'hours', + tooltipDateFormatter: (value) => value.format('hh A'), + tooltipGraphDateFormatter: (value) => value.format('hh:mm A'), + }, + last7days: { + stringFrom: 'now-7d/d', + stringTo: 'now/d', + comparison: { + stringFrom: 'now-14d/d', + stringTo: 'now-7d/d', + }, + interval: INTERVAL.days, + videoChartDataKey: 'date', + tooltipDateFormatter: (value) => value.format('MMM DD'), + tooltipGraphDateFormatter: (value) => value.format('MMM DD, YYYY'), + }, + last30days: { + stringFrom: 'now-30d/d', + stringTo: 'now/d', + comparison: { + stringFrom: 'now-60d/d', + stringTo: 'now-30d/d', + }, + interval: INTERVAL.days, + videoChartDataKey: 'date', + tooltipDateFormatter: (value) => value.format('MMM DD'), + tooltipGraphDateFormatter: (value) => value.format('MMM DD, YYYY'), + }, + last90days: { + stringFrom: 'now-90d/d', + stringTo: 'now/d', + comparison: { + stringFrom: 'now-180d/d', + stringTo: 'now-90d/d', + }, + interval: INTERVAL.weeks, + videoChartDataKey: 'weeks', + tooltipDateFormatter: (value) => value.format('MMM DD'), + tooltipGraphDateFormatter: (value) => value.format('MMM DD, YYYY'), + }, + custom: 'Custom', +}; + +export const CALENDAR_FILTER = [ + { label: 'Today', value: CALENDAR_FILTER_VALUE.today }, + { label: 'Yesterday', value: CALENDAR_FILTER_VALUE.yesterday }, + { label: 'Last 7 Days', value: CALENDAR_FILTER_VALUE.last7days }, + { label: 'Last 30 Days', value: CALENDAR_FILTER_VALUE.last30days }, + { label: 'Last 90 Days', value: CALENDAR_FILTER_VALUE.last90days }, + { label: 'Custom', value: CALENDAR_FILTER_VALUE.custom }, +]; + +export const VIDEO_METRICS_TABS = [ + { + key: 'NO_OF_VIEWS', + title: 'Views', + formatter: (value) => abbreviateNumber(value, 2), + dataKey: 'numOfViews', + barColor: 'secondary.light', + }, + { + key: 'ATTENTION_SPAN', + title: 'Attention Span', + formatter: (value) => `${value?.toFixed(1)}%`, + dataKey: 'attentionSpan', + barColor: 'primary.main', + }, + { + key: 'ENG_SCORE', + title: 'Eng. Score', + formatter: (value) => value?.toFixed(2), + dataKey: 'engScore', + barColor: 'warning.main', + domainChart: [0, 'dataMax'], + ticks: [0, 1, 2, 3, 4, 5], + }, +]; + +export const TABS_CONFIG = { + VIDEOS: { + id: 'VIDEOS', + title: 'Top Videos', + label: 'Video Name', + chartLegend: 'This Video', + }, + CHANNELS: { + id: 'CHANNELS', + title: 'Top Channels', + label: 'Channel Name', + chartLegend: 'This Channel', + }, + WATCHES: { + id: 'WATCHES', + title: 'Top Watches', + label: 'Watch Name', + chartLegend: 'This Watch', + }, + HUBS: { + id: 'HUBS', + title: 'Top Hubs', + label: 'Hub Name', + chartLegend: 'This Hub', + }, +}; + +export const TABS = [TABS_CONFIG.VIDEOS, TABS_CONFIG.CHANNELS, TABS_CONFIG.WATCHES, TABS_CONFIG.HUBS]; + +export const TABLE_HEADERS = [ + { + id: 'name', + getLabel: (activeTabId) => TABS_CONFIG[activeTabId]?.label, + sortable: false, + }, + { + id: 'NO_OF_VIEWS', + getLabel: (activeTabId) => (activeTabId === TABS_CONFIG.VIDEOS.id ? 'Views' : 'Views, total'), + sortable: true, + align: 'center', + }, + { + id: 'ATTENTION_SPAN', + getLabel: (activeTabId) => (activeTabId === TABS_CONFIG.VIDEOS.id ? 'Attention Span' : 'Attention Span, avg'), + sortable: true, + align: 'center', + }, + { + id: 'ENG_SCORE', + getLabel: (activeTabId) => (activeTabId === TABS_CONFIG.VIDEOS.id ? 'Eng. Score' : 'Engagement Score, avg'), + sortable: true, + align: 'center', + }, +]; diff --git a/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createMainMetrics.js b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createMainMetrics.js new file mode 100644 index 0000000..e656256 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createMainMetrics.js @@ -0,0 +1,30 @@ +import dayjs from 'dayjs'; + +import { INTERVAL } from '../../constants'; + +const createMainMetrics = ({ metricViews, metricAttentionSpan, metricSearches, interval }) => { + const head = ['Date', 'No. of Views', 'Attention Span', 'Engagement Score', 'No. of Searches']; + + const views = [...metricViews].reverse(); + const attentionSpan = [...metricAttentionSpan].reverse(); + const searches = [...metricSearches].reverse(); + + const body = []; + views.forEach(({ time, value }, index) => { + body.push([ + dayjs(time).format(interval === INTERVAL.hours ? 'HH:MM:SS A' : 'DD-MMM-YY'), + `"${value}"`, + attentionSpan[index]?.value ? `"${attentionSpan[index]?.value}"` : 'N/A', + 'N/A', + searches[index]?.value ? `"${searches[index]?.value}"` : 'N/A', + ]); + }); + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export default createMainMetrics; diff --git a/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopSearches.js b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopSearches.js new file mode 100644 index 0000000..9822256 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopSearches.js @@ -0,0 +1,18 @@ +import { abbreviateNumber } from '@/modules/@common/helpers/number'; + +const createTopSearches = ({ topSearches }) => { + const head = ['Keyword', 'Search %', 'Total Searches']; + + const body = []; + topSearches.forEach(({ query, rate, count }) => { + body.push([`"${query.replace(/"/g, "'")}"`, `${Math.round(rate * 100)}%`, abbreviateNumber(count, 2)]); + }); + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export default createTopSearches; diff --git a/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopVideos.js b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopVideos.js new file mode 100644 index 0000000..e704f06 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/createTopVideos.js @@ -0,0 +1,23 @@ +import { abbreviateNumber } from '@/modules/@common/helpers/number'; + +const createTopVideos = ({ videos }) => { + const head = ['Video Name', 'No. of Views', 'Attention Span', 'Eng. Score ']; + + const body = []; + videos.forEach(({ name, numOfViews, attentionSpan, engScore }) => { + body.push([ + `"${name.replace(/"/g, "'")}"`, + abbreviateNumber(numOfViews, 2), + attentionSpan.toFixed(1), + engScore.toFixed(1), + ]); + }); + + const rows = [head, ...body]; + + const csvContent = rows.map((e) => e.join(',')).join('\n'); + + return csvContent; +}; + +export default createTopVideos; diff --git a/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/index.js b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/index.js new file mode 100644 index 0000000..1eca85f --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/helpers/csv/index.js @@ -0,0 +1,5 @@ +import createMainMetrics from './createMainMetrics'; +import createTopSearches from './createTopSearches'; +import createTopVideos from './createTopVideos'; + +export { createMainMetrics, createTopSearches, createTopVideos }; diff --git a/src/modules/analytics/videoContentPerformance/index.jsx b/anyclip/src/modules/analytics/videoContentPerformance/index.jsx similarity index 100% rename from src/modules/analytics/videoContentPerformance/index.jsx rename to anyclip/src/modules/analytics/videoContentPerformance/index.jsx diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToCsv.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToCsv.js new file mode 100644 index 0000000..3dbc39f --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToCsv.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE } from '../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { createMainMetrics, createTopSearches, createTopVideos } from '../../helpers/csv'; +import * as selectors from '../selectors'; +import { exportToCsvAction } from '../slices'; +import { saveBlobAsFile } from '@/modules/@common/helpers/file-saver'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(exportToCsvAction.type), + switchMap(() => { + const state = state$.value; + + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const topSearches = selectors.topSearchesSelector(state); + const tableItems = selectors.tableItemsSelector(state); + const metricViews = selectors.metricViewsSelector(state); + const metricAttentionSpan = selectors.metricAttentionSpanSelector(state); + const metricSearches = selectors.metricSearchesSelector(state); + + const interval = calendar === CALENDAR_FILTER_VALUE.custom ? calendarCustom.interval : calendar.interval; + + const stream$ = defer(async () => { + const JSZip = (await import('jszip')).default; + + const zip = new JSZip(); + zip.file( + 'mainMetrics.csv', + createMainMetrics({ + metricViews, + metricAttentionSpan, + metricSearches, + interval, + }), + ); + zip.file('topSearches.csv', createTopSearches({ topSearches })); + zip.file('topVideos.csv', createTopVideos({ videos: tableItems })); + return zip.generateAsync({ type: 'blob' }); + }).pipe( + switchMap((content) => { + saveBlobAsFile(content, 'analyticsVideoContentPerformance.zip'); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Download was successful', + }), + ), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToPdf.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToPdf.js new file mode 100644 index 0000000..affd66e --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/exportToPdf.js @@ -0,0 +1,34 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { exportToPdfAction, setFieldAction } from '../slices'; +import { html2pdf } from '@/modules/analytics/common/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(exportToPdfAction.type), + switchMap((action) => { + const stream$ = defer(async () => html2pdf('video-content-performance', action.payload)).pipe( + switchMap(() => + concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Download was successful', + }), + ), + ), + ), + ); + + return concat( + of(setFieldAction({ isExportPdfLoading: true })), + stream$, + of(setFieldAction({ isExportPdfLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getHubOptionsAutocomplete.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getHubOptionsAutocomplete.js new file mode 100644 index 0000000..c8395d0 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getHubOptionsAutocomplete.js @@ -0,0 +1,72 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getHubOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query AnalyticsPublishers( + $watchEnabledOnly: Boolean, + $removeDisabled: Boolean, + $removeLimit: Boolean, + $formEnabledOnly: Boolean + ) { + analyticsPublishers( + watchEnabledOnly: $watchEnabledOnly, + removeDisabled: $removeDisabled, + removeLimit: $removeLimit, + formEnabledOnly: $formEnabledOnly + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { analyticsPublishers } }) => + analyticsPublishers?.records?.map((publisher) => ({ value: publisher.id, label: publisher.name })) ?? []; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + // variables: { + // watchEnabledOnly: false, + // removeDisabled: true, + // removeLimit: true, + // formEnabledOnly: false, + // }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + hubOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + hubOptions: null, + watchOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getItemMetrics.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getItemMetrics.js new file mode 100644 index 0000000..f474641 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getItemMetrics.js @@ -0,0 +1,286 @@ +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import weekOfYearPlugin from 'dayjs/plugin/weekOfYear'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE, TABS_CONFIG } from '../../constants'; +import { WIDGET_DISPLAY_STATE_ERROR } from '@/modules/analytics/common/constants'; + +import * as selectors from '../selectors'; +import { getItemMetricsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); +dayjs.extend(weekOfYearPlugin); + +const videoQuery = ` + query PerformanceVideoMetricsQuery( + $id: String, + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $interval: String, + $metric: String, + $includeArchived: Boolean + ) { + performanceVideoMetrics( + id: $id, + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + interval: $interval, + metric: $metric, + includeArchived: $includeArchived + ) { + data { + time + value + total + } + rid + } + } +`; + +const channelQuery = ` + query PerformanceChannelMetricsQuery( + $id: String, + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $interval: String, + $metric: String, + $includeArchived: Boolean + ) { + performanceChannelMetrics( + id: $id, + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + interval: $interval, + metric: $metric, + includeArchived: $includeArchived + ) { + data { + time + value + total + } + rid + } + } +`; + +const watchQuery = ` + query PerformanceWatchMetricsQuery( + $id: String, + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $interval: String, + $metric: String, + $includeArchived: Boolean + ) { + performanceWatchMetrics( + id: $id, + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + interval: $interval, + metric: $metric, + includeArchived: $includeArchived + ) { + data { + time + value + total + } + rid + } + } +`; + +const siteQuery = ` + query PerformanceSiteMetricsQuery( + $id: String, + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $interval: String, + $metric: String, + $includeArchived: Boolean + ) { + performanceSiteMetrics( + id: $id, + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + interval: $interval, + metric: $metric, + includeArchived: $includeArchived + ) { + data { + time + value + total + } + rid + } + } +`; + +const QUERIES = { + [TABS_CONFIG.VIDEOS.id]: videoQuery, + [TABS_CONFIG.CHANNELS.id]: channelQuery, + [TABS_CONFIG.HUBS.id]: siteQuery, + [TABS_CONFIG.WATCHES.id]: watchQuery, +}; + +export default (action$, state$) => + action$.pipe( + ofType(getItemMetricsAction.type), + filter(({ payload }) => { + const selectedItemId = selectors.selectedItemIdSelector(state$.value); + + return selectedItemId?.length || payload?.length; + }), + switchMap(({ payload }) => { + const state = state$.value; + + const hub = selectors.hubSelector(state); + const watch = selectors.watchSelector(state); + const watchChannel = selectors.watchChannelSelector(state); + const origin = selectors.originSelector(state); + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const selectedItemId = selectors.selectedItemIdSelector(state); + const activeMetric = selectors.activeMetricSelector(state); + const activeTabId = selectors.activeTabIdSelector(state); + const includeArchived = selectors.includeArchivedSelector(state); + + const id = selectedItemId || payload; + + const { comparison, interval, videoChartDataKey, ...dateRange } = calendar; + + let filters = {}; + const timezone = getUserTimezoneSelector(state$.value); + + if (hub) { + filters = { + ...filters, + publisherId: hub.value, + }; + } + + if (watch) { + filters = { + ...filters, + watchId: watch.value, + }; + } + + if (watchChannel) { + filters = { + ...filters, + channelId: watchChannel.value, + }; + } + + if (origin !== 'ALL') { + filters = { + ...filters, + origin, + }; + } + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + filters = { + ...filters, + dateRange: { + stringFrom: calendarCustom.dateStart, + stringTo: calendarCustom.dateEnd, + timezone, + }, + interval: calendarCustom.interval, + }; + } else { + filters = { + ...filters, + dateRange: { + ...dateRange, + timezone, + }, + interval, + }; + } + + const stream$ = gqlRequest({ + query: QUERIES[activeTabId], + variables: { + id, + includeArchived, + ...filters, + metric: activeMetric, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const response = { + ...data.performanceVideoMetrics, + ...data.performanceChannelMetrics, + ...data.performanceWatchMetrics, + ...data.performanceSiteMetrics, + }; + + actions.push( + of( + setFieldAction({ + itemMetrics: response.data?.map((item) => ({ + ...item, + date: dayjs(item.time).tz(timezone).format('MMM DD'), + hours: dayjs(item.time).tz(timezone).format('h A'), + weeks: `Week ${dayjs(item.time).tz(timezone).week()}`, + })), + }), + ), + ); + } else { + actions.push( + of( + setFieldAction({ + displayStateMainMetrics: WIDGET_DISPLAY_STATE_ERROR, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTopSearches.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTopSearches.js new file mode 100644 index 0000000..f190996 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTopSearches.js @@ -0,0 +1,153 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE } from '../../constants'; +import { + WIDGET_DISPLAY_STATE_DATA, + WIDGET_DISPLAY_STATE_ERROR, + WIDGET_DISPLAY_STATE_NO_DATA, +} from '@/modules/analytics/common/constants'; + +import * as selectors from '../selectors'; +import { getPerformanceTopSearchesAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query PerformanceTopSearchesQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $size: Int, + $includeArchived: Boolean + ) { + performanceTopSearches( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + size: $size, + includeArchived: $includeArchived + ) { + total + searches { + query + count + rate + } + rid + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPerformanceTopSearchesAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap(() => { + const state = state$.value; + + const hub = selectors.hubSelector(state); + const watch = selectors.watchSelector(state); + const watchChannel = selectors.watchChannelSelector(state); + const origin = selectors.originSelector(state); + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const includeArchived = selectors.includeArchivedSelector(state); + + const { comparison, interval, videoChartDataKey, ...dateRange } = calendar; + + let filters = {}; + const timezone = getUserTimezoneSelector(state$.value); + + if (hub) { + filters = { + ...filters, + publisherId: hub.value, + }; + } + + if (watch) { + filters = { + ...filters, + watchId: watch.value, + }; + } + + if (watchChannel) { + filters = { + ...filters, + channelId: watchChannel.value, + }; + } + + if (origin !== 'ALL') { + filters = { + ...filters, + origin, + }; + } + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + filters = { + ...filters, + dateRange: { + stringFrom: calendarCustom.dateStart, + stringTo: calendarCustom.dateEnd, + timezone, + }, + }; + } else { + filters = { + ...filters, + dateRange: { + ...dateRange, + timezone, + }, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + includeArchived, + size: 15, + ...filters, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const topSearches = data?.performanceTopSearches?.searches ?? []; + actions.push( + of( + setFieldAction({ + topSearches, + displayStateTopSearches: topSearches.length + ? WIDGET_DISPLAY_STATE_DATA + : WIDGET_DISPLAY_STATE_NO_DATA, + }), + ), + ); + } else { + actions.push( + of( + setFieldAction({ + displayStateTopSearches: WIDGET_DISPLAY_STATE_ERROR, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotal.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotal.js new file mode 100644 index 0000000..a3268bb --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotal.js @@ -0,0 +1,172 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE } from '../../constants'; +import { + WIDGET_DISPLAY_STATE_DATA, + WIDGET_DISPLAY_STATE_ERROR, + WIDGET_DISPLAY_STATE_NO_DATA, +} from '@/modules/analytics/common/constants'; + +import * as selectors from '../selectors'; +import { getPerformanceVideosTotalAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query PerformanceVideosTotalQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $dateRangeComparison: AnalyticsDateRangeInputType, + $includeArchived: Boolean + ) { + performanceVideoTotal( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + dateRangeComparison: $dateRangeComparison, + includeArchived: $includeArchived + ) { + metrics { + total { + numOfViews + attentionSpan + engScore + searches + } + comparison { + numOfViews + attentionSpan + engScore + searches + } + hideComparisonNumbers + } + rid + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPerformanceVideosTotalAction.type), + switchMap(() => { + const state = state$.value; + + const hub = selectors.hubSelector(state); + const watch = selectors.watchSelector(state); + const watchChannel = selectors.watchChannelSelector(state); + const origin = selectors.originSelector(state); + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const includeArchived = selectors.includeArchivedSelector(state); + + const { comparison, interval, videoChartDataKey, ...dateRange } = calendar; + + let filters = {}; + const timezone = getUserTimezoneSelector(state$.value); + + if (hub) { + filters = { + ...filters, + publisherId: hub.value, + }; + } + + if (watch) { + filters = { + ...filters, + watchId: watch.value, + }; + } + + if (watchChannel) { + filters = { + ...filters, + channelId: watchChannel.value, + }; + } + + if (origin !== 'ALL') { + filters = { + ...filters, + origin, + }; + } + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + filters = { + ...filters, + dateRange: { + stringFrom: calendarCustom.dateStart, + stringTo: calendarCustom.dateEnd, + timezone, + }, + dateRangeComparison: { + stringFrom: calendarCustom.comparison.dateStart, + stringTo: calendarCustom.comparison.dateEnd, + timezone, + }, + }; + } else { + filters = { + ...filters, + dateRange: { + ...dateRange, + timezone, + }, + dateRangeComparison: { + ...comparison, + timezone, + }, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + includeArchived, + ...filters, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length && data?.performanceVideoTotal?.metrics) { + actions.push( + of( + setFieldAction({ + metrics: data.performanceVideoTotal.metrics, + displayStateMainMetrics: + data.performanceVideoTotal.metrics?.total?.numOfViews !== null || + data.performanceVideoTotal.metrics?.total?.attentionSpan !== null || + data.performanceVideoTotal.metrics?.total?.engScore !== null || + data.performanceVideoTotal.metrics?.total?.searches !== null + ? WIDGET_DISPLAY_STATE_DATA + : WIDGET_DISPLAY_STATE_NO_DATA, + }), + ), + ); + } else { + actions.push( + of( + setFieldAction({ + displayStateMainMetrics: WIDGET_DISPLAY_STATE_ERROR, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotalMetric.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotalMetric.js new file mode 100644 index 0000000..de72557 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getPerformanceVideosTotalMetric.js @@ -0,0 +1,153 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE } from '../../constants'; +import { + WIDGET_DISPLAY_STATE_DATA, + WIDGET_DISPLAY_STATE_ERROR, + WIDGET_DISPLAY_STATE_NO_DATA, +} from '@/modules/analytics/common/constants'; + +import * as selectors from '../selectors'; +import { getPerformanceVideosTotalMetricAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query PerformanceVideoTotalMetricQuery( + $metric: String, + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $interval: String, + $includeArchived: Boolean + ) { + performanceVideoTotalMetric( + metric: $metric, + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + interval: $interval, + includeArchived: $includeArchived + ) { + data { + time + value + } + rid + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPerformanceVideosTotalMetricAction.type), + mergeMap(({ payload: { metric, key } }) => { + const state = state$.value; + + const hub = selectors.hubSelector(state); + const watch = selectors.watchSelector(state); + const watchChannel = selectors.watchChannelSelector(state); + const origin = selectors.originSelector(state); + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const includeArchived = selectors.includeArchivedSelector(state); + + const { comparison, interval, videoChartDataKey, ...dateRange } = calendar; + + let filters = {}; + const timezone = getUserTimezoneSelector(state$.value); + + if (hub) { + filters = { + ...filters, + publisherId: hub.value, + }; + } + + if (watch) { + filters = { + ...filters, + watchId: watch.value, + }; + } + + if (watchChannel) { + filters = { + ...filters, + channelId: watchChannel.value, + }; + } + + if (origin !== 'ALL') { + filters = { + ...filters, + origin, + }; + } + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + filters = { + ...filters, + dateRange: { + stringFrom: calendarCustom.dateStart, + stringTo: calendarCustom.dateEnd, + timezone, + }, + interval: calendarCustom.interval, + }; + } else { + filters = { + ...filters, + dateRange: { + ...dateRange, + timezone, + }, + interval, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + includeArchived, + metric, + ...filters, + }, + }).pipe( + switchMap(({ data: { performanceVideoTotalMetric }, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setFieldAction({ + [key]: performanceVideoTotalMetric.data, + displayStateMainMetrics: performanceVideoTotalMetric.data?.length + ? WIDGET_DISPLAY_STATE_DATA + : WIDGET_DISPLAY_STATE_NO_DATA, + }), + ), + ); + } else { + actions.push( + of( + setFieldAction({ + displayStateMainMetrics: WIDGET_DISPLAY_STATE_ERROR, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getSearchOptionsAutocomplete.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getSearchOptionsAutocomplete.js new file mode 100644 index 0000000..9d5dcb0 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getSearchOptionsAutocomplete.js @@ -0,0 +1,228 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE, TABS_CONFIG } from '../../constants'; + +import * as selectors from '../selectors'; +import { getSearchOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const videoQuery = ` + query PerformanceVideoTopAutocompleteQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $size: Int, + $prefix: String, + $includeArchived: Boolean + ) { + performanceVideoTopAutocomplete( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + size: $size, + prefix: $prefix, + includeArchived: $includeArchived + ) { + names + rid + } + } +`; + +const channelQuery = ` + query PerformanceChannelTopAutocompleteQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $size: Int, + $prefix: String, + $includeArchived: Boolean + ) { + performanceChannelTopAutocomplete( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + size: $size, + prefix: $prefix, + includeArchived: $includeArchived + ) { + names + rid + } + } +`; + +const siteQuery = ` + query PerformanceSiteTopAutocompleteQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $size: Int, + $prefix: String, + $includeArchived: Boolean + ) { + performanceSiteTopAutocomplete( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + size: $size, + prefix: $prefix, + includeArchived: $includeArchived + ) { + names + rid + } + } +`; + +const watchQuery = ` + query PerformanceWatchTopAutocompleteQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $size: Int, + $prefix: String, + $includeArchived: Boolean + ) { + performanceWatchTopAutocomplete( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + size: $size, + prefix: $prefix, + includeArchived: $includeArchived + ) { + names + rid + } + } +`; + +const QUERIES = { + [TABS_CONFIG.VIDEOS.id]: videoQuery, + [TABS_CONFIG.CHANNELS.id]: channelQuery, + [TABS_CONFIG.HUBS.id]: siteQuery, + [TABS_CONFIG.WATCHES.id]: watchQuery, +}; + +export default (action$, state$) => + action$.pipe( + ofType(getSearchOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap(({ payload = '' }) => { + const state = state$.value; + + const hub = selectors.hubSelector(state); + const watch = selectors.watchSelector(state); + const watchChannel = selectors.watchChannelSelector(state); + const origin = selectors.originSelector(state); + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const activeTabId = selectors.activeTabIdSelector(state); + + const { comparison, interval, videoChartDataKey, ...dateRange } = calendar; + + let filters = {}; + const timezone = getUserTimezoneSelector(state$.value); + + if (hub) { + filters = { + ...filters, + publisherId: hub.value, + }; + } + + if (watch) { + filters = { + ...filters, + watchId: watch.value, + }; + } + + if (watchChannel) { + filters = { + ...filters, + channelId: watchChannel.value, + }; + } + + if (origin !== 'ALL') { + filters = { + ...filters, + origin, + }; + } + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + filters = { + ...filters, + dateRange: { + stringFrom: calendarCustom.dateStart, + stringTo: calendarCustom.dateEnd, + timezone, + }, + }; + } else { + filters = { + ...filters, + dateRange: { + ...dateRange, + timezone, + }, + }; + } + + const stream$ = gqlRequest({ + query: QUERIES[activeTabId], + variables: { + size: 15, + prefix: payload, + ...filters, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const response = { + ...data.performanceVideoTopAutocomplete, + ...data.performanceChannelTopAutocomplete, + ...data.performanceWatchTopAutocomplete, + ...data.performanceSiteTopAutocomplete, + }; + + actions.push( + of( + setFieldAction({ + searchOptions: response.names?.length ? response.names.map((i) => ({ label: i, value: i })) : [], + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getTableItems.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getTableItems.js new file mode 100644 index 0000000..d366fe2 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getTableItems.js @@ -0,0 +1,371 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { CALENDAR_FILTER_VALUE, TABS_CONFIG } from '../../constants'; +import { + WIDGET_DISPLAY_STATE_DATA, + WIDGET_DISPLAY_STATE_ERROR, + WIDGET_DISPLAY_STATE_NO_DATA, +} from '@/modules/analytics/common/constants'; + +import * as selectors from '../selectors'; +import { getItemMetricsAction, getTableItemsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const videoQuery = ` + query PerformanceTopVideosQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $dateRangeComparison: AnalyticsDateRangeInputType, + $sort: AnalyticsSortingInputType, + $total: Boolean, + $query: String, + $size: Int, + $exactMatch: Boolean, + $includeArchived: Boolean + ) { + performanceTopVideos( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + dateRangeComparison: $dateRangeComparison, + sort: $sort, + query: $query, + total: $total, + size: $size, + exactMatch: $exactMatch, + includeArchived: $includeArchived + ) { + total + videos { + uuid + name + thumbnail + numOfViews + attentionSpan + engScore + duration + status + thumbnails { + file + width + height + } + } + rid + } + } +`; + +const channelQuery = ` + query PerformanceTopChannelsQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $dateRangeComparison: AnalyticsDateRangeInputType, + $sort: AnalyticsSortingInputType, + $total: Boolean, + $query: String, + $size: Int, + $exactMatch: Boolean, + $includeArchived: Boolean + ) { + performanceTopChannels( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + dateRangeComparison: $dateRangeComparison, + sort: $sort, + query: $query, + total: $total, + size: $size, + exactMatch: $exactMatch, + includeArchived: $includeArchived + ) { + total + channels { + channelId + channelName + numOfViews + attentionSpan + engScore + } + rid + } + } +`; + +const siteQuery = ` + query PerformanceTopSitesQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $dateRangeComparison: AnalyticsDateRangeInputType, + $sort: AnalyticsSortingInputType, + $total: Boolean, + $query: String, + $size: Int, + $exactMatch: Boolean, + $includeArchived: Boolean + ) { + performanceTopSites( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + dateRangeComparison: $dateRangeComparison, + sort: $sort, + query: $query, + total: $total, + size: $size, + exactMatch: $exactMatch, + includeArchived: $includeArchived + ) { + total + sites { + site + siteName + numOfViews + attentionSpan + engScore + } + rid + } + } +`; + +const watchQuery = ` + query PerformanceTopWatchesQuery( + $publisherId: Int, + $watchId: Int, + $channelId: Int, + $origin: String, + $dateRange: AnalyticsDateRangeInputType, + $dateRangeComparison: AnalyticsDateRangeInputType, + $sort: AnalyticsSortingInputType, + $total: Boolean, + $query: String, + $size: Int, + $exactMatch: Boolean, + $includeArchived: Boolean + ) { + performanceTopWatches( + publisherId: $publisherId, + watchId: $watchId, + channelId: $channelId, + origin: $origin, + dateRange: $dateRange, + dateRangeComparison: $dateRangeComparison, + sort: $sort, + query: $query, + total: $total, + size: $size, + exactMatch: $exactMatch, + includeArchived: $includeArchived + ) { + total + watches { + watchId + watchName + numOfViews + attentionSpan + engScore + } + rid + } + } +`; + +const QUERIES = { + [TABS_CONFIG.VIDEOS.id]: videoQuery, + [TABS_CONFIG.CHANNELS.id]: channelQuery, + [TABS_CONFIG.HUBS.id]: siteQuery, + [TABS_CONFIG.WATCHES.id]: watchQuery, +}; + +export default (action$, state$) => + action$.pipe( + ofType(getTableItemsAction.type), + debounce((action) => timer(action.payload?.query?.length ? 1000 : 0)), + switchMap(({ payload = {} }) => { + const { total = true, exactMatch = false } = payload; + + const state = state$.value; + + const hub = selectors.hubSelector(state); + const watch = selectors.watchSelector(state); + const watchChannel = selectors.watchChannelSelector(state); + const origin = selectors.originSelector(state); + const calendar = selectors.calendarSelector(state); + const calendarCustom = selectors.calendarCustomSelector(state); + const activeTabId = selectors.activeTabIdSelector(state); + const includeArchived = selectors.includeArchivedSelector(state); + const search = selectors.searchSelector(state); + const sortBy = selectors.sortBySelector(state); + const sortOrder = selectors.sortOrderSelector(state); + + const { comparison, interval, videoChartDataKey, ...dateRange } = calendar; + + let filters = {}; + const timezone = getUserTimezoneSelector(state$.value); + + if (hub) { + filters = { + ...filters, + publisherId: hub.value, + }; + } + + if (watch) { + filters = { + ...filters, + watchId: watch.value, + }; + } + + if (watchChannel) { + filters = { + ...filters, + channelId: watchChannel.value, + }; + } + + if (origin !== 'ALL') { + filters = { + ...filters, + origin, + }; + } + + if (search?.value) { + filters = { + ...filters, + query: search.value, + }; + } + + if (calendar === CALENDAR_FILTER_VALUE.custom) { + filters = { + ...filters, + dateRange: { + stringFrom: calendarCustom.dateStart, + stringTo: calendarCustom.dateEnd, + timezone, + }, + }; + } else { + filters = { + ...filters, + dateRange: { + ...dateRange, + timezone, + }, + }; + } + + const stream$ = gqlRequest({ + query: QUERIES[activeTabId], + variables: { + total, + exactMatch, + includeArchived, + size: 20, + ...filters, + sort: { + by: sortBy, + order: sortOrder, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (data && !errors.length) { + const response = { + ...data.performanceTopVideos, + ...data.performanceTopChannels, + ...data.performanceTopSites, + ...data.performanceTopWatches, + }; + + let items = []; + + if (activeTabId === TABS_CONFIG.VIDEOS.id) { + items = response.videos; + } else if (activeTabId === TABS_CONFIG.CHANNELS.id) { + items = response.channels?.map(({ channelId, channelName, ...other }) => ({ + uuid: channelId, + name: channelName, + ...other, + })); + } else if (activeTabId === TABS_CONFIG.HUBS.id) { + items = response.sites?.map(({ site, siteName, ...other }) => ({ + uuid: site, + name: siteName, + ...other, + })); + } else if (activeTabId === TABS_CONFIG.WATCHES.id) { + items = response.watches?.map(({ watchId, watchName, ...other }) => ({ + uuid: watchId, + name: watchName, + ...other, + })); + } + + if (total && activeTabId === TABS_CONFIG.VIDEOS.id) { + actions.push( + of( + setFieldAction({ + total: response.total, + }), + ), + ); + } + + const firstItemId = items?.[0]?.uuid ?? null; + + actions.push( + of( + setFieldAction({ + tableItems: items, + selectedItemId: firstItemId, + displayStateTopVideos: + !items?.length && !search?.value ? WIDGET_DISPLAY_STATE_NO_DATA : WIDGET_DISPLAY_STATE_DATA, + }), + ), + ); + + if (firstItemId) { + actions.push(of(getItemMetricsAction(firstItemId))); + } + } else { + actions.push( + of( + setFieldAction({ + displayStateTopVideos: WIDGET_DISPLAY_STATE_ERROR, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getWatchOptionsAutocomplete.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getWatchOptionsAutocomplete.js new file mode 100644 index 0000000..f4ac2a9 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/getWatchOptionsAutocomplete.js @@ -0,0 +1,99 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getWatchOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query AnalyticsWatchesQuery( + $page: Int, + $pageSize: Int, + $publisherIds: [Int], + $search: String, + $sortBy: String, + $sortOrder: String + ) { + analyticsWatches( + page: $page, + pageSize: $pageSize, + publisherIds: $publisherIds, + search: $search, + sortBy: $sortBy, + sortOrder: $sortOrder + ) { + records { + id + title + watchChannels { + id + title + } + } + } + } +`; + +const toOptions = (entity, extra = {}) => ({ + value: entity.id, + label: entity.title, + ...extra, +}); +const getResponse = ({ + data: { + analyticsWatches: { records }, + }, +}) => + records.map((watch) => + toOptions(watch, { + channels: watch.watchChannels.map((channel) => toOptions(channel)), + }), + ); + +export default (action$, state$) => + action$.pipe( + ofType(getWatchOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + + const hub = selectors.hubSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + page: 1, + pageSize: 30, + publisherIds: [hub.value], + search: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + watchOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + watchOptions: null, + watchChannelOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/index.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/index.js new file mode 100644 index 0000000..924ab67 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/epics/index.js @@ -0,0 +1,25 @@ +import { combineEpics } from 'redux-observable'; + +import exportToCsv from './exportToCsv'; +import exportToPdf from './exportToPdf'; +import getHubOptionsAutocomplete from './getHubOptionsAutocomplete'; +import getItemMetrics from './getItemMetrics'; +import getPerformanceTopSearches from './getPerformanceVideosTopSearches'; +import getPerformanceVideosTotal from './getPerformanceVideosTotal'; +import getPerformanceVideosTotalMetric from './getPerformanceVideosTotalMetric'; +import getSearchOptionsAutocomplete from './getSearchOptionsAutocomplete'; +import getTableItems from './getTableItems'; +import getWatchOptionsAutocomplete from './getWatchOptionsAutocomplete'; + +export default combineEpics( + getHubOptionsAutocomplete, + getWatchOptionsAutocomplete, + getTableItems, + getPerformanceTopSearches, + getSearchOptionsAutocomplete, + getPerformanceVideosTotal, + getPerformanceVideosTotalMetric, + getItemMetrics, + exportToCsv, + exportToPdf, +); diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/selectors/index.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/selectors/index.js new file mode 100644 index 0000000..b908a5d --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/selectors/index.js @@ -0,0 +1,52 @@ +import { WIDGET_DISPLAY_STATE_ERROR, WIDGET_DISPLAY_STATE_NO_DATA } from '@/modules/analytics/common/constants'; + +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const hubSelector = (state$) => state$[nameSpace].hub; +export const hubOptionsSelector = (state$) => state$[nameSpace].hubOptions; +export const watchSelector = (state$) => state$[nameSpace].watch; +export const watchOptionsSelector = (state$) => state$[nameSpace].watchOptions; +export const watchChannelSelector = (state$) => state$[nameSpace].watchChannel; +export const watchChannelOptionsSelector = (state$) => state$[nameSpace].watchChannelOptions; +export const originSelector = (state$) => state$[nameSpace].origin; +export const calendarSelector = (state$) => state$[nameSpace].calendar; +export const calendarCustomSelector = (state$) => state$[nameSpace].calendarCustom; +export const includeArchivedSelector = (state$) => state$[nameSpace].includeArchived; +export const activeTabIdSelector = (state$) => state$[nameSpace].activeTabId; +export const totalSelector = (state$) => state$[nameSpace].total; +export const topSearchesSelector = (state$) => state$[nameSpace].topSearches; +export const tableItemsSelector = (state$) => state$[nameSpace].tableItems; +export const searchSelector = (state$) => state$[nameSpace].search; +export const searchOptionsSelector = (state$) => state$[nameSpace].searchOptions; +export const sortBySelector = (state$) => state$[nameSpace].sortBy; +export const sortOrderSelector = (state$) => state$[nameSpace].sortOrder; +export const metricsSelector = (state$) => state$[nameSpace].metrics; +export const metricViewsSelector = (state$) => state$[nameSpace].metricViews; +export const metricAttentionSpanSelector = (state$) => state$[nameSpace].metricAttentionSpan; +export const metricSearchesSelector = (state$) => state$[nameSpace].metricSearches; +export const selectedItemIdSelector = (state$) => state$[nameSpace].selectedItemId; +export const activeMetricSelector = (state$) => state$[nameSpace].activeMetric; +export const itemMetricsSelector = (state$) => state$[nameSpace].itemMetrics; +export const displayStateMainMetricsSelector = (state$) => state$[nameSpace].displayStateMainMetrics; +export const displayStateTopSearchesSelector = (state$) => state$[nameSpace].displayStateTopSearches; +export const displayStateTopVideosSelector = (state$) => state$[nameSpace].displayStateTopVideos; +export const isExportCsvLoadingSelector = (state$) => state$[nameSpace].isExportCsvLoading; +export const isExportPdfLoadingSelector = (state$) => state$[nameSpace].isExportPdfLoading; + +export const getGlobalNoDataState = (state$) => { + const state = state$[nameSpace]; + + return [state.displayStateMainMetrics, state.displayStateTopSearches, state.displayStateTopVideos].every( + (o) => o === WIDGET_DISPLAY_STATE_NO_DATA, + ); +}; + +export const getGlobalErrorState = (state$) => { + const state = state$[nameSpace]; + + return [state.displayStateMainMetrics, state.displayStateTopSearches, state.displayStateTopVideos].every( + (o) => o === WIDGET_DISPLAY_STATE_ERROR, + ); +}; diff --git a/anyclip/src/modules/analytics/videoContentPerformance/redux/slices/index.js b/anyclip/src/modules/analytics/videoContentPerformance/redux/slices/index.js new file mode 100644 index 0000000..14b7149 --- /dev/null +++ b/anyclip/src/modules/analytics/videoContentPerformance/redux/slices/index.js @@ -0,0 +1,92 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { CALENDAR_FILTER_VALUE, TABS_CONFIG, VIDEO_METRICS_TABS } from '../../constants'; +import { WIDGET_DISPLAY_STATE_NO_DATA } from '@/modules/analytics/common/constants'; + +const initialState = { + // filters + hub: null, + hubOptions: null, + watch: null, + watchOptions: null, + watchChannel: null, + watchChannelOptions: null, + origin: 'ALL', + calendar: CALENDAR_FILTER_VALUE.last30days, + calendarCustom: { + dateStart: null, + dateEnd: null, + comparison: { + dateStart: null, + dateEnd: null, + }, + interval: 'DAYS', + }, + includeArchived: false, + activeTabId: TABS_CONFIG.VIDEOS.id, + total: 0, + topSearches: [], + tableItems: [], + search: null, + searchOptions: [], + sortBy: 'NO_OF_VIEWS', + sortOrder: 'DESC', + metrics: { + total: null, + comparison: null, + }, + metricViews: [], + metricAttentionSpan: [], + metricSearches: [], + selectedItemId: null, + activeMetric: VIDEO_METRICS_TABS[0].key, + itemMetrics: [], + + // displayState, + displayStateMainMetrics: WIDGET_DISPLAY_STATE_NO_DATA, + displayStateTopSearches: WIDGET_DISPLAY_STATE_NO_DATA, + displayStateTopVideos: WIDGET_DISPLAY_STATE_NO_DATA, + + // exports loading + isExportCsvLoading: false, + isExportPdfLoading: false, +}; + +export const slice = createSlice({ + name: '@@analyticsVideoContentPerformance/ANALYTICS_VIDEO_CONTENT_PERFORMANCE', + initialState, + + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + getHubOptionsAction: (state) => state, + getWatchOptionsAction: (state) => state, + getTableItemsAction: (state) => state, + getPerformanceTopSearchesAction: (state) => state, + getSearchOptionsAction: (state) => state, + getPerformanceVideosTotalAction: (state) => state, + getPerformanceVideosTotalMetricAction: (state) => state, + getItemMetricsAction: (state) => state, + exportToCsvAction: (state) => state, + exportToPdfAction: (state) => state, + }, +}); + +export const { + setFieldAction, + getHubOptionsAction, + getWatchOptionsAction, + getTableItemsAction, + getPerformanceTopSearchesAction, + getSearchOptionsAction, + getPerformanceVideosTotalAction, + getPerformanceVideosTotalMetricAction, + getItemMetricsAction, + exportToCsvAction, + exportToPdfAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.jsx b/anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.jsx new file mode 100644 index 0000000..bc4019b --- /dev/null +++ b/anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.jsx @@ -0,0 +1,156 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { VisibilityOffRounded, VisibilityRounded } from '@mui/icons-material'; + +import { passwordRegExp } from '@/modules/@common/constants/validation'; +import { CREATE_PASSWORD_PAGE } from '@/modules/@common/router/constants'; + +import { emailSelector, password2Selector, passwordSelector } from './redux/selectors'; +import { setFieldAction, submitPasswordAction, verifyCodeAction } from './redux/slices'; +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; +import { Button, IconButton, InputAdornment, Stack, TextField } from '@/mui/components'; +import { CustomKeywords, CustomPeople } from '@/mui/components/CustomIcon'; + +import styles from './CreateResetPassword.module.scss'; + +function CreateResetPassword() { + const dispatch = useDispatch(); + const router = useRouter(); + const email = useSelector(emailSelector); + const password = useSelector(passwordSelector); + const password2 = useSelector(password2Selector); + const isFormLoading = useSelector(isFormLoadingSelector); + const [passwordError, setPasswordError] = useState(''); + const [showPassword, togglePassword] = useState(false); + + const isCreatePage = router.route === CREATE_PASSWORD_PAGE.path; + + useEffect(() => { + dispatch( + verifyCodeAction({ + token: router.query.token, + }), + ); + }, []); + + const disabledSubmit = isFormLoading || !password || !password2; + + const onSubmit = (event) => { + event.preventDefault(); + + if (password !== password2) { + setPasswordError('Passwords don`t match!'); + } else if (!passwordRegExp.test(password)) { + setPasswordError( + 'Password does not meet password policy. ' + + 'Use at least 8 characters and a mix of upper/lowercase letters, numbers, and symbols', + ); + } else { + dispatch(submitPasswordAction()); + } + }; + + return ( + + + + + + ), + }} + /> + + dispatch(setFieldAction({ password: target.value }))} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + + togglePassword((e) => !e)}> + {showPassword ? : } + + + ), + }} + /> + dispatch(setFieldAction({ password2: target.value }))} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + + togglePassword((e) => !e)}> + {showPassword ? : } + + + ), + }} + /> + +
    + +
    + + } + /> + ); +} + +export default CreateResetPassword; diff --git a/anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.module.scss b/anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.module.scss new file mode 100644 index 0000000..6e0ce30 --- /dev/null +++ b/anyclip/src/modules/auth/CreateResetPassword/CreateResetPassword.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"HelperText":"CreateResetPassword_HelperText___CkOZ"}; \ No newline at end of file diff --git a/anyclip/src/modules/auth/CreateResetPassword/redux/epics/index.js b/anyclip/src/modules/auth/CreateResetPassword/redux/epics/index.js new file mode 100644 index 0000000..96d753a --- /dev/null +++ b/anyclip/src/modules/auth/CreateResetPassword/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import submitPassword from './submitPassword'; +import verifyCode from './verifyCode'; + +export default combineEpics(verifyCode, submitPassword); diff --git a/anyclip/src/modules/auth/CreateResetPassword/redux/epics/submitPassword.js b/anyclip/src/modules/auth/CreateResetPassword/redux/epics/submitPassword.js new file mode 100644 index 0000000..bfbba65 --- /dev/null +++ b/anyclip/src/modules/auth/CreateResetPassword/redux/epics/submitPassword.js @@ -0,0 +1,96 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; +import StringCrypto from 'string-crypto'; + +import { PASS_CRYPTO_SALT } from '@/modules/@common/envs/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; +import { TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE } from '@/modules/@common/token/constants'; +import { AUTH_URL } from '@/modules/auth/common/constants/auth'; + +import { submitPasswordAction } from '../slices'; +import { getSessionStorageItem } from '@/modules/@common/storage/helpers'; +import { setTokenCookieName, setTokenCookieValue } from '@/modules/@common/token/helpers'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { + codeSelector, + emailSelector, + password2Selector, + passwordSelector, +} from '@/modules/auth/CreateResetPassword/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(submitPasswordAction.type), + switchMap(() => { + const email = emailSelector(state$.value); + const code = codeSelector(state$.value); + const password = passwordSelector(state$.value); + const password2 = password2Selector(state$.value); + + const { encryptString } = new StringCrypto(); + + const passwordHash = encryptString(password, PASS_CRYPTO_SALT); + const passwordHash2 = encryptString(password2, PASS_CRYPTO_SALT); + + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/auth/reset-password`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + email, + resetCode: code, + password: passwordHash, + password2: passwordHash2, + }), + }).pipe( + switchMap(({ response }) => { + const redirectUrl = getSessionStorageItem(REDIRECT_URL_STORAGE_NAME) || window.location.origin; + + if (response?.cookieName) { + setTokenCookieName(response.cookieName); + setTokenCookieValue(response.cookieValue); + } + + return ajax({ + method: 'POST', + url: AUTH_URL, + crossDomain: true, + withCredentials: true, + body: { + token: response.token, + [TOKEN_COOKIE_NAME]: response?.cookieName, + [TOKEN_COOKIE_VALUE]: response?.cookieValue, + }, + }).pipe( + switchMap(() => { + window.location.replace(redirectUrl); + + return EMPTY; + }), + ); + }), + catchError(({ response }) => + concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + of(setFormLoadingAction(false)), + ), + ), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/CreateResetPassword/redux/epics/verifyCode.js b/anyclip/src/modules/auth/CreateResetPassword/redux/epics/verifyCode.js new file mode 100644 index 0000000..51a0d10 --- /dev/null +++ b/anyclip/src/modules/auth/CreateResetPassword/redux/epics/verifyCode.js @@ -0,0 +1,68 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap, tap } from 'rxjs/operators'; + +import { ERROR_UI_LINK_WAS_EXPIRED } from '@/modules/@common/constants/errors'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { USER_AUTH_ERROR_PAGE } from '@/modules/@common/router/constants'; + +import { setFieldAction, verifyCodeAction } from '../slices'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(verifyCodeAction.type), + switchMap(({ payload }) => { + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/auth/get-reset-password-preload-data`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + token: payload.token, + }), + }).pipe( + switchMap(({ response }) => + concat( + of( + setFieldAction({ + email: response.email, + code: response.resetCode, + }), + ), + of(setFormLoadingAction(false)), + ), + ), + catchError(({ response }) => + concat( + of({ type: '@#' }).pipe( + tap(() => + Router.push({ + pathname: USER_AUTH_ERROR_PAGE.path, + query: { + error: ERROR_UI_LINK_WAS_EXPIRED, + }, + }), + ), + ), + of(setFormLoadingAction(false)), + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + ), + ), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/CreateResetPassword/redux/selectors/index.js b/anyclip/src/modules/auth/CreateResetPassword/redux/selectors/index.js new file mode 100644 index 0000000..e71ad03 --- /dev/null +++ b/anyclip/src/modules/auth/CreateResetPassword/redux/selectors/index.js @@ -0,0 +1,8 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const emailSelector = (state$) => state$[nameSpace].email; +export const codeSelector = (state$) => state$[nameSpace].code; +export const passwordSelector = (state$) => state$[nameSpace].password; +export const password2Selector = (state$) => state$[nameSpace].password2; diff --git a/anyclip/src/modules/auth/CreateResetPassword/redux/slices/index.js b/anyclip/src/modules/auth/CreateResetPassword/redux/slices/index.js new file mode 100644 index 0000000..5ec6da7 --- /dev/null +++ b/anyclip/src/modules/auth/CreateResetPassword/redux/slices/index.js @@ -0,0 +1,24 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + email: '', + code: '', + password: '', + password2: '', +}; + +export const slice = createSlice({ + name: '@@auth/CREATE_RESET_PASSWORD', + initialState, + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + verifyCodeAction: (state) => state, + submitPasswordAction: (state) => state, + }, +}); + +export const { setFieldAction, verifyCodeAction, submitPasswordAction } = slice.actions; diff --git a/anyclip/src/modules/auth/ForgotPassword/ForgotPassword.jsx b/anyclip/src/modules/auth/ForgotPassword/ForgotPassword.jsx new file mode 100644 index 0000000..aadf4a3 --- /dev/null +++ b/anyclip/src/modules/auth/ForgotPassword/ForgotPassword.jsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { emailRegExp } from '@/modules/@common/constants/validation'; + +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; +import { resetPasswordEventAction } from '@/modules/auth/Login/redux/slices'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; +import { Button, InputAdornment, Stack, TextField } from '@/mui/components'; +import { CustomPeople } from '@/mui/components/CustomIcon'; + +function ForgotPassword() { + const dispatch = useDispatch(); + const [email, setEmail] = useState(''); + const isFormLoading = useSelector(isFormLoadingSelector); + const [error, setError] = useState(false); + const [message, setMessage] = useState(''); + const [isDisableControllers, disableControllers] = useState(false); + + const onSubmit = (event) => { + event.preventDefault(); + + const emailTrimmed = email.trim(); + + setEmail(emailTrimmed); + + if (!emailTrimmed) { + setError(true); + setMessage('The email cannot be empty'); + } else if (!emailRegExp.test(emailTrimmed)) { + setError(true); + setMessage('The email you entered is incorrect'); + } else { + setError(false); + setMessage('If your email is valid you will be sent a reset password link'); + dispatch( + resetPasswordEventAction({ + email: emailTrimmed, + }), + ); + disableControllers(true); + } + }; + + return ( + + setEmail(event.target.value)} + disabled={isFormLoading || isDisableControllers} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> +
    + +
    + + } + /> + ); +} + +export default ForgotPassword; diff --git a/anyclip/src/modules/auth/GuestActivation/GuestActivation.jsx b/anyclip/src/modules/auth/GuestActivation/GuestActivation.jsx new file mode 100644 index 0000000..c2498d2 --- /dev/null +++ b/anyclip/src/modules/auth/GuestActivation/GuestActivation.jsx @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { emailSelector } from './redux/selectors'; +import { registerUserAction, verifyTokenAction } from './redux/slices'; +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; +import { Button, Checkbox, FormControlLabel, Link, Stack, TextField, Typography } from '@/mui/components'; + +function GuestActivation() { + const router = useRouter(); + const dispatch = useDispatch(); + const isFormLoading = useSelector(isFormLoadingSelector); + const email = useSelector(emailSelector); + const [isAgreeWithTerms, setAgreeWithTerms] = useState(false); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + + useEffect(() => { + dispatch( + verifyTokenAction({ + token: router.query.token, + }), + ); + }, []); + + const onSubmit = (event) => { + event.preventDefault(); + + dispatch( + registerUserAction({ + firstName, + lastName, + email, + token: router.query.token, + }), + ); + }; + + return ( + + + setFirstName(target.value)} + /> + setLastName(target.value)} + /> + + setAgreeWithTerms((prev) => !prev)} + name="terms" + /> + } + label={ + + I agree to the +   + + Terms & conditions + +   + and the +   + + Privacy Policy + + + } + /> +
    + +
    + + } + footer={false} + /> + ); +} + +export default GuestActivation; diff --git a/anyclip/src/modules/auth/GuestActivation/redux/epics/index.js b/anyclip/src/modules/auth/GuestActivation/redux/epics/index.js new file mode 100644 index 0000000..01e7775 --- /dev/null +++ b/anyclip/src/modules/auth/GuestActivation/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import registerUser from './registerUser'; +import verifyToken from './verifyToken'; + +export default combineEpics(verifyToken, registerUser); diff --git a/anyclip/src/modules/auth/GuestActivation/redux/epics/registerUser.js b/anyclip/src/modules/auth/GuestActivation/redux/epics/registerUser.js new file mode 100644 index 0000000..7e742e1 --- /dev/null +++ b/anyclip/src/modules/auth/GuestActivation/redux/epics/registerUser.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; + +import { registerUserAction } from '../slices'; +import { setSessionStorageItem } from '@/modules/@common/storage/helpers'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { getPasswordLessAuthAction } from '@/modules/auth/Login/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(registerUserAction.type), + switchMap(({ payload }) => { + const { email, token, firstName, lastName } = payload; + + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/invitations/signIn`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + email, + token, + firstName, + lastName, + }), + }).pipe( + switchMap(({ response }) => { + setSessionStorageItem(REDIRECT_URL_STORAGE_NAME, response); + + const actions = [of(getPasswordLessAuthAction({ email })), of(setFormLoadingAction(false))]; + + return concat(...actions); + }), + catchError(({ response }) => + concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + of(setFormLoadingAction(false)), + ), + ), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/GuestActivation/redux/epics/verifyToken.js b/anyclip/src/modules/auth/GuestActivation/redux/epics/verifyToken.js new file mode 100644 index 0000000..17f77b3 --- /dev/null +++ b/anyclip/src/modules/auth/GuestActivation/redux/epics/verifyToken.js @@ -0,0 +1,76 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { LOGIN_PAGE } from '@/modules/@common/router/constants'; + +import { setFieldAction, verifyTokenAction } from '../slices'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(verifyTokenAction.type), + switchMap(({ payload }) => { + const { token } = payload; + + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/invitations/getPreloadInvitationData`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + token, + }), + }).pipe( + switchMap(({ response }) => { + if (response.url) { + window.location.replace(response.url); + return EMPTY; + } + + if (!response.email) { + window.location.replace(LOGIN_PAGE.path); + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Email doesn`t exist', + }), + ), + ); + } + + return concat( + of( + setFieldAction({ + email: response.email, + token, + }), + ), + of(setFormLoadingAction(false)), + ); + }), + catchError(({ response }) => { + window.location.replace(LOGIN_PAGE.path); + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + of(setFormLoadingAction(false)), + ); + }), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/GuestActivation/redux/selectors/index.js b/anyclip/src/modules/auth/GuestActivation/redux/selectors/index.js new file mode 100644 index 0000000..07b3eed --- /dev/null +++ b/anyclip/src/modules/auth/GuestActivation/redux/selectors/index.js @@ -0,0 +1,9 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const emailSelector = (state) => state[nameSpace].email; +export const firstNameSelector = (state) => state[nameSpace].firstName; +export const lastNameSelector = (state) => state[nameSpace].lastName; +export const passwordSelector = (state) => state[nameSpace].password; +export const tokenSelector = (state) => state[nameSpace].token; diff --git a/anyclip/src/modules/auth/GuestActivation/redux/slices/index.js b/anyclip/src/modules/auth/GuestActivation/redux/slices/index.js new file mode 100644 index 0000000..a2b6443 --- /dev/null +++ b/anyclip/src/modules/auth/GuestActivation/redux/slices/index.js @@ -0,0 +1,25 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + email: '', + firstName: '', + lastName: '', + password: '', + token: '', +}; + +export const slice = createSlice({ + name: '@@auth/GUEST_ACTIVATION', + initialState, + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + verifyTokenAction: (state) => state, + registerUserAction: (state) => state, + }, +}); + +export const { setFieldAction, verifyTokenAction, registerUserAction } = slice.actions; diff --git a/anyclip/src/modules/auth/Login/Login.jsx b/anyclip/src/modules/auth/Login/Login.jsx new file mode 100644 index 0000000..50d5199 --- /dev/null +++ b/anyclip/src/modules/auth/Login/Login.jsx @@ -0,0 +1,270 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { VisibilityOffRounded, VisibilityRounded } from '@mui/icons-material'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { FORGOT_PASSWORD_PAGE, PASSWORD_LESS_LOGIN_PAGE, SSO_LOGIN_PAGE } from '@/modules/@common/router/constants'; + +import { + codeSelector, + emailSelector, + isCodeStepSelector, + isSsoAuthProcessSelector, + isSsoFailedSelector, + passwordSelector, +} from './redux/selectors'; +import { + getUserDataEventAction, + setCodeAction, + setCodeStepAction, + setEmailAction, + setIsSsoAuthProcessAction, + setPasswordAction, + verifyCodeEventAction, +} from './redux/slices'; +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; +import useSetRedirectUrl from '@/modules/auth/common/useSetRedirectUrl'; +import useSsoBackgroundLogin from '@/modules/auth/common/useSsoBackgroundLogin'; +import GoogleIconColored from '@/mui/customIcons/UnstyledIcons/GoogleIconColored'; +import MicrosoftIconColored from '@/mui/customIcons/UnstyledIcons/MicrosoftIconColored'; +import OktaIconColored from '@/mui/customIcons/UnstyledIcons/OktaIconColored'; +import { Button, Divider, IconButton, InputAdornment, Link, Stack, TextField, Typography } from '@/mui/components'; +import { CustomKeywords, CustomPeople } from '@/mui/components/CustomIcon'; + +function AuthPage() { + const router = useRouter(); + const dispatch = useDispatch(); + const isFormLoading = useSelector(isFormLoadingSelector); + const email = useSelector(emailSelector); + const password = useSelector(passwordSelector); + const isCodeStep = useSelector(isCodeStepSelector); + const code = useSelector(codeSelector); + const isSsoAuthProcess = useSelector(isSsoAuthProcessSelector); + const isSsoFailed = useSelector(isSsoFailedSelector); + const { initSsoBackgroundLoginFlow, getRedirectUrl } = useSsoBackgroundLogin(); + const [showPassword, togglePassword] = useState(false); + + const { code: ssoCode, error } = router.query; + + const onSubmit = (event) => { + event.preventDefault(); + + dispatch(!isCodeStep ? getUserDataEventAction() : verifyCodeEventAction()); + }; + + const { isRedirectUrlAdded } = useSetRedirectUrl(); + + useEffect(() => { + if (error) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Login failed or account is not configured for SSO', + }), + ); + } + + if (ssoCode) { + dispatch(setIsSsoAuthProcessAction(true)); + dispatch( + getUserDataEventAction({ + ssoCode, + redirectUrl: getRedirectUrl(), + }), + ); + } else { + initSsoBackgroundLoginFlow(); + } + }, []); + + return ( + + {!isCodeStep ? ( + <> + + dispatch(setEmailAction(event.target.value))} + disabled={isFormLoading} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + dispatch(setPasswordAction(event.target.value))} + disabled={isFormLoading} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + + togglePassword((e) => !e)}> + {showPassword ? : } + + + ), + }} + /> + + + {' '} + { + event.preventDefault(); + + if (!isFormLoading) { + router.push(FORGOT_PASSWORD_PAGE.path); + } + }} + > + Forgot password? + + + + ) : ( + <> + dispatch(setCodeAction(event.target.value))} + disabled={isFormLoading} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + { + event.preventDefault(); + + if (!isFormLoading) { + dispatch(setCodeStepAction(false)); + dispatch(setCodeAction('')); + } + }} + > + Back + + + + )} + + } + secondSection={ + !isCodeStep && ( + + + + + ) + } + footer={ + !isCodeStep && ( + <> + New to AnyClip? +   + + Get started now + + + ) + } + /> + ); +} + +export default AuthPage; diff --git a/anyclip/src/modules/auth/Login/redux/epics/getCustomLoginPageByAccount.js b/anyclip/src/modules/auth/Login/redux/epics/getCustomLoginPageByAccount.js new file mode 100644 index 0000000..464546c --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/getCustomLoginPageByAccount.js @@ -0,0 +1,33 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { switchMap } from 'rxjs/operators'; + +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { getCustomLoginPageByAccountIdAction, setIsSsoAuthProcessAction } from '@/modules/auth/Login/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(getCustomLoginPageByAccountIdAction.type), + switchMap((action) => { + const { accountId, redirectUrl } = action.payload; + + const stream$ = ajax({ + method: 'GET', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/props/getCustomLoginPage/${accountId}`, + crossDomain: true, + withCredentials: true, + }).pipe( + switchMap(({ response }) => { + if (response?.customLoginPageUrl) { + window.location.replace(`${response.customLoginPageUrl}?olp=${encodeURIComponent(redirectUrl)}`); + return EMPTY; + } + + return concat(of(setFormLoadingAction(false)), of(setIsSsoAuthProcessAction(false))); + }), + ); + + return concat(of(setFormLoadingAction(true)), of(setIsSsoAuthProcessAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/Login/redux/epics/getSsoLink.js b/anyclip/src/modules/auth/Login/redux/epics/getSsoLink.js new file mode 100644 index 0000000..88c7bb0 --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/getSsoLink.js @@ -0,0 +1,102 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { LOGIN_PAGE } from '@/modules/@common/router/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; +import { AUTH_TYPES } from '@/modules/auth/SsoLogin/constants'; + +import { setSessionStorageItem } from '@/modules/@common/storage/helpers'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { + getSsoLinkAction, + setEmailAction, + setIsSsoAuthProcessAction, + setSsoFailedAction, +} from '@/modules/auth/Login/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { getFromUrlWithoutAccountUserParams } from '@/modules/auth/common/useSsoBackgroundLogin'; + +const types = { + [AUTH_TYPES.byEmail]: 'SAML', + [AUTH_TYPES.byGoogle]: 'Google', +}; + +export default (action$) => + action$.pipe( + ofType(getSsoLinkAction.type), + switchMap((action) => { + const { type, email = '', subdomain, shouldRedirectToLoginPageIfError = false, ssoCheck } = action.payload; + + let url = `${process.env.APP_PCN_API_BASE_URL_FE}/public/props/getSsoRedirectionUrl?provider=${types[type]}`; + + if (email) { + url = `${url}&email=${encodeURIComponent(email.trim())}`; + } else if (subdomain) { + url = `${url}&subdomain=${encodeURIComponent(subdomain.trim())}`; + } + + const stream$ = ajax({ + method: 'GET', + url, + crossDomain: true, + withCredentials: true, + }).pipe( + switchMap(({ response: { ssoLoginUri } }) => { + const actions = []; + + if (!ssoCheck) { + const authUrl = encodeURIComponent(ssoLoginUri); + const redirectUri = encodeURIComponent(`${window.location.origin}/login`); + + actions.push(of(setFormLoadingAction(true)), of(setIsSsoAuthProcessAction(true))); + + window.location.replace( + `${process.env.APP_ENV_BASE_URL}/auth/cognito?authUri=${authUrl}&redirectUri=${redirectUri}`, + ); + } else { + actions.push( + of(setEmailAction(email)), + of(setFormLoadingAction(false)), + of(setIsSsoAuthProcessAction(false)), + ); + } + + return concat(...actions); + }), + catchError(({ response }) => { + if (shouldRedirectToLoginPageIfError) { + setSessionStorageItem(REDIRECT_URL_STORAGE_NAME, getFromUrlWithoutAccountUserParams()); + Router.replace(LOGIN_PAGE.path); + } + + const actions = [ + of(setEmailAction(email)), + of(setFormLoadingAction(false)), + of(setIsSsoAuthProcessAction(false)), + ]; + + if (subdomain) { + actions.push(of(setSsoFailedAction(true))); + } else { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setFormLoadingAction(!ssoCheck)), of(setIsSsoAuthProcessAction(!ssoCheck)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/Login/redux/epics/getUserData.js b/anyclip/src/modules/auth/Login/redux/epics/getUserData.js new file mode 100644 index 0000000..174f73b --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/getUserData.js @@ -0,0 +1,114 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; +import StringCrypto from 'string-crypto'; + +import { PASS_CRYPTO_SALT } from '@/modules/@common/envs/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { LOGIN_PAGE } from '@/modules/@common/router/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; +import { TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE } from '@/modules/@common/token/constants'; +import { AUTH_URL } from '@/modules/auth/common/constants/auth'; + +import { getUserDataEventAction, setCodeStepAction, setIsSsoAuthProcessAction } from '../slices'; +import { clearStorage, getSessionStorageItem } from '@/modules/@common/storage/helpers'; +import { setTokenCookieName, setTokenCookieValue } from '@/modules/@common/token/helpers'; +import { setUser } from '@/modules/@common/user/helpers'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { emailSelector, passwordSelector } from '@/modules/auth/Login/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(getUserDataEventAction.type), + switchMap((action) => { + const sso = action.payload; + const email = emailSelector(state$.value); + const password = passwordSelector(state$.value); + + const { encryptString } = new StringCrypto(); + + const passwordHash = encryptString(password, PASS_CRYPTO_SALT); + + const body = {}; + + if (sso?.ssoCode) { + body.ssoConfirmationCode = sso.ssoCode; + } else { + body.email = email; + body.password = passwordHash; + } + + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/auth/login`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify(body), + }).pipe( + switchMap(({ response }) => { + const actions = []; + + if (response.is2FaVerification) { + actions.push(of(setFormLoadingAction(false)), of(setCodeStepAction(true))); + } else { + clearStorage(); + + setUser(response?.token); + + if (response?.cookieName) { + setTokenCookieName(response.cookieName); + setTokenCookieValue(response.cookieValue); + } + + const redirectUrl = + sso?.redirectUrl || getSessionStorageItem(REDIRECT_URL_STORAGE_NAME) || window.location.origin; + + const authStream = ajax({ + method: 'POST', + url: AUTH_URL, + crossDomain: true, + withCredentials: true, + body: { + token: response.token, + [TOKEN_COOKIE_NAME]: response?.cookieName, + [TOKEN_COOKIE_VALUE]: response?.cookieValue, + }, + }).pipe( + switchMap(() => { + window.location.replace(redirectUrl); + + return EMPTY; + }), + ); + + actions.push(authStream); + } + + return concat(...actions); + }), + catchError(({ response }) => { + Router.replace(LOGIN_PAGE.path); + + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + of(setIsSsoAuthProcessAction(false)), + of(setFormLoadingAction(false)), + ); + }), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/Login/redux/epics/index.js b/anyclip/src/modules/auth/Login/redux/epics/index.js new file mode 100644 index 0000000..d128595 --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import getCustomLoginPageByAccount from './getCustomLoginPageByAccount'; +import getSsoLink from './getSsoLink'; +import getUserData from './getUserData'; +import passwordLessAuth from './passwordLessAuth'; +import passwordLessAuthCode from './passwordLessAuthCode'; +import resetPassword from './resetPassword'; +import verifyCode from './verifyCode'; + +export default combineEpics( + getUserData, + resetPassword, + verifyCode, + getSsoLink, + passwordLessAuth, + passwordLessAuthCode, + getCustomLoginPageByAccount, +); diff --git a/anyclip/src/modules/auth/Login/redux/epics/passwordLessAuth.js b/anyclip/src/modules/auth/Login/redux/epics/passwordLessAuth.js new file mode 100644 index 0000000..f2ff8f0 --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/passwordLessAuth.js @@ -0,0 +1,98 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap, tap } from 'rxjs/operators'; + +import { + ERROR_CANT_USE_PASSWORDLESS_AUTH, + ERROR_OTP_ALREADY_USED, + ERROR_OTP_HAS_EXPIRED, + ERROR_TOO_MANY_ATTEMPTS, + ERROR_TWO_FA_IS_REQUIRED, + ERROR_USER_NOT_VALID, +} from '@/modules/@common/constants/errors'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { PASSWORD_LESS_LOGIN_CODE_PAGE, USER_AUTH_ERROR_PAGE } from '@/modules/@common/router/constants'; + +import { getPasswordLessAuthAction } from '../slices'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(getPasswordLessAuthAction.type), + switchMap(({ payload }) => { + const { email } = payload; + + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/auth/request-passwordless-auth-code`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + email, + }), + }).pipe( + switchMap(() => + concat( + of({ type: '@#' }).pipe( + tap(() => + Router.push({ + pathname: PASSWORD_LESS_LOGIN_CODE_PAGE.path, + query: { + email, + }, + }), + ), + ), + of(setFormLoadingAction(false)), + ), + ), + catchError(({ response }) => { + const actions = []; + + if ( + [ + ERROR_USER_NOT_VALID, + ERROR_OTP_HAS_EXPIRED, + ERROR_OTP_ALREADY_USED, + ERROR_TOO_MANY_ATTEMPTS, + ERROR_TWO_FA_IS_REQUIRED, + ERROR_CANT_USE_PASSWORDLESS_AUTH, + ].includes(response.errorCode) + ) { + actions.push( + of({ type: '@#' }).pipe( + tap(() => + Router.push({ + pathname: USER_AUTH_ERROR_PAGE.path, + query: { + error: response.errorCode, + }, + }), + ), + ), + ); + } else { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + ); + } + + return concat(...actions, of(setFormLoadingAction(false))); + }), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/Login/redux/epics/passwordLessAuthCode.js b/anyclip/src/modules/auth/Login/redux/epics/passwordLessAuthCode.js new file mode 100644 index 0000000..835c991 --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/passwordLessAuthCode.js @@ -0,0 +1,115 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap, tap } from 'rxjs/operators'; + +import { + ERROR_CANT_USE_PASSWORDLESS_AUTH, + ERROR_OTP_ALREADY_USED, + ERROR_OTP_HAS_EXPIRED, + ERROR_TOO_MANY_ATTEMPTS, + ERROR_TWO_FA_IS_REQUIRED, + ERROR_USER_NOT_VALID, +} from '@/modules/@common/constants/errors'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { USER_AUTH_ERROR_PAGE } from '@/modules/@common/router/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; +import { TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE } from '@/modules/@common/token/constants'; +import { AUTH_URL } from '@/modules/auth/common/constants/auth'; + +import { verifyPasswordLessAuthAction } from '../slices'; +import { getSessionStorageItem } from '@/modules/@common/storage/helpers'; +import { setTokenCookieName, setTokenCookieValue } from '@/modules/@common/token/helpers'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(verifyPasswordLessAuthAction.type), + switchMap(({ payload }) => { + const { email, code } = payload; + + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/auth/verify-passwordless-auth-code`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + email, + code, + }), + }).pipe( + switchMap(({ response }) => { + const redirectUrl = getSessionStorageItem(REDIRECT_URL_STORAGE_NAME) || window.location.origin; + + if (response?.cookieName) { + setTokenCookieName(response.cookieName); + setTokenCookieValue(response.cookieValue); + } + + return ajax({ + method: 'POST', + url: AUTH_URL, + crossDomain: true, + withCredentials: true, + body: { + token: response.token, + [TOKEN_COOKIE_NAME]: response?.cookieName, + [TOKEN_COOKIE_VALUE]: response?.cookieValue, + }, + }).pipe( + switchMap(() => { + window.location.replace(redirectUrl); + + return EMPTY; + }), + ); + }), + catchError(({ response }) => { + const actions = []; + + if ( + [ + ERROR_USER_NOT_VALID, + ERROR_OTP_HAS_EXPIRED, + ERROR_OTP_ALREADY_USED, + ERROR_TOO_MANY_ATTEMPTS, + ERROR_TWO_FA_IS_REQUIRED, + ERROR_CANT_USE_PASSWORDLESS_AUTH, + ].includes(response.errorCode) + ) { + actions.push( + of({ type: '@#' }).pipe( + tap(() => + Router.push({ + pathname: USER_AUTH_ERROR_PAGE.path, + query: { + error: response.errorCode, + }, + }), + ), + ), + ); + } else { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + ); + } + + return concat(...actions, of(setFormLoadingAction(false))); + }), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/Login/redux/epics/resetPassword.js b/anyclip/src/modules/auth/Login/redux/epics/resetPassword.js new file mode 100644 index 0000000..9c14bb4 --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/resetPassword.js @@ -0,0 +1,42 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { resetPasswordEventAction } from '../slices'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(resetPasswordEventAction.type), + switchMap(({ payload }) => { + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/auth/request-password-reset`, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: payload.email, + }), + }).pipe( + switchMap(() => concat(of(setFormLoadingAction(false)))), + catchError(({ response }) => + concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + of(setFormLoadingAction(false)), + ), + ), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/Login/redux/epics/verifyCode.js b/anyclip/src/modules/auth/Login/redux/epics/verifyCode.js new file mode 100644 index 0000000..c1bd201 --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/epics/verifyCode.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; +import { TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE } from '@/modules/@common/token/constants'; +import { AUTH_URL } from '@/modules/auth/common/constants/auth'; + +import { verifyCodeEventAction } from '../slices'; +import { getSessionStorageItem } from '@/modules/@common/storage/helpers'; +import { setTokenCookieName, setTokenCookieValue } from '@/modules/@common/token/helpers'; +import { setUser } from '@/modules/@common/user/helpers'; +import { setFormLoadingAction } from '@/modules/auth/common/redux/slices'; +import { codeSelector, emailSelector } from '@/modules/auth/Login/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(verifyCodeEventAction.type), + switchMap(() => { + const email = emailSelector(state$.value); + const code = codeSelector(state$.value); + + const stream$ = ajax({ + method: 'POST', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/auth/verify2facode`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + email, + twoFaCode: code, + }), + }).pipe( + switchMap(({ response }) => { + const redirectUrl = getSessionStorageItem(REDIRECT_URL_STORAGE_NAME) || window.location.origin; + + setUser(response?.token); + + if (response?.cookieName) { + setTokenCookieName(response.cookieName); + setTokenCookieValue(response.cookieValue); + } + + return ajax({ + method: 'POST', + url: AUTH_URL, + crossDomain: true, + withCredentials: true, + body: { + token: response.token, + [TOKEN_COOKIE_NAME]: response?.cookieName, + [TOKEN_COOKIE_VALUE]: response?.cookieValue, + }, + }).pipe( + switchMap(() => { + window.location.replace(redirectUrl); + + return EMPTY; + }), + ); + }), + catchError(({ response }) => + concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + of(setFormLoadingAction(false)), + ), + ), + ); + + return concat(of(setFormLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/auth/Login/redux/selectors/index.js b/anyclip/src/modules/auth/Login/redux/selectors/index.js new file mode 100644 index 0000000..e442e4d --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/selectors/index.js @@ -0,0 +1,10 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const emailSelector = (state) => state[nameSpace].email; +export const passwordSelector = (state) => state[nameSpace].password; +export const isCodeStepSelector = (state) => state[nameSpace].isCodeStep; +export const codeSelector = (state) => state[nameSpace].code; +export const isSsoAuthProcessSelector = (state) => state[nameSpace].isSsoAuthProcess; +export const isSsoFailedSelector = (state) => state[nameSpace].isSsoFailed; diff --git a/anyclip/src/modules/auth/Login/redux/slices/index.js b/anyclip/src/modules/auth/Login/redux/slices/index.js new file mode 100644 index 0000000..782c0bc --- /dev/null +++ b/anyclip/src/modules/auth/Login/redux/slices/index.js @@ -0,0 +1,58 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + email: '', + password: '', + code: '', + isCodeStep: false, + isSsoAuthProcess: false, + isSsoFailed: false, +}; + +export const slice = createSlice({ + name: '@@auth/AUTH', + initialState, + reducers: { + setEmailAction: (state, action) => { + state.email = action.payload; + }, + setPasswordAction: (state, action) => { + state.password = action.payload; + }, + getUserDataEventAction: (state) => state, + resetPasswordEventAction: (state) => state, + setCodeStepAction: (state, action) => { + state.isCodeStep = action.payload; + }, + setCodeAction: (state, action) => { + state.code = action.payload; + }, + verifyCodeEventAction: (state) => state, + getSsoLinkAction: (state) => state, + setIsSsoAuthProcessAction: (state, action) => { + state.isSsoAuthProcess = action.payload; + }, + setSsoFailedAction: (state, action) => { + state.isSsoFailed = action.payload; + }, + getCustomLoginPageByAccountIdAction: (state) => state, + getPasswordLessAuthAction: (state) => state, + verifyPasswordLessAuthAction: (state) => state, + }, +}); + +export const { + setEmailAction, + setPasswordAction, + getUserDataEventAction, + resetPasswordEventAction, + setCodeStepAction, + setCodeAction, + verifyCodeEventAction, + getSsoLinkAction, + setIsSsoAuthProcessAction, + setSsoFailedAction, + getCustomLoginPageByAccountIdAction, + getPasswordLessAuthAction, + verifyPasswordLessAuthAction, +} = slice.actions; diff --git a/anyclip/src/modules/auth/PasswordLessLogin/PasswordLessLogin.jsx b/anyclip/src/modules/auth/PasswordLessLogin/PasswordLessLogin.jsx new file mode 100644 index 0000000..2f3fb91 --- /dev/null +++ b/anyclip/src/modules/auth/PasswordLessLogin/PasswordLessLogin.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { emailRegExp } from '@/modules/@common/constants/validation'; + +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; +import { getPasswordLessAuthAction } from '@/modules/auth/Login/redux/slices'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; +import { Button, InputAdornment, Stack, TextField } from '@/mui/components'; +import { CustomPeople } from '@/mui/components/CustomIcon'; + +function UserPasswordError() { + const dispatch = useDispatch(); + const isFormLoading = useSelector(isFormLoadingSelector); + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + + const onSubmit = (event) => { + event.preventDefault(); + + const emailTrimmed = email.trim(); + + if (!emailTrimmed) { + setError('The email cannot be empty'); + } else if (!emailRegExp.test(emailTrimmed)) { + setError('The email you entered is incorrect'); + } else { + dispatch( + getPasswordLessAuthAction({ + email: emailTrimmed, + }), + ); + } + }; + + return ( + + + + + ), + }} + onChange={(event) => setEmail(event.target.value)} + onFocus={() => setError('')} + /> +
    + +
    + + } + /> + ); +} + +export default UserPasswordError; diff --git a/anyclip/src/modules/auth/PasswordLessLoginCode/PasswordLessLoginCode.jsx b/anyclip/src/modules/auth/PasswordLessLoginCode/PasswordLessLoginCode.jsx new file mode 100644 index 0000000..3c97a1f --- /dev/null +++ b/anyclip/src/modules/auth/PasswordLessLoginCode/PasswordLessLoginCode.jsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; +import { getPasswordLessAuthAction, verifyPasswordLessAuthAction } from '@/modules/auth/Login/redux/slices'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; +import { Button, InputAdornment, Link, Stack, TextField } from '@/mui/components'; +import { CustomKeywords } from '@/mui/components/CustomIcon'; + +function PasswordLessLoginCode(props) { + const dispatch = useDispatch(); + const router = useRouter(); + const isFormLoading = useSelector(isFormLoadingSelector); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const { email } = router.query; + + const onSubmit = (event) => { + event.preventDefault(); + + const codeTrimmed = code.trim(); + + setCode(codeTrimmed); + + if (!codeTrimmed) { + setError('Password cannot be empty'); + } else { + dispatch( + verifyPasswordLessAuthAction({ + email, + code: codeTrimmed, + }), + ); + } + }; + + return ( + + We have sent it to the email you specified. +
    + Please check your inbox + + } + firstSection={ + + + + + ), + }} + onChange={(event) => setCode(event.target.value)} + /> + + + { + event.preventDefault(); + + dispatch( + getPasswordLessAuthAction({ + email, + }), + ); + }} + > + Re-send the password + + + + } + /> + ); +} + +export default PasswordLessLoginCode; diff --git a/anyclip/src/modules/auth/SsoLogin/components/index.jsx b/anyclip/src/modules/auth/SsoLogin/components/index.jsx new file mode 100644 index 0000000..641b52d --- /dev/null +++ b/anyclip/src/modules/auth/SsoLogin/components/index.jsx @@ -0,0 +1,124 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { AUTH_TYPES } from '../constants'; +import { emailRegExp } from '@/modules/@common/constants/validation'; + +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; +import { emailSelector } from '@/modules/auth/Login/redux/selectors'; +import { getSsoLinkAction, setEmailAction } from '@/modules/auth/Login/redux/slices'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; +import useSsoBackgroundLogin from '@/modules/auth/common/useSsoBackgroundLogin'; +import useSsoLocalStorageEmails from '@/modules/auth/SsoLogin/components/useSsoLocalStorageEmails'; +import GoogleIconColored from '@/mui/customIcons/UnstyledIcons/GoogleIconColored'; +import { Autocomplete, Button, InputAdornment, Stack, TextField } from '@/mui/components'; +import { CustomPeople } from '@/mui/components/CustomIcon'; + +function Sso() { + const router = useRouter(); + const dispatch = useDispatch(); + const isFormLoading = useSelector(isFormLoadingSelector); + const email = useSelector(emailSelector); + const [emailError, setEmailError] = useState(''); + const { emails, saveEmailToLocalStorage } = useSsoLocalStorageEmails(); + const { ssoLoginByUParam } = useSsoBackgroundLogin(); + const { u } = router.query; + + const handleEmailChange = (value) => dispatch(setEmailAction(value)); + const handleEmailLogin = (event) => { + event.preventDefault(); + + if (emailRegExp.test(email?.trim() ?? '')) { + setEmailError(''); + saveEmailToLocalStorage(email); + dispatch( + getSsoLinkAction({ + type: AUTH_TYPES.byEmail, + email, + }), + ); + } else { + setEmailError('The email you entered is incorrect'); + } + }; + const handleGoogleLogin = () => { + dispatch( + getSsoLinkAction({ + type: AUTH_TYPES.byGoogle, + }), + ); + }; + + useEffect(() => ssoLoginByUParam(), []); + + return ( + + handleEmailChange(email$?.label ?? email$ ?? '')} + onOpen={() => setEmailError('')} + renderInput={(params) => ( + handleEmailChange(target.value)} + error={!!emailError} + InputProps={{ + ...params.InputProps, + startAdornment: ( + + + + ), + }} + /> + )} + /> +
    + +
    + + } + secondSection={ + + } + /> + ); +} + +export default Sso; diff --git a/anyclip/src/modules/auth/SsoLogin/components/useSsoLocalStorageEmails.jsx b/anyclip/src/modules/auth/SsoLogin/components/useSsoLocalStorageEmails.jsx new file mode 100644 index 0000000..545a6e6 --- /dev/null +++ b/anyclip/src/modules/auth/SsoLogin/components/useSsoLocalStorageEmails.jsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import dayjs from 'dayjs'; + +import ssoEmailStorageManager from '../helpers/ssoEmailStorageManager'; + +const EXPIRED_DAYS = 30; + +function useSsoLocalStorageEmails() { + const [emails, setEmails] = useState([]); + + const handleUpdate = (emailsToUpdate) => { + setEmails(emailsToUpdate); + ssoEmailStorageManager.setEmails(emailsToUpdate); + }; + + const handleSaveEmailToLocalStorage = (email) => { + const isExist = emails.some((o) => o.label === email); + + if (isExist) { + handleUpdate( + emails.map((o) => ({ + ...o, + createdAt: o.label === email ? Date.now() : o.createdAt, + })), + ); + } else { + handleUpdate( + [].concat(emails, { + label: email, + createdAt: Date.now(), + }), + ); + } + }; + + useEffect(() => { + const filteredByExpired = ssoEmailStorageManager + .getEmails() + .filter((email) => dayjs(email.createdAt).isBefore(dayjs(email.createdAt).add(EXPIRED_DAYS, 'd'))); + handleUpdate(filteredByExpired); + }, []); + + return { + emails: emails.reverse(), + saveEmailToLocalStorage: handleSaveEmailToLocalStorage, + }; +} + +export default useSsoLocalStorageEmails; diff --git a/anyclip/src/modules/auth/SsoLogin/constants/index.js b/anyclip/src/modules/auth/SsoLogin/constants/index.js new file mode 100644 index 0000000..3cbfde0 --- /dev/null +++ b/anyclip/src/modules/auth/SsoLogin/constants/index.js @@ -0,0 +1,6 @@ +export const AUTH_TYPES = { + byEmail: 'BY_EMAIL', + byGoogle: 'BY_GOOGLE', +}; + +export default AUTH_TYPES; diff --git a/anyclip/src/modules/auth/SsoLogin/helpers/ssoEmailStorageManager.js b/anyclip/src/modules/auth/SsoLogin/helpers/ssoEmailStorageManager.js new file mode 100644 index 0000000..80e3443 --- /dev/null +++ b/anyclip/src/modules/auth/SsoLogin/helpers/ssoEmailStorageManager.js @@ -0,0 +1,10 @@ +import { getStorageItem, setBrowserStorageItem } from '@/modules/@common/storage/helpers'; + +const SSO_EMAILS_STORAGE_NAME = 'SSO_EMAILS_STORAGE'; + +const ssoEmailStorageManager = { + setEmails: (emails) => setBrowserStorageItem(SSO_EMAILS_STORAGE_NAME, JSON.stringify(emails)), + getEmails: () => JSON.parse(getStorageItem(SSO_EMAILS_STORAGE_NAME)) || [], +}; + +export default ssoEmailStorageManager; diff --git a/anyclip/src/modules/auth/SsoLogin/index.jsx b/anyclip/src/modules/auth/SsoLogin/index.jsx new file mode 100644 index 0000000..2c8e2a0 --- /dev/null +++ b/anyclip/src/modules/auth/SsoLogin/index.jsx @@ -0,0 +1,3 @@ +import SsoLogin from './components'; + +export default SsoLogin; diff --git a/anyclip/src/modules/auth/UserAuthError/UserAuthError.jsx b/anyclip/src/modules/auth/UserAuthError/UserAuthError.jsx new file mode 100644 index 0000000..255b7d8 --- /dev/null +++ b/anyclip/src/modules/auth/UserAuthError/UserAuthError.jsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +import { + ERROR_CANT_USE_PASSWORDLESS_AUTH, + ERROR_OTP_ALREADY_USED, + ERROR_OTP_HAS_EXPIRED, + ERROR_TOO_MANY_ATTEMPTS, + ERROR_TWO_FA_IS_REQUIRED, + ERROR_UI_LINK_WAS_EXPIRED, + ERROR_USER_NOT_VALID, +} from '@/modules/@common/constants/errors'; +import { LOGIN_PAGE } from '@/modules/@common/router/constants'; + +import Layout from '@/modules/auth/common/components/Layout/Layout'; + +const errorMap = { + [ERROR_OTP_ALREADY_USED]: { + title: 'You have already signed in with this password in another session', + description: 'To sign in again, please request another password', + }, + [ERROR_USER_NOT_VALID]: { + title: 'Could not verify user', + description: 'Please provide a valid email or try other sign in options', + }, + [ERROR_OTP_HAS_EXPIRED]: { + title: 'This password has expired', + description: 'Please request a new one', + }, + [ERROR_TWO_FA_IS_REQUIRED]: { + title: '2-factor authentication is required', + description: 'Please use other sign in options', + }, + [ERROR_CANT_USE_PASSWORDLESS_AUTH]: { + title: 'One-time password authentication is disabled for you', + description: 'Please use other sign in options instead', + }, + [ERROR_TOO_MANY_ATTEMPTS]: { + title: 'Too many invalid attempts', + description: 'You will be able to request a new password later, or use other sign in options', + }, + [ERROR_UI_LINK_WAS_EXPIRED]: { + title: 'The link expired', + description: 'Please try again', + }, +}; + +function UserAuthError() { + const router = useRouter(); + + const data = errorMap[router.query.error] ?? {}; + + useEffect(() => { + if (!errorMap[router.query.error]) { + router.replace(LOGIN_PAGE.path); + } + }, [router.query.errorKey]); + + return ; +} + +export default UserAuthError; diff --git a/anyclip/src/modules/auth/common/components/Layout/Layout.jsx b/anyclip/src/modules/auth/common/components/Layout/Layout.jsx new file mode 100644 index 0000000..2512987 --- /dev/null +++ b/anyclip/src/modules/auth/common/components/Layout/Layout.jsx @@ -0,0 +1,137 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import Image from 'next/image'; +import NextLink from 'next/link'; +import { useRouter } from 'next/router'; + +import { ACTIVATION_GUEST_PAGE, LOGIN_PAGE } from '@/modules/@common/router/constants'; + +import { isFormLoadingSelector } from '@/modules/auth/common/redux/selectors'; + +import { LinearProgress, Link, Paper, Stack, Typography } from '@/mui/components'; + +import logo from '@/assets/img/logo.png'; + +import styles from './Layout.module.scss'; + +function Layout({ subTitle = null, firstSection = null, secondSection = null, footer = null, ...props }) { + const router = useRouter(); + const isFormLoading = useSelector(isFormLoadingSelector); + const isGuestActivation = router.route === ACTIVATION_GUEST_PAGE.path; + + return ( + + {!props.shouldHideContent && ( + + + AnyClip + + +
    + {isFormLoading && } + + + {props.title} + + {subTitle && ( + + {subTitle} + + )} + + + Or use + + + } + > + {firstSection} + {secondSection} + + {footer !== false ? ( + + {footer ?? ( + + Back to Sign in page + + )} + + ) : null} + +
    + + + + {isGuestActivation ? 'Welcome to AnyClip' : ''} + + {isGuestActivation ? ( + The Visual Intelligence Company + ) : ( + [ + { + title: 'About AnyClip', + href: 'https://anyclip.com', + }, + { + title: 'Latest news', + href: 'https://anyclip.com/press', + }, + { + title: 'Ask a question', + href: 'https://anyclip.com/contact/?inquiry=contact%20support', + }, + ].map((link) => ( +
    + + {link.title} + +
    + )) + )} +
    + + + Privacy Policy + + + Terms of Service + + +
    +
    +
    + )} +
    + ); +} + +Layout.propTypes = { + title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired, + subTitle: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + firstSection: PropTypes.node, + secondSection: PropTypes.node, + shouldHideContent: PropTypes.bool, + footer: PropTypes.oneOfType([PropTypes.oneOfType([PropTypes.node, PropTypes.string]), PropTypes.oneOf([false])]), +}; + +export default Layout; diff --git a/anyclip/src/modules/auth/common/components/Layout/Layout.module.scss b/anyclip/src/modules/auth/common/components/Layout/Layout.module.scss new file mode 100644 index 0000000..51d4864 --- /dev/null +++ b/anyclip/src/modules/auth/common/components/Layout/Layout.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"Layout_Container__u5JCy","Paper":"Layout_Paper__YNO0a","Logo":"Layout_Logo__cJMqD","Progress":"Layout_Progress__b5MJE","Left":"Layout_Left__mlod9","Right":"Layout_Right__AOqhx","Content":"Layout_Content__C5toM","FooterLink":"Layout_FooterLink__AQezS","SectionDivider":"Layout_SectionDivider__JRcAX","LeftTitle":"Layout_LeftTitle__QyHUP","LeftSubTitle":"Layout_LeftSubTitle__EJtwd","LeftWrapper":"Layout_LeftWrapper__lE2l6","LeftContent":"Layout_LeftContent__WtSRX"}; \ No newline at end of file diff --git a/anyclip/src/modules/auth/common/constants/auth.js b/anyclip/src/modules/auth/common/constants/auth.js new file mode 100644 index 0000000..f73f9d0 --- /dev/null +++ b/anyclip/src/modules/auth/common/constants/auth.js @@ -0,0 +1,3 @@ +export const AUTH_URL = '/api/auth/login'; + +export default {}; diff --git a/anyclip/src/modules/auth/common/redux/selectors/index.js b/anyclip/src/modules/auth/common/redux/selectors/index.js new file mode 100644 index 0000000..84dfe20 --- /dev/null +++ b/anyclip/src/modules/auth/common/redux/selectors/index.js @@ -0,0 +1,7 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const isFormLoadingSelector = (state$) => state$[nameSpace].isFormLoading; + +export default {}; diff --git a/anyclip/src/modules/auth/common/redux/slices/index.js b/anyclip/src/modules/auth/common/redux/slices/index.js new file mode 100644 index 0000000..050bef0 --- /dev/null +++ b/anyclip/src/modules/auth/common/redux/slices/index.js @@ -0,0 +1,17 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + isFormLoading: false, +}; + +export const slice = createSlice({ + name: '@@auth/LAYOUT', + initialState, + reducers: { + setFormLoadingAction: (state, action) => { + state.isFormLoading = action.payload; + }, + }, +}); + +export const { setFormLoadingAction } = slice.actions; diff --git a/anyclip/src/modules/auth/common/useSetRedirectUrl.js b/anyclip/src/modules/auth/common/useSetRedirectUrl.js new file mode 100644 index 0000000..e4ff1b4 --- /dev/null +++ b/anyclip/src/modules/auth/common/useSetRedirectUrl.js @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; + +import { LOGIN_PAGE } from '@/modules/@common/router/constants'; +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; + +import { removeSessionStorageItem, setSessionStorageItem } from '@/modules/@common/storage/helpers'; + +const REDIRECT_QUERY_KEY = 'redirectTo='; + +function getNormalizeRedirectUrl(redirectUrl) { + let normalizeRedirectUrl = ''; + + if (!redirectUrl) { + return normalizeRedirectUrl; + } + + try { + const p = new URL(redirectUrl, window.location.origin); + normalizeRedirectUrl = `${window.location.origin}${p.pathname}${p.search}${p.hash}`; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + normalizeRedirectUrl = ''; + } + + return normalizeRedirectUrl; +} + +function useSetRedirectUrl() { + const [isRedirectUrlAdded, setIsRedirectUrlAdded] = useState(false); + const router = useRouter(); + + useEffect(() => { + const redirectUrlObject = new URL(router.asPath, window.location.origin); + + let redirectUrl = getNormalizeRedirectUrl(redirectUrlObject.search.split(REDIRECT_QUERY_KEY)[1]); + + if (redirectUrl && redirectUrlObject.hash) { + redirectUrl += redirectUrlObject.hash; + } + + if (redirectUrl && redirectUrl.search('/login') === -1) { + setSessionStorageItem(REDIRECT_URL_STORAGE_NAME, redirectUrl); + } else { + // to avoid infinite redirect to login + removeSessionStorageItem(REDIRECT_URL_STORAGE_NAME); + } + + setIsRedirectUrlAdded(true); + + router.replace(LOGIN_PAGE.path); + }, []); + + return { + isRedirectUrlAdded, + }; +} + +export default useSetRedirectUrl; diff --git a/anyclip/src/modules/auth/common/useSsoBackgroundLogin.js b/anyclip/src/modules/auth/common/useSsoBackgroundLogin.js new file mode 100644 index 0000000..66d46f5 --- /dev/null +++ b/anyclip/src/modules/auth/common/useSsoBackgroundLogin.js @@ -0,0 +1,124 @@ +import { useDispatch } from 'react-redux'; +import Router from 'next/router'; + +import { REDIRECT_URL_STORAGE_NAME } from '@/modules/@common/storage/constants'; +import { AUTH_TYPES } from '@/modules/auth/SsoLogin/constants'; + +import { + getSessionStorageItem, + getStorageItem, + removeStorageItem, + setBrowserStorageItem, +} from '@/modules/@common/storage/helpers'; +import { getCustomLoginPageByAccountIdAction, getSsoLinkAction } from '@/modules/auth/Login/redux/slices'; + +const PARAMS = { + account: 'acid', + user: 'u', + redirectUrl: 'olp', +}; + +/** + * Background sso login redirect url storage manager + */ +const SSO_REDIRECT_URL_STORAGE_NAME = 'SSO_REDIRECT_URL_STORAGE_NAME'; + +const getSubdomain = () => { + const subdomain = window.location.host.split('.')[0]; + const mainSubdomain = process.env.APP_ENV_BASE_URL.split('//')[1].split('.')[0]; + const isCustomSubdomain = subdomain !== mainSubdomain; + + return { + subdomain, + isCustomSubdomain, + }; +}; + +export const ssoRedirectUrlManager = { + set: (link) => setBrowserStorageItem(SSO_REDIRECT_URL_STORAGE_NAME, link), + get: () => getStorageItem(SSO_REDIRECT_URL_STORAGE_NAME) || '', + clear: () => removeStorageItem(SSO_REDIRECT_URL_STORAGE_NAME), +}; + +/** + * Cleanup history from url + */ +export const getFromUrlWithoutAccountUserParams = () => { + const fromLocation = getSessionStorageItem(REDIRECT_URL_STORAGE_NAME); + + if (!fromLocation) return '/'; + + const url = new URL(fromLocation); + url.searchParams.delete(PARAMS.account); + url.searchParams.delete(PARAMS.user); + + return url.toString(); +}; + +function useSsoBackgroundLogin() { + const dispatch = useDispatch(); + + const ssoLoginByUParam = (u = null, ssoCheck = null) => { + const params = Router.query; + const email = u || params[PARAMS.user]; + + const { subdomain, isCustomSubdomain } = getSubdomain(); + + if (email || isCustomSubdomain) { + dispatch( + getSsoLinkAction({ + type: AUTH_TYPES.byEmail, + email, + subdomain: isCustomSubdomain ? subdomain : null, + shouldRedirectToLoginPageIfError: true, + ssoCheck, + }), + ); + } + }; + + const initSsoBackgroundLoginFlow = () => { + const { isCustomSubdomain } = getSubdomain(); + + try { + const fromLocation = new URL(getSessionStorageItem(REDIRECT_URL_STORAGE_NAME)); + const params = new URLSearchParams(fromLocation.search); + const redirectUrl = getFromUrlWithoutAccountUserParams(); + + // save redirect url to localstorage + ssoRedirectUrlManager.set(redirectUrl); + + if (params.get(PARAMS.user) || isCustomSubdomain) { + ssoLoginByUParam(params.get(PARAMS.user)); + } else if (params.get(PARAMS.account)) { + const accountId = params.get(PARAMS.account); + + dispatch( + getCustomLoginPageByAccountIdAction({ + accountId, + redirectUrl, + }), + ); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + if (isCustomSubdomain) { + ssoLoginByUParam(null, true); + } + + return false; + } + + return false; + }; + + const getRedirectUrl = () => ssoRedirectUrlManager.get(); + + return { + initSsoBackgroundLoginFlow, + getRedirectUrl, + ssoLoginByUParam, + }; +} + +export default useSsoBackgroundLogin; diff --git a/anyclip/src/modules/configuration/components/dictionary/buttonsFileLoader.jsx b/anyclip/src/modules/configuration/components/dictionary/buttonsFileLoader.jsx new file mode 100644 index 0000000..b47886a --- /dev/null +++ b/anyclip/src/modules/configuration/components/dictionary/buttonsFileLoader.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DownloadRounded, UploadRounded } from '@mui/icons-material'; + +import { Button, Stack } from '@/mui/components'; + +function ButtonsFileLoader({ allowedExtensions = ['.csv'], ...props }) { + return ( + !!props.dictionary.url && ( + + + + ) + ); +} + +ButtonsFileLoader.propTypes = { + allowedExtensions: PropTypes.arrayOf(PropTypes.string), + dictionary: PropTypes.shape({ + url: PropTypes.string, + bucket: PropTypes.string, + key: PropTypes.string, + }).isRequired, + dictionaryName: PropTypes.string.isRequired, + uploadConfigFileAction: PropTypes.func.isRequired, +}; + +export default ButtonsFileLoader; diff --git a/anyclip/src/modules/configuration/components/dictionary/dictionary.jsx b/anyclip/src/modules/configuration/components/dictionary/dictionary.jsx new file mode 100644 index 0000000..4d4902f --- /dev/null +++ b/anyclip/src/modules/configuration/components/dictionary/dictionary.jsx @@ -0,0 +1,405 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import { InfoOutlined } from '@mui/icons-material'; + +import { iabClassificationCategoriesCollection, numberInRange } from '../../helpers'; +import { dictionaryBlockInfoComputedSelector } from '../../redux/selectors'; +import { + saveConfigurationDataOnServerAction, + updateConfigurationByPropAction, + uploadConfigFileAction, +} from '../../redux/slices'; + +import ButtonsFileLoader from './buttonsFileLoader'; +import MultiButtonsFileLoader from './multiButtonsFileLoader'; +import { + Autocomplete, + Button, + CircularProgress, + Paper, + Stack, + Switch, + TextField, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './dictionary.module.scss'; + +const brandSafetyCategoriesCollection = [ + { + name: 'BRAND_SAFETY', + value: 'BRAND_SAFETY', + }, +]; + +function Dictionary() { + const dispatch = useDispatch(); + const dictionaryBlockInfo = useSelector(dictionaryBlockInfoComputedSelector); + + const onBlurInput = ({ min, max, isFloat, fieldName, value }) => { + const resultValue = numberInRange({ + min, + max, + isFloat, + value, + }); + + dispatch( + updateConfigurationByPropAction({ + [fieldName]: Number.isNaN(resultValue) ? min : resultValue, + }), + ); + }; + + const onChangeInput = ({ min, max, isFloat, fieldName, value }) => { + const resultValue = numberInRange({ + min, + max, + isFloat, + value, + }); + + dispatch( + updateConfigurationByPropAction({ + [fieldName]: Number.isNaN(resultValue) ? null : resultValue, + }), + ); + }; + const onChangeCheckbox = (fieldName, value) => { + dispatch( + updateConfigurationByPropAction({ + [fieldName]: value, + }), + ); + }; + + const fileLoaders = [ + { + title: 'Keywords Blacklist', + description: 'Source keywords to remove from analysis', + dictionary: dictionaryBlockInfo.dictionaryBlacklistFile, + dictionaryName: 'dictionaryBlacklistFile', + allowedExtensions: ['.csv'], + }, + { + title: 'Keywords Map', + description: 'Mapping diverse keywords into standardized dictionary', + dictionary: dictionaryBlockInfo.dictionaryMapFile, + dictionaryName: 'dictionaryMapFile', + allowedExtensions: ['.csv'], + }, + { + title: 'Keywords Categorization', + + description: + 'List of keywords from one category (like General) that should be processed as other category (like Brand Safety)', + dictionary: dictionaryBlockInfo.dictionaryCategorizationFile, + dictionaryName: 'dictionaryCategorizationFile', + allowedExtensions: ['.csv'], + }, + { + title: 'Final Filter', + description: 'Thresholds for final tag filtering (per category)', + dictionary: dictionaryBlockInfo.dictionaryFilterFile, + dictionaryName: 'dictionaryFilterFile', + allowedExtensions: ['.csv'], + }, + { + title: 'Brand Safety Tags', + description: 'Mapping of unsafe keywords into BrandSafety tags/groups', + dictionary: dictionaryBlockInfo.dictionaryBrandSafetyFile, + dictionaryName: 'dictionaryBrandSafetyFile', + allowedExtensions: ['.csv'], + }, + { + title: 'Keywords to IAB Map', + description: 'Mapping keywords into their corresponding IAB categories', + dictionary: dictionaryBlockInfo.classificationIabMapFile, + dictionaryName: 'classificationIabMapFile', + allowedExtensions: ['.csv'], + }, + { + title: 'Iab merging weights', + description: 'Iab merging (correction) weights', + dictionary: dictionaryBlockInfo.classificationIabWeightsFile, + dictionaryName: 'classificationIabWeightsFile', + allowedExtensions: ['.csv'], + }, + { + title: 'Celebrities to IAB Map', + description: 'Mapping celebrities into their corresponding IAB categories', + dictionary: dictionaryBlockInfo.classificationIabCelebritiesMapFile, + dictionaryName: 'classificationIabCelebritiesMapFile', + allowedExtensions: ['.csv'], + }, + ]; + + const multiFileLoaders = [ + { + title: 'Text to Brand Safety Map', + description: 'Mapping text into their corresponding BrandSafety tags', + dictionary: dictionaryBlockInfo.dictionaryBrandSafetyText, + dictionaryName: 'dictionaryBrandSafetyText', + allowedExtensions: ['.csv'], + }, + { + title: 'Speech to Brand Safety Map', + description: 'Mapping speech into their corresponding BrandSafety tags', + dictionary: dictionaryBlockInfo.dictionaryBrandSafetySpeech, + dictionaryName: 'dictionaryBrandSafetySpeech', + allowedExtensions: ['.csv'], + }, + ]; + + const inputsFormCollection = [ + { + title: 'IAB classification count', + value: dictionaryBlockInfo.classificationIabCount, + propStateName: 'classificationIabCount', + min: 0, + step: 1, + max: 100, + isFloat: false, + }, + { + title: 'IAB classification lower bound', + value: dictionaryBlockInfo.classificationIabLowerbound, + propStateName: 'classificationIabLowerbound', + min: 0, + step: 0.01, + max: 1, + isFloat: true, + }, + { + title: 'IAB classification keywords lower bound', + value: dictionaryBlockInfo.classificationKeywordsLowerbound, + propStateName: 'classificationKeywordsLowerbound', + min: 0, + step: 0.01, + max: 1, + isFloat: true, + }, + { + title: 'IAB classification keywords count', + value: dictionaryBlockInfo.classificationKeywordsCount, + propStateName: 'classificationKeywordsCount', + min: 0, + step: 1, + max: 100, + isFloat: false, + }, + ]; + + const checkboxFormCollection = [ + { + title: 'Enable IAB categorization algorithm', + checked: dictionaryBlockInfo.enableIabCategorizationAlgorithm, + propStateName: 'enableIabCategorizationAlgorithm', + }, + { + title: 'Enable IAB keywords merging algorithm', + checked: dictionaryBlockInfo.enableIabKeywordsMergingAlgorithm, + propStateName: 'enableIabKeywordsMergingAlgorithm', + }, + ]; + + const multiSelectFormCollection = [ + { + title: 'Brand Safety categories', + options: brandSafetyCategoriesCollection.map((item) => ({ + label: item.name, + value: item.value, + })), + value: dictionaryBlockInfo.dictionaryBrandSafetyCategories.map((item) => ({ + label: item, + value: item, + })), + dictionary: 'dictionaryBrandSafetyCategories', + }, + { + title: 'IAB classification categories', + options: iabClassificationCategoriesCollection.map((item) => ({ + label: item.name, + value: item.value, + })), + value: dictionaryBlockInfo.dictionaryIabClassificationCategories.map((item) => ({ + label: item, + value: item, + })), + dictionary: 'dictionaryIabClassificationCategories', + }, + ]; + + return ( +
    + {dictionaryBlockInfo.isLoading && ( +
    + +
    + )} +
    + + + {fileLoaders.map((item$) => ( +
    + +
    {item$.title}
    + + + +
    +
    + dispatch(uploadConfigFileAction())} + /> +
    +
    + ))} + {multiFileLoaders.map((item$) => ( +
    + + {item$.title} + + + + +
    + dispatch(uploadConfigFileAction())} + /> +
    +
    + ))} +
    +
    + + + {inputsFormCollection.map((item$) => ( +
    + + {item$.title} + +
    + + onChangeInput({ + min: item$.min, + max: item$.max, + isFloat: item$.isFloat, + fieldName: item$.propStateName, + value: event.target.value, + }) + } + onBlur={(event) => + onBlurInput({ + min: item$.min, + max: item$.max, + isFloat: item$.isFloat, + fieldName: item$.propStateName, + value: event.target.value, + }) + } + /> +
    +
    + ))} + {checkboxFormCollection.map((item$) => ( +
    + + {item$.title} + +
    + onChangeCheckbox(item$.propStateName, !item$.checked)} + /> +
    +
    + ))} + + {multiSelectFormCollection.map((item$) => ( +
    + + {item$.title} + +
    + { + dispatch( + updateConfigurationByPropAction({ + [item$.dictionary]: elements.map((el) => el.label), + }), + ); + + return elements; + }} + renderInput={(params) => } + /> +
    +
    + ))} + + + +
    +
    +
    +
    + ); +} + +export default Dictionary; diff --git a/anyclip/src/modules/configuration/components/dictionary/dictionary.module.scss b/anyclip/src/modules/configuration/components/dictionary/dictionary.module.scss new file mode 100644 index 0000000..f023874 --- /dev/null +++ b/anyclip/src/modules/configuration/components/dictionary/dictionary.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"dictionary_Wrapper__7UdF6","Content":"dictionary_Content__e3Yfr","Content_item":"dictionary_Content_item__cRdTE","SpinnerWrapper":"dictionary_SpinnerWrapper__rSvre","Block":"dictionary_Block__8ZSxz","Item":"dictionary_Item__RMV5r","Item___title":"dictionary_Item___title__uft8G","Item___value":"dictionary_Item___value__vVNVd"}; \ No newline at end of file diff --git a/anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.jsx b/anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.jsx new file mode 100644 index 0000000..e026615 --- /dev/null +++ b/anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.jsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { DownloadRounded, UploadRounded } from '@mui/icons-material'; + +import { Button, MenuItem, Select, Stack } from '@/mui/components'; + +import styles from './multiButtonsFileLoader.module.scss'; + +function MultiButtonsFileLoader({ allowedExtensions = ['.csv'], ...props }) { + const [selected, setSelected] = useState(0); + + const selectedItem = props.dictionary[selected]; + + return ( + !!selectedItem && ( + + + + + + + ) + ); +} + +MultiButtonsFileLoader.propTypes = { + allowedExtensions: PropTypes.arrayOf(PropTypes.string), + dictionary: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.shape({ + url: PropTypes.string, + bucket: PropTypes.string, + key: PropTypes.string, + }), + }), + ).isRequired, + dictionaryName: PropTypes.string.isRequired, + uploadConfigFileAction: PropTypes.func.isRequired, +}; + +export default MultiButtonsFileLoader; diff --git a/anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.module.scss b/anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.module.scss new file mode 100644 index 0000000..cf9bef3 --- /dev/null +++ b/anyclip/src/modules/configuration/components/dictionary/multiButtonsFileLoader.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Select":"multiButtonsFileLoader_Select__Chs0m"}; \ No newline at end of file diff --git a/anyclip/src/modules/configuration/components/index.jsx b/anyclip/src/modules/configuration/components/index.jsx new file mode 100644 index 0000000..d0c511a --- /dev/null +++ b/anyclip/src/modules/configuration/components/index.jsx @@ -0,0 +1,47 @@ +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { getConfigurationDataAction } from '../redux/slices'; + +import Models from '@/modules/configuration/components/model/Models'; +import Dictionary from './dictionary/dictionary'; +import { Stack, Typography } from '@/mui/components'; + +import styles from './index.module.scss'; + +function ConfigurationPage() { + const dispatch = useDispatch(); + const sections = [ + { + title: 'Dictionary', + component: Dictionary, + }, + { + title: 'Content analysis models', + component: Models, + }, + ]; + + useEffect(() => { + dispatch(getConfigurationDataAction()); + }, []); + + return ( + + {sections.map((item$) => { + const Component = item$.component; + + return ( + + {item$.title} +
    + +
    +
    + ); + })} +
    + ); +} + +export default ConfigurationPage; diff --git a/anyclip/src/modules/configuration/components/index.module.scss b/anyclip/src/modules/configuration/components/index.module.scss new file mode 100644 index 0000000..3bc5bbd --- /dev/null +++ b/anyclip/src/modules/configuration/components/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"components_Wrapper__N3aeq"}; \ No newline at end of file diff --git a/anyclip/src/modules/configuration/components/model/Models.jsx b/anyclip/src/modules/configuration/components/model/Models.jsx new file mode 100644 index 0000000..b6666d6 --- /dev/null +++ b/anyclip/src/modules/configuration/components/model/Models.jsx @@ -0,0 +1,488 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; + +import { iabClassificationCategoriesCollection, numberInRange } from '../../helpers'; +import { modelBlockInfoComputedSelector } from '../../redux/selectors'; +import { + onAddFormAction, + onChangeModelFormAction, + onChangeModelTempFormAction, + onDeleteFormAction, + onUpdateFormAction, +} from '../../redux/slices'; + +import { Button, CircularProgress, MenuItem, Paper, Select, Stack, TextField, Typography } from '@/mui/components'; + +import styles from './Models.module.scss'; + +const orderItems = ['model', 'category', 'lowerBound', 'maxResults']; + +const getFormCollectionData = (providers, models) => { + const { providerName, formList, tempForm } = models; + const providerModels = providers.find(($el) => $el.provider === providerName)?.models ?? []; + + return { + providerName, + formsList: formList.map((form) => ({ + formId: form.formId, + model: { + options: providerModels.map((item) => ({ ...item })), + selected: form.model, + }, + category: { + options: iabClassificationCategoriesCollection.map((item) => ({ ...item })), + selected: form.category, + }, + lowerBound: { + value: form.lowerBound, + }, + maxResults: { + value: form.maxResults, + }, + })), + tempForm, + }; +}; + +const returnRowTitle = () => ( +
    +
    + Model +
    +
    + Category +
    +
    + Lower bound +
    +
    + Max results +
    +
    + Actions +
    +
    +); + +function Models() { + const dispatch = useDispatch(); + const modelBlockInfo = useSelector(modelBlockInfoComputedSelector); + + const amazonFormCollection = getFormCollectionData(modelBlockInfo.providers, modelBlockInfo.awsRecognitionModels); + const clarifaiFormCollection = getFormCollectionData(modelBlockInfo.providers, modelBlockInfo.clarifaiModels); + const googleFormCollection = getFormCollectionData(modelBlockInfo.providers, modelBlockInfo.googleModels); + const weavoFormCollection = getFormCollectionData(modelBlockInfo.providers, modelBlockInfo.weavoModels); + + const returnRows = ({ providerName, formsList, tempForm }) => { + const onBlurInput = ({ formId, fieldName, min, max, isFloat, value }) => { + const resultValue = numberInRange({ + min, + max, + isFloat, + value, + }); + + let result = resultValue; + + if (Number.isNaN(resultValue)) { + result = fieldName === 'maxResults' ? null : min; + } + + dispatch( + onChangeModelFormAction({ + fieldName, + providerName, + formId, + value: result, + }), + ); + }; + + const onChangeInput = ({ formId, fieldName, min, max, isFloat, value }) => { + const resultValue = numberInRange({ + min, + max, + isFloat, + value, + }); + + dispatch( + onChangeModelFormAction({ + fieldName, + providerName, + formId, + value: Number.isNaN(resultValue) ? null : resultValue, + }), + ); + }; + + const onChangeSelect = (formId, fieldName, event) => { + const targetForm = formsList.find((form) => form.formId === formId); + const { options } = targetForm[fieldName]; + + dispatch( + onChangeModelFormAction({ + fieldName, + providerName, + formId, + value: options[event.target.selectedIndex].value, + }), + ); + }; + + const onChangeEditInput = ({ fieldName, value, min, max, isFloat }) => { + const resultValue = numberInRange({ + min, + max, + isFloat, + value, + }); + + dispatch( + onChangeModelTempFormAction({ + fieldName, + providerName, + value: { + value: Number.isNaN(resultValue) ? null : resultValue, + }, + }), + ); + }; + + const onBlurEditInput = ({ fieldName, value, min, max, isFloat }) => { + const resultValue = numberInRange({ + min, + max, + isFloat, + value, + }); + + let result = resultValue; + + if (Number.isNaN(resultValue)) { + result = fieldName === 'maxResults' ? null : min; + } + + dispatch( + onChangeModelTempFormAction({ + fieldName, + providerName, + value: { + value: result, + }, + }), + ); + }; + + const onChangeEditSelect = (fieldName, event) => { + const { options } = tempForm[fieldName]; + + dispatch( + onChangeModelTempFormAction({ + fieldName, + providerName, + value: { + selected: options[event.target.selectedIndex].value, + }, + }), + ); + }; + + const formComponents = formsList.map((dataRow) => { + const { formId } = dataRow; + + const rowElement = orderItems.map((fieldName) => { + const oneFieldInfo = dataRow[fieldName]; + + let input; + + if (fieldName === 'model' || fieldName === 'category') { + input = ( + + ); + } else { + const isFloatInput = fieldName === 'lowerBound'; + const minInput = 0; + const stepInput = isFloatInput ? 0.01 : 1; + const maxInput = isFloatInput ? 1 : 100; + + input = ( + + onChangeInput({ + min: minInput, + max: maxInput, + isFloat: isFloatInput, + formId, + fieldName, + value: event.target.value, + }) + } + onBlur={(event) => + onBlurInput({ + min: minInput, + max: maxInput, + isFloat: isFloatInput, + formId, + fieldName, + value: event.target.value, + }) + } + /> + ); + } + + return ( +
    + {input} +
    + ); + }); + + rowElement.push( +
    + + + + +
    , + ); + + return ( +
    + {rowElement} +
    + ); + }); + + if (tempForm) { + formComponents.push( +
    +
    + +
    +
    + +
    +
    + + onChangeEditInput({ + fieldName: 'lowerBound', + value: event.target.value, + min: 0, + max: 1, + isFloat: true, + }) + } + onBlur={(event) => + onBlurEditInput({ + fieldName: 'lowerBound', + value: event.target.value, + min: 0, + max: 1, + isFloat: true, + }) + } + /> +
    +
    + + onChangeEditInput({ + fieldName: 'maxResults', + value: event.target.value, + min: 0, + max: 100, + isFloat: false, + }) + } + onBlur={(event) => + onBlurEditInput({ + fieldName: 'maxResults', + value: event.target.value, + min: 0, + max: 100, + isFloat: false, + }) + } + /> +
    +
    + +
    +
    , + ); + } + + return formComponents; + }; + + return ( + + {modelBlockInfo.isLoading && ( +
    + +
    + )} + + + + AWS Models + +
    + {returnRowTitle()} + {returnRows(amazonFormCollection)} +
    +
    +
    + + + + Clarifai Models + +
    + {returnRowTitle()} + {returnRows(clarifaiFormCollection)} +
    +
    +
    + + + + Google Models + +
    + {returnRowTitle()} + {returnRows(googleFormCollection)} +
    +
    +
    + + + + WEAVO Models + +
    + {returnRowTitle()} + {returnRows(weavoFormCollection)} +
    +
    +
    +
    + ); +} + +export default Models; diff --git a/anyclip/src/modules/configuration/components/model/Models.module.scss b/anyclip/src/modules/configuration/components/model/Models.module.scss new file mode 100644 index 0000000..3b2e177 --- /dev/null +++ b/anyclip/src/modules/configuration/components/model/Models.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Models_Wrapper__ejTv3","SpinnerWrapper":"Models_SpinnerWrapper__SN4gL","BlockContentAnalytics":"Models_BlockContentAnalytics__2Fy7A","BlockTable":"Models_BlockTable__swNfP","Row":"Models_Row__zcU6U","Row___title":"Models_Row___title__2_mpN","Cell":"Models_Cell__nP56I","Cell___big":"Models_Cell___big__OObqB","Input":"Models_Input__6pNp7"}; \ No newline at end of file diff --git a/anyclip/src/modules/configuration/helpers/index.js b/anyclip/src/modules/configuration/helpers/index.js new file mode 100644 index 0000000..2affb1f --- /dev/null +++ b/anyclip/src/modules/configuration/helpers/index.js @@ -0,0 +1,62 @@ +export const modelsByProviders = { + AMAZON: 'awsRecognitionModels', + CLARIFAI: 'clarifaiModels', + GOOGLE: 'googleModels', + WEAVO: 'weavoModels', +}; + +export const iabClassificationCategoriesCollection = [ + { + name: 'BRANDS', + value: 'BRANDS', + }, + { + name: 'BRAND_SAFETY', + value: 'BRAND_SAFETY', + }, + { + name: 'KEYWORDS', + value: 'KEYWORDS', + }, + { + name: 'PEOPLE', + value: 'PEOPLE', + }, + { + name: 'TEXT', + value: 'TEXT', + }, +]; + +export const getDefaultTempForm = (providers, providerName) => { + const providerModels = providers.find(($el) => $el.provider === providerName)?.models ?? []; + + return { + model: { + options: providerModels.map((item) => ({ ...item })), + selected: providerModels[0]?.value ?? '', + }, + category: { + options: iabClassificationCategoriesCollection.map((item) => ({ ...item })), + selected: iabClassificationCategoriesCollection[0].value, + }, + lowerBound: { + value: '0.5', + }, + maxResults: { + value: '', + }, + }; +}; + +export const numberInRange = ({ min, max, isFloat, value }) => { + let resultValue = isFloat ? parseFloat(value) : parseInt(value, 10); + + if (resultValue < min) { + resultValue = min; + } else if (resultValue > max) { + resultValue = max; + } + + return resultValue; +}; diff --git a/anyclip/src/modules/configuration/index.js b/anyclip/src/modules/configuration/index.js new file mode 100644 index 0000000..1647609 --- /dev/null +++ b/anyclip/src/modules/configuration/index.js @@ -0,0 +1,3 @@ +import ConfigurationPage from './components'; + +export default ConfigurationPage; diff --git a/anyclip/src/modules/configuration/redux/epics/addModelForm.js b/anyclip/src/modules/configuration/redux/epics/addModelForm.js new file mode 100644 index 0000000..05281cf --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/addModelForm.js @@ -0,0 +1,39 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getDefaultTempForm, modelsByProviders } from '../../helpers'; +import { getAllAnalyticsModelsComputedSelector, getProvidersListComputedSelector } from '../selectors'; +import { onAddFormAction, saveConfigurationDataOnServerAction, updateConfigurationByPropAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(onAddFormAction.type), + switchMap(({ payload: { providerName } }) => { + const providerModelName = modelsByProviders[providerName]; + const model = getAllAnalyticsModelsComputedSelector(state$.value)[providerModelName]; + const { providers } = getProvidersListComputedSelector(state$.value); + const { tempForm } = model; + + const updatedFormList = { + ...model, + formList: model.formList.concat({ + formId: `${Math.random()}-${Math.random()}`, + model: tempForm.model.selected, + category: tempForm.category.selected, + lowerBound: tempForm.lowerBound.value, + maxResults: tempForm.maxResults.value, + }), + tempForm: getDefaultTempForm(providers, providerName), + }; + + return concat( + of( + updateConfigurationByPropAction({ + [providerModelName]: updatedFormList, + }), + ), + of(saveConfigurationDataOnServerAction([providerModelName])), + ); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/epics/changeModelForm.js b/anyclip/src/modules/configuration/redux/epics/changeModelForm.js new file mode 100644 index 0000000..507f96f --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/changeModelForm.js @@ -0,0 +1,37 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { modelsByProviders } from '../../helpers'; +import { getAllAnalyticsModelsComputedSelector } from '../selectors'; +import { onChangeModelFormAction, updateConfigurationByPropAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(onChangeModelFormAction.type), + switchMap(({ payload: { providerName, fieldName, formId, value } }) => { + const providerModelName = modelsByProviders[providerName]; + const model = getAllAnalyticsModelsComputedSelector(state$.value)[providerModelName]; + + const updatedFormList = { + ...model, + formList: model.formList.map((form) => { + const updatedForm = { ...form }; + + if (form.formId === formId) { + updatedForm[fieldName] = value; + } + + return updatedForm; + }), + }; + + return concat( + of( + updateConfigurationByPropAction({ + [providerModelName]: updatedFormList, + }), + ), + ); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/epics/changeTempModelForm.js b/anyclip/src/modules/configuration/redux/epics/changeTempModelForm.js new file mode 100644 index 0000000..a9ec3ec --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/changeTempModelForm.js @@ -0,0 +1,33 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { modelsByProviders } from '../../helpers'; +import { getAllAnalyticsModelsComputedSelector } from '../selectors'; +import { onChangeModelTempFormAction, updateConfigurationByPropAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(onChangeModelTempFormAction.type), + switchMap(({ payload: { providerName, fieldName, value } }) => { + const providerModelName = modelsByProviders[providerName]; + const model = getAllAnalyticsModelsComputedSelector(state$.value)[providerModelName]; + + const copyTempForm = { ...model.tempForm }; + + copyTempForm[fieldName] = { ...copyTempForm[fieldName], ...value }; + + const updatedFormList = { + ...model, + tempForm: copyTempForm, + }; + + return concat( + of( + updateConfigurationByPropAction({ + [providerModelName]: updatedFormList, + }), + ), + ); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/epics/deleteModelForm.js b/anyclip/src/modules/configuration/redux/epics/deleteModelForm.js new file mode 100644 index 0000000..ddab192 --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/deleteModelForm.js @@ -0,0 +1,30 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { modelsByProviders } from '../../helpers'; +import { getAllAnalyticsModelsComputedSelector } from '../selectors'; +import { onDeleteFormAction, saveConfigurationDataOnServerAction, updateConfigurationByPropAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(onDeleteFormAction.type), + switchMap(({ payload: { providerName, formId } }) => { + const providerModelName = modelsByProviders[providerName]; + const model = getAllAnalyticsModelsComputedSelector(state$.value)[providerModelName]; + + const updatedFormList = { + ...model, + formList: model.formList.filter((form) => form.formId !== formId), + }; + + return concat( + of( + updateConfigurationByPropAction({ + [providerModelName]: updatedFormList, + }), + ), + of(saveConfigurationDataOnServerAction([providerModelName])), + ); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/epics/getConfiguration.js b/anyclip/src/modules/configuration/redux/epics/getConfiguration.js new file mode 100644 index 0000000..2467810 --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/getConfiguration.js @@ -0,0 +1,208 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getDefaultTempForm } from '../../helpers'; +import { getConfigurationDataAction, setConfigurationDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getModelsConfig { + getModelsConfig { + data { + provider + models { + name + value + } + } + } + getDictionaryConfig { + awsRecognitionModels { + model + category + lowerBound + maxResults + } + clarifaiModels { + model + category + lowerBound + maxResults + } + googleModels { + model + category + lowerBound + maxResults + } + weavoModels { + model + category + lowerBound + maxResults + } + dictionaryBrandSafetyCategories + dictionaryIabClassificationCategories + notifications { + type + state + email { + title + message + replyTo { + address + name + } + } + } + dictionaryBrandSafetySpeech { + grouped { + label + value { + bucket + key + url + } + } + } + dictionaryBrandSafetyText { + grouped { + label + value { + bucket + key + url + } + } + } + dictionaryBlacklistFile { + bucket + key + url + } + dictionaryMapFile { + bucket + key + url + } + dictionaryCategorizationFile { + bucket + key + url + } + dictionaryFilterFile { + bucket + key + url + } + dictionaryBrandSafetyFile { + bucket + key + url + } + htmlParsingConfigFile { + bucket + key + url + } + classificationIabWeightsFile { + bucket + key + url + } + classificationIabCelebritiesMapFile { + bucket + key + url + } + classificationIabMapFile { + bucket + key + url + } + classificationKeywordsLowerbound + classificationKeywordsCount + classificationIabCount + classificationIabLowerbound + enableIabKeywordsMergingAlgorithm + enableIabCategorizationAlgorithm + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getConfigurationDataAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query: queryGQL, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const providers = data.getModelsConfig?.data ?? []; + + const { + awsRecognitionModels, + clarifaiModels, + googleModels, + weavoModels, + dictionaryBrandSafetyText, + dictionaryBrandSafetySpeech, + } = data.getDictionaryConfig; + + actions.push( + of( + setConfigurationDataAction({ + ...data.getDictionaryConfig, + ...{ + awsRecognitionModels: { + providerName: 'AMAZON', + formList: awsRecognitionModels.map((item) => ({ + ...item, + formId: `${Math.random()}-${Math.random()}`, + })), + tempForm: getDefaultTempForm(providers, 'AMAZON'), + }, + clarifaiModels: { + providerName: 'CLARIFAI', + formList: clarifaiModels.map((item) => ({ + ...item, + formId: `${Math.random()}-${Math.random()}`, + })), + tempForm: getDefaultTempForm(providers, 'CLARIFAI'), + }, + googleModels: { + providerName: 'GOOGLE', + formList: googleModels.map((item) => ({ + ...item, + formId: `${Math.random()}-${Math.random()}`, + })), + tempForm: getDefaultTempForm(providers, 'GOOGLE'), + }, + weavoModels: { + providerName: 'WEAVO', + formList: weavoModels.map((item) => ({ + ...item, + formId: `${Math.random()}-${Math.random()}`, + })), + tempForm: getDefaultTempForm(providers, 'WEAVO'), + }, + dictionaryBrandSafetyText: dictionaryBrandSafetyText.grouped, + dictionaryBrandSafetySpeech: dictionaryBrandSafetySpeech.grouped, + }, + providers, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/epics/index.js b/anyclip/src/modules/configuration/redux/epics/index.js new file mode 100644 index 0000000..f9c54d2 --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/index.js @@ -0,0 +1,21 @@ +import { combineEpics } from 'redux-observable'; + +import addModelForm from './addModelForm'; +import changeModelForm from './changeModelForm'; +import changeTempModelForm from './changeTempModelForm'; +import deleteModelForm from './deleteModelForm'; +import getConfiguration from './getConfiguration'; +import saveConfigurationDataOnServerAction from './saveConfigurationDataOnServer'; +import updateModelForm from './updateModelForm'; +import uploadS3ConfigurationFile from './uploadS3ConfigurationFile'; + +export default combineEpics( + addModelForm, + updateModelForm, + changeModelForm, + deleteModelForm, + getConfiguration, + changeTempModelForm, + uploadS3ConfigurationFile, + saveConfigurationDataOnServerAction, +); diff --git a/anyclip/src/modules/configuration/redux/epics/saveConfigurationDataOnServer.js b/anyclip/src/modules/configuration/redux/epics/saveConfigurationDataOnServer.js new file mode 100644 index 0000000..9a94cb0 --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/saveConfigurationDataOnServer.js @@ -0,0 +1,253 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { saveConfigurationDataOnServerAction, updateConfigurationByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query saveConfiguration( + $awsRecognitionModels: [inputModelDictionaryObject], + $clarifaiModels: [inputModelDictionaryObject], + $googleModels: [inputModelDictionaryObject], + $weavoModels: [inputModelDictionaryObject], + $dictionaryBrandSafetyCategories: [String], + $dictionaryIabClassificationCategories: [String], + $dictionaryBrandSafetySpeech: inputDictionaryBrandSafetyObjectType, + $dictionaryBrandSafetyText: inputDictionaryBrandSafetyObjectType, + $dictionaryBlacklistFile: inputFileDictionaryObjectType, + $dictionaryMapFile: inputFileDictionaryObjectType, + $dictionaryCategorizationFile: inputFileDictionaryObjectType, + $dictionaryFilterFile: inputFileDictionaryObjectType, + $dictionaryBrandSafetyFile: inputFileDictionaryObjectType, + $htmlParsingConfigFile: inputFileDictionaryObjectType, + $classificationIabWeightsFile: inputFileDictionaryObjectType, + $classificationIabCelebritiesMapFile: inputFileDictionaryObjectType, + $classificationIabMapFile: inputFileDictionaryObjectType, + $classificationKeywordsLowerbound: Float, + $classificationKeywordsCount: Float, + $classificationIabCount: Float, + $classificationIabLowerbound: Float, + $enableIabKeywordsMergingAlgorithm: Boolean, + $enableIabCategorizationAlgorithm: Boolean, + ) { + saveConfigurationDictionary( + awsRecognitionModels: $awsRecognitionModels, + clarifaiModels: $clarifaiModels, + googleModels: $googleModels, + weavoModels: $weavoModels, + dictionaryBrandSafetyCategories: $dictionaryBrandSafetyCategories, + dictionaryIabClassificationCategories: $dictionaryIabClassificationCategories, + dictionaryBrandSafetySpeech: $dictionaryBrandSafetySpeech, + dictionaryBrandSafetyText: $dictionaryBrandSafetyText, + dictionaryBlacklistFile: $dictionaryBlacklistFile, + dictionaryMapFile: $dictionaryMapFile, + dictionaryCategorizationFile: $dictionaryCategorizationFile, + dictionaryFilterFile: $dictionaryFilterFile, + dictionaryBrandSafetyFile: $dictionaryBrandSafetyFile, + htmlParsingConfigFile: $htmlParsingConfigFile, + classificationIabWeightsFile: $classificationIabWeightsFile, + classificationIabCelebritiesMapFile: $classificationIabCelebritiesMapFile, + classificationIabMapFile: $classificationIabMapFile, + classificationKeywordsLowerbound: $classificationKeywordsLowerbound, + classificationKeywordsCount: $classificationKeywordsCount, + classificationIabCount: $classificationIabCount, + classificationIabLowerbound: $classificationIabLowerbound, + enableIabKeywordsMergingAlgorithm: $enableIabKeywordsMergingAlgorithm, + enableIabCategorizationAlgorithm: $enableIabCategorizationAlgorithm + ) { + awsRecognitionModels { + model + category + lowerBound + maxResults + } + clarifaiModels { + model + category + lowerBound + maxResults + } + googleModels { + model + category + lowerBound + maxResults + } + weavoModels { + model + category + lowerBound + maxResults + } + dictionaryBrandSafetyCategories + dictionaryIabClassificationCategories + notifications { + type + state + email { + title + message + replyTo { + address + name + } + } + } + dictionaryBrandSafetySpeech { + grouped { + label + value { + bucket + key + url + } + } + } + dictionaryBrandSafetyText { + grouped { + label + value { + bucket + key + url + } + } + } + dictionaryBlacklistFile { + bucket + key + url + } + dictionaryMapFile { + bucket + key + url + } + dictionaryCategorizationFile { + bucket + key + url + } + dictionaryFilterFile { + bucket + key + url + } + dictionaryBrandSafetyFile { + bucket + key + url + } + htmlParsingConfigFile { + bucket + key + url + } + classificationIabWeightsFile { + bucket + key + url + } + classificationIabCelebritiesMapFile { + bucket + key + url + } + classificationIabMapFile { + bucket + key + url + } + classificationKeywordsLowerbound + classificationKeywordsCount + classificationIabCount + classificationIabLowerbound + enableIabKeywordsMergingAlgorithm + enableIabCategorizationAlgorithm + } + } +`; + +const allAnalyticsModels = ['awsRecognitionModels', 'clarifaiModels', 'googleModels', 'weavoModels']; + +const allFilesProps = [ + 'dictionaryBlacklistFile', + 'dictionaryMapFile', + 'dictionaryCategorizationFile', + 'dictionaryFilterFile', + 'dictionaryBrandSafetyFile', + 'classificationIabWeightsFile', + 'classificationIabCelebritiesMapFile', + 'classificationIabMapFile', +]; + +export default (action$, state$) => + action$.pipe( + ofType(saveConfigurationDataOnServerAction.type), + switchMap(({ payload }) => { + const state = state$.value; + + const propsToRequest = payload.reduce((acc, rawKey) => { + const key = `${rawKey}Selector`; + + // eslint-disable-next-line import/namespace + let value = selectors[key](state); + + if (rawKey === 'dictionaryBrandSafetyText' || rawKey === 'dictionaryBrandSafetySpeech') { + value = { + // eslint-disable-next-line import/namespace + grouped: selectors[key](state).map((item) => ({ + label: item.label, + value: { + bucket: item.value.bucket, + key: item.value.key, + }, + })), + }; + } else if (allAnalyticsModels.find((prop) => prop === rawKey)) { + // eslint-disable-next-line import/namespace + value = selectors[key](state).formList.map((item) => ({ + model: item.model, + category: item.category, + lowerBound: item.lowerBound, + maxResults: item.maxResults, + })); + } else if (allFilesProps.find((prop) => prop === rawKey)) { + value = { + // eslint-disable-next-line import/namespace + bucket: selectors[key](state).bucket, + // eslint-disable-next-line import/namespace + key: selectors[key](state).key, + }; + } + return { ...acc, [rawKey]: value }; + }, {}); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...propsToRequest, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateConfigurationByPropAction({ + isLoading: false, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/epics/updateModelForm.js b/anyclip/src/modules/configuration/redux/epics/updateModelForm.js new file mode 100644 index 0000000..23368d6 --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/updateModelForm.js @@ -0,0 +1,16 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { modelsByProviders } from '../../helpers'; +import { onUpdateFormAction, saveConfigurationDataOnServerAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(onUpdateFormAction.type), + switchMap(({ payload: { providerName } }) => { + const providerModelName = modelsByProviders[providerName]; + + return concat(of(saveConfigurationDataOnServerAction([providerModelName]))); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/epics/uploadS3ConfigurationFile.js b/anyclip/src/modules/configuration/redux/epics/uploadS3ConfigurationFile.js new file mode 100644 index 0000000..cbe3e78 --- /dev/null +++ b/anyclip/src/modules/configuration/redux/epics/uploadS3ConfigurationFile.js @@ -0,0 +1,172 @@ +import { ofType } from 'redux-observable'; +import { concat, merge, of, Subject } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, concatMap, map, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { dictionaryBlockInfoComputedSelector } from '../selectors'; +import { + saveConfigurationDataOnServerAction, + updateConfigurationByPropAction, + uploadConfigFileAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query s3ConfigurationFileUploadLink($filename: String!, $bucket: String!, $key: String!) { + s3ConfigurationFileUploadLink(filename: $filename, bucket: $bucket, key: $key) { + uploadUrl + downloadUrl + filename + bucket + key + } + } +`; + +const replaceFileName = (string, filename) => { + const keySplit = string.split('/'); + const key = keySplit.slice(0, keySplit.length - 1); + key.push(filename); + + return key.join('/'); +}; + +export default (action$, state$) => + action$.pipe( + ofType(uploadConfigFileAction.type), + switchMap(({ payload }) => { + const { allowedExtensions, dictionary, dictionaryName, fileInfo, multipleLabel } = payload; + + const filename = fileInfo.name; + + const nameSplit = filename.split('.'); + + if (allowedExtensions.indexOf(`.${nameSplit[nameSplit.length - 1]}`) === -1) { + return of( + showNotificationAction({ + type: TYPE_ERROR, + message: `Only ${allowedExtensions.join(', ')} files are supported`, + }), + ); + } + + const propBucket = dictionaryBlockInfoComputedSelector(state$.value)[dictionaryName]; + let newBucketInfo = null; + + if (multipleLabel) { + newBucketInfo = propBucket.map((item) => { + if (item.label === multipleLabel) { + return { + label: item.label, + value: { + bucket: item.value.bucket, + key: replaceFileName(item.value.key, filename), + url: replaceFileName(item.value.url, filename), + }, + }; + } + return { ...item }; + }); + } else { + newBucketInfo = { + bucket: propBucket.bucket, + key: replaceFileName(propBucket.key, filename), + url: replaceFileName(propBucket.url, filename), + }; + } + + const keySplit = dictionary.key.split('/'); + keySplit[keySplit.length - 1] = filename; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + filename, + bucket: dictionary.bucket, + key: keySplit.join('/'), + }, + }).pipe( + switchMap(({ data, errors }) => { + if (!errors.length) { + const { uploadUrl } = data.s3ConfigurationFileUploadLink; + + const progressSubscriber = new Subject(); + + const fileUploadRequest = ajax({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Type': 'application/octet-stream', + }, + crossDomain: true, + withCredentials: true, + body: fileInfo, + progressSubscriber, + }); + + const requestObservable = fileUploadRequest.pipe( + concatMap(({ status }) => { + if (status === 200) { + return concat( + of( + updateConfigurationByPropAction({ + [dictionaryName]: newBucketInfo, + }), + ), + of(saveConfigurationDataOnServerAction([dictionaryName])), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: `File ${filename} uploaded`, + }), + ), + ); + } + + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Something went wrong', + }), + ), + ); + }), + ); + + const fileUploadSteam = merge( + progressSubscriber.pipe(map((e) => ({ percentage: e.loaded / e.total }))), + requestObservable, + ).pipe( + catchError((error) => + concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: error ?? 'Something went wrong', + }), + ), + ), + ), + ); + + return concat(fileUploadSteam); + } + + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: errors[0].message ?? 'Something went wrong', + }), + ), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/configuration/redux/selectors/index.js b/anyclip/src/modules/configuration/redux/selectors/index.js new file mode 100644 index 0000000..ea056c4 --- /dev/null +++ b/anyclip/src/modules/configuration/redux/selectors/index.js @@ -0,0 +1,80 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const providersSelector = (state) => state[nameSpace].providers; +export const awsRecognitionModelsSelector = (state) => state[nameSpace].awsRecognitionModels; +export const clarifaiModelsSelector = (state) => state[nameSpace].clarifaiModels; +export const googleModelsSelector = (state) => state[nameSpace].googleModels; +export const weavoModelsSelector = (state) => state[nameSpace].weavoModels; +export const dictionaryBrandSafetyCategoriesSelector = (state) => state[nameSpace].dictionaryBrandSafetyCategories; +export const dictionaryIabClassificationCategoriesSelector = (state) => + state[nameSpace].dictionaryIabClassificationCategories; +export const notificationsSelector = (state) => state[nameSpace].notifications; +export const dictionaryBrandSafetySpeechSelector = (state) => state[nameSpace].dictionaryBrandSafetySpeech; +export const dictionaryBrandSafetyTextSelector = (state) => state[nameSpace].dictionaryBrandSafetyText; +export const dictionaryBlacklistFileSelector = (state) => state[nameSpace].dictionaryBlacklistFile; +export const dictionaryMapFileSelector = (state) => state[nameSpace].dictionaryMapFile; +export const dictionaryCategorizationFileSelector = (state) => state[nameSpace].dictionaryCategorizationFile; +export const dictionaryFilterFileSelector = (state) => state[nameSpace].dictionaryFilterFile; +export const dictionaryBrandSafetyFileSelector = (state) => state[nameSpace].dictionaryBrandSafetyFile; +export const htmlParsingConfigFileSelector = (state) => state[nameSpace].htmlParsingConfigFile; +export const classificationIabWeightsFileSelector = (state) => state[nameSpace].classificationIabWeightsFile; +export const classificationIabCelebritiesMapFileSelector = (state) => + state[nameSpace].classificationIabCelebritiesMapFile; +export const classificationIabMapFileSelector = (state) => state[nameSpace].classificationIabMapFile; +export const classificationKeywordsLowerboundSelector = (state) => state[nameSpace].classificationKeywordsLowerbound; +export const classificationKeywordsCountSelector = (state) => state[nameSpace].classificationKeywordsCount; +export const classificationIabCountSelector = (state) => state[nameSpace].classificationIabCount; +export const classificationIabLowerboundSelector = (state) => state[nameSpace].classificationIabLowerbound; +export const enableIabKeywordsMergingAlgorithmSelector = (state) => state[nameSpace].enableIabKeywordsMergingAlgorithm; +export const enableIabCategorizationAlgorithmSelector = (state) => state[nameSpace].enableIabCategorizationAlgorithm; + +export const dictionaryBlockInfoComputedSelector = (state) => ({ + isLoading: isLoadingSelector(state), + weavoModels: weavoModelsSelector(state), + googleModels: googleModelsSelector(state), + clarifaiModels: clarifaiModelsSelector(state), + awsRecognitionModels: awsRecognitionModelsSelector(state), + dictionaryBrandSafetyCategories: dictionaryBrandSafetyCategoriesSelector(state), + dictionaryIabClassificationCategories: dictionaryIabClassificationCategoriesSelector(state), + notifications: notificationsSelector(state), + dictionaryBrandSafetySpeech: dictionaryBrandSafetySpeechSelector(state), + dictionaryBrandSafetyText: dictionaryBrandSafetyTextSelector(state), + dictionaryBlacklistFile: dictionaryBlacklistFileSelector(state), + dictionaryMapFile: dictionaryMapFileSelector(state), + dictionaryCategorizationFile: dictionaryCategorizationFileSelector(state), + dictionaryFilterFile: dictionaryFilterFileSelector(state), + dictionaryBrandSafetyFile: dictionaryBrandSafetyFileSelector(state), + htmlParsingConfigFile: htmlParsingConfigFileSelector(state), + classificationIabWeightsFile: classificationIabWeightsFileSelector(state), + classificationIabCelebritiesMapFile: classificationIabCelebritiesMapFileSelector(state), + classificationIabMapFile: classificationIabMapFileSelector(state), + classificationKeywordsLowerbound: classificationKeywordsLowerboundSelector(state), + classificationKeywordsCount: classificationKeywordsCountSelector(state), + classificationIabCount: classificationIabCountSelector(state), + classificationIabLowerbound: classificationIabLowerboundSelector(state), + enableIabKeywordsMergingAlgorithm: enableIabKeywordsMergingAlgorithmSelector(state), + enableIabCategorizationAlgorithm: enableIabCategorizationAlgorithmSelector(state), +}); + +export const modelBlockInfoComputedSelector = (state) => ({ + isLoading: isLoadingSelector(state), + providers: providersSelector(state), + awsRecognitionModels: awsRecognitionModelsSelector(state), + clarifaiModels: clarifaiModelsSelector(state), + googleModels: googleModelsSelector(state), + weavoModels: weavoModelsSelector(state), +}); + +export const getAllAnalyticsModelsComputedSelector = (state) => ({ + awsRecognitionModels: awsRecognitionModelsSelector(state), + clarifaiModels: clarifaiModelsSelector(state), + googleModels: googleModelsSelector(state), + weavoModels: weavoModelsSelector(state), +}); + +export const getProvidersListComputedSelector = (state) => ({ + providers: providersSelector(state), +}); diff --git a/anyclip/src/modules/configuration/redux/slices/index.js b/anyclip/src/modules/configuration/redux/slices/index.js new file mode 100644 index 0000000..23800cb --- /dev/null +++ b/anyclip/src/modules/configuration/redux/slices/index.js @@ -0,0 +1,86 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + isLoading: true, + providers: [], + awsRecognitionModels: { + providerName: 'AMAZON', + formList: [], + tempForm: null, + }, + clarifaiModels: { + providerName: 'CLARIFAI', + formList: [], + tempForm: null, + }, + googleModels: { + providerName: 'GOOGLE', + formList: [], + tempForm: null, + }, + weavoModels: { + providerName: 'WEAVO', + formList: [], + tempForm: null, + }, + dictionaryBrandSafetyCategories: [], + dictionaryIabClassificationCategories: [], + notifications: [], + dictionaryBrandSafetySpeech: [], + dictionaryBrandSafetyText: [], + dictionaryBlacklistFile: {}, + dictionaryMapFile: {}, + dictionaryCategorizationFile: {}, + dictionaryFilterFile: {}, + dictionaryBrandSafetyFile: {}, + htmlParsingConfigFile: {}, + classificationIabWeightsFile: {}, + classificationIabCelebritiesMapFile: {}, + classificationIabMapFile: {}, + classificationKeywordsLowerbound: 0, + classificationKeywordsCount: 0, + classificationIabCount: 0, + classificationIabLowerbound: 0, + enableIabKeywordsMergingAlgorithm: false, + enableIabCategorizationAlgorithm: false, +}; + +export const slice = createSlice({ + name: '@@CONFIG_PAGE', + initialState, + reducers: { + getConfigurationDataAction: (state) => state, + updateConfigurationByPropAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + uploadConfigFileAction: (state) => state, + setConfigurationDataAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + + state.isLoading = false; + }, + saveConfigurationDataOnServerAction: (state) => state, + onUpdateFormAction: (state) => state, + onDeleteFormAction: (state) => state, + onAddFormAction: (state) => state, + onChangeModelFormAction: (state) => state, + onChangeModelTempFormAction: (state) => state, + }, +}); + +export const { + getConfigurationDataAction, + updateConfigurationByPropAction, + uploadConfigFileAction, + setConfigurationDataAction, + saveConfigurationDataOnServerAction, + onUpdateFormAction, + onDeleteFormAction, + onAddFormAction, + onChangeModelFormAction, + onChangeModelTempFormAction, +} = slice.actions; diff --git a/anyclip/src/modules/contentOwners/Editor/components/Editor.jsx b/anyclip/src/modules/contentOwners/Editor/components/Editor.jsx new file mode 100644 index 0000000..c9d6ec4 --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/components/Editor.jsx @@ -0,0 +1,133 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_GENERAL } from '../constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const name = useSelector(selectors.nameSelector); + + const id = parseInt(router.query.id, 10); + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( +
    + + + {id ? `${name} > Settings` : 'New Content Owner'} + + + + {tabs.length > 1 && ( + + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/contentOwners/Editor/components/Editor.module.scss b/anyclip/src/modules/contentOwners/Editor/components/Editor.module.scss new file mode 100644 index 0000000..a8d5959 --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__REURe","Title":"Editor_Title__oWqMo","Controls":"Editor_Controls__B_p69","Tabs":"Editor_Tabs__IzQjP"}; \ No newline at end of file diff --git a/anyclip/src/modules/contentOwners/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/contentOwners/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..6f3489a --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { TYPE_PUBLISHER, TYPES_OPTIONS } from '../../../../List/constants'; +import { PUBLIC_ACCOUNT_TYPE } from '@/modules/contentOwners/Editor/constants'; + +import * as selectors from '../../../redux/selectors'; +import { getAccountOptionsAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, MenuItem, Select, Stack, Switch, TextField, Typography } from '@/mui/components'; + +const COMMENTS_MAX_LENGTH = 255; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + // selectors + const name = useSelector(selectors.nameSelector); + const salesforceId = useSelector(selectors.salesforceIdSelector); + const comments = useSelector(selectors.commentsSelector); + const playFromCo = useSelector(selectors.playFromCoSelector); + const callToAction = useSelector(selectors.callToActionSelector); + const type = useSelector(selectors.typeSelector); + const status = useSelector(selectors.statusSelector); + const isPublic = useSelector(selectors.isPublicSelector); + const feedPriority = useSelector(selectors.feedPrioritySelector); + + const account = useSelector(selectors.accountSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + + const scheme = useSelector(selectors.schemeSelector); + + const notAllowedConnectPublicCoWithNonPublicAccount = account && !!isPublic && account?.type !== PUBLIC_ACCOUNT_TYPE; + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + handleSetState({ salesforceId: e.target.value })} + {...getInputPropsByName(scheme, ['salesforceId'])} + onFocus={() => dispatch(removeErrorByPropAction(['salesforceId']))} + /> + + + handleSetState({ comments: e.target.value })} + /> + + + + handleSetState({ + playFromCo: target.checked, + }) + } + /> + + + + handleSetState({ + callToAction: target.checked, + }) + } + /> + + + + { + handleSetState({ + account: selected, + }); + }} + onOpen={() => { + dispatch(getAccountOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getAccountOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['account']))} + /> + )} + /> + {notAllowedConnectPublicCoWithNonPublicAccount && ( + + You cannot connect a public content owner to an account of this type. Please set Public to OFF for select + account. + + )} + + + + + + + + + handleSetState({ + status: e.target.checked, + }) + } + /> + {!status && ( + + Please make sure to archive associated sources + + )} + + + + + handleSetState({ + isPublic: e.target.checked, + }) + } + /> + + + handleSetState({ feedPriority: e.target.value })} + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/contentOwners/Editor/constants/index.js b/anyclip/src/modules/contentOwners/Editor/constants/index.js new file mode 100644 index 0000000..815cdd2 --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/constants/index.js @@ -0,0 +1,4 @@ +export const TAB_GENERAL = 'general'; +export const REDUX_FIELD_NAME = 'commonForm'; + +export const PUBLIC_ACCOUNT_TYPE = 'SYNDICATION'; diff --git a/anyclip/src/modules/contentOwners/Editor/helpers/validationScheme.js b/anyclip/src/modules/contentOwners/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..2bbeebb --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/helpers/validationScheme.js @@ -0,0 +1,45 @@ +import { TAB_GENERAL } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'salesforceId', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length !== 15) { + return 'Must be 15 alphanumeric characters'; + } + + return ''; + }, + }, + { + fieldName: 'account', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/contentOwners/Editor/redux/epics/createItem.js b/anyclip/src/modules/contentOwners/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..4d4cd6f --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/redux/epics/createItem.js @@ -0,0 +1,72 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_ITEM } from '../../../../../graphql/services/contentOwners/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/contentOwners/types/payload/item'; + +import * as selectors from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${CREATE_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const name = selectors.nameSelector(state$.value); + const salesforceId = selectors.salesforceIdSelector(state$.value); + const comments = selectors.commentsSelector(state$.value); + const playFromCo = selectors.playFromCoSelector(state$.value); + const callToAction = selectors.callToActionSelector(state$.value); + const type = selectors.typeSelector(state$.value); + const status = selectors.statusSelector(state$.value); + const isPublic = selectors.isPublicSelector(state$.value); + const feedPriority = selectors.feedPrioritySelector(state$.value); + const account = selectors.accountSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + name, + salesforceId, + comments, + playFromCo, + callToAction, + type, + status: status ? 1 : 0, + isPublic: isPublic ? 1 : 0, + feedPriority: +feedPriority, + accountId: account.id, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/content-owners'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Content owner created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/contentOwners/Editor/redux/epics/getAccountOptions.js b/anyclip/src/modules/contentOwners/Editor/redux/epics/getAccountOptions.js new file mode 100644 index 0000000..4cd71cb --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/redux/epics/getAccountOptions.js @@ -0,0 +1,72 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ACCOUNTS_OPTIONS } from '../../../../../graphql/services/contentOwners/constants'; +import { TYPE_BUSINESS, TYPE_PUBLISHER, TYPE_SYNDICATION, TYPE_VAST } from '@/modules/@common/constants/account'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/contentOwners/types/payload/accounts'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_ACCOUNTS_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_ACCOUNTS_OPTIONS}(payload: $payload) { + id + name + type + } + } +`; + +const getResponse = ({ data }) => + data[GET_ACCOUNTS_OPTIONS].map((account) => ({ + ...account, + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload, + pageSize: 30, + filtersValues: [TYPE_PUBLISHER, TYPE_BUSINESS, TYPE_VAST, TYPE_SYNDICATION], + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push( + of( + setAction({ + accountOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + accountOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/contentOwners/Editor/redux/epics/getItem.js b/anyclip/src/modules/contentOwners/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..8b31fe1 --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/redux/epics/getItem.js @@ -0,0 +1,107 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ITEM } from '../../../../../graphql/services/contentOwners/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/contentOwners/types/payload/item'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_ITEM}($payload: ${PAYLOAD_NAME}) { + ${GET_ITEM}(payload: $payload) { + name + status + type + comments + account_id + account_name + account_type + call_to_action + play_from_co + isPublic + feed_priority + salesforce_id + } + } +`; + +const getResponse = ({ data }) => { + const item = data[GET_ITEM]; + + return { + name: item.name, + status: item.status, + type: item.type, + comments: item.comments, + account: item.account_id + ? { + id: item.account_id, + value: item.account_id, + label: item.account_name || 'Empty', + type: item.account_type, + } + : null, + callToAction: !!item.call_to_action, + playFromCo: !!item.play_from_co, + isPublic: item.isPublic, + feedPriority: item.feed_priority, + salesforceId: item.salesforce_id, + }; +}; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: action.payload.id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open for edit", + }), + ), + ); + + Router.push('/content-owners'); + } else { + const data = getResponse(response); + + actions.push( + of( + setAction({ + ...data, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/contentOwners/Editor/redux/epics/index.js b/anyclip/src/modules/contentOwners/Editor/redux/epics/index.js new file mode 100644 index 0000000..5a1c590 --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getAccountOptions from './getAccountOptions'; +import getItem from './getItem'; +import updateItem from './updateItem'; + +export default combineEpics(getAccountOptions, createItem, updateItem, getItem); diff --git a/anyclip/src/modules/contentOwners/Editor/redux/epics/updateItem.js b/anyclip/src/modules/contentOwners/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..52bcb05 --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/redux/epics/updateItem.js @@ -0,0 +1,73 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_ITEM } from '../../../../../graphql/services/contentOwners/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/contentOwners/types/payload/item'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const name = selectors.nameSelector(state$.value); + const salesforceId = selectors.salesforceIdSelector(state$.value); + const comments = selectors.commentsSelector(state$.value); + const playFromCo = selectors.playFromCoSelector(state$.value); + const callToAction = selectors.callToActionSelector(state$.value); + const type = selectors.typeSelector(state$.value); + const status = selectors.statusSelector(state$.value); + const isPublic = selectors.isPublicSelector(state$.value); + const feedPriority = selectors.feedPrioritySelector(state$.value); + const account = selectors.accountSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: action.payload, + name, + salesforceId, + comments, + playFromCo, + callToAction, + type, + status: status ? 1 : 0, + isPublic: isPublic ? 1 : 0, + feedPriority: +feedPriority, + accountId: account.id, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/content-owners'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Content owner updated', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/contentOwners/Editor/redux/selectors/index.js b/anyclip/src/modules/contentOwners/Editor/redux/selectors/index.js new file mode 100644 index 0000000..56ad94e --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/redux/selectors/index.js @@ -0,0 +1,26 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const idSelector = (state) => state[nameSpace].id; +export const nameSelector = (state) => state[nameSpace].name; +export const salesforceIdSelector = (state) => state[nameSpace].salesforceId; +export const commentsSelector = (state) => state[nameSpace].comments; +export const playFromCoSelector = (state) => state[nameSpace].playFromCo; +export const callToActionSelector = (state) => state[nameSpace].callToAction; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +export const typeSelector = (state) => state[nameSpace].type; +export const statusSelector = (state) => state[nameSpace].status; +export const isPublicSelector = (state) => state[nameSpace].isPublic; +export const feedPrioritySelector = (state) => state[nameSpace].feedPriority; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +// forms +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/contentOwners/Editor/redux/slices/index.js b/anyclip/src/modules/contentOwners/Editor/redux/slices/index.js new file mode 100644 index 0000000..e4589e2 --- /dev/null +++ b/anyclip/src/modules/contentOwners/Editor/redux/slices/index.js @@ -0,0 +1,68 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { IS_PUBLIC_NO, STATUSES_ACTIVE, TYPE_PUBLISHER } from '../../../List/constants'; +import { REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + name: '', + salesforceId: '', + comments: '', + playFromCo: false, + callToAction: false, + account: null, + accountOptions: null, + type: TYPE_PUBLISHER, + status: STATUSES_ACTIVE, + isPublic: IS_PUBLIC_NO, + feedPriority: 0.5, + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@CONTENT_OWNERS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getAccountOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getAccountOptionsAction, + createItemAction, + updateItemAction, + + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/contentOwners/List/components/Empty/Empty.jsx b/anyclip/src/modules/contentOwners/List/components/Empty/Empty.jsx new file mode 100644 index 0000000..23927b4 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/components/Empty/Empty.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { AddRounded } from '@mui/icons-material'; + +import { Button, Grid, Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +function Empty() { + const router = useRouter(); + return ( + + + empty-logo + + Click below to create content owner + + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/contentOwners/List/components/Empty/Empty.module.scss b/anyclip/src/modules/contentOwners/List/components/Empty/Empty.module.scss new file mode 100644 index 0000000..9293614 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__0XSjB","EmptyContent":"Empty_EmptyContent__YswWI"}; \ No newline at end of file diff --git a/anyclip/src/modules/contentOwners/List/components/List.jsx b/anyclip/src/modules/contentOwners/List/components/List.jsx new file mode 100644 index 0000000..6919a34 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/components/List.jsx @@ -0,0 +1,306 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useRouter } from 'next/router'; +import { AddRounded, ExpandMoreRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; + +import { + CALL_TO_ACTIONS_OPTIONS, + IS_PUBLIC_OPTIONS, + PLAY_FROM_CO_OPTIONS, + SEARCH_TEXT_MAX_LENGTH, + STATUSES_ACTIVE, + STATUSES_ALL, + STATUSES_INACTIVE, + STATUSES_OPTIONS, + TYPES_OPTIONS, +} from '../constants'; + +import { getConfigHeaders } from '../helpers'; +import * as computedState from '../helpers/computedState'; +import * as selectors from '../redux/selectors'; +import { bulkDisableOrActiveAction, getDataAction, setAction, setTableAction } from '../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable from '@/modules/@common/Table'; +import Empty from './Empty/Empty'; +import { + Autocomplete, + Button, + Checkbox, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './List.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +function getValue(options, value) { + return options.find((option) => option.value === value)?.label ?? 'Unknown'; +} + +function List() { + const router = useRouter(); + const dispatch = useDispatch(); + + const [actions, setActions] = useState(''); + + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + + const search = useSelector(selectors.searchSelector); + const status = useSelector(selectors.statusSelector); + + const selected = useSelector(selectors.selectedSelector); + const shouldShowEmpty = useSelector(computedState.shouldShowEmpty); + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + const handleSelectDeselectAllRows = (checked) => { + dispatch( + setTableAction({ + selected: checked ? data.map((r) => r.id) : [], + }), + ); + }; + + const handleSelectDeselectRow = (rowId) => { + const selectedIndex = selected.indexOf(rowId); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, rowId); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + dispatch( + setTableAction({ + selected: newSelected, + }), + ); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + + + {actions && ( + setActions('')}> + { + dispatch(bulkDisableOrActiveAction(STATUSES_ACTIVE)); + setActions(''); + }} + > + Active + + { + dispatch(bulkDisableOrActiveAction(STATUSES_INACTIVE)); + setActions(''); + }} + > + Disable + + + )} +
    + handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
    + + + + + s.value === status) ?? STATUSES_ALL} + options={STATUSES_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ status: selected$?.value ?? STATUSES_ALL, page: 1 })} + renderInput={(params) => } + /> + + } + renderActions={ + + + + } + > + {shouldShowEmpty ? ( + + ) : ( + { + const isItemSelected = selectedRows.includes(row.id); + + return ( + router.push(`/content-owners/${row.id}`)} + > + + { + event.stopPropagation(); + onSelectDeselectRow(row.id); + }} + /> + + +
    {row.id}
    +
    + +
    {row.name}
    +
    + +
    {getValue(STATUSES_OPTIONS, row.status)}
    +
    + +
    {getValue(TYPES_OPTIONS, row.type)}
    +
    + +
    + + {row.comments} + +
    +
    + +
    {`${row.activeFeedsCount} / ${row.feedsCount}`}
    +
    + +
    {row.salesforce_id}
    +
    + +
    {getValue(CALL_TO_ACTIONS_OPTIONS, row.call_to_action)}
    +
    + +
    {getValue(PLAY_FROM_CO_OPTIONS, row.play_from_co)}
    +
    + +
    {getValue(IS_PUBLIC_OPTIONS, row.isPublic)}
    +
    + +
    {dayjs(row.update_date).format('MMM D, YYYY hh:mm A')}
    +
    + +
    {row.updated_by}
    +
    +
    + ); + }} + data={data || []} + selected={selected} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onSelectDeselectAllRows={handleSelectDeselectAllRows} + onSelectDeselectRow={handleSelectDeselectRow} + onFilter={handleFilter} + /> + )} +
    + ); +} + +export default List; diff --git a/anyclip/src/modules/contentOwners/List/components/List.module.scss b/anyclip/src/modules/contentOwners/List/components/List.module.scss new file mode 100644 index 0000000..06dbbb2 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"ActionsSelect":"List_ActionsSelect__eqAxa","SearchField":"List_SearchField__wnKcJ","AccountSelect":"List_AccountSelect__d3j_I","RolesSelect":"List_RolesSelect__ssq79","StatusSelect":"List_StatusSelect__iUwjn","Row":"List_Row__azPxu","NoWrap":"List_NoWrap__SBBzq","CommentsWrap":"List_CommentsWrap__r3pv_","Actions":"List_Actions__o7z6I"}; \ No newline at end of file diff --git a/anyclip/src/modules/contentOwners/List/constants/index.js b/anyclip/src/modules/contentOwners/List/constants/index.js new file mode 100644 index 0000000..46e6c74 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/constants/index.js @@ -0,0 +1,48 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = 0; +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Inactive', value: STATUSES_INACTIVE }, +]; + +export const TYPE_STUDIO = 1; +export const TYPE_PUBLISHER = 2; +export const TYPE_BROADCASTER = 3; +export const TYPE_BUSINESS = 4; +export const TYPE_SYNDICATOR = 5; +export const TYPE_LIVEEVENT = 99; +export const TYPES_OPTIONS = [ + { label: 'Publisher', value: TYPE_PUBLISHER }, + { label: 'Business', value: TYPE_BUSINESS }, + { label: 'Syndicator', value: TYPE_SYNDICATOR }, + { label: 'Broadcaster', value: TYPE_BROADCASTER }, + { label: 'Studio', value: TYPE_STUDIO }, + { label: 'Live Event', value: TYPE_LIVEEVENT }, +]; + +export const CALL_TO_ACTIONS_OPTIONS = [ + { label: 'No', value: 0 }, + { label: 'Yes', value: 1 }, +]; + +export const PLAY_FROM_CO_OPTIONS = [ + { label: 'No', value: 0 }, + { label: 'Yes', value: 1 }, +]; + +export const IS_PUBLIC_NO = 0; +export const IS_PUBLIC_YES = 1; +export const IS_PUBLIC_OPTIONS = [ + { label: 'No', value: IS_PUBLIC_NO }, + { label: 'Yes', value: IS_PUBLIC_YES }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'id'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/src/modules/xRay/campaigns/List/helpers/computedState.js b/anyclip/src/modules/contentOwners/List/helpers/computedState.js similarity index 100% rename from src/modules/xRay/campaigns/List/helpers/computedState.js rename to anyclip/src/modules/contentOwners/List/helpers/computedState.js diff --git a/anyclip/src/modules/contentOwners/List/helpers/index.js b/anyclip/src/modules/contentOwners/List/helpers/index.js new file mode 100644 index 0000000..115ca80 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/helpers/index.js @@ -0,0 +1,73 @@ +export const getConfigHeaders = () => [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '100', + }, + { + id: 'name', + label: 'Name', + sortable: true, + width: '255', + }, + { + id: 'status', + label: 'Status', + sortable: true, + width: '100', + }, + { + id: 'type', + label: 'Type', + sortable: true, + width: '100', + }, + { + id: 'comments', + label: 'Comments', + sortable: true, + width: '500', + }, + { + id: 'activeFeedsCount', + label: 'Sources', + width: '100', + }, + { + id: 'salesforce_id', + label: 'Salesforce ID', + sortable: true, + width: '150', + }, + { + id: 'call_to_action', + label: 'Call To Action', + sortable: true, + width: '80', + }, + { + id: 'play_from_co', + label: 'Play from CO', + sortable: true, + width: '80', + }, + { + id: 'isPublic', + label: 'Public', + sortable: true, + width: '80', + }, + { + id: 'update_date', + label: 'Updated Date', + sortable: true, + width: '100', + }, + { + id: 'updated_by', + label: 'Updated By', + sortable: true, + width: '100', + }, +]; diff --git a/anyclip/src/modules/contentOwners/List/redux/epics/bulkActionDisableOrActive.js b/anyclip/src/modules/contentOwners/List/redux/epics/bulkActionDisableOrActive.js new file mode 100644 index 0000000..0e7dc10 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/redux/epics/bulkActionDisableOrActive.js @@ -0,0 +1,67 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, filter, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { BULK_ACTION_DISABLE_OR_ACTIVE } from '../../../../../graphql/services/contentOwners/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/contentOwners/types/payload/bulkActionDisableOrActive'; + +import * as selectors from '../selectors'; +import { bulkDisableOrActiveAction, getDataAction, setTableAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${BULK_ACTION_DISABLE_OR_ACTIVE} ($payload: ${PAYLOAD_NAME}) { + ${BULK_ACTION_DISABLE_OR_ACTIVE}(payload: $payload) { + ids + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(bulkDisableOrActiveAction.type), + filter(() => { + const selected = selectors.selectedSelector(state$.value); + + return selected.length > 0; + }), + switchMap((action) => { + const selected = selectors.selectedSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + ids: selected, + status: action.payload, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Action completed successfully', + }), + ), + of( + setTableAction({ + selected: [], + }), + ), + of(getDataAction()), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/contentOwners/List/redux/epics/getData.js b/anyclip/src/modules/contentOwners/List/redux/epics/getData.js new file mode 100644 index 0000000..2f38b62 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/redux/epics/getData.js @@ -0,0 +1,65 @@ +import { GET_CONTENT_OWNERS } from '../../../../../graphql/services/contentOwners/constants'; +import { STATUSES_ALL } from '../../constants'; + +import { PAYLOAD_NAME } from '../../../../../graphql/services/contentOwners/types/payload/contentOwners'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_CONTENT_OWNERS}($payload: ${PAYLOAD_NAME}) { + ${GET_CONTENT_OWNERS}(payload: $payload) { + records { + id + name + status + type + comments + activeFeedsCount + feedsCount + salesforce_id + call_to_action + play_from_co + isPublic + update_date + updated_by + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = status; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const users = data[GET_CONTENT_OWNERS]; + + return { + records: users.records, + recordsTotal: users.recordsTotal, + allRecordsCount: users.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/contentOwners/List/redux/epics/index.js b/anyclip/src/modules/contentOwners/List/redux/epics/index.js new file mode 100644 index 0000000..c07dbe9 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import bulkActionDisableOrActive from './bulkActionDisableOrActive'; +import getData from './getData'; + +export default combineEpics(getData, bulkActionDisableOrActive); diff --git a/anyclip/src/modules/contentOwners/List/redux/selectors/index.js b/anyclip/src/modules/contentOwners/List/redux/selectors/index.js new file mode 100644 index 0000000..cd1bbd4 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/redux/selectors/index.js @@ -0,0 +1,21 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const statusSelector = (state) => state[nameSpace].status; +export const searchSelector = (state) => state[nameSpace].search; diff --git a/anyclip/src/modules/contentOwners/List/redux/slices/index.js b/anyclip/src/modules/contentOwners/List/redux/slices/index.js new file mode 100644 index 0000000..76ce923 --- /dev/null +++ b/anyclip/src/modules/contentOwners/List/redux/slices/index.js @@ -0,0 +1,40 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ACTIVE, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + status: STATUSES_ACTIVE, +}; + +export const slice = createSlice({ + name: '@@CONTENT_OWNERS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + bulkDisableOrActiveAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, bulkDisableOrActiveAction } = slice.actions; diff --git a/anyclip/src/modules/customReports/Editor/components/Editor.jsx b/anyclip/src/modules/customReports/Editor/components/Editor.jsx new file mode 100644 index 0000000..3775210 --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/components/Editor.jsx @@ -0,0 +1,147 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_GENERAL } from '../constants'; + +import { getCanReadOnly } from '../helpers/getRestrictions'; +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const uiName = useSelector(selectors.uiNameSelector); + const activeTabId = useSelector(selectors.activeTabIdSelector); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const id = parseInt(router.query.id, 10); + + const canReadOnly = getCanReadOnly(id, userPermissions); + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( +
    + + + {id ? `${uiName} > Settings` : 'New Dashboard'} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + + {!canReadOnly && ( + + )} + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/customReports/Editor/components/Editor.module.scss b/anyclip/src/modules/customReports/Editor/components/Editor.module.scss new file mode 100644 index 0000000..580ce9a --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__oeCiY","Title":"Editor_Title__UAzFI","Controls":"Editor_Controls__7C_xB","Tabs":"Editor_Tabs__gaA16"}; \ No newline at end of file diff --git a/anyclip/src/modules/customReports/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/customReports/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..ebc078f --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,191 @@ +import React, { useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUSES_ACTIVE, STATUSES_INACTIVE } from '../../../../List/constants'; +import { OPTION_ALL, OPTION_ALL_ID } from '../../../constants'; + +import { getCanReadOnly } from '../../../helpers/getRestrictions'; +import * as selectors from '../../../redux/selectors'; +import { getAccountOptionsAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Switch, TextField } from '@/mui/components'; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + // selectors + const account = useSelector(selectors.accountSelector); + const allSites = useSelector(selectors.allSitesSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + const publisherIds = useSelector(selectors.publisherIdsSelector); + const lookerReportId = useSelector(selectors.lookerReportIdSelector); + const uiName = useSelector(selectors.uiNameSelector); + const description = useSelector(selectors.descriptionSelector); + const enabled = useSelector(selectors.enabledSelector); + const scheme = useSelector(selectors.schemeSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + + const publisherOptions = useMemo(() => [{ ...OPTION_ALL }, ...(account?.publishers || [])], [account?.publishers]); + + const hubValue = useMemo(() => { + let value = []; + + if (allSites) { + value = [{ ...OPTION_ALL }]; + } else if (publisherIds?.length) { + value = publisherIds.map((id$) => { + const name = account?.publishers?.find((p) => p.id === id$)?.name ?? ''; + + return { + name, + id: id$, + }; + }); + } + + return value; + }, [account?.publishers, allSites, publisherIds]); + + const id = parseInt(router.query.id, 10); + const canReadOnly = getCanReadOnly(id, userPermissions); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + ( + dispatch(removeErrorByPropAction(['account']))} + /> + )} + filterSelectedOptions + onOpen={() => dispatch(getAccountOptionsAction())} + onInputChange={(event) => { + if (event) { + dispatch(getAccountOptionsAction({ searchText: event.target.value })); + } + }} + onChange={(e, account$) => + handleSetState({ + account: account$, + publisherIds: [], + }) + } + /> + + + ( + dispatch(removeErrorByPropAction(['publisherIds']))} + /> + )} + onChange={(e, publisherIds$, reason, single) => { + const isAll = single?.option?.id === OPTION_ALL_ID; + + handleSetState({ + allSites: reason !== 'removeOption' && isAll ? 1 : 0, + publisherIds: + reason === 'clear' || isAll + ? [] + : publisherIds$.filter((item) => item.id !== OPTION_ALL_ID).map((item) => item.id), + }); + }} + /> + + + + handleSetState({ + enabled: e.target.checked ? STATUSES_ACTIVE : STATUSES_INACTIVE, + }) + } + /> + + + handleSetState({ lookerReportId: e.target.value })} + {...getInputPropsByName(scheme, ['lookerReportId'])} + onFocus={() => dispatch(removeErrorByPropAction(['lookerReportId']))} + /> + + + + handleSetState({ uiName: e.target.value })} + {...getInputPropsByName(scheme, ['uiName'])} + onFocus={() => dispatch(removeErrorByPropAction(['uiName']))} + /> + + + + handleSetState({ description: e.target.value })} + {...getInputPropsByName(scheme, ['description'])} + label="" + onFocus={() => dispatch(removeErrorByPropAction(['description']))} + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/customReports/Editor/constants/index.js b/anyclip/src/modules/customReports/Editor/constants/index.js new file mode 100644 index 0000000..bd263d0 --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/constants/index.js @@ -0,0 +1,10 @@ +export const TAB_GENERAL = 'general'; + +export const REDUX_FIELD_NAME = 'commonForm'; + +export const OPTION_ALL_ID = -1; + +export const OPTION_ALL = { + id: OPTION_ALL_ID, + name: 'All Hubs', +}; diff --git a/anyclip/src/modules/customReports/Editor/helpers/getRestrictions.js b/anyclip/src/modules/customReports/Editor/helpers/getRestrictions.js new file mode 100644 index 0000000..2bbb479 --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/helpers/getRestrictions.js @@ -0,0 +1,9 @@ +import { PCN_GET_CUSTOM_REPORTS } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +export const getCanReadOnly = (id, userPermissions) => + !(id + ? // todo: fix permissions + hasPermission(PCN_GET_CUSTOM_REPORTS /* PCN_PUT_CUSTOM_REPORTS */, userPermissions) + : hasPermission(PCN_GET_CUSTOM_REPORTS /* PCN_POST_CUSTOM_REPORTS */, userPermissions)); diff --git a/anyclip/src/modules/customReports/Editor/helpers/validationScheme.js b/anyclip/src/modules/customReports/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..87b3368 --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/helpers/validationScheme.js @@ -0,0 +1,77 @@ +import { TAB_GENERAL } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'account', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'publisherIds', + tabId: TAB_GENERAL, + validation: (value, currentState) => { + if (!value.length && !currentState.allSites) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'lookerReportId', + tabId: TAB_GENERAL, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + if (value.length < 2) { + return 'Field cannot be less then 2 symbols'; + } + if (value.length > 50) { + return 'Looker Dashboard ID is too long'; + } + + return ''; + }, + }, + { + fieldName: 'uiName', + tabId: TAB_GENERAL, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + if (value.length < 2) { + return 'Field cannot be less then 2 symbols'; + } + if (value.length > 50) { + return 'Name is too long'; + } + + return ''; + }, + }, + { + fieldName: 'description', + tabId: TAB_GENERAL, + validation: (description) => { + const value = description?.trim(); + + if (value.length > 50) { + return 'Description is too long'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/customReports/Editor/redux/epics/createItem.js b/anyclip/src/modules/customReports/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..4a0b7a9 --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/redux/epics/createItem.js @@ -0,0 +1,77 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_ITEM } from '@/graphql/services/customReports/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/customReports/types/payload/item'; + +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { + accountSelector, + descriptionSelector, + enabledSelector, + iconSelector, + lookerReportIdSelector, + publisherIdsSelector, + uiNameSelector, +} from '@/modules/customReports/Editor/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` +mutation ${CREATE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${CREATE_ITEM}(payload: $payload) { + id + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const account = accountSelector(state$.value); + const description = descriptionSelector(state$.value); + const enabled = enabledSelector(state$.value); + const lookerReportId = lookerReportIdSelector(state$.value); + const publisherIds = publisherIdsSelector(state$.value); + const uiName = uiNameSelector(state$.value); + const icon = iconSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + accountId: account.id, + allSites: !publisherIds.length ? 1 : 0, + description, + enabled, + lookerReportId, + publisherIds, + uiName, + icon, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/custom-reports'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Dashboard created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/customReports/Editor/redux/epics/getAccountOptions.js b/anyclip/src/modules/customReports/Editor/redux/epics/getAccountOptions.js new file mode 100644 index 0000000..4e02e19 --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/redux/epics/getAccountOptions.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ACCOUNTS } from '@/graphql/services/customReports/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/customReports/types/payload/accounts'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_ACCOUNTS}($payload: ${PAYLOAD_NAME}) { + ${GET_ACCOUNTS}(payload: $payload) { + id + name + publishers { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_ACCOUNTS]; + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + switchMap((action) => { + const { searchText = '' } = action.payload ?? {}; + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText, + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push( + of( + setAction({ + accountOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/customReports/Editor/redux/epics/getItem.js b/anyclip/src/modules/customReports/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..b324c2e --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/redux/epics/getItem.js @@ -0,0 +1,83 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ITEM } from '@/graphql/services/customReports/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/customReports/types/payload/item'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_ITEM}($payload: ${PAYLOAD_NAME}) { + ${GET_ITEM}(payload: $payload) { + id + account { + id + name + publishers { + id + name + } + } + allSites + uiName + enabled + description + lookerReportId + publisherIds + icon + } + } +`; + +const getResponse = ({ data }) => data[GET_ITEM]; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: action.payload.id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open dashboard for edit", + }), + ), + ); + + Router.push('/custom-reports'); + } else { + const data = getResponse(response); + + actions.push(of(setAction(data))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/customReports/Editor/redux/epics/index.js b/anyclip/src/modules/customReports/Editor/redux/epics/index.js new file mode 100644 index 0000000..03a4b59 --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getAccountOptions from './getAccountOptions'; +import getItem from './getItem'; +import updateItem from './updateItem'; + +export default combineEpics(getAccountOptions, getItem, createItem, updateItem); diff --git a/anyclip/src/modules/customReports/Editor/redux/epics/updateItem.js b/anyclip/src/modules/customReports/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..2f4910b --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/redux/epics/updateItem.js @@ -0,0 +1,80 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_ITEM } from '@/graphql/services/customReports/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/customReports/types/payload/item'; + +import { + accountSelector, + descriptionSelector, + enabledSelector, + iconSelector, + lookerReportIdSelector, + publisherIdsSelector, + uiNameSelector, +} from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` +mutation ${UPDATE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_ITEM}(payload: $payload) { + id + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const account = accountSelector(state$.value); + const description = descriptionSelector(state$.value); + const enabled = enabledSelector(state$.value); + const lookerReportId = lookerReportIdSelector(state$.value); + const publisherIds = publisherIdsSelector(state$.value); + const uiName = uiNameSelector(state$.value); + const icon = iconSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: action.payload, + accountId: account.id, + allSites: !publisherIds.length ? 1 : 0, + description, + enabled, + lookerReportId, + publisherIds, + uiName, + icon, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/custom-reports'); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Dashboard updated', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/customReports/Editor/redux/selectors/index.js b/anyclip/src/modules/customReports/Editor/redux/selectors/index.js new file mode 100644 index 0000000..92cdbcd --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/redux/selectors/index.js @@ -0,0 +1,26 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; + +export const publisherIdsSelector = (state) => state[nameSpace].publisherIds; + +export const lookerReportIdSelector = (state) => state[nameSpace].lookerReportId; +export const uiNameSelector = (state) => state[nameSpace].uiName; +export const descriptionSelector = (state) => state[nameSpace].description; +export const enabledSelector = (state) => state[nameSpace].enabled; +export const iconSelector = (state) => state[nameSpace].icon; +export const allSitesSelector = (state) => state[nameSpace].allSites; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/customReports/Editor/redux/slices/index.js b/anyclip/src/modules/customReports/Editor/redux/slices/index.js new file mode 100644 index 0000000..6e394de --- /dev/null +++ b/anyclip/src/modules/customReports/Editor/redux/slices/index.js @@ -0,0 +1,72 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { STATUSES_ACTIVE } from '../../../List/constants'; +import { REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + account: null, + accountOptions: [], + publisherIds: [], + lookerReportId: null, + allSites: 0, + uiName: '', + description: '', + enabled: STATUSES_ACTIVE, + icon: '', + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@CUSTOM_DASHBOARD/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getAccountOptionsAction: (state) => state, + getDemandAccountsOptionsAction: (state) => state, + getTemplatePlayerOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getAccountOptionsAction, + createItemAction, + updateItemAction, + + setActiveTabIdAction, + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/customReports/List/components/Empty/Empty.jsx b/anyclip/src/modules/customReports/List/components/Empty/Empty.jsx new file mode 100644 index 0000000..12a33f6 --- /dev/null +++ b/anyclip/src/modules/customReports/List/components/Empty/Empty.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { AddRounded } from '@mui/icons-material'; + +import { Button, Grid, Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +function Empty() { + const router = useRouter(); + return ( + + + empty-logo + + Click below to create your first dashboard + + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/customReports/List/components/Empty/Empty.module.scss b/anyclip/src/modules/customReports/List/components/Empty/Empty.module.scss new file mode 100644 index 0000000..f74398a --- /dev/null +++ b/anyclip/src/modules/customReports/List/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__jsTi_","EmptyContent":"Empty_EmptyContent__X88a2"}; \ No newline at end of file diff --git a/anyclip/src/modules/customReports/List/components/List.jsx b/anyclip/src/modules/customReports/List/components/List.jsx new file mode 100644 index 0000000..11ccf6b --- /dev/null +++ b/anyclip/src/modules/customReports/List/components/List.jsx @@ -0,0 +1,286 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { AddRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; + +import { SEARCH_TEXT_MAX_LENGTH, STATUSES_ALL, STATUSES_OPTIONS, TABLE_HEADER } from '../constants'; +import { PCN_GET_CUSTOM_REPORTS } from '@/modules/@common/acl/constants'; +import { ACCOUNT } from '@/modules/@common/user/constants/rolesType'; + +import * as computedState from '../helpers/computedState'; +import * as selectors from '../redux/selectors'; +import { getAccountOptionsAction, getDataAction, setAction, setTableAction } from '../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector, getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable from '@/modules/@common/Table'; +import Empty from './Empty/Empty'; +import { + Autocomplete, + Button, + Checkbox, + Divider, + IconButton, + InputAdornment, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './List.module.scss'; + +// in current implementation phase table multi actions is disable +const TABLE_MULTIACTIONS_ENABLED = false; + +function List() { + const router = useRouter(); + + const dispatch = useDispatch(); + + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + + const search = useSelector(selectors.searchSelector); + const account = useSelector(selectors.accountSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + const status = useSelector(selectors.statusSelector); + + const selected = useSelector(selectors.selectedSelector); + + const shouldShowEmpty = useSelector(computedState.shouldShowEmpty); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const hasAccount = useSelector(getUserRoleTypeSelector) === ACCOUNT; + // todo: have to be post/ (issue with permissions for all PCN pages that migrates from pcn folder) + const canCreate = hasPermission(PCN_GET_CUSTOM_REPORTS, userPermissions); + + const accountIdFromUrl = router.query?.accountId; + const accountAutocompleteValue = + accountIdFromUrl && account?.value && !account?.label && data?.length + ? { value: accountIdFromUrl, label: data[0].accountName } + : account; + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + const handleSelectDeselectAllRows = (checked) => { + dispatch( + setTableAction({ + selected: checked ? data.map((r) => r.id) : [], + }), + ); + }; + + const handleSelectDeselectRow = (rowId) => { + const selectedIndex = selected.indexOf(rowId); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, rowId); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + dispatch( + setTableAction({ + selected: newSelected, + }), + ); + }; + + useEffect(() => { + if (accountIdFromUrl) { + dispatch( + setAction({ + account: { value: +accountIdFromUrl, label: '' }, + }), + ); + } + dispatch(getDataAction()); + }, []); + + return ( + +
    + handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
    + + {!hasAccount && ( + + + + + )} + + {!hasAccount && ( + s.value === status) ?? STATUSES_ALL} + options={STATUSES_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ status: selected$?.value ?? STATUSES_ALL, page: 1 })} + renderInput={(params) => } + /> + )} + + {!hasAccount && ( + handleFilter({ account: selectedAccount, page: 1 })} + onOpen={() => { + dispatch(getAccountOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getAccountOptionsAction(searchText))} + renderInput={(params) => } + /> + )} + + } + renderActions={ + + {canCreate && ( + + + + )} + + } + > + {shouldShowEmpty ? ( + + ) : ( + { + const isItemSelected = selectedRows.includes(row.id); + return ( + router.push(`/custom-reports/${row.id}`)} + > + {TABLE_MULTIACTIONS_ENABLED && !hasAccount && ( + + onSelectDeselectRow(row.id)} /> + + )} + + {row.id} + {!hasAccount && ( + +
    {row.account?.name}
    +
    + )} + +
    {row.lookerReportId}
    +
    + +
    {row.uiName}
    +
    + +
    {row.description}
    +
    + +
    {row.enabled ? 'Enabled' : 'Disabled'}
    +
    + +
    {row.updatedBy}
    +
    + +
    {dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
    +
    +
    + ); + }} + data={data || []} + selected={selected} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onSelectDeselectAllRows={handleSelectDeselectAllRows} + onSelectDeselectRow={handleSelectDeselectRow} + onFilter={handleFilter} + /> + )} +
    + ); +} + +export default List; diff --git a/anyclip/src/modules/customReports/List/components/List.module.scss b/anyclip/src/modules/customReports/List/components/List.module.scss new file mode 100644 index 0000000..0433d10 --- /dev/null +++ b/anyclip/src/modules/customReports/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"ActionsSelect":"List_ActionsSelect__RkjPs","SearchField":"List_SearchField__YtIQ_","AccountSelect":"List_AccountSelect__t_ngT","StatusSelect":"List_StatusSelect__eeRK6","Row":"List_Row__jczEd","Name":"List_Name___9zQ9","Description":"List_Description__N2OeT","LookerId":"List_LookerId__aFZ_k","NoWrap":"List_NoWrap__P7h2T"}; \ No newline at end of file diff --git a/anyclip/src/modules/customReports/List/constants/index.js b/anyclip/src/modules/customReports/List/constants/index.js new file mode 100644 index 0000000..c445b08 --- /dev/null +++ b/anyclip/src/modules/customReports/List/constants/index.js @@ -0,0 +1,70 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +// Status Select +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = 0; + +export const STATUSES_OPTIONS = [ + { label: 'Enabled', value: STATUSES_ACTIVE }, + { label: 'Disabled', value: STATUSES_INACTIVE }, +]; + +// Table header +export const TABLE_HEADER = [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '100', + }, + { + id: 'account', + label: 'Account', + sortable: true, + width: '200', + }, + { + id: 'lookerReportId', + label: 'Looker Dashboard ID', + sortable: true, + width: '156', + }, + { + id: 'uiName', + label: 'Name', + sortable: true, + width: '115', + }, + { + id: 'description', + label: 'Description', + sortable: true, + width: '115', + }, + { + id: 'enabled', + label: 'Status', + sortable: true, + width: '115', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: '150', + }, + { + id: 'updatedAt', + label: 'Updated Date', + sortable: true, + width: '240', + }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/src/modules/hubs/List/helpers/computedState.js b/anyclip/src/modules/customReports/List/helpers/computedState.js similarity index 100% rename from src/modules/hubs/List/helpers/computedState.js rename to anyclip/src/modules/customReports/List/helpers/computedState.js diff --git a/anyclip/src/modules/customReports/List/redux/epics/getAccounts.js b/anyclip/src/modules/customReports/List/redux/epics/getAccounts.js new file mode 100644 index 0000000..bb37540 --- /dev/null +++ b/anyclip/src/modules/customReports/List/redux/epics/getAccounts.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_ACCOUNTS } from '@/graphql/services/customReports/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/customReports/types/payload/accounts'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_ACCOUNTS}($payload: ${PAYLOAD_NAME}) { + ${GET_ACCOUNTS}(payload: $payload) { + id + name + } + } +`; + +const getResponse = ({ data }) => + data[GET_ACCOUNTS].map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/customReports/List/redux/epics/getData.js b/anyclip/src/modules/customReports/List/redux/epics/getData.js new file mode 100644 index 0000000..5aa39bb --- /dev/null +++ b/anyclip/src/modules/customReports/List/redux/epics/getData.js @@ -0,0 +1,70 @@ +import { STATUSES_ALL } from '../../constants'; +import { GET_LIST } from '@/graphql/services/customReports/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/customReports/types/payload/list'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_LIST}($payload: ${PAYLOAD_NAME}) { + ${GET_LIST}(payload: $payload) { + rows { + id + account { + id + name + } + uiName + enabled + icon + description + lookerReportId + allSites + publisherIds + updatedAt + updatedBy + createdAt + createdBy + } + countTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const account = selectors.accountSelector(state); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + // todo: check flow + if (status !== STATUSES_ALL) { + variables.enabled = !!status; + } + + if (account) { + variables.accountId = account.value; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => ({ + records: data[GET_LIST].rows, + recordsTotal: data[GET_LIST].countTotal, + allRecordsCount: data[GET_LIST].countTotal, + }), + setTableAction, +}); diff --git a/anyclip/src/modules/customReports/List/redux/epics/index.js b/anyclip/src/modules/customReports/List/redux/epics/index.js new file mode 100644 index 0000000..ae14d03 --- /dev/null +++ b/anyclip/src/modules/customReports/List/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getAccounts from './getAccounts'; +import getData from './getData'; + +export default combineEpics(getData, getAccounts); diff --git a/anyclip/src/modules/customReports/List/redux/selectors/index.js b/anyclip/src/modules/customReports/List/redux/selectors/index.js new file mode 100644 index 0000000..32c733c --- /dev/null +++ b/anyclip/src/modules/customReports/List/redux/selectors/index.js @@ -0,0 +1,23 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +export const statusSelector = (state) => state[nameSpace].status; diff --git a/anyclip/src/modules/customReports/List/redux/slices/index.js b/anyclip/src/modules/customReports/List/redux/slices/index.js new file mode 100644 index 0000000..af90c19 --- /dev/null +++ b/anyclip/src/modules/customReports/List/redux/slices/index.js @@ -0,0 +1,42 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ALL, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + account: null, + accountOptions: null, // null need for loading state + status: STATUSES_ALL, +}; + +export const slice = createSlice({ + name: '@@CUSTOM_DASHBOARD/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getAccountOptionsAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, getAccountOptionsAction } = slice.actions; diff --git a/anyclip/src/modules/designSystem/DesignSystemLayout.jsx b/anyclip/src/modules/designSystem/DesignSystemLayout.jsx new file mode 100644 index 0000000..a05ea0c --- /dev/null +++ b/anyclip/src/modules/designSystem/DesignSystemLayout.jsx @@ -0,0 +1,192 @@ +import React, { useContext, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { useRouter } from 'next/router'; +import { useTheme } from '@mui/material/styles'; +import { + CheckCircleOutlineRounded, + ChevronLeftRounded, + ChevronRightRounded, + ConstructionRounded, + DarkModeRounded, + DoNotDisturb, + LightModeRounded, + RemoveCircleOutline, +} from '@mui/icons-material'; + +import * as routers from './modules/constants/routes'; +import { + ROUTE_APP_BAR, + ROUTE_CARD, + ROUTE_DATE_TIME_RANGE_PICKER, + ROUTE_DURATION_FIELD, + ROUTE_STEPPER, + ROUTE_TIME_RANGE_PICKER, +} from './modules/constants/routes'; +import { APPEARANCE_MODE_LIGHT } from '@/mui/constants'; + +import { SettingsContext } from '@/modules/@common/app/SettingsProvider'; +import MenuItemWithScroll from '@/modules/designSystem/components/MenuItem'; +import { Box, Button, List, ListItem, Paper, Stack, Typography } from '@/mui/components'; + +import styles from './DesignSystemLayout.module.scss'; + +const DONE = '1'; +const IN_PROGRESS = '2'; +const UNUSED = '3'; +const UN_EXISTS = '4'; + +const undoneStatuses = { + [IN_PROGRESS]: [ROUTE_DURATION_FIELD], + [UNUSED]: [ROUTE_APP_BAR, ROUTE_CARD, ROUTE_STEPPER], + [UN_EXISTS]: [ROUTE_TIME_RANGE_PICKER, ROUTE_DATE_TIME_RANGE_PICKER], +}; + +const sections = Object.values(routers) + .map((path) => { + const title = + path + .split('/design-system/') + .pop() + ?.split(/[-/]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') ?? ''; + + const status = Object.keys(undoneStatuses).find((key) => undoneStatuses[key].includes(path)) ?? DONE; + + return { + title, + path, + status, + }; + }) + .sort((a, b) => a.title.localeCompare(b.title)); + +const doneSection = []; +const inProgressSection = []; +const unusedSection = []; +const unExistSection = []; + +sections.forEach((section) => { + if (section.status === IN_PROGRESS) { + inProgressSection.push(section); + } else if (section.status === UNUSED) { + unusedSection.push(section); + } else if (section.status === UN_EXISTS) { + unExistSection.push(section); + } else { + doneSection.push(section); + } +}); + +function DesignSystemLayout(props) { + const router = useRouter(); + const theme = useTheme(); + const [expanded, setExpanded] = useState(true); + const { themeMode, onToggleMode } = useContext(SettingsContext); + + return ( + + + + + + {doneSection.map(({ title, path }) => ( + } + /> + ))} + {!!inProgressSection.length && ( + + + IN PROGRESS + + + )} + {inProgressSection.map(({ title, path }) => ( + } + /> + ))} + + + UNUSED + + + {unusedSection.map(({ title, path }) => ( + } + /> + ))} + + + DOESN`T EXISTS + + + {unExistSection.map(({ title, path }) => ( + } + /> + ))} + + + +
    + + {props.children} + + + ); +} + +DesignSystemLayout.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default DesignSystemLayout; diff --git a/anyclip/src/modules/designSystem/DesignSystemLayout.module.scss b/anyclip/src/modules/designSystem/DesignSystemLayout.module.scss new file mode 100644 index 0000000..0ad9e08 --- /dev/null +++ b/anyclip/src/modules/designSystem/DesignSystemLayout.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"DesignSystemLayout_Wrapper__hAOQK","MenuWrapper":"DesignSystemLayout_MenuWrapper__mww6E","Menu":"DesignSystemLayout_Menu__xdljq","Menu___expanded":"DesignSystemLayout_Menu___expanded__qHojb","MenuButton":"DesignSystemLayout_MenuButton__Z97sl","MenuButton___expanded":"DesignSystemLayout_MenuButton___expanded__eSIhd","SubTitle":"DesignSystemLayout_SubTitle__g9JbL","BoxWrapper":"DesignSystemLayout_BoxWrapper__F54Eu","Box":"DesignSystemLayout_Box__tI1BM","Button":"DesignSystemLayout_Button__TKjiB"}; \ No newline at end of file diff --git a/anyclip/src/modules/designSystem/components/MenuItem.jsx b/anyclip/src/modules/designSystem/components/MenuItem.jsx new file mode 100644 index 0000000..239f939 --- /dev/null +++ b/anyclip/src/modules/designSystem/components/MenuItem.jsx @@ -0,0 +1,26 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useRef } from 'react'; +import NextLink from 'next/link'; + +import { ListItem, ListItemButton, ListItemIcon, ListItemText } from '@/mui/components'; + +function MenuItemWithScroll(props) { + const itemRef = useRef(null); + + useEffect(() => { + if (props.selected) { + itemRef.current?.scrollIntoView(true); + } + }, [props.selected]); + + return ( + + + {props.icon} + + + + ); +} + +export default MenuItemWithScroll; diff --git a/anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.jsx b/anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.jsx new file mode 100644 index 0000000..2270da4 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.jsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; + +import { getImportsString, getKeyByState, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + FormControlLabel, + Stack, + Switch, + Typography, +} from '@/mui/components'; + +import styles from './AccordionSection.module.scss'; + +function AccordionSection() { + const [expandedId, setExpandedId] = useState(''); + + const isExpanded = (isControlled, accordionId) => (isControlled ? expandedId === accordionId : undefined); + const handleExpand = (isControlled, accordionId) => { + if (isControlled) { + setExpandedId((prevState) => (prevState === accordionId ? '' : accordionId)); + } + }; + + return ( + { + const props = { + ...state, + controlled: undefined, + expanded: isExpanded(state.controlled, 'accordion1'), + onChange: () => handleExpand(state.controlled, 'accordion1'), + }; + + const propsString = getPropsString({ + ...props, + expanded: state.controlled ? '!!!isExpandedId' : undefined, + onChange: state.controlled ? '!!!() => setExpandedId(...)' : undefined, + }); + + const importsList = getImportsString([[['Accordion', 'AccordionDetails', 'AccordionSummary', 'Typography']]]); + + return { + component: ( + + + + Accordion 1 + + + + null} />} label="Slides" /> + null} />} label="Transcript" /> + null} />} label="Highlights" /> + + + + + ), + code: `${importsList} + + + Accordion 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse + malesuada lacus ex, sit amet blandit leo lobortis eget. + +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (id === 'controlled' && value) { + setState({ + ...nextState, + defaultExpanded: false, + }); + } else if (id === 'defaultExpanded' && value) { + setState({ + ...nextState, + controlled: false, + }); + } + }} + /> + ); +} + +export default AccordionSection; diff --git a/anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.module.scss b/anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.module.scss new file mode 100644 index 0000000..6ccc5d9 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/AccordionSection/AccordionSection.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"AccordionDetails":"AccordionSection_AccordionDetails__ZhJzG"}; \ No newline at end of file diff --git a/anyclip/src/modules/designSystem/modules/AlertSection/AlertSection.jsx b/anyclip/src/modules/designSystem/modules/AlertSection/AlertSection.jsx new file mode 100644 index 0000000..7966041 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/AlertSection/AlertSection.jsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { getImportsString, getPropsString, shapes } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Alert } from '@/mui/components'; + +function AlertSection() { + return ( + { + const props = { + ...state, + onClose: state.onClose ? () => null : undefined, + }; + + const importsList = getImportsString([[['Alert']]]); + + const text = `This is an ${state.severity} alert`; + + return { + component: {text}, + code: `${importsList} + + ${text} +`, + }; + }} + /> + ); +} + +export default AlertSection; diff --git a/anyclip/src/modules/designSystem/modules/AutocompleteSection/AutocompleteSection.jsx b/anyclip/src/modules/designSystem/modules/AutocompleteSection/AutocompleteSection.jsx new file mode 100644 index 0000000..e00bd31 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/AutocompleteSection/AutocompleteSection.jsx @@ -0,0 +1,206 @@ +import React from 'react'; + +import { getImportsString, getKeyByState, getOptions, getPropsString, shapes } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Autocomplete, Button, InputAdornment, TextField } from '@/mui/components'; +import { CustomPeople } from '@/mui/components/CustomIcon'; + +const options = getOptions(20); + +const groupedOptions = options + .map((option) => { + const firstLetter = option.label[0].toUpperCase(); + + return { + firstLetter: /[0-9]/.test(firstLetter) ? '0-9' : firstLetter, + ...option, + }; + }) + .sort((a, b) => a.firstLetter.localeCompare(b.firstLetter)); + +function AutocompleteSection() { + return ( + { + const props = { + ...state, + error: undefined, + startAdornment: undefined, + endAdornment: undefined, + optionLabelKey: 'label', + optionValueKey: 'value', + groupBy: state.groupBy ? (option) => option.firstLetter : undefined, + options: state.groupBy ? groupedOptions : options, + renderInput: (params) => { + const InputProps = { + ...params.InputProps, + }; + + if (state.startAdornment) { + InputProps.startAdornment = ( + + + + ); + } + + if (state.endAdornment) { + InputProps.endAdornment = ( + + + + ); + } + + return ( + + ); + }, + }; + const importsList = ['Autocomplete', 'TextField']; + + const propsString = getPropsString({ + ...props, + options: state.groupBy + ? `!!!options.map((option) => { + const firstLetter = option.label[0].toUpperCase(); + + return { + firstLetter: /[0-9]/.test(firstLetter) ? '0-9' : firstLetter, + ...option, + }; + }) + .sort((a, b) => a.firstLetter.localeCompare(b.firstLetter))` + : '!!!options', + groupBy: state.groupBy ? '!!!(option) => option.firstLetter' : undefined, + renderInput: `!!!(params$) => `, + }); + + const importsListString = getImportsString([[importsList]]); + + return { + component: , + code: `${importsListString} +const options = ${JSON.stringify(options.slice(0, 2), null, 2)}; + +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (id === 'checkboxes' && value) { + setState({ + ...nextState, + multiple: true, + freeSolo: false, + filterSelectedOptions: false, + }); + } else if (id === 'freeSolo' && nextState.checkboxes) { + setState({ + ...nextState, + freeSolo: false, + }); + } else if (id === 'filterSelectedOptions' && nextState.checkboxes) { + setState({ + ...nextState, + filterSelectedOptions: false, + }); + } + }} + /> + ); +} + +export default AutocompleteSection; diff --git a/anyclip/src/modules/designSystem/modules/AvatarSection/AvatarSection.jsx b/anyclip/src/modules/designSystem/modules/AvatarSection/AvatarSection.jsx new file mode 100644 index 0000000..7b4c79a --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/AvatarSection/AvatarSection.jsx @@ -0,0 +1,161 @@ +import React from 'react'; + +import { getImportsString, getKeyByState, getPropsString, imageAvatarUrl } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { AvatarGroup, UserAvatar } from '@/mui/components'; + +const users = [ + { + id: 1, + firstName: 'Bill', + lastName: 'Simpson', + }, + { + id: 2, + firstName: 'Tomas', + lastName: 'Smith', + }, + { + id: 3, + firstName: 'Fahid', + lastName: 'Zaril', + }, + { + id: 4, + firstName: 'Simon', + lastName: 'Aurum', + }, + { + id: 5, + firstName: 'Kent', + lastName: 'Dodds', + }, + { + id: 6, + firstName: 'Jed', + lastName: 'Watson', + }, + { + id: 7, + firstName: 'Tim', + lastName: 'Neutkens', + }, +]; + +function AvatarSection() { + return ( + <> + { + const props = omitUndefinedProps({ + ...state, + id: 1, + withImage: undefined, + src: state.withImage ? imageAvatarUrl : undefined, + }); + + const importsList = getImportsString([[['UserAvatar']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + { + const props = { + ...state, + }; + + const importsList = getImportsString([[['AvatarGroup', 'UserAvatar']]]); + + return { + component: ( + + {users.map((user) => ( + + ))} + + ), + code: `${importsList} +const users = ${JSON.stringify(users.slice(0, 2), null, 2)}; + + + {users.map((user) => ( + + ))} +`, + }; + }} + /> + + ); +} + +export default AvatarSection; diff --git a/anyclip/src/modules/designSystem/modules/BadgeSection/BadgeSection.jsx b/anyclip/src/modules/designSystem/modules/BadgeSection/BadgeSection.jsx new file mode 100644 index 0000000..eaa51b0 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/BadgeSection/BadgeSection.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Email } from '@mui/icons-material'; + +import { colorList, getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Badge, Stack } from '@/mui/components'; + +function BadgeSection() { + return ( + { + const props = { + ...state, + }; + + const importsList = getImportsString([[['Badge']], [['Email'], '@mui/icons-material']]); + + return { + component: ( + + + + + + + + + ), + code: `${importsList} + + +`, + }; + }} + /> + ); +} + +export default BadgeSection; diff --git a/anyclip/src/modules/designSystem/modules/BottomNavigationSection/BottomNavigationSection.jsx b/anyclip/src/modules/designSystem/modules/BottomNavigationSection/BottomNavigationSection.jsx new file mode 100644 index 0000000..678ea37 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/BottomNavigationSection/BottomNavigationSection.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Favorite, LocationOn, Restore } from '@mui/icons-material'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { BottomNavigation, BottomNavigationAction } from '@/mui/components'; + +function BottomNavigationSection() { + const [value, setValue] = React.useState(0); + + return ( + { + const props = { + ...state, + value, + onChange: (event, newValue) => { + setValue(newValue); + }, + }; + + const importsList = getImportsString([ + [['BottomNavigation', 'BottomNavigationAction']], + [['Restore', 'Favorite', 'LocationOn'], '@mui/icons-material'], + ]); + + return { + component: ( + + } /> + } /> + } /> + + ), + code: `${importsList} + + } /> + } /> + } /> +`, + }; + }} + /> + ); +} + +export default BottomNavigationSection; diff --git a/anyclip/src/modules/designSystem/modules/BreadcrumbsSection/BreadcrumbsSection.jsx b/anyclip/src/modules/designSystem/modules/BreadcrumbsSection/BreadcrumbsSection.jsx new file mode 100644 index 0000000..6329ac4 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/BreadcrumbsSection/BreadcrumbsSection.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Breadcrumbs, Link, Typography } from '@/mui/components'; + +function BreadcrumbsSection() { + const router = useRouter(); + + return ( + { + const props = { + ...state, + separator: state.separator === 'default' ? undefined : state.separator, + }; + + const propsString = getPropsString({ + ...props, + }); + const importsList = getImportsString([[['Breadcrumbs', 'Link', 'Typography']]]); + + return { + component: ( + + { + event.preventDefault(); + return null; + }} + > + MUI + + { + event.preventDefault(); + return null; + }} + > + Core + + + Breadcrumb + + + ), + code: `${importsList} + + { + event.preventDefault(); + return null; + }} + > + MUI + + { + event.preventDefault(); + return null; + }} + > + Core + + + Breadcrumb + +`, + }; + }} + /> + ); +} + +export default BreadcrumbsSection; diff --git a/anyclip/src/modules/designSystem/modules/ButtonGroupSection/ButtonGroupSection.jsx b/anyclip/src/modules/designSystem/modules/ButtonGroupSection/ButtonGroupSection.jsx new file mode 100644 index 0000000..bbc60a8 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ButtonGroupSection/ButtonGroupSection.jsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { colorList, getImportsString, getKeyByState, getPropsString, shapes } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Button, ButtonGroup } from '@/mui/components'; + +function ButtonGroupSection() { + return ( + { + const props = { + ...state, + }; + + const buttonProps = getPropsString({ + onClick: () => null, + }); + + const importsList = getImportsString([[['ButtonGroup', 'Button']]]); + + return { + component: ( + + + + + + ), + code: `${importsList} + + 10 + 20 + 30 +`, + }; + }} + /> + ); +} + +export default ButtonGroupSection; diff --git a/anyclip/src/modules/designSystem/modules/ButtonSection/ButtonSection.jsx b/anyclip/src/modules/designSystem/modules/ButtonSection/ButtonSection.jsx new file mode 100644 index 0000000..ea01431 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ButtonSection/ButtonSection.jsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { Favorite, Star } from '@mui/icons-material'; + +import { colorList, getImportsString, getPropsString, shapes } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { Button } from '@/mui/components'; + +function ButtonSection() { + return ( + { + const square = state.widthBehavior === 'square'; + + const props = omitUndefinedProps({ + ...state, + widthBehavior: undefined, + text: undefined, + label: undefined, + fullWidth: state.widthBehavior === 'fullWidth', + square: square || undefined, + startIcon: state.startIcon ? : undefined, + endIcon: state.endIcon ? : undefined, + }); + + let content = state.text ? state.label : ''; + + if (square) { + content = content.slice(0, 3); + } + + const propsString = getPropsString({ + ...props, + startIcon: state.startIcon && '!!!', + endIcon: state.endIcon && '!!!', + }); + + const importsList = getImportsString([[['Button']]]); + + return { + component: ( +
    + +
    + ), + code: `${importsList} + + ${content} +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (nextState.widthBehavior === 'square') { + if (id === 'text') { + setState({ + ...nextState, + label: nextState.label.slice(0, 3), + endIcon: false, + startIcon: false, + }); + } else if (nextState.startIcon || nextState.endIcon) { + setState({ + ...nextState, + endIcon: false, + startIcon: true, + text: false, + }); + } else if (id === 'label') { + setState({ + ...nextState, + label: value.slice(0, 3), + }); + } + } else if (!nextState.text) { + setState({ + ...nextState, + text: true, + }); + } + }} + /> + ); +} + +export default ButtonSection; diff --git a/anyclip/src/modules/designSystem/modules/CardSection/CardSection.jsx b/anyclip/src/modules/designSystem/modules/CardSection/CardSection.jsx new file mode 100644 index 0000000..7906896 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/CardSection/CardSection.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import Playground from '../Playground/Playground'; +import { Card } from '@/mui/components'; + +function CardSection() { + return ( + ({ + component: , + code: '', + })} + /> + ); +} + +export default CardSection; diff --git a/anyclip/src/modules/designSystem/modules/CheckboxSection/CheckboxSection.jsx b/anyclip/src/modules/designSystem/modules/CheckboxSection/CheckboxSection.jsx new file mode 100644 index 0000000..2fac329 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/CheckboxSection/CheckboxSection.jsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { colorList, getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Checkbox } from '@/mui/components'; + +function CheckboxSection() { + return ( + { + const props = { + ...state, + controlled: undefined, + checked: state.controlled ? state.value === 'checked' : undefined, + indeterminate: state.controlled ? state.value === 'indeterminate' : undefined, + defaultChecked: !state.controlled ? true : undefined, + }; + + const importsList = getImportsString([[['Checkbox']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + ); +} + +export default CheckboxSection; diff --git a/anyclip/src/modules/designSystem/modules/ChipAltSection/ChipAltSection.jsx b/anyclip/src/modules/designSystem/modules/ChipAltSection/ChipAltSection.jsx new file mode 100644 index 0000000..b9637ad --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ChipAltSection/ChipAltSection.jsx @@ -0,0 +1,249 @@ +import React from 'react'; +import { useTheme } from '@mui/material/styles'; +import { AddCircle, TripOriginOutlined } from '@mui/icons-material'; + +import { CUSTOM_TAGS_COLORS } from '@/modules/editorial/TagEditor/constants'; + +import { getImportsString, getKeyByState, getPropsString, shapes } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { Box, Chip } from '@/mui/components'; +import { CustomLabelTagCircular } from '@/mui/components/CustomIcon'; + +const mockFn = () => null; +const label = 'Graph Chip'; +const longLabel = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit'; + +function ChipAltSection() { + const theme = useTheme(); + + return ( + <> + i), + selected: 0, + }, + { + type: 'select', + stateId: 'size', + options: ['xSmall', 'small', 'medium', 'large'], + selected: 'medium', + }, + { + type: 'select', + stateId: 'shape', + options: shapes, + selected: 'circular', + }, + { + type: 'checkbox', + stateId: 'onClick', + selected: false, + }, + { + type: 'checkbox', + stateId: 'withIcon', + selected: false, + }, + { + type: 'checkbox', + stateId: 'overlapEdit', + selected: false, + }, + { + type: 'checkbox', + stateId: 'selected', + selected: false, + }, + { + type: 'checkbox', + stateId: 'noEnoughSpace', + selected: false, + }, + { + type: 'select', + stateId: 'onDelete', + options: ['none', 'default', 'custom'], + selected: 'none', + }, + { + type: 'input-text', + stateId: 'label', + selected: 'Chip Component', + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + ]} + renderCallback={(state) => { + const props = omitUndefinedProps({ + ...state, + withIcon: undefined, + noEnoughSpace: undefined, + overlapEdit: undefined, + color: CUSTOM_TAGS_COLORS[state.color], + icon: state.withIcon ? : undefined, + deleteIcon: state.onDelete === 'custom' ? : undefined, + onClick: state.onClick ? mockFn : undefined, + onDelete: state.onDelete !== 'none' ? mockFn : undefined, + onApply: state.overlapEdit ? mockFn : undefined, + onCancel: state.overlapEdit ? mockFn : undefined, + }); + const propsString = getPropsString({ + ...props, + color: `!!!CUSTOM_TAGS_COLORS[${state.color}]`, + icon: state.withIcon ? '!!!' : undefined, + deleteIcon: state.onDelete === 'custom' ? '!!!' : undefined, + }); + + const importsList = getImportsString([ + [['Chip']], + [['CUSTOM_TAGS_COLORS'], '@/modules/editorial/TagEditor/constants'], + ]); + + return { + component: ( + + + + ), + code: `${importsList}\n`, + }; + }} + /> + i), + selected: 0, + }, + { + type: 'select', + stateId: 'size', + options: ['xSmall', 'small', 'medium', 'large'], + selected: 'medium', + }, + { + type: 'select', + stateId: 'shape', + options: shapes, + selected: 'circular', + }, + { + type: 'checkbox', + stateId: 'onClick', + selected: false, + }, + { + type: 'checkbox', + stateId: 'withIcon', + selected: true, + }, + { + type: 'checkbox', + stateId: 'overlapEdit', + selected: false, + }, + { + type: 'checkbox', + stateId: 'selected', + selected: false, + }, + { + type: 'checkbox', + stateId: 'noEnoughSpace', + selected: false, + }, + { + type: 'select', + stateId: 'onDelete', + options: ['none', 'default', 'custom'], + selected: 'none', + }, + { + type: 'input-text', + stateId: 'label', + selected: label, + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + ]} + renderCallback={(state) => { + const props = omitUndefinedProps({ + ...state, + withIcon: undefined, + noEnoughSpace: undefined, + overlapEdit: undefined, + icon: state.withIcon ? : undefined, + deleteIcon: state.onDelete === 'custom' ? : undefined, + onClick: state.onClick ? mockFn : undefined, + onDelete: state.onDelete !== 'none' ? mockFn : undefined, + onApply: state.overlapEdit ? mockFn : undefined, + onCancel: state.overlapEdit ? mockFn : undefined, + color: theme.palette['-graph'][state.color], + }); + const propsString = getPropsString({ + ...props, + color: `!!!theme.palette['-graph'][${state.color}]`, + icon: state.withIcon ? '!!!' : undefined, + deleteIcon: state.onDelete === 'custom' ? '!!!' : undefined, + }); + const importsList = getImportsString([ + [['useTheme'], '@mui/material/styles'], + [['TripOriginOutlined'], '@mui/icons-material'], + [['Chip']], + ]); + + return { + component: ( + + + + ), + code: `${importsList} + +const theme = useTheme(); + +`, + }; + }} + onChange={(nextState, id, value$, setState) => { + if (id === 'noEnoughSpace') { + setState({ + ...nextState, + label: value$ ? longLabel : label, + }); + } + }} + /> + + ); +} + +export default ChipAltSection; diff --git a/anyclip/src/modules/designSystem/modules/ChipSection/ChipSection.jsx b/anyclip/src/modules/designSystem/modules/ChipSection/ChipSection.jsx new file mode 100644 index 0000000..70b741b --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ChipSection/ChipSection.jsx @@ -0,0 +1,285 @@ +import React from 'react'; +import { AddCircle } from '@mui/icons-material'; + +import { colorList, getImportsString, getKeyByState, getPropsString, imageAvatarUrl, shapes } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { Box, Chip, UserAvatar } from '@/mui/components'; +import { + CustomBrandSafetyCircular, + CustomBrandsCircular, + CustomCcCircular, + CustomIABCircular, + CustomKeywordsCircular, + CustomPeopleCircular, + CustomTextCircular, +} from '@/mui/components/CustomIcon'; + +const mockFn = () => null; + +const dataCollection = { + TEXT: { + Icon: CustomTextCircular, + iconName: 'CustomTextCircular', + color: '-tagText', + }, + BRAND_SAFETY: { + Icon: CustomBrandSafetyCircular, + iconName: 'CustomBrandSafetyCircular', + color: '-tagBrandSafety', + }, + BRANDS: { + Icon: CustomBrandsCircular, + iconName: 'CustomBrandsCircular', + color: '-tagBrands', + }, + IAB: { + Icon: CustomIABCircular, + iconName: 'CustomIABCircular', + color: '-tagIAB', + }, + PEOPLE: { + Icon: CustomPeopleCircular, + iconName: 'CustomPeopleCircular', + color: '-tagPeople', + }, + KEYWORDS: { + Icon: CustomKeywordsCircular, + iconName: 'CustomKeywordsCircular', + color: '-tagKeywords', + }, + CC: { + Icon: CustomCcCircular, + iconName: 'CustomCcCircular', + color: '-tagCC', + }, +}; + +function ChipSection() { + return ( + <> + { + let avatar; + let avatarString; + + if (state.icon === 'avatar' || state.icon === 'letter-avatar') { + const avatarProps = { + src: state.icon === 'avatar' ? imageAvatarUrl : undefined, + id: 1, + firstName: 'Tomas', + lastName: 'Smith', + }; + + avatar = ; + avatarString = `!!!`; + } + + const props = omitUndefinedProps({ + ...state, + overlapEdit: undefined, + noEnoughSpace: undefined, + deleteIcon: state.onDelete === 'custom' ? : undefined, + onClick: state.onClick ? mockFn : undefined, + onDelete: state.onDelete !== 'none' ? mockFn : undefined, + icon: state.icon === 'icon' ? : undefined, + onApply: state.overlapEdit ? mockFn : undefined, + onCancel: state.overlapEdit ? mockFn : undefined, + avatar, + }); + + const propsString = getPropsString({ + ...props, + icon: state.icon === 'icon' ? '!!!' : undefined, + deleteIcon: state.onDelete === 'custom' ? '!!!' : undefined, + avatar: avatarString, + }); + const importsList = getImportsString([ + [['Chip']], + (state.onDelete === 'custom' || state.icon === 'icon') && [['AddCircle'], '@mui/icons-material'], + avatar && [['UserAvatar']], + ]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + { + const { color, Icon, iconName } = dataCollection[state.type]; + + const props = omitUndefinedProps({ + ...state, + color, + type: undefined, + withIcon: undefined, + overlapEdit: undefined, + noEnoughSpace: undefined, + icon: state.withIcon ? : undefined, + deleteIcon: state.onDelete === 'custom' ? : undefined, + onClick: state.onClick ? mockFn : undefined, + onDelete: state.onDelete !== 'none' ? mockFn : undefined, + onApply: state.overlapEdit ? mockFn : undefined, + onCancel: state.overlapEdit ? mockFn : undefined, + }); + + const propsString = getPropsString({ + ...props, + icon: state.withIcon ? `!!!<${iconName} />` : undefined, + deleteIcon: state.onDelete === 'custom' ? '!!!' : undefined, + }); + const importsList = getImportsString([ + [['Chip']], + [['useTheme'], '@mui/material/styles'], + state.withIcon && [[iconName], '@/mui/components/CustomIcon'], + (state.onDelete === 'custom' || state.icon === 'icon') && [['AddCircle'], '@mui/icons-material'], + ]); + + return { + component: ( + + + + ), + code: `${importsList} +const theme = useTheme(); + +`, + }; + }} + /> + + ); +} + +export default ChipSection; diff --git a/anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.jsx b/anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.jsx new file mode 100644 index 0000000..c62c1d3 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.jsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; + +import { getImportsString, getKeyByState, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { ButtonColorPicker, Paper, Stack, StaticColorPicker, TextFieldColorPicker, Typography } from '@/mui/components'; + +import styles from './ColorPickerSection.module.scss'; + +const presetColors = [ + '#FF0000', + '#00FF00', + '#0000FF', + '#FFFF00', + '#FF00FF', + '#00FFFF', + '#800000', + '#808000', + '#008000', + '#800080', + '#008080', + '#000080', + '#FFA500', + '#A52A2A', + '#C0C0C0', + '#000000', +]; + +function ColorPickerSection() { + const [color, setColor] = useState('#ff0000'); + + const onPickerChange = ({ rgb }) => { + setColor(`rgba(${[rgb.r, rgb.g, rgb.b, rgb.a].join(',')})`); + }; + + return ( + { + const inputs = [ + { + Component: TextFieldColorPicker, + valueKey: 'value', + onChangeKey: 'onPickerChange', + }, + { + Component: ButtonColorPicker, + valueKey: 'color', + onChangeKey: 'onPickerChange', + }, + { + Component: StaticColorPicker, + valueKey: 'color', + onChangeKey: 'onChange', + }, + ]; + + const props = { + ...state, + presetColors: state.presetColors ? presetColors : undefined, + }; + + const importsList = getImportsString([[inputs.map(({ Component }) => Component.displayName)]]); + + const code = inputs + .map(({ Component, valueKey, onChangeKey }) => { + const propsSting = getPropsString({ + ...props, + [valueKey]: color, + [onChangeKey]: '!!!({ rgb, hex }) => console.log(rgb, hex)', + }); + + return `<${Component.displayName}${propsSting}/>`; + }) + .join('\n'); + + return { + component: ( + + {inputs.map(({ Component, valueKey, onChangeKey }) => { + const props$ = { + ...props, + [valueKey]: color, + [onChangeKey]: onPickerChange, + }; + + return ( + + + {Component.displayName} +
    + +
    +
    +
    + ); + })} +
    + ), + code: `${importsList}\n${code}`, + }; + }} + /> + ); +} + +export default ColorPickerSection; diff --git a/anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.module.scss b/anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.module.scss new file mode 100644 index 0000000..92d9331 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ColorPickerSection/ColorPickerSection.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"ComponentWrapper":"ColorPickerSection_ComponentWrapper__RdvYa"}; \ No newline at end of file diff --git a/anyclip/src/modules/designSystem/modules/CompareSection/CompareSection.tsx b/anyclip/src/modules/designSystem/modules/CompareSection/CompareSection.tsx new file mode 100644 index 0000000..5e12d3d --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/CompareSection/CompareSection.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { useTheme } from '@mui/material/styles'; +import { + AddCircleRounded, + EmailRounded, + FormatAlignCenter, + FormatAlignJustify, + FormatAlignLeft, + FormatAlignRight, +} from '@mui/icons-material'; + +import { + Badge, + Button, + Checkbox, + Chip, + IconButton, + Pagination, + Radio, + Rating, + Slider, + Stack, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, +} from '@/mui/components'; + +function CompareSection() { + const theme = useTheme(); + + return ( + + +
    + +
    +
    + +
    +
    + +
    + +
    +
    + +
    +
    + + + + + + + + + + null} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + } + onClick={() => null} + /> + } + onClick={() => null} + /> + } + onClick={() => null} + /> + + + + + + + + + null} /> + +
    + ); +} + +export default CompareSection; diff --git a/anyclip/src/modules/designSystem/modules/DataGridSection/DataGridSection.jsx b/anyclip/src/modules/designSystem/modules/DataGridSection/DataGridSection.jsx new file mode 100644 index 0000000..88866e4 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DataGridSection/DataGridSection.jsx @@ -0,0 +1,290 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import { useGridApiRef } from '@mui/x-data-grid-pro'; +import { AddCircleOutlineRounded, InfoOutlined, PauseCircleFilledRounded } from '@mui/icons-material'; + +import { DAY_OPTIONS, PERMISSION_OPTIONS, STATUS_OPTIONS } from '@/modules/designSystem/modules/constants'; + +import { getIAB } from '@/modules/@common/iab/helpers'; +import { getImportsString, getPropsString, imageAvatarUrl } from '@/modules/designSystem/modules/helpers'; +import { getHashFromString } from '@/mui/helpers'; +import { createFlatTreeMap } from '@/mui/helpers/treeView'; + +import IabSelector from '@/modules/@common/iab/components/IabSelector/IabSelector'; +import Playground from '@/modules/designSystem/modules/Playground/Playground'; +import AvatarEdit from './components/Edit'; +import Avatar from '../../../../mui/components/Avatar/Avatar'; +import Chip from '../../../../mui/components/Chip/Chip'; +import { Box, DataGridPro, IconButton, Stack, Tooltip } from '@/mui/components'; + +import fullNames from './internal/fullNamesList.json'; + +const rows = new Array(100).fill(0).map((n, index) => { + const dateTime = new Date(index * 1000).getTime(); + + const getItemArray = (array) => array[index % (array.length - 1)]; + const email = `${getItemArray(fullNames.firstName)}.${getItemArray(fullNames.lastName)}@gmail.com`; + const staticHash = Math.abs(getHashFromString(email)); + + return { + id: `${index}`, + desk: `D-${index}`, + logo: imageAvatarUrl, + iab: ['1', '2'], + email, + verified: staticHash % 5 === 0, + status: getItemArray(STATUS_OPTIONS), + day: getItemArray(DAY_OPTIONS), + permissions: [getItemArray(PERMISSION_OPTIONS)], + price: staticHash % 10000, + dateTime, + date: dateTime, + time: dateTime, + rating: staticHash % 5, + slider: Math.max(1, staticHash % 100), + actions: null, + }; +}); + +const treeMapper = { + label: 'name', + children: 'categories', +}; + +function DataGridSection() { + const [flatTree] = useState(createFlatTreeMap(getIAB(), treeMapper, [])); + const apiRef = useGridApiRef(); + + const data$ = { + initialState: { + pagination: { paginationModel: { pageSize: 5, page: 0 } }, + columnVisibilityModel: { + desk: false, + }, + }, + columns: [ + { + field: 'id', + headerName: 'Id', + width: 40, + }, + { + field: 'logo', + headerName: 'custom cell', + cellType: 'string', + width: 80, + editable: true, + align: 'center', + renderCellValue: (props) => , + renderEditCell: (props) => , + }, + { + field: 'iab', + headerName: 'custom cell', + cellType: 'multiSelect', + width: 220, + editable: true, + renderCellValue: (props) => + !props.value.length ? ( + 'No any tags' + ) : ( + + {props.value.map((nodeId) => { + const { label } = flatTree.get(nodeId); + + return ; + })} + + ), + renderEditCell: (props) => ( + { + props.onChange(value.map(({ id }) => id)); + }} + /> + ), + }, + { + field: 'email', + headerName: 'cell (string)', + cellType: 'string', + width: 200, + editable: true, + renderHeader: (params) => ( + +
    {params.colDef.headerName}
    + + + +
    + ), + renderEditProps: () => ({ + root: { + placeholder: 'Enter Email', + }, + }), + }, + { + field: 'verified', + headerName: 'cell (checkbox)', + cellType: 'boolean', + width: 100, + editable: true, + }, + { + field: 'status', + headerName: 'cell (singleSelect)', + cellType: 'singleSelect', + valueOptions: STATUS_OPTIONS, + width: 180, + editable: true, + }, + { + field: 'day', + headerName: 'cell (autocompleteSelect)', + cellType: 'autocompleteSelect', + valueOptions: DAY_OPTIONS, + width: 220, + editable: true, + renderEditProps: () => ({ + input: { + placeholder: 'Select Day', + }, + }), + }, + { + field: 'permissions', + headerName: 'cell (multiSelect)', + cellType: 'multiSelect', + valueOptions: PERMISSION_OPTIONS, + width: 250, + editable: true, + renderEditProps: () => ({ + input: { + placeholder: 'Select Permission', + }, + }), + }, + { + field: 'price', + headerName: 'cell (number)', + cellType: 'number', + editable: true, + }, + { + field: 'dateTime', + headerName: 'cell (dateTime)', + cellType: 'dateTime', + width: 200, + editable: true, + }, + { + field: 'date', + headerName: 'cell (date)', + cellType: 'date', + width: 120, + editable: true, + }, + { + field: 'time', + headerName: 'cell (time)', + cellType: 'time', + width: 100, + editable: true, + }, + { + field: 'slider', + headerName: 'cell (slider)', + cellType: 'slider', + width: 140, + editable: true, + }, + { + field: 'rating', + headerName: 'cell (rating)', + cellType: 'rating', + width: 150, + editable: true, + renderEditProps: () => ({ + root: { + precision: 0.5, + }, + }), + }, + { + field: 'actions', + headerName: 'cell (actions)', + cellType: 'actions', + renderCellValue: (props) => ( + <> + null}> + + + null}> + + + + ), + }, + ].filter(Boolean), + rows, + }; + + return ( + { + const props = { + ...state, + apiRef, + loading: data$.rows.length === 0, + checkboxSelection: true, + disableRowSelectionOnClick: true, + initialState: data$.initialState, + columns: data$.columns, + rows: data$.rows, + pageSizeOptions: [10, 15, 25], + }; + + const propsString = getPropsString({ + ...props, + apiRef: '!!!apiRef', + }); + const importsList = getImportsString([[['DataGridPro']]]); + + return { + component: ( + + + + ), + code: `${importsList} +const apiRef = useGridApiRef(); + +`, + }; + }} + /> + ); +} + +export default DataGridSection; diff --git a/anyclip/src/modules/designSystem/modules/DataGridSection/components/Edit.jsx b/anyclip/src/modules/designSystem/modules/DataGridSection/components/Edit.jsx new file mode 100644 index 0000000..176e57f --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DataGridSection/components/Edit.jsx @@ -0,0 +1,51 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useRef } from 'react'; + +import { Avatar, Stack } from '@/mui/components'; + +function Edit(props) { + const inputRef = useRef(null); + + const clearInput = () => { + inputRef.current.value = ''; + }; + + const readFile = () => { + const [file] = inputRef.current.files; + + if (file) { + const readerUrl = new FileReader(); + + readerUrl.addEventListener('load', () => { + props.onChange(readerUrl.result); + }); + + readerUrl.addEventListener('error', () => { + clearInput(); + }); + + readerUrl.readAsDataURL(file); + } + }; + + useEffect(() => { + inputRef.current.click(); + }, []); + + return ( + + null} /> + + + ); +} + +export default Edit; diff --git a/anyclip/src/modules/designSystem/modules/DatePickerSection/DatePickerSection.jsx b/anyclip/src/modules/designSystem/modules/DatePickerSection/DatePickerSection.jsx new file mode 100644 index 0000000..d1466f3 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DatePickerSection/DatePickerSection.jsx @@ -0,0 +1,79 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { DatePicker, StaticDatePicker } from '@/mui/components'; + +function DatePickerSection() { + const [value, setValue] = useState(new Date('2014-08-18T21:11:54')); + + return ( + <> + { + const props = { + ...state, + value: dayjs(value), + label: 'Date Picker', + onChange: (newValue) => setValue(newValue), + }; + + const propsString = { + ...props, + value: props.value.toDate(), + }; + + const importsList = getImportsString([[['DatePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + { + const props = { + ...state, + value: dayjs(value), + onChange: (newValue) => setValue(newValue), + }; + + const propsString = { + ...props, + value: props.value.toDate(), + }; + + const importsList = getImportsString([[['StaticDatePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + + ); +} + +export default DatePickerSection; diff --git a/anyclip/src/modules/designSystem/modules/DateRangePickerSection/DateRangePickerSection.jsx b/anyclip/src/modules/designSystem/modules/DateRangePickerSection/DateRangePickerSection.jsx new file mode 100644 index 0000000..dc1e527 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DateRangePickerSection/DateRangePickerSection.jsx @@ -0,0 +1,79 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { DateRangePicker, StaticDateRangePicker } from '@/mui/components'; + +function DateRangePickerSection() { + const [value, setValue] = useState([new Date('2014-08-16T21:11:54'), new Date('2014-08-18T21:11:54')]); + + return ( + <> + { + const props = { + ...state, + value: value.map((date) => dayjs(date)), + calendars: 2, + onChange: (value$) => setValue(value$), + }; + + const propsString = { + ...props, + value: props.value.map((date) => date.toDate()), + }; + + const importsList = getImportsString([[['DateRangePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + { + const props = { + ...state, + value: value.map((date) => dayjs(date)), + calendars: 2, + onChange: (value$) => setValue(value$), + }; + + const propsString = { + ...props, + value: props.value.map((date) => date.toDate()), + }; + + const importsList = getImportsString([[['StaticDateRangePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + + ); +} + +export default DateRangePickerSection; diff --git a/anyclip/src/modules/designSystem/modules/DateTimePickerSection/DateTimePickerSection.jsx b/anyclip/src/modules/designSystem/modules/DateTimePickerSection/DateTimePickerSection.jsx new file mode 100644 index 0000000..a4c352c --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DateTimePickerSection/DateTimePickerSection.jsx @@ -0,0 +1,79 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { DateTimePicker, StaticDateTimePicker } from '@/mui/components'; + +function DateTimePickerSection() { + const [value, setValue] = useState(new Date('2014-08-18T21:11:54')); + + return ( + <> + { + const props = { + ...state, + value: dayjs(value), + label: 'Date time', + onChange: (newValue) => setValue(newValue), + }; + + const propsString = { + ...props, + value: props.value.toDate(), + }; + + const importsList = getImportsString([[['DateTimePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + { + const props = { + ...state, + value: dayjs(value), + onChange: (newValue) => setValue(newValue), + }; + + const propsString = { + ...props, + value: props.value.toDate(), + }; + + const importsList = getImportsString([[['StaticDateTimePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + + ); +} + +export default DateTimePickerSection; diff --git a/anyclip/src/modules/designSystem/modules/DateTimeRangePickerSection/DateTimeRangePickerSection.jsx b/anyclip/src/modules/designSystem/modules/DateTimeRangePickerSection/DateTimeRangePickerSection.jsx new file mode 100644 index 0000000..f052208 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DateTimeRangePickerSection/DateTimeRangePickerSection.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Typography } from '@/mui/components'; + +function DateTimeRangePickerSection() { + return ( + { + const props = { + ...state, + }; + + const propsString = { + ...props, + }; + + const importsList = getImportsString([[['DateTimeRangePicker']]]); + + return { + component: Doesn`t exists, + code: `${importsList}\n`, + }; + }} + /> + ); +} + +export default DateTimeRangePickerSection; diff --git a/anyclip/src/modules/designSystem/modules/DialogSection/DialogSection.jsx b/anyclip/src/modules/designSystem/modules/DialogSection/DialogSection.jsx new file mode 100644 index 0000000..61450e1 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DialogSection/DialogSection.jsx @@ -0,0 +1,134 @@ +import React from 'react'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function DialogSection() { + const [open, setOpen] = React.useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = (event, reason, disableClickOutside) => { + if (reason && reason === 'backdropClick' && disableClickOutside) { + return; + } + setOpen(false); + }; + + return ( + { + const props = { + ...state, + disableClickOutside: undefined, + maxWidth: state.fullScreen ? undefined : state.maxWidth, + fullScreen: state.fullScreen, + }; + const propsString = getPropsString({ + ...props, + }); + + const importsList = getImportsString([ + [['Dialog', 'DialogTitle', 'DialogContent', 'DialogContent', 'DialogActions', 'Button']], + ]); + + return { + component: ( + <> + + handleClose(event, reason, state.disableClickOutside)} + > + handleClose()}>Set backup account + + In publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the + visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used + as a placeholder before final copy is available. + + + + + + + + ), + code: `${importsList} + +const [open, setOpen] = useState(false); + +const handleClose = (event, reason) => { + ${ + state.disableClickOutside + ? `if (reason && reason === 'backdropClick') { + return; + } + ` + : '' + }setOpen(false); +}; + + + + Set backup account + + + In publishing and graphic design, Lorem ipsum is a placeholder text commonly used + to demonstrate the visual form of a document or a typeface without relying on meaningful + content. Lorem ipsum may be used as a placeholder before final copy is available. + + + + + +`, + }; + }} + /> + ); +} + +export default DialogSection; diff --git a/anyclip/src/modules/designSystem/modules/DurationFieldSection/DurationFieldSection.jsx b/anyclip/src/modules/designSystem/modules/DurationFieldSection/DurationFieldSection.jsx new file mode 100644 index 0000000..7ef0308 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/DurationFieldSection/DurationFieldSection.jsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { AccessTimeRounded } from '@mui/icons-material'; + +import { getImportsString, getPropsString, shapes } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { DurationField, InputAdornment } from '@/mui/components'; + +function DurationFieldSection() { + const [value, setValue] = React.useState(0); + + return ( + { + const props = omitUndefinedProps({ + ...state, + value, + placeholder: 'Placeholder', + margin: state.margin === 'auto' ? undefined : state.margin, + withLabel: undefined, + withHelperText: undefined, + startAdornment: undefined, + endAdornment: undefined, + label: state.withLabel ? state.label : undefined, + helperText: state.withHelperText ? state.helperText : undefined, + onChange: (parsedValue) => setValue(parsedValue), + }); + + let InputProps; + const importsList$ = ['DurationField']; + const importsListIcons$ = []; + const InputStringProps = []; + + if (state.startAdornment || state.endAdornment) { + InputProps = InputProps || {}; + + importsListIcons$.push('AccessTimeRounded'); + importsList$.push('InputAdornment'); + + if (state.startAdornment) { + InputProps.startAdornment = ( + + + + ); + + InputStringProps.push( + `startAdornment: ( + + + +)`, + ); + } + + if (state.endAdornment) { + InputProps.endAdornment = ( + + + + ); + + InputStringProps.push( + `endAdornment: ( + + + +)`, + ); + } + } + + const propsString = getPropsString({ + ...props, + onChange: `onChange((timeInMilliseconds) => console.log(timeInMilliseconds))`, + InputProps: InputStringProps.length ? `!!!{\n${InputStringProps.join(',\n')}}\n` : undefined, + }); + + const importsList = getImportsString([[importsList$], [importsListIcons$, '@mui/icons-material']]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + onChange={(nextState, id, value$, setState) => { + if (id === 'hh' || id === 'ms') { + setValue(0); + } else if (id === 'textAlign' && value$ === 'center') { + setState((prevState) => ({ + ...prevState, + startAdornment: false, + endAdornment: false, + })); + } else if (id === 'margin' && value$ === 'none') { + setState((prevState) => ({ + ...prevState, + label: '', + startAdornment: false, + endAdornment: false, + })); + } + }} + /> + ); +} + +export default DurationFieldSection; diff --git a/anyclip/src/modules/designSystem/modules/FabSection/FabSection.jsx b/anyclip/src/modules/designSystem/modules/FabSection/FabSection.jsx new file mode 100644 index 0000000..348c3af --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/FabSection/FabSection.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { useRouter } from 'next/router'; +import { Add } from '@mui/icons-material'; + +import { colorList, getImportsString, getKeyByState, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Fab } from '@/mui/components'; + +function FabSection() { + const router = useRouter(); + + return ( + { + const props = { + ...state, + href: state.href ? router.pathname : null, + }; + const importsList = getImportsString([[['Fab']]]); + return { + component: ( + + + + ), + code: `${importsList} +`, + }; + }} + /> + ); +} + +export default FabSection; diff --git a/anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.jsx b/anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.jsx new file mode 100644 index 0000000..0414848 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.jsx @@ -0,0 +1,441 @@ +import React, { useState } from 'react'; +import { AddRounded, CheckRounded, CloseRounded } from '@mui/icons-material'; + +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; +import { DAY_OPTIONS, PERMISSION_OPTIONS, STATUS_OPTIONS } from '@/modules/designSystem/modules/constants'; + +import { getOptions } from '@/modules/designSystem/modules/helpers'; + +import { + Form, + FormContent, + FormGroup, + FormGroupTitle, + FormImageUploader, + FormRow, + FormRowItem, + FormSection, +} from '@/modules/@common/Form'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Button, + Checkbox, + FormControlLabel, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableScroll, + TableSortLabel, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@/mui/components'; + +import fullNames from '@/modules/designSystem/modules/DataGridSection/internal/fullNamesList.json'; + +import styles from './FormsSection.module.scss'; + +const options = getOptions(20); + +//todo: create code sample on view +const rows = new Array(105).fill(0).map((n, index) => { + const getItemArray = (array) => array[index % (array.length - 1)]; + + return { + id: index + 1, + desk: `D-${index}`, + email: `${getItemArray(fullNames.firstName)}.${getItemArray(fullNames.lastName)}@gmail.com`, + verified: index % 5 === 0, + status: getItemArray(STATUS_OPTIONS), + permissions: [getItemArray(PERMISSION_OPTIONS)], + day: getItemArray(DAY_OPTIONS), + }; +}); + +function descendingComparator(a, b, orderBy) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +function getComparator(order, orderBy) { + return order === SORT_DESC + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +const headCells = [ + { + id: 'id', + headerName: 'Id', + }, + { + id: 'email', + headerName: 'Email', + }, + { + id: 'verified', + align: 'center', + headerName: 'Verified', + }, + { + id: 'status', + headerName: 'Status', + }, + { + id: 'day', + headerName: 'Day', + }, + { + id: 'permissions', + headerName: 'Permissions', + }, +]; + +function FormsSection() { + const [countOfForms, setCount] = useState(2); + const [order, setOrder] = useState(SORT_ASC); + const [orderBy, setOrderBy] = useState('id'); + const [selected, setSelected] = useState([]); + const [page, setPage] = useState(1); + const [rowsPerPageOptions] = useState([10, 15, 25]); + const [rowsPerPage, setRowsPerPage] = useState(rowsPerPageOptions[0]); + + const indexStart = (page - 1) * rowsPerPage; + const indexEnd = indexStart + rowsPerPage; + + const formSize = 'small'; + + return ( + <> +
    + { + if (newState) { + setCount(newState); + } + }} + > + 1 Form Section + 2 Form Sections + +
    +
    +
    + + {new Array(countOfForms) + .fill(0) + .map((_, index) => index + 1) + .map((num) => ( + + {`Primary ${num}`} + + {new Array(4) + .fill(0) + .map((_, index) => `${index}`) + .map((key) => ( + + ))} + + + + + + + + + + + + + Some random title + + + // console.log(file); + // 250 * 1000 + true + } + onLoad={() => null} + onError={() => null} + restrictionMessage={'JPEG, PNG, SVG.\n Maximum allowed file size for image is 5mb.'} + /> + + + + + + + Accordion 1 + + + + null} />} + label="Slides" + /> + null} />} + label="Transcript" + /> + null} />} + label="Highlights" + /> + + + + + + Accordion 2 + + + + In the vast expanse of the universe lies a galaxy teeming with mysteries and wonders. + Spirals of stars, dust, and gas swirl in a cosmic dance, forming brilliant clusters and + nebulae. Within this galaxy, billions of stars illuminate the darkness, each potentially + hosting planetary systems with unknown worlds. The Milky Way, our galactic home, stretches + across the night sky, its dense core hiding supermassive black holes. As we explore, we + uncover the secrets of stellar nurseries, ancient supernovae, and the ever-expanding + universe. + + + + + + Other changes + + + {new Array(2) + .fill(0) + .map((_, index) => `${index}`) + .map((key) => ( + + {key} + + ))} + + + + + + {new Array(16) + .fill(0) + .map((_, index) => `${index}`) + .map((key) => ( + + {key} + + ))} + + + + + + + + + + + + + + + + + Aspect Ratio + + + + + + + } + /> + + + + + + + + + + + + + 0 && selected.length < rows.length} + checked={rows.length > 0 && selected.length === rows.length} + onChange={(event) => { + setSelected(event.target.checked ? rows.map((n) => n.id) : []); + }} + inputProps={{ + 'aria-label': 'select all desserts', + }} + /> + + {headCells.map((headCell) => ( + + { + const isAsc = orderBy === headCell.id && order === SORT_ASC; + setOrder(isAsc ? SORT_DESC : SORT_ASC); + setOrderBy(headCell.id); + }} + > + {headCell.headerName} + + + ))} + + + + {rows + .sort(getComparator(order, orderBy)) + .slice(indexStart, indexEnd) + .map((row) => { + const isItemSelected = selected.includes(row.id); + + return ( + + + + + {row.id} + +
    {row.email}
    +
    + + {row.verified ? : } + + {row.status} + {row.day} + {row.permissions.map(({ label }) => label).join(', ')} +
    + ); + })} +
    +
    +
    + setPage(newPage)} + onRowsPerPageChange={(event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(1); + }} + /> +
    +
    + + + {new Array(4) + .fill(0) + .map((_, index) => `${index}`) + .map((key) => ( + + + + ))} + + + {new Array(4) + .fill(0) + .map((_, index) => `${index}`) + .map((key) => ( + + ))} + + + + null} name="gilad" />} + label="Gilad Gray" + /> + null} name="jason" />} + label="Jason Killian" + /> + null} name="antoine" />} + label="Antoine Llorca" + /> + null} name="robert" />} + label="Robert Smithson" + /> + +
    + ))} +
    +
    +
    + + ); +} + +export default FormsSection; diff --git a/anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.module.scss b/anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.module.scss new file mode 100644 index 0000000..dcd5f9e --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/FormsSection/FormsSection.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"CompareWrapper":"FormsSection_CompareWrapper__ATgNM","Wrapper":"FormsSection_Wrapper__3Yd_G","NoWrap":"FormsSection_NoWrap__iGiVI","AccordionDetails":"FormsSection_AccordionDetails__bHCd2"}; \ No newline at end of file diff --git a/anyclip/src/modules/designSystem/modules/GridListSection/GridListSection.jsx b/anyclip/src/modules/designSystem/modules/GridListSection/GridListSection.jsx new file mode 100644 index 0000000..c46052d --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/GridListSection/GridListSection.jsx @@ -0,0 +1,83 @@ +import React from 'react'; + +import { getImportsString, getPropsString } from '@/modules/designSystem/modules/helpers'; + +import Playground from '@/modules/designSystem/modules/Playground/Playground'; +import { Checkbox, FormControlLabel, GridList } from '@/mui/components'; + +const getList = (count) => new Array(Math.max(1, count)).fill('').map((_, i) => i + 1); + +function GridListSection() { + const [list, setList] = React.useState(getList(10)); + + return ( +
    + { + const props = { + ...state, + gap: state.gap === 'default' ? undefined : state.gap, + }; + + const propsString = getPropsString({ + ...props, + }); + const importsList = getImportsString([[['GridList']]]); + + return { + component: ( + + {list.map((id) => ( + } + label={`${id} Checkbox in list`} + /> + ))} + + ), + code: `${importsList}\n`, + }; + }} + onChange={(nextState, id, value) => { + if (id === 'listOfItems') { + setList(getList(Math.max(1, value))); + } + }} + /> +
    + ); +} + +export default GridListSection; diff --git a/anyclip/src/modules/designSystem/modules/IconButtonSection/IconButtonSection.jsx b/anyclip/src/modules/designSystem/modules/IconButtonSection/IconButtonSection.jsx new file mode 100644 index 0000000..8693ac1 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/IconButtonSection/IconButtonSection.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Favorite } from '@mui/icons-material'; + +import { colorList, getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { IconButton } from '@/mui/components'; + +function IconButtonSection() { + return ( + { + const props = { + ...state, + onClick: () => null, + }; + const propsString = getPropsString({ + ...props, + }); + + const importsList = getImportsString([[['IconButton']], [['Favorite'], '@mui/icons-material']]); + + return { + component: ( + + + + ), + code: `${importsList} + + +`, + }; + }} + /> + ); +} + +export default IconButtonSection; diff --git a/anyclip/src/modules/designSystem/modules/IconSection/IconSection.jsx b/anyclip/src/modules/designSystem/modules/IconSection/IconSection.jsx new file mode 100644 index 0000000..6a8328c --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/IconSection/IconSection.jsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { useTheme } from '@mui/material/styles'; +import { MonitorHeart } from '@mui/icons-material'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Box, Stack, Typography } from '@/mui/components'; +import * as customIcons from '@/mui/components/CustomIcon'; + +const customIconsEntries = Object.entries(customIcons); + +function IconSection() { + const theme = useTheme(); + + const colors = Object.entries({ + text: theme.palette.text, + primary: theme.palette.primary, + secondary: theme.palette.secondary, + info: theme.palette.info, + success: theme.palette.success, + warning: theme.palette.warning, + error: theme.palette.error, + }).reduce((acc, [key, value]) => { + const res = Object.keys(value) + .map((key$) => (key$ === 'contrastText' ? null : `${key}.${key$}`)) + .filter(Boolean); + + return acc.concat(...res); + }, []); + + return ( + + key), + selected: customIconsEntries[0][0], + }, + { + type: 'select', + stateId: 'fontSize', + options: [ + 'inherit', + 'x5Small', + 'x4Small', + 'x3Small', + 'x2Small', + 'xSmall', + 'small', + 'medium', + 'large', + 'xLarge', + 'x2Large', + 'x3Large', + 'x4Large', + 'x5Large', + 'x6Large', + 'x7Large', + 'x8Large', + 'x9Large', + ], + selected: 'medium', + }, + { + type: 'select', + stateId: 'htmlColor', + options: colors, + selected: colors[0], + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + iconKey: undefined, + }; + + const propsString = getPropsString({ + ...props, + component: undefined, + }); + + const Icon = customIconsEntries.find((icon) => icon[0] === state.iconKey)[1]; + + const importsList = getImportsString([ + [['MonitorHeart'], '@mui/icons-material'], + [[state.iconKey], '@/mui/components/CustomIcon'], + ]); + + return { + component: ( + + + + +
    + + + +
    + ), + code: `${importsList} +<${state.iconKey}${propsString} /> +`, + }; + }} + /> + Custom Icons List + + {customIconsEntries.map(([key, Icon]) => ( + + + + ))} + +
    + ); +} + +export default IconSection; diff --git a/anyclip/src/modules/designSystem/modules/InlineAutocompleteSection/InlineAutocompleteSection.jsx b/anyclip/src/modules/designSystem/modules/InlineAutocompleteSection/InlineAutocompleteSection.jsx new file mode 100644 index 0000000..9067a4f --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/InlineAutocompleteSection/InlineAutocompleteSection.jsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; + +import typography from '@/mui/constants/typography'; + +import { getImportsString, getOptions, getPropsString } from '@/modules/designSystem/modules/helpers'; + +import Playground from '@/modules/designSystem/modules/Playground/Playground'; +import { InlineEditAutocomplete, Stack } from '@/mui/components'; + +const options = getOptions(20); + +function InlineAutocompleteSection() { + const [value, setValue] = useState(options[0]); + + return ( + + typeof value$ === 'object') + .map(([key]) => key), + selected: 'body2', + }, + { + type: 'select', + stateId: 'size', + options: ['xSmall', 'small', 'medium', 'large'], + selected: 'medium', + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + { + type: 'checkbox', + stateId: 'readOnly', + selected: false, + }, + { + type: 'checkbox', + stateId: 'editableFromStart', + selected: false, + }, + { + type: 'input-text', + stateId: 'placeholder', + selected: 'Enter description here', + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + value, + options, + optionLabelKey: 'label', + optionValueKey: 'value', + editableFromStart: undefined, + editable: state.editableFromStart, + onChange: (event, newValue) => { + setValue(newValue); + }, + }; + + const importsList = getImportsString([[['InlineEditAutocomplete']]]); + + return { + component: , + code: `${importsList} +`, + }; + }} + /> + + ); +} + +export default InlineAutocompleteSection; diff --git a/anyclip/src/modules/designSystem/modules/InlineDateTimePickerSection/InlineDateTimePickerSection.jsx b/anyclip/src/modules/designSystem/modules/InlineDateTimePickerSection/InlineDateTimePickerSection.jsx new file mode 100644 index 0000000..44ec947 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/InlineDateTimePickerSection/InlineDateTimePickerSection.jsx @@ -0,0 +1,191 @@ +import React, { useMemo } from 'react'; +import dayjs from 'dayjs'; + +import typography from '@/mui/constants/typography'; + +import { getImportsString, getPropsString } from '@/modules/designSystem/modules/helpers'; + +import Playground from '@/modules/designSystem/modules/Playground/Playground'; +import { InlineEditDatePicker, InlineEditDateTimePicker, InlineEditTimePicker } from '@/mui/components'; + +function InlineDateTimePickerSection() { + const initialDate = useMemo(() => new Date(), []); + + return ( + <> + typeof value$ === 'object') + .map(([key]) => key), + selected: 'body2', + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + { + type: 'checkbox', + stateId: 'readOnly', + selected: false, + }, + { + type: 'checkbox', + stateId: 'editableFromStart', + selected: false, + }, + { + type: 'input-text', + stateId: 'placeholder', + selected: 'Enter description here', + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + format: 'MMM D, YYYY hh:mm A', + value: dayjs(initialDate), + editableFromStart: undefined, + editable: state.editableFromStart, + onCancel: () => null, + onApply: () => null, + }; + + const importsList = getImportsString([[['InlineEditDateTimePicker']]]); + + const propsString = getPropsString({ + ...props, + value: '!!!dayjs(new Date())', + }); + + return { + component: , + code: `${importsList} +`, + }; + }} + /> + typeof value$ === 'object') + .map(([key]) => key), + selected: 'body2', + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + { + type: 'checkbox', + stateId: 'readOnly', + selected: false, + }, + { + type: 'checkbox', + stateId: 'editableFromStart', + selected: false, + }, + { + type: 'input-text', + stateId: 'placeholder', + selected: 'Enter description here', + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + format: 'MMM DD, YYYY', + value: dayjs(initialDate), + editableFromStart: undefined, + editable: state.editableFromStart, + onCancel: () => null, + onApply: () => null, + }; + + const importsList = getImportsString([[['InlineEditDatePicker']]]); + + const propsString = getPropsString({ + ...props, + value: '!!!dayjs(new Date())', + }); + + return { + component: , + code: `${importsList} +`, + }; + }} + /> + typeof value$ === 'object') + .map(([key]) => key), + selected: 'body2', + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + { + type: 'checkbox', + stateId: 'readOnly', + selected: false, + }, + { + type: 'checkbox', + stateId: 'editableFromStart', + selected: false, + }, + { + type: 'input-text', + stateId: 'placeholder', + selected: 'Enter description here', + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + format: 'hh:mm A', + value: dayjs(initialDate), + editableFromStart: undefined, + editable: state.editableFromStart, + onCancel: () => null, + onApply: () => null, + }; + + const importsList = getImportsString([[['InlineEditTimePicker']]]); + + const propsString = getPropsString({ + ...props, + value: '!!!dayjs(new Date())', + }); + + return { + component: , + code: `${importsList} +`, + }; + }} + /> + + ); +} + +export default InlineDateTimePickerSection; diff --git a/anyclip/src/modules/designSystem/modules/InlineTextFieldSection/InlineTextFieldSection.jsx b/anyclip/src/modules/designSystem/modules/InlineTextFieldSection/InlineTextFieldSection.jsx new file mode 100644 index 0000000..431e609 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/InlineTextFieldSection/InlineTextFieldSection.jsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import typography from '@/mui/constants/typography'; + +import { getImportsString, getPropsString } from '@/modules/designSystem/modules/helpers'; + +import Playground from '@/modules/designSystem/modules/Playground/Playground'; +import { InlineEditTextField, Stack } from '@/mui/components'; + +function InlineTextFieldSection() { + const text = + 'By instantly activating the innate data in video with AI, ' + + 'the power once reserved for text—transparency, interactivity and collaboration—is now available for' + + ' the most desired and prevalent form of communication: video. ' + + 'Instantly and automatically analyze every video frame-by-frame by brand, object, people, ' + + 'spoken word, text, content category and brand safety. Centralize all your video content in one ' + + 'place so you can automatically manage, organize, host, and distribute.'; + + return ( + + typeof value$ === 'object' && key !== 'overline') + .map(([key]) => key), + selected: 'body2', + }, + { + type: 'input-number', + stateId: 'maxLength', + selected: '1000', + }, + { + type: 'select', + stateId: 'minRows', + options: ['none', ...new Array(5).fill(0).map((_, i) => i + 1)], + selected: 'none', + }, + { + type: 'select', + stateId: 'rows', + options: ['none', ...new Array(5).fill(0).map((_, i) => i + 1)], + selected: 'none', + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + { + type: 'checkbox', + stateId: 'readOnly', + selected: false, + }, + { + type: 'checkbox', + stateId: 'editableFromStart', + selected: false, + }, + { + type: 'input-text', + stateId: 'placeholder', + selected: 'Enter description here', + }, + { + type: 'input-text', + stateId: 'textPlaceholder', + selected: 'No description yet...', + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + value: text, + maxLength: parseInt(state.maxLength, 10) || 0, + minRows: state.minRows === 'none' ? undefined : state.minRows, + rows: state.rows === 'none' ? undefined : state.rows, + editableFromStart: undefined, + editable: state.editableFromStart, + onCancel: () => null, + onApply: () => null, + }; + + const importsList = getImportsString([[['InlineEditTextField']]]); + + return { + component: , + code: `${importsList} +`, + }; + }} + /> + + ); +} + +export default InlineTextFieldSection; diff --git a/anyclip/src/modules/designSystem/modules/JSONEditorSection/JSONEditorSection.tsx b/anyclip/src/modules/designSystem/modules/JSONEditorSection/JSONEditorSection.tsx new file mode 100644 index 0000000..2b32b00 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/JSONEditorSection/JSONEditorSection.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; + +import { APPEARANCE_MODE_DARK, APPEARANCE_MODE_LIGHT } from '@/mui/constants'; + +import Playground from '@/modules/designSystem/modules/Playground/Playground'; +import { Box, JSONEditor } from '@/mui/components'; + +type StateProps = { + theme: typeof APPEARANCE_MODE_DARK | typeof APPEARANCE_MODE_LIGHT | ''; + minRows: number | 'none'; + maxRows: number | 'none'; +}; + +function JSONEditorSection() { + const [value, setValue] = useState(`{ + "string": "This is a string", + "number": 42, + "float": 3.14159, + "booleanTrue": true, + "booleanFalse": false, + "nullValue": null, + "array": [1, "two", false, null, {"nested": "object"}], + "object": { + "nestedString": "Nested", + "nestedNumber": 100, + "nestedBoolean": true, + "nestedArray": [1, 2, 3], + "nestedObject": { + "key": "value" + } + }, + "emptyArray": [], + "emptyObject": {} +}`); + + return ( + ({ + component: ( + + setValue(json)} + minRows={state.minRows === 'none' ? undefined : state.minRows} + maxRows={state.maxRows === 'none' ? undefined : state.maxRows} + /> + + ), + code: ` json} />`, + })} + /> + ); +} + +export default JSONEditorSection; diff --git a/anyclip/src/modules/designSystem/modules/ListSection/ListSection.jsx b/anyclip/src/modules/designSystem/modules/ListSection/ListSection.jsx new file mode 100644 index 0000000..233f872 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ListSection/ListSection.jsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { Drafts, Inbox, KeyboardArrowDown, KeyboardArrowUp, Send, StarBorder } from '@mui/icons-material'; + +import typography from '@/mui/constants/typography'; + +import { getImportsString, getKeyByState } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Collapse, List, ListItem, ListItemButton, ListItemIcon, ListItemText, ListSubheader } from '@/mui/components'; + +function ListSection() { + const [open, setOpen] = React.useState(true); + + const handleClick = () => { + setOpen(!open); + }; + + return ( + <> + typeof value === 'object') + .map(([key]) => key), + selected: 'body2', + }, + { + type: 'select', + stateId: 'listStyleParent', + options: ['initial', 'decimal', 'square'], + selected: 'initial', + }, + { + type: 'select', + stateId: 'listStyleChild', + options: ['initial', 'decimal', 'square'], + selected: 'square', + }, + { + type: 'checkbox', + stateId: 'dense', + selected: true, + }, + ]} + renderCallback={(state) => { + const importsList = getImportsString([[['List', 'ListItem', 'ListItemText']]]); + + return { + component: ( + + + + + + + + + + + + + + + + + ), + code: `${importsList} + + + + + + + + + + + + + + + +`, + }; + }} + /> + { + const importsList = getImportsString([ + [['List', 'ListSubheader', 'ListItemButton', 'ListItemIcon', 'ListItemText', 'Collapse']], + [['Send', 'Drafts', 'Inbox', 'StarBorder'], '@mui/icons-material'], + ]); + + return { + component: ( + + Nested List Items + + } + > + + + + + + + + + + + + + + + + + + {open ? : } + + + + + + + + + + + + + ), + code: `${importsList} + + Nested List Items + + )} +> + + + + + + + + + + + + + + + + + + {open ? : } + + + + + + + + + + + +`, + }; + }} + /> + + ); +} + +export default ListSection; diff --git a/anyclip/src/modules/designSystem/modules/MenuSection/MenuSection.jsx b/anyclip/src/modules/designSystem/modules/MenuSection/MenuSection.jsx new file mode 100644 index 0000000..4c849ec --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/MenuSection/MenuSection.jsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Logout, Person, Send } from '@mui/icons-material'; + +import Playground from '../Playground/Playground'; +import { Button, Menu, MenuItem } from '@/mui/components'; + +function MenuSection() { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + return ( + ({ + component: ( + <> + + setAnchorEl(null)}> + setAnchorEl(null)}> + + Profile + + setAnchorEl(null)}> + + Settings + + setAnchorEl(null)}> + + Logout + + + + ), + code: ` + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + <> + + setAnchorEl(null)} + > + setAnchorEl(null)}> + + Profile + + setAnchorEl(null)}> + + My account + + setAnchorEl(null)}> + + Logout + + + `, + })} + /> + ); +} + +export default MenuSection; diff --git a/anyclip/src/modules/designSystem/modules/NumberFieldSection/NumberFieldSection.jsx b/anyclip/src/modules/designSystem/modules/NumberFieldSection/NumberFieldSection.jsx new file mode 100644 index 0000000..2a802d0 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/NumberFieldSection/NumberFieldSection.jsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { MonetizationOn } from '@mui/icons-material'; + +import { getImportsString, getPropsString, shapes } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { InputAdornment, NumberField } from '@/mui/components'; + +function NumberFieldSection() { + const [value, setValue] = React.useState(''); + + return ( + { + let min = parseFloat(state.min); + let max = parseFloat(state.max); + + if (Number.isNaN(min)) { + min = undefined; + } + + if (Number.isNaN(max)) { + max = undefined; + } + + let step = parseFloat(state.step); + + if (!step || step <= 0) { + step = 1; + } + + const props = omitUndefinedProps({ + ...state, + value, + placeholder: 'Placeholder', + margin: state.margin === 'auto' ? undefined : state.margin, + withLabel: undefined, + withHelperText: undefined, + startAdornment: undefined, + endAdornment: undefined, + maxDecimals: parseInt(state.maxDecimals, 10) || undefined, + min, + max, + step, + label: state.withLabel ? state.label : undefined, + helperText: state.withHelperText ? state.helperText : undefined, + onChange: (event) => setValue(event.target.value), + }); + + let InputProps; + const importsList$ = ['NumberField']; + const importsListIcons$ = []; + const InputStringProps = []; + + if (state.startAdornment || state.endAdornment) { + InputProps = InputProps || {}; + + importsListIcons$.push('MonetizationOn'); + importsList$.push('InputAdornment'); + + if (state.startAdornment) { + InputProps.startAdornment = ( + + + + ); + + InputStringProps.push( + `startAdornment: ( + + + +)`, + ); + } + + if (state.endAdornment) { + InputProps.endAdornment = ( + + + + ); + + InputStringProps.push( + `endAdornment: ( + + + +)`, + ); + } + } + + const propsString = getPropsString({ + ...props, + onChange: `!!!(event) => setValue(event.target.value)`, + InputProps: InputStringProps.length ? `!!!{\n${InputStringProps.join(',\n')}}\n` : undefined, + }); + + const importsList = getImportsString([[importsList$], [importsListIcons$, '@mui/icons-material']]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + onChange={(nextState, id, value$, setState) => { + if (id === 'textAlign' && value$ === 'center') { + setState((prevState) => ({ + ...prevState, + startAdornment: false, + endAdornment: false, + })); + } else if (id === 'margin' && value$ === 'none') { + setState((prevState) => ({ + ...prevState, + label: '', + startAdornment: false, + endAdornment: false, + })); + } + }} + /> + ); +} + +export default NumberFieldSection; diff --git a/anyclip/src/modules/designSystem/modules/PaginationSection/PaginationSection.jsx b/anyclip/src/modules/designSystem/modules/PaginationSection/PaginationSection.jsx new file mode 100644 index 0000000..30cb314 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/PaginationSection/PaginationSection.jsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; + +import { getImportsString, getKeyByState, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Divider, DotPagination, Pagination, Stack, TablePagination } from '@/mui/components'; + +const defaultRowsPerPage = [10, 20, 50]; + +function PaginationSection() { + const [page, setPage] = useState(1); + const [rowsPerPageOptions] = useState(defaultRowsPerPage); + const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage[0]); + + return ( + { + const paginationPropsString = getPropsString({ + ...state, + count: '!!!Math.ceil(count / rowsPerPage)', + rowsPerPage: '!!!rowsPerPage', + }); + const tablePaginationPropsString = getPropsString({ + ...state, + count: '!!!count', + rowsPerPage: '!!!rowsPerPage', + rowsPerPageOptions: `!!!${JSON.stringify(rowsPerPageOptions)}`, + }); + + const importsList = getImportsString([[['Pagination'], ['DotPagination'], ['TablePagination']]]); + + const data = `const count = ${state.count};\nconst rowsPerPage = ${state.rowsPerPage};`; + + return { + component: ( + }> +
    + setPage(newPage)} + /> +
    +
    + +
    +
    + setPage(newPage)} + onRowsPerPageChange={(event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(1); + }} + /> +
    +
    + ), + code: `${importsList} +${data} + + + +`, + }; + }} + onChange={(nextState, id) => { + if (id === 'rowsPerPage' || id === 'count') { + setPage(1); + } + }} + /> + ); +} + +export default PaginationSection; diff --git a/anyclip/src/modules/designSystem/modules/Playground/Playground.jsx b/anyclip/src/modules/designSystem/modules/Playground/Playground.jsx new file mode 100644 index 0000000..c3a8307 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/Playground/Playground.jsx @@ -0,0 +1,321 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'clsx'; +import { useTheme } from '@mui/material/styles'; + +import copyToClipboard from '@/modules/@common/helpers/copy'; + +import { + Box, + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + InputLabel, + MenuItem, + Paper, + Radio, + RadioGroup, + Select, + Stack, + Switch, + TextField, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './Playground.module.scss'; + +const getState = (propState) => propState.reduce((acc, c) => ({ ...acc, [c.stateId]: c.selected }), {}); + +function Playground(props) { + const theme = useTheme(); + const ref = useRef(null); + const [state, setState] = useState(getState(props.initialState)); + const [codeHTML, setCodeHTML] = useState(null); + + const onChange = (id, value) => { + let nextState = state; + + setState((p) => { + nextState = { + ...p, + [id]: value, + }; + + return nextState; + }); + + props.onChange?.(nextState, id, value, setState); + }; + + const getDependenceData = (dependOn) => { + let isDepend = false; + let dependFields = []; + + if (typeof dependOn === 'string' && !state[dependOn]) { + isDepend = true; + dependFields = [dependOn]; + } else if (Array.isArray(dependOn) && dependOn.some((d) => !state[d])) { + isDepend = true; + dependFields = [dependOn.filter((d) => !state[d])]; + } + + return { isDepend, dependFields }; + }; + + const getRadioBlock = ({ id, stateId, options, dependOn }) => { + const { isDepend, dependFields } = getDependenceData(dependOn); + const component = ( + + {stateId} + onChange(stateId, event.target.value)} + disabled={isDepend} + > + {options.map((v) => ( + } label={v} /> + ))} + + + ); + + if (isDepend) { + return {component}; + } + + return component; + }; + + const getCheckboxBlock = ({ stateId, dependOn }) => { + const { isDepend, dependFields } = getDependenceData(dependOn); + + const component = ( + onChange(stateId, !state[stateId])} + disabled={isDepend} + /> + } + labelPlacement="end" + label={stateId} + /> + ); + + if (isDepend) { + return {component}; + } + + return component; + }; + + const getSwitchBlock = ({ stateId, dependOn }) => { + const { isDepend, dependFields } = getDependenceData(dependOn); + const component = ( + onChange(stateId, !state[stateId])} + disabled={isDepend} + /> + } + labelPlacement="end" + label={stateId} + /> + ); + + if (isDepend) { + return {component}; + } + + return component; + }; + + const getSelectBlock = ({ stateId, options, dependOn }) => { + const { isDepend, dependFields } = getDependenceData(dependOn); + + const component = ( + + {stateId} + + + ); + + if (isDepend) { + return {component}; + } + + return component; + }; + + const getInputTextBlock = ({ stateId, dependOn }) => { + const { isDepend, dependFields } = getDependenceData(dependOn); + const component = ( + + onChange(stateId, event.target.value)} + disabled={isDepend} + /> + + ); + + if (isDepend) { + return {component}; + } + + return component; + }; + + const getInputNumberBlock = ({ stateId, dependOn }) => { + const { isDepend, dependFields } = getDependenceData(dependOn); + const component = ( + + { + onChange(stateId, parseInt(event.target.value, 10) || 0); + }} + disabled={isDepend} + /> + + ); + + if (isDepend) { + return {component}; + } + + return component; + }; + + const independentState = { ...state }; + props.initialState + .filter((c) => !!c.dependOn) + .forEach((c) => { + const { isDepend } = getDependenceData(c.dependOn); + if (isDepend) { + delete independentState[c.stateId]; + } + }); + + const { component, code } = props.renderCallback(independentState); + + useEffect(() => { + import('prismjs').then((Prism$) => { + setCodeHTML({ + __html: Prism$.default.highlight(code, Prism$.default.languages.javascript, 'javascript'), + }); + }); + }, [code]); + + return ( + +
    + {props.title && {props.title}} + + {component} + + + {props.initialState + .slice() + .sort((a, b) => a.stateId.localeCompare(b.stateId)) + .map((c) => { + const getComponent = { + select: getSelectBlock, + 'input-text': getInputTextBlock, + 'input-number': getInputNumberBlock, + }[c.type]; + + return getComponent && {getComponent(c)}; + })} + + + + {props.initialState + .slice() + .sort((a, b) => a.stateId.localeCompare(b.stateId)) + .map((c) => { + const getComponent = { + radio: getRadioBlock, + checkbox: getCheckboxBlock, + switch: getSwitchBlock, + }[c.type]; + + return getComponent && {getComponent(c)}; + })} + +
    + +
    +          
    +        
    +
    + +
    +
    +
    + ); +} + +export default Playground; diff --git a/anyclip/src/modules/designSystem/modules/Playground/Playground.module.scss b/anyclip/src/modules/designSystem/modules/Playground/Playground.module.scss new file mode 100644 index 0000000..3701af7 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/Playground/Playground.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Playground_Wrapper__Y1pMW","ContentWrapper":"Playground_ContentWrapper__YF8AK","Input":"Playground_Input__zbquY","CodeWrapper":"Playground_CodeWrapper__ISNS0","CodeCopy":"Playground_CodeCopy__a1EnL","Pre":"Playground_Pre__qaeBV","Code":"Playground_Code___t1tz"}; \ No newline at end of file diff --git a/anyclip/src/modules/designSystem/modules/ProgressSection/ProgressSection.jsx b/anyclip/src/modules/designSystem/modules/ProgressSection/ProgressSection.jsx new file mode 100644 index 0000000..5f9c714 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ProgressSection/ProgressSection.jsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { colorList, getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { CircularProgress, LinearProgress } from '@/mui/components'; + +function ProgressSection() { + return ( + <> + { + const props = { + ...state, + }; + + const importsList = getImportsString([[['LinearProgress']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + { + const props = { + ...state, + }; + + const importsList = getImportsString([[['CircularProgress']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + + ); +} + +export default ProgressSection; diff --git a/anyclip/src/modules/designSystem/modules/RadioSection/RadioSection.jsx b/anyclip/src/modules/designSystem/modules/RadioSection/RadioSection.jsx new file mode 100644 index 0000000..a1212e9 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/RadioSection/RadioSection.jsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import { colorList, getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Radio } from '@/mui/components'; + +function RadioSection() { + return ( + { + const props = { + ...state, + }; + + const importsList = getImportsString([[['Radio']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + ); +} + +export default RadioSection; diff --git a/anyclip/src/modules/designSystem/modules/RatingSection/RatingSection.jsx b/anyclip/src/modules/designSystem/modules/RatingSection/RatingSection.jsx new file mode 100644 index 0000000..fa77080 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/RatingSection/RatingSection.jsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; + +import { colorList, getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Rating } from '@/mui/components'; + +function RatingSection() { + const [value, setValue] = useState(4); + return ( + `${i + 2}`), + selected: '4', + }, + { + type: 'checkbox', + stateId: 'highlightSelectedOnly', + selected: false, + }, + { + type: 'checkbox', + stateId: 'readOnly', + selected: false, + }, + { + type: 'checkbox', + stateId: 'disabled', + selected: false, + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + value, + precision: +state.precision || 0, + max: +state.max || 1, + onChange: (event, newValue) => setValue(newValue), + }; + + const importsList = getImportsString([[['Rating']]]); + + return { + component: , + code: `${importsList} +`, + }; + }} + /> + ); +} + +export default RatingSection; diff --git a/anyclip/src/modules/designSystem/modules/SelectSection/SelectSection.jsx b/anyclip/src/modules/designSystem/modules/SelectSection/SelectSection.jsx new file mode 100644 index 0000000..355d1f4 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/SelectSection/SelectSection.jsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import { getImportsString, getKeyByState, getOptions, getPropsString, shapes } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { FormControl, InputLabel, MenuItem, Select } from '@/mui/components'; + +const options = getOptions(10); + +function SelectSection() { + const [age, setAge] = React.useState(options[0].value); + const [multipleAge, setMultipleAge] = React.useState([]); + + const handleChange = (event) => { + setAge(event.target.value); + }; + + const handleChangeMultiple = (event, isNative) => { + if (isNative) { + const { + target: { options: options$ }, + } = event; + const result = []; + for (let i = 0; i < options$.length; i += 1) { + if (options$[i].selected) { + result.push(options$[i].value); + } + } + setMultipleAge(result); + } else { + setMultipleAge(event.target.value); + } + }; + + return ( + { + const props = { + ...state, + value: state.multiple ? multipleAge : age, + onChange: state.multiple ? (event) => handleChangeMultiple(event, false) : handleChange, + }; + + const importsList = getImportsString([[['FormControl', 'InputLabel', 'Select', 'MenuItem']]]); + + return { + component: ( + + + {state.label} + + + + ), + code: `${importsList} + + ${state.label} + + ${options.map(({ label, value }) => `${label}`).join('\n ')} + +`, + }; + }} + /> + ); +} + +export default SelectSection; diff --git a/anyclip/src/modules/designSystem/modules/SkeletonSection/SkeletonSection.jsx b/anyclip/src/modules/designSystem/modules/SkeletonSection/SkeletonSection.jsx new file mode 100644 index 0000000..a1d240c --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/SkeletonSection/SkeletonSection.jsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Skeleton } from '@/mui/components'; + +function SkeletonSection() { + return ( + { + const props = { + ...state, + }; + + const importsList = getImportsString([[['Skeleton']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + ); +} + +export default SkeletonSection; diff --git a/anyclip/src/modules/designSystem/modules/SliderSection/SliderSection.jsx b/anyclip/src/modules/designSystem/modules/SliderSection/SliderSection.jsx new file mode 100644 index 0000000..9106188 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/SliderSection/SliderSection.jsx @@ -0,0 +1,191 @@ +import React from 'react'; + +import { colorList, getImportsString, getKeyByState, getPropsString, shapes } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Box, Slider } from '@/mui/components'; + +const customMarksOptions = [ + { + value: 0, + label: '0°C', + }, + { + value: 20, + label: '20°C', + }, + { + value: 50, + label: '50°C', + }, + { + value: 100, + label: '100°C', + }, +]; + +function SliderSection() { + return ( + { + const props = { + ...state, + customMarks: undefined, + customRange: undefined, + disableSwap: state.customRange ? state.disableSwap : undefined, + marks: state.marks && state.customMarks ? customMarksOptions : state.marks, + scale: (value) => (state.scale ? value * value : value), + valueLabelFormat: (value) => value, + }; + + const propsString = getPropsString({ + ...props, + scale: `!!!(value) => ${state.scale ? 'value * value' : 'value'}`, + valueLabelFormat: '!!!(value) => value', + }); + + const importsList = getImportsString([[['Slider']]]); + + return { + component: ( + + + + ), + code: `${importsList} +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (id === 'customRange' && value) { + setState({ + ...nextState, + min: 0, + max: 100, + step: 10, + defaultValue: [20, 60], + scale: false, + }); + } else if (id === 'scale' && value) { + setState({ + ...nextState, + min: 0, + max: 30, + step: 1, + defaultValue: 3, + customRange: false, + }); + } else if ((id === 'customRange' && !value) || (id === 'scale' && !value)) { + setState({ + ...nextState, + min: 0, + max: 100, + step: 10, + defaultValue: 30, + }); + } + }} + /> + ); +} + +export default SliderSection; diff --git a/anyclip/src/modules/designSystem/modules/SnackbarSection/SnackbarSection.jsx b/anyclip/src/modules/designSystem/modules/SnackbarSection/SnackbarSection.jsx new file mode 100644 index 0000000..f5fc5cf --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/SnackbarSection/SnackbarSection.jsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import { getImportsString } from '../helpers'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; + +import Playground from '../Playground/Playground'; +import { Button } from '@/mui/components'; + +const longText = + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. ' + + "Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer " + + 'took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries,' + + ' but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the ' + + '1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop ' + + 'publishing software like Aldus PageMaker including versions of Lorem Ipsum'; + +function SnackbarSection() { + const dispatch = useDispatch(); + + return ( + { + const props = { + ...state, + longText: undefined, + verticalPosition: undefined, + horizontalPosition: undefined, + persist: state.persist || undefined, + autoHideDuration: !state.persist ? state.autoHideDuration : undefined, + anchorOrigin: { + vertical: state.verticalPosition, + horizontal: state.horizontalPosition, + }, + onClose: (/* event, reason, myKey */) => { + // console.log(event, reason, myKey) + }, + onExited: (/* event, myKey */) => { + // console.log(event, myKey) + }, + }; + + Object.entries(props).forEach(([key, value]) => { + if (value === undefined) { + delete props[key]; + } + }); + + const propsString = JSON.stringify(props, null, 2); + + const importsList = getImportsString([[['Button', 'IconButton']], [['Close'], '@mui/icons-material']]); + + return { + component: ( + + ), + code: `${importsList} +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (id === 'variant' && value) { + setState({ + ...nextState, + message: `${value} ${nextState.longText ? longText : 'Notification'}`, + }); + } else if (id === 'persist' && value) { + setState({ + ...nextState, + autoHideDuration: 0, + }); + } else if (id === 'persist' && !value) { + setState({ + ...nextState, + autoHideDuration: 5000, + }); + } else if (id === 'autoHideDuration' && value) { + setState({ + ...nextState, + persist: false, + }); + } + + if (id === 'longText' && value) { + setState({ + ...nextState, + message: `${nextState.variant} ${longText}`, + }); + } else if (id === 'longText' && !value) { + setState({ + ...nextState, + message: `${nextState.variant} Notification`, + }); + } else if (id === 'message' && value) { + setState({ + ...nextState, + longText: false, + }); + } + }} + /> + ); +} + +export default SnackbarSection; diff --git a/anyclip/src/modules/designSystem/modules/StepperSection/StepperSection.jsx b/anyclip/src/modules/designSystem/modules/StepperSection/StepperSection.jsx new file mode 100644 index 0000000..69db611 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/StepperSection/StepperSection.jsx @@ -0,0 +1,480 @@ +import React, { useState } from 'react'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Button, Stack, Step, StepButton, StepLabel, Stepper, Typography } from '@/mui/components'; + +const steps = [...Array(5).keys()].map((value) => `Step ${value + 1}`); + +function StepperSection() { + const [activeStep, setActiveStep] = useState(0); + const [completedStepIndexes, setCompletedStepIndexes] = useState(new Set()); + const [skippedStepsIndexes, setSkippedStepsIndexes] = useState(new Set()); + + const isBackAllowed = activeStep === 0; + const isNextAllowed = activeStep === steps.length - 1; + const isStepSkipped = skippedStepsIndexes.has(activeStep); + const isStepCompleted = completedStepIndexes.has(activeStep); + + const completedStep = [...completedStepIndexes].map((stepIndex) => steps[stepIndex]); + const skippedSteps = [...skippedStepsIndexes].map((stepIndex) => steps[stepIndex]); + const finishedSteps = [...completedStep, ...skippedSteps]; + const notFinishedSteps = steps.filter((s) => !finishedSteps.includes(s)); + const isAllStepsFinished = notFinishedSteps.length === 0; + + const handleBack = () => setActiveStep((prevActiveStep) => prevActiveStep - 1); + const handleNext = () => setActiveStep((prevActiveStep) => prevActiveStep + 1); + + const handleSkip = () => { + setSkippedStepsIndexes((prevSkipped) => { + const newSkipped = new Set(prevSkipped.values()); + newSkipped.add(activeStep); + return newSkipped; + }); + + if (activeStep < steps.length - 1) { + handleNext(); + } + }; + + const handleComplete = () => { + setCompletedStepIndexes((prevCompleted) => { + const newCompleted = new Set(prevCompleted.values()); + newCompleted.add(activeStep); + return newCompleted; + }); + + if (activeStep < steps.length - 1) { + handleNext(); + } + }; + + const handleReset = () => { + setActiveStep(0); + setSkippedStepsIndexes(new Set()); + setCompletedStepIndexes(new Set()); + }; + + return ( + + + Linear stepper + { + const importsListString = getImportsString([[['Stepper', 'Step', 'StepLabel']]]); + + const { optionalSteps, failedSteps, ...restState } = state; + const propsString = getPropsString(restState); + + return { + component: ( + + {steps.map((label) => { + const labelProps = { + optional: undefined, + error: undefined, + }; + + if (state.optionalSteps) { + labelProps.optional = Optional; + } + + if (state.failedSteps) { + labelProps.optional = ( + + Alert message + + ); + labelProps.error = true; + } + + return ( + + {label} + + ); + })} + + ), + code: `${importsListString} +const steps = ${JSON.stringify(steps)}; + + + {steps.map((label) => {${ + optionalSteps + ? ` + const labelProps = { + optional: Optional, + }; +` + : '' + }${ + failedSteps + ? ` + const labelProps = { + optional: Alert message; + error: true, + }; +` + : '' + } + return ( + + + {label} + + + ); + })} + +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (id === 'optionalSteps' && value) { + setState({ + ...nextState, + failedSteps: false, + }); + } else if (id === 'failedSteps' && value) { + setState({ + ...nextState, + optionalSteps: false, + }); + } + }} + /> + + + Non-linear stepper + { + const importsListString = getImportsString([[['Stepper', 'Step', 'StepButton', 'StepLabel']]]); + + const { optionalSteps, failedSteps, ...restState } = state; + const propsString = getPropsString(restState); + + return { + component: ( + + {steps.map((label, index) => { + const buttonProps = { + optional: undefined, + }; + const labelProps = { + optional: undefined, + error: undefined, + }; + + if (state.optionalSteps) { + buttonProps.optional = Optional; + } + + if (state.failedSteps) { + buttonProps.optional = ( + + Alert message + + ); + labelProps.error = true; + } + + return ( + + setActiveStep(index)}> + {label} + + + ); + })} + + ), + code: `${importsListString} +const steps = ${JSON.stringify(steps)}; + +const [activeStep, setActiveStep] = useState(0); + + + {steps.map((label, index) => {${ + optionalSteps + ? ` + const buttonProps = { + optional: Optional, + }; +` + : '' + }${ + failedSteps + ? ` + const buttonProps = { + optional: Alert message; + }; + const labelProps = { + error: true, + }; +` + : '' + } + + setActiveStep(index)}> + {label} + + + })} + +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (id === 'optionalSteps' && value) { + setState({ + ...nextState, + failedSteps: false, + }); + } else if (id === 'failedSteps' && value) { + setState({ + ...nextState, + optionalSteps: false, + }); + } + }} + /> + + + Custom actions + { + const props = { + ...state, + }; + const importsListString = getImportsString([[['Stepper', 'Step', 'StepButton', 'StepLabel']]]); + + const propsString = getPropsString(props); + + return { + component: ( + + + {steps.map((label, index) => { + const stepProps = { + completed: completedStepIndexes.has(index), + }; + + return ( + + setActiveStep(index)}> + {label} + + + ); + })} + + + Step info: + {`skipped: ${isStepSkipped}`} + {`completed: ${isStepCompleted}`} + + + Stepper info: + {`skipped: ${skippedSteps}`} + {`completed: ${completedStep}`} + {`not finished: ${notFinishedSteps}`} + + + + + + + + + + ), + code: `${importsListString} +const steps = ${JSON.stringify(steps)}; + +const [activeStep, setActiveStep] = useState(0); +const [completedStepIndexes, setCompletedStepIndexes] = useState(new Set()); +const [skippedStepsIndexes, setSkippedStepsIndexes] = useState(new Set()); + +const isBackAllowed = activeStep === 0; +const isNextAllowed = activeStep === steps.length - 1; +const isStepSkipped = skippedStepsIndexes.has(activeStep); +const isStepCompleted = completedStepIndexes.has(activeStep); + +const completedStep = [...completedStepIndexes].map((stepIndex) => steps[stepIndex]); +const skippedSteps = [...skippedStepsIndexes].map((stepIndex) => steps[stepIndex]); +const finishedSteps = [...completedStep, ...skippedSteps]; +const notFinishedSteps = steps.filter((s) => !finishedSteps.includes(s)); +const isAllStepsFinished = notFinishedSteps.length === 0; + +const handleBack = () => setActiveStep((prevActiveStep) => prevActiveStep - 1); +const handleNext = () => setActiveStep((prevActiveStep) => prevActiveStep + 1); + +const handleSkip = () => { + setSkippedSteps((prevSkipped) => { + const newSkipped = new Set(prevSkipped.values()); + newSkipped.add(activeStep); + return newSkipped; + }); + + if (activeStep < steps.length - 1) { + handleNext(); + } +}; + +const handleComplete = () => { + setCompletedSteps((prevCompleted) => { + const newCompleted = new Set(prevCompleted.values()); + newCompleted.add(activeStep); + return newCompleted; + }); + + if (activeStep < steps.length - 1) { + handleNext(); + } +}; + +const handleReset = () => { + setActiveStep(0); + setSkippedSteps(new Set()); + setCompletedSteps(new Set()); +}; + + + + {steps.map((label, index) => { + const stepProps = { + completed: completedSteps.has(index), + }; + + return ( + + setActiveStep(index)}> + {label} + + + ); + })} + + + Step info: + {\`skipped: ${isStepSkipped}\`} + {\`completed: ${isStepCompleted}\`} + + + Stepper info: + {\`skipped: ${skippedSteps}\`} + {\`completed: ${completedStep}\`} + {\`not finished: ${notFinishedSteps}\`} + + + + + + + + + +`, + }; + }} + /> + + + ); +} + +export default StepperSection; diff --git a/anyclip/src/modules/designSystem/modules/SwitchSection/SwitchSection.jsx b/anyclip/src/modules/designSystem/modules/SwitchSection/SwitchSection.jsx new file mode 100644 index 0000000..1e91855 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/SwitchSection/SwitchSection.jsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { colorList, getImportsString, getKeyByState, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Switch } from '@/mui/components'; + +function SwitchSection() { + return ( + { + const props = { + ...state, + edge: state.edge === 'false' ? undefined : state.edge, + onChange: () => null, + }; + + const importsList = getImportsString([[['Switch']]]); + + return { + component: ( + <> + Switch Me + + ), + code: `${importsList} +Switch Me`, + }; + }} + /> + ); +} + +export default SwitchSection; diff --git a/anyclip/src/modules/designSystem/modules/TabSection/TabSection.jsx b/anyclip/src/modules/designSystem/modules/TabSection/TabSection.jsx new file mode 100644 index 0000000..6fb74b1 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TabSection/TabSection.jsx @@ -0,0 +1,212 @@ +import React, { useState } from 'react'; +import { InfoOutlined } from '@mui/icons-material'; + +import { getImportsString, getPropsString } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { Box, Tab, Tabs, Tooltip } from '@/mui/components'; +import { + CustomCommentsOutlined, + CustomDeepSearchOutlined, + CustomHighlightsOutlined, + CustomPlaylistOutlined, + CustomSlidesOutlined, +} from '@/mui/components/CustomIcon'; + +function TabSection() { + const [value, setValue] = useState(0); + + return ( + <> + { + let limitWidth = null; + let limitHeight = null; + + if (state.emulateNoEnoughSpace) { + if (state.orientation === 'vertical') { + limitHeight = '200px'; + } else { + limitWidth = '300px'; + } + } + + const props = omitUndefinedProps({ + ...state, + iconPosition: undefined, + withIcon: undefined, + withText: undefined, + emulateNoEnoughSpace: undefined, + value, + onChange: (event, newValue) => setValue(newValue), + }); + + const tabs = [ + { + label: 'Playlist', + Icon: CustomPlaylistOutlined, + iconName: 'CustomPlaylistOutlined', + }, + { + label: 'Deep Search', + Icon: CustomDeepSearchOutlined, + iconName: 'CustomDeepSearchOutlined', + }, + { + label: 'Comments', + Icon: CustomCommentsOutlined, + iconName: 'CustomCommentsOutlined', + }, + { + label: 'Highlights', + Icon: CustomHighlightsOutlined, + iconName: 'CustomHighlightsOutlined', + }, + { + label: 'Slides', + Icon: CustomSlidesOutlined, + iconName: 'CustomSlidesOutlined', + }, + ]; + + const importsList = getImportsString([[['Tabs', 'Tab']]]); + + const tabsString = tabs.map(({ label, iconName }, index) => { + const tab = index === 0 ? ' ' : ' '; + + return `${tab}` : undefined, + iconPosition: state.iconPosition === 'start' ? undefined : state.iconPosition, + })}/>`; + }); + + return { + component: ( + + + {tabs.map(({ label, Icon }) => ( + : null} + iconPosition={state.iconPosition} + /> + ))} + + + ), + code: `${importsList} + + ${tabsString.join('\n')} +`, + }; + }} + /> + { + const importsList = getImportsString([[['Tabs', 'Tab']]]); + + return { + component: ( + setValue(newValue)}> + + + + } + iconPosition="end" + /> + } iconPosition="end" /> + + ), + code: `${importsList} + setValue(newValue)}> + + + + )} + iconPosition="end" + /> + } + iconPosition="end" + /> +`, + }; + }} + /> + + ); +} + +export default TabSection; diff --git a/anyclip/src/modules/designSystem/modules/TableSection/TableSection.jsx b/anyclip/src/modules/designSystem/modules/TableSection/TableSection.jsx new file mode 100644 index 0000000..40d5e0c --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TableSection/TableSection.jsx @@ -0,0 +1,372 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import { CheckRounded, CloseRounded } from '@mui/icons-material'; + +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; +import { DAY_OPTIONS, PERMISSION_OPTIONS, STATUS_OPTIONS } from '@/modules/designSystem/modules/constants'; + +import { getKeyByState, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { + Box, + Checkbox, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableScroll, + TableSortLabel, +} from '@/mui/components'; + +import fullNames from '@/modules/designSystem/modules/DataGridSection/internal/fullNamesList.json'; + +const rows = new Array(105).fill(0).map((n, index) => { + const getItemArray = (array) => array[index % (array.length - 1)]; + + return { + id: index + 1, + desk: `D-${index}`, + email: `${getItemArray(fullNames.firstName)}.${getItemArray(fullNames.lastName)}@gmail.com`, + verified: index % 5 === 0, + status: getItemArray(STATUS_OPTIONS), + permissions: [getItemArray(PERMISSION_OPTIONS)], + day: getItemArray(DAY_OPTIONS), + }; +}); + +function descendingComparator(a, b, orderBy) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +function getComparator(order, orderBy) { + return order === SORT_DESC + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +const headCells = [ + { + id: 'id', + headerName: 'Id', + }, + { + id: 'email', + headerName: 'Email', + }, + { + id: 'verified', + align: 'center', + headerName: 'Verified', + }, + { + id: 'status', + headerName: 'Status', + }, + { + id: 'day', + headerName: 'Day', + }, + { + id: 'permissions', + headerName: 'Permissions', + }, +]; + +function TableSection() { + const [order, setOrder] = useState(SORT_ASC); + const [orderBy, setOrderBy] = useState('id'); + const [selected, setSelected] = useState([]); + const [page, setPage] = useState(1); + const [rowsPerPageOptions] = useState([10, 15, 25]); + const [rowsPerPage, setRowsPerPage] = useState(rowsPerPageOptions[0]); + + const handleClick = (name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + setSelected(newSelected); + }; + + // Avoid a layout jump when reaching the last page with empty rows. + const indexStart = (page - 1) * rowsPerPage; + const indexEnd = indexStart + rowsPerPage; + + return ( + { + const props = { + ...state, + inverseScroll: undefined, + }; + + const propsString = getPropsString(props); + + return { + component: ( + + + + + + + + 0 && selected.length < rows.length} + checked={rows.length > 0 && selected.length === rows.length} + onChange={(event) => { + setSelected(event.target.checked ? rows.map((n) => n.id) : []); + }} + inputProps={{ + 'aria-label': 'select all desserts', + }} + /> + + {headCells.map((headCell) => ( + + { + const isAsc = orderBy === headCell.id && order === SORT_ASC; + setOrder(isAsc ? SORT_DESC : SORT_ASC); + setOrderBy(headCell.id); + }} + > + {headCell.headerName} + + + ))} + + + + {rows + .sort(getComparator(order, orderBy)) + .slice(indexStart, indexEnd) + .map((row) => { + const isItemSelected = selected.includes(row.id); + + return ( + + + handleClick(row.id)} + /> + + {row.id} + {row.email} + {row.verified ? : } + {row.status} + {row.day} + {row.permissions.map(({ label }) => label).join(', ')} + + ); + })} + +
    +
    + setPage(newPage)} + onRowsPerPageChange={(event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(1); + }} + /> +
    +
    + ), + code: ` const [order, setOrder] = useState(SORT_ASC); + const [orderBy, setOrderBy] = useState('id'); + const [selected, setSelected] = useState([]); + const [page, setPage] = useState(1); + const [rowsPerPageOptions] = useState([10, 20, 50]); + const [rowsPerPage, setRowsPerPage] = useState(rowsPerPageOptions[0]); + + const handleClick = (name) => { + const selectedIndex = selected.indexOf(name); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, name); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1), + ); + } + + setSelected(newSelected); + }; + + + + + + + + 0 && selected.length < rows.length} + checked={rows.length > 0 && selected.length === rows.length} + onChange={(event) => { + setSelected(event.target.checked ? rows.map((n) => n.id) : []); + }} + inputProps={{ + 'aria-label': 'select all desserts', + }} + /> + + {headCells.map((headCell) => ( + + { + const isAsc = orderBy === headCell.id && order === SORT_ASC; + setOrder(isAsc ? SORT_DESC : SORT_ASC); + setOrderBy(headCell.id); + }} + > + {headCell.headerName} + + + ))} + + + + {rows.map((row) => { + const isItemSelected = selected.includes(row.id); + + return ( + + + handleClick(row.id)} + /> + + + {row.id} + + + {row.email} + + + {row.verified ? : } + + + {row.status} + + + {row.day} + + + {row.permissions.map(({ label }) => label).join(', ')} + + + ); + })} + + + + setPage(newPage)} + onRowsPerPageChange={(event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(1); + }} +/> +`, + }; + }} + /> + ); +} + +export default TableSection; diff --git a/anyclip/src/modules/designSystem/modules/TextFieldSection/TextFieldSection.jsx b/anyclip/src/modules/designSystem/modules/TextFieldSection/TextFieldSection.jsx new file mode 100644 index 0000000..20f2daa --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TextFieldSection/TextFieldSection.jsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { Lock, Visibility } from '@mui/icons-material'; + +import { getImportsString, getPropsString, shapes } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { IconButton, InputAdornment, TextField } from '@/mui/components'; + +function TextFieldSection() { + const [value, setValue] = React.useState(''); + + return ( + { + const props = { + ...state, + value, + placeholder: 'Placeholder', + margin: state.margin === 'auto' ? undefined : state.margin, + withLabel: undefined, + withHelperText: undefined, + startAdornment: undefined, + endAdornment: undefined, + label: state.withLabel ? state.label : undefined, + helperText: state.withHelperText ? state.helperText : undefined, + onChange: (event) => setValue(event.target.value), + }; + + let InputProps; + const importsList$ = ['TextField']; + const importsListIcons$ = []; + const InputStringProps = []; + + if (state.startAdornment) { + InputProps = InputProps || {}; + + InputProps.startAdornment = ( + + + + ); + + importsListIcons$.push('Lock'); + importsList$.push('InputAdornment'); + + InputStringProps.push( + `startAdornment: ( + + + +)`, + ); + } + + if (state.endAdornment) { + InputProps = InputProps || {}; + + InputProps.endAdornment = ( + + null}> + + + + ); + + importsList$.push('InputAdornment', 'IconButton'); + importsListIcons$.push('Visibility'); + InputStringProps.push(` endAdornment: ( + + null}> + + + + )\n`); + } + + const propsString = getPropsString({ + ...props, + InputProps: InputStringProps.length ? `!!!{\n${InputStringProps.join(',\n')}}\n` : undefined, + }); + + const importsList = getImportsString([[importsList$], [importsListIcons$, '@mui/icons-material']]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + onChange={(nextState, id, value$, setState) => { + if (['minRows', 'maxRows'].includes(id)) { + setState((prevState) => { + const nextStateObj = { + ...prevState, + [id]: +value$ || 0, + }; + + if (nextStateObj.minRows > nextState.maxRows) { + nextStateObj.minRows = nextState.maxRows; + } + + return nextStateObj; + }); + } else if (id === 'textAlign' && value$ === 'center') { + setState((prevState) => ({ + ...prevState, + startAdornment: false, + endAdornment: false, + })); + } else if (id === 'margin' && value$ === 'none') { + setState((prevState) => ({ + ...prevState, + label: '', + startAdornment: false, + endAdornment: false, + })); + } + }} + /> + ); +} + +export default TextFieldSection; diff --git a/anyclip/src/modules/designSystem/modules/TimePickerSection/TimePickerSection.jsx b/anyclip/src/modules/designSystem/modules/TimePickerSection/TimePickerSection.jsx new file mode 100644 index 0000000..7d284ca --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TimePickerSection/TimePickerSection.jsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { StaticTimePicker, TimePicker } from '@/mui/components'; + +function TimePickerSection() { + const [value, setValue] = useState(new Date('2014-08-18T21:11:54')); + + return ( + <> + { + const props = { + ...state, + value: dayjs(value), + label: 'Time', + onChange: (newValue) => setValue(newValue), + }; + + const propsString = { + ...props, + // eslint-disable-next-line react/prop-types + value: props.value.toDate(), + }; + + const importsList = getImportsString([[['TimePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + { + const props = { + ...state, + value: dayjs(value), + label: 'Static Time Picker', + onChange: (newValue) => setValue(newValue), + }; + + const propsString = { + ...props, + }; + + const importsList = getImportsString([[['StaticTimePicker']]]); + + return { + component: , + code: `${importsList}\n`, + }; + }} + /> + + ); +} + +export default TimePickerSection; diff --git a/anyclip/src/modules/designSystem/modules/TimeRangePickerSection/TimeRangePickerSection.jsx b/anyclip/src/modules/designSystem/modules/TimeRangePickerSection/TimeRangePickerSection.jsx new file mode 100644 index 0000000..f052208 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TimeRangePickerSection/TimeRangePickerSection.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Typography } from '@/mui/components'; + +function DateTimeRangePickerSection() { + return ( + { + const props = { + ...state, + }; + + const propsString = { + ...props, + }; + + const importsList = getImportsString([[['DateTimeRangePicker']]]); + + return { + component: Doesn`t exists, + code: `${importsList}\n`, + }; + }} + /> + ); +} + +export default DateTimeRangePickerSection; diff --git a/anyclip/src/modules/designSystem/modules/ToggleButtonSection/ToggleButtonSection.jsx b/anyclip/src/modules/designSystem/modules/ToggleButtonSection/ToggleButtonSection.jsx new file mode 100644 index 0000000..7d7cf1f --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/ToggleButtonSection/ToggleButtonSection.jsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +import { FormatAlignCenter, FormatAlignJustify, FormatAlignLeft, FormatAlignRight } from '@mui/icons-material'; + +import { colorList, getImportsString, getKeyByState, getPropsString, shapes } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { Stack, ToggleButton, ToggleButtonGroup } from '@/mui/components'; + +const icons = [ + { + id: 'left', + Icon: FormatAlignLeft, + iconName: 'FormatAlignLeft', + text: 'Align left', + disabled: false, + }, + { + id: 'center', + Icon: FormatAlignCenter, + iconName: 'FormatAlignCenter', + text: 'Align center', + disabled: false, + }, + { + id: 'right', + Icon: FormatAlignRight, + iconName: 'FormatAlignRight', + text: 'Align right', + disabled: false, + }, + { + id: 'justify', + Icon: FormatAlignJustify, + iconName: 'FormatAlignJustify', + text: 'Aligning justify', + disabled: true, + }, +]; + +const defaultValue = icons[0].id; + +function ToggleButtonSection() { + const [alignment, setAlignment] = useState(defaultValue); + + return ( + { + const props = omitUndefinedProps({ + ...state, + buttonContent: undefined, + value: alignment, + onChange: (event, newAlignment) => setAlignment(newAlignment), + }); + + const propsString = getPropsString({ + ...props, + onChange: '!!!(event, newAlignment) => console.log(newAlignment)', + }); + + const importsList = getImportsString([ + [['ToggleButtonGroup', 'ToggleButton']], + [icons.map(({ iconName }) => iconName), '@mui/icons-material'], + ]); + + const addIcon = /icon/.test(state.buttonContent); + const addText = /text/.test(state.buttonContent); + + const tabsList = icons + .map((item) => { + const icon = addIcon && `<${item.iconName} />`; + const text = addText && item.text; + const data = + icon && text + ? ` + ${icon}
    ${text}
    +
    ` + : icon || text; + + return ` + ${data} + `; + }) + .join('\n'); + + return { + component: ( + + {icons.map((item) => { + const { Icon } = item; + + let content = item.text; + + if (addIcon && addText) { + content = ( + + + +
    {item.text}
    +
    +
    + ); + } else if (addIcon) { + content = ; + } + + return ( + + {content} + + ); + })} +
    + ), + code: `${importsList} + +${tabsList} +`, + }; + }} + onChange={(nextState, id, value) => { + if (id === 'exclusive') { + setAlignment(value ? defaultValue : icons.slice(0, 2).map((item) => item.id)); + } + }} + /> + ); +} + +export default ToggleButtonSection; diff --git a/anyclip/src/modules/designSystem/modules/TooltipSection/TooltipSection.jsx b/anyclip/src/modules/designSystem/modules/TooltipSection/TooltipSection.jsx new file mode 100644 index 0000000..d2b4f63 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TooltipSection/TooltipSection.jsx @@ -0,0 +1,99 @@ +import React from 'react'; + +import { getImportsString, getPropsString } from '../helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import Playground from '../Playground/Playground'; +import { Button, Tooltip } from '@/mui/components'; +import { maxWidthMapping } from '@/mui/components/Tooltip/Tooltip.api'; + +const text = + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's"; + +const sizes = Object.entries(maxWidthMapping) + .sort(([, aValue], [bValue]) => bValue - aValue) + .map(([key]) => key); + +function TooltipSection() { + return ( + { + const props = omitUndefinedProps({ + ...state, + open: state.open || undefined, + }); + + return { + component: ( + + + + ), + code: `${getImportsString([[['Tooltip']]])} + + +`, + }; + }} + /> + ); +} + +export default TooltipSection; diff --git a/anyclip/src/modules/designSystem/modules/TreeViewSection/TreeViewSection.jsx b/anyclip/src/modules/designSystem/modules/TreeViewSection/TreeViewSection.jsx new file mode 100644 index 0000000..cfa16e3 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TreeViewSection/TreeViewSection.jsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; + +import { createFlatTreeMap, getAggregateSelectedIds } from '@/mui/helpers/treeView'; + +import Playground from '@/modules/designSystem/modules/Playground/Playground'; +import iabResolver from '../../../../graphql/services/configuration/resolvers/iab'; +import { Autocomplete, Stack, TextField, TreeView } from '@/mui/components'; + +function TreeViewSection() { + const [search, setSearch] = useState(''); + const [flatTree, setFlatTree] = useState( + createFlatTreeMap(iabResolver(), { + label: 'name', + children: 'categories', + }), + ); + + const updateTreeState = (selectedFn) => { + setFlatTree((prevFlatMap) => { + const copiedMap = new Map(); + + prevFlatMap.forEach((node) => { + copiedMap.set(node.id, { + ...node, + selected: selectedFn(node), + }); + }); + + return copiedMap; + }); + }; + + return ( + ({ + component: ( + + flatTree.get(id).label} + onChange={(event, autocompleteValues, reason, value) => { + if (reason === 'removeOption') { + const targetNode = flatTree.get(value.option); + + updateTreeState( + (sourceNode) => + sourceNode.selected && + sourceNode.id !== targetNode.id && + !targetNode.childrenIds.includes(sourceNode.id) && + !targetNode.parentPath.includes(sourceNode.id), + ); + } else if (reason === 'clear') { + updateTreeState(() => false); + setSearch(''); + } + }} + onInputChange={(event, value) => setSearch(value)} + renderInput={(params) => } + /> + { + updateTreeState((sourceNode) => nodeIds.includes(sourceNode.id)); + }} + /> + + ), + code: ` +const [search, setSearch] = useState(''); +const [flatTree, setFlatTree] = useState(createFlatTreeMap(iabResolver(), { + label: 'name', + children: 'categories', +})); + +const updateTreeState = (selectedFn) => setFlatTree((prevFlatMap) => { + const copiedMap = new Map(); + + prevFlatMap.forEach((node) => { + copiedMap.set(node.id, { + ...node, + selected: selectedFn(node), + }); + }); + + return copiedMap; +}); + + flatTree.get(id).label} + onChange={(event, autocompleteValues, reason, value) => { + if (reason === 'removeOption') { + const targetNode = flatTree.get(value.option); + + updateTreeState((sourceNode) => sourceNode.selected && sourceNode.id !== targetNode.id + && !targetNode.childrenIds.includes(sourceNode.id) + && !targetNode.parentPath.includes(sourceNode.id)); + } else if (reason === 'clear') { + updateTreeState(() => false); + setSearch(''); + } + }} + onInputChange={(event, value) => setSearch(value)} + renderInput={(params) => } +/> + { + updateTreeState((sourceNode) => nodeIds.includes(sourceNode.id)); + }} +/>`, + })} + /> + ); +} + +export default TreeViewSection; diff --git a/anyclip/src/modules/designSystem/modules/TypographySection/TypographySection.jsx b/anyclip/src/modules/designSystem/modules/TypographySection/TypographySection.jsx new file mode 100644 index 0000000..c22a404 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/TypographySection/TypographySection.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { useTheme } from '@mui/material/styles'; + +import typography from '@/mui/constants/typography'; + +import { getImportsString, getPropsString } from '../helpers'; + +import Playground from '../Playground/Playground'; +import { Typography } from '@/mui/components'; + +function TypographySection() { + const theme = useTheme(); + + const colors = Object.entries({ + text: theme.palette.text, + primary: theme.palette.primary, + secondary: theme.palette.secondary, + info: theme.palette.info, + success: theme.palette.success, + warning: theme.palette.warning, + error: theme.palette.error, + }).reduce((acc, [key, value]) => { + const res = Object.keys(value) + .map((key$) => (key$ === 'contrastText' ? null : `${key}.${key$}`)) + .filter(Boolean); + + return acc.concat(...res); + }, []); + + const fontWeightOptions = new Array(7).fill(0).map((t, i) => (i + 1) * 100); + + return ( + typeof value === 'object') + .map(([key]) => key), + selected: 'h1', + }, + { + type: 'select', + stateId: 'color', + options: colors, + selected: colors[0], + }, + { + type: 'select', + stateId: 'fontWeight', + options: fontWeightOptions, + selected: theme.typography.h1.fontWeight, + }, + ]} + renderCallback={(state) => { + const props = { + ...state, + }; + + const value = `${state.variant}. Text example`; + + return { + component: {value}, + code: `${getImportsString([[['Typography']]])} + + ${value} +`, + }; + }} + onChange={(nextState, id, value, setState) => { + if (id === 'variant') { + setState((prevState) => ({ + ...prevState, + fontWeight: theme.typography[value].fontWeight, + })); + } + }} + /> + ); +} + +export default TypographySection; diff --git a/anyclip/src/modules/designSystem/modules/constants/index.js b/anyclip/src/modules/designSystem/modules/constants/index.js new file mode 100644 index 0000000..2e24784 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/constants/index.js @@ -0,0 +1,21 @@ +export const STATUS_OPTIONS = ['Account Admin', 'Editor', 'Contributor', 'Guest']; +export const DAY_OPTIONS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + +export const PERMISSION_OPTIONS = [ + { + label: 'permission 1', + value: 1, + }, + { + label: 'permission 2', + value: 2, + }, + { + label: 'permission 3', + value: 3, + }, + { + label: 'permission 4', + value: 4, + }, +]; diff --git a/anyclip/src/modules/designSystem/modules/constants/routes.js b/anyclip/src/modules/designSystem/modules/constants/routes.js new file mode 100644 index 0000000..5268805 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/constants/routes.js @@ -0,0 +1,54 @@ +export const ROUTE_NEUTRAL = '/design-system/compare'; +export const ROUTE_ICON = '/design-system/icon'; +export const ROUTE_LIST = '/design-system/list'; +export const ROUTE_MENU = '/design-system/menu'; +export const ROUTE_CHIP = '/design-system/chip'; +export const ROUTE_CHIP_ALT = '/design-system/chip-alt'; +export const ROUTE_CARD = '/design-system/card'; +export const ROUTE_STEPPER = '/design-system/stepper'; +export const ROUTE_COLOR_PICKER = '/design-system/color-picker'; +export const ROUTE_PAGINATION = '/design-system/pagination'; +export const ROUTE_AVATAR = '/design-system/avatar'; +export const ROUTE_APP_BAR = '/design-system/app-bar'; +export const ROUTE_ALERT = '/design-system/alert'; +export const ROUTE_BREADCRUMBS = '/design-system/breadcrumbs'; +export const ROUTE_SKELETON = '/design-system/skeleton'; +export const ROUTE_SWITCH = '/design-system/switch'; +export const ROUTE_RADIO = '/design-system/radio'; +export const ROUTE_DATA_GRID = '/design-system/data-grid'; +export const ROUTE_DIALOG = '/design-system/dialog'; +export const ROUTE_CHECKBOX = '/design-system/checkbox'; +export const ROUTE_BUTTON = '/design-system/button'; +export const ROUTE_ICON_BUTTON = '/design-system/icon-button'; +export const ROUTE_BUTTON_GROUP = '/design-system/button-group'; +export const ROUTE_BADGE = '/design-system/badge'; +export const ROUTE_TYPOGRAPHY = '/design-system/typography'; +export const ROUTE_RATING = '/design-system/rating'; +export const ROUTE_ACCORDION = '/design-system/accordion'; +export const ROUTE_DATE_PICKER = '/design-system/date-picker'; +export const ROUTE_DATE_RANGE_PICKER = '/design-system/date-range-picker'; +export const ROUTE_DATE_TIME_PICKER = '/design-system/date-time-picker'; +export const ROUTE_DATE_TIME_RANGE_PICKER = '/design-system/date-time-range-picker'; +export const ROUTE_TIME_PICKER = '/design-system/time-picker'; +export const ROUTE_TIME_RANGE_PICKER = '/design-system/time-range-picker'; +export const ROUTE_AUTOCOMPLETE = '/design-system/autocomplete'; +export const ROUTE_NUMBER_FIELD = '/design-system/number-field'; +export const ROUTE_DURATION_FIELD = '/design-system/duration-field'; +export const ROUTE_TEXT_FIELD = '/design-system/text-field'; +export const ROUTE_SELECT = '/design-system/select'; +export const ROUTE_TABS = '/design-system/tab'; +export const ROUTE_TABLE = '/design-system/table'; +export const ROUTE_SLIDER = '/design-system/slider'; +export const ROUTE_PROGRESS = '/design-system/progress'; +export const ROUTE_FLOATING_ACTION_BUTTONS = '/design-system/fab'; +export const ROUTE_SNACKBAR = '/design-system/snackbar'; +export const ROUTE_TOOLTIP = '/design-system/tooltip'; +export const ROUTE_TOGGLE_BUTTON = '/design-system/toggle-button'; +export const ROUTE_BOTTOM_NAVIGATION = '/design-system/bottom-navigation'; +export const ROUTE_TREE_VIEW = '/design-system/tree-view'; +export const ROUTE_INLINE_TEXT_FIELD = '/design-system/inline-edit/text-field'; +export const ROUTE_INLINE_AUTOCOMPLETE = '/design-system/inline-edit/autocomplete'; +export const ROUTE_INLINE_DATE_TIME_PICKER = '/design-system/inline-edit/date-time-picker'; +export const ROUTE_FORMS = '/design-system/forms'; +export const ROUTE_GRID_LIST = '/design-system/grid-list'; +export const ROUTE_JSON_EDITOR = '/design-system/json-editor'; diff --git a/anyclip/src/modules/designSystem/modules/helpers/index.js b/anyclip/src/modules/designSystem/modules/helpers/index.js new file mode 100644 index 0000000..6993d03 --- /dev/null +++ b/anyclip/src/modules/designSystem/modules/helpers/index.js @@ -0,0 +1,131 @@ +import fullNames from '@/modules/designSystem/modules/DataGridSection/internal/fullNamesList.json'; + +export const colorList = [ + 'primary', + 'secondary', + 'info', + 'error', + 'warning', + 'highlight', + 'highlight2', + 'complimentary', + 'success', + 'neutral', +]; + +export const shapes = ['square', 'rounded', 'circular']; + +export const imageAvatarUrl = + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAUEBAQEAwUEBAQGBQUGCA0ICAcHCBALDAkNExAUExIQEhIUFx0ZFBYcFhISGiMaHB4fISEhFBkkJyQgJh0gISABBQYGCAcIDwgIDyAVEhUgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIP/AABEIAIAAgAMBEQACEQEDEQH/xACPAAABBQEBAQAAAAAAAAAAAAAHAgMEBQYIAQAQAAEDAwMDAgMFBgQHAAAAAAECAwQABREGEiETMUEHURRhcSKBkaGxIyQyQlLBCBVyojNDYoLR4fEBAAIDAQEAAAAAAAAAAAAAAAAEAQIDBQYRAQACAgICAwADAQEAAAAAAAABAgMRBBIhMRMiMhRBQiNR/9oADAMBAAIRAxEAPwC2Q0cUi1PJaoB5LPyNAOBk8eaAV0MUB70qAT0wTj8qjrtPpTXbUNism4XK5NMrSNxb7r/Ac1pWlmc3ZKT6raTaXtY+MfHlSWdoH4nP5Vr/AB5U+ZEZ9VrE8vaYzyfy59s9qn+OPmau0321XtGIT+XNu4tqxuA/vWFsU0aRdYrZwap7Sjlr5CpSYW0R4oCK41VlUVbeDVg2qGfNZtD6GeO1APIa5AoBwNGgFBr5ULaQbpcrbZYSpl0msxGUjO5xQBPyA7k/SpiGUyBWsPVabdiuHYA5BiDI6x4dWPu/hp7HxtFb5A0W466ve44pxR8qJJpuIrUvF5l70yUbvap3A0QUEUbGlnabhNtktEiG8ptxBygg9jWVqRZpSzonTF6b1FZW5g2peSAHUDwfeublr0O0WymucVksjraqQira4PFAQ3GqsG6SzVVzyWaAeSzQCw1x2oDLa41ZD0dYDLd/aS38txWRwVrx3+QHk1pXH3lTJdy/eb3c7/PXOuspb7yjxk8IHsB4FdStYrDn2srkp9zVlDgbO4EJqq3STqEkr27cggj+9RMwtWLPVMKKs7CBRE1W6WKCClGQk9x4o3WEdLN/6Z3ZTV9VAJIRKQoFI8qA4pTPuY8GscxX2ORa+zjvSTXRhbXHapV0iLaoShOtd+KA3yWcDgULnQzigHA1igFBugOXfWK6uz/USTELmWoKEsITjBT5V+Jro8aNRsjlkPAkngDk+KY3uS1Y2toNuCyN/PvWNraO0xtZadOx5a0gpGDx9aSvnmHRpx4ltbdoG0qUjeyta85+X0pK2exyvFq1LfpjaZLYOzpq2kAd+9U/kWWnBVJY9JrUww6h1CHFOjBPsPGKrOe0Sj4K2CDWGkbnoO7IududU22FkIc8oyK6mHNFo8uXyOPMeml9Ntdv3aebLc+XNhU26Co5xyQoEmrZMei2O+xTW3xxShlEca71KiE61QBCDFQuWGaAX0jQH3SoDjr1OJPqbfeVk9c/x11eP6c/Mztvj9V8FXYd6m8ow1aqHHTkDHnvSc2dSlW40/G/ekrIOAO3vSWSzpYoFO1RErebJSBgdqTNS2cZtAcCGxwB4qGMrBtgqG4jip6qxLK6w02zqLTky3Pgb1oIQr2Pg1eltSpeu4cnaW+LsvqLASllan2JXSU2Dgq5IUn5nvxXetPbE8/FemR1IW0lIKP4SMjjFc2fyb/0juN1AQnW+KsqI/R/SpXfBrigFBqgPOn5x2oDjb1Vjts+qt9ZZRsAeB88kpBJ59ya6WD0QyKe0RHD+02/Z7VnknyYwVaaIlKMbiBS0w6FW6sJa6jad4BJyAfNJXg7jkWLXH/4Sk4PGSaWMLn4noyABnnuaGa7Yc3IGDmrRO2UwZlIyk1WVquTNe2oxvWpDcJrDkmWy6gcjKioV2cFu2FxuXXrldELRyaThKK43UrIjjdWVErpVZV908UB50hQl4W80Byd60C1zPUx2TbZQeJaQ3KAQRsdb+yRyOeAORTWK3hlbH5UvTbiQkJ4TtTWVo3JuniEGNbr1eA4/GKIsVHd11W0fWtd9fSvx5Le5JTFvsVYch3uNJcR/wAtl4FePkD3o8W/TOaZa+pF/wBM9ZXGUv4C5g9RBNc3Pj1Lq4b7p1FOXIbVtXtwRzgnvSv6bxHWobOar9T37mXLVYuvFyUhtTe3AzwdxUO9O0x4dFt5Y9tFbddXyJKaj6ssL8Bl0hHX7obPuTWFqRvwvTf9q+92ViX63WeaQFlqGqQPbKcgH8SKYwTquinIrudtstFEey6MtFCERxHegCX0vGK2UedMUB5s4oQ8CMqHGOaEw5U1tEYuWs5qiR8V8Y4VJ8lAWrn7sCik6h0L44VYt4mFSTjA96rF/I+PwhXqJPkxmbZFUUoKiVDPBPjNXpbp7Uvhtf8AMiHpPTGmGfTpy23hh2bdCFOIc6YSUrJyAlRPYVjly7n6tMXFmv6kP2pM3T2rmwlZTsWNySoKIGfcVpP3qr+MmnTbCUSNLidEZD0npbwk/wAxxwBmudrVj29yyPp5pa/6ln3efr6a/FbyEw2GXSnb5JG0kY8YIpv/AJa8FLxli3lpYNmkus3GwXVRmxErKWHlpOSPB58j8qV3O/Bqa6r5JcsqWdWR5e8kR7YWB4yeqO/3Cr1nTC9d1TVo4phzI9oy0UJRVooAmbMeKYZE7PFAJKOfnULE7OeOD4qohyM7brjB1/Lj3rKpRU8UKCshRyckmiZdDey4a1InLRxjNZTDSrWRoDCkddCEkkcgil5kzSCJ8p+LEPQYAKeylnj7h5qatLSFMt39/WtStzq17lE+TT2vq5cz9nVGgpDMrSUJpw7z0xurnX9n/wDLZwTGiEnOz/q7fjU1UvOzrjjTqytAz86LMohSTUpMoq7K24PHirY1M0oK00xJGEZaeKgIq0UATSimmRBTQCSngVAIxwRiqgE/VTS8hnUsbU7IQYqyEL8KS7txz7ggVQ1jkMF4RcVcYCiDxVTkNvZUdRKUdxSdztXmtWnIOnXnIyAt4oOPepxs7yCMN1qM/EmSkF8OPDekDcRjk5HiupP5crf2dQaS1fAlxYkVyGYbkoqTEBQQlSW8ZOcYHccGuVf260R9W4beDr21Ywrx7GqRLOYWKEbUVpDKZ0pJRC7i8B/KlIP35NaYy+WUZY/Wt5KwjLHeqhGcHegCcU96bZE7eKAb2ioBG2oDFepsIy9CSSngsrQ4TgnAzg9qo0pLmT4sp+ysZUkkDxwDxVdHIs09svyYkRuQokgjtnvS1sZyl9KvUOtJEpwwWWQta05CuScVriwssmZYaJ0vDl3VFzuqmwQQUI4zkVOVfFi2NduiSmoDZRGbdUytSkoVgceCPY4pIxabUWtvu8Ca+pjJaktq2qbWMEGjWmUrwvANkZGattlLOxXTJdmPnG1T6koIPdKQB+oNMUglmktQrSWP9I6x3oCMsUAT8UywIKRQCCKhY2RQFVfrcm66fnW5QH7dlSU5GcKxwfxxVU1lxlcsNz32VODqtqwpGfIyKtpvs7bno6W0pnFRSDkBJ/8APes7t8dtkT7VEmXBciMtxIUASoKIJ49qml9L2x7afTMPTzDJ/wA3nOJQDk5cUCPwNL5DmK/QYbBE0/cIyHLTeZis9imST49jSze2atn0nTUmLq6PfGrjJeZwlLzTpB7eU4H5VS0l5XtxvDUfepYKUpHGeAf/AKeKmkbZSctsRUO1MMODDgTuWPZROT+Zp7TnXncnlihWUdY70BGcHegCeRTbE3j/ANUAg8iqoNkULQQRlWB3qI8ifEuRPW+zCx+oUt9hAEWftfGzgodI+2k/UgmrUTM+AuN3dQogKOMDtx+NMfGpOZf6ZvCEy225RLiSocA0rkxm8OcUY9rs+oeiSz046Cd4T9kk/WufuaurERcSdMtWmz27EZrpIKSoKWBjAOBWc/YdYqpbzrFTFzTGjyCqQkng5CSOR9/H54orSZ8Sxm8VSdOJe1TdE3aUk/AwV5RkfZfdx3B8pT+tM1xxUnlzTb03iu5rQuYWKqtBhYqUIy/NAE48802xN0A2fbtVUI0uVHhQ35kt5DEeOhTrrqzhKEJBJUfkACalLjD1I9dNTarub8KwTn7NYQShtDBLbz6e29xY5Gf6AQBTdMXgnbLuVXYGlzfT74SXlZW86tJcJJIOMKyee+eaTv8AWXUw07VYu4Qnorym3AePNOUvshkxzCKw+tpwYJ47EVa1YllS0w2dn1pIgNrbKjsWkDhWD9aTtx4s6VOV1aBr1SnCGhhMdXUQgJHT7YBzk/PjFZxx4q1tyZsnaI07fNd38Tp0p2LbmjlxxBPn+RGfJxyfAqMvWs6hGOtrV2udD+tzFtuh0vqWMyxbWHlsRprCMBlAWQkOpHBHusVrOHvXZL5YrbToDclSQpJCkkZBByCD5FKmIMrNVWgwqhCMugCcac2x0aJwKqhmtUa30ro2KH9SXqPAKhlDRO51z/S2nKj9cYq0UR3cwerfrqNZ2penNMMSYVpcP70+/gLlgdk7RnYimaY2FrgUkArGfetp9MNfYUdPv9aC2BwkoHHtgY/tXLzQ72Cd1W7+n03COopQCodsisq5NNL4tsXL0stqWWVILaxTdcxC3HaOw+lsu7r4nFkfNG6qTyl44gmWb0Ws9tAfnvvTnPmNrZPzFKX5EyZpgiBRtVtj2u1oYjMoabQDtSlOAKUmfts5XXXq4/8AUnSJ0lrB5pslcSXmQyVdwFKJKT9DXcwZN104PJw/FbsLXpb6vWKLpaHp7VM5cSVE/YsyXEEtLa/kClDO0pFVy45UplGxiVHmRUS4chqTHcGUOtLC0KHyI4NJamDUX2Ss1GlkdZo1CPLzUPrt6aWEOIF+F2fRx0bagvf7+Ef7q6EUKzcBdZ/4kdWXvfG0uyNOwv6xh2Sf+8jCPuFbRjZTkBOZNmXGa7Nnynpcp05W8+srWs/NR5NaVoytKKTVpnSj4GiFolvdESkrbeYcUMJI5/pJ/sf1pDPDp8O+xesjICktqRnd5H965do07FLbWF20yzKSHQ2hZT4IorItELXTFmXDfS+wpaAf4kE5GaraUQIIZU8lPUByPGapEbVlLca2pSgY+lW0iv8A65k/xES4p1Jarc0QX2GVLc+QUa6vCrtzebfv4BUV0ptEuT6XFg1XqHTD/Wsd2fh5OVNpOW1/6kH7J/CspxxK9cgxad9f217GNVWnZ4MqD+pbNK2waM1zi1Z9Q2XUUL4yy3Jia156Z+0g+yknlJ+opO1Jg3W8S//Z'; + +const rawStringDetectReg = /['"]!!!(.*?)['"]/g; + +export const getPropsString = (reactProps, options) => { + const { spaceCount } = { + ...(options || {}), + spaceCount: 2, + }; + + const result = Object.entries(reactProps) + .filter(([key, value]) => value !== undefined && !/^\$\$/.test(key)) + .map(([key, value]) => { + let isStringQuote = false; + + if (typeof value === 'boolean') { + if (value) { + return key; + } + + return null; + } + + let value$ = value; + + if (typeof value === 'function') { + value$ = `${key}Fn`; + } else if (typeof value === 'object') { + value$ = JSON.stringify(value, null, 2).replace(rawStringDetectReg, '$1'); + } + + if (typeof value === 'string') { + if (/!!!/.test(value)) { + value$ = value.replace(rawStringDetectReg, '$1').replace(/!!!/, ''); + } else { + isStringQuote = true; + } + } + + if (typeof value === 'string' && /\/\*\*\//.test(value)) { + return `\n// ${key}={${value$}}\n`; + } + + const resValue = isStringQuote ? `"${value$}"` : `{${value$}}`; + + return `${key}=${resValue}`; + }) + .filter(Boolean); + + let string = ''; + + if (result.length) { + string = ' '; + const oneLine = result.join(' '); + + if (oneLine.length > 60) { + string += `\n${' '.repeat(spaceCount)}`; + string += result.join(`\n${' '.repeat(spaceCount)}`); + string += '\n'; + } else { + string += oneLine; + } + } + + return string; +}; + +const getImportString = (listNames, fromString) => { + let list = null; + + if (listNames.length) { + const fixedList = [...new Set(listNames)].filter(Boolean); + const divider = fixedList.length <= 3 ? ' ' : '\n'; + + list = `${divider}${fixedList.join(`,${divider}`)}${divider}`; + } + + return list ? `import {${list}} from '${fromString}';` : ''; +}; + +const defaultFrom = '@/mui/components'; + +export const getImportsString = (listNames) => { + const dataString = listNames + .filter(Boolean) + .reduce((acc, [list, from]) => { + const from$ = from || defaultFrom; + const index = acc.findIndex((item) => item[1] === from$); + + if (index === -1) { + acc.push([list, from$]); + } else { + acc[index][0] = [...acc[index][0], ...list]; + } + + return acc; + }, []) + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([list, from]) => getImportString(list, from)) + .join('\n'); + + return `${dataString}\n`; +}; + +export const getKeyByState = (state) => Object.entries(state).reduce((acc, item) => `${acc}${item}`, ''); + +export const getOptions = (count) => + new Array(count).fill(0).map((_, index) => ({ + label: `${fullNames.firstName[index]} ${fullNames.lastName[index]}`, + value: index, + })); + +export default {}; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/constants/index.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/constants/index.js new file mode 100644 index 0000000..7dcd37c --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/constants/index.js @@ -0,0 +1,11 @@ +export const QUERY_NEW_PLAYLIST_PARAM = 'new'; + +export const PLAYLIST_TYPE = 'EDITORIAL'; + +export const TAB_GENERAL = 'general'; +export const TAB_PROMOTIONS = 'promotions'; +export const FORM_REDUX_FIELD_NAME = 'commonForm'; + +export const ERROR_MESSAGE_NO_MATCH = 'URL is not matching any player'; +export const ERROR_MESSAGE_DEFAULT = 'URL is not valid please try again'; +export const ERROR_MESSAGE_ALREADY_EXISTS = 'URL already exists for player'; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/player.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/player.js new file mode 100644 index 0000000..b8ff55d --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/player.js @@ -0,0 +1,27 @@ +const regexEx = /^[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,16}\b([-a-zA-Z0-9@:%_+.~#?&/=\S]*)/; +const extraRegex = /(https?:\/\/)|(\/){2,}/; // no double slash + +export const removeProtocol = (url) => url.replace(/https?:\/\//i, '').replace(/\/$/, ''); +export const removeWorldWideWebPrefix = (url) => url.replace(/^www\./, ''); + +export const isValidPlaylistUrl = (url) => { + const urlWithoutProtocol = removeProtocol(url).toLowerCase(); + + return regexEx.test(url) && !extraRegex.test(urlWithoutProtocol) && url.substring(url.length - 1) !== '/'; +}; + +export const matchedPlayersByURL = (url, players) => { + const urlWithoutWorldWideWebPrefix = url ? removeWorldWideWebPrefix(url.toLowerCase()) : ''; + + return players.filter((player) => + player.urlPrefix.find((playerUrl) => + urlWithoutWorldWideWebPrefix.includes(removeWorldWideWebPrefix(playerUrl.toLowerCase())), + ), + ); +}; + +export const availablePlayers = (players, playlists) => { + const usedPlayers = [...new Set(playlists.map((playlist) => playlist.playerId))]; + + return players.filter((player) => !usedPlayers.includes(player.id)); +}; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/validationScheme.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/validationScheme.js new file mode 100644 index 0000000..691a831 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/validationScheme.js @@ -0,0 +1,103 @@ +import { + ERROR_MESSAGE_ALREADY_EXISTS, + ERROR_MESSAGE_DEFAULT, + ERROR_MESSAGE_NO_MATCH, + TAB_GENERAL, + TAB_PROMOTIONS, +} from '../constants'; + +import { + availablePlayers, + isValidPlaylistUrl, + matchedPlayersByURL, +} from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/player'; + +export const validationScheme = [ + { + fieldName: 'hub', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Hub is required'; + } + + return ''; + }, + }, + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'url', + tabId: TAB_GENERAL, + validation: (value, state) => { + if (!isValidPlaylistUrl(value)) { + return ERROR_MESSAGE_DEFAULT; + } + + const playersByURL = matchedPlayersByURL(value, state.playerOptions); + if (!playersByURL.length) { + return ERROR_MESSAGE_NO_MATCH; + } + + if (!availablePlayers(playersByURL, state.playlistsByUrl).length) { + return ERROR_MESSAGE_ALREADY_EXISTS; + } + + return ''; + }, + }, + { + fieldName: 'player', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Player is required'; + } + + return ''; + }, + }, + { + fieldName: 'frequency', + tabId: TAB_PROMOTIONS, + validation: (value, state) => { + if (!state.includeSponsoredContent) { + return ''; + } + + if (!value) { + return 'empty'; + } + + if (value < 1 || value > 10) { + return 'between 1 and 10'; + } + + return ''; + }, + }, + { + fieldName: 'supplyTag', + tabId: TAB_PROMOTIONS, + validation: (value, state) => { + if (!state.includeSponsoredContent) { + return ''; + } + + if (!value || !value.id) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/createPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/createPlaylist.js new file mode 100644 index 0000000..271818c --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/createPlaylist.js @@ -0,0 +1,122 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap, switchMap } from 'rxjs/operators'; + +import { QUERY_PARAM_PLAYLIST } from '@/modules/editorial/constants/routing'; +import { PLAYLIST_TYPE } from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/constants'; + +import { getPlaylistByIdAction } from '../../../redux/slices'; +import { clearCreateFormAction, createPlaylistAction, setIsLoadingAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { + frequencySelector, + hubSelector, + includeSponsoredContentSelector, + isPrefixSelector, + nameSelector, + playerSelector, + shuffleSelector, + statusSelector, + supplyTagSelector, + urlSelector, +} from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/selectors'; + +const queryGQL = ` + mutation createPlaylist( + $playerId: [Int!], + $status: Boolean!, + $isPrefix: Boolean!, + $name: String!, + $url: String, + $publisherId: Int!, + $clips: [String], + $type: String, + $shuffle: Boolean, + $includeSponsoredContent: Boolean, + $frequency: Int, + $supplyTagId: String, + $supplyTagName: String, + ) { + createPlaylist( + playerId: $playerId, + status: $status, + isPrefix: $isPrefix, + name: $name, + url: $url, + publisherId: $publisherId, + clips: $clips, + type: $type, + shuffle: $shuffle, + includeSponsoredContent: $includeSponsoredContent, + frequency: $frequency, + supplyTagId: $supplyTagId, + supplyTagName: $supplyTagName, + ) { + playlistId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createPlaylistAction.type), + filter(() => !!getToken()), + switchMap(() => { + const hub = hubSelector(state$.value); + const name = nameSelector(state$.value); + const status = statusSelector(state$.value); + const url = urlSelector(state$.value); + const playerId = playerSelector(state$.value).id; + const isPrefix = isPrefixSelector(state$.value); + const shuffle = shuffleSelector(state$.value); + + const includeSponsoredContent = includeSponsoredContentSelector(state$.value); + const frequency = parseInt(frequencySelector(state$.value), 10); + const supplyTagId = supplyTagSelector(state$.value)?.id; + const supplyTagName = supplyTagSelector(state$.value)?.name; + + const clips = []; + + const params = { + publisherId: hub.value, + name, + status, + playerId, + isPrefix, + shuffle, + clips, + type: PLAYLIST_TYPE, + + includeSponsoredContent, + frequency, + supplyTagId, + supplyTagName, + }; + if (url) { + params.url = url; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: params, + }).pipe( + mergeMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(clearCreateFormAction()), + of(removeQueriesAction([QUERY_PARAM_PLAYLIST])), + of(getPlaylistByIdAction({ id: data.createPlaylist.playlistId })), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/duplicatePlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/duplicatePlaylist.js new file mode 100644 index 0000000..69e181b --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/duplicatePlaylist.js @@ -0,0 +1,77 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap, switchMap } from 'rxjs/operators'; + +import { QUERY_PARAM_PLAYLIST } from '@/modules/editorial/constants/routing'; + +import { getPlaylistByIdAction } from '../../../redux/slices'; +import { clearCreateFormAction, duplicatePlaylistAction, setIsLoadingAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { + duplicatedPlaylistIdSelector, + nameSelector, + playerSelector, + urlSelector, +} from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/selectors'; + +const queryGQL = ` + mutation duplicatePlaylist( + $playlistId: Int!, + $name: String!, + $playerId: Int!, + $url: String, + ) { + duplicatePlaylist( + playListId: $playlistId, + name: $name, + url: $url, + playerId: $playerId + ) { + playlistId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(duplicatePlaylistAction.type), + filter(() => !!getToken()), + switchMap(() => { + const duplicatedPlaylistId = duplicatedPlaylistIdSelector(state$.value); + const name = nameSelector(state$.value); + const url = urlSelector(state$.value); + const playerId = playerSelector(state$.value).id; + + const params = { + playlistId: duplicatedPlaylistId, + name, + playerId, + }; + if (url) { + params.url = url; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: params, + }).pipe( + mergeMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(clearCreateFormAction()), + of(removeQueriesAction([QUERY_PARAM_PLAYLIST])), + of(getPlaylistByIdAction({ id: data.duplicatePlaylist.playlistId })), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlayers.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlayers.js new file mode 100644 index 0000000..c74bb6d --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlayers.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getPlayerOptionsAction, getPlaylistsByUrlAction, setPlayerOptionsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { getPublisherIdsSelector } from '@/modules/@common/user/redux/selectors'; +import { urlSelector } from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/selectors'; + +const queryGQL = ` + query getPublisherPlayers($publisherIds: [Int]) { + getPublisherPlayers(publisherIds: $publisherIds) { + results { + id + name + alias + publisherId + urlPrefix + displayEmbedCode + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPlayerOptionsAction.type), + filter(() => !!getToken()), + filter((action) => action?.payload || !!getPublisherIdsSelector(state$.value)?.length), + switchMap((action) => { + const variables = {}; + + variables.publisherIds = [action.payload]; + + const stream$ = gqlRequest({ query: queryGQL, variables }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const players = data.getPublisherPlayers?.results + .sort((a, b) => a?.alias?.localeCompare(b?.alias)) + .map((player) => ({ + value: player.id, + label: player.alias, + ...player, + })); + + actions.push(of(setPlayerOptionsAction(players))); + + const url = urlSelector(state$.value); + if (url) { + actions.push(of(getPlaylistsByUrlAction())); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistById.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistById.js new file mode 100644 index 0000000..70bcbba --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistById.js @@ -0,0 +1,102 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, filter, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { PLAYLIST_TYPE } from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/constants'; + +import { getPlaylistByIdAction, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query getPlaylistById( + $id: Int!, + ) { + getPlaylistById( + id: $id, + ) { + id + playerId + status + isPrefix + name + url + createdAt + updatedAt + createdBy + updatedBy + clips + shuffle + playerAlias + widgetName + pubName + publisherId + publisherName + playerAspectRatio + type + isFallback + includeSponsoredContent, + frequency, + supplyTagId, + supplyTagName, + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlaylistByIdAction.type), + filter(() => !!getToken()), + concatMap(({ payload }) => + gqlRequest({ + query: queryGQL, + variables: { + id: payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + if (data.getPlaylistById.type !== PLAYLIST_TYPE) { + return of( + showNotificationAction({ + type: TYPE_ERROR, + message: `Playlist ${data.getPlaylistById.id} doesn't exist`, + }), + ); + } + + const selectedPlayer = { + value: data.getPlaylistById.playerId, + label: data.getPlaylistById.playerAlias, + urlPrefix: [data.getPlaylistById.url], + }; + + actions.push( + of( + updateStoreByPropAction({ + ...data.getPlaylistById, + playlistId: data.getPlaylistById.id, + hub: { + value: data.getPlaylistById.publisherId, + label: data.getPlaylistById.publisherName, + }, + player: selectedPlayer, + playerOptions: [selectedPlayer], + supplyTag: { + id: data.getPlaylistById.supplyTagId, + name: data.getPlaylistById.supplyTagName, + }, + }), + ), + ); + } + + return concat(...actions); + }), + ), + ), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistsByUrl.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistsByUrl.js new file mode 100644 index 0000000..1dc9134 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPlaylistsByUrl.js @@ -0,0 +1,99 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { REDUX_ERROR_PROP_NAME } from '@/modules/@common/Form/constants'; + +import { hubSelector, playerOptionsSelector, playlistIdSelector, urlSelector } from '../selectors'; +import { + getPlaylistsByUrlAction, + setErrorByPropAction, + setPlaylistsByUrlAction, + setScrollToFieldNameAction, + validateSingleField, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { + isValidPlaylistUrl, + removeWorldWideWebPrefix, +} from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/helpers/player'; + +const queryGQL = ` + query getPlaylists( + $page: Int, + $pageSize: Int, + $publisherIds: [Int], + $type: String, + $searchText: String, + $searchIn: String + ) { + getPlaylists( + page: $page, + pageSize: $pageSize, + publisherIds: $publisherIds, + type: $type, + searchText: $searchText, + searchIn: $searchIn + ) { + records { + id + playerId + url + } + recordsTotal + page + pageSize + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPlaylistsByUrlAction.type), + debounceTime(500), + filter(() => !!getToken()), + filter( + () => !!hubSelector(state$.value) && !!urlSelector(state$.value) && isValidPlaylistUrl(urlSelector(state$.value)), + ), + switchMap(() => { + const url = removeWorldWideWebPrefix(urlSelector(state$.value)); + const playerOptions = playerOptionsSelector(state$.value); + + const variables = { + page: 1, + pageSize: 1000, + publisherIds: [hubSelector(state$.value).value], + type: 'EDITORIAL', + searchText: url, + searchIn: 'url', + }; + + return gqlRequest({ query: queryGQL, variables }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const playlistId = playlistIdSelector(state$.value); + const playlists = data.getPlaylists.records.filter( + (playlist) => playlist.id !== playlistId && url === removeWorldWideWebPrefix(playlist.url), + ); + + const validation = validateSingleField('url', url, { + playerOptions, + playlistsByUrl: playlists, + }); + + if (validation[REDUX_ERROR_PROP_NAME]) { + actions.push(of(setErrorByPropAction([validation]))); + actions.push(of(setScrollToFieldNameAction(validation.fieldName))); + } + + actions.push(of(setPlaylistsByUrlAction(playlists))); + } + + return concat(...actions); + }), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPublishers.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPublishers.js new file mode 100644 index 0000000..93acba1 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getPublishers.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getHubOptionsAction, setHubOptionsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const query = ` + query PlaylistGetHubs( + $pageSize: Int, + $searchText: String, + ) { + playlistGetHubs( + pageSize: $pageSize, + searchText: $searchText + ) { + records { + id + name + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + filter(() => !!getToken()), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + pageSize: 100, + searchText: action.payload?.searchText ?? '', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(setHubOptionsAction(data.playlistGetHubs.records.map(({ id, name }) => ({ value: id, label: name })))), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getSupplyTagOptions.ts b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getSupplyTagOptions.ts new file mode 100644 index 0000000..b1ad104 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/getSupplyTagOptions.ts @@ -0,0 +1,96 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { hubSelector } from '../selectors'; +import { getSupplyTagOptionsAction, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type RequestPayloadType = { + searchText?: string; + pageSize: number; + siteIds?: number[]; +}; + +type ResponseType = { + data: { + id: string; + name: string; + }[]; +}; + +type ActionPayload = { + searchText: string; +}; + +const queryName = 'playlistGetSupplyTags' as const; + +const getResponse = (data: ResponseType) => data.data; + +const query = ` + query PlaylistGetSupplyTags( + $pageSize: Int, + $searchText: String, + $siteIds: [Int], + ) { + ${queryName}( + pageSize: $pageSize, + searchText: $searchText, + siteIds: $siteIds, + ) { + data { + id + name + } + } + } +`; + +const getSupplyTagOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter( + (action): action is ReturnType => + action.type === getSupplyTagOptionsAction.type, + ), + debounce((action) => { + if (!action.payload) { + return timer(0); + } + const { searchText = '' } = action.payload; + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actionPayload = action.payload! as ActionPayload; + const hub = hubSelector(state$.value); + const payload: RequestPayloadType = { + searchText: actionPayload.searchText || '', + pageSize: 30, + }; + + if (hub?.value) { + payload.siteIds = [hub.value]; + } + + return gqlRequest({ + query, + variables: payload, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + updateStoreByPropAction({ + supplyTagOptions: getResponse(response.data[queryName]), + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getSupplyTagOptionsEpic; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/index.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/index.js new file mode 100644 index 0000000..7315566 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/index.js @@ -0,0 +1,21 @@ +import { combineEpics } from 'redux-observable'; + +import createPlaylist from './createPlaylist'; +import duplicatePlaylist from './duplicatePlaylist'; +import getPlayers from './getPlayers'; +import getPlaylistById from './getPlaylistById'; +import getPlaylistsByUrl from './getPlaylistsByUrl'; +import getPublishers from './getPublishers'; +import getSupplyTagOptions from './getSupplyTagOptions'; +import updatePlaylist from './updatePlaylist'; + +export default combineEpics( + getPlaylistById, + getPublishers, + getPlayers, + getPlaylistsByUrl, + createPlaylist, + updatePlaylist, + duplicatePlaylist, + getSupplyTagOptions, +); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/updatePlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/updatePlaylist.js new file mode 100644 index 0000000..e7fb00b --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/epics/updatePlaylist.js @@ -0,0 +1,121 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import { QUERY_PARAM_PLAYLIST_EDIT } from '@/modules/editorial/constants/routing'; +import { PLAYLIST_TYPE } from '@/modules/editorial/RightSideBar/TabPlaylist/Edit/constants'; + +import { + clipsSelector, + frequencySelector, + hubSelector, + includeSponsoredContentSelector, + isPrefixSelector, + nameSelector, + playerSelector, + playlistIdSelector, + shuffleSelector, + statusSelector, + supplyTagSelector, + urlSelector, +} from '../selectors'; +import { clearCreateFormAction, updatePlaylistAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getPlaylistByIdAction } from '@/modules/editorial/RightSideBar/TabPlaylist/redux/slices'; + +const queryGQL = ` + mutation updatePlaylist( + $playlistId: Int!, + $publisherId: Int!, + $name: String, + $url: String, + $clips: [String], + $playerId: Int, + $isPrefix: Boolean, + $status: Boolean, + $shuffle: Boolean, + $includeSponsoredContent: Boolean, + $frequency: Int, + $supplyTagId: String, + $supplyTagName: String, + ) { + updatePlaylist( + playlistId: $playlistId, + publisherId: $publisherId, + name: $name, + url: $url, + clips: $clips, + playerId: $playerId, + isPrefix: $isPrefix, + status: $status, + shuffle: $shuffle, + includeSponsoredContent: $includeSponsoredContent, + frequency: $frequency, + supplyTagId: $supplyTagId, + supplyTagName: $supplyTagName, + ){ + playlistId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updatePlaylistAction.type), + switchMap(() => { + const playlistId = playlistIdSelector(state$.value); + const hub = hubSelector(state$.value); + const name = nameSelector(state$.value); + const status = statusSelector(state$.value); + const url = urlSelector(state$.value); + const playerId = playerSelector(state$.value).id; + const isPrefix = isPrefixSelector(state$.value); + const shuffle = shuffleSelector(state$.value); + const clips = clipsSelector(state$.value); + + const includeSponsoredContent = includeSponsoredContentSelector(state$.value); + const frequency = parseInt(frequencySelector(state$.value), 10); + const supplyTagId = supplyTagSelector(state$.value)?.id; + const supplyTagName = supplyTagSelector(state$.value)?.name; + + const params = { + playlistId, + publisherId: hub.value, + name, + url, + status: !!status, + playerId, + isPrefix, + shuffle, + clips, + type: PLAYLIST_TYPE, + + includeSponsoredContent, + frequency, + supplyTagId, + supplyTagName, + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: params, + }).pipe( + mergeMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(clearCreateFormAction()), + of(removeQueriesAction([QUERY_PARAM_PLAYLIST_EDIT])), + of(getPlaylistByIdAction({ id: data.updatePlaylist.playlistId })), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/selectors/index.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/selectors/index.js new file mode 100644 index 0000000..578a48c --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/selectors/index.js @@ -0,0 +1,36 @@ +import { FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; +export const formChangedSelector = (state) => state[nameSpace].formChanged; + +export const playlistIdSelector = (state) => state[nameSpace].playlistId; +export const duplicatedPlaylistIdSelector = (state) => state[nameSpace].duplicatedPlaylistId; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; +export const hubSelector = (state) => state[nameSpace].hub; +export const nameSelector = (state) => state[nameSpace].name; +export const statusSelector = (state) => state[nameSpace].status; +export const urlSelector = (state) => state[nameSpace].url; +export const isPrefixSelector = (state) => state[nameSpace].isPrefix; +export const playerOptionsSelector = (state) => state[nameSpace].playerOptions; +export const playerSelector = (state) => state[nameSpace].player; +export const playlistsByUrlSelector = (state) => state[nameSpace].playlistsByUrl; +export const isFallbackSelector = (state) => state[nameSpace].isFallback; +export const shuffleSelector = (state) => state[nameSpace].shuffle; +export const clipsSelector = (state) => state[nameSpace].clips; + +export const includeSponsoredContentSelector = (state) => state[nameSpace].includeSponsoredContent; +export const frequencySelector = (state) => state[nameSpace].frequency; +export const supplyTagSelector = (state) => state[nameSpace].supplyTag; +export const supplyTagOptionsSelector = (state) => state[nameSpace].supplyTagOptions; + +const formSelectors = createFormSelector(FORM_REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/slices/index.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/slices/index.js new file mode 100644 index 0000000..8a7384e --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/Edit/redux/slices/index.js @@ -0,0 +1,113 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { FORM_REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(FORM_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + isLoading: false, + activeTabId: TAB_GENERAL, + formChanged: false, + + playlistId: null, + duplicatedPlaylistId: null, + hubOptions: [], + hub: null, + name: '', + status: true, + url: '', + isPrefix: true, + playerOptions: [], + player: null, + playlistsByUrl: [], + isFallback: false, + shuffle: false, + clips: [], + + // sponsored content + includeSponsoredContent: false, + frequency: 3, + supplyTag: null, + supplyTagOptions: null, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@EDITORIAL/SIDEBAR/PLAYLISTS/PLAYLIST_EDIT', + initialState, + + reducers: { + // common + setIsLoadingAction: (state, action) => { + state.isLoading = action.payload; + }, + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + getPlaylistByIdAction: (state) => state, + + getHubOptionsAction: (state) => state, + setHubOptionsAction: (state, action) => { + state.hubOptions = action.payload; + }, + + getPlayerOptionsAction: (state) => state, + setPlayerOptionsAction: (state, action) => { + state.playerOptions = action.payload; + }, + + getPlaylistsByUrlAction: (state) => state, + setPlaylistsByUrlAction: (state, action) => { + state.playlistsByUrl = action.payload; + }, + + updateStoreByPropAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + + createPlaylistAction: (state) => state, + updatePlaylistAction: (state) => state, + duplicatePlaylistAction: (state) => state, + clearCreateFormAction: (state) => ({ ...initialState, activeTabId: state.activeTabId }), + getSupplyTagOptionsAction: (state) => state, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setIsLoadingAction, + setActiveTabIdAction, + getPlaylistByIdAction, + + getHubOptionsAction, + setHubOptionsAction, + getPlayerOptionsAction, + setPlayerOptionsAction, + getPlaylistsByUrlAction, + setPlaylistsByUrlAction, + updateStoreByPropAction, + + createPlaylistAction, + updatePlaylistAction, + duplicatePlaylistAction, + clearCreateFormAction, + + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, + + getSupplyTagOptionsAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/getAiFilters.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/getAiFilters.js new file mode 100644 index 0000000..4f572d9 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/getAiFilters.js @@ -0,0 +1,378 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { TRENDING } from '@/modules/editorial/constants/video'; +import { FILTER_COMPONENTS_NAMES, VIDEO_DURATION_LABELS } from '@/modules/editorial/editorialSearchFilter/constants'; + +import { getVideoDurationFilterEnumValue } from '../../../../common/helpers'; +import { playlistsSelector } from '../../selectors'; +import { getAiFiltersAction, setAiFiltersAction } from '../../slices'; +import { getLanguageOptions } from '@/modules/@common/helpers/videoLangs'; +import { findNodes, getIAB } from '@/modules/@common/iab/helpers'; +import { gqlRequest } from '@/modules/@common/request'; +import { + configAction, + configChangedEventAction, + isFilterDirtyAction, +} from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; + +import { + getDefaultConfig, + getEmptySearchOrderConfig, + timeValueMap, +} from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +const queryGQL = ` + query getEditorialAiFilters( + $editorialPlaylistId: Int!, + $aiPlaylistId: Int!, + ) { + getEditorialAiFilters( + editorialPlaylistId: $editorialPlaylistId, + aiPlaylistId: $aiPlaylistId, + ) { + id + videoFilterValues { + id + videoFilterId + type + value + action + extra + color + name + } + } + } +`; + +const ACTIONS = { + INCLUDE: true, + EXCLUDE: false, +}; + +const configUpdater = { + IAB: (config, { value, action }) => ({ + ...config, + categories: { + ...config.categories, + filters: [ + { + ...config.categories.filters[0], + value: [ + ...config.categories.filters[0].value, + { + id: findNodes(getIAB(), [value])[0].id, + name: findNodes(getIAB(), [value])[0].name, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + PEOPLE: (config, { value, action }) => ({ + ...config, + people: { + ...config.people, + filters: [ + { + ...config.people.filters[0], + value: [ + ...config.people.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + BRANDS: (config, { value, action }) => ({ + ...config, + brands: { + ...config.brands, + filters: [ + { + ...config.brands.filters[0], + value: [ + ...config.brands.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + BRAND_SAFETY: (config, { value, action }) => ({ + ...config, + brandSafety: { + ...config.brandSafety, + filters: [ + { + ...config.brandSafety.filters[0], + value: [ + ...config.brandSafety.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + LABEL: (config, { value, action, color, name, extra }) => ({ + ...config, + labels: { + ...config.labels, + filters: [ + { + ...config.labels.filters[0], + value: [ + ...config.labels.filters[0].value, + { + value: value.split(':').slice(1).join(':'), + label: value.split(':').slice(1).join(':'), + labelId: extra, + name, + color, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + KEYWORDS: (config, { value, action, color, name, extra }) => ({ + ...config, + keywords: { + ...config.keywords, + filters: [ + { + ...config.keywords.filters[0], + value: [ + ...config.keywords.filters[0].value, + { + value, + label: value, + labelId: extra, + name, + color, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + LANG: (config, { value, action }) => ({ + ...config, + languages: { + ...config.languages, + filters: [ + { + ...config.languages.filters[0], + value: [ + ...config.languages.filters[0].value, + { + value, + label: getLanguageOptions().find((lang) => lang.value === value).label, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + FEED: (config, { value, action }) => ({ + ...config, + source: { + ...config.source, + filters: [ + { + ...config.source.filters[0], + value: [ + ...config.source.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + EVERGREEN: (config, { value }) => ({ + ...config, + evergreen: { + ...config.evergreen, + filters: [ + { + ...config.evergreen.filters[0], + value: [ + { + value: value === 'true' ? 'EVERGREEN' : 'NO_EVERGREEN', + label: value === 'true' ? 'Evergreen' : 'Non Evergreen', + }, + ], + }, + ], + }, + }), + TARGETING_STATUS: (config, { value }) => ({ + ...config, + targetingStatus: { + ...config.targetingStatus, + filters: [ + { + ...config.targetingStatus.filters[0], + value: [{ label: 'Video Targeting', value }], + }, + ], + }, + }), + MEDIA_TYPE: (config, { value }) => ({ + ...config, + mediaType: { + ...config.mediaType, + filters: [ + { + ...config.mediaType.filters[0], + value: [{ label: 'Media Type', value }], + }, + ], + }, + }), +}; + +const getUpdatedConfig = (oldConfig, newConfig = []) => { + const updatedConfig = { + ...oldConfig, + }; + + const customTime = { + filters: [ + { + label: 'Custom', + value: {}, + component: FILTER_COMPONENTS_NAMES.FilterTimePicker, + }, + ], + }; + + const config = newConfig.reduce((acc, cur) => { + if (configUpdater[cur.type]) { + return configUpdater[cur.type](acc, cur); + } + return acc; + }, updatedConfig); + + const duration = {}; + + newConfig.forEach((item) => { + if (item.type === 'TIME') { + config.time = { + filters: [timeValueMap[item.value]], + }; + } else if (['START_DATE', 'END_DATE'].includes(item.type)) { + customTime.filters[0].value[item.type === 'START_DATE' ? 'startDate' : 'endDate'] = dayjs(+item.value) + .toDate() + .getTime(); + } + + if (item.type === 'LENGTH_TO') { + duration.to = item.value; + } + + if (item.type === 'LENGTH_FROM') { + duration.from = item.value; + } + + if (item.type === 'VIDEO_AFFILIATION' && item.value === 'MY_VIDEOS') { + config.videos = { + filters: [{ label: 'My Videos', value: item.value }], + }; + } + }); + + if (customTime.filters[0].value.startDate) { + config.time = customTime; + } + + const videoDurationFilterEnumValue = getVideoDurationFilterEnumValue(duration); + if (videoDurationFilterEnumValue) { + config.duration = { + filters: [{ label: VIDEO_DURATION_LABELS[videoDurationFilterEnumValue], value: videoDurationFilterEnumValue }], + }; + } + + return config; +}; + +export default (action$, state$) => + action$.pipe( + ofType(getAiFiltersAction.type), + concatMap(({ payload: { playlistId, aiPlaylistId, withSearch = false } }) => { + const playlists = playlistsSelector(state$.value); + const playlist = playlists.find((playlist$) => playlist$.id === playlistId); + const aiPlaylist = Object.values(playlist.aiPlaylists).find((aiPlaylist$) => aiPlaylist$.id === aiPlaylistId); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + editorialPlaylistId: playlistId, + aiPlaylistId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { videoFilterValues } = data.getEditorialAiFilters; + const config = getDefaultConfig(); + + const defaultOrderConfig = getEmptySearchOrderConfig(); + + const filterConfig = { + ...config, + ...defaultOrderConfig, + }; + + const updatedConfig = getUpdatedConfig(filterConfig, videoFilterValues); + + actions.push(of(setAiFiltersAction({ playlistId, aiPlaylistId, aiFilters: videoFilterValues.slice() }))); + + if (aiPlaylist.type === TRENDING) { + updatedConfig.order.filters[0] = { + label: 'Trending', + value: 'trending', + isSortable: false, + }; + } + + if (withSearch) { + actions.push( + of(configAction(updatedConfig)), + of(configChangedEventAction()), + of(isFilterDirtyAction(true)), + ); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/updateAiFilters.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/updateAiFilters.js new file mode 100644 index 0000000..129118e --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiFilters/updateAiFilters.js @@ -0,0 +1,210 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { setAiFiltersAction, updateAiFiltersAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { + filterParamsSelector, + searchFiltersSelector, +} from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { timeMap } from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +const queryGQL = ` + mutation createEditorialAiFilters( + $editorialPlaylistId: Int!, + $aiPlaylistId: Int!, + $filters: [EditorialAiFilterValuesInput], + ) { + createEditorialAiFilters( + editorialPlaylistId: $editorialPlaylistId, + aiPlaylistId: $aiPlaylistId, + filters: $filters, + ) { + id + } + } +`; + +const INCLUDE = 'INCLUDE'; +const EXCLUDE = 'EXCLUDE'; + +const acceptedParams = { + iab: (filters) => + filters.map((param) => ({ + type: 'IAB', + value: param, + action: INCLUDE, + })), + iabExcludes: (filters) => + filters.map((param) => ({ + type: 'IAB', + value: param, + action: EXCLUDE, + })), + keywordFilters: (filters) => + filters.map((param) => ({ + type: param.category, + value: param.keyword, + action: INCLUDE, + })), + keywordExcludes: (filters) => + filters.map((param) => ({ + type: param.category, + value: param.keyword, + action: EXCLUDE, + })), + labelFilters: (filters) => + filters.map((param) => ({ + type: 'LABEL', + value: `${param.name}:${param.value}`, + color: param.color, + action: INCLUDE, + extra: param.labelId, + })), + labelExcludes: (filters) => + filters.map((param) => ({ + type: 'LABEL', + value: `${param.name}:${param.value}`, + color: param.color, + action: EXCLUDE, + extra: param.labelId, + })), + lang: (filters) => + filters.map((param) => ({ + type: 'LANG', + value: param.toUpperCase(), + action: INCLUDE, + })), + langExcludes: (filters) => + filters.map((param) => ({ + type: 'LANG', + value: param.toUpperCase(), + action: EXCLUDE, + })), + feedDescription: (filters) => + filters.map((param) => ({ + type: 'FEED', + value: param, + action: INCLUDE, + })), + feedDescriptionExcludes: (filters) => + filters.map((param) => ({ + type: 'FEED', + value: param, + action: EXCLUDE, + })), + evergreen: (filter) => [ + { + type: 'EVERGREEN', + value: filter.toString(), + action: INCLUDE, + }, + ], + lengthFrom: (filter) => [ + { + type: 'LENGTH_FROM', + value: `${filter}`, + action: INCLUDE, + }, + ], + lengthTo: (filter) => [ + { + type: 'LENGTH_TO', + value: `${filter}`, + action: INCLUDE, + }, + ], + videoAffiliation: (filter) => [ + { + type: 'VIDEO_AFFILIATION', + value: filter, + action: INCLUDE, + }, + ], + targetingStatus: (filter) => [ + { + type: 'TARGETING_STATUS', + value: filter, + action: INCLUDE, + }, + ], + mediaType: (filter) => [ + { + type: 'MEDIA_TYPE', + value: filter, + action: INCLUDE, + }, + ], +}; + +export default (action$, state$) => + action$.pipe( + ofType(updateAiFiltersAction.type), + switchMap(({ payload: { playlistId, aiPlaylistId } }) => { + const filterParams = filterParamsSelector(state$.value); + const { time } = searchFiltersSelector(state$.value); + + const filtersToSend = Object.keys(filterParams) + .filter((param) => Object.keys(acceptedParams).includes(param)) + .reduce((acc, cur) => [...acc, ...acceptedParams[cur](filterParams[cur])], []); + + if (timeMap[time.filters[0]?.value]) { + filtersToSend.push({ + type: 'TIME', + value: timeMap[time.filters[0].value], + action: INCLUDE, + }); + } else if (filterParams.startDate && filterParams.endDate) { + const { startDate, endDate } = filterParams; + filtersToSend.push( + { + type: 'START_DATE', + value: startDate.toString(), + action: INCLUDE, + }, + { + type: 'END_DATE', + value: endDate.toString(), + action: INCLUDE, + }, + ); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + editorialPlaylistId: playlistId, + aiPlaylistId, + filters: filtersToSend.map((item) => + Object.keys(item).reduce((acc, key) => { + if (key !== 'color') { + acc[key] = item[key]; + } + + return acc; + }, {}), + ), + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(setAiFiltersAction({ playlistId, aiPlaylistId, aiFilters: filtersToSend.slice() })), + of(showNotificationAction({ type: TYPE_SUCCESS, message: 'Filters saved' })), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/createAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/createAiPlaylist.js new file mode 100644 index 0000000..e584314 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/createAiPlaylist.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { createAiPlaylistAction, setAiPlaylistModeAction, updatePlaylistAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { playlistTypeAction } from '@/modules/editorial/editorialSearch/reduxSearch/slices'; +import { + configAction, + configChangedEventAction, + isFilterDirtyAction, +} from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; +import { aiPlaylistIdAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { playlistsSelector } from '@/modules/editorial/RightSideBar/TabPlaylist/redux/selectors'; + +import { getDefaultConfig, getEmptySearchOrderConfig } from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +const queryGQL = ` + mutation createEditorialAiPlaylist( + $editorialPlaylistId: Int!, + $type: String!, + $length: Int, + ) { + createEditorialAiPlaylist( + editorialPlaylistId: $editorialPlaylistId, + type: $type, + length: $length, + ) { + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createAiPlaylistAction.type), + concatMap(({ payload: { playlistId, publisherId, type } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + editorialPlaylistId: playlistId, + type, + }, + }).pipe( + concatMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const config = getDefaultConfig(); + + const defaultOrderConfig = getEmptySearchOrderConfig(); + + const filterConfig = { + ...config, + ...defaultOrderConfig, + }; + + const playlists = playlistsSelector(state$.value); + const playlist = playlists.find((playlist$) => playlist$.id === playlistId); + + actions.push( + of(configAction(filterConfig)), + of(configChangedEventAction()), + of(isFilterDirtyAction(false)), + of( + updatePlaylistAction({ + clips: [...(playlist.clips ?? []), `${data.createEditorialAiPlaylist.id}`], + playlistId, + publisherId, + }), + ), + of(aiPlaylistIdAction(data.createEditorialAiPlaylist.id)), + of(playlistTypeAction(type)), + of(setAiPlaylistModeAction(true)), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/deleteAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/deleteAiPlaylist.js new file mode 100644 index 0000000..95612b6 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/deleteAiPlaylist.js @@ -0,0 +1,45 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { deleteAiPlaylistAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { configChangedEventAction } from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; + +const queryGQL = ` + mutation deleteEditorialAiPlaylist( + $editorialPlaylistId: Int!, + $aiPlaylistId: Int!, + ) { + deleteEditorialAiPlaylist( + editorialPlaylistId: $editorialPlaylistId, + aiPlaylistId: $aiPlaylistId, + ) + } +`; + +export default (action$) => + action$.pipe( + ofType(deleteAiPlaylistAction.type), + switchMap(({ payload: { playlistId, aiPlaylistId } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + editorialPlaylistId: playlistId, + aiPlaylistId, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(configChangedEventAction())); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/getAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/getAiPlaylist.js new file mode 100644 index 0000000..6603cfa --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/getAiPlaylist.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { getAiFiltersAction, getAiPlaylistAction, setAiPlaylistAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getEditorialAiPlaylist( + $editorialPlaylistId: Int!, + $aiPlaylistId: Int!, + ) { + getEditorialAiPlaylist( + editorialPlaylistId: $editorialPlaylistId, + aiPlaylistId: $aiPlaylistId, + ) { + id + type + length + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getAiPlaylistAction.type), + concatMap((action) => { + const { playlistId, aiPlaylistId } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + editorialPlaylistId: playlistId, + aiPlaylistId, + }, + }).pipe( + concatMap(({ data, errors }) => { + const actions = []; + + if (!errors.length && data.getEditorialAiPlaylist) { + actions.push(of(setAiPlaylistAction({ playlistId, aiPlaylist: data.getEditorialAiPlaylist }))); + actions.push(of(getAiFiltersAction({ playlistId, aiPlaylistId }))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/updateAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/updateAiPlaylist.js new file mode 100644 index 0000000..a3b5fe4 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/aiPlaylist/updateAiPlaylist.js @@ -0,0 +1,67 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { playlistsSelector } from '../../selectors'; +import { setAiPlaylistAction, updateAiPlaylistAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + mutation updateEditorialAiPlaylist( + $editorialPlaylistId: Int!, + $aiPlaylistId: Int!, + $length: Int! + ) { + updateEditorialAiPlaylist( + editorialPlaylistId: $editorialPlaylistId, + aiPlaylistId: $aiPlaylistId, + length: $length + ) { + id + type + length + } + } +`; + +const getResponse = ({ data: { updateEditorialAiPlaylist: aiPlaylist } }) => aiPlaylist; + +export default (action$, state$) => + action$.pipe( + ofType(updateAiPlaylistAction.type), + debounceTime(+process.env.APP_CLEAR_TIMEOUT), + filter(() => !!getToken()), + switchMap((action) => { + const { playlistId, aiPlaylistId, length } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + editorialPlaylistId: playlistId, + aiPlaylistId, + length, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const playlists = playlistsSelector(state$.value); + const playlist = playlists.find((playlist$) => playlist$.id === playlistId); + const aiPlaylist = Object.values(playlist.aiPlaylists).find( + (aiPlaylist$) => aiPlaylist$.id === aiPlaylistId, + ); + + const updatedAiPlaylist = { ...aiPlaylist, length: getResponse(response).length }; + + actions.push(of(setAiPlaylistAction({ playlistId, aiPlaylist: updatedAiPlaylist }))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getEmbedCode.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getEmbedCode.js new file mode 100644 index 0000000..9b465da --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getEmbedCode.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { embedCodePopupSelector } from '../selectors'; +import { getEmbedCodeAction, getEmbedCodeSuccessAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getPlaylistEmbedCode( + $playerId: Int!, + $playlistId: String, + $aspectRatio: String + ) { + getPlaylistEmbedCode( + playerId: $playerId, + playlistId: $playlistId, + aspectRatio: $aspectRatio + ) { + displayEmbedCode + embedCode + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getEmbedCodeAction.type), + switchMap(({ payload = {} }) => { + const embedCodePopup = embedCodePopupSelector(state$.value); + + const variables = { + playlistId: `${embedCodePopup.playlistId}`, + playerId: embedCodePopup.player?.value, + aspectRatio: embedCodePopup.aspectRatio?.label, + ...payload, + }; + + const stream$ = gqlRequest({ query: queryGQL, variables }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(getEmbedCodeSuccessAction(data.getPlaylistEmbedCode?.embedCode))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPlayers.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPlayers.js new file mode 100644 index 0000000..c6e26b5 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPlayers.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getPlayersAction, setPlayersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getPublisherIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + query getPublisherPlayers($publisherIds: [Int]) { + getPublisherPlayers(publisherIds: $publisherIds) { + results { + id + name + alias + publisherId + urlPrefix + displayEmbedCode + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPlayersAction.type), + filter((action) => action?.payload?.id || !!getPublisherIdsSelector(state$.value)?.length), + switchMap((action) => { + const variables = {}; + + if (action.payload?.id) { + variables.publisherIds = [action.payload.id]; + } + + const stream$ = gqlRequest({ query: queryGQL, variables }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const players = data.getPublisherPlayers?.results.sort((a, b) => a?.alias?.localeCompare(b?.alias)); + + if (!errors.length) { + actions.push(of(setPlayersAction(players))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPublishers.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPublishers.js new file mode 100644 index 0000000..b9dd86e --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/getPublishers.js @@ -0,0 +1,49 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPublishersAction, setPublishersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query PlaylistGetHubs( + $pageSize: Int, + $searchText: String, + ) { + playlistGetHubs( + pageSize: $pageSize, + searchText: $searchText + ) { + records { + id + name + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPublishersAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + pageSize: 100, + searchText: action.payload ?? '', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setPublishersAction(data.playlistGetHubs.records))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/index.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/index.js new file mode 100644 index 0000000..7812e56 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/index.js @@ -0,0 +1,42 @@ +import { combineEpics } from 'redux-observable'; + +import getAiFilters from './aiFilters/getAiFilters'; +import updateAiFilters from './aiFilters/updateAiFilters'; +import createAiPlaylist from './aiPlaylist/createAiPlaylist'; +import deleteAiPlaylist from './aiPlaylist/deleteAiPlaylist'; +import getAiPlaylist from './aiPlaylist/getAiPlaylist'; +import updateAiPlaylist from './aiPlaylist/updateAiPlaylist'; +import getEmbedCode from './getEmbedCode'; +import getPlayers from './getPlayers'; +import getPublishers from './getPublishers'; +import addVideoToPlaylist from './playlists/addVideoToPlaylist'; +import deletePlaylist from './playlists/deletePlaylist'; +import dragAndDrop from './playlists/dragAndDrop'; +import getPlaylistById from './playlists/getPlaylistById'; +import getPlaylists from './playlists/getPlaylists'; +import getPlaylistVideos from './playlists/getPlaylistVideos'; +import updatePlaylist from './playlists/updatePlaylist'; +import searchVideo from './searchVideo'; + +export default combineEpics( + getAiFilters, + updateAiFilters, + + createAiPlaylist, + deleteAiPlaylist, + getAiPlaylist, + updateAiPlaylist, + + addVideoToPlaylist, + deletePlaylist, + dragAndDrop, + getPlaylistById, + getPlaylistVideos, + getPlaylists, + updatePlaylist, + + getEmbedCode, + getPlayers, + getPublishers, + searchVideo, +); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/addVideoToPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/addVideoToPlaylist.js new file mode 100644 index 0000000..f775129 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/addVideoToPlaylist.js @@ -0,0 +1,41 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; + +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { QUERY_PARAM_TAB, TAB_WATCH } from '@/modules/editorial/constants/routing'; + +import { playlistIdSelector, playlistsSelector } from '../../selectors'; +import { updatePlaylistAction } from '../../slices'; +import { targetClipIdSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { targetClipIdEventAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { isMobileApp } from '@/modules/layout/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(targetClipIdEventAction.type), + filter(() => targetClipIdSelector(state$.value) && !(Router.query[QUERY_PARAM_TAB] === TAB_WATCH)), + mergeMap(() => { + const targetClipId = targetClipIdSelector(state$.value); + const playlists = playlistsSelector(state$.value); + const playlistId = playlistIdSelector(state$.value); + + const playlist = playlists.find((playlist$) => playlist$.id === playlistId); + + if (playlist?.clips?.includes(targetClipId)) { + return of(showNotificationAction({ type: TYPE_ERROR, message: 'The video is already in the playlist' })); + } + + const newClips = [...(playlist?.clips ?? []), targetClipId]; + + const actions = [of(updatePlaylistAction({ clips: newClips, publisherId: playlist?.publisherId }))]; + + if (isMobileApp) { + actions.push(of(showNotificationAction({ type: TYPE_SUCCESS, message: 'Video added to the playlist' }))); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/deletePlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/deletePlaylist.js new file mode 100644 index 0000000..6dd083e --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/deletePlaylist.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { MODE_LIST } from '../../../../common/constants'; + +import { modeSelector, playlistsSelector } from '../../selectors'; +import { deletePlaylistAction, setModeAction, setPlaylistsAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { deleteLiveEventPlaylistAction } from '@/modules/liveEvents/liveEvent/redux/slices'; + +const queryGQL = ` + mutation deletePlaylist( + $id: Int!, + $publisherId: Int! + ) { + deletePlaylist( + id: $id, + publisherId: $publisherId + ) { + playlistId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deletePlaylistAction.type, deleteLiveEventPlaylistAction.type), + filter((action) => action.payload.publisherId), + switchMap((action) => { + const { id, publisherId } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id, + publisherId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const mode = modeSelector(state$.value); + const playlists = playlistsSelector(state$.value); + + const newPlaylists = playlists.filter((playlist) => playlist.id !== data.deletePlaylist.playlistId); + + actions.push(of(setPlaylistsAction(newPlaylists))); + actions.push(of(setPlaylistsAction(newPlaylists))); + + if (mode !== MODE_LIST) { + actions.push(of(setModeAction(MODE_LIST))); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/dragAndDrop.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/dragAndDrop.js new file mode 100644 index 0000000..309b89a --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/dragAndDrop.js @@ -0,0 +1,74 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { DND_MAIN_LIST_OF_VIDEO_TYPE, DND_PLAYLIST_VIDEO_TYPE } from '@/modules/editorial/constants/dnd'; +import { QUERY_PARAM_TAB, TAB_PLAYLIST } from '@/modules/editorial/constants/routing'; + +import { playlistIdSelector, playlistsSelector } from '../../selectors'; +import { updatePlaylistAction } from '../../slices'; +import { dragAndDropEndSelector, videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { dragAndDropEndAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { parseDndId } from '@/modules/editorial/helpers/createDndId'; +import { reorderVideos } from '@/modules/editorial/RightSideBar/common/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(dragAndDropEndAction.type), + filter(() => Router.query[QUERY_PARAM_TAB] === TAB_PLAYLIST), + mergeMap(() => { + const videos = videosSelector(state$.value); + const { active, over } = dragAndDropEndSelector(state$.value); + + const activeMetadata = active?.id ? parseDndId(active.id) : {}; + const overMetadata = over?.id ? parseDndId(over.id) : {}; + + const playlists = playlistsSelector(state$.value); + const playlistId = playlistIdSelector(state$.value); + const playlist = playlists.find((playlist$) => playlist$.id === playlistId); + + const actions = []; + + // dnd videos inside playlist + // metadataObject = { type, distributionId, itemIndex } + if (activeMetadata.type === DND_PLAYLIST_VIDEO_TYPE && overMetadata.type === DND_PLAYLIST_VIDEO_TYPE) { + const items = reorderVideos(playlist.videos ?? [], activeMetadata.itemIndex, overMetadata.itemIndex); + + actions.push( + of( + updatePlaylistAction({ + clips: items?.map((item) => item?.distributionId) ?? [], + publisherId: playlist.publisherId, + }), + ), + ); + } + + // dnd video from search results to channel + // metadataObject = { type, videUid, distributionId, itemIndex } + if (activeMetadata.type === DND_MAIN_LIST_OF_VIDEO_TYPE && overMetadata.type === DND_PLAYLIST_VIDEO_TYPE) { + const targetClip = + videos.find(({ uid }) => uid === activeMetadata.videoUid) ?? + playlist.videos.find(({ distributionId }) => distributionId === activeMetadata.distributionId); + const canDropToPlaylist = ['ACTIVE', 'PROCESSING'].find((status) => targetClip.status === status); + + if (playlist?.clips?.includes(activeMetadata.distributionId)) { + return of(showNotificationAction({ type: TYPE_ERROR, message: 'The video is already in the playlist' })); + } + + if (!canDropToPlaylist) { + return of(showNotificationAction({ type: TYPE_ERROR, message: 'The video cannot be added' })); + } + + const newClips = [...(playlist?.clips ?? [])]; + newClips.splice(overMetadata.itemIndex, 0, targetClip.distributionId); + + actions.push(of(updatePlaylistAction({ clips: newClips, publisherId: playlist.publisherId }))); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistById.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistById.js new file mode 100644 index 0000000..f768ced --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistById.js @@ -0,0 +1,130 @@ +import dayjs from 'dayjs'; +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { MODE_LIST, MODE_PLAYLIST } from '../../../../common/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { QUERY_PARAM_PLAYLIST } from '@/modules/editorial/constants/routing'; + +import { playersSelector, playlistsSelector } from '../../selectors'; +import { + getPlaylistByIdAction, + setModeAction, + setPlaylistIdAction, + setPlaylistsAction, + updatePlaylistAction, +} from '../../slices'; +import { addQueriesAction, removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const TYPE = 'EDITORIAL'; + +const queryGQL = ` + query getPlaylistById( + $id: Int!, + ) { + getPlaylistById( + id: $id, + ){ + id + playerId + status + isPrefix + name + url + createdAt + updatedAt + createdBy + updatedBy + clips + shuffle + playerAlias + widgetName + pubName + publisherId + publisherName + playerAspectRatio + type + isFallback + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPlaylistByIdAction.type), + concatMap(({ payload }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: payload?.id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + if (data.getPlaylistById.type !== TYPE) { + return of( + showNotificationAction({ + type: TYPE_ERROR, + message: `Playlist ${data.getPlaylistById.id} doesn't exist`, + }), + ); + } + + const playlists = playlistsSelector(state$.value); + const players = playersSelector(state$.value); + + // this queries gets from wordpress mode + const { title } = Router.query; + + const player = players.find((item) => item?.id === data.getPlaylistById.playerId); + + // convert from dayjs to milliseconds + const createdAt = dayjs(data.getPlaylistById.createdDate).valueOf(); + const updatedAt = data.getPlaylistById.updatedDate + ? dayjs(data.getPlaylistById.updatedDate).valueOf() + : data.getPlaylistById.updatedDate; + + const newPlaylist = { + ...data.getPlaylistById, + createdAt, + updatedAt, + isStatusUpdateDisabled: !data.getPlaylistById.status && !player, + displayEmbedCode: player?.displayEmbedCode, + }; + + const newPlaylists = [...playlists]; + const playlistIndex = newPlaylists.findIndex((playlist$) => playlist$.id === newPlaylist.id); + if (playlistIndex !== -1) { + newPlaylists.splice(playlistIndex, 1, { ...newPlaylists[playlistIndex], ...newPlaylist }); + } else { + newPlaylists.unshift(newPlaylist); + } + + actions.push( + of(setPlaylistIdAction(data.getPlaylistById.id)), + of(addQueriesAction({ [QUERY_PARAM_PLAYLIST]: data.getPlaylistById.id })), + of(setModeAction(MODE_PLAYLIST)), + of(setPlaylistsAction(newPlaylists)), + ); + + // update name for wordpress mode + if (payload?.isInitial && title && title !== data.getPlaylistById.name) { + actions.push(of(updatePlaylistAction({ name: title, publisherId: data.getPlaylistById.publisherId }))); + } + } else { + actions.push(of(removeQueriesAction(QUERY_PARAM_PLAYLIST)), of(setModeAction(MODE_LIST))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistVideos.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistVideos.js new file mode 100644 index 0000000..79ba2fa --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylistVideos.js @@ -0,0 +1,153 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { + TITLE_VIDEO_IS_SPONSORED, + TOOLTIP_VIDEO_IS_SPONSORED, +} from '@/modules/editorial/RightSideBar/common/constants'; + +import { getPlaylistVideosAction, setPlaylistVideosAction } from '../../slices'; +import { getDuration } from '@/modules/@common/helpers/time'; +import { gqlRequest } from '@/modules/@common/request'; +import { isVideoUnsafe } from '@/modules/editorial/editorialVideoInfo/helpers'; +import { isVideoAvailable } from '@/modules/editorial/RightSideBar/common/helpers'; +import { playlistIdSelector } from '@/modules/editorial/RightSideBar/TabPlaylist/redux/selectors'; +import { updateLiveEventFormAction } from '@/modules/liveEvents/liveEvent/redux/slices'; + +const queryGQL = ` + query clipSearch($clipIds: [String], $size: Int) { + clipSearch(clipIds: $clipIds, size: $size){ + totalCount + videos { + uid + distributionId + name + thumbnailUrl + thumbnailFiles { + width + height + file + } + videoLength + status + contentOwner + keywords { + category + value + } + approval { + status + } + aspectRatio + access { + level + } + targetingStatus + sponsored { + sponsored + } + } + } + } +`; + +const defineThumbnail = (thumbnailUrl, thumbnailFiles) => { + const thumbnail360 = thumbnailFiles?.find((thumbnail) => thumbnail.height === 360)?.file; + + return thumbnail360 ?? thumbnailUrl; +}; + +const getResponse = ({ data: { clipSearch } }) => { + const videos = clipSearch?.videos; + return { + videos: videos?.map((video) => ({ + ...video, + startTime: 0, + endTime: video.videoLength || 0, + videoStatus: video.status, + distributionStatus: video.status, + keywords: video.keywords, + })), + }; +}; + +export default (action$, state$) => + action$.pipe( + ofType(getPlaylistVideosAction.type), + filter((action) => action.payload?.clips?.length && action.payload?.playlistId), + switchMap(({ payload: { clips, eventImages, playlistId } }) => { + const playlistId$ = playlistIdSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + clipIds: clips.map((clip) => clip.toString()), + size: clips?.length ?? 10, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const videoList = getResponse(response); + + const videosListSorted = clips + .map((clip) => videoList.videos.find((listItem) => clip === listItem.distributionId)) + .filter((video) => video); + + if (eventImages) { + return concat(of(updateLiveEventFormAction({ [eventImages]: videosListSorted[0]?.thumbnailFiles }))); + } + + const videos = clips.map((id) => { + const video = videosListSorted.find(({ distributionId }) => distributionId === id) || {}; + const isAvailable = isVideoAvailable(video); + + let videoAlertMessage = ''; + let videoAlertMessageTooltip = ''; + + if (!isAvailable) { + videoAlertMessage = 'Video was archived'; + } + + if (video.videoStatus === 'PROCESSING') { + videoAlertMessage = 'Video analysis still in progress'; + } + + if (video?.access?.level === 'PRIVATE') { + videoAlertMessage = 'Private video, access controlled by its owner'; + } + + if (video?.sponsored?.sponsored) { + videoAlertMessage = TITLE_VIDEO_IS_SPONSORED; + videoAlertMessageTooltip = TOOLTIP_VIDEO_IS_SPONSORED; + } + + const isDeleted = !videoList.videos.find(({ distributionId }) => distributionId === id); + + return { + ...video, + distributionId: Number.isNaN(parseInt(id, 10)) ? id : parseInt(id, 10), + isAvailable, + isDeleted, + isUnsafe: isVideoUnsafe(video), + isHasTargeting: video.targetingStatus === 'ON', + videoName: video.name ?? '', + videoAlertMessage, + videoAlertMessageTooltip, + videoThumbnailUrl: defineThumbnail(video.thumbnailUrl, video.thumbnailFiles) || '', + videoTime: video?.endTime ? getDuration(video.endTime - video.startTime) : '00:00', + }; + }); + + actions.push(of(setPlaylistVideosAction({ playlistId: playlistId ?? playlistId$, videos }))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylists.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylists.js new file mode 100644 index 0000000..a6dbb62 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/getPlaylists.js @@ -0,0 +1,177 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { + fallbackOnlySelector, + pageSelector, + pageSizeSelector, + playersSelector, + playlistIdSelector, + playlistsSelector, + publisherSelector, + searchSelector, + sortBySelector, + sortOrderSelector, + statusSelector, +} from '../../selectors'; +import { + getPlaylistsAction, + setIsLoadingAction, + setPageAction, + setPlaylistsAction, + setRecordsTotalAction, +} from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { getPublisherIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + query getPlaylists( + $publisherIds: [Int], + $type: String, + $page: Int, + $pageSize: Int, + $searchText: String, + $status: String, + $fallbackOnly: Boolean, + $sortBy: String, + $sortOrder: String + ) { + getPlaylists( + publisherIds: $publisherIds, + type: $type, + page: $page, + pageSize: $pageSize, + searchText: $searchText, + status: $status, + fallbackOnly: $fallbackOnly, + sortBy: $sortBy, + sortOrder: $sortOrder) { + records { + id + playerId + status + isPrefix + name + url + createdAt + updatedAt + createdBy + updatedBy + clips + shuffle + playerAlias + widgetName + pubName + publisherId + publisherName + playerAspectRatio + isFallback + + includeSponsoredContent, + frequency, + supplyTagId, + supplyTagName, + } + recordsTotal + page + pageSize + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPlaylistsAction.type), + debounceTime(500), + filter(() => !!getToken()), + filter(() => !!getPublisherIdsSelector(state$.value)?.length || publisherSelector(state$.value)), + switchMap(({ payload: { isNext = false } = {} }) => { + const pageSize = pageSizeSelector(state$.value); + const players = playersSelector(state$.value); + const prevPage = pageSelector(state$.value); + const publisher = publisherSelector(state$.value); + const search = searchSelector(state$.value); + const status = statusSelector(state$.value); + const fallbackOnly = fallbackOnlySelector(state$.value); + const sortBy = sortBySelector(state$.value); + const sortOrder = sortOrderSelector(state$.value); + + const variables = { + type: 'EDITORIAL', + searchText: search, + sortBy: sortBy.toLowerCase(), + sortOrder: sortOrder.toLowerCase(), + page: isNext ? prevPage + 1 : 1, + pageSize, + isNew: true, + }; + + if (publisher?.id) { + variables.publisherIds = [publisher.id]; + } + + if (status) { + variables.status = status.value; + } + + if (fallbackOnly) { + variables.fallbackOnly = fallbackOnly; + } + + const stream$ = gqlRequest({ query: queryGQL, variables }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { records, recordsTotal } = data.getPlaylists; + + const newPlaylists = records.map((playlist) => { + const { status: playlistStatus, playerId, createdAt: createdDate, updatedAt: updatedDate } = playlist; + + // convert from dayjs to milliseconds + const createdAt = dayjs(createdDate).valueOf(); + const updatedAt = updatedDate ? dayjs(updatedDate).valueOf() : updatedDate; + + const player = players.find((item) => item?.id === playerId); + + return { + ...playlist, + createdAt, + updatedAt, + isStatusUpdateDisabled: !playlistStatus && !player, + displayEmbedCode: player?.displayEmbedCode, + }; + }); + + const playlistId = playlistIdSelector(state$.value); + const oldPlaylists = playlistsSelector(state$.value); + + if (playlistId) { + // If you open a playlist, then go to the playlist list and quickly open another playlist, + // the store (state.playlists) gets updated and overwrites + // the video values (state.playlists: [{ ...playlistInfo, videos: [] }]) + const playlistOld = oldPlaylists.find((playlist$) => playlist$.id === playlistId); + const playlistNewIndex = newPlaylists.findIndex((playlist$) => playlist$.id === playlistId); + if (playlistOld && playlistNewIndex !== -1) { + newPlaylists.splice(playlistNewIndex, 1, { ...newPlaylists[playlistNewIndex], ...playlistOld }); + } + } + + actions.push( + of(setIsLoadingAction(false)), + of(setPlaylistsAction(isNext ? [...oldPlaylists, ...newPlaylists] : newPlaylists)), + of(setPageAction(variables.page)), + of(setRecordsTotalAction(recordsTotal)), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/updatePlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/updatePlaylist.js new file mode 100644 index 0000000..fc40b26 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/playlists/updatePlaylist.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, filter } from 'rxjs/operators'; + +import { playlistIdSelector, playlistsSelector } from '../../selectors'; +import { getPlaylistByIdAction, setPlaylistsAction, updatePlaylistAction } from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation updatePlaylist( + $playlistId: Int!, + $publisherId: Int!, + $name: String, + $clips: [String], + $playerId: Int, + $isPrefix: Boolean, + $status: Boolean + $shuffle: Boolean + ) { + updatePlaylist( + playlistId: $playlistId, + publisherId: $publisherId, + name: $name, + clips: $clips, + playerId: $playerId, + isPrefix: $isPrefix, + status: $status + shuffle: $shuffle + ){ + playlistId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updatePlaylistAction.type), + filter(({ payload }) => payload.publisherId), + concatMap(({ payload }) => { + const plId = playlistIdSelector(state$.value); + + const playlistId = payload?.playlistId || plId; + + const data = { ...payload }; + + if (payload?.clips) { + data.clips = payload.clips.filter((id) => id).map((id) => id.toString()); + } + + const stream$ = gqlRequest({ query: queryGQL, variables: { ...data, playlistId } }).pipe( + concatMap((_, errors) => { + const actions = []; + + if (!errors.length) { + if (payload?.shouldRefreshCurrentPlaylistData) { + actions.push(of(getPlaylistByIdAction({ id: playlistId }))); + } + } + + const playlists = playlistsSelector(state$.value); + const updatedPlaylists = playlists.map((playlist) => { + if (playlist.id === playlistId) { + return { + ...playlist, + ...payload, + videos: payload?.clips + ? (playlist.videos ?? []).filter((video$) => data.clips?.includes(video$.distributionId.toString())) + : playlist.videos, + }; + } + + return playlist; + }); + + actions.push(of(setPlaylistsAction(updatedPlaylists))); + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/searchVideo.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/searchVideo.js new file mode 100644 index 0000000..dadb092 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/epics/searchVideo.js @@ -0,0 +1,12 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { searchVideoAction } from '../slices'; +import { queryAction, searchEventAction } from '@/modules/editorial/editorialSearch/reduxSearch/slices'; + +export default (action$) => + action$.pipe( + ofType(searchVideoAction.type), + concatMap(({ payload }) => concat(of(queryAction(payload)), of(searchEventAction()))), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/selectors/index.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/selectors/index.js new file mode 100644 index 0000000..b8fc942 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/selectors/index.js @@ -0,0 +1,23 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const playersSelector = (state) => state[nameSpace].players; +export const publishersSelector = (state) => state[nameSpace].publishers; +export const publisherSelector = (state) => state[nameSpace].publisher; +export const searchSelector = (state) => state[nameSpace].search; +export const pageSelector = (state) => state[nameSpace].page; +export const pageSizeSelector = (state) => state[nameSpace].pageSize; +export const recordsTotalSelector = (state) => state[nameSpace].recordsTotal; +export const sortBySelector = (state) => state[nameSpace].sortBy; +export const sortOrderSelector = (state) => state[nameSpace].sortOrder; +export const statusSelector = (state) => state[nameSpace].status; +export const fallbackOnlySelector = (state) => state[nameSpace].fallbackOnly; +export const modeSelector = (state) => state[nameSpace].mode; +export const aiPlaylistModeSelector = (state) => state[nameSpace].aiPlaylistMode; + +export const playlistsSelector = (state) => state[nameSpace].playlists; +export const playlistIdSelector = (state) => state[nameSpace].playlistId; + +export const embedCodePopupSelector = (state) => state[nameSpace].embedCodePopup; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/slices/index.js b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/slices/index.js new file mode 100644 index 0000000..ec23e60 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabPlaylist/redux/slices/index.js @@ -0,0 +1,244 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +const initialState = { + // common + isLoading: false, + players: [], + publishers: [], + publisher: null, + fallbackOnly: false, + search: '', + page: 1, + pageSize: 10, + recordsTotal: 0, + sortBy: 'Date', + sortOrder: SORT_DESC, + status: null, + mode: null, + aiPlaylistMode: false, + + playlists: [], + // example of state.playlists: + // playlists: [{ + // // ...playlistInfo + // videos: [], + // aiPlaylists: { + // // { [$TYPE]: { id, length, type, filters: { id, videoFilterValues: [] } } } + // RECENT: null, + // TRENDING: null, + // }, + // }], + playlistId: null, + + embedCodePopup: { + playlistId: null, + aspectRatio: null, + player: null, + embedCode: '', + }, +}; + +export const slice = createSlice({ + name: '@@EDITORIAL/SIDEBAR/PLAYLISTS', + initialState, + + reducers: { + // common + setIsLoadingAction: (state, action) => { + state.isLoading = action.payload; + }, + getPlayersAction: (state) => state, + setPlayersAction: (state, action) => { + state.players = action.payload; + }, + getPublishersAction: (state) => state, + setPublishersAction: (state, action) => { + state.publishers = action.payload; + }, + setPublisherAction: (state, action) => { + state.publisher = action.payload; + }, + setFallbackOnlyAction: (state, action) => { + state.fallbackOnly = action.payload; + }, + searchPlaylistAction: (state, action) => { + state.search = action.payload; + }, + setPageAction: (state, action) => { + state.page = action.payload; + }, + setRecordsTotalAction: (state, action) => { + state.recordsTotal = action.payload; + }, + setSortByAction: (state, action) => { + state.sortBy = action.payload; + }, + setSortOrderAction: (state, action) => { + state.sortOrder = action.payload; + }, + setStatusAction: (state, action) => { + state.status = action.payload; + }, + setModeAction: (state, action) => { + state.mode = action.payload; + }, + setAiPlaylistModeAction: (state, action) => { + state.aiPlaylistMode = action.payload; + }, + + // playlists + getPlaylistsAction: (state) => state, + setPlaylistsAction: (state, action) => { + state.playlists = action.payload; + }, + setPlaylistIdAction: (state, action) => { + state.playlistId = action.payload; + }, + getPlaylistByIdAction: (state) => state, + updatePlaylistAction: (state, action) => { + const playlistId = action.payload?.playlistId || state.playlistId; + const playlist = state.playlists.find((playlist$) => playlist$.id === playlistId); + + if (playlist) { + Object.keys(action.payload).forEach((key) => { + playlist[key] = action.payload[key]; + }); + } + }, + deletePlaylistAction: (state) => state, + + // videos + getPlaylistVideosAction: (state) => state, + setPlaylistVideosAction: (state, action) => { + const { playlistId, videos } = action.payload; + const playlist = state.playlists.find((playlist$) => playlist$.id === playlistId); + + if (playlist) { + playlist.videos = videos; + } + }, + setPlaylistVideosAttributeAction: (state, action) => { + const { uid, attribute } = action.payload; + const playlist = state.playlists.find((playlist$) => playlist$.id === state.playlistId); + + if (playlist) { + playlist.videos = playlist.videos.map((video) => (video.uid === uid ? { ...video, ...attribute } : video)); + } + }, + searchVideoAction: (state) => state, + + // aiPlaylist + getAiPlaylistAction: (state) => state, + setAiPlaylistAction: (state, action) => { + const { playlistId, aiPlaylist } = action.payload; + + const playlist = state.playlists.find((playlist$) => playlist$.id === playlistId); + if (playlist) { + if (!playlist.aiPlaylists) { + playlist.aiPlaylists = {}; + } + + playlist.aiPlaylists[aiPlaylist.type] = aiPlaylist; + } + }, + createAiPlaylistAction: (state) => state, + updateAiPlaylistAction: (state) => state, + deleteAiPlaylistAction: (state, action) => { + const { playlistId, aiPlaylistId } = action.payload; + + const playlist = state.playlists.find((playlist$) => playlist$.id === playlistId); + + if (playlist) { + const aiPlaylist = Object.values(playlist.aiPlaylists).find((aiPlaylist$) => aiPlaylist$.id === aiPlaylistId); + if (aiPlaylist) { + delete playlist.aiPlaylists[aiPlaylist.type]; + } + + const aiPlaylistIdString = aiPlaylistId.toString(); + playlist.videos = playlist.videos.filter((video) => video.distributionId.toString() !== aiPlaylistIdString); + playlist.clips = playlist.clips.filter((clip) => clip.toString() !== aiPlaylistIdString); + } + }, + + // aiFilters + getAiFiltersAction: (state) => state, + setAiFiltersAction: (state, action) => { + const { playlistId, aiPlaylistId, aiFilters } = action.payload; + + const playlist = state.playlists.find((playlist$) => playlist$.id === playlistId); + + if (playlist) { + const aiPlaylist = Object.values(playlist.aiPlaylists).find((aiPlaylist$) => aiPlaylist$.id === aiPlaylistId); + + if (aiPlaylist) { + if (!aiFilters || !aiFilters.length) { + delete aiPlaylist.aiFilters; + } else { + aiPlaylist.aiFilters = aiFilters; + } + } + } + }, + updateAiFiltersAction: (state) => state, + + // embed code + getEmbedCodeAction: (state) => state, + getEmbedCodeSuccessAction: (state, action) => { + state.embedCodePopup.embedCode = action.payload; + }, + setEmbedCodeInfoAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.embedCodePopup[key] = action.payload[key]; + }); + }, + }, +}); + +export const { + // common + setIsLoadingAction, + getPlayersAction, + setPlayersAction, + getPublishersAction, + setPublishersAction, + setPublisherAction, + setFallbackOnlyAction, + searchPlaylistAction, + setPageAction, + setRecordsTotalAction, + setSortByAction, + setSortOrderAction, + setStatusAction, + setModeAction, + setAiPlaylistModeAction, + // playlists + getPlaylistsAction, + setPlaylistsAction, + setPlaylistIdAction, + getPlaylistByIdAction, + updatePlaylistAction, + deletePlaylistAction, + // videos + getPlaylistVideosAction, + setPlaylistVideosAction, + searchVideoAction, + setPlaylistVideosAttributeAction, + // aiPlaylist + getAiPlaylistAction, + setAiPlaylistAction, + createAiPlaylistAction, + updateAiPlaylistAction, + deleteAiPlaylistAction, + // aiFilters + getAiFiltersAction, + setAiFiltersAction, + updateAiFiltersAction, + // embed code + getEmbedCodeAction, + getEmbedCodeSuccessAction, + setEmbedCodeInfoAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/constants/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/constants/index.js new file mode 100644 index 0000000..b97b444 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/constants/index.js @@ -0,0 +1,49 @@ +export const TAB_GENERAL = 'general'; +export const TAB_LOOK_AND_FEEL = 'lookAndFeel'; +export const TAB_PLAYER_PAGE = 'playerPage'; +export const TAB_PROMOTIONS = 'promotions'; + +export const DESIGN_STANDARD = 'STANDARD'; +export const DESIGN_BACKGROUND = 'BACKGROUND'; +export const DESIGN_INLINE = 'INLINE'; +export const DESIGN_VERTICAL = 'VERTICAL'; +export const DESIGN_INLINE_VERTICAL = 'INLINE_VERTICAL'; + +export const CAROUSEL_DESIGN_LIST = [ + { + name: 'Standard', + value: DESIGN_STANDARD, + }, + { + name: 'With background', + value: DESIGN_BACKGROUND, + }, + { + name: 'Inline player', + value: DESIGN_INLINE, + }, + { + name: 'Vertical', + value: DESIGN_VERTICAL, + }, + { + name: 'Inline Vertical', + value: DESIGN_INLINE_VERTICAL, + }, +]; + +export const SIZE_MEDIUM = 'STANDARD'; +export const SIZE_LARGE = 'LARGE'; + +export const CAROUSEL_SIZE_LIST = [ + { + name: 'Medium', + value: SIZE_MEDIUM, + }, + { + name: 'Large', + value: SIZE_LARGE, + }, +]; + +export const FORM_REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/helpers/validationScheme.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/helpers/validationScheme.js new file mode 100644 index 0000000..f26071a --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/helpers/validationScheme.js @@ -0,0 +1,95 @@ +import { uniqueChannelsMessages } from '../../constants'; +import { TAB_GENERAL, TAB_LOOK_AND_FEEL, TAB_PROMOTIONS } from '../constants'; + +const urlRegExp = /^(https?):\/\/[^\s/$.?#].[^\s]*\.[a-z]{2,}(\/[^\s]*)?$/i; + +export const validationScheme = [ + { + fieldName: 'title', + tabId: TAB_GENERAL, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Field cannot be less then 2 symbols'; + } + + return ''; + }, + }, + { + fieldName: 'MRSSAliasDomain', + tabId: TAB_GENERAL, + validation: (title, fields) => { + const value = title?.trim(); + + if (fields.MRSSForExport && !value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'MRSSLinkUrl', + tabId: TAB_GENERAL, + validation: (title, fields) => { + const value = title?.trim(); + + if (fields.MRSSForExport && value && !urlRegExp.test(value)) { + return 'Field must be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'design', + tabId: TAB_LOOK_AND_FEEL, + validation: (value, allProps, unique) => { + if (unique[value]) { + return uniqueChannelsMessages[value]; + } + + return ''; + }, + }, + { + fieldName: 'frequency', + tabId: TAB_PROMOTIONS, + validation: (value, allProps) => { + if (!allProps.includeSponsoredContent) { + return ''; + } + + if (!value) { + return 'empty'; + } + + if (value < 1 || value > 10) { + return 'between 1 and 10'; + } + + return ''; + }, + }, + { + fieldName: 'supplyTag', + tabId: TAB_PROMOTIONS, + validation: (value, allProps) => { + if (!allProps.includeSponsoredContent) { + return ''; + } + + if (!value || !value.id) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/createChannel.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/createChannel.js new file mode 100644 index 0000000..cf3000a --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/createChannel.js @@ -0,0 +1,157 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { QUERY_PARAM_CHANNEL, QUERY_PARAM_WATCH_ID } from '@/modules/editorial/constants/routing'; + +import { getWatchesAction } from '../../../Watches/redux/slices'; +import * as channelSelectors from '../selectors'; +import { clearAction, createChannelAction, updateStoreByPropAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation CreateChannel( + $watchId: Int!, + $title: String, + $description: String, + $bg: String, + $logo: String, + $size: String, + $design: String, + $titleColor: String, + $descColor: String, + $vidTitleColor: String, + $vidInfoColor: String, + $cTitleColor: String, + $cVidTitleColor: String, + $cVidInfoColor: String, + $cTitleSize: String, + $cVidTitleSize: String, + $cVidInfoSize: String, + $bgLogo: String, + $MRSSForExport: Boolean, + $MRSSAliasDomain: String, + $MRSSLinkUrl: String, + $includeSponsoredContent: Boolean, + $frequency: Int, + $supplyTagId: String, + $supplyTagName: String, + ) { + createChannel( + watchId: $watchId, + title: $title, + description: $description, + bg: $bg, + logo: $logo, + size: $size, + design: $design, + titleColor: $titleColor, + descColor: $descColor, + vidTitleColor: $vidTitleColor, + vidInfoColor: $vidInfoColor, + cTitleColor: $cTitleColor, + cVidTitleColor: $cVidTitleColor, + cVidInfoColor: $cVidInfoColor, + cTitleSize: $cTitleSize, + cVidTitleSize: $cVidTitleSize, + cVidInfoSize: $cVidInfoSize, + bgLogo: $bgLogo, + MRSSForExport: $MRSSForExport, + MRSSAliasDomain: $MRSSAliasDomain, + MRSSLinkUrl: $MRSSLinkUrl, + includeSponsoredContent: $includeSponsoredContent, + frequency: $frequency, + supplyTagId: $supplyTagId, + supplyTagName: $supplyTagName, + ) { + id + watchId + order + videoFilterId + playlistId + title + name + description + bg + logo + size + design + titleColor + descColor + vidTitleColor + vidInfoColor + cTitleColor + cVidTitleColor + cVidInfoColor + cTitleSize + cVidTitleSize + cVidInfoSize + bgLogo + updatedBy + updatedAt + createdBy + createdAt + MRSSForExport + MRSSAliasDomain + MRSSLinkUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createChannelAction.type), + switchMap(({ payload: { watchId } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + logo: channelSelectors.logoSelector(state$.value), + title: channelSelectors.titleSelector(state$.value), + description: channelSelectors.descriptionSelector(state$.value), + size: channelSelectors.sizeSelector(state$.value), + design: channelSelectors.designSelector(state$.value), + bg: channelSelectors.bgSelector(state$.value), + titleColor: channelSelectors.titleColorSelector(state$.value), + descColor: channelSelectors.descColorSelector(state$.value), + vidTitleColor: channelSelectors.vidTitleColorSelector(state$.value), + vidInfoColor: channelSelectors.vidInfoColorSelector(state$.value), + cTitleColor: channelSelectors.cTitleColorSelector(state$.value), + cVidTitleColor: channelSelectors.cVidTitleColorSelector(state$.value), + cVidInfoColor: channelSelectors.cVidInfoColorSelector(state$.value), + cTitleSize: channelSelectors.cTitleSizeSelector(state$.value), + cVidTitleSize: channelSelectors.cVidTitleSizeSelector(state$.value), + cVidInfoSize: channelSelectors.cVidInfoSizeSelector(state$.value), + bgLogo: channelSelectors.bgLogoSelector(state$.value), + MRSSForExport: channelSelectors.mrssForExportSelector(state$.value), + MRSSAliasDomain: channelSelectors.mrssAliasDomainSelector(state$.value), + MRSSLinkUrl: channelSelectors.mrssLinkUrlSelector(state$.value), + includeSponsoredContent: channelSelectors.includeSponsoredContentSelector(state$.value), + frequency: parseInt(channelSelectors.frequencySelector(state$.value), 10), + supplyTagId: channelSelectors.supplyTagSelector(state$.value)?.id, + supplyTagName: channelSelectors.supplyTagSelector(state$.value)?.name, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(clearAction()), + of(getWatchesAction()), + of(removeQueriesAction([QUERY_PARAM_WATCH_ID, QUERY_PARAM_CHANNEL])), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of(updateStoreByPropAction({ isLoading: true })), + stream$, + of(updateStoreByPropAction({ isLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getChannelById.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getChannelById.js new file mode 100644 index 0000000..f2ac893 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getChannelById.js @@ -0,0 +1,103 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import { QUERY_PARAM_CHANNEL, QUERY_PARAM_WATCH_ID } from '@/modules/editorial/constants/routing'; + +import { getChannelByIdAction, updateStoreByPropAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query ChannelQuery($watchId: Int!, $id: Int!) { + channel(watchId: $watchId, id: $id){ + id + watchId + order + videoFilterId + playlistId + title + name + description + bg + logo + size + design + titleColor + descColor + vidTitleColor + vidInfoColor + cTitleColor + cVidTitleColor + cVidInfoColor + cTitleSize + cVidTitleSize + cVidInfoSize + bgLogo + updatedBy + updatedAt + createdBy + createdAt + MRSSForExport + MRSSAliasDomain + MRSSLinkUrl + includeSponsoredContent, + frequency, + supplyTagId, + supplyTagName, + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getChannelByIdAction.type), + mergeMap(({ payload }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId: payload.watchId, + id: payload.channelId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (errors.length) { + actions.push(of(removeQueriesAction([QUERY_PARAM_WATCH_ID, QUERY_PARAM_CHANNEL]))); + } else { + const valuesWithoutNull = Object.keys(data.channel).reduce((acc, propName) => { + if (data.channel[propName] !== null || propName === 'logo') { + return { + ...acc, + [propName]: data.channel[propName], + }; + } + + return acc; + }, {}); + + if ('supplyTagId' in valuesWithoutNull && 'supplyTagName' in valuesWithoutNull) { + valuesWithoutNull.supplyTag = { + id: valuesWithoutNull.supplyTagId, + name: valuesWithoutNull.supplyTagName, + }; + + delete valuesWithoutNull.supplyTagId; + delete valuesWithoutNull.supplyTagName; + } + + actions.push(of(updateStoreByPropAction(valuesWithoutNull))); + } + + return concat(...actions); + }), + ); + + return concat( + of(updateStoreByPropAction({ isLoading: true })), + stream$, + of(updateStoreByPropAction({ isLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getSupplyTagOptions.ts b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getSupplyTagOptions.ts new file mode 100644 index 0000000..e01b533 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/getSupplyTagOptions.ts @@ -0,0 +1,96 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { watchHubSelector } from '../selectors'; +import { getSupplyTagOptionsAction, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type RequestPayloadType = { + searchText?: string; + pageSize: number; + siteIds?: number[]; +}; + +type ResponseType = { + data: { + id: string; + name: string; + }[]; +}; + +type ActionPayload = { + searchText: string; +}; + +const queryName = 'watchGetSupplyTags' as const; + +const getResponse = (data: ResponseType) => data.data; + +const query = ` + query WatchGetSupplyTags( + $pageSize: Int, + $searchText: String, + $siteIds: [Int], + ) { + ${queryName}( + pageSize: $pageSize, + searchText: $searchText, + siteIds: $siteIds, + ) { + data { + id + name + } + } + } +`; + +const getSupplyTagOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter( + (action): action is ReturnType => + action.type === getSupplyTagOptionsAction.type, + ), + debounce((action) => { + if (!action.payload) { + return timer(0); + } + const { searchText = '' } = action.payload; + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actionPayload = action.payload! as ActionPayload; + const watchHub = watchHubSelector(state$.value); + const payload: RequestPayloadType = { + searchText: actionPayload.searchText || '', + pageSize: 30, + }; + + if (watchHub?.id) { + payload.siteIds = [watchHub.id]; + } + + return gqlRequest({ + query, + variables: payload, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + updateStoreByPropAction({ + supplyTagOptions: getResponse(response.data[queryName]), + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getSupplyTagOptionsEpic; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/index.js new file mode 100644 index 0000000..eb1e836 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/index.js @@ -0,0 +1,15 @@ +import { combineEpics } from 'redux-observable'; + +import createChannel from './createChannel'; +import getChannelById from './getChannelById'; +import supplyTagOptionsSelector from './getSupplyTagOptions'; +import updateChannel from './updateChannel'; +import updateDefaultPropFromWatch from './updateDefaultPropFromWatch'; + +export default combineEpics( + getChannelById, + createChannel, + updateChannel, + updateDefaultPropFromWatch, + supplyTagOptionsSelector, +); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateChannel.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateChannel.js new file mode 100644 index 0000000..f2eece9 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateChannel.js @@ -0,0 +1,160 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { QUERY_PARAM_CHANNEL, QUERY_PARAM_WATCH_ID } from '@/modules/editorial/constants/routing'; + +import { getWatchesAction } from '../../../Watches/redux/slices'; +import * as channelSelectors from '../selectors'; +import { clearAction, updateChannelAction, updateStoreByPropAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation UpdateChannel( + $id: Int!, + $watchId: Int!, + $title: String, + $description: String, + $bg: String, + $logo: String, + $size: String, + $design: String, + $titleColor: String, + $descColor: String, + $vidTitleColor: String, + $vidInfoColor: String, + $cTitleColor: String, + $cVidTitleColor: String, + $cVidInfoColor: String, + $cTitleSize: String, + $cVidTitleSize: String, + $cVidInfoSize: String, + $bgLogo: String, + $MRSSForExport: Boolean, + $MRSSAliasDomain: String, + $MRSSLinkUrl: String, + $includeSponsoredContent: Boolean, + $frequency: Int, + $supplyTagId: String, + $supplyTagName: String, + ) { + updateChannel( + id: $id, + watchId: $watchId, + title: $title, + description: $description, + bg: $bg, + logo: $logo, + size: $size, + design: $design, + titleColor: $titleColor, + descColor: $descColor, + vidTitleColor: $vidTitleColor, + vidInfoColor: $vidInfoColor, + cTitleColor: $cTitleColor, + cVidTitleColor: $cVidTitleColor, + cVidInfoColor: $cVidInfoColor, + cTitleSize: $cTitleSize, + cVidTitleSize: $cVidTitleSize, + cVidInfoSize: $cVidInfoSize, + bgLogo: $bgLogo, + MRSSForExport: $MRSSForExport, + MRSSAliasDomain: $MRSSAliasDomain, + MRSSLinkUrl: $MRSSLinkUrl, + includeSponsoredContent: $includeSponsoredContent, + frequency: $frequency, + supplyTagId: $supplyTagId, + supplyTagName: $supplyTagName, + ) { + id + watchId + order + videoFilterId + playlistId + title + name + description + bg + logo + size + design + titleColor + descColor + vidTitleColor + vidInfoColor + cTitleColor + cVidTitleColor + cVidInfoColor + cTitleSize + cVidTitleSize + cVidInfoSize + bgLogo + updatedBy + updatedAt + createdBy + createdAt + MRSSForExport + MRSSAliasDomain + MRSSLinkUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateChannelAction.type), + switchMap(({ payload: { watchId, id } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + id, + logo: channelSelectors.logoSelector(state$.value), + title: channelSelectors.titleSelector(state$.value), + description: channelSelectors.descriptionSelector(state$.value), + size: channelSelectors.sizeSelector(state$.value), + design: channelSelectors.designSelector(state$.value), + bg: channelSelectors.bgSelector(state$.value), + titleColor: channelSelectors.titleColorSelector(state$.value), + descColor: channelSelectors.descColorSelector(state$.value), + vidTitleColor: channelSelectors.vidTitleColorSelector(state$.value), + vidInfoColor: channelSelectors.vidInfoColorSelector(state$.value), + cTitleColor: channelSelectors.cTitleColorSelector(state$.value), + cVidTitleColor: channelSelectors.cVidTitleColorSelector(state$.value), + cVidInfoColor: channelSelectors.cVidInfoColorSelector(state$.value), + cTitleSize: channelSelectors.cTitleSizeSelector(state$.value), + cVidTitleSize: channelSelectors.cVidTitleSizeSelector(state$.value), + cVidInfoSize: channelSelectors.cVidInfoSizeSelector(state$.value), + bgLogo: channelSelectors.bgLogoSelector(state$.value), + MRSSForExport: channelSelectors.mrssForExportSelector(state$.value), + MRSSAliasDomain: channelSelectors.mrssAliasDomainSelector(state$.value), + MRSSLinkUrl: channelSelectors.mrssLinkUrlSelector(state$.value), + includeSponsoredContent: channelSelectors.includeSponsoredContentSelector(state$.value), + frequency: parseInt(channelSelectors.frequencySelector(state$.value), 10), + supplyTagId: channelSelectors.supplyTagSelector(state$.value)?.id, + supplyTagName: channelSelectors.supplyTagSelector(state$.value)?.name, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(clearAction()), + of(getWatchesAction()), + of(removeQueriesAction([QUERY_PARAM_WATCH_ID, QUERY_PARAM_CHANNEL])), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of(updateStoreByPropAction({ isLoading: true })), + stream$, + of(updateStoreByPropAction({ isLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateDefaultPropFromWatch.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateDefaultPropFromWatch.js new file mode 100644 index 0000000..c9b1ceb --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateDefaultPropFromWatch.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import { setDefaultPropsFromWatchAction, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query WatchQuery($id: Int!) { + watch(id: $id){ + id + folderName + environment + appearance + title + bg + fontSize + color + cTitleSize + cTitleColor + cDescSize + cDescColor + cVidTitleSize + cVidTitleColor + cInfoColor + updatedBy + updatedAt + createdBy + createdAt + description + player { + id + name + alias + publisherId + } + publisher { + id + name + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(setDefaultPropsFromWatchAction.type), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: payload.watchId, + }, + }).pipe( + mergeMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const resultData = { + appearance: data.watch.appearance, + watchTitle: data.watch.title, + watchEnvironment: data.watch.environment, + watchFolderName: data.watch.folderName, + watchHub: data.watch.publisher, + }; + + if (payload.newChannel) { + resultData.titleColor = data.watch.cTitleColor; + resultData.descColor = data.watch.cDescColor; + resultData.vidTitleColor = data.watch.cVidTitleColor; + resultData.vidInfoColor = data.watch.cInfoColor; + } + + actions.push(of(updateStoreByPropAction(resultData))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/selectors/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/selectors/index.js new file mode 100644 index 0000000..c7f5827 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/selectors/index.js @@ -0,0 +1,47 @@ +import { FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const watchTitleSelector = (state) => state[nameSpace].watchTitle; +export const watchEnvironmentSelector = (state) => state[nameSpace].watchEnvironment; +export const watchFolderNameSelector = (state) => state[nameSpace].watchFolderName; +export const watchHubSelector = (state) => state[nameSpace].watchHub; +export const idSelector = (state) => state[nameSpace].id; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; +export const appearanceSelector = (state) => state[nameSpace].appearance; +export const titleSelector = (state) => state[nameSpace].title; +export const descriptionSelector = (state) => state[nameSpace].description; +export const sizeSelector = (state) => state[nameSpace].size; +export const designSelector = (state) => state[nameSpace].design; +export const bgSelector = (state) => state[nameSpace].bg; +export const logoSelector = (state) => state[nameSpace].logo; +export const bgLogoSelector = (state) => state[nameSpace].bgLogo; +export const titleColorSelector = (state) => state[nameSpace].titleColor; +export const descColorSelector = (state) => state[nameSpace].descColor; +export const vidTitleColorSelector = (state) => state[nameSpace].vidTitleColor; +export const vidInfoColorSelector = (state) => state[nameSpace].vidInfoColor; +export const cTitleColorSelector = (state) => state[nameSpace].cTitleColor; +export const cVidTitleColorSelector = (state) => state[nameSpace].cVidTitleColor; +export const cVidInfoColorSelector = (state) => state[nameSpace].cVidInfoColor; +export const cTitleSizeSelector = (state) => state[nameSpace].cTitleSize; +export const cVidTitleSizeSelector = (state) => state[nameSpace].cVidTitleSize; +export const cVidInfoSizeSelector = (state) => state[nameSpace].cVidInfoSize; +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const formChangedSelector = (state) => state[nameSpace].formChanged; +export const mrssForExportSelector = (state) => state[nameSpace].MRSSForExport; +export const mrssAliasDomainSelector = (state) => state[nameSpace].MRSSAliasDomain; +export const mrssLinkUrlSelector = (state) => state[nameSpace].MRSSLinkUrl; + +export const includeSponsoredContentSelector = (state) => state[nameSpace].includeSponsoredContent; +export const frequencySelector = (state) => state[nameSpace].frequency; +export const supplyTagSelector = (state) => state[nameSpace].supplyTag; +export const supplyTagOptionsSelector = (state) => state[nameSpace].supplyTagOptions; + +const formSelectors = createFormSelector(FORM_REDUX_FIELD_NAME, nameSpace); + +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/slices/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/slices/index.js new file mode 100644 index 0000000..7aaa67e --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/slices/index.js @@ -0,0 +1,109 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ENVIRONMENT_INTERNAL } from '../../../Edit/constants'; +import { DESIGN_STANDARD, FORM_REDUX_FIELD_NAME, SIZE_MEDIUM, TAB_GENERAL } from '../../constants'; +import { APPEARANCE_MODE_LIGHT } from '@/mui/constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(FORM_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + watchTitle: '', + watchEnvironment: ENVIRONMENT_INTERNAL, + watchFolderName: '', + watchHub: null, + /** todo: + should be received from the PCN watch by endpoint has an issue + that's why it is from taken from component React by useEffect + but should be from src/modules/editorial/RightSideBar/TabWatch/ChannelEdit/redux/epics/updateDefaultPropFromWatch.js + */ + watchChannels: [], + id: null, + appearance: APPEARANCE_MODE_LIGHT, + title: '', + description: '', + + size: SIZE_MEDIUM, // STANDARD, LARGE + design: DESIGN_STANDARD, // STANDARD, BACKGROUND, INLINE + + bg: null, + + logo: null, + bgLogo: null, + titleColor: null, + descColor: null, + + vidTitleColor: null, + vidInfoColor: null, + + cTitleColor: null, + cVidTitleColor: null, + cVidInfoColor: null, + + cTitleSize: null, + cVidTitleSize: null, + cVidInfoSize: null, + + isLoading: false, + formChanged: false, + + MRSSForExport: false, + MRSSAliasDomain: '', + MRSSLinkUrl: '', + + // sponsored content + includeSponsoredContent: false, + frequency: 3, + supplyTag: null, + supplyTagOptions: null, + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@WATCH/CHANNEL_EDIT', + initialState, + reducers: { + clearAction: () => ({ ...initialState, watchChannels: [] }), + updateStoreByPropAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + createChannelAction: (state) => state, + getChannelByIdAction: (state) => state, + setDefaultPropsFromWatchAction: (state) => state, + updateChannelAction: (state) => state, + getSupplyTagOptionsAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + clearAction, + createChannelAction, + getChannelByIdAction, + setActiveTabIdAction, + setScrollToFieldNameAction, + setDefaultPropsFromWatchAction, + setErrorByPropAction, + removeErrorByPropAction, + updateChannelAction, + updateStoreByPropAction, + getSupplyTagOptionsAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/index.js new file mode 100644 index 0000000..50e801b --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/index.js @@ -0,0 +1,173 @@ +export const TAB_BASIC_DETAILS = 'basicDetails'; +export const TAB_LOOK_AND_FEEL = 'lookAndFeel'; +export const TAB_SEARCH_LOOK_AND_FEEL = 'searchLookAndFeel'; +export const TAB_CHIPS = 'chips'; +export const TAB_ADVANCED = 'advanced'; +export const TAB_CUSTOM = 'custom'; +export const TAB_CONFIGURATION = 'configuration'; + +export const FONT_SIZE_LIST = new Array(23).fill(null).map((item, index) => { + const value = `${10 + index}px`; + + return { + label: value, + value, + }; +}); + +export const FONT_FAMILY_LIST = [ + { name: 'Roboto', value: 'Roboto' }, + { name: 'Lato', value: 'Lato' }, + { name: 'Rubik', value: 'Rubik' }, + { name: 'Open Sans', value: 'Open Sans' }, + { name: 'Montserrat', value: 'Montserrat' }, + { name: 'Titillium Web', value: 'Titillium Web' }, + { name: 'Inter', value: 'Inter' }, + { name: 'Heebo', value: 'Heebo' }, + { name: 'Arvo', value: 'Arvo' }, + { name: 'IBM Plex Serif', value: 'IBM Plex Serif' }, + { name: 'Nunito Sans', value: 'Nunito Sans' }, + { name: 'Manrope', value: 'Manrope' }, + // ----------- + { name: 'Device Specific', value: '' }, + { name: 'Inherit Site Font Family', value: 'inherit' }, +]; + +export const ENUM_LIGHT = 'LIGHT'; +export const ENUM_DARK = 'DARK'; + +export const APPEARANCE_MODE_LIST = [ + { + label: 'Light', + value: ENUM_LIGHT, + }, + { + label: 'Dark', + value: ENUM_DARK, + }, +]; + +export const DEEP_SEARCH_REDIRECT_PLAYLIST = 'PLAYLIST'; +export const DEEP_SEARCH_REDIRECT_DEEP_SEARCH = 'DEEP_SEARCH'; + +export const DEEP_SEARCH_REDIRECT_LIST = [ + { + label: 'Playlist Tab', + value: DEEP_SEARCH_REDIRECT_PLAYLIST, + }, + { + label: 'Deep Search Tab', + value: DEEP_SEARCH_REDIRECT_DEEP_SEARCH, + }, +]; + +export const PAGE_MODE_STANDARD = 'DEFAULT'; +export const PAGE_MODE_POPUP = 'IN_POPUP'; + +export const PAGE_MODE_LIST = [ + { + label: 'Standard', + value: PAGE_MODE_STANDARD, + }, + { + label: 'Pop Up', + value: PAGE_MODE_POPUP, + }, +]; + +export const INLINE_CHANNEL_PLAYBACK_FLOW_LIST = [ + { + label: 'Double-click to play', + value: true, + }, + { + label: 'Click to play', + value: false, + }, +]; + +export const ENVIRONMENT_INTERNAL = 'INTERNAL'; +export const ENVIRONMENT_EXTERNAL = 'EXTERNAL'; +export const ENVIRONMENT_INTERNAL_EXTERNAL = 'INTERNAL_EXTERNAL'; +export const ENVIRONMENT_LIST = [ + { + label: 'Internal', + value: ENVIRONMENT_INTERNAL, + }, + { + label: 'External', + value: ENVIRONMENT_EXTERNAL, + }, + { + label: 'Internal and External', + value: ENVIRONMENT_INTERNAL_EXTERNAL, + }, +]; + +export const VIEWS = 'VIEWS'; +export const LIKES = 'LIKES'; +export const SHARES = 'SHARES'; + +export const VIDEO_ENGAGEMENT_CONTROLS = [ + { + label: 'Views', + value: VIEWS, + }, + { + label: 'Likes', + value: LIKES, + }, + { + label: 'Shares', + value: SHARES, + }, +]; + +export const DEFAULT_LANGUAGES_LIST = [ + { + label: 'English', + value: 'EN', + }, + { + label: 'German', + value: 'DE', + }, + { + label: 'French', + value: 'FR', + }, + { + label: 'Spanish', + value: 'ES', + }, + { + label: 'Hebrew', + value: 'HE', + }, + { + label: 'Ukrainian', + value: 'UK', + }, +]; + +export const DEFAULT_WATCH_LANGUAGE_VALUE = 'en'; +export const WATCH_LANGUAGE_HEBREW_VALUE = 'he'; + +export const RTL_LANGUAGE_LIST = [WATCH_LANGUAGE_HEBREW_VALUE]; + +export const WATCH_LANGUAGES_LIST = [ + { + label: 'English', + value: DEFAULT_WATCH_LANGUAGE_VALUE, + }, + { + label: 'Hebrew', + value: WATCH_LANGUAGE_HEBREW_VALUE, + }, +]; + +export const GEN_AI_DEFAULT_VIDEO_COUNT = 5; +export const GEN_AI_MIN_VIDEO_COUNT = 3; +export const GEN_AI_MAX_VIDEO_COUNT = 20; + +export const FORM_REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/label.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/label.js new file mode 100644 index 0000000..f224014 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/label.js @@ -0,0 +1,64 @@ +export const SYSTEM_TAG_KEYWORDS = 'KEYWORDS'; +export const SYSTEM_TAG_PEOPLE = 'PEOPLE'; +export const SYSTEM_TAG_BRANDS = 'BRANDS'; +export const SYSTEM_TAG_CC = 'CC'; +export const SYSTEM_TAG_TEXT = 'TEXT'; +export const SYSTEM_TAG_BRAND_SAFETY = 'BRAND_SAFETY'; +export const SYSTEM_TAG_IAB = 'IAB'; + +export const CUSTOM_TAG = 'LABEL'; +export const CUSTOM_TAG_NAME = 'LABEL_NAME'; +export const CUSTOM_TAG_VALUE = 'LABEL_VALUE'; + +export const TYPE_DESCRIBE_TITLE = 'TITLE'; +export const TYPE_DESCRIBE_PLOT = 'PLOT'; + +export const DEEP_SEARCH_TAGS_LIST = [ + { + label: 'People', + value: SYSTEM_TAG_PEOPLE, + }, + { + label: 'Brands', + value: SYSTEM_TAG_BRANDS, + }, + { + label: 'Transcript', + value: SYSTEM_TAG_CC, + }, + { + label: 'Text', + value: SYSTEM_TAG_TEXT, + }, + { + label: 'Custom Tags', + value: CUSTOM_TAG_VALUE, + }, +]; + +export const CHIP_TYPE_LIST = [ + { + label: 'Custom Tag Categories', + value: CUSTOM_TAG_NAME, + }, + { + label: 'Custom Tags', + value: CUSTOM_TAG_VALUE, + }, + { + label: 'People', + value: SYSTEM_TAG_PEOPLE, + }, + { + label: 'Brands', + value: SYSTEM_TAG_BRANDS, + }, + { + label: 'IAB Categories', + value: SYSTEM_TAG_IAB, + }, + { + label: 'Keywords', + value: SYSTEM_TAG_KEYWORDS, + }, +]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/orderOfTabs.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/orderOfTabs.js new file mode 100644 index 0000000..a0abd7b --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/orderOfTabs.js @@ -0,0 +1,45 @@ +import { BookmarksOutlined } from '@mui/icons-material'; + +import { + CustomCommentsOutlined, + CustomDeepSearchOutlined, + CustomPlaylistOutlined, + CustomSlidesOutlined, + CustomTranscriptOutlined, +} from '@/mui/components/CustomIcon'; + +export const TAB_PLAYLIST = 'PLAYLIST'; +export const TAB_DEEP_SEARCH = 'DEEP_SEARCH'; +export const TAB_CHAPTERS = 'CHAPTERS'; +export const TAB_COMMENTS = 'COMMENTS'; +export const TAB_TRANSCRIPTS = 'TRANSCRIPTS'; +export const TAB_SLIDES = 'SLIDES'; + +export const ORDER_OF_TAB_INFO = { + [TAB_PLAYLIST]: { + label: 'Playlist', + Icon: CustomPlaylistOutlined, + }, + [TAB_DEEP_SEARCH]: { + label: 'Deep Search', + Icon: CustomDeepSearchOutlined, + }, + [TAB_CHAPTERS]: { + label: 'Chapters', + Icon: BookmarksOutlined, + }, + [TAB_COMMENTS]: { + label: 'Comments', + Icon: CustomCommentsOutlined, + }, + [TAB_TRANSCRIPTS]: { + label: 'Transcript', + Icon: CustomTranscriptOutlined, + }, + [TAB_SLIDES]: { + label: 'Slides', + Icon: CustomSlidesOutlined, + }, +}; + +export const ORDER_OF_TABS = [TAB_PLAYLIST, TAB_DEEP_SEARCH, TAB_CHAPTERS, TAB_COMMENTS, TAB_TRANSCRIPTS, TAB_SLIDES]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/pages.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/pages.js new file mode 100644 index 0000000..4193be3 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/pages.js @@ -0,0 +1,23 @@ +export const PAGE_MAIN = 'MAIN'; +export const PAGE_CHANNEL = 'CHANNEL'; +export const PAGE_PLAYER = 'PLAYER'; +export const PAGE_SEARCH = 'SEARCH'; + +export const PAGE_LIST = [ + { + label: 'Main', + value: PAGE_MAIN, + }, + { + label: 'Channel', + value: PAGE_CHANNEL, + }, + { + label: 'Player', + value: PAGE_PLAYER, + }, + { + label: 'Search', + value: PAGE_SEARCH, + }, +]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/presets.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/presets.js new file mode 100644 index 0000000..3182ea9 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/constants/presets.js @@ -0,0 +1,60 @@ +import { ENUM_DARK, ENUM_LIGHT } from '.'; +import { CUSTOM_TAG_VALUE, SYSTEM_TAG_BRANDS, SYSTEM_TAG_IAB, SYSTEM_TAG_PEOPLE } from './label'; +import { PAGE_CHANNEL, PAGE_MAIN, PAGE_PLAYER, PAGE_SEARCH } from './pages'; + +export const LIGHT_PRESET = { + color: null, + bg: null, + + cTitleColor: null, + cDescColor: null, + + cVidTitleColor: null, + cInfoColor: null, + + sTitleColor: null, + sVidTitleColor: null, + sVidInfoColor: null, +}; + +export const DARK_PRESET = { + color: null, + bg: null, + + cTitleColor: null, + cDescColor: null, + + cVidTitleColor: null, + cInfoColor: null, + + sTitleColor: null, + sVidTitleColor: null, + sVidInfoColor: null, +}; + +export const PRESETS = { + [ENUM_LIGHT]: { ...LIGHT_PRESET }, + [ENUM_DARK]: { ...DARK_PRESET }, +}; + +export const CHIPS_ENABLED_PRESET = { + tags: true, + tagTypes: [SYSTEM_TAG_PEOPLE, SYSTEM_TAG_BRANDS, SYSTEM_TAG_IAB, CUSTOM_TAG_VALUE], + fixedTags: [], + excludedTags: [], + tagPages: [PAGE_MAIN, PAGE_CHANNEL, PAGE_PLAYER, PAGE_SEARCH], + tagCount: 3, + tagMin: 3, + tagMax: 30, +}; + +export const CHIPS_DISABLED_PRESET = { + tags: false, + tagTypes: [], + fixedTags: [], + excludedTags: [], + tagPages: [], + tagCount: 1, + tagMin: 0, + tagMax: 1, +}; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/index.js new file mode 100644 index 0000000..6d9bf14 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/index.js @@ -0,0 +1,19 @@ +import { canCreate, canEdit } from '../../helpers/permissions'; + +export const getSitemapFileUrlBy = (hubId, watchId) => { + const cdnName = process.env.APP_DWH_SEO_S3_BUCKET; + + return `https://${cdnName}/${hubId}/${hubId}_${watchId}/sitemap.xml`; +}; + +export const getSourceDefaultValue = () => [{ value: -1, label: 'All' }]; + +export const strToInt = (string = '') => parseInt(string.replace(/\D+/g, ''), 10) || 0; + +export const valuesArrayToAutocomplete = (values, optionList) => + values.map((value) => optionList.find((opt) => opt.value === value)); + +export const getIsAllowedForCreateOrUpdate = (watchId, userPermissions) => + watchId ? canEdit(userPermissions) : canCreate(userPermissions); + +export default {}; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/shouldAddAllOptionsSelector.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/shouldAddAllOptionsSelector.js new file mode 100644 index 0000000..42b7b2d --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/shouldAddAllOptionsSelector.js @@ -0,0 +1,21 @@ +import { HUB_ADMIN_ROLE } from '@/modules/@common/user/constants/roles'; + +import { + getPublisherIdsSelector, + getUserAccountSelector, + getUserRoleSelector, +} from '@/modules/@common/user/redux/selectors'; + +export function shouldAddAllOptionsSelector(state) { + const userRole = getUserRoleSelector(state); + const userAccount = getUserAccountSelector(state); + const userHubsIds = getPublisherIdsSelector(state); + + if (!userAccount) return true; + + const userHubs = userAccount.publishers.filter((hub) => userHubsIds.includes(hub.id)); + const isAllHubsMatched = userAccount.publishers.length > 1 && userAccount.publishers.length === userHubs.length; + const isNotHubAdmin = userRole?.name !== HUB_ADMIN_ROLE; + + return isAllHubsMatched && isNotHubAdmin; +} diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/validationScheme.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/validationScheme.js new file mode 100644 index 0000000..4f72c97 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/helpers/validationScheme.js @@ -0,0 +1,149 @@ +import { TAB_ADVANCED, TAB_BASIC_DETAILS, TAB_CONFIGURATION, TAB_CUSTOM } from '../constants'; + +import { isValidUrl } from '@/modules/@common/helpers/string'; + +export const validationScheme = [ + { + fieldName: 'hub', + tabId: TAB_BASIC_DETAILS, + validation: (value) => { + if (!value) { + return 'Hub is required'; + } + + return ''; + }, + }, + { + fieldName: 'title', + tabId: TAB_BASIC_DETAILS, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + if (value.length < 2) { + return 'Field cannot be less then 2 symbols'; + } + + return ''; + }, + }, + { + fieldName: 'source', + tabId: TAB_BASIC_DETAILS, + validation: (value) => { + if (!value?.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'domain', + tabId: TAB_BASIC_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'sitemap', + tabId: TAB_BASIC_DETAILS, + validation: (value) => { + if (typeof value === 'boolean' && value === true) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + if (!isValidUrl(value)) { + return 'Wrong link format'; + } + + return ''; + }, + }, + { + fieldName: 'dsTags', + tabId: TAB_ADVANCED, + validation: (value) => { + if (!value.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'adHubId', + tabId: TAB_ADVANCED, + validation: (value) => { + if (!value.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'adWidgetId', + tabId: TAB_ADVANCED, + validation: (value) => { + if (!value.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'widgetOrigin', + tabId: TAB_CUSTOM, + validation: (value) => { + if (value && !isValidUrl(value)) { + return 'Wrong URL format'; + } + + return ''; + }, + }, + { + fieldName: 'js', + tabId: TAB_CUSTOM, + validation: (value) => { + try { + // eslint-disable-next-line no-new-func + new Function(value)(); + } catch (e) { + return e.message; + } + + return ''; + }, + }, + { + fieldName: 'configuration', + tabId: TAB_CONFIGURATION, + validation: (value) => { + try { + if (value) { + const parsed = JSON.parse(value); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error(); + } + } + + return ''; + } catch { + return 'Invalid JSON format'; + } + }, + }, +]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/createWatch.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/createWatch.js new file mode 100644 index 0000000..ec55665 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/createWatch.js @@ -0,0 +1,320 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { WATCH_CONFIGURATION_CUSTOM_SETTING } from '@/modules/@common/acl/constants'; +import { QUERY_PARAM_WATCH_ID } from '@/modules/editorial/constants/routing'; + +import { getWatchesAction } from '../../../Watches/redux/slices'; +import * as watchSelectors from '../selectors'; +import { clearAction, createWatchAction, updateStoreByPropAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + mutation CreateWatch( + $publisherId: Int, + $title: String, + $description: String, + $status: Int, + $environment: String, + $feedIds: [Int], + $domainId: Int, + + $search: Boolean, + $deepSearch: Boolean, + $genAI: Boolean, + $comments: Boolean, + $autoplay: Boolean, + $autoplayMobile: Boolean, + $autoplayNext: Boolean, + $sound: Boolean, + $soundMobile: Boolean, + $seo: Boolean, + $sitemap: String, + $download: Boolean, + $emailShare: Boolean, + $socialShare: Boolean, + $shareService: Boolean, + $highlights: Boolean, + $transcriptDownload: Boolean, + $slides: Boolean, + $chapters: Boolean, + + $appearance: String, + $font: String, + $fontSize: String, + $color: String, + $bg: String, + $dockMobile: Boolean, + $offsetTop: String, + $offsetBottom: String, + $cTitleSize: String, + $cTitleColor: String, + $cDescSize: String, + $cDescColor: String, + $cVidTitleSize: String, + $cVidTitleColor: String, + $cInfoColor: String, + $engagement: [String], + $layout: String, + $popupSize: Int, + + $sTitleSize: String, + $sTitleColor: String, + $sVidTitleSize: String, + $sVidTitleColor: String, + $sVidInfoSize: String, + $sVidInfoColor: String, + $searchPlaceholder: String, + $searchLabel: String, + + $tags: Boolean, + $tagTypes: [String], + $fixedTags: [String], + $excludedTags: [String], + $tagPages: [String], + $tagCount: Int, + $tagMin: Int, + $tagMax: Int, + + $language: String, + $languages: [WatchLanguageInputType], + $tabOrder: [String], + $dsTags: [String], + $dsRedirect: String, + $dblClickFlow: Boolean, + $rtl: Boolean, + $ad: Boolean, + $adHubId: String, + $adWidgetId: String, + $videoPreview: Boolean, + $delayPreview: Int, + $css: String, + $configuration: String, + + $myWatch: Boolean, + $widgetOrigin: String, + $js: String, + $backMobile: Boolean, + $genAICount: Int, + ) { + createWatch( + publisherId: $publisherId, + title: $title, + description: $description, + status: $status, + environment: $environment, + domainId: $domainId, + feedIds: $feedIds, + + search: $search, + deepSearch: $deepSearch, + genAI: $genAI, + comments: $comments, + autoplay: $autoplay, + autoplayMobile: $autoplayMobile, + autoplayNext: $autoplayNext, + sound: $sound, + soundMobile: $soundMobile, + seo: $seo, + sitemap: $sitemap, + download: $download, + emailShare: $emailShare, + socialShare: $socialShare, + shareService: $shareService, + highlights: $highlights, + transcriptDownload: $transcriptDownload, + slides: $slides, + chapters: $chapters, + + appearance: $appearance, + font: $font, + fontSize: $fontSize, + color: $color, + bg: $bg, + dockMobile: $dockMobile, + offsetTop: $offsetTop, + offsetBottom: $offsetBottom, + cTitleSize: $cTitleSize, + cTitleColor: $cTitleColor, + cDescSize: $cDescSize, + cDescColor: $cDescColor, + cVidTitleSize: $cVidTitleSize, + cVidTitleColor: $cVidTitleColor, + cInfoColor: $cInfoColor, + engagement: $engagement, + layout: $layout, + popupSize: $popupSize, + + sTitleSize: $sTitleSize, + sTitleColor: $sTitleColor, + sVidTitleSize: $sVidTitleSize, + sVidTitleColor: $sVidTitleColor, + sVidInfoSize: $sVidInfoSize, + sVidInfoColor: $sVidInfoColor, + searchPlaceholder: $searchPlaceholder, + searchLabel: $searchLabel, + + tags: $tags, + tagTypes: $tagTypes, + fixedTags: $fixedTags, + excludedTags: $excludedTags, + tagPages: $tagPages, + tagCount: $tagCount, + tagMin: $tagMin, + tagMax: $tagMax, + + language: $language, + languages: $languages, + tabOrder: $tabOrder, + dsTags: $dsTags, + dsRedirect: $dsRedirect, + dblClickFlow: $dblClickFlow, + rtl: $rtl, + ad: $ad, + adHubId: $adHubId, + adWidgetId: $adWidgetId, + videoPreview: $videoPreview, + delayPreview: $delayPreview, + css: $css, + configuration: $configuration, + + myWatch: $myWatch, + widgetOrigin: $widgetOrigin, + js: $js, + backMobile: $backMobile, + genAICount: $genAICount, + ) { + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createWatchAction.type), + switchMap(() => { + const hub = watchSelectors.hubSelector(state$.value); + const source = watchSelectors.sourceSelector(state$.value); + const domain = watchSelectors.domainSelector(state$.value); + const userPermissions = getUserPermissionsSelector(state$.value); + + const variables = { + publisherId: hub.value, + title: watchSelectors.titleSelector(state$.value), + description: watchSelectors.descriptionSelector(state$.value), + status: watchSelectors.statusSelector(state$.value), + environment: watchSelectors.environmentSelector(state$.value), + feedIds: source.map((item) => item.value), + + search: watchSelectors.searchSelector(state$.value), + deepSearch: watchSelectors.deepSearchSelector(state$.value), + genAI: watchSelectors.genAISelector(state$.value), + comments: watchSelectors.commentsSelector(state$.value), + autoplay: watchSelectors.autoplaySelector(state$.value), + autoplayMobile: watchSelectors.autoplayMobileSelector(state$.value), + autoplayNext: watchSelectors.autoplayNextSelector(state$.value), + sound: watchSelectors.soundSelector(state$.value), + soundMobile: watchSelectors.soundMobileSelector(state$.value), + seo: watchSelectors.seoSelector(state$.value), + sitemap: watchSelectors.sitemapSelector(state$.value), + download: watchSelectors.downloadSelector(state$.value), + emailShare: watchSelectors.emailShareSelector(state$.value), + socialShare: watchSelectors.socialShareSelector(state$.value), + shareService: watchSelectors.shareServiceSelector(state$.value), + highlights: watchSelectors.highlightsSelector(state$.value), + transcriptDownload: watchSelectors.transcriptDownloadSelector(state$.value), + slides: watchSelectors.slidesSelector(state$.value), + chapters: watchSelectors.chaptersSelector(state$.value), + + appearance: watchSelectors.appearanceSelector(state$.value), + font: watchSelectors.fontSelector(state$.value), + fontSize: watchSelectors.fontSizeSelector(state$.value), + color: watchSelectors.colorSelector(state$.value), + bg: watchSelectors.bgSelector(state$.value), + dockMobile: watchSelectors.dockMobileSelector(state$.value), + offsetTop: watchSelectors.offsetTopSelector(state$.value), + offsetBottom: watchSelectors.offsetBottomSelector(state$.value), + cTitleSize: watchSelectors.cTitleSizeSelector(state$.value), + cTitleColor: watchSelectors.cTitleColorSelector(state$.value), + cDescSize: watchSelectors.cDescSizeSelector(state$.value), + cDescColor: watchSelectors.cDescColorSelector(state$.value), + cVidTitleSize: watchSelectors.cVidTitleSizeSelector(state$.value), + cVidTitleColor: watchSelectors.cVidTitleColorSelector(state$.value), + cInfoColor: watchSelectors.cInfoColorSelector(state$.value), + engagement: watchSelectors.engagementSelector(state$.value), + layout: watchSelectors.layoutSelector(state$.value), + popupSize: watchSelectors.popupSizeSelector(state$.value), + + sTitleSize: watchSelectors.sTitleSizeSelector(state$.value), + sTitleColor: watchSelectors.sTitleColorSelector(state$.value), + sVidTitleSize: watchSelectors.sVidTitleSizeSelector(state$.value), + sVidTitleColor: watchSelectors.sVidTitleColorSelector(state$.value), + sVidInfoSize: watchSelectors.sVidInfoSizeSelector(state$.value), + sVidInfoColor: watchSelectors.sVidInfoColorSelector(state$.value), + searchPlaceholder: watchSelectors.searchPlaceholderSelector(state$.value), + searchLabel: watchSelectors.searchLabelSelector(state$.value), + + tags: watchSelectors.tagsSelector(state$.value), + tagTypes: watchSelectors.tagTypesSelector(state$.value), + fixedTags: watchSelectors.fixedTagsSelector(state$.value), + excludedTags: watchSelectors.excludedTagsSelector(state$.value), + tagPages: watchSelectors.tagPagesSelector(state$.value), + tagCount: watchSelectors.tagCountSelector(state$.value), + tagMin: watchSelectors.tagMinSelector(state$.value), + tagMax: watchSelectors.tagMaxSelector(state$.value), + + language: watchSelectors.languageSelector(state$.value), + languages: watchSelectors.languagesSelector(state$.value), + tabOrder: watchSelectors.tabOrderSelector(state$.value), + dsTags: watchSelectors.dsTagsSelector(state$.value), + dsRedirect: watchSelectors.dsRedirectSelector(state$.value), + dblClickFlow: watchSelectors.dblClickFlowSelector(state$.value), + rtl: watchSelectors.rtlSelector(state$.value), + ad: watchSelectors.adSelector(state$.value), + adHubId: watchSelectors.adHubIdSelector(state$.value), + adWidgetId: watchSelectors.adWidgetIdSelector(state$.value), + videoPreview: watchSelectors.videoPreviewSelector(state$.value), + delayPreview: watchSelectors.delayPreviewSelector(state$.value), + css: watchSelectors.cssSelector(state$.value), + configuration: watchSelectors.configurationSelector(state$.value), + + myWatch: watchSelectors.myWatchSelector(state$.value), + }; + + if (domain?.value) { + variables.domainId = domain.value; + } + + if (hasPermission(WATCH_CONFIGURATION_CUSTOM_SETTING, userPermissions)) { + variables.widgetOrigin = watchSelectors.widgetOriginSelector(state$.value); + variables.js = watchSelectors.jsSelector(state$.value); + variables.backMobile = watchSelectors.backMobileSelector(state$.value); + variables.genAICount = watchSelectors.genAICountSelector(state$.value); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(clearAction()), of(getWatchesAction()), of(removeQueriesAction([QUERY_PARAM_WATCH_ID]))); + } + + return concat(...actions); + }), + ); + + return concat( + of(updateStoreByPropAction({ isLoading: true })), + stream$, + of(updateStoreByPropAction({ isLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getAccountFeatures.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getAccountFeatures.js new file mode 100644 index 0000000..3f828bf --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getAccountFeatures.js @@ -0,0 +1,44 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getAccountFeatures, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query accountFeaturesQuery($accountId: Int!) { + accountFeatures(accountId: $accountId){ + name + value + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getAccountFeatures.type), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + accountId: payload, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountFeatures = response.data.accountFeatures.reduce((acc, feature) => { + acc[feature.name] = feature.value; + return acc; + }, {}); + actions.push(of(updateStoreByPropAction({ accountFeatures }))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getDomainsAutocomplete.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getDomainsAutocomplete.js new file mode 100644 index 0000000..da667e8 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getDomainsAutocomplete.js @@ -0,0 +1,74 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as watchSelectors from '../selectors'; +import { getDomainOptionsAction, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query WatchGetDomains( + $pageSize: Int, + $searchText: String, + $publisherId: Int, + ) { + watchGetDomains( + pageSize: $pageSize, + searchText: $searchText + publisherId: $publisherId, + ) { + records { + id + domain + } + } + } +`; + +const getResponse = ({ + data: { + watchGetDomains: { records }, + }, +}) => records.map((domain) => ({ value: domain.id, label: domain.domain })); + +export default (action$, state$) => + action$.pipe( + ofType(getDomainOptionsAction.type), + switchMap((action) => { + const hub = watchSelectors.hubSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + publisherId: hub.value, + top: 30, + searchText: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + updateStoreByPropAction({ + domainOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + updateStoreByPropAction({ + domainOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getHubsAutocomplete.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getHubsAutocomplete.js new file mode 100644 index 0000000..c4f4736 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getHubsAutocomplete.js @@ -0,0 +1,70 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getHubOptionsAction, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query WatchGetHubs( + $pageSize: Int, + $searchText: String, + ) { + watchGetHubs( + pageSize: $pageSize, + searchText: $searchText + ) { + records { + id + name + slug + accountId + } + } + } +`; + +const getResponse = ({ + data: { + watchGetHubs: { records }, + }, +}) => records.map((hub) => ({ value: hub.id, label: hub.name, accountId: hub.accountId, slug: hub.slug })); + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + pageSize: 30, + searchText: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + updateStoreByPropAction({ + hubOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + updateStoreByPropAction({ + hubOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getSourceAutocomplete.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getSourceAutocomplete.js new file mode 100644 index 0000000..f1fb819 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getSourceAutocomplete.js @@ -0,0 +1,77 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getSourceDefaultValue } from '../../helpers'; +import { shouldAddAllOptionsSelector } from '../../helpers/shouldAddAllOptionsSelector'; +import * as watchSelectors from '../selectors'; +import { getSourceOptionsAction, updateStoreByPropAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query WatchGetSources( + $pageSize: Int, + $searchText: String, + $publisherId: Int, + ) { + watchGetSources( + pageSize: $pageSize, + searchText: $searchText, + publisherId: $publisherId, + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ + data: { + watchGetSources: { records }, + }, +}) => records.map((sources) => ({ value: sources.id, label: sources.name })); + +export default (action$, state$) => + action$.pipe( + ofType(getSourceOptionsAction.type), + switchMap((action) => { + const hub = watchSelectors.hubSelector(state$.value); + const shouldAddAllOptions = shouldAddAllOptionsSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + publisherId: hub.value, + pageSize: 30, + searchText: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + updateStoreByPropAction({ + sourceOptions: [].concat(shouldAddAllOptions ? getSourceDefaultValue() : [], getResponse(response)), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + updateStoreByPropAction({ + sourceOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getWatchById.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getWatchById.js new file mode 100644 index 0000000..0e3cb00 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/getWatchById.js @@ -0,0 +1,212 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import { QUERY_PARAM_WATCH_ID } from '@/modules/editorial/constants/routing'; + +import { getAccountFeatures, getWatchByIdAction, updateStoreByPropAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query WatchQuery($id: Int!) { + watch(id: $id){ + id + accountId + publisher { + id + name + } + title + description + status + environment + feeds { + id + name + } + domain { + id + name + } + + search + deepSearch + genAI + comments + autoplay + autoplayMobile + autoplayNext + sound + soundMobile + seo + sitemap + download + emailShare + socialShare + shareService + highlights + transcriptDownload + slides + chapters + + appearance + font + fontSize + color + bg + dockMobile + offsetTop + offsetBottom + cTitleSize + cTitleColor + cDescSize + cDescColor + cVidTitleSize + cVidTitleColor + cInfoColor + engagement + layout + popupSize + + sTitleSize + sTitleColor + sVidTitleSize + sVidTitleColor + sVidInfoSize + sVidInfoColor + searchPlaceholder + searchLabel + + tags + tagTypes + fixedTags + excludedTags + tagPages + tagCount + tagMin + tagMax + + language + languages { + label + value + }, + tabOrder + dsTags + dsRedirect + dblClickFlow + rtl + ad + adHubId + adWidgetId + videoPreview + delayPreview + css + + player { + id + name + alias + publisherId + } + inlinePlayer { + id + name + alias + publisherId + } + verticalPlayer { + id + name + alias + publisherId + } + inlineVerticalPlayer { + id + name + alias + publisherId + } + myWatch + widgetOrigin + js + backMobile + genAICount + + configuration + folderName + + createdAt + createdBy + updatedAt + updatedBy + } + } +`; + +const toAutocompleteValue = (item, extra = {}) => ({ + value: item.id, + label: item.name, + ...extra, +}); + +const getResponse = ({ data: { watch } }) => ({ + ...watch, + hub: toAutocompleteValue(watch.publisher), + source: watch.feeds.map((item) => toAutocompleteValue(item)), + domain: watch.domain ? toAutocompleteValue(watch.domain) : null, + dsTags: watch.dsTags, +}); + +export default (action$) => + action$.pipe( + ofType(getWatchByIdAction.type), + mergeMap(({ payload }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: payload, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push(of(removeQueriesAction([QUERY_PARAM_WATCH_ID]))); + } else { + const watchData = getResponse(response); + + const normalizeValues = Object.keys(watchData).reduce((acc, curr) => { + if (watchData[curr] !== null) { + return { + ...acc, + [curr]: watchData[curr], + }; + } + + if (curr === 'languages') { + return { + ...acc, + [curr]: [], + }; + } + + return acc; + }, {}); + + actions.push(of(updateStoreByPropAction({ ...normalizeValues }))); + actions.push(of(getAccountFeatures(normalizeValues.accountId))); + } + + return concat(...actions); + }), + ); + + return concat( + of(updateStoreByPropAction({ isLoading: true })), + stream$, + of(updateStoreByPropAction({ isLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/index.js new file mode 100644 index 0000000..547ff80 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import createWatch from './createWatch'; +import getAccountFeatures from './getAccountFeatures'; +import getDomainsAutocomplete from './getDomainsAutocomplete'; +import getHubsAutocomplete from './getHubsAutocomplete'; +import getSourceAutocomplete from './getSourceAutocomplete'; +import getWatchById from './getWatchById'; +import updateWatch from './updateWatch'; + +export default combineEpics( + createWatch, + getWatchById, + updateWatch, + getHubsAutocomplete, + getSourceAutocomplete, + getDomainsAutocomplete, + getAccountFeatures, +); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/updateWatch.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/updateWatch.js new file mode 100644 index 0000000..41adcdb --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/epics/updateWatch.js @@ -0,0 +1,333 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { WATCH_CONFIGURATION_CUSTOM_SETTING } from '@/modules/@common/acl/constants'; +import { QUERY_PARAM_WATCH_ID } from '@/modules/editorial/constants/routing'; + +import { getWatchesAction } from '../../../Watches/redux/slices'; +import * as watchSelectors from '../selectors'; +import { clearAction, updateStoreByPropAction, updateWatchAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + mutation UpdateWatch( + $id: Int!, + $publisherId: Int, + $title: String, + $description: String, + $status: Int, + $environment: String, + $feedIds: [Int], + $domainId: Int, + + $search: Boolean, + $deepSearch: Boolean, + $genAI: Boolean, + $comments: Boolean, + $autoplay: Boolean, + $autoplayMobile: Boolean, + $autoplayNext: Boolean, + $sound: Boolean, + $soundMobile: Boolean, + $seo: Boolean, + $sitemap: String, + $download: Boolean, + $emailShare: Boolean, + $socialShare: Boolean, + $shareService: Boolean, + $highlights: Boolean, + $transcriptDownload: Boolean, + $slides: Boolean, + $chapters: Boolean, + + $appearance: String, + $font: String, + $fontSize: String, + $color: String, + $bg: String, + $dockMobile: Boolean, + $offsetTop: String, + $offsetBottom: String, + $cTitleSize: String, + $cTitleColor: String, + $cDescSize: String, + $cDescColor: String, + $cVidTitleSize: String, + $cVidTitleColor: String, + $cInfoColor: String, + $engagement: [String], + $layout: String, + $popupSize: Int, + + $sTitleSize: String, + $sTitleColor: String, + $sVidTitleSize: String, + $sVidTitleColor: String, + $sVidInfoSize: String, + $sVidInfoColor: String, + $searchPlaceholder: String, + $searchLabel: String, + + $tags: Boolean, + $tagTypes: [String], + $fixedTags: [String], + $excludedTags: [String], + $tagPages: [String], + $tagCount: Int, + $tagMin: Int, + $tagMax: Int, + + $language: String, + $languages: [WatchLanguageInputType], + $tabOrder: [String], + $dsTags: [String], + $dsRedirect: String, + $dblClickFlow: Boolean, + $rtl: Boolean, + $ad: Boolean, + $adHubId: String, + $adWidgetId: String, + $videoPreview: Boolean, + $delayPreview: Int, + $css: String, + + $configuration: String, + $myWatch: Boolean, + $widgetOrigin: String, + $js: String, + $backMobile: Boolean, + $genAICount: Int, + ) { + updateWatch( + id: $id, + publisherId: $publisherId, + title: $title, + status: $status, + environment: $environment, + description: $description, + domainId: $domainId, + feedIds: $feedIds, + + search: $search, + deepSearch: $deepSearch, + genAI: $genAI, + comments: $comments, + autoplay: $autoplay, + autoplayMobile: $autoplayMobile, + autoplayNext: $autoplayNext, + sound: $sound, + soundMobile: $soundMobile, + seo: $seo, + sitemap: $sitemap, + download: $download, + emailShare: $emailShare, + socialShare: $socialShare, + shareService: $shareService, + highlights: $highlights, + transcriptDownload: $transcriptDownload, + slides: $slides, + chapters: $chapters, + + appearance: $appearance, + font: $font, + fontSize: $fontSize, + color: $color, + bg: $bg, + dockMobile: $dockMobile, + offsetTop: $offsetTop, + offsetBottom: $offsetBottom, + cTitleSize: $cTitleSize, + cTitleColor: $cTitleColor, + cDescSize: $cDescSize, + cDescColor: $cDescColor, + cVidTitleSize: $cVidTitleSize, + cVidTitleColor: $cVidTitleColor, + cInfoColor: $cInfoColor, + engagement: $engagement, + layout: $layout, + popupSize: $popupSize, + + sTitleSize: $sTitleSize, + sTitleColor: $sTitleColor, + sVidTitleSize: $sVidTitleSize, + sVidTitleColor: $sVidTitleColor, + sVidInfoSize: $sVidInfoSize, + sVidInfoColor: $sVidInfoColor, + searchPlaceholder: $searchPlaceholder, + searchLabel: $searchLabel, + + tags: $tags, + tagTypes: $tagTypes, + fixedTags: $fixedTags, + excludedTags: $excludedTags, + tagPages: $tagPages, + tagCount: $tagCount, + tagMin: $tagMin, + tagMax: $tagMax, + + language: $language, + languages: $languages, + tabOrder: $tabOrder, + dsTags: $dsTags, + dsRedirect: $dsRedirect, + dblClickFlow: $dblClickFlow, + rtl: $rtl, + ad: $ad, + adHubId: $adHubId, + adWidgetId: $adWidgetId, + videoPreview: $videoPreview, + delayPreview: $delayPreview, + css: $css, + + configuration: $configuration, + myWatch: $myWatch, + widgetOrigin: $widgetOrigin, + js: $js, + backMobile: $backMobile, + genAICount: $genAICount, + ){ + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateWatchAction.type), + switchMap(({ payload }) => { + const hub = watchSelectors.hubSelector(state$.value); + const source = watchSelectors.sourceSelector(state$.value); + const domain = watchSelectors.domainSelector(state$.value); + const userPermissions = getUserPermissionsSelector(state$.value); + + const variables = { + // general + id: payload, + publisherId: hub.value, + title: watchSelectors.titleSelector(state$.value), + description: watchSelectors.descriptionSelector(state$.value), + status: watchSelectors.statusSelector(state$.value), + environment: watchSelectors.environmentSelector(state$.value), + feedIds: source.map((item) => item.value), + // general - features + search: watchSelectors.searchSelector(state$.value), + deepSearch: watchSelectors.deepSearchSelector(state$.value), + genAI: watchSelectors.genAISelector(state$.value), + comments: watchSelectors.commentsSelector(state$.value), + autoplay: watchSelectors.autoplaySelector(state$.value), + autoplayMobile: watchSelectors.autoplayMobileSelector(state$.value), + autoplayNext: watchSelectors.autoplayNextSelector(state$.value), + sound: watchSelectors.soundSelector(state$.value), + soundMobile: watchSelectors.soundMobileSelector(state$.value), + seo: watchSelectors.seoSelector(state$.value), + sitemap: watchSelectors.sitemapSelector(state$.value), + download: watchSelectors.downloadSelector(state$.value), + emailShare: watchSelectors.emailShareSelector(state$.value), + socialShare: watchSelectors.socialShareSelector(state$.value), + shareService: watchSelectors.shareServiceSelector(state$.value), + highlights: watchSelectors.highlightsSelector(state$.value), + transcriptDownload: watchSelectors.transcriptDownloadSelector(state$.value), + slides: watchSelectors.slidesSelector(state$.value), + chapters: watchSelectors.chaptersSelector(state$.value), + + // lookAndFeel - watch color + appearance: watchSelectors.appearanceSelector(state$.value), + font: watchSelectors.fontSelector(state$.value), + fontSize: watchSelectors.fontSizeSelector(state$.value), + color: watchSelectors.colorSelector(state$.value), + bg: watchSelectors.bgSelector(state$.value), + dockMobile: watchSelectors.dockMobileSelector(state$.value), + offsetTop: watchSelectors.offsetTopSelector(state$.value), + offsetBottom: watchSelectors.offsetBottomSelector(state$.value), + // lookAndFeel - channel color + cTitleSize: watchSelectors.cTitleSizeSelector(state$.value), + cTitleColor: watchSelectors.cTitleColorSelector(state$.value), + cDescSize: watchSelectors.cDescSizeSelector(state$.value), + cDescColor: watchSelectors.cDescColorSelector(state$.value), + cVidTitleSize: watchSelectors.cVidTitleSizeSelector(state$.value), + cVidTitleColor: watchSelectors.cVidTitleColorSelector(state$.value), + cInfoColor: watchSelectors.cInfoColorSelector(state$.value), + // lookAndFeel + engagement: watchSelectors.engagementSelector(state$.value), + layout: watchSelectors.layoutSelector(state$.value), + popupSize: watchSelectors.popupSizeSelector(state$.value), + + // searchLookAndFeel + sTitleSize: watchSelectors.sTitleSizeSelector(state$.value), + sTitleColor: watchSelectors.sTitleColorSelector(state$.value), + sVidTitleSize: watchSelectors.sVidTitleSizeSelector(state$.value), + sVidTitleColor: watchSelectors.sVidTitleColorSelector(state$.value), + sVidInfoSize: watchSelectors.sVidInfoSizeSelector(state$.value), + sVidInfoColor: watchSelectors.sVidInfoColorSelector(state$.value), + searchPlaceholder: watchSelectors.searchPlaceholderSelector(state$.value), + searchLabel: watchSelectors.searchLabelSelector(state$.value), + + // chips + tags: watchSelectors.tagsSelector(state$.value), + tagTypes: watchSelectors.tagTypesSelector(state$.value), + fixedTags: watchSelectors.fixedTagsSelector(state$.value), + excludedTags: watchSelectors.excludedTagsSelector(state$.value), + tagPages: watchSelectors.tagPagesSelector(state$.value), + tagCount: watchSelectors.tagCountSelector(state$.value), + tagMin: watchSelectors.tagMinSelector(state$.value), + tagMax: watchSelectors.tagMaxSelector(state$.value), + + // advanced + language: watchSelectors.languageSelector(state$.value), + languages: watchSelectors.languagesSelector(state$.value), + tabOrder: watchSelectors.tabOrderSelector(state$.value), + dsTags: watchSelectors.dsTagsSelector(state$.value), + dsRedirect: watchSelectors.dsRedirectSelector(state$.value), + dblClickFlow: watchSelectors.dblClickFlowSelector(state$.value), + rtl: watchSelectors.rtlSelector(state$.value), + ad: watchSelectors.adSelector(state$.value), + adHubId: watchSelectors.adHubIdSelector(state$.value), + adWidgetId: watchSelectors.adWidgetIdSelector(state$.value), + videoPreview: watchSelectors.videoPreviewSelector(state$.value), + delayPreview: watchSelectors.delayPreviewSelector(state$.value), + css: watchSelectors.cssSelector(state$.value), + configuration: watchSelectors.configurationSelector(state$.value), + + // custom + myWatch: watchSelectors.myWatchSelector(state$.value), + }; + + if (domain?.value) { + // general + variables.domainId = domain.value; + } + + if (hasPermission(WATCH_CONFIGURATION_CUSTOM_SETTING, userPermissions)) { + // custom + variables.widgetOrigin = watchSelectors.widgetOriginSelector(state$.value); + variables.js = watchSelectors.jsSelector(state$.value); + variables.backMobile = watchSelectors.backMobileSelector(state$.value); + variables.genAICount = watchSelectors.genAICountSelector(state$.value); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(clearAction()), of(getWatchesAction()), of(removeQueriesAction([QUERY_PARAM_WATCH_ID]))); + } + + return concat(...actions); + }), + ); + + return concat( + of(updateStoreByPropAction({ isLoading: true })), + stream$, + of(updateStoreByPropAction({ isLoading: false })), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/selectors/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/selectors/index.js new file mode 100644 index 0000000..cf13bdb --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/selectors/index.js @@ -0,0 +1,124 @@ +import { FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +// general +export const idSelector = (state) => state[nameSpace].id; +export const hubSelector = (state) => state[nameSpace].hub; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; +export const titleSelector = (state) => state[nameSpace].title; +export const descriptionSelector = (state) => state[nameSpace].description; +export const statusSelector = (state) => state[nameSpace].status; +export const environmentSelector = (state) => state[nameSpace].environment; +export const sourceSelector = (state) => state[nameSpace].source; +export const sourceOptionsSelector = (state) => state[nameSpace].sourceOptions; +export const sourceSearchTextSelector = (state) => state[nameSpace].sourceSearchText; +export const domainSelector = (state) => state[nameSpace].domain; +export const domainOptionsSelector = (state) => state[nameSpace].domainOptions; +// general - features +export const searchSelector = (state) => state[nameSpace].search; +export const deepSearchSelector = (state) => state[nameSpace].deepSearch; +export const genAISelector = (state) => state[nameSpace].genAI; +export const commentsSelector = (state) => state[nameSpace].comments; +export const autoplaySelector = (state) => state[nameSpace].autoplay; +export const autoplayMobileSelector = (state) => state[nameSpace].autoplayMobile; +export const autoplayNextSelector = (state) => state[nameSpace].autoplayNext; +export const soundSelector = (state) => state[nameSpace].sound; +export const soundMobileSelector = (state) => state[nameSpace].soundMobile; +export const seoSelector = (state) => state[nameSpace].seo; +export const sitemapSelector = (state) => state[nameSpace].sitemap; +export const downloadSelector = (state) => state[nameSpace].download; +export const emailShareSelector = (state) => state[nameSpace].emailShare; +export const socialShareSelector = (state) => state[nameSpace].socialShare; +export const shareServiceSelector = (state) => state[nameSpace].shareService; +export const highlightsSelector = (state) => state[nameSpace].highlights; +export const transcriptDownloadSelector = (state) => state[nameSpace].transcriptDownload; +export const slidesSelector = (state) => state[nameSpace].slides; +export const chaptersSelector = (state) => state[nameSpace].chapters; + +// lookAndFeel - watch color +export const appearanceSelector = (state) => state[nameSpace].appearance; +export const fontSelector = (state) => state[nameSpace].font; +export const fontSizeSelector = (state) => state[nameSpace].fontSize; +export const colorSelector = (state) => state[nameSpace].color; +export const bgSelector = (state) => state[nameSpace].bg; +export const dockMobileSelector = (state) => state[nameSpace].dockMobile; +export const offsetTopSelector = (state) => state[nameSpace].offsetTop; +export const offsetBottomSelector = (state) => state[nameSpace].offsetBottom; +// lookAndFeel - channel color +export const cTitleSizeSelector = (state) => state[nameSpace].cTitleSize; +export const cTitleColorSelector = (state) => state[nameSpace].cTitleColor; +export const cDescSizeSelector = (state) => state[nameSpace].cDescSize; +export const cDescColorSelector = (state) => state[nameSpace].cDescColor; +export const cVidTitleSizeSelector = (state) => state[nameSpace].cVidTitleSize; +export const cVidTitleColorSelector = (state) => state[nameSpace].cVidTitleColor; +export const cInfoColorSelector = (state) => state[nameSpace].cInfoColor; +// lookAndFeel +export const engagementSelector = (state) => state[nameSpace].engagement; +export const layoutSelector = (state) => state[nameSpace].layout; +export const popupSizeSelector = (state) => state[nameSpace].popupSize; + +// searchLookAndFeel - search color +export const sTitleSizeSelector = (state) => state[nameSpace].sTitleSize; +export const sTitleColorSelector = (state) => state[nameSpace].sTitleColor; +export const sVidTitleSizeSelector = (state) => state[nameSpace].sVidTitleSize; +export const sVidTitleColorSelector = (state) => state[nameSpace].sVidTitleColor; +export const sVidInfoSizeSelector = (state) => state[nameSpace].sVidInfoSize; +export const sVidInfoColorSelector = (state) => state[nameSpace].sVidInfoColor; +// searchLookAndFeel +export const searchPlaceholderSelector = (state) => state[nameSpace].searchPlaceholder; +export const searchLabelSelector = (state) => state[nameSpace].searchLabel; + +// chips +export const tagsSelector = (state) => state[nameSpace].tags; +export const tagTypesSelector = (state) => state[nameSpace].tagTypes; +export const fixedTagsSelector = (state) => state[nameSpace].fixedTags; +export const excludedTagsSelector = (state) => state[nameSpace].excludedTags; +export const tagPagesSelector = (state) => state[nameSpace].tagPages; +export const tagCountSelector = (state) => state[nameSpace].tagCount; +export const tagMinSelector = (state) => state[nameSpace].tagMin; +export const tagMaxSelector = (state) => state[nameSpace].tagMax; + +// advanced +export const languageSelector = (state) => state[nameSpace].language; +export const languagesSelector = (state) => state[nameSpace].languages; +export const tabOrderSelector = (state) => state[nameSpace].tabOrder; +export const dsTagsSelector = (state) => state[nameSpace].dsTags; +export const dsRedirectSelector = (state) => state[nameSpace].dsRedirect; +export const dblClickFlowSelector = (state) => state[nameSpace].dblClickFlow; +export const rtlSelector = (state) => state[nameSpace].rtl; +export const adSelector = (state) => state[nameSpace].ad; +export const adHubIdSelector = (state) => state[nameSpace].adHubId; +export const adWidgetIdSelector = (state) => state[nameSpace].adWidgetId; +export const videoPreviewSelector = (state) => state[nameSpace].videoPreview; +export const delayPreviewSelector = (state) => state[nameSpace].delayPreview; +export const cssSelector = (state) => state[nameSpace].css; + +// custom +export const playerSelector = (state) => state[nameSpace].player; +export const inlinePlayerSelector = (state) => state[nameSpace].inlinePlayer; +export const verticalPlayerSelector = (state) => state[nameSpace].verticalPlayer; +export const inlineVerticalPlayerSelector = (state) => state[nameSpace].inlineVerticalPlayer; +export const myWatchSelector = (state) => state[nameSpace].myWatch; +export const widgetOriginSelector = (state) => state[nameSpace].widgetOrigin; +export const jsSelector = (state) => state[nameSpace].js; +export const backMobileSelector = (state) => state[nameSpace].backMobile; +export const genAICountSelector = (state) => state[nameSpace].genAICount; +export const configurationSelector = (state) => state[nameSpace].configuration; + +// custom watch fields +export const accountIdSelector = (state) => state[nameSpace].accountId; +export const accountFeaturesSelector = (state) => state[nameSpace].accountFeatures; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; +export const formChangedSelector = (state) => state[nameSpace].formChanged; +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const folderNameSelector = (state) => state[nameSpace].folderName; + +const formSelectors = createFormSelector(FORM_REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/slices/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/slices/index.js new file mode 100644 index 0000000..8f54a2d --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Edit/redux/slices/index.js @@ -0,0 +1,210 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + DEEP_SEARCH_REDIRECT_PLAYLIST, + DEFAULT_LANGUAGES_LIST, + DEFAULT_WATCH_LANGUAGE_VALUE, + ENUM_LIGHT, + ENVIRONMENT_INTERNAL, + FORM_REDUX_FIELD_NAME, + GEN_AI_DEFAULT_VIDEO_COUNT, + LIKES, + PAGE_MODE_STANDARD, + SHARES, + TAB_BASIC_DETAILS, + VIEWS, +} from '../../constants'; +import { DEEP_SEARCH_TAGS_LIST } from '../../constants/label'; +import { CHIPS_ENABLED_PRESET, LIGHT_PRESET, PRESETS } from '../../constants/presets'; +import { ORDER_OF_TABS } from '@/modules/editorial/RightSideBar/TabWatch/Edit/constants/orderOfTabs'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const defaultWatchColorPreset = { + appearance: ENUM_LIGHT, + font: 'Roboto', + fontSize: null, + color: LIGHT_PRESET.color, + bg: LIGHT_PRESET.bg, + dockMobile: true, + offsetTop: '0', + offsetBottom: '10', +}; + +const defaultChannelColorPreset = { + cTitleSize: null, + cTitleColor: LIGHT_PRESET.cTitleColor, + cDescSize: null, + cDescColor: LIGHT_PRESET.cDescColor, + cVidTitleSize: null, + cVidTitleColor: LIGHT_PRESET.cVidTitleColor, + cInfoColor: LIGHT_PRESET.cInfoColor, +}; + +const defaultSearchColorPreset = { + sTitleSize: null, + sTitleColor: LIGHT_PRESET.sTitleColor, + sVidTitleSize: null, + sVidTitleColor: LIGHT_PRESET.sVidTitleColor, + sVidInfoSize: null, + sVidInfoColor: LIGHT_PRESET.sVidInfoColor, +}; + +const formSlice = createFormSlice(FORM_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + // general + id: null, + hub: null, // { value, label } + hubOptions: null, + title: '', + description: '', + status: 1, + environment: ENVIRONMENT_INTERNAL, + source: [], // [{ value, label}] + sourceOptions: null, + sourceSearchText: '', + domain: null, // [{ value, label}] + domainOptions: null, + // general - features + search: true, + deepSearch: true, + genAI: false, + comments: false, + autoplay: true, + autoplayMobile: true, + autoplayNext: true, + sound: true, + soundMobile: true, + seo: false, + sitemap: '', + download: false, + emailShare: true, + socialShare: true, + shareService: true, + highlights: false, + transcriptDownload: false, + slides: false, + chapters: true, + + // lookAndFeel + ...defaultWatchColorPreset, + ...defaultChannelColorPreset, + engagement: [VIEWS, LIKES, SHARES], + layout: PAGE_MODE_STANDARD, + popupSize: 80, + + // searchLookAndFeel + ...defaultSearchColorPreset, + searchPlaceholder: 'Search within Video - Powered by AnyClip', + searchLabel: 'Search within Video', + + // chips + ...CHIPS_ENABLED_PRESET, + + // advanced + language: DEFAULT_WATCH_LANGUAGE_VALUE, + languages: DEFAULT_LANGUAGES_LIST, + tabOrder: ORDER_OF_TABS, + dsTags: DEEP_SEARCH_TAGS_LIST.map((tag) => tag.value), + dsRedirect: DEEP_SEARCH_REDIRECT_PLAYLIST, + dblClickFlow: true, + rtl: false, + ad: false, + adHubId: '', + adWidgetId: '', + videoPreview: true, + delayPreview: 750, + css: '', + configuration: null, + + // custom + player: null, + inlinePlayer: null, + verticalPlayer: null, + inlineVerticalPlayer: null, + myWatch: false, + widgetOrigin: '', + js: '', + backMobile: false, + genAICount: GEN_AI_DEFAULT_VIDEO_COUNT, + genAICountDraft: '', + + // custom watch fields + accountId: null, + accountFeatures: {}, // { [featureName]: bool } + formChanged: false, + isLoading: false, + folderName: '', + + activeTabId: TAB_BASIC_DETAILS, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@WATCH/WATCH_EDIT', + initialState, + reducers: { + getWatchByIdAction: (state) => state, + createWatchAction: (state) => state, + updateWatchAction: (state) => state, + getHubOptionsAction: (state) => state, + getSourceOptionsAction: (state) => state, + getDomainOptionsAction: (state) => state, + getAccountFeatures: (state) => state, + updateStoreByPropAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + clearAction: () => ({ ...initialState }), + setAppearanceModelAction: (state, action) => { + const preset = PRESETS[action.payload] ?? {}; + Object.keys(preset).forEach((key) => { + state[key] = preset[key]; + }); + }, + setLookAndFeelToDefaultAction: (state) => { + Object.keys(defaultWatchColorPreset).forEach((key) => { + state[key] = defaultWatchColorPreset[key]; + }); + Object.keys(defaultChannelColorPreset).forEach((key) => { + state[key] = defaultChannelColorPreset[key]; + }); + Object.keys(defaultSearchColorPreset).forEach((key) => { + state[key] = defaultSearchColorPreset[key]; + }); + }, + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + clearAction, + createWatchAction, + getDomainOptionsAction, + getAccountFeatures, + getHubOptionsAction, + getSourceOptionsAction, + getWatchByIdAction, + setActiveTabIdAction, + setAppearanceModelAction, + setErrorByPropAction, + setLookAndFeelToDefaultAction, + updateStoreByPropAction, + removeErrorByPropAction, + updateWatchAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/channels.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/channels.js new file mode 100644 index 0000000..40c7c24 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/channels.js @@ -0,0 +1,26 @@ +import * as watchesSelectors from '../redux/selectors'; + +export const getChannelFromStore = (state) => { + const watchId = watchesSelectors.watchIdSelector(state); + const channelId = watchesSelectors.channelIdSelector(state); + const watches = watchesSelectors.watchesSelector(state); + + if (channelId && watchId) { + const watch = watches.find((item) => item.id === watchId); + const channel = watch?.watchChannels?.find((item) => item?.id === channelId) ?? null; + + if (channel) { + return { + watchTitle: watch.title, + watchId, + ...channel, + }; + } + + return null; + } + + return null; +}; + +export default {}; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/index.js new file mode 100644 index 0000000..90152e0 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/helpers/index.js @@ -0,0 +1,12 @@ +export const SORTERS = [ + { label: 'Name', value: 'title' }, + { label: 'Date', value: 'updatedAt' }, + { label: 'User', value: 'updatedBy' }, +]; + +export const getMrssLink = (hubId, channelId) => { + const url = new URL(window.location.origin); + const domain = url.hostname.split('.').slice(1).join('.'); + const mrssDomain = `mrss.${domain}`; + return `${url.protocol}//${mrssDomain}/${hubId}/${channelId}.xml`; +}; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/addVideoToChannel.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/addVideoToChannel.js new file mode 100644 index 0000000..addabaa --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/addVideoToChannel.js @@ -0,0 +1,59 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { EMPTY, of } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { QUERY_PARAM_TAB, TAB_WATCH } from '@/modules/editorial/constants/routing'; + +import { getChannelFromStore } from '../../helpers/channels'; +import * as watchesSelectors from '../selectors'; +import { updatePlaylistAction } from '../slices'; +import { targetClipIdSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { targetClipIdEventAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(targetClipIdEventAction.type), + filter(() => targetClipIdSelector(state$.value) && Router.query[QUERY_PARAM_TAB] === TAB_WATCH), + mergeMap(() => { + const targetClipId = targetClipIdSelector(state$.value); + const channel = getChannelFromStore(state$.value); + + if (!channel?.playlistId) { + return of( + showNotificationAction({ + type: TYPE_ERROR, + message: "The channel doesn't have playlist id", + }), + ); + } + + const playlistsEntities = watchesSelectors.playlistsEntitiesSelector(state$.value); + const playlist = playlistsEntities[channel.playlistId] ?? null; + + if (!playlist?.publisherId) { + return EMPTY; + } + + if (playlist?.clips?.includes(targetClipId)) { + return of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'The video is already in the playlist', + }), + ); + } + + const newClips = [...(playlist?.clips ?? []), targetClipId]; + + return of( + updatePlaylistAction({ + playlistId: channel.playlistId, + clips: newClips, + publisherId: playlist.publisherId, + }), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/copyWatchChannel.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/copyWatchChannel.js new file mode 100644 index 0000000..12ebb69 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/copyWatchChannel.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { watchesSelector } from '../selectors'; +import { copyChannelAction, copyWatchChannelAction, getWatchesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query CopyWatchChannel( + $fromChannelId: Int!, + $toWatchId: Int!, + ) { + copyWatchChannel( + fromChannelId: $fromChannelId, + toWatchId: $toWatchId, + ) { + id + title + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(copyWatchChannelAction.type), + mergeMap(({ payload: { fromChannelId, toWatchId } }) => { + const watches = watchesSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + fromChannelId, + toWatchId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const targetWatch = watches.find((watch) => watch.id === toWatchId); + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: `Channel pasted as ${data.copyWatchChannel.title} into the ${targetWatch.title} Watch`, + }), + ), + of(copyChannelAction()), + of(getWatchesAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/createAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/createAiPlaylist.js new file mode 100644 index 0000000..d910492 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/createAiPlaylist.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { setAiPlaylistModeAction } from '../../../../TabPlaylist/redux/slices'; +import { createAiPlaylistAction, updatePlaylistAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { + configAction, + configChangedEventAction, + isFilterDirtyAction, +} from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; +import { playlistsEntitiesSelector } from '@/modules/editorial/RightSideBar/TabWatch/Watches/redux/selectors'; + +import { getDefaultConfig, getEmptySearchOrderConfig } from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +const queryGQL = ` + mutation createAiPlaylist( + $watchId: Int!, + $channelId: Int!, + $type: String!, + $length: Int, + ) { + createAiPlaylist( + watchId: $watchId, + channelId: $channelId, + type: $type, + length: $length, + ) { + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createAiPlaylistAction.type), + concatMap(({ payload: { watchId, channelId, playlistId, publisherId, type } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + channelId, + type, + }, + }).pipe( + concatMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const config = getDefaultConfig(); + + const defaultOrderConfig = getEmptySearchOrderConfig(); + + const filterConfig = { + ...config, + ...defaultOrderConfig, + }; + + const playlists = playlistsEntitiesSelector(state$.value); + const playlist = playlists[playlistId]; + + actions.push( + of(configAction(filterConfig)), + of(configChangedEventAction()), + of(isFilterDirtyAction(false)), + of( + updatePlaylistAction({ + clips: [...(playlist.clips ?? []), `${data.createAiPlaylist.id}`], + playlistId, + publisherId, + callBackAction: () => setAiPlaylistModeAction(true), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteAiPlaylist.js new file mode 100644 index 0000000..abeec6c --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteAiPlaylist.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { deleteAiPlaylistAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { configChangedEventAction } from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; + +const queryGQL = ` + mutation deleteAiPlaylist( + $watchId: Int!, + $channelId: Int!, + $aiPlaylistId: Int!, + ) { + deleteAiPlaylist( + watchId: $watchId, + channelId: $channelId, + aiPlaylistId: $aiPlaylistId, + ) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(deleteAiPlaylistAction.type), + switchMap(({ payload: { watchId, channelId, aiPlaylistId } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + channelId, + aiPlaylistId, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(configChangedEventAction())); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteChannel.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteChannel.js new file mode 100644 index 0000000..31ab788 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteChannel.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as watchesSelectors from '../selectors'; +import { deleteChannelAction, setWatchesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation DeleteChannel($watchId: Int!, $id: Int!) { + deleteChannel(watchId: $watchId, id: $id){ + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deleteChannelAction.type), + switchMap(({ payload: { watchId, id } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + id, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + const watches = watchesSelectors.watchesSelector(state$.value); + + const newWatches = watches.map((watch) => { + if (watch.id === watchId) { + const watchChannels = watch.watchChannels.filter((channel) => channel.id !== id); + + return { + ...watch, + watchChannels, + }; + } + + return watch; + }); + + actions.push(of(setWatchesAction(newWatches))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteWatch.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteWatch.js new file mode 100644 index 0000000..d748b52 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/deleteWatch.js @@ -0,0 +1,42 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as watchesSelectors from '../selectors'; +import { deleteWatchAction, setWatchesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation DeleteWatch($id: Int!) { + deleteWatch(id: $id){ + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deleteWatchAction.type), + switchMap(({ payload: { id } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + const watches = watchesSelectors.watchesSelector(state$.value); + const newWatches = watches.filter((watch) => watch.id !== id); + actions.push(of(setWatchesAction(newWatches))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/dragAndDrop.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/dragAndDrop.js new file mode 100644 index 0000000..5b2545d --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/dragAndDrop.js @@ -0,0 +1,124 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { + DND_MAIN_LIST_OF_VIDEO_TYPE, + DND_PLAYLIST_TYPE, + DND_PLAYLIST_VIDEO_TYPE, +} from '@/modules/editorial/constants/dnd'; +import { QUERY_PARAM_TAB, TAB_WATCH } from '@/modules/editorial/constants/routing'; + +import { getChannelFromStore } from '../../helpers/channels'; +import * as watchesSelectors from '../selectors'; +import { setWatchesAction, updateChannelAction, updatePlaylistAction } from '../slices'; +import { dragAndDropEndSelector, videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { dragAndDropEndAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { parseDndId } from '@/modules/editorial/helpers/createDndId'; +import { reorderVideos } from '@/modules/editorial/RightSideBar/common/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(dragAndDropEndAction.type), + filter(() => Router.query[QUERY_PARAM_TAB] === TAB_WATCH), + filter(() => !!dragAndDropEndSelector(state$.value)?.over?.id), + mergeMap(() => { + const { active, over } = dragAndDropEndSelector(state$.value); + + const videos = videosSelector(state$.value); + + const channel = getChannelFromStore(state$.value); + let playlist = null; + + if (channel) { + const playlistsEntities = watchesSelectors.playlistsEntitiesSelector(state$.value); + playlist = playlistsEntities[channel.playlistId] ?? null; + } + + const actions = []; + + const activeMetadata = active?.id ? parseDndId(active.id) : {}; + const overMetadata = over?.id ? parseDndId(over.id) : {}; + + // dnd channels + // metadataObject = { type, watchId, channelId, itemIndex } + if ( + activeMetadata.type === DND_PLAYLIST_TYPE && + overMetadata.type === DND_PLAYLIST_TYPE && + activeMetadata.watchId === overMetadata.watchId + ) { + const watches = watchesSelectors.watchesSelector(state$.value); + const newWatches = watches?.map((watch) => { + if (watch.id === +activeMetadata.watchId) { + const watchChannels = reorderVideos(watch.watchChannels, activeMetadata.itemIndex, overMetadata.itemIndex); + + return { + ...watch, + watchChannels, + }; + } + + return watch; + }); + + actions.push( + of(setWatchesAction(newWatches)), + of( + updateChannelAction({ + id: +activeMetadata.channelId, + watchId: +activeMetadata.watchId, + order: +overMetadata.itemIndex + 1, + }), + ), + ); + } + + // dnd videos inside playlist + // metadataObject = { type, channelId, distributionId, itemIndex } + if (activeMetadata.type === DND_PLAYLIST_VIDEO_TYPE && overMetadata.type === DND_PLAYLIST_VIDEO_TYPE) { + const items = reorderVideos(playlist?.clips ?? [], activeMetadata.itemIndex, overMetadata.itemIndex); + + actions.push( + of( + updatePlaylistAction({ + playlistId: channel.playlistId, + clips: items ?? [], + publisherId: playlist.publisherId, + }), + ), + ); + } + + // dnd video from search results to channel + // metadataObject = { type, videUid, distributionId, itemIndex } + if (activeMetadata.type === DND_MAIN_LIST_OF_VIDEO_TYPE && overMetadata.type === DND_PLAYLIST_VIDEO_TYPE) { + const targetClip = videos[activeMetadata.itemIndex]; + if (playlist?.clips?.includes(targetClip?.distributionId)) { + return of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'The video is already in the channel', + }), + ); + } + + const newClips = [...(playlist?.clips ?? [])]; + newClips.splice(overMetadata.itemIndex, 0, targetClip.distributionId); + + actions.push( + of( + updatePlaylistAction({ + playlistId: channel.playlistId, + clips: newClips, + publisherId: playlist.publisherId, + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiFilters.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiFilters.js new file mode 100644 index 0000000..8271e59 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiFilters.js @@ -0,0 +1,388 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { TRENDING } from '@/modules/editorial/constants/video'; +import { FILTER_COMPONENTS_NAMES, VIDEO_DURATION_LABELS } from '@/modules/editorial/editorialSearchFilter/constants'; + +import { getVideoDurationFilterEnumValue } from '../../../../common/helpers'; +import { aiPlaylistSelector } from '../selectors'; +import { getAiFiltersAction, setAiFiltersAction } from '../slices'; +import { getLanguageOptions } from '@/modules/@common/helpers/videoLangs'; +import { findNodes, getIAB } from '@/modules/@common/iab/helpers'; +import { gqlRequest } from '@/modules/@common/request'; +import { + configAction, + configChangedEventAction, + isFilterDirtyAction, +} from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; + +import { + getDefaultConfig, + getEmptySearchOrderConfig, + timeValueMap, +} from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +const queryGQL = ` + query getAiFilters( + $watchId: Int!, + $channelId: Int!, + $aiPlaylistId: Int!, + ) { + getAiFilters( + watchId: $watchId, + channelId: $channelId, + aiPlaylistId: $aiPlaylistId, + ) { + id + videoFilterValues { + id + videoFilterId + type + value + action + extra + color + name + } + } + } +`; + +const ACTIONS = { + INCLUDE: true, + EXCLUDE: false, +}; + +const configUpdater = { + IAB: (config, { value, action }) => ({ + ...config, + categories: { + ...config.categories, + filters: [ + { + ...config.categories.filters[0], + value: [ + ...config.categories.filters[0].value, + { + id: findNodes(getIAB(), [value])[0].id, + name: findNodes(getIAB(), [value])[0].name, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + PEOPLE: (config, { value, action }) => ({ + ...config, + people: { + ...config.people, + filters: [ + { + ...config.people.filters[0], + value: [ + ...config.people.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + BRANDS: (config, { value, action }) => ({ + ...config, + brands: { + ...config.brands, + filters: [ + { + ...config.brands.filters[0], + value: [ + ...config.brands.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + BRAND_SAFETY: (config, { value, action }) => ({ + ...config, + brandSafety: { + ...config.brandSafety, + filters: [ + { + ...config.brandSafety.filters[0], + value: [ + ...config.brandSafety.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + LABEL: (config, { value, action, color, name, extra }) => ({ + ...config, + labels: { + ...config.labels, + filters: [ + { + ...config.labels.filters[0], + value: [ + ...config.labels.filters[0].value, + { + value: value.split(':').slice(1).join(':'), + label: value.split(':').slice(1).join(':'), + labelId: extra, + name, + color, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + KEYWORDS: (config, { value, action, color, name, extra }) => ({ + ...config, + keywords: { + ...config.keywords, + filters: [ + { + ...config.keywords.filters[0], + value: [ + ...config.keywords.filters[0].value, + { + value, + label: value, + labelId: extra, + name, + color, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + LANG: (config, { value, action }) => ({ + ...config, + languages: { + ...config.languages, + filters: [ + { + ...config.languages.filters[0], + value: [ + ...config.languages.filters[0].value, + { + value, + label: getLanguageOptions().find((lang) => lang.value === value).label, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + FEED: (config, { value, action }) => ({ + ...config, + source: { + ...config.source, + filters: [ + { + ...config.source.filters[0], + value: [ + ...config.source.filters[0].value, + { + value, + label: value, + include: ACTIONS[action], + }, + ], + }, + ], + }, + }), + EVERGREEN: (config, { value }) => ({ + ...config, + evergreen: { + ...config.evergreen, + filters: [ + { + ...config.evergreen.filters[0], + value: [ + { + value: value === 'true' ? 'EVERGREEN' : 'NO_EVERGREEN', + label: value === 'true' ? 'Evergreen' : 'Non Evergreen', + }, + ], + }, + ], + }, + }), + TARGETING_STATUS: (config, { value }) => ({ + ...config, + targetingStatus: { + ...config.targetingStatus, + filters: [ + { + ...config.targetingStatus.filters[0], + value: [{ label: 'Video Targeting', value }], + }, + ], + }, + }), + MEDIA_TYPE: (config, { value }) => ({ + ...config, + mediaType: { + ...config.mediaType, + filters: [ + { + ...config.mediaType.filters[0], + value: [{ label: 'Media Type', value }], + }, + ], + }, + }), +}; + +const getUpdatedConfig = (oldConfig, newConfig = []) => { + const updatedConfig = { + ...oldConfig, + }; + + const customTime = { + filters: [ + { + label: 'Custom', + value: {}, + component: FILTER_COMPONENTS_NAMES.FilterTimePicker, + }, + ], + }; + + const config = newConfig.reduce((acc, cur) => { + if (configUpdater[cur.type]) { + return configUpdater[cur.type](acc, cur); + } + return acc; + }, updatedConfig); + + const duration = {}; + + newConfig.forEach((item) => { + if (item.type === 'TIME') { + config.time = { + filters: [timeValueMap[item.value]], + }; + } else if (['START_DATE', 'END_DATE'].includes(item.type)) { + customTime.filters[0].value[item.type === 'START_DATE' ? 'startDate' : 'endDate'] = dayjs(+item.value) + .toDate() + .getTime(); + } + + if (item.type === 'LENGTH_TO') { + duration.to = item.value; + } + + if (item.type === 'LENGTH_FROM') { + duration.from = item.value; + } + + if (item.type === 'VIDEO_AFFILIATION' && item.value === 'MY_VIDEOS') { + config.videos = { + filters: [{ label: 'My Videos', value: item.value }], + }; + } + }); + + if (customTime.filters[0].value.startDate) { + config.time = customTime; + } + + const videoDurationFilterEnumValue = getVideoDurationFilterEnumValue(duration); + if (videoDurationFilterEnumValue) { + config.duration = { + filters: [{ label: VIDEO_DURATION_LABELS[videoDurationFilterEnumValue], value: videoDurationFilterEnumValue }], + }; + } + + return config; +}; + +export default (action$, state$) => + action$.pipe( + ofType(getAiFiltersAction.type), + concatMap(({ payload: { watchId, channelId, aiPlaylistId, withSearch = false } }) => { + const aiPlaylist = aiPlaylistSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + channelId, + aiPlaylistId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + if (!errors.length) { + const { videoFilterValues } = data.getAiFilters; + const config = getDefaultConfig(); + + const defaultOrderConfig = getEmptySearchOrderConfig(); + + const filterConfig = { + ...config, + ...defaultOrderConfig, + }; + + const updatedConfig = getUpdatedConfig(filterConfig, videoFilterValues); + + const playlistType = Object.keys(aiPlaylist).find((key) => aiPlaylist[key]?.id === aiPlaylistId); + + if (playlistType) { + actions.push( + of( + setAiFiltersAction({ + [playlistType]: [...videoFilterValues], + }), + ), + ); + } + + if (playlistType === TRENDING) { + updatedConfig.order.filters[0] = { + label: 'Trending', + value: 'trending', + isSortable: false, + }; + } + + if (withSearch) { + actions.push( + of(configAction(updatedConfig)), + of(configChangedEventAction()), + of(isFilterDirtyAction(true)), + ); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiPlaylist.js new file mode 100644 index 0000000..0c14198 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getAiPlaylist.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { getAiFiltersAction, getAiPlaylistAction, setAiPlaylistAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getAiPlaylist( + $watchId: Int!, + $channelId: Int!, + $aiPlaylistId: String! + ) { + getAiPlaylist( + watchId: $watchId, + channelId: $channelId, + aiPlaylistId: $aiPlaylistId + ) { + id + type + length + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getAiPlaylistAction.type), + concatMap((action) => { + const { watchId, channelId, aiPlaylistId } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + channelId, + aiPlaylistId, + }, + }).pipe( + concatMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(setAiPlaylistAction({ [data.getAiPlaylist.type]: data.getAiPlaylist })), + of(getAiFiltersAction({ watchId, channelId, aiPlaylistId: +aiPlaylistId })), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistById.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistById.js new file mode 100644 index 0000000..d12f1d1 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistById.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPlaylistByIdAction, getPlaylistVideosAction, setPlaylistEntityAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getPlaylistById($id: Int!) { + getPlaylistById(id: $id){ + id + status + isPrefix + name + url + createdAt + updatedAt + createdBy + updatedBy + clips + playerAlias + widgetName + pubName + publisherId + playerAspectRatio + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlaylistByIdAction.type), + switchMap(({ payload }) => { + const { playlistId } = payload; + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: playlistId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const playlist = data.getPlaylistById; + + if (playlist.id) { + actions.push( + of( + setPlaylistEntityAction({ + [playlist.id]: playlist, + }), + ), + ); + } + + if (playlist.clips?.length) { + actions.push(of(getPlaylistVideosAction(playlist.clips))); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistVideos.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistVideos.js new file mode 100644 index 0000000..d981eab --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPlaylistVideos.js @@ -0,0 +1,141 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + TITLE_VIDEO_IS_SPONSORED, + TOOLTIP_VIDEO_IS_SPONSORED, +} from '@/modules/editorial/RightSideBar/common/constants'; + +import { getPlaylistVideosAction, setPlaylistVideosEntitiesAction } from '../slices'; +import { getDuration } from '@/modules/@common/helpers/time'; +import { gqlRequest } from '@/modules/@common/request'; +import { isVideoUnsafe } from '@/modules/editorial/editorialVideoInfo/helpers'; +import { isVideoAvailable } from '@/modules/editorial/RightSideBar/common/helpers'; + +const queryGQL = ` + query clipSearch($clipIds: [String], $size: Int) { + clipSearch(clipIds: $clipIds, size: $size){ + totalCount + videos { + uid + distributionId + name + thumbnailUrl + thumbnailFiles { + width + height + file + } + videoLength + status + contentOwner + keywords { + category + value + } + approval { + status + } + aspectRatio + access { + level + } + targetingStatus + sponsored { + sponsored + } + } + } + } +`; + +const defineThumbnail = (thumbnailUrl, thumbnailFiles) => { + const thumbnail360 = thumbnailFiles?.find((thumbnail) => thumbnail.height === 360)?.file; + + return thumbnail360 ?? thumbnailUrl; +}; + +export default (action$) => + action$.pipe( + ofType(getPlaylistVideosAction.type), + switchMap(({ payload: clips }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + clipIds: clips, + size: clips?.length ?? 10, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const videosList = data.clipSearch.videos.map((video$) => ({ + ...video$, + startTime: 0, + endTime: video$.videoLength || 0, + videoStatus: video$.status, + distributionStatus: video$.status, + keywords: video$.keywords, + })); + + const videos = clips.map((id) => { + const video = videosList.find(({ distributionId }) => distributionId === id) || {}; + const isAvailable = isVideoAvailable(video); + + let videoAlertMessage = ''; + let videoAlertMessageTooltip = ''; + + if (!isAvailable) { + videoAlertMessage = 'Video was archived'; + } + + if (video.videoStatus === 'PROCESSING') { + videoAlertMessage = 'Video analysis still in progress'; + } + + if (video?.access?.level === 'PRIVATE') { + videoAlertMessage = 'Private video, access controlled by its owner'; + } + + if (video?.sponsored?.sponsored) { + videoAlertMessage = TITLE_VIDEO_IS_SPONSORED; + videoAlertMessageTooltip = TOOLTIP_VIDEO_IS_SPONSORED; + } + + const isDeleted = !videosList.find(({ distributionId }) => distributionId === id); + + return { + ...video, + distributionId: id, + isAvailable, + isDeleted, + isUnsafe: isVideoUnsafe(video), + isHasTargeting: video.targetingStatus === 'ON', + videoName: video.name ?? '', + videoAlertMessage, + videoAlertMessageTooltip, + videoThumbnailUrl: defineThumbnail(video.thumbnailUrl, video.thumbnailFiles) || '', + videoTime: video?.endTime ? getDuration(video.endTime - video.startTime) : '00:00', + }; + }); + + const videosMap = videos.reduce( + (acc, curr) => ({ + ...acc, + [`${curr.distributionId}`]: curr, + }), + {}, + ); + + actions.push(of(setPlaylistVideosEntitiesAction(videosMap))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPublishers.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPublishers.js new file mode 100644 index 0000000..d876aaf --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getPublishers.js @@ -0,0 +1,49 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPublishersAction, setPublishersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query WatchGetHubs( + $pageSize: Int, + $searchText: String, + ) { + watchGetHubs( + pageSize: $pageSize, + searchText: $searchText + ) { + records { + id + name + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPublishersAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + pageSize: 100, + searchText: action.payload ?? '', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setPublishersAction(data.watchGetHubs.records))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatchEmbedCode.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatchEmbedCode.js new file mode 100644 index 0000000..858c336 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatchEmbedCode.js @@ -0,0 +1,39 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getEmbedCodeAction, setEmbedCodeAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query WatchEmbedCode($id: Int!) { + watchEmbedCode(id: $id){ + data + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getEmbedCodeAction.type), + switchMap(({ payload: { id } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setEmbedCodeAction(data.watchEmbedCode.data))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatches.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatches.js new file mode 100644 index 0000000..ab32c61 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/getWatches.js @@ -0,0 +1,164 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import * as watchesSelectors from '../selectors'; +import { + getNextWatchesAction, + getWatchesAction, + setMrssOnlyAction, + setNextWatchesAction, + setPublisherAction, + setSearchAction, + setSortByAction, + setSortOrderAction, + setStatusAction, + setVerticalOnlyAction, + setWatchesAction, + setWatchesPaginationInfoAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + query WatchesQuery( + $page: Int, + $pageSize: Int, + $publisherIds: [Int], + $search: String, + $status: Int, + $mrssOnly: Boolean, + $verticalOnly: Boolean, + $sortBy: String, + $sortOrder: String + ) { + watches( + page: $page, + pageSize: $pageSize, + publisherIds: $publisherIds, + search: $search, + status: $status, + mrssOnly: $mrssOnly, + verticalOnly: $verticalOnly, + sortBy: $sortBy, + sortOrder: $sortOrder + ) { + recordsTotal + page + pageSize + records { + id + title + status + environment + folderName + createdAt + createdBy + updatedAt + updatedBy + watchChannels { + id + title + size + design + playlistId + createdAt + createdBy + updatedAt + updatedBy + MRSSForExport + } + player { + publisherId + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType( + getWatchesAction.type, + getNextWatchesAction.type, + setPublisherAction.type, + setSearchAction.type, + setSortByAction.type, + setSortOrderAction.type, + setStatusAction.type, + setMrssOnlyAction.type, + setVerticalOnlyAction.type, + ), + debounce((action) => timer(action.type === setSearchAction.type ? 1000 : 0)), + filter(() => !!getToken()), + switchMap(({ type }) => { + const page = watchesSelectors.pageSelector(state$.value); + const pageSize = watchesSelectors.pageSizeSelector(state$.value); + const publisher = watchesSelectors.publisherSelector(state$.value); + const search = watchesSelectors.searchSelector(state$.value); + const status = watchesSelectors.statusSelector(state$.value); + const mrssOnly = watchesSelectors.mrssOnlySelector(state$.value); + const verticalOnly = watchesSelectors.verticalOnlySelector(state$.value); + const sortBy = watchesSelectors.sortBySelector(state$.value); + const sortOrder = watchesSelectors.sortOrderSelector(state$.value); + + const variables = { + page: type === getNextWatchesAction.type ? page + 1 : 1, + pageSize, + search, + sortBy, + sortOrder: sortOrder.toLowerCase(), + }; + + if (publisher?.id) { + variables.publisherIds = [publisher.id]; + } + + if (status) { + variables.status = status.value; + } + + if (mrssOnly) { + variables.mrssOnly = mrssOnly; + } + + if (verticalOnly) { + variables.verticalOnly = verticalOnly; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const { + data: { watches }, + } = response; + + if (type === getNextWatchesAction.type) { + actions.push(of(setNextWatchesAction(watches.records))); + } else { + actions.push(of(setWatchesAction(watches.records))); + } + + actions.push( + of( + setWatchesPaginationInfoAction({ + page: watches.page, + pageSize: watches.pageSize, + recordsTotal: watches.recordsTotal, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/index.js new file mode 100644 index 0000000..109bc97 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/index.js @@ -0,0 +1,41 @@ +import { combineEpics } from 'redux-observable'; + +import addVideoToChannel from './addVideoToChannel'; +import copyWatchChannel from './copyWatchChannel'; +import createAiPlaylist from './createAiPlaylist'; +import deleteAiPlaylist from './deleteAiPlaylist'; +import deleteChannel from './deleteChannel'; +import deleteWatch from './deleteWatch'; +import dragAndDrop from './dragAndDrop'; +import getAiFilters from './getAiFilters'; +import getAiPlaylist from './getAiPlaylist'; +import getPlaylistById from './getPlaylistById'; +import getPlaylistVideos from './getPlaylistVideos'; +import getPublishers from './getPublishers'; +import getWatchEmbedCode from './getWatchEmbedCode'; +import getWatches from './getWatches'; +import updateAiFilters from './updateAiFilters'; +import updateAiPlaylist from './updateAiPlaylist'; +import updateChannel from './updateChannel'; +import updatePlaylist from './updatePlaylist'; + +export default combineEpics( + addVideoToChannel, + copyWatchChannel, + createAiPlaylist, + deleteAiPlaylist, + deleteChannel, + deleteWatch, + dragAndDrop, + getAiFilters, + getAiPlaylist, + getPlaylistById, + getPlaylistVideos, + getPublishers, + getWatchEmbedCode, + getWatches, + updateAiFilters, + updateAiPlaylist, + updateChannel, + updatePlaylist, +); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiFilters.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiFilters.js new file mode 100644 index 0000000..90f8493 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiFilters.js @@ -0,0 +1,235 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { aiPlaylistSelector } from '../selectors'; +import { setAiFiltersAction, updateAiFiltersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { + filterParamsSelector, + searchFiltersSelector, +} from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { timeMap } from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +const queryGQL = ` + mutation createAiFilters( + $watchId: Int!, + $channelId: Int!, + $aiPlaylistId: Int!, + $filters: [AiFilter], + ) { + createAiFilters( + watchId: $watchId, + channelId: $channelId, + aiPlaylistId: $aiPlaylistId, + filters: $filters, + ) { + data { + id + } + } + } +`; + +const INCLUDE = 'INCLUDE'; +const EXCLUDE = 'EXCLUDE'; + +const acceptedParams = { + iab: (filters) => + filters.map((param) => ({ + type: 'IAB', + value: param, + action: INCLUDE, + })), + iabExcludes: (filters) => + filters.map((param) => ({ + type: 'IAB', + value: param, + action: EXCLUDE, + })), + keywordFilters: (filters) => + filters.map((param) => ({ + type: param.category, + value: param.keyword, + action: INCLUDE, + })), + keywordExcludes: (filters) => + filters.map((param) => ({ + type: param.category, + value: param.keyword, + action: EXCLUDE, + })), + labelFilters: (filters) => + filters.map((param) => ({ + type: 'LABEL', + value: `${param.name}:${param.value}`, + color: param.color, + action: INCLUDE, + extra: param.labelId, + })), + labelExcludes: (filters) => + filters.map((param) => ({ + type: 'LABEL', + value: `${param.name}:${param.value}`, + color: param.color, + action: EXCLUDE, + extra: param.labelId, + })), + lang: (filters) => + filters.map((param) => ({ + type: 'LANG', + value: param.toUpperCase(), + action: INCLUDE, + })), + langExcludes: (filters) => + filters.map((param) => ({ + type: 'LANG', + value: param.toUpperCase(), + action: EXCLUDE, + })), + feedDescription: (filters) => + filters.map((param) => ({ + type: 'FEED', + value: param, + action: INCLUDE, + })), + feedDescriptionExcludes: (filters) => + filters.map((param) => ({ + type: 'FEED', + value: param, + action: EXCLUDE, + })), + evergreen: (filter) => [ + { + type: 'EVERGREEN', + value: filter.toString(), + action: INCLUDE, + }, + ], + lengthFrom: (filter) => [ + { + type: 'LENGTH_FROM', + value: `${filter}`, + action: INCLUDE, + }, + ], + lengthTo: (filter) => [ + { + type: 'LENGTH_TO', + value: `${filter}`, + action: INCLUDE, + }, + ], + videoAffiliation: (filter) => [ + { + type: 'VIDEO_AFFILIATION', + value: filter, + action: INCLUDE, + }, + ], + videoTab: (filter) => [ + { + type: 'VIDEO_OWNERSHIP', + value: filter, + action: INCLUDE, + }, + ], + targetingStatus: (filter) => [ + { + type: 'TARGETING_STATUS', + value: filter, + action: INCLUDE, + }, + ], + mediaType: (filter) => [ + { + type: 'MEDIA_TYPE', + value: filter, + action: INCLUDE, + }, + ], +}; + +export default (action$, state$) => + action$.pipe( + ofType(updateAiFiltersAction.type), + switchMap(({ payload: { watchId, channelId, aiPlaylistId } }) => { + const aiPlaylists = aiPlaylistSelector(state$.value); + const filterParams = filterParamsSelector(state$.value); + const { time } = searchFiltersSelector(state$.value); + + const filtersToSend = Object.keys(filterParams) + .filter((param) => Object.keys(acceptedParams).includes(param)) + .reduce((acc, cur) => [...acc, ...acceptedParams[cur](filterParams[cur])], []); + + if (timeMap[time.filters[0]?.value]) { + filtersToSend.push({ + type: 'TIME', + value: timeMap[time.filters[0].value], + action: INCLUDE, + }); + } else if (filterParams.startDate && filterParams.endDate) { + const { startDate, endDate } = filterParams; + filtersToSend.push( + { + type: 'START_DATE', + value: startDate.toString(), + action: INCLUDE, + }, + { + type: 'END_DATE', + value: endDate.toString(), + action: INCLUDE, + }, + ); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + channelId, + aiPlaylistId, + filters: filtersToSend.map((item) => + Object.keys(item).reduce((acc, key) => { + if (key !== 'color') { + acc[key] = item[key]; + } + + return acc; + }, {}), + ), + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + const playlistType = Object.keys(aiPlaylists).find((key) => aiPlaylists[key]?.id === aiPlaylistId); + + actions.push( + of( + setAiFiltersAction({ + [playlistType]: [...filtersToSend], + }), + ), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Filters saved', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiPlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiPlaylist.js new file mode 100644 index 0000000..488e0d0 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateAiPlaylist.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { setAiPlaylistAction, updateAiPlaylistAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + mutation updateAiPlaylist( + $watchId: Int!, + $channelId: Int!, + $aiPlaylistId: String!, + $length: Int! + ) { + updateAiPlaylist( + watchId: $watchId, + channelId: $channelId, + aiPlaylistId: $aiPlaylistId, + length: $length + ) { + id + type + length + } + } +`; + +const getResponse = ({ data: { updateAiPlaylist: aiPlaylist } }) => aiPlaylist; + +export default (action$) => + action$.pipe( + ofType(updateAiPlaylistAction.type), + debounceTime(+process.env.APP_CLEAR_TIMEOUT), + filter(() => !!getToken()), + switchMap((action) => { + const { watchId, channelId, aiPlaylistId, length } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + channelId, + aiPlaylistId, + length, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const aiPlaylist = getResponse(response); + + actions.push(of(setAiPlaylistAction({ [aiPlaylist.type]: aiPlaylist }))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateChannel.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateChannel.js new file mode 100644 index 0000000..988165a --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updateChannel.js @@ -0,0 +1,39 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { updateChannelAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation UpdateChannel( + $id: Int!, + $watchId: Int!, + $order: Int + ) { + updateChannel( + id: $id, + watchId: $watchId, + order: $order + ) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(updateChannelAction.type), + switchMap(({ payload: { watchId, id, order } }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + watchId, + id, + order, + }, + }).pipe(switchMap(() => EMPTY)); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updatePlaylist.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updatePlaylist.js new file mode 100644 index 0000000..9632bd3 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/epics/updatePlaylist.js @@ -0,0 +1,86 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { concatMap, filter } from 'rxjs/operators'; + +import { playlistsEntitiesSelector } from '../selectors'; +import { + getPlaylistByIdAction, + getPlaylistVideosAction, + setPlaylistEntityAction, + updatePlaylistAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation updatePlaylist( + $playlistId: Int!, + $publisherId: Int, + $name: String, + $clips: [String], + $playerId: Int, + $isPrefix: Boolean, + $status: Boolean + ) { + updatePlaylist( + playlistId: $playlistId, + publisherId: $publisherId, + name: $name, + clips: $clips, + playerId: $playerId, + isPrefix: $isPrefix, + status: $status + ){ + playlistId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updatePlaylistAction.type), + filter(({ payload }) => payload.publisherId), + concatMap(({ payload: { playlistId, callBackAction = null, ...restParams } }) => { + const playlistsEntities = playlistsEntitiesSelector(state$.value); + + const data = { ...restParams }; + + if (restParams.clips) { + data.clips = restParams.clips.filter((id) => id); + } + + const playlist = playlistsEntities[playlistId]; + const updatedPlaylist = { ...playlist, ...data }; + + if (restParams.aiPlaylist) { + updatedPlaylist.clips = [`${restParams.aiPlaylist}`, ...updatedPlaylist.clips]; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...data, + playlistId, + }, + }).pipe( + concatMap(() => { + const actions = []; + + if (callBackAction) { + actions.push(of(callBackAction())); + } + + if (!playlist) { + return concat(...actions, of(getPlaylistByIdAction({ playlistId }))); + } + + if (updatedPlaylist?.clips?.length) { + return concat(...actions, of(getPlaylistVideosAction(updatedPlaylist.clips))); + } + + return EMPTY; + }), + ); + + return concat(of(setPlaylistEntityAction({ [playlist.id]: updatedPlaylist })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/selectors/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/selectors/index.js new file mode 100644 index 0000000..26f9aa2 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/selectors/index.js @@ -0,0 +1,25 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const copiedChannelIdSelector = (state) => state[nameSpace].copiedChannelId; +export const copiedChannelDesignSelector = (state) => state[nameSpace].copiedChannelDesign; +export const pageSelector = (state) => state[nameSpace].page; +export const pageSizeSelector = (state) => state[nameSpace].pageSize; +export const recordsTotalSelector = (state) => state[nameSpace].recordsTotal; +export const watchesSelector = (state) => state[nameSpace].watches; +export const watchIdSelector = (state) => state[nameSpace].watchId; +export const channelIdSelector = (state) => state[nameSpace].channelId; +export const playlistsEntitiesSelector = (state) => state[nameSpace].playlistsEntities; +export const videosEntitiesSelector = (state) => state[nameSpace].videosEntities; +export const embedCodeSelector = (state) => state[nameSpace].embedCode; +export const publishersSelector = (state) => state[nameSpace].publishers; +export const publisherSelector = (state) => state[nameSpace].publisher; +export const searchSelector = (state) => state[nameSpace].search; +export const statusSelector = (state) => state[nameSpace].status; +export const mrssOnlySelector = (state) => state[nameSpace].mrssOnly; +export const verticalOnlySelector = (state) => state[nameSpace].verticalOnly; +export const sortBySelector = (state) => state[nameSpace].sortBy; +export const sortOrderSelector = (state) => state[nameSpace].sortOrder; +export const aiPlaylistSelector = (state) => state[nameSpace].aiPlaylist; +export const aiFiltersSelector = (state) => state[nameSpace].aiFilters; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/slices/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/slices/index.js new file mode 100644 index 0000000..dfc8c38 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/Watches/redux/slices/index.js @@ -0,0 +1,191 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { SORT_DESC } from '@/modules/@common/constants/sort'; +import { RECENT, TRENDING } from '@/modules/editorial/constants/video'; + +import { SORTERS } from '../../helpers'; + +const initialState = { + page: 1, + pageSize: 10, + recordsTotal: 0, + watches: [], + watchId: null, + channelId: null, + playlistsEntities: {}, + videosEntities: {}, + embedCode: '', + publishers: [], + publisher: null, + search: '', + copiedChannelId: null, + copiedChannelDesign: null, + status: null, + mrssOnly: false, + verticalOnly: false, + sortBy: SORTERS[1].value, + sortOrder: SORT_DESC, + aiPlaylist: { + [RECENT]: null, + [TRENDING]: null, + }, + aiFilters: { + [RECENT]: [], + [TRENDING]: [], + }, +}; + +export const slice = createSlice({ + name: '@@EDITORIAL/SIDEBAR/WATCHES', + initialState, + reducers: { + getWatchesAction: (state) => state, + getNextWatchesAction: (state) => state, + deleteWatchAction: (state) => state, + deleteChannelAction: (state) => state, + getPlaylistByIdAction: (state) => state, + updatePlaylistAction: (state) => state, + getPlaylistVideosAction: (state) => state, + getEmbedCodeAction: (state) => state, + updateChannelAction: (state) => state, + getPublishersAction: (state) => state, + copyWatchChannelAction: (state) => state, + setWatchesAction: (state, action) => { + state.watches = action.payload; + }, + setNextWatchesAction: (state, action) => { + state.watches = [...state.watches, ...action.payload]; + }, + setWatchesPaginationInfoAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + openChannelAction: (state, action) => { + state.channelId = action.payload.channelId; + state.watchId = action.payload.watchId; + }, + setPlaylistEntityAction: (state, action) => { + state.playlistsEntities = { + ...state.playlists, + ...action.payload, + }; + }, + setPlaylistVideosEntitiesAction: (state, action) => { + state.videosEntities = { + ...state.videosEntities, + ...action.payload, + }; + }, + setPlaylistVideosEntityAttributeAction: (state, action) => { + const { uid, attribute } = action.payload; + + state.videosEntities = Object.entries(state.videosEntities).reduce((acc, [key, entity]) => { + acc[key] = entity.uid === uid ? { ...entity, ...attribute } : entity; + return acc; + }, {}); + }, + setEmbedCodeAction: (state, action) => { + state.embedCode = action.payload; + }, + setPublishersAction: (state, action) => { + state.publishers = action.payload; + }, + setPublisherAction: (state, action) => { + state.publisher = action.payload; + }, + setSearchAction: (state, action) => { + state.search = action.payload; + }, + setStatusAction: (state, action) => { + state.status = action.payload; + }, + setMrssOnlyAction: (state, action) => { + state.mrssOnly = action.payload; + }, + setVerticalOnlyAction: (state, action) => { + state.verticalOnly = action.payload; + }, + setSortByAction: (state, action) => { + state.sortBy = action.payload; + }, + setSortOrderAction: (state, action) => { + state.sortOrder = action.payload; + }, + copyChannelAction: (state, action) => { + state.copiedChannelId = action.payload?.id ?? null; + state.copiedChannelDesign = action.payload?.design ?? null; + }, + getAiPlaylistAction: (state) => state, + setAiPlaylistAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.aiPlaylist[key] = action.payload[key]; + }); + }, + createAiPlaylistAction: (state) => state, + updateAiPlaylistAction: (state) => state, + deleteAiPlaylistAction: (state) => state, + clearAiPlaylistAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.aiPlaylist[key] = action.payload[key]; + }); + }, + getAiFiltersAction: (state) => state, + setAiFiltersAction: (state, action) => { + state.aiFilters = { + ...state.aiFilters, + ...action.payload, + }; + }, + updateAiFiltersAction: (state) => state, + clearAiFiltersAction: (state, action) => { + state.aiFilters = { + ...state.aiFilters, + ...action.payload, + }; + }, + }, +}); + +export const { + copyChannelAction, + copyWatchChannelAction, + deleteChannelAction, + deleteWatchAction, + getEmbedCodeAction, + getNextWatchesAction, + getPlaylistByIdAction, + getPlaylistVideosAction, + getPublishersAction, + getWatchesAction, + openChannelAction, + setEmbedCodeAction, + setNextWatchesAction, + setPlaylistEntityAction, + setPlaylistVideosEntitiesAction, + setPublisherAction, + setPublishersAction, + setSearchAction, + setStatusAction, + setMrssOnlyAction, + setVerticalOnlyAction, + setSortByAction, + setSortOrderAction, + setWatchesAction, + setWatchesPaginationInfoAction, + updateChannelAction, + updatePlaylistAction, + getAiPlaylistAction, + setAiPlaylistAction, + createAiPlaylistAction, + updateAiPlaylistAction, + deleteAiPlaylistAction, + clearAiPlaylistAction, + getAiFiltersAction, + setAiFiltersAction, + updateAiFiltersAction, + clearAiFiltersAction, + setPlaylistVideosEntityAttributeAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/constants/index.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/constants/index.js new file mode 100644 index 0000000..5d7f610 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/constants/index.js @@ -0,0 +1,6 @@ +import { DESIGN_INLINE, DESIGN_INLINE_VERTICAL } from '../ChannelEdit/constants'; + +export const uniqueChannelsMessages = { + [DESIGN_INLINE]: 'Only one Inline Player channel can be added to a Watch', + [DESIGN_INLINE_VERTICAL]: 'Only one Inline Vertical channel can be added to a Watch', +}; diff --git a/anyclip/src/modules/editorial/RightSideBar/TabWatch/helpers/permissions.js b/anyclip/src/modules/editorial/RightSideBar/TabWatch/helpers/permissions.js new file mode 100644 index 0000000..bd9b704 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/TabWatch/helpers/permissions.js @@ -0,0 +1,13 @@ +import { + PCN_DELETE_MANAGE_WATCH, + PCN_POST_MANAGE_WATCH, + PCN_PUT_MANAGE_WATCH, + VIDEO_SPONSORED_CONTENT, +} from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +export const canCreate = (userPermissions) => hasPermission(PCN_POST_MANAGE_WATCH, userPermissions); +export const canEdit = (userPermissions) => hasPermission(PCN_PUT_MANAGE_WATCH, userPermissions); +export const canDelete = (userPermissions) => hasPermission(PCN_DELETE_MANAGE_WATCH, userPermissions); +export const canShowPromotionsTab = (userPermissions) => hasPermission(VIDEO_SPONSORED_CONTENT, userPermissions); diff --git a/anyclip/src/modules/editorial/RightSideBar/common/constants/index.js b/anyclip/src/modules/editorial/RightSideBar/common/constants/index.js new file mode 100644 index 0000000..43769df --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/common/constants/index.js @@ -0,0 +1,33 @@ +export const AI_BLOCK_WATCH = 'AI_BLOCK_WATCH'; +export const AI_BLOCK_PLAYLIST = 'AI_BLOCK_PLAYLIST'; + +export const MODE_LIST = 'MODE_LIST'; +export const MODE_PLAYLIST = 'MODE_PLAYLIST'; + +export const WATCH_STATUS_ACTIVE = 1; +export const WATCH_STATUS_INACTIVE = 0; + +export const WATCH_STATUSES = [ + { label: 'Active', value: WATCH_STATUS_ACTIVE }, + { label: 'Inactive', value: WATCH_STATUS_INACTIVE }, +]; + +export const PLAYLIST_STATUS_ENABLED = 'Enabled'; +export const PLAYLIST_STATUS_DISABLED = 'Disabled'; + +export const PLAYLIST_STATUSES = [ + { label: 'Active', value: PLAYLIST_STATUS_ENABLED }, + { label: 'Inactive', value: PLAYLIST_STATUS_DISABLED }, +]; + +export const SORTERS = [ + { label: 'Name', value: 'Name' }, + { label: 'Date', value: 'Date' }, + { label: 'User', value: 'User' }, + { label: 'URL', value: 'URL' }, +]; + +export const MAX_PLAYLIST_NAME_LENGTH = 100; + +export const TITLE_VIDEO_IS_SPONSORED = 'The video is Sponsored'; +export const TOOLTIP_VIDEO_IS_SPONSORED = 'Manually added Sponsored videos aren’t shown in the Player'; diff --git a/anyclip/src/modules/editorial/RightSideBar/common/helpers/index.js b/anyclip/src/modules/editorial/RightSideBar/common/helpers/index.js new file mode 100644 index 0000000..d75fdf6 --- /dev/null +++ b/anyclip/src/modules/editorial/RightSideBar/common/helpers/index.js @@ -0,0 +1,35 @@ +import { VIDEO_DURATION_VALUES } from '@/modules/editorial/editorialSearchFilter/constants'; + +export const getVideoDurationFilterEnumValue = (duration) => { + if (!Object.values(duration).length) { + return ''; + } + + return Object.entries(VIDEO_DURATION_VALUES).reduce((acc, item) => { + const [name, value] = item; + + if (value.from === +duration.from && (!value.to || value.to === +duration.to)) { + return name; + } + return acc; + }, ''); +}; + +export const isVideoAvailable = (clip) => { + if (typeof clip !== 'object') { + return false; + } + + const { distributionStatus, videoStatus, contentOwner } = clip; + + return !(distributionStatus !== 'ACTIVE' || videoStatus !== 'ACTIVE' || !contentOwner); +}; + +export const reorderVideos = (list, startIndex, endIndex) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; +}; + +export default {}; diff --git a/src/modules/editorial/RightSideBar/index.jsx b/anyclip/src/modules/editorial/RightSideBar/index.jsx similarity index 100% rename from src/modules/editorial/RightSideBar/index.jsx rename to anyclip/src/modules/editorial/RightSideBar/index.jsx diff --git a/src/modules/editorial/RightSideBar/styles.module.scss b/anyclip/src/modules/editorial/RightSideBar/styles.module.scss similarity index 100% rename from src/modules/editorial/RightSideBar/styles.module.scss rename to anyclip/src/modules/editorial/RightSideBar/styles.module.scss diff --git a/src/modules/editorial/TagEditor/components/CategoryColors/CategoryColors.module.scss b/anyclip/src/modules/editorial/TagEditor/components/CategoryColors/CategoryColors.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/CategoryColors/CategoryColors.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/CategoryColors/CategoryColors.module.scss diff --git a/src/modules/editorial/TagEditor/components/CategoryColors/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/CategoryColors/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/CategoryColors/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/CategoryColors/index.jsx diff --git a/src/modules/editorial/TagEditor/components/TagCreate/TagCreate.module.scss b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/TagCreate.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/TagCreate.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/TagCreate.module.scss diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.jsx b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.jsx rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.jsx diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.module.scss b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/CreateCategory.module.scss diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/SelectCreateCategory.module.scss b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/SelectCreateCategory.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/SelectCreateCategory.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/SelectCreateCategory.module.scss diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateCategory/index.jsx diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/WithCategory.module.scss b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/WithCategory.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/WithCategory.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/WithCategory.module.scss diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.jsx b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.jsx rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.jsx diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.module.scss b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/components/IabSelect.module.scss diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithCategory/index.jsx diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/WithoutCategory.module.scss b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/WithoutCategory.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/WithoutCategory.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/WithoutCategory.module.scss diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/components/WithoutCategory/index.jsx diff --git a/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/components/SelectCreateTag/index.jsx diff --git a/src/modules/editorial/TagEditor/components/TagCreate/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/TagCreate/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/TagCreate/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/TagCreate/index.jsx diff --git a/src/modules/editorial/TagEditor/components/TagEditor.module.scss b/anyclip/src/modules/editorial/TagEditor/components/TagEditor.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/TagEditor.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/TagEditor.module.scss diff --git a/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/EditCategory.module.scss b/anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/EditCategory.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/EditCategory.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/EditCategory.module.scss diff --git a/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditCategory/index.jsx diff --git a/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/Edit.module.scss b/anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/Edit.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/Edit.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/Edit.module.scss diff --git a/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/components/EditTag/index.jsx diff --git a/src/modules/editorial/TagEditor/components/Tags/CustomTags/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/CustomTags/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/index.jsx diff --git a/src/modules/editorial/TagEditor/components/Tags/CustomTags/index.module.scss b/anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/index.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/CustomTags/index.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/Tags/CustomTags/index.module.scss diff --git a/src/modules/editorial/TagEditor/components/Tags/EmptyTag/EmptyTag.module.scss b/anyclip/src/modules/editorial/TagEditor/components/Tags/EmptyTag/EmptyTag.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/EmptyTag/EmptyTag.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/Tags/EmptyTag/EmptyTag.module.scss diff --git a/src/modules/editorial/TagEditor/components/Tags/EmptyTag/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/Tags/EmptyTag/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/EmptyTag/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/Tags/EmptyTag/index.jsx diff --git a/src/modules/editorial/TagEditor/components/Tags/Layout/Layout.module.scss b/anyclip/src/modules/editorial/TagEditor/components/Tags/Layout/Layout.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/Layout/Layout.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/Tags/Layout/Layout.module.scss diff --git a/src/modules/editorial/TagEditor/components/Tags/Layout/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/Tags/Layout/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/Layout/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/Tags/Layout/index.jsx diff --git a/src/modules/editorial/TagEditor/components/Tags/SystemTags/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/Tags/SystemTags/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/SystemTags/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/Tags/SystemTags/index.jsx diff --git a/src/modules/editorial/TagEditor/components/Tags/SystemTags/index.module.scss b/anyclip/src/modules/editorial/TagEditor/components/Tags/SystemTags/index.module.scss similarity index 100% rename from src/modules/editorial/TagEditor/components/Tags/SystemTags/index.module.scss rename to anyclip/src/modules/editorial/TagEditor/components/Tags/SystemTags/index.module.scss diff --git a/src/modules/editorial/TagEditor/components/index.jsx b/anyclip/src/modules/editorial/TagEditor/components/index.jsx similarity index 100% rename from src/modules/editorial/TagEditor/components/index.jsx rename to anyclip/src/modules/editorial/TagEditor/components/index.jsx diff --git a/anyclip/src/modules/editorial/TagEditor/constants/index.js b/anyclip/src/modules/editorial/TagEditor/constants/index.js new file mode 100644 index 0000000..3bc60e2 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/constants/index.js @@ -0,0 +1,96 @@ +import { + CustomBrands, + CustomBrandSafety, + CustomIAB, + CustomKeywords, + CustomPeople, + CustomText, +} from '@/mui/components/CustomIcon'; + +// Tag type +export const TAG_SYSTEM_TYPE = 'SYSTEM'; +export const TAG_CUSTOM_TYPE = 'CUSTOM'; + +// System Tag Categories +export const TAG_SYSTEM_CATEGORY_TEXT = 'TEXT'; +export const TAG_SYSTEM_CATEGORY_CC = 'CC'; +export const TAG_SYSTEM_CATEGORY_PEOPLE = 'PEOPLE'; +export const TAG_SYSTEM_CATEGORY_BRANDS = 'BRANDS'; +export const TAG_SYSTEM_CATEGORY_IAB = 'IAB'; +export const TAG_SYSTEM_CATEGORY_BRAND_SAFETY = 'BRAND_SAFETY'; +export const TAG_SYSTEM_CATEGORY_KEYWORDS = 'KEYWORDS'; +export const TAG_SYSTEM_CATEGORY_CUSTOM = 'CUSTOM'; + +export const SYSTEM_CATEGORY_TAGS_ICON_COMPONENT = { + [TAG_SYSTEM_CATEGORY_PEOPLE]: CustomPeople, + [TAG_SYSTEM_CATEGORY_BRANDS]: CustomBrands, + [TAG_SYSTEM_CATEGORY_IAB]: CustomIAB, + [TAG_SYSTEM_CATEGORY_BRAND_SAFETY]: CustomBrandSafety, + [TAG_SYSTEM_CATEGORY_KEYWORDS]: CustomKeywords, + [TAG_SYSTEM_CATEGORY_TEXT]: CustomText, +}; + +export const SYSTEM_TAGS_CATEGORY_OPTIONS = [ + { + label: 'People', + value: TAG_SYSTEM_CATEGORY_PEOPLE, + iconComponent: SYSTEM_CATEGORY_TAGS_ICON_COMPONENT[TAG_SYSTEM_CATEGORY_PEOPLE], + group: TAG_SYSTEM_TYPE, + }, + { + label: 'Brands', + value: TAG_SYSTEM_CATEGORY_BRANDS, + iconComponent: SYSTEM_CATEGORY_TAGS_ICON_COMPONENT[TAG_SYSTEM_CATEGORY_BRANDS], + group: TAG_SYSTEM_TYPE, + }, + { + label: 'IAB Categories', + value: TAG_SYSTEM_CATEGORY_IAB, + iconComponent: SYSTEM_CATEGORY_TAGS_ICON_COMPONENT[TAG_SYSTEM_CATEGORY_IAB], + group: TAG_SYSTEM_TYPE, + }, + { + label: 'Brands Safety', + value: TAG_SYSTEM_CATEGORY_BRAND_SAFETY, + iconComponent: SYSTEM_CATEGORY_TAGS_ICON_COMPONENT[TAG_SYSTEM_CATEGORY_BRAND_SAFETY], + group: TAG_SYSTEM_TYPE, + }, + { + label: 'Keywords', + value: TAG_SYSTEM_CATEGORY_KEYWORDS, + iconComponent: SYSTEM_CATEGORY_TAGS_ICON_COMPONENT[TAG_SYSTEM_CATEGORY_KEYWORDS], + group: TAG_SYSTEM_TYPE, + }, + { + label: 'Text', + value: TAG_SYSTEM_CATEGORY_TEXT, + iconComponent: SYSTEM_CATEGORY_TAGS_ICON_COMPONENT[TAG_SYSTEM_CATEGORY_TEXT], + group: TAG_SYSTEM_TYPE, + }, +]; + +export const CUSTOM_TAGS_COLORS = [ + '#e45043', + '#fb6923', + '#fca93e', + '#934c20', + '#fa0729', + '#9e0788', + '#f36cc1', + '#b7a0b4', + '#0e3674', + '#2b6db3', + '#5cb0d6', + '#3fd3fc', + '#50e593', + '#68a335', + '#5e7698', +]; + +export const CATEGORY_SIZE = 64; +export const SYSTEM_LABEL_VALUE_SIZE = 64; +export const CUSTOM_LABEL_VALUE_SIZE = 255; + +export const CONTEXT_OPEN_IN_TAG_LOG = 'CONTEXT_OPEN_IN_TAG_LOG'; +export const CONTEXT_OPEN_IN_TAG_LOG_SUMMARY = 'CONTEXT_OPEN_IN_TAG_LOG_SUMMARY'; +export const CONTEXT_OPEN_IN_VIDEO_EDIT_TAGS = 'CONTEXT_OPEN_IN_VIDEO_EDIT_TAGS'; diff --git a/src/modules/editorial/TagEditor/constants/propTypes.js b/anyclip/src/modules/editorial/TagEditor/constants/propTypes.js similarity index 100% rename from src/modules/editorial/TagEditor/constants/propTypes.js rename to anyclip/src/modules/editorial/TagEditor/constants/propTypes.js diff --git a/src/modules/editorial/TagEditor/helpers/index.js b/anyclip/src/modules/editorial/TagEditor/helpers/index.js similarity index 100% rename from src/modules/editorial/TagEditor/helpers/index.js rename to anyclip/src/modules/editorial/TagEditor/helpers/index.js diff --git a/src/modules/editorial/TagEditor/hooks/useTagEditorDialog.js b/anyclip/src/modules/editorial/TagEditor/hooks/useTagEditorDialog.js similarity index 100% rename from src/modules/editorial/TagEditor/hooks/useTagEditorDialog.js rename to anyclip/src/modules/editorial/TagEditor/hooks/useTagEditorDialog.js diff --git a/src/modules/editorial/TagEditor/index.js b/anyclip/src/modules/editorial/TagEditor/index.js similarity index 100% rename from src/modules/editorial/TagEditor/index.js rename to anyclip/src/modules/editorial/TagEditor/index.js diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/createCustomCategory.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/createCustomCategory.js new file mode 100644 index 0000000..ee5d9f8 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/createCustomCategory.js @@ -0,0 +1,66 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { mapApiError } from '@/modules/@common/constants/mapApiError'; + +import { createCustomCategoryAction, getCustomCategoriesOptionsAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation labelCreateMutation($label: LabelInputType!) { + labelCreate(label: $label) { + uid + name + color + } + } +`; + +export default (action$) => + action$.pipe( + ofType(createCustomCategoryAction.type), + switchMap((action) => { + const { + payload: { category, contentOwner }, + } = action; + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { + label: { + name: category.label, + color: category.color, + contentOwnerId: contentOwner, + }, + }, + }, + { + mapError: mapApiError({ + categoryName: category.label, + }), + }, + ).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { uid } = data.labelCreate; + actions.push( + of( + setStateAction({ + selectedCategory: { ...category, id: uid }, + }), + ), + of(getCustomCategoriesOptionsAction({ contentOwner })), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/createSystemTag.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/createSystemTag.js new file mode 100644 index 0000000..c1db049 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/createSystemTag.js @@ -0,0 +1,69 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; + +import { createSystemTagAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation createTaxonomyKeyword( + $category: String!, + $status: String, + $source: String, + $types: [String], + $keywords: [TaxonomyKeywordInputType]) { + createTaxonomyKeyword( + category: $category, + status: $status, + source: $source, + types: $types, + keywords: $keywords + ) { + uid, + category, + status, + source, + types, + keywords { + lang + value + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(createSystemTagAction.type), + switchMap((action) => { + const { value, category, callbackAfterCreate } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + keywords: [{ lang: 'EN', value }], + types: ['OWN_CONTENT'], + status: 'REVIEW', + source: 'OWN_CONTENT', + category, + }, + }).pipe( + tap(({ data, errors }) => { + if (!errors.length) { + const { uid, keywords } = data.createTaxonomyKeyword; + const createdTag = { + id: uid, + label: keywords[0].value, + value: keywords[0].value, + }; + callbackAfterCreate(createdTag); + } + + return EMPTY; + }), + switchMap(() => EMPTY), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/editCustomCategory.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/editCustomCategory.js new file mode 100644 index 0000000..f1dd842 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/editCustomCategory.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; + +import { editCustomCategoryAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation labelUpdateMutation( + $id: String!, + $color: String! + $name: String! + ) { + labelUpdate( + id: $id + color: $color + name: $name + ) { + uid + name + color + } + } +`; + +export default (action$) => + action$.pipe( + ofType(editCustomCategoryAction.type), + switchMap((action) => { + const { id, color, name, callbackAfterCreate } = action.payload; + const categoryForUpdate = { id, color, name }; + const stream$ = gqlRequest({ + query: queryGQL, + variables: categoryForUpdate, + }).pipe( + tap(({ errors }) => { + if (!errors.length) { + callbackAfterCreate(categoryForUpdate); + } + + return EMPTY; + }), + switchMap(() => EMPTY), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomCategoriesOptions.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomCategoriesOptions.js new file mode 100644 index 0000000..fa08103 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomCategoriesOptions.js @@ -0,0 +1,69 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCustomCategoriesOptionsAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query autocompleteLabel( + $accountId: Int, + $prefix: String, + $excludeOrigin: String + ) { + autocompleteLabel( + accountId: $accountId, + prefix: $prefix, + excludeOrigin: $excludeOrigin + ) { + uid + name + color + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCustomCategoriesOptionsAction.type), + switchMap((action) => { + const { accountId, prefix = '' } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + accountId, + prefix, + excludeOrigin: 'RSS', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const customCategoriesOptions = data.autocompleteLabel + .filter((category) => category.name && category.color && category.uid) + .map((category) => ({ + id: category.uid, + label: category.name || '', + value: category.name || '', + color: category.color, + })) + .sort((category, nextCategory) => category.label.localeCompare(nextCategory.label)); + + actions.push( + of( + setStateAction({ + customCategoriesOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsOptions.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsOptions.js new file mode 100644 index 0000000..f9bc19a --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsOptions.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCustomTagsOptionsAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query autocompleteLabelValue( + $accountId: Int, + $prefix: String, + $label: String, + $excludeOrigin: String + ) { + autocompleteLabelValue( + accountId: $accountId, + prefix: $prefix, + label: $label, + excludeOrigin: $excludeOrigin + ) { + value + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCustomTagsOptionsAction.type), + switchMap((action) => { + const { searchText, category, accountId } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + prefix: searchText, + label: category, + accountId, + excludeOrigin: 'RSS', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const tagsOptions = data.autocompleteLabelValue + .filter((tag) => tag.value) + .map((tag) => ({ + label: tag.value, + value: tag.value, + })) + .sort((tag, nextTag) => tag.label.localeCompare(nextTag.label)); + + actions.push( + of( + setStateAction({ + tagsOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsWithCategoryOptions.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsWithCategoryOptions.js new file mode 100644 index 0000000..b0993e7 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/getCustomTagsWithCategoryOptions.js @@ -0,0 +1,82 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCustomTagsWithCategoryOptionsAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query autocompleteLabelGrouped( + $accountId: Int, + $prefix: String!, + $excludeOrigin: String + ) { + autocompleteLabelGrouped( + accountId: $accountId, + prefix: $prefix, + excludeOrigin: $excludeOrigin + ) { + labelGrouped { + name + values { + value + } + color + contentOwnerId + labelId + accountId + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCustomTagsWithCategoryOptionsAction.type), + switchMap((action) => { + const { searchText, accountId } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + prefix: searchText, + accountId, + excludeOrigin: 'RSS', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const tagsOptions = data.autocompleteLabelGrouped.labelGrouped + .reduce((acc, curr) => { + const labels = curr.values + ? curr.values.map((label) => ({ + label: label.value, + value: label.value, + categoryName: curr.name, + categoryLabelId: curr.labelId, + categoryColor: curr.color, + })) + : []; + + return [...acc, ...labels]; + }, []) + .sort((tag, nextTag) => tag.label.localeCompare(nextTag.label)); + + actions.push( + of( + setStateAction({ + tagsOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/getSystemTagsOptions.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/getSystemTagsOptions.js new file mode 100644 index 0000000..d167ac1 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/getSystemTagsOptions.js @@ -0,0 +1,108 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TAG_SYSTEM_CATEGORY_TEXT } from '../../constants'; + +import { getSystemTagsOptionsAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQLTaxonomyAutocomplete = ` + query taxonomyAutocomplete( + $category: String!, + $prefix: String, + $lang: String, + $size: Int + ) { + taxonomyAutocomplete( + category: $category, + prefix: $prefix, + lang: $lang, + size: $size + ) { + uid, + category, + values { + lang + value + suggestions + } + } + } +`; + +const queryGQLAutocompleteKeyword = ` + query autocompleteKeyword( + $category: String!, + $prefix: String, + $size: Int, + $sort: String, + $videoId: String, + $contentOwner: [Float] + ) { + autocompleteKeyword( + category: $category, + prefix: $prefix, + size: $size, + sort: $sort, + videoId: $videoId, + contentOwner: $contentOwner + ) { + value + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getSystemTagsOptionsAction.type), + switchMap((action) => { + const { searchText, category } = action.payload; + const queryGQL = + category === TAG_SYSTEM_CATEGORY_TEXT ? queryGQLAutocompleteKeyword : queryGQLTaxonomyAutocomplete; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + prefix: searchText, + category, + size: 50, + lang: 'EN', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + let tagsOptions = []; + + if (data.taxonomyAutocomplete) { + tagsOptions = data.taxonomyAutocomplete.map((tag) => ({ + id: tag.uid, + label: tag.values[0].value, + value: tag.values[0].value, + })); + } else if (data.autocompleteKeyword) { + tagsOptions = data.autocompleteKeyword.map((tag) => ({ + id: tag.value, + label: tag.value, + value: tag.value, + })); + } + + actions.push( + of( + setStateAction({ + tagsOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/TagEditor/redux/epics/index.js b/anyclip/src/modules/editorial/TagEditor/redux/epics/index.js new file mode 100644 index 0000000..644fb8a --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import createCustomCategory from './createCustomCategory'; +import createSystemTag from './createSystemTag'; +import editCustomCategory from './editCustomCategory'; +import getCustomCategoriesOptions from './getCustomCategoriesOptions'; +import getCustomTagsOptions from './getCustomTagsOptions'; +import getCustomTagsWithCategoryOptions from './getCustomTagsWithCategoryOptions'; +import getSystemTagsOptions from './getSystemTagsOptions'; + +export default combineEpics( + getCustomCategoriesOptions, + createCustomCategory, + getCustomTagsOptions, + getCustomTagsWithCategoryOptions, + getSystemTagsOptions, + createSystemTag, + editCustomCategory, +); diff --git a/src/modules/editorial/TagEditor/redux/selectors/index.js b/anyclip/src/modules/editorial/TagEditor/redux/selectors/index.js similarity index 100% rename from src/modules/editorial/TagEditor/redux/selectors/index.js rename to anyclip/src/modules/editorial/TagEditor/redux/selectors/index.js diff --git a/anyclip/src/modules/editorial/TagEditor/redux/slices/index.js b/anyclip/src/modules/editorial/TagEditor/redux/slices/index.js new file mode 100644 index 0000000..ffe4f19 --- /dev/null +++ b/anyclip/src/modules/editorial/TagEditor/redux/slices/index.js @@ -0,0 +1,46 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { CONTEXT_OPEN_IN_VIDEO_EDIT_TAGS } from '../../constants'; + +const initialState = { + // States for SelectCreateCategory logic + selectedCategory: null, + customCategoriesOptions: null, + // States for SelectCreateTag logic, + selectedTag: null, + tagsOptions: null, + // presave from props + openContext: CONTEXT_OPEN_IN_VIDEO_EDIT_TAGS, +}; + +export const slice = createSlice({ + name: '@@tagEditor', + initialState, + reducers: { + setStateAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + clearAction: () => initialState, + getCustomCategoriesOptionsAction: (state) => state, + createCustomCategoryAction: (state) => state, + editCustomCategoryAction: (state) => state, + getCustomTagsOptionsAction: (state) => state, + getCustomTagsWithCategoryOptionsAction: (state) => state, + getSystemTagsOptionsAction: (state) => state, + createSystemTagAction: (state) => state, + }, +}); + +export const { + setStateAction, + clearAction, + getCustomCategoriesOptionsAction, + createCustomCategoryAction, + editCustomCategoryAction, + getCustomTagsOptionsAction, + getCustomTagsWithCategoryOptionsAction, + getSystemTagsOptionsAction, + createSystemTagAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/ConfirmWarnEdit/ConfirmWarnEdit.jsx b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/ConfirmWarnEdit/ConfirmWarnEdit.jsx new file mode 100644 index 0000000..60e5da7 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/ConfirmWarnEdit/ConfirmWarnEdit.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@/mui/components'; + +let registeredCallback = null; + +export function setCallback(callback) { + registeredCallback = callback; +} + +export function getCallback() { + return registeredCallback; +} + +export function removeCallback() { + registeredCallback = null; +} + +function ConfirmWarnEdit(props) { + const handleOnClose = () => { + removeCallback(); + props.onClose(); + }; + + const handleOverwrite = () => { + const callback = getCallback(); + callback(); + removeCallback(); + + props.onOverwrite(); + }; + + return ( + + {props.title} + + + {props.body} + + + + + + + + ); +} + +ConfirmWarnEdit.propTypes = { + title: PropTypes.node.isRequired, + body: PropTypes.node.isRequired, + open: PropTypes.bool.isRequired, + + onClose: PropTypes.func.isRequired, + onOverwrite: PropTypes.func.isRequired, +}; + +export default ConfirmWarnEdit; diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getFeedOptions.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getFeedOptions.js new file mode 100644 index 0000000..234e574 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getFeedOptions.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, switchMap } from 'rxjs/operators'; + +import { getFeedOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query GetVideoFeedSourcesByAccount( + $searchText: String, + ) { + getVideoFeedSourcesByAccount( + searchText: $searchText, + ) { + records { + id + name + description + schedule_status + status + accessLevel + contentOwner { + id + name + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getFeedOptionsAction.type), + debounceTime(+process.env.APP_CLEAR_TIMEOUT), + switchMap(({ payload = '' }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + searchText: payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length && data.getVideoFeedSourcesByAccount) { + const feedOptions = data.getVideoFeedSourcesByAccount.records.map((feed) => ({ + label: feed.description.length ? feed.description : `Invalid data(${feed.name})`, + value: feed.id, + feedDescription: feed.description, + feedSource: feed.name, + scheduleStatus: feed.schedule_status, + accessLevel: feed.accessLevel, + contentOwner: { + label: feed.contentOwner.name, + value: feed.contentOwner.id, + }, + })); + + actions.push(of(setAction({ feedOptions }))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getHubsOptions.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getHubsOptions.js new file mode 100644 index 0000000..d0c794e --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/getHubsOptions.js @@ -0,0 +1,55 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getHubsOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getVideoUserHubs( + $pageSize: Int + $searchText: String + ) { + getVideoUserHubs( + pageSize: $pageSize, + searchText: $searchText, + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { getVideoUserHubs } }) => + getVideoUserHubs.records.map((record) => ({ value: `${record.id}`, label: record.name })); + +export default (action$) => + action$.pipe( + ofType(getHubsOptionsAction.type), + switchMap(() => { + const variables = { + pageSize: 10000, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const hubsOptions = getResponse(response); + + actions.push(of(setAction({ hubsOptions }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ hubsOptions: null })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/index.js new file mode 100644 index 0000000..e78d929 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getFeedOptions from './getFeedOptions'; +import getHubsOptions from './getHubsOptions'; + +export default combineEpics(getFeedOptions, getHubsOptions); diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/selectors/index.js new file mode 100644 index 0000000..1a192aa --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/selectors/index.js @@ -0,0 +1,32 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +// tab highlights +export const fadeInIsActiveSelector = (state) => state[nameSpace].fadeInIsActive; +export const fadeInSelector = (state) => state[nameSpace].fadeIn; +export const fadeOutIsActiveSelector = (state) => state[nameSpace].fadeOutIsActive; +export const fadeOutSelector = (state) => state[nameSpace].fadeOut; + +// tab basic +export const titleSelector = (state) => state[nameSpace].title; +export const descriptionSelector = (state) => state[nameSpace].description; +export const languageSelector = (state) => state[nameSpace].language; +export const feedSelector = (state) => state[nameSpace].feed; +export const feedOptionsSelector = (state) => state[nameSpace].feedOptions; +export const ownerSelector = (state) => state[nameSpace].owner; +export const accessLevelSelector = (state) => state[nameSpace].accessLevel; +export const hubsSelector = (state) => state[nameSpace].hubs; +export const hubsOptionsSelector = (state) => state[nameSpace].hubsOptions; + +// tab advanced +export const dateSelector = (state) => state[nameSpace].date; +export const notesSelector = (state) => state[nameSpace].notes; +export const evergreenSelector = (state) => state[nameSpace].evergreen; +export const labelsSelector = (state) => state[nameSpace].labels; +export const keywordsSelector = (state) => state[nameSpace].keywords; +export const iabSelector = (state) => state[nameSpace].iab; + +export const isNotifySelector = (state) => state[nameSpace].isNotify; +export const isHubsNotifySelector = (state) => state[nameSpace].isHubsNotify; +export const isCreateDateValidationErrorSelector = (state) => state[nameSpace].isCreateDateValidationError; diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/slices/index.js new file mode 100644 index 0000000..0f12795 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/slices/index.js @@ -0,0 +1,55 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + // create video from highlights (cv prefix) + // tab highlights + fadeInIsActive: true, + fadeIn: 1000, + fadeOutIsActive: true, + fadeOut: 1000, + + // tab basic + title: '', + description: '', + language: null, + owner: null, + feed: null, + feedOptions: null, + accessLevel: null, + hubs: null, + hubsOptions: null, + + // tab advanced + date: null, + notes: '', + evergreen: false, + labels: [], + keywords: [], + iab: [], + + isNotify: true, + isHubsNotify: false, + + isCreateDateValidationError: false, +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/CREATE_VIDEO', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + unmountAction: (state) => { + Object.keys(initialState).forEach((key) => { + state[key] = initialState[key]; + }); + }, + getFeedOptionsAction: (state) => state, + getHubsOptionsAction: (state) => state, + }, +}); + +export const { setAction, unmountAction, getFeedOptionsAction, getHubsOptionsAction } = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/constants/index.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/constants/index.js new file mode 100644 index 0000000..49273cb --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/constants/index.js @@ -0,0 +1,15 @@ +// list of modules +export const MODULE_CHAPTERS = 'chapters'; +export const MODULE_HIGHLIGHTS = 'highlights'; +export const MODULE_SLIDES = 'slides'; +export const MODULE_TRANSLATIONS = 'translations'; +export const MODULE_DESCRIPTION = 'description'; +export const MODULE_TAGLOG = 'taglog'; +export const MODULE_THUMBNAIL = 'thumbnail'; + +export const PLAYER_INSTANCE_KEY = 'AI_WORKBENCH'; + +// tooltips +export const TOOLTIP_DRAFT_TEXT = 'There are unpublished changes'; +export const TOOLTIP_PUBLISHED_TEXT = 'Published'; +export const TOOLTIP_PROCESSING_TEXT = 'In Progress'; diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getModulesAttribute.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getModulesAttribute.js new file mode 100644 index 0000000..f3bbb3c --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getModulesAttribute.js @@ -0,0 +1,100 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { setAction as chaptersSetAction } from '../../../Chapters/redux/slices'; +import { setAction as descriptionSetAction } from '../../../Description/redux/slices'; +import { setAction as highlightsSetAction } from '../../../Highlights/redux/slices'; +import { setAction as slidesSetAction } from '../../../Slides/redux/slices'; +import { setAction as thumbnailSetAction } from '../../../Thumbnail/redux/slices'; +import { setAction as translationsSetAction } from '../../../Translations/redux/slices'; +import { selectedVideoSelector } from '../selectors'; +import { getModulesAttributeAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query AiWorkbenchGetModulesAttribute( + $videoId: String!, + ) { + aiWorkbenchGetModulesAttribute( + videoId: $videoId, + ) { + chapters { + published + processing + draft + updatedAt + updatedBy + } + highlights { + published + processing + draft + updatedAt + updatedBy + } + slides { + published + processing + draft + download + updatedAt + updatedBy + } + translations { + published + processing + } + description { + published + processing + draft + updatedAt + updatedBy + } + thumbnail { + published + processing + draft + updatedAt + updatedBy + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getModulesAttributeAction.type), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const { chapters, highlights, slides, translations, description, thumbnail } = + data.aiWorkbenchGetModulesAttribute; + + if (!errors?.length) { + actions.push( + of(chaptersSetAction(chapters)), + of(highlightsSetAction(highlights)), + of(slidesSetAction(slides)), + of(translationsSetAction(translations)), + of(descriptionSetAction(description)), + of(thumbnailSetAction(thumbnail)), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getSelectedVideo.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getSelectedVideo.js new file mode 100644 index 0000000..2c5e5fe --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/getSelectedVideo.js @@ -0,0 +1,41 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getSelectedVideoAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +import queryGQL from '@/modules/@common/gql/queries/videoById'; + +export default (action$) => + action$.pipe( + ofType(getSelectedVideoAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + uid: action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors?.length) { + actions.push( + of( + setAction({ + selectedVideo: data.video, + }), + ), + of(monitoringStartAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/index.js new file mode 100644 index 0000000..5edf849 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/index.js @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import getModulesAttribute from './getModulesAttribute'; +import getSelectedVideo from './getSelectedVideo'; +import warnEdit from './warnEdit'; + +export default combineEpics(getModulesAttribute, warnEdit, getSelectedVideo); diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/warnEdit.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/warnEdit.js new file mode 100644 index 0000000..f2cd066 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/epics/warnEdit.js @@ -0,0 +1,90 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { MODULE_CHAPTERS, MODULE_HIGHLIGHTS, MODULE_SLIDES } from '../../constants'; + +import { setAction as chaptersSetAction } from '../../../Chapters/redux/slices'; +import { setAction as highlightsSetAction } from '../../../Highlights/redux/slices'; +import { setAction as slidesSetAction } from '../../../Slides/redux/slices'; +import { activeModuleSelector, selectedVideoSelector } from '../selectors'; +import { setAction, warnEditAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +import { + getCallback, + removeCallback, + setCallback, +} from '@/modules/editorial/aiWorkbench/AiWorkbench/components/ConfirmWarnEdit/ConfirmWarnEdit'; + +const queryGQL = ` + query AiWorkbenchWarnEditConfirm( + $videoId: String!, + $activeModule: String! + ) { + aiWorkbenchWarnEditConfirm( + videoId: $videoId, + activeModule: $activeModule, + ) { + shouldWarn + updatedBy + updatedAt + } + } +`; + +const SET_STATE_ACTION = { + [MODULE_SLIDES]: slidesSetAction, + [MODULE_CHAPTERS]: chaptersSetAction, + [MODULE_HIGHLIGHTS]: highlightsSetAction, +}; + +export default (action$, state$) => + action$.pipe( + ofType(warnEditAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const activeModule = activeModuleSelector(state$.value); + const setStateAction = SET_STATE_ACTION[activeModule] || (() => null); + + setCallback(action.payload); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + activeModule, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const { shouldWarn, updatedBy, updatedAt } = data.aiWorkbenchWarnEditConfirm; + + if (!errors?.length) { + actions.push( + of( + setAction({ + shouldShowWarnEditConfirm: shouldWarn, + updatedBy, + updatedAt, + }), + ), + ); + + if (!shouldWarn) { + const callback = getCallback(); + callback(); + + removeCallback(); + } else { + actions.push(of(setStateAction({ isLoading: false }))); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors/index.js new file mode 100644 index 0000000..5dd2d66 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors/index.js @@ -0,0 +1,13 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const activeModuleSelector = (state) => state[nameSpace].activeModule; +// export const selectedVideoSelector = (state) => state[VideoDetailsNameSpace].selectedVideo; +export const selectedVideoSelector = (state) => state[nameSpace].selectedVideo; +export const shouldShowWarnEditConfirmSelector = (state) => state[nameSpace].shouldShowWarnEditConfirm; +export const updatedBySelector = (state) => state[nameSpace].updatedBy; +export const updatedAtSelector = (state) => state[nameSpace].updatedAt; + +export const prevVideoIdInQuerySelector = (state) => state[nameSpace].prevVideoIdInQuery; +export const selectedVideoIdSelector = (state) => state[nameSpace].selectedVideoId; diff --git a/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/slices/index.js new file mode 100644 index 0000000..5ff3668 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/AiWorkbench/redux/slices/index.js @@ -0,0 +1,37 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { MODULE_SLIDES } from '../../constants'; + +const initialState = { + activeModule: MODULE_SLIDES, + shouldShowWarnEditConfirm: false, + updatedBy: '', + updatedAt: '', + + // open module + prevVideoIdInQuery: null, + selectedVideoId: null, + selectedVideo: null, +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getModulesAttributeAction: (state) => state, + warnEditAction: (state) => state, + initAiWorkbenchAction: (state, action) => { + state.prevVideoIdInQuery = action.payload.prevVideoIdInQuery; + state.selectedVideoId = action.payload.videoId; + }, + getSelectedVideoAction: (state) => state, + }, +}); + +export const { setAction, getModulesAttributeAction, warnEditAction, initAiWorkbenchAction, getSelectedVideoAction } = + slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/helpers/index.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/helpers/index.js new file mode 100644 index 0000000..5413c3a --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/helpers/index.js @@ -0,0 +1,8 @@ +export function attachIdToChapters(chapters) { + return chapters.map((chapter, chapterIndex) => ({ + ...chapter, + id: window.btoa(`${chapter.time}:${chapterIndex}`), + })); +} + +export default {}; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/createVideo.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/createVideo.js new file mode 100644 index 0000000..ad10f0d --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/createVideo.js @@ -0,0 +1,140 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { mapApiError } from '@/modules/@common/constants/mapApiError'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { chaptersSelector, selectedChaptersSelector } from '../selectors'; +import { createVideoAction, getListOfCreatedVideoAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import * as selectors from '@/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/selectors'; +import { selectedVideoSelector } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchChaptersCreateVideosQuery( + $payload: AiWorkbenchChaptersCreateVideosInputType, + ) { + aiWorkbenchChaptersCreateVideos( + payload: $payload, + ) { + uid + } + } +`; + +function getChaptersForVideosGenerate(chapters, selectedChapters, videoLength) { + const selected = new Set(selectedChapters); + const chaptersForVideoGenerate = []; + + for (let i = 0; i < chapters.length; i++) { + if (selected.has(chapters[i].time)) { + const { time: startTime, name, description } = chapters[i]; + const chapterForCreateVideo = { + startTime, + endTime: chapters[i + 1]?.time || videoLength, + name: `[CHAPTER] ${i + 1} ${name}`, + description, + }; + chaptersForVideoGenerate.push(chapterForCreateVideo); + } + } + + return chaptersForVideoGenerate; +} + +function ifOneChapterAddNameAndPlotFromForm(chapters, data) { + return chapters.length === 1 ? [{ ...chapters[0], ...data }] : chapters; +} + +export default (action$, state$) => + action$.pipe( + ofType(createVideoAction.type), + switchMap((action) => { + const state = state$.value; + const successCallback = action.payload; + + const { uid: videoId, videoLength } = selectedVideoSelector(state); + + const name = selectors.titleSelector(state); + const plot = selectors.descriptionSelector(state); + const lang = selectors.languageSelector(state).value; + const feedSourceId = selectors.feedSelector(state).value; + const accessLevel = selectors.accessLevelSelector(state); + const hubs = selectors.hubsSelector(state); + + const videoCreationDate = selectors.dateSelector(state); + const notes = selectors.notesSelector(state); + const evergreen = selectors.evergreenSelector(state); + const label = selectors.labelsSelector(state); + const keywords = selectors.keywordsSelector(state); + const iab = selectors.iabSelector(state); + const isNotify = selectors.isNotifySelector(state); + const isHubsNotify = selectors.isHubsNotifySelector(state); + + const chapters = chaptersSelector(state); + const selectedChapters = selectedChaptersSelector(state); + + const generateChapterVideos = ifOneChapterAddNameAndPlotFromForm( + getChaptersForVideosGenerate(chapters, selectedChapters, videoLength), + { + name, + description: plot, + }, + ); + + const videoCreationDateCalculated = dayjs().isBefore(dayjs(videoCreationDate)) + ? dayjs(new Date()) + : dayjs(videoCreationDate); + + const payload = { + videoId, + lang, + feedSourceId, + videoCreationDate: videoCreationDateCalculated.utc().valueOf(), + notes, + evergreen, + label, + keywords, + iab, + accessLevel, + hubs, + isNotify, + isHubsNotify, + generateChapterVideos, + }; + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { payload }, + }, + { + mapError: mapApiError(), + }, + ).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push( + of(getListOfCreatedVideoAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Chapter videos are being generated. The new video could be open from "Chapter Videos"', + }), + ), + ); + successCallback(); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/generateByAiChapters.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/generateByAiChapters.js new file mode 100644 index 0000000..d09b85e --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/generateByAiChapters.js @@ -0,0 +1,81 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR, TYPE_WARNING } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { generateByAiAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchGenerateChaptersByAiQuery( + $videoId: String!, + $notifyByEmail: Boolean! + ) { + aiWorkbenchGenerateChaptersByAi( + videoId: $videoId, + notifyByEmail: $notifyByEmail, + ) + } +`; + +const ERROR_MODEL_IS_DISABLE = 11101; +const ERROR_MODEL_DOESNT_SUPPORT_LANG = 11102; +const ERROR_MODEL_UPLOADED_CC_AUTO = 11201; +const ERROR_MODEL_DEFAULT = 11200; + +const ERRORS = { + [ERROR_MODEL_IS_DISABLE]: + 'Chapters cannot be generated for this video. Please contact your Customer Success Manager (EO1).', + [ERROR_MODEL_DOESNT_SUPPORT_LANG]: + 'Chapters cannot be generated for this video due to the video language is not supported. Please contact your Customer Success Manager (E02).', + [ERROR_MODEL_UPLOADED_CC_AUTO]: + 'Chapters cannot be generated for this video since it has manually uploaded closed captions rather than automatically generated ones.', + [ERROR_MODEL_DEFAULT]: 'Chapters cannot be generated for this video.', +}; + +export default (action$, state$) => + action$.pipe( + ofType(generateByAiAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { + videoId: video.uid, + notifyByEmail: action.payload, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push(of(setAction({ processing: true }))); + } else { + actions.push( + of( + showNotificationAction({ + type: ERRORS[errors[0]?.response?.code] ? TYPE_WARNING : TYPE_ERROR, + message: ERRORS[errors[0]?.response?.code] || ERRORS[ERROR_MODEL_DEFAULT], + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getChapters.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getChapters.js new file mode 100644 index 0000000..eca25f5 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getChapters.js @@ -0,0 +1,80 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { attachIdToChapters } from '../../helpers'; +import { getChaptersAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query AiWorkbenchGetChaptersQuery( + $videoId: String!, + ) { + aiWorkbenchGetChapters( + videoId: $videoId, + ) { + published + draft + isGeneratingAvailable + causes + chapters { + time + name + description + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getChaptersAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const { shouldShowGenerationsCompletedNotification = false } = action.payload || {}; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.aiWorkbenchGetChapters; + const chapters = response?.chapters ?? []; + + if (!errors?.length) { + actions.push( + of( + setAction({ + chapters: attachIdToChapters(chapters), + published: response.published, + draft: response.draft, + isLoading: false, + isGeneratingAvailable: response.isGeneratingAvailable, + causes: response.causes, + }), + ), + ); + + if (shouldShowGenerationsCompletedNotification) { + const message = chapters.length + ? `Chapters generation completed. ${chapters.length} chapters were identified` + : 'Chapters generation completed. No chapters were identified'; + + actions.push(of(showNotificationAction({ type: TYPE_SUCCESS, message }))); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getListOfCreatedVideo.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getListOfCreatedVideo.js new file mode 100644 index 0000000..2ca9948 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/getListOfCreatedVideo.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getListOfCreatedVideoAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query AiWorkbenchGetChaptersGetCreatedVideosQuery($videoId: String!) { + aiWorkbenchGetChaptersGetCreatedVideos(videoId: $videoId) { + totalCount + data { + uid + name + videoCreationDate + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getListOfCreatedVideoAction.type), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const payload = { isLoading: false }; + + if (!errors.length) { + const { data: videos } = data.aiWorkbenchGetChaptersGetCreatedVideos; + payload.videos = videos; + } + + actions.push(of(setAction(payload))); + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/index.js new file mode 100644 index 0000000..c49c2b8 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import createVideo from './createVideo'; +import generateByAiChapters from './generateByAiChapters'; +import getChapters from './getChapters'; +import getListOfCreatedVideo from './getListOfCreatedVideo'; +import monitoringChapters from './monitoringChapters'; +import publishUnpublishChapters from './publishUnpublishChapters'; +import setChapters from './setChapters'; + +export default combineEpics( + getChapters, + setChapters, + publishUnpublishChapters, + generateByAiChapters, + monitoringChapters, + createVideo, + getListOfCreatedVideo, +); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/monitoringChapters.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/monitoringChapters.js new file mode 100644 index 0000000..92fbf93 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/monitoringChapters.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingSelector } from '../selectors'; +import { getChaptersAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const JOB_TYPE = 'AUTO_CHAPTERS'; +const JOB_STATE_START = 'STARTED'; +const JOB_STATE_DONE = 'DONE'; +const JOB_STATE_PROCESSING = 'PROCESSING'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE); + const processing = processingSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!job || !video?.uid) { + return EMPTY; + } + + const actions = []; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingPercentage: job.progress === 100 ? 99 : job.progress, + }), + ), + ); + } + + if (job.state === JOB_STATE_DONE && processing) { + actions.push( + of(setAction({ processing: false, processingPercentage: null })), + of( + getChaptersAction({ + shouldShowGenerationsCompletedNotification: true, + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/publishUnpublishChapters.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/publishUnpublishChapters.js new file mode 100644 index 0000000..7f80b17 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/publishUnpublishChapters.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { publishUnpublishAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchPublishUnpublishChaptersQuery( + $videoId: String!, + $publish: Boolean!, + ) { + aiWorkbenchPublishUnpublishChapters( + videoId: $videoId, + publish: $publish, + ) { + published + draft + processing + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(publishUnpublishAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + publish: action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchPublishUnpublishChapters; + const payload = { isLoading: false }; + const actions = []; + + if (!errors?.length) { + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + + actions.push( + of(setAction(payload)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: action.payload ? 'Chapters successfully published!' : 'Chapters successfully unpublished!', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/setChapters.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/setChapters.js new file mode 100644 index 0000000..8ed3fa8 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/epics/setChapters.js @@ -0,0 +1,64 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { attachIdToChapters } from '../../helpers'; +import { setAction, setChaptersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation AiWorkbenchSetChaptersQuery( + $videoId: String!, + $chapters: [AiWorkbenchChapterInputType], + ) { + aiWorkbenchSetChapters( + videoId: $videoId, + chapters: $chapters, + ) { + published + draft + processing + chapters { + time + name + description + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setChaptersAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + chapters: action.payload.sort((a, b) => a.time - b.time), + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchSetChapters; + const payload = { isLoading: false }; + + if (!errors?.length) { + payload.chapters = attachIdToChapters(response?.chapters ?? []); + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + payload.editableId = null; + payload.createId = null; + payload.isDirty = true; + } + + return concat(of(setAction(payload))); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/selectors/index.js new file mode 100644 index 0000000..6de8f12 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/selectors/index.js @@ -0,0 +1,26 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const chaptersSelector = (state) => state[nameSpace].chapters; +export const publishedSelector = (state) => state[nameSpace].published; +export const draftSelector = (state) => state[nameSpace].draft; +export const processingSelector = (state) => state[nameSpace].processing; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const editableIdSelector = (state) => state[nameSpace].editableId; +export const createIdSelector = (state) => state[nameSpace].createId; + +export const shouldOpenGenerateByAiConfirm = (state) => state[nameSpace].shouldOpenGenerateByAiConfirm; + +export const isDirtySelector = (state) => state[nameSpace].isDirty; + +export const processingPercentageSelector = (state) => state[nameSpace].processingPercentage; + +export const isGeneratingAvailableSelector = (state) => state[nameSpace].isGeneratingAvailable; +export const causesSelector = (state) => state[nameSpace].causes; + +export const isMultiselectModeActiveSelector = (state) => state[nameSpace].isMultiselectModeActive; +export const selectedChaptersSelector = (state) => state[nameSpace].selectedChapters; +export const shouldOpenCreateVideoFormSelector = (state) => state[nameSpace].shouldOpenCreateVideoForm; +export const videosSelector = (state) => state[nameSpace].videos; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/slices/index.js new file mode 100644 index 0000000..91a7e76 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Chapters/redux/slices/index.js @@ -0,0 +1,73 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + chapters: null, + published: false, + draft: false, + processing: false, + + processingPercentage: null, + + isLoading: false, + + editableId: null, + createId: null, + + shouldOpenGenerateByAiConfirm: false, + + isDirty: false, + + isGeneratingAvailable: false, + causes: [], + + // multiselect + isMultiselectModeActive: false, + selectedChapters: [], // used time to indicate what are selected + + // create video + shouldOpenCreateVideoForm: false, + videos: [], +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/CHAPTERS', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getChaptersAction: (state) => state, + setChaptersAction: (state) => state, + publishUnpublishAction: (state) => state, + toggleConfirmForGenerateByAiConfirmAction: (state, action) => { + state.shouldOpenGenerateByAiConfirm = action.payload; + }, + generateByAiAction: (state) => state, + unmountAction: (state) => { + state.isDirty = false; + state.editableId = false; + state.createId = false; + state.chapters = null; + state.isMultiselectModeActive = false; + state.selectedChapters = []; + state.shouldOpenCreateVideoForm = false; + state.videos = []; + }, + createVideoAction: (state) => state, + getListOfCreatedVideoAction: (state) => state, + }, +}); + +export const { + setAction, + getChaptersAction, + setChaptersAction, + publishUnpublishAction, + toggleConfirmForGenerateByAiConfirmAction, + generateByAiAction, + unmountAction, + createVideoAction, + getListOfCreatedVideoAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/generateByAiDescription.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/generateByAiDescription.js new file mode 100644 index 0000000..591b4ed --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/generateByAiDescription.js @@ -0,0 +1,54 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { generateByAiAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchGenerateDescriptionByAiQuery( + $videoId: String!, + $notifyByEmail: Boolean! + ) { + aiWorkbenchGenerateDescriptionByAi( + videoId: $videoId, + notifyByEmail: $notifyByEmail, + ) + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(generateByAiAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { + videoId: video.uid, + notifyByEmail: action.payload, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push(of(setAction({ processing: true }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/getDescription.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/getDescription.js new file mode 100644 index 0000000..4a05e90 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/getDescription.js @@ -0,0 +1,80 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getDescriptionAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query AiWorkbenchGetDescriptionQuery( + $videoId: String!, + ) { + aiWorkbenchGetDescription( + videoId: $videoId, + ) { + published + draft + description + descriptions { + type + value + } + isGeneratingAvailable + causes + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getDescriptionAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const { shouldShowGenerationsCompletedNotification = false } = action.payload || {}; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.aiWorkbenchGetDescription; + const descriptions = response?.descriptions ?? []; + + if (!errors?.length) { + actions.push( + of( + setAction({ + description: response.description, + descriptions, + published: response.published, + draft: response.draft, + isLoading: false, + isGeneratingAvailable: response.isGeneratingAvailable, + causes: response.causes, + }), + ), + ); + + if (shouldShowGenerationsCompletedNotification) { + const message = descriptions.length + ? 'Description options successfully generated' + : 'Description generation completed. No descriptions were identified'; + + actions.push(of(showNotificationAction({ type: TYPE_SUCCESS, message }))); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/index.js new file mode 100644 index 0000000..315629c --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/index.js @@ -0,0 +1,17 @@ +import { combineEpics } from 'redux-observable'; + +import generateByAiDescription from './generateByAiDescription'; +import getDescription from './getDescription'; +import monitoringDescription from './monitoringDescription'; +import publishUnpublish from './publishUnpublishDescription'; +import setDescription from './setDescription'; +import updateDescriptionInVideoListOrDetail from './updateDescriptionInVideoListOrDetail'; + +export default combineEpics( + getDescription, + setDescription, + generateByAiDescription, + monitoringDescription, + publishUnpublish, + updateDescriptionInVideoListOrDetail, +); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/monitoringDescription.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/monitoringDescription.js new file mode 100644 index 0000000..07246a6 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/monitoringDescription.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingSelector } from '../selectors'; +import { getDescriptionAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const JOB_TYPE = 'DESCRIPTIONS'; +const JOB_STATE_START = 'STARTED'; +const JOB_STATE_DONE = 'DONE'; +const JOB_STATE_PROCESSING = 'PROCESSING'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE); + const processing = processingSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!job || !video?.uid) { + return EMPTY; + } + + const actions = []; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingPercentage: job.progress === 100 ? 99 : job.progress, + }), + ), + ); + } + + if (job.state === JOB_STATE_DONE && processing) { + actions.push( + of(setAction({ processing: false, processingPercentage: null })), + of( + getDescriptionAction({ + shouldShowGenerationsCompletedNotification: true, + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/publishUnpublishDescription.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/publishUnpublishDescription.js new file mode 100644 index 0000000..a342621 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/publishUnpublishDescription.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { publishUnpublishAction, setAction, updateDescriptionInVideoListOrDetailAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchPublishUnpublishDescriptionQuery( + $videoId: String!, + $publish: Boolean!, + ) { + aiWorkbenchPublishUnpublishDescription( + videoId: $videoId, + publish: $publish, + ) { + published + draft + processing + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(publishUnpublishAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + publish: action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchPublishUnpublishDescription; + const payload = { isLoading: false }; + const actions = []; + + if (!errors?.length) { + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + + actions.push( + of(setAction(payload)), + of(updateDescriptionInVideoListOrDetailAction(action.payload)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: action.payload + ? 'Description successfully published!' + : 'Description successfully unpublished!', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/setDescription.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/setDescription.js new file mode 100644 index 0000000..98ea234 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/setDescription.js @@ -0,0 +1,62 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { setAction, setDescriptionAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation AiWorkbenchSetDescriptionQuery( + $videoId: String!, + $description: String, + ) { + aiWorkbenchSetDescription( + videoId: $videoId, + description: $description, + ) { + published + draft + processing + description + descriptions { + type + value + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setDescriptionAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + description: action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchSetDescription; + const payload = { isLoading: false }; + + if (!errors?.length) { + payload.description = response.description; + payload.descriptions = response?.descriptions ?? []; + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + payload.isDirty = true; + } + + return concat(of(setAction(payload))); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/updateDescriptionInVideoListOrDetail.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/updateDescriptionInVideoListOrDetail.js new file mode 100644 index 0000000..ddd6e1f --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/epics/updateDescriptionInVideoListOrDetail.js @@ -0,0 +1,34 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { descriptionSelector } from '../selectors'; +import { updateDescriptionInVideoListOrDetailAction } from '../slices'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { selectedVideoSelector as detailSelectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; +import { reloadSelectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(updateDescriptionInVideoListOrDetailAction.type), + switchMap(({ payload }) => { + const { uid } = selectedVideoSelector(state$.value); + const description = descriptionSelector(state$.value); + const videos = videosSelector(state$.value); + const isVideoDetailOpened = detailSelectedVideoSelector(state$.value); + + const updatedVideos = videos.map((originalVideo) => + originalVideo.uid === uid ? { ...originalVideo, plot: payload ? description : '' } : originalVideo, + ); + + const actions = []; + actions.push(of(videosAction(updatedVideos))); + if (isVideoDetailOpened) { + actions.push(of(reloadSelectedVideoAction())); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/selectors/index.js new file mode 100644 index 0000000..bd81771 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/selectors/index.js @@ -0,0 +1,19 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const descriptionSelector = (state) => state[nameSpace].description; +export const descriptionsSelector = (state) => state[nameSpace].descriptions; +export const publishedSelector = (state) => state[nameSpace].published; +export const draftSelector = (state) => state[nameSpace].draft; +export const processingSelector = (state) => state[nameSpace].processing; +export const processingPercentageSelector = (state) => state[nameSpace].processingPercentage; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; + +export const shouldOpenGenerateByAiConfirm = (state) => state[nameSpace].shouldOpenGenerateByAiConfirm; + +export const isGeneratingAvailableSelector = (state) => state[nameSpace].isGeneratingAvailable; +export const causesSelector = (state) => state[nameSpace].causes; + +export const isDirtySelector = (state) => state[nameSpace].isDirty; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Description/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/slices/index.js new file mode 100644 index 0000000..78b7d3a --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Description/redux/slices/index.js @@ -0,0 +1,56 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + description: null, + descriptions: null, + published: false, + draft: false, + processing: false, + + processingPercentage: null, + + isLoading: false, + + shouldOpenGenerateByAiConfirm: false, + + isGeneratingAvailable: false, + causes: [], + + isDirty: false, +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/DESCRIPTION', + initialState, + reducers: { + getDescriptionAction: (state) => state, + setDescriptionAction: (state) => state, + generateByAiAction: (state) => state, + publishUnpublishAction: (state) => state, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + toggleConfirmForGenerateByAiConfirmAction: (state, action) => { + state.shouldOpenGenerateByAiConfirm = action.payload; + }, + unmountAction: (state) => { + state.description = null; + state.descriptionOptions = null; + state.isDirty = false; + }, + updateDescriptionInVideoListOrDetailAction: (state) => state, + }, +}); + +export const { + getDescriptionAction, + setDescriptionAction, + generateByAiAction, + publishUnpublishAction, + setAction, + toggleConfirmForGenerateByAiConfirmAction, + unmountAction, + updateDescriptionInVideoListOrDetailAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/constants/index.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/constants/index.js new file mode 100644 index 0000000..f461e13 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/constants/index.js @@ -0,0 +1,7 @@ +export const JOB_TYPE_VIDEO_HIGHLIGHTS_PROCESSING = 'VIDEO_HIGHLIGHTS_PROCESSING'; +export const JOB_TYPE_HIGHLIGHTS_VIDEO_PROCESSING = 'HIGHLIGHTS_VIDEO_PROCESSING'; + +export const JOB_STATE_START = 'STARTED'; +export const JOB_STATE_DONE = 'DONE'; +export const JOB_STATE_PROCESSING = 'PROCESSING'; +export const JOB_STATE_ERROR = 'ERROR'; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/createVideo.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/createVideo.js new file mode 100644 index 0000000..836a62e --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/createVideo.js @@ -0,0 +1,121 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { mapApiError } from '@/modules/@common/constants/mapApiError'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { createVideoAction, getListOfCreatedVideo, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import * as selectors from '@/modules/editorial/aiWorkbench/AiWorkbench/components/CreateVideoForm/redux/selectors'; +import { selectedVideoSelector } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchHighlightsCreateVideoQuery( + $video: AiWorkbenchHighlightsCreateVideoInputType, + ) { + aiWorkbenchHighlightsCreateVideo( + video: $video, + ) { + uid + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createVideoAction.type), + switchMap((action) => { + const state = state$.value; + const successCallback = action.payload; + + const { uid: videoId } = selectedVideoSelector(state); + + const fadeIn = selectors.fadeInSelector(state); + const fadeInIsActive = selectors.fadeInIsActiveSelector(state); + const fadeOut = selectors.fadeOutSelector(state); + const fadeOutIsActive = selectors.fadeOutIsActiveSelector(state); + + const name = selectors.titleSelector(state); + const plot = selectors.descriptionSelector(state); + const lang = selectors.languageSelector(state).value; + const feedSourceId = selectors.feedSelector(state).value; + const accessLevel = selectors.accessLevelSelector(state); + const hubs = selectors.hubsSelector(state); + + const videoCreationDate = selectors.dateSelector(state); + const notes = selectors.notesSelector(state); + const evergreen = selectors.evergreenSelector(state); + const label = selectors.labelsSelector(state); + const keywords = selectors.keywordsSelector(state); + const iab = selectors.iabSelector(state); + const isNotify = selectors.isNotifySelector(state); + const isHubsNotify = selectors.isHubsNotifySelector(state); + + const videoCreationDateCalculated = dayjs().isBefore(dayjs(videoCreationDate)) + ? dayjs(new Date()) + : dayjs(videoCreationDate); + + const video = { + videoId, + name, + plot, + lang, + feedSourceId, + videoCreationDate: videoCreationDateCalculated.utc().valueOf(), + notes, + evergreen, + label, + keywords, + iab, + accessLevel, + hubs, + isNotify, + isHubsNotify, + }; + + if (fadeInIsActive && fadeIn) { + video.fadeIn = +fadeIn; + } + + if (fadeOutIsActive && fadeOut) { + video.fadeOut = +fadeOut; + } + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { video }, + }, + { + mapError: mapApiError(), + }, + ).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + const { uid } = data.aiWorkbenchHighlightsCreateVideo; + actions.push( + of(getListOfCreatedVideo()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: + 'Highlights video is being generated... The new video could be open from "Highlights Videos"', + }), + ), + of(setAction({ lastCreatedVideoId: uid })), + ); + successCallback(); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/generateByAiHighlights.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/generateByAiHighlights.js new file mode 100644 index 0000000..44b93ca --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/generateByAiHighlights.js @@ -0,0 +1,66 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_WARNING } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { generateByAiAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchGenerateHighlightsByAiQuery( + $videoId: String!, + $notifyByEmail: Boolean! + ) { + aiWorkbenchGenerateHighlightsByAi( + videoId: $videoId, + notifyByEmail: $notifyByEmail, + ) + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(generateByAiAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { + videoId: video.uid, + notifyByEmail: action.payload, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push(of(setAction({ processing: true }))); + } else { + actions.push( + of( + showNotificationAction({ + type: TYPE_WARNING, + message: errors[0].message, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getCcSegments.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getCcSegments.js new file mode 100644 index 0000000..18d604e --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getCcSegments.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getCcSegmentsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query AiWorkbenchGetCcSegments($videoId: String!) { + aiWorkbenchGetCcSegments(videoId: $videoId) { + data { + uid + transcript + provider + speakerId + startTime + endTime + tokens { + startTime + endTime + word + kind + confidence + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getCcSegmentsAction.type), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setAction({ ccSegments: data?.aiWorkbenchGetCcSegments?.data || [] }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getHighlights.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getHighlights.js new file mode 100644 index 0000000..de4d4a9 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getHighlights.js @@ -0,0 +1,70 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getHighlightsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query AiWorkbenchGetHighlights($videoId: String!) { + aiWorkbenchGetHighlights(videoId: $videoId) { + published + draft + isGeneratingAvailable + causes + highlights { + id + start + end + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getHighlightsAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const { shouldShowGenerationsCompletedNotification = false } = action.payload || {}; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchGetHighlights; + const actions = []; + const payload = { isLoading: false }; + + if (!errors.length) { + payload.highlights = response.highlights || []; + payload.published = response.published; + payload.draft = response.draft; + payload.isGeneratingAvailable = response.isGeneratingAvailable; + payload.causes = response.causes; + + if (shouldShowGenerationsCompletedNotification) { + const message = payload.highlights.length + ? `Highlights generation completed. ${payload.highlights.length} highlights were identified` + : 'Highlights generation completed. No highlights were identified'; + + actions.push(of(showNotificationAction({ type: TYPE_SUCCESS, message }))); + } + } + + actions.push(of(setAction(payload))); + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getListOfCreatedVideo.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getListOfCreatedVideo.js new file mode 100644 index 0000000..a91756b --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/getListOfCreatedVideo.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getListOfCreatedVideo, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query AiWorkbenchGetHighlightsGetCreatedVideosQuery($videoId: String!) { + aiWorkbenchGetHighlightsGetCreatedVideos(videoId: $videoId) { + totalCount + data { + uid + name + videoCreationDate + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getListOfCreatedVideo.type), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const payload = { isLoading: false }; + + if (!errors.length) { + const { data: videos } = data.aiWorkbenchGetHighlightsGetCreatedVideos; + payload.videos = videos; + } + + actions.push(of(setAction(payload))); + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/index.js new file mode 100644 index 0000000..e8544c8 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/index.js @@ -0,0 +1,21 @@ +import { combineEpics } from 'redux-observable'; + +import createVideo from './createVideo'; +import generateByAiHighlights from './generateByAiHighlights'; +import getCcSegments from './getCcSegments'; +import getHighlights from './getHighlights'; +import geyListOfCreatedVideos from './getListOfCreatedVideo'; +import monitoringHighlights from './monitoringHighlights'; +import publishUnpublish from './publishUnpublishHighlights'; +import setHighlights from './setHighlights'; + +export default combineEpics( + getCcSegments, + getHighlights, + setHighlights, + publishUnpublish, + generateByAiHighlights, + monitoringHighlights, + geyListOfCreatedVideos, + createVideo, +); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/monitoringHighlights.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/monitoringHighlights.js new file mode 100644 index 0000000..033e798 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/monitoringHighlights.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + JOB_STATE_DONE, + JOB_STATE_ERROR, + JOB_STATE_PROCESSING, + JOB_STATE_START, + JOB_TYPE_VIDEO_HIGHLIGHTS_PROCESSING, +} from '../../constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingSelector } from '../selectors'; +import { getHighlightsAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE_VIDEO_HIGHLIGHTS_PROCESSING); + const processing = processingSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!job || !video?.uid) { + return EMPTY; + } + + const actions = []; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingPercentage: job.progress === 100 ? 99 : job.progress, + currentProcessingJobType: JOB_TYPE_VIDEO_HIGHLIGHTS_PROCESSING, + }), + ), + ); + } + + if (job.state === JOB_STATE_DONE && processing) { + actions.push( + of(setAction({ processing: false, processingPercentage: null, currentProcessingJobType: null })), + of( + getHighlightsAction({ + shouldShowGenerationsCompletedNotification: true, + }), + ), + ); + } + + if (job.state === JOB_STATE_ERROR && processing) { + actions.push( + of(setAction({ processing: false, processingPercentage: null, currentProcessingJobType: null })), + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Highlights cannot be created for this video.', + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/publishUnpublishHighlights.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/publishUnpublishHighlights.js new file mode 100644 index 0000000..c59e118 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/publishUnpublishHighlights.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { publishUnpublishAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchPublishUnpublishHighlightsQuery( + $videoId: String!, + $publish: Boolean!, + ) { + aiWorkbenchPublishUnpublishHighlights( + videoId: $videoId, + publish: $publish, + ) { + published + draft + processing + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(publishUnpublishAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + publish: action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchPublishUnpublishHighlights; + const payload = { isLoading: false }; + const actions = []; + + if (!errors?.length) { + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + + actions.push( + of(setAction(payload)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: action.payload ? 'Highlights published!' : 'Highlights unpublished!', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/setHighlights.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/setHighlights.js new file mode 100644 index 0000000..5857b7f --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/epics/setHighlights.js @@ -0,0 +1,64 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { setAction, setHighlightsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation AiWorkbenchSetHighlightsQuery( + $videoId: String!, + $highlights: [AiWorkbenchHighlightInputType], + ) { + aiWorkbenchSetHighlights( + videoId: $videoId, + highlights: $highlights, + ) { + published + draft + processing + highlights { + id + start + end + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setHighlightsAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + highlights: action.payload.map((highlight) => ({ + ...highlight, + ...(highlight.id.search('new') === -1 ? { id: highlight.id } : {}), + })), + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchSetHighlights; + const payload = { isLoading: false }; + + if (!errors?.length) { + payload.highlights = response?.highlights ?? []; + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + payload.isDirty = true; + } + + return concat(of(setAction(payload))); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/selectors/index.js new file mode 100644 index 0000000..4c370bb --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/selectors/index.js @@ -0,0 +1,23 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const ccSegmentsSelector = (state) => state[nameSpace].ccSegments; +export const highlightsSelector = (state) => state[nameSpace].highlights; +export const publishedSelector = (state) => state[nameSpace].published; +export const draftSelector = (state) => state[nameSpace].draft; +export const processingSelector = (state) => state[nameSpace].processing; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; + +export const shouldOpenGenerateByAiConfirm = (state) => state[nameSpace].shouldOpenGenerateByAiConfirm; + +export const isDirtySelector = (state) => state[nameSpace].isDirty; + +export const processingPercentageSelector = (state) => state[nameSpace].processingPercentage; + +export const isGeneratingAvailableSelector = (state) => state[nameSpace].isGeneratingAvailable; +export const causesSelector = (state) => state[nameSpace].causes; + +export const videosSelector = (state) => state[nameSpace].videos; +export const lastCreatedVideoIdSelector = (state) => state[nameSpace].lastCreatedVideoId; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/slices/index.js new file mode 100644 index 0000000..cf0229c --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Highlights/redux/slices/index.js @@ -0,0 +1,61 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + ccSegments: null, + highlights: [], + published: false, + draft: false, + processing: false, + processingPercentage: null, + currentProcessingJobType: null, + + isLoading: false, + shouldOpenGenerateByAiConfirm: false, + isDirty: false, + + isGeneratingAvailable: false, + causes: [], + + videos: [], + lastCreatedVideoId: null, +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/HIGHLIGHTS', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getCcSegmentsAction: (state) => state, + getHighlightsAction: (state) => state, + setHighlightsAction: (state) => state, + publishUnpublishAction: (state) => state, + toggleConfirmForGenerateByAiConfirmAction: (state, action) => { + state.shouldOpenGenerateByAiConfirm = action.payload; + }, + generateByAiAction: (state) => state, + unmountAction: (state) => { + state.isDirty = false; + state.highlights = []; + state.lastCreatedVideoId = null; + }, + createVideoAction: (state) => state, + getListOfCreatedVideo: (state) => state, + }, +}); + +export const { + setAction, + getCcSegmentsAction, + getHighlightsAction, + setHighlightsAction, + publishUnpublishAction, + toggleConfirmForGenerateByAiConfirmAction, + generateByAiAction, + unmountAction, + getListOfCreatedVideo, + createVideoAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/constants/index.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/constants/index.js new file mode 100644 index 0000000..a00bf3f --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/constants/index.js @@ -0,0 +1,6 @@ +export const CREATE_ID_PREFIX = 'create_'; + +export const STATE_PUBLISH = 'publish'; +export const STATE_DOWNLOAD = 'download'; + +export default {}; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/generateByAiSlides.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/generateByAiSlides.js new file mode 100644 index 0000000..347ad72 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/generateByAiSlides.js @@ -0,0 +1,49 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { generateByAiAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchGenerateSlidesByAiQuery( + $videoId: String!, + $notifyByEmail: Boolean! + ) { + aiWorkbenchGenerateSlidesByAi( + videoId: $videoId, + notifyByEmail: $notifyByEmail, + ) + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(generateByAiAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + notifyByEmail: action.payload, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push(of(setAction({ processing: true }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/getSlides.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/getSlides.js new file mode 100644 index 0000000..f02f54c --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/getSlides.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getSlidesAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query AiWorkbenchGetSlidesQuery( + $videoId: String!, + ) { + aiWorkbenchGetSlides( + videoId: $videoId, + ) { + published + draft + download + slides { + id + time + img + title + } + pdfFile + isGeneratingAvailable + causes + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getSlidesAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const { shouldShowGenerationsCompletedNotification = false, isPdfMonitoringUpdate = false } = + action.payload || {}; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.aiWorkbenchGetSlides; + const slides = response?.slides ?? []; + const payload = { pdfFile: response.pdfFile || '' }; + + if (!errors?.length) { + if (!isPdfMonitoringUpdate) { + payload.slides = slides; + payload.published = response.published; + payload.download = response.download; + payload.draft = response.draft; + payload.isLoading = false; + payload.isGeneratingAvailable = response.isGeneratingAvailable; + payload.causes = response.causes; + } + + actions.push(of(setAction(payload))); + + if (shouldShowGenerationsCompletedNotification) { + const message = slides.length + ? `Slides generation completed. ${slides.length} slides were identified` + : 'Slides generation completed. No slides were identified'; + + actions.push(of(showNotificationAction({ type: TYPE_SUCCESS, message }))); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: !isPdfMonitoringUpdate })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/index.js new file mode 100644 index 0000000..742a85c --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import generateByAiSlides from './generateByAiSlides'; +import getSlides from './getSlides'; +import monitoringPdf from './monitoringPdf'; +import monitoringSlides from './monitoringSlides'; +import setSlides from './setSlides'; +import setStateSlides from './setStateSlides'; +import upload from './upload'; + +export default combineEpics( + getSlides, + setSlides, + upload, + generateByAiSlides, + monitoringSlides, + setStateSlides, + monitoringPdf, +); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringPdf.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringPdf.js new file mode 100644 index 0000000..15ebd3b --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringPdf.js @@ -0,0 +1,89 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { debounceTime, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getSlidesAction, runPdfMonitoringAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query AiWorkbenchPdfMonitoringSlidesQuery($videoId: String!) { + aiWorkbenchPdfMonitoringSlides(videoId: $videoId) { + jobs { + videoId + state + progress + } + } + } +`; + +const getResponse = ({ data: { aiWorkbenchPdfMonitoringSlides } }) => aiWorkbenchPdfMonitoringSlides?.jobs[0]; + +const MONITORING_DELAY = 2000; + +const MONITORING_STATUS = { + STARTED: 'STARTED', + PROCESSING: 'PROCESSING', + DONE: 'DONE', + ERROR: 'ERROR', + FAILED: 'FAILED', +}; + +export default (action$, state$) => + action$.pipe( + ofType(runPdfMonitoringAction.type), + debounceTime(MONITORING_DELAY), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + + if (!video?.uid) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { videoId: video.uid }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const job = getResponse(response); + + switch (job?.state) { + case MONITORING_STATUS.DONE: { + return concat( + of(getSlidesAction({ isPdfMonitoringUpdate: true })), + of(setAction({ isPdfFileReadyToDownload: true })), + ); + } + case MONITORING_STATUS.STARTED: + case MONITORING_STATUS.PROCESSING: { + return concat(of(setAction({ isPdfFileReadyToDownload: false })), of(runPdfMonitoringAction())); + } + case MONITORING_STATUS.ERROR: + case MONITORING_STATUS.FAILED: { + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Pdf generation is failed', + }), + ), + ); + } + default: { + return EMPTY; + } + } + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringSlides.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringSlides.js new file mode 100644 index 0000000..2430146 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/monitoringSlides.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingSelector } from '../selectors'; +import { getSlidesAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const JOB_TYPE = 'VIDEO_SLIDES_PROCESSING'; +const JOB_STATE_START = 'STARTED'; +const JOB_STATE_DONE = 'DONE'; +const JOB_STATE_PROCESSING = 'PROCESSING'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE); + const processing = processingSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!job || !video?.uid) { + return EMPTY; + } + + const actions = []; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingPercentage: job.progress === 100 ? 99 : job.progress, + }), + ), + ); + } + + if (job.state === JOB_STATE_DONE && processing) { + actions.push( + of(setAction({ processing: false, processingPercentage: null })), + of( + getSlidesAction({ + shouldShowGenerationsCompletedNotification: true, + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setSlides.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setSlides.js new file mode 100644 index 0000000..152c466 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setSlides.js @@ -0,0 +1,64 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { setAction, setSlidesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation AiWorkbenchSetSlidesQuery( + $videoId: String!, + $slides: [AiWorkbenchSlideInputType], + ) { + aiWorkbenchSetSlides( + videoId: $videoId, + slides: $slides, + ) { + published + draft + processing + download + slides { + id + time + title + img + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setSlidesAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + slides: action.payload.sort((a, b) => a.time - b.time), + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchSetSlides; + const payload = { isLoading: false }; + + if (!errors?.length) { + payload.slides = response?.slides ?? []; + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + payload.formId = null; + payload.isDirty = true; + } + + return concat(of(setAction(payload))); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setStateSlides.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setStateSlides.js new file mode 100644 index 0000000..c275a45 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/setStateSlides.js @@ -0,0 +1,100 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { STATE_DOWNLOAD, STATE_PUBLISH } from '../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { runPdfMonitoringAction, setAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchSetStateSlidesQuery( + $videoId: String!, + $publish: Boolean, + $download: Boolean, + ) { + aiWorkbenchSetStateSlides( + videoId: $videoId, + publish: $publish, + download: $download, + ) { + published + draft + processing + download + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setStateAction.type), + switchMap((action) => { + const { state, whoChange } = action.payload; + const video = selectedVideoSelector(state$.value); + + const variables = { + videoId: video.uid, + }; + + if (whoChange === STATE_PUBLISH) { + variables.publish = state.publish; + } + + if (whoChange === STATE_DOWNLOAD) { + variables.download = state.download; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const response = data.aiWorkbenchSetStateSlides; + const payload = { isLoading: false }; + const actions = []; + + if (!errors?.length) { + payload.published = response.published; + payload.download = response.download; + payload.draft = response.draft; + payload.processing = response.processing; + + actions.push(of(setAction(payload))); + + if (whoChange === STATE_PUBLISH) { + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: action.payload.state.publish + ? 'Slides successfully published!' + : 'Slides successfully unpublished!', + }), + ), + of(runPdfMonitoringAction()), + ); + + if (state.publish) { + actions.push( + of( + setAction({ + published: state.publish, + draft: false, + }), + ), + ); + } + } + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/upload.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/upload.js new file mode 100644 index 0000000..144aac7 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/epics/upload.js @@ -0,0 +1,81 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_ID_PREFIX } from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { slidesSelector } from '../selectors'; +import { setAction, setSlidesAction, uploadAction } from '../slices'; +import { gqlRequest, uploadS3 } from '@/modules/@common/request'; + +const queryGetLinksGQL = ` + query Query($name: String, $filename: String!, $thumbnail: String, $contentOwnerId: Float!) { + S3UploadLink(name: $name, filename: $filename, thumbnail: $thumbnail, contentOwnerId: $contentOwnerId) { + uploadUrl + downloadUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(uploadAction.type), + switchMap((action) => { + const selectedVideo = selectedVideoSelector(state$.value); + const slides = slidesSelector(state$.value); + const contentOwnerId = selectedVideo.contentOwner; + const form = action.payload; + const filename = form.file.file.name.replace(/\s/g, '_'); + + const stream$ = gqlRequest({ + query: queryGetLinksGQL, + variables: { + contentOwnerId, + filename, + }, + }).pipe( + switchMap(({ data, errors }) => { + const { uploadUrl, downloadUrl } = data.S3UploadLink; + const actions = []; + + if (!errors?.length) { + const uploadStream$ = uploadS3(uploadUrl, form.file.file).pipe( + switchMap((response) => { + if (!response.errors.length) { + const slidesToUpdate = slides.map((slide) => { + if (slide.id === form.id) { + const updatedSlide = { + time: form.timeMark, + title: form.title, + img: downloadUrl, + }; + + if (!form.id.startsWith(CREATE_ID_PREFIX)) { + updatedSlide.id = slide.id; + } + + return updatedSlide; + } + return slide; + }); + + return concat(of(setSlidesAction(slidesToUpdate))); + } + + return concat(of(setAction({ isLoading: false }))); + }), + ); + + actions.push(uploadStream$); + } else { + actions.push(of(setAction({ isLoading: false }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/selectors/index.js new file mode 100644 index 0000000..5c5ca5a --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/selectors/index.js @@ -0,0 +1,22 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const slidesSelector = (state) => state[nameSpace].slides; +export const publishedSelector = (state) => state[nameSpace].published; +export const draftSelector = (state) => state[nameSpace].draft; +export const processingSelector = (state) => state[nameSpace].processing; +export const processingPercentageSelector = (state) => state[nameSpace].processingPercentage; +export const downloadSelector = (state) => state[nameSpace].download; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const formIdSelector = (state) => state[nameSpace].formId; + +export const shouldOpenGenerateByAiConfirm = (state) => state[nameSpace].shouldOpenGenerateByAiConfirm; + +export const pdfFileSelector = (state) => state[nameSpace].pdfFile; +export const isPdfFileReadyToDownload = (state) => state[nameSpace].isPdfFileReadyToDownload; +export const isDirtySelector = (state) => state[nameSpace].isDirty; + +export const isGeneratingAvailableSelector = (state) => state[nameSpace].isGeneratingAvailable; +export const causesSelector = (state) => state[nameSpace].causes; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/slices/index.js new file mode 100644 index 0000000..bc2b2ac --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Slides/redux/slices/index.js @@ -0,0 +1,58 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + slides: null, + published: false, + draft: false, + processing: false, + processingPercentage: null, + download: false, + + isLoading: false, + formId: null, + shouldOpenGenerateByAiConfirm: false, + pdfFile: '', + isPdfFileReadyToDownload: false, + isDirty: false, + + isGeneratingAvailable: false, + causes: [], +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/SLIDES', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getSlidesAction: (state) => state, + setSlidesAction: (state) => state, + toggleConfirmForGenerateByAiConfirmAction: (state, action) => { + state.shouldOpenGenerateByAiConfirm = action.payload; + }, + generateByAiAction: (state) => state, + uploadAction: (state) => state, + setStateAction: (state) => state, + runPdfMonitoringAction: (state) => state, + unmountAction: (state) => { + state.isDirty = false; + state.formId = null; + state.slides = null; + }, + }, +}); + +export const { + setAction, + getSlidesAction, + setSlidesAction, + toggleConfirmForGenerateByAiConfirmAction, + generateByAiAction, + uploadAction, + setStateAction, + runPdfMonitoringAction, + unmountAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/constants/index.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/constants/index.js new file mode 100644 index 0000000..c837f59 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/constants/index.js @@ -0,0 +1,26 @@ +export const TAG_SYSTEM_CATEGORY_TEXT = 'TEXT'; +export const TAG_SYSTEM_CATEGORY_CC = 'CC'; +export const TAG_SYSTEM_CATEGORY_PEOPLE = 'PEOPLE'; +export const TAG_SYSTEM_CATEGORY_BRANDS = 'BRANDS'; +export const TAG_SYSTEM_CATEGORY_IAB = 'IAB'; +export const TAG_SYSTEM_CATEGORY_BRAND_SAFETY = 'BRAND_SAFETY'; +export const TAG_SYSTEM_CATEGORY_KEYWORDS = 'KEYWORDS'; +export const TAG_SYSTEM_CATEGORY_CUSTOM = 'CUSTOM'; + +export const tagsTypesColor = { + [TAG_SYSTEM_CATEGORY_KEYWORDS]: '-tagKeywords', + [TAG_SYSTEM_CATEGORY_PEOPLE]: '-tagPeople', + [TAG_SYSTEM_CATEGORY_BRANDS]: '-tagBrands', + [TAG_SYSTEM_CATEGORY_IAB]: '-tagIAB', + [TAG_SYSTEM_CATEGORY_BRAND_SAFETY]: '-tagBrandSafety', + [TAG_SYSTEM_CATEGORY_TEXT]: '-tagText', + [TAG_SYSTEM_CATEGORY_CC]: '-tagCC', +}; + +export const VIDEO_STATUS_APPROVED = 'APPROVED'; + +export const DOWNLOAD_FINAL_TAGS = 'final-tags'; +export const DOWNLOAD_SOURCES_TAGS = 'source'; + +export const TAG_INFO_CONTEXT_SUMMARY = 'tag-info-context-summary'; +export const TAG_INFO_CONTEXT_TAG_ROW = 'tag-info-context-tag-row'; diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/helpers/index.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/helpers/index.js new file mode 100644 index 0000000..21632ac --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/helpers/index.js @@ -0,0 +1,12 @@ +export const getInfoTooltipKey = (context, tagId, logRowUid = null) => { + let key = `${context}/${tagId}`; + + if (logRowUid) { + key += `/${logRowUid}`; + } + + return key; +}; + +export const createTagUniqKey = (logRowId, { category, id, value }) => + `${logRowId}_${id || value.trim().toLowerCase().replace(/ /g, '-')}_${category.toLowerCase()}`; diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/download.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/download.js new file mode 100644 index 0000000..0f761ec --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/download.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { switchMap } from 'rxjs/operators'; + +import { downloadAction, setAction } from '../slices'; +import { getFileName } from '@/modules/@common/helpers/format'; +import { getToken } from '@/modules/@common/token/helpers'; +import { selectedVideoSelector } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(downloadAction.type), + switchMap((action) => { + const { name, uid } = selectedVideoSelector(state$.value); + const apiSuffix = action.payload; + + const fileName = getFileName(`${name.replace(/[^A-Za-z0-9_]/g, '-')}-${apiSuffix}`, 'xlsx'); + + const request = () => + ajax({ + method: 'POST', + url: `/api/export/${apiSuffix}`, + headers: { + Authorization: getToken(), + }, + body: { + videoId: uid, + fromEditorial: true, + }, + responseType: 'blob', + }); + + const stream$ = defer(() => request()).pipe( + switchMap(({ response }) => { + const urlObject = window.URL.createObjectURL(new Blob([response])); + const link = document.createElement('a'); + + link.href = urlObject; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + + return of(setAction({ isLoading: false })); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getData.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getData.js new file mode 100644 index 0000000..898e521 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getData.js @@ -0,0 +1,96 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_AIW_TAGLOG_DATA } from '@/graphql/services/aiWorkbench/constants/tagLog'; + +import { GET_AIW_TAGLOG_DATA_PAYLOAD } from '@/graphql/services/aiWorkbench/types/tagLog/payload/data'; + +import { getDataAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors'; + +const gqlQuery = ` + query ${GET_AIW_TAGLOG_DATA}($payload: ${GET_AIW_TAGLOG_DATA_PAYLOAD}) { + ${GET_AIW_TAGLOG_DATA}(payload: $payload) { + hasNext + hasPrevious + groups { + startTime + endTime + log { + created + startTime + endTime + type + uid + version + videoUid + name + index + distributionStatus + iabCategories { + data + } + clipUrl + text + keywords { + category + value + id + probability + version + labelId + labelName + color + } + joinedKeywords { + category + value + } + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getDataAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const { startTime } = action.payload || {}; + const payload = { + videoId: video.uid, + startTime, + next: 100000, + }; + + const stream$ = gqlRequest({ + query: gqlQuery, + variables: { + payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const rowData = data[GET_AIW_TAGLOG_DATA]?.groups || []; + + if (!errors?.length) { + actions.push( + of( + setAction({ + data: rowData, + isLoading: false, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getTagInfo.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getTagInfo.js new file mode 100644 index 0000000..72c6551 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/getTagInfo.js @@ -0,0 +1,106 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TAG_SYSTEM_CATEGORY_CUSTOM, TAG_SYSTEM_CATEGORY_IAB, TAG_SYSTEM_CATEGORY_TEXT } from '../../constants'; +import { GET_AIW_TAGLOG_TAG_INFO } from '@/graphql/services/aiWorkbench/constants/tagLog'; + +import { GET_AIW_TAGLOG_TAG_INFO_PAYLOAD } from '@/graphql/services/aiWorkbench/types/tagLog/payload/tagInfo'; + +import { getInfoTooltipKey } from '../../helpers'; +import { tagInfoSelector } from '../selectors'; +import { getTagInfoAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors'; + +const TAG_REST_TYPES_DEFAULT_PAYLOAD = 'TAG_REST_TYPES_DEFAULT_PAYLOAD'; +const createRequestPayload = (tag) => { + const mapper = { + [TAG_SYSTEM_CATEGORY_CUSTOM]: { + labelId: tag.labelId || tag.id, + labelValue: tag.value, + }, + [TAG_SYSTEM_CATEGORY_TEXT]: { + text: tag.value, + }, + [TAG_REST_TYPES_DEFAULT_PAYLOAD]: { + uid: tag.id, + }, + }; + + return mapper[tag.category] || mapper[TAG_REST_TYPES_DEFAULT_PAYLOAD]; +}; + +const gqlQuery = ` + query ${GET_AIW_TAGLOG_TAG_INFO}($payload: ${GET_AIW_TAGLOG_TAG_INFO_PAYLOAD}) { + ${GET_AIW_TAGLOG_TAG_INFO}(payload: $payload) { + created + versions { + version + type + description + } + providers { + provider + models { + value + probability + } + } + editHistory { + refUserId + added + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getTagInfoAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const tagInfo = tagInfoSelector(state$.value); + const { logRowUid = null, tag, context } = action.payload; + const keywordId = tag.id || tag.value; + const payload = { + videoId: video.uid, + tagId: logRowUid, + isIab: tag.category === TAG_SYSTEM_CATEGORY_IAB, + ...createRequestPayload(tag), + }; + + const stream$ = gqlRequest( + { + query: gqlQuery, + variables: { + payload, + }, + }, + { showNotificationMessage: false }, + ).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const tagInfoFromApi = data[GET_AIW_TAGLOG_TAG_INFO]; + const key = getInfoTooltipKey(context, keywordId, logRowUid); + const tagInfoUpdated = { + ...tagInfo, + [key]: !errors?.length ? tagInfoFromApi : {}, + }; + + actions.push( + of( + setAction({ + tagInfo: tagInfoUpdated, + isLoading: false, + }), + ), + ); + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/index.js new file mode 100644 index 0000000..fdb9f3a --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/index.js @@ -0,0 +1,9 @@ +import { combineEpics } from 'redux-observable'; + +import download from './download'; +import getData from './getData'; +import getTagInfo from './getTagInfo'; +import updateSummaryTagsAction from './updateSelectedVideoAttributes'; +import upsertTags from './upsertTags'; + +export default combineEpics(getData, upsertTags, updateSummaryTagsAction, getTagInfo, download); diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/updateSelectedVideoAttributes.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/updateSelectedVideoAttributes.js new file mode 100644 index 0000000..4dd3bfd --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/updateSelectedVideoAttributes.js @@ -0,0 +1,93 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, switchMap } from 'rxjs/operators'; + +import { + getDataAction, + setAction as setActionTagLog, + updateSummaryTagsAction, + updateVideoVerificationStateAction, +} from '../slices'; +import { iabFlatToTree } from '@/modules/@common/iab/helpers'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors'; +import { setAction } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/slices'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; + +import queryVideoUpdateGQL from '@/modules/@common/gql/queries/videoUpdate'; + +const DELAY = 500; + +export default (action$, state$) => + action$.pipe( + ofType(updateSummaryTagsAction.type, updateVideoVerificationStateAction.type), + debounceTime(DELAY), + switchMap((action) => { + const videos = videosSelector(state$.value); + const selectedVideo = selectedVideoSelector(state$.value); + const { payload: updateVideo } = action; + + // optimistic update + const forUpdateVideo = { ...updateVideo }; + if (forUpdateVideo.iab) { + forUpdateVideo.iab = { + data: JSON.stringify({ + categories: iabFlatToTree(updateVideo.iab), + }), + }; + } + + const videosOptimisticUpdate = videos.map((originalVideo) => { + if (originalVideo.uid === selectedVideo.uid) { + return { ...originalVideo, ...forUpdateVideo }; + } + + return originalVideo; + }); + + const selectedVideoOptimisticUpdate = { + ...selectedVideo, + ...forUpdateVideo, + }; + + const stream$ = gqlRequest({ + query: queryVideoUpdateGQL, + variables: { + id: selectedVideo.uid, + video: { + ...updateVideo, + updateClip: true, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const updatedVideoList = videos.map((originalVideo) => + originalVideo.uid === selectedVideo.uid ? { ...originalVideo, ...data.videoUpdate } : originalVideo, + ); + + actions.push( + of(videosAction(updatedVideoList)), + of(setAction({ selectedVideo: { ...selectedVideo, ...data.videoUpdate } })), + ); + + if (action.type === updateSummaryTagsAction.type) { + actions.push(of(getDataAction())); + } + } + + return concat(...actions); + }), + ); + + return concat( + of(setAction({ selectedVideo: selectedVideoOptimisticUpdate })), + of(videosAction(videosOptimisticUpdate)), + of(setActionTagLog({ isLoading: false })), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/upsertTags.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/upsertTags.js new file mode 100644 index 0000000..ff96be5 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/epics/upsertTags.js @@ -0,0 +1,125 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPSERT_TAGS } from '@/graphql/services/aiWorkbench/constants/tagLog'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { UPSERT_TAGS_PAYLOAD_NAME } from '@/graphql/services/aiWorkbench/types/tagLog/payload/upserTag'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getSelectedVideoAction } from '../../../AiWorkbench/redux/slices'; +import { dataSelector } from '../selectors'; +import { setAction, upsertTagsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPSERT_TAGS}($payload: ${UPSERT_TAGS_PAYLOAD_NAME}) { + ${UPSERT_TAGS}(payload: $payload) { + startTime + uid + keywords { + category + value + id + labelId + labelName + color + } + } +}`; + +function getTagLogDataUpdated(tagLogData, res) { + let tagLogDataUpdated = [...tagLogData]; + const isExistStartTimeIndex = tagLogData.findIndex((tagLog) => tagLog.startTime === res.startTime); + + if (isExistStartTimeIndex >= 0) { + const logRow = tagLogData[isExistStartTimeIndex]; + const isLogExistInLogRowIndex = logRow.log.findIndex((log) => log.uid === res.uid); + const updatedLog = + isLogExistInLogRowIndex >= 0 + ? logRow.log.map((log, index) => (index === isLogExistInLogRowIndex ? res : log)) + : [...logRow.log, res]; + + tagLogDataUpdated[isExistStartTimeIndex] = { ...logRow, log: updatedLog }; + } else { + const startIndex = tagLogData.findIndex((tag) => tag.startTime >= res.startTime); + const logObject = { + startTime: res.startTime, + log: [res], + }; + + if (startIndex === 0) { + tagLogDataUpdated = [logObject, ...tagLogDataUpdated]; + } else if (startIndex === -1) { + tagLogDataUpdated = [...tagLogDataUpdated, logObject]; + } else { + tagLogDataUpdated = [ + ...tagLogDataUpdated.slice(0, startIndex), + logObject, + ...tagLogDataUpdated.slice(startIndex), + ]; + } + } + + return tagLogDataUpdated; +} + +export default (action$, state$) => + action$.pipe( + ofType(upsertTagsAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const tagLogData = dataSelector(state$.value); + + const { startTime, keywords, logRowUid } = action.payload; + + // optimistic update + const tagLogDataUpdatedOptimistics = getTagLogDataUpdated(tagLogData, { startTime, keywords, uid: logRowUid }); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + videoId: video.uid, + keywords, + startTime, + logRowUid, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const payload = { isLoading: false }; + const actions = [of(setAction(payload))]; + + if (!errors?.length) { + const res = data[UPSERT_TAGS]; + const tagLogDataUpdated = getTagLogDataUpdated(tagLogData, res); + + actions.push( + of(getSelectedVideoAction(video.uid)), + of(setAction({ data: tagLogDataUpdated })), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Tag updated', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + isLoading: true, + data: logRowUid ? tagLogDataUpdatedOptimistics : tagLogData, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/selectors/index.js new file mode 100644 index 0000000..afb01a7 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/selectors/index.js @@ -0,0 +1,50 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const dataSelector = (state) => state[nameSpace].data; +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const selectedStartTimeSelector = (state) => state[nameSpace].selectedStartTime; +export const selectedItemTimeSelector = (state) => state[nameSpace].selectedItemTime; +export const shouldWrapTagsSelector = (state) => state[nameSpace].shouldWrapTags; +export const searchSelector = (state) => state[nameSpace].search; +export const tagInfoSelector = (state) => state[nameSpace].tagInfo; +export const activeTagsFilterSelector = (state) => state[nameSpace].activeTagsFilter; + +// computed with simple cache +// for avoid rerenders +let lastActiveTags = null; +let lastData = null; +let lastResult = null; + +export const dataWithTagsFilterSelector = (state) => { + const activeTagsFilter = activeTagsFilterSelector(state); + const data = dataSelector(state); + + // Check if inputs have changed + if (lastResult && lastActiveTags === activeTagsFilter && lastData === data) { + return lastResult; + } + + const filteredData = data?.reduce((acc, frame) => { + const log = frame.log + .map((log$) => { + const keywords = log$.keywords.filter((keyword) => activeTagsFilter.includes(keyword.category)); + return keywords.length ? { ...log$, keywords } : null; + }) + .filter(Boolean); + + if (log.length) { + acc.push({ ...frame, log }); + } + + return acc; + }, []); + + // Cache the inputs and result + lastActiveTags = activeTagsFilter; + lastData = data; + lastResult = filteredData; + + return filteredData; +}; diff --git a/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/slices/index.js new file mode 100644 index 0000000..7f83383 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/TagLog/redux/slices/index.js @@ -0,0 +1,75 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + TAG_SYSTEM_CATEGORY_BRAND_SAFETY, + TAG_SYSTEM_CATEGORY_BRANDS, + TAG_SYSTEM_CATEGORY_CUSTOM, + TAG_SYSTEM_CATEGORY_IAB, + TAG_SYSTEM_CATEGORY_PEOPLE, +} from '../../constants'; + +const initialState = { + data: [], + isLoading: false, + // used when user click edit or delete btn + // for manipulate tag data + selectedStartTime: null, + // used for select row + // when player play or user click to row + selectedItemTime: null, + shouldWrapTags: true, + search: { + searchText: '', + startTime: null, + currentSearchedUniqIndex: null, + }, + tagInfo: {}, + // filters + activeTagsFilter: [ + TAG_SYSTEM_CATEGORY_PEOPLE, + TAG_SYSTEM_CATEGORY_BRANDS, + TAG_SYSTEM_CATEGORY_BRAND_SAFETY, + TAG_SYSTEM_CATEGORY_IAB, + TAG_SYSTEM_CATEGORY_CUSTOM, + ], +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/TAGLOG', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + unmountAction: (state) => { + state.data = null; + state.selectedStartTime = null; + state.selectedItemTime = null; + state.shouldWrapTags = true; + state.search = { + searchText: '', + startTime: null, + }; + state.tagInfo = {}; + }, + getDataAction: (state) => state, + upsertTagsAction: (state) => state, + updateSummaryTagsAction: (state) => state, + updateVideoVerificationStateAction: (state) => state, + getTagInfoAction: (state) => state, + downloadAction: (state) => state, + }, +}); + +export const { + setAction, + unmountAction, + getDataAction, + upsertTagsAction, + updateSummaryTagsAction, + updateVideoVerificationStateAction, + getTagInfoAction, + downloadAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/constants/index.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/constants/index.js new file mode 100644 index 0000000..5ba3b5a --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/constants/index.js @@ -0,0 +1,11 @@ +export const TRANSPARENT_PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +export const JOB_TYPE_THUMBNAIL_PROCESSING = 'THUMBNAIL_PROCESSING'; +export const JOB_TYPE_THUMBNAILS_CURRENT_FRAME_PROCESSING = 'THUMBNAILS_CURRENT_FRAME_PROCESSING'; +export const JOB_TYPE_THUMBNAILS_PUBLISHING = 'THUMBNAILS_PUBLISHING'; + +export const JOB_STATE_START = 'STARTED'; +export const JOB_STATE_DONE = 'DONE'; +export const JOB_STATE_PROCESSING = 'PROCESSING'; +export const JOB_STATE_ERROR = 'ERROR'; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/generateByAi.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/generateByAi.js new file mode 100644 index 0000000..5dbbf7e --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/generateByAi.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GENERATE_AIW_THUMBNAIL_OPTIONS } from '@/graphql/services/aiWorkbench/constants/thumbnail'; + +import { GENERATE_AIW_THUMBNAIL_OPTIONS_PAYLOAD } from '@/graphql/services/aiWorkbench/types/thumbnail/payload/generateThubnailOptions'; + +import { generateByAiAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/aiWorkbench/AiWorkbench/redux/selectors'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const queryGQL = ` + mutation ${GENERATE_AIW_THUMBNAIL_OPTIONS}($payload: ${GENERATE_AIW_THUMBNAIL_OPTIONS_PAYLOAD}) { + ${GENERATE_AIW_THUMBNAIL_OPTIONS}( + payload: $payload, + ) + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(generateByAiAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { + payload: { + videoId: video.uid, + notifyByEmail: action.payload, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push(of(setAction({ processing: true }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/getThumbnail.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/getThumbnail.js new file mode 100644 index 0000000..52d3bf0 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/getThumbnail.js @@ -0,0 +1,103 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { JOB_TYPE_THUMBNAIL_PROCESSING, JOB_TYPE_THUMBNAILS_PUBLISHING } from '../../constants'; +import { GET_AIW_THUMBNAIL_DATA } from '@/graphql/services/aiWorkbench/constants/thumbnail'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { GET_AIW_THUMBNAIL_PAYLOAD } from '@/graphql/services/aiWorkbench/types/thumbnail/payload/thumbnail'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getThumbnailAction, setAction, updateThumbnailInVideoListOrDetailAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query ${GET_AIW_THUMBNAIL_DATA}($payload: ${GET_AIW_THUMBNAIL_PAYLOAD} ) { + ${GET_AIW_THUMBNAIL_DATA}(payload: $payload) { + published + draft + draftPreview { + uid + url + } + thumbnailOptions { + uid + url + } + isGeneratingAvailable + causes + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getThumbnailAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const { processingComplete = false, processingJob = null } = action.payload || {}; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + payload: { + videoId: video.uid, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data[GET_AIW_THUMBNAIL_DATA]; + const thumbnails = response?.thumbnailOptions ?? []; + + if (errors?.length) { + actions.push( + of( + setAction({ + isLoading: false, + }), + ), + ); + } + + if (!errors?.length) { + actions.push( + of( + setAction({ + thumbnailDraftPreview: response.draftPreview, + thumbnails, + published: response.published, + draft: response.draft, + isLoading: false, + isGeneratingAvailable: response.isGeneratingAvailable, + causes: response.causes, + }), + ), + ); + + if (processingComplete) { + const MESSAGES = { + [JOB_TYPE_THUMBNAIL_PROCESSING]: 'Thumbnail process successfully completed', + [JOB_TYPE_THUMBNAILS_PUBLISHING]: 'Thumbnail successfully published', + }; + + const message = MESSAGES[processingJob]; + if (message) { + actions.push(of(showNotificationAction({ type: TYPE_SUCCESS, message }))); + } + + if (processingJob === JOB_TYPE_THUMBNAILS_PUBLISHING) { + actions.push(of(updateThumbnailInVideoListOrDetailAction())); + } + } + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/index.js new file mode 100644 index 0000000..8867533 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/index.js @@ -0,0 +1,25 @@ +import { combineEpics } from 'redux-observable'; + +import generateByAi from './generateByAi'; +import getThumbnail from './getThumbnail'; +import monitoringThumbnailProcessing from './monitoringThumbnailProcessing'; +import monitoringThumbnailPublish from './monitoringThumbnailPublish'; +import monitoringThumbnailSetToFrame from './monitoringThumbnailSetToFrame'; +import publish from './publish'; +import setThumbnailDraft from './setThumbnailDraft'; +import setThumbnailFromVideoFrame from './setThumbnailFromVideoFrame'; +import updateThumbnailInVideoListOrDetail from './updateThumbnailInVideoListOrDetail'; +import upload from './upload'; + +export default combineEpics( + getThumbnail, + generateByAi, + monitoringThumbnailProcessing, + monitoringThumbnailPublish, + monitoringThumbnailSetToFrame, + setThumbnailDraft, + upload, + publish, + setThumbnailFromVideoFrame, + updateThumbnailInVideoListOrDetail, +); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailProcessing.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailProcessing.js new file mode 100644 index 0000000..f69a0b8 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailProcessing.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + JOB_STATE_DONE, + JOB_STATE_ERROR, + JOB_STATE_PROCESSING, + JOB_STATE_START, + JOB_TYPE_THUMBNAIL_PROCESSING as JOB_TYPE, +} from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingJobSelector, processingSelector } from '../selectors'; +import { getThumbnailAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE); + const processing = processingSelector(state$.value); + const processingJob = processingJobSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!job || !video?.uid) { + return EMPTY; + } + + const actions = []; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingJob: JOB_TYPE, + processingPercentage: job.progress === 100 ? 99 : job.progress, + }), + ), + ); + } + + if ([JOB_STATE_ERROR, JOB_STATE_DONE].includes(job.state) && processing && processingJob === JOB_TYPE) { + actions.push( + of(setAction({ processing: false, processingPercentage: null, processingJob: null })), + of( + getThumbnailAction({ + processingComplete: true, + processingJob: JOB_TYPE, + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailPublish.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailPublish.js new file mode 100644 index 0000000..2c2ead8 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailPublish.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + JOB_STATE_DONE, + JOB_STATE_ERROR, + JOB_STATE_PROCESSING, + JOB_STATE_START, + JOB_TYPE_THUMBNAILS_PUBLISHING as JOB_TYPE, +} from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingJobSelector, processingSelector } from '../selectors'; +import { getThumbnailAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE); + const processing = processingSelector(state$.value); + const processingJob = processingJobSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!job || !video?.uid) { + return EMPTY; + } + + const actions = []; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingJob: JOB_TYPE, + processingPercentage: job.progress === 100 ? 99 : job.progress, + }), + ), + ); + } + + if ([JOB_STATE_ERROR, JOB_STATE_DONE].includes(job.state) && processing && processingJob === JOB_TYPE) { + actions.push( + of(setAction({ processing: false, processingPercentage: null, processingJob: null })), + of( + getThumbnailAction({ + processingComplete: true, + processingJob: JOB_TYPE, + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailSetToFrame.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailSetToFrame.js new file mode 100644 index 0000000..4d7d9a3 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/monitoringThumbnailSetToFrame.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + JOB_STATE_DONE, + JOB_STATE_ERROR, + JOB_STATE_PROCESSING, + JOB_STATE_START, + JOB_TYPE_THUMBNAILS_CURRENT_FRAME_PROCESSING as JOB_TYPE, +} from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingJobSelector, processingSelector } from '../selectors'; +import { getThumbnailAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE); + const processing = processingSelector(state$.value); + const processingJob = processingJobSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!job || !video?.uid) { + return EMPTY; + } + + const actions = []; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingJob: JOB_TYPE, + processingPercentage: job.progress === 100 ? 99 : job.progress, + }), + ), + ); + } + + if ([JOB_STATE_ERROR, JOB_STATE_DONE].includes(job.state) && processing && processingJob === JOB_TYPE) { + actions.push( + of(setAction({ processing: false, processingPercentage: null, processingJob: null })), + of( + getThumbnailAction({ + processingComplete: true, + processingJob: JOB_TYPE, + }), + ), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/publish.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/publish.js new file mode 100644 index 0000000..85211e0 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/publish.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PUBLISH_AIW_THUMBNAIL } from '@/graphql/services/aiWorkbench/constants/thumbnail'; + +import { PUBLISH_AIW_THUMBNAIL_PAYLOAD } from '@/graphql/services/aiWorkbench/types/thumbnail/payload/publishThumbnail'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { publishAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const queryGQL = ` + mutation ${PUBLISH_AIW_THUMBNAIL}($payload: ${PUBLISH_AIW_THUMBNAIL_PAYLOAD}) { + ${PUBLISH_AIW_THUMBNAIL}(payload: $payload) { + published + draft + processing + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(publishAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + payload: { + videoId: video.uid, + publish: action.payload, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data[PUBLISH_AIW_THUMBNAIL]; + const payload = { isLoading: false }; + const actions = []; + + if (!errors?.length) { + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + payload.isDirty = false; + + actions.push(of(setAction(payload)), of(monitoringStartAction())); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailDraft.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailDraft.js new file mode 100644 index 0000000..e824afe --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailDraft.js @@ -0,0 +1,66 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SET_AIW_THUMBNAIL_DRAFT } from '@/graphql/services/aiWorkbench/constants/thumbnail'; + +import { SET_AIW_THUMBNAIL_DRAFT_PAYLOAD } from '@/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailDarft'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { setAction, setThumbnailDraftAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation ${SET_AIW_THUMBNAIL_DRAFT}($payload: ${SET_AIW_THUMBNAIL_DRAFT_PAYLOAD}) { + ${SET_AIW_THUMBNAIL_DRAFT}(payload: $payload) { + published + draft + draftPreview { + uid + url + } + thumbnailOptions { + uid + url + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setThumbnailDraftAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + // payload object + // uid ( when selected options ) or thumbnail ( when upload ) + payload: { + videoId: video.uid, + ...action.payload, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data[SET_AIW_THUMBNAIL_DRAFT]; + const payload = { isLoading: false }; + + if (!errors?.length) { + payload.thumbnailDraftPreview = response.draftPreview; + payload.thumbnailOptions = response?.thumbnailOptions ?? []; + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + payload.isDirty = true; + } + + return concat(of(setAction(payload))); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailFromVideoFrame.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailFromVideoFrame.js new file mode 100644 index 0000000..4145561 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/setThumbnailFromVideoFrame.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME } from '@/graphql/services/aiWorkbench/constants/thumbnail'; + +import { SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME_PAYLOAD } from '@/graphql/services/aiWorkbench/types/thumbnail/payload/setThumbnailFromVideoFrame'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { setAction, setThumbnailFromVideoFrameAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const queryGQL = ` + mutation ${SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME}($payload: ${SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME_PAYLOAD}) { + ${SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME}(payload: $payload) { + published + draft + processing + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setThumbnailFromVideoFrameAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + payload: { + videoId: video.uid, + timestamp: action.payload, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const response = data[SET_AIW_THUMBNAIL_FROM_VIDEO_FRAME]; + const payload = { isLoading: false }; + const actions = []; + + if (!errors?.length) { + payload.published = response.published; + payload.draft = response.draft; + payload.processing = response.processing; + payload.isDirty = true; + + actions.push(of(setAction(payload)), of(monitoringStartAction())); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/updateThumbnailInVideoListOrDetail.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/updateThumbnailInVideoListOrDetail.js new file mode 100644 index 0000000..38c2861 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/updateThumbnailInVideoListOrDetail.js @@ -0,0 +1,35 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getSelectedVideoAction } from '../../../AiWorkbench/redux/slices'; +import { thumbnailDraftPreviewSelector } from '../selectors'; +import { updateThumbnailInVideoListOrDetailAction } from '../slices'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { selectedVideoSelector as detailSelectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; +import { reloadSelectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(updateThumbnailInVideoListOrDetailAction.type), + switchMap(() => { + const { uid } = selectedVideoSelector(state$.value); + const thumbnail = thumbnailDraftPreviewSelector(state$.value); + const videos = videosSelector(state$.value); + const isVideoDetailOpened = detailSelectedVideoSelector(state$.value); + + const updatedVideos = videos.map((originalVideo) => + originalVideo.uid === uid ? { ...originalVideo, thumbnailUrl: thumbnail?.url || '' } : originalVideo, + ); + + const actions = []; + actions.push(of(videosAction(updatedVideos)), of(getSelectedVideoAction(uid))); + if (isVideoDetailOpened) { + actions.push(of(reloadSelectedVideoAction())); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/upload.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/upload.js new file mode 100644 index 0000000..13b72fb --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/epics/upload.js @@ -0,0 +1,61 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { setAction, setThumbnailDraftAction, uploadAction } from '../slices'; +import { gqlRequest, uploadS3 } from '@/modules/@common/request'; + +const queryGetLinksGQL = ` + query Query($name: String, $filename: String!, $thumbnail: String, $contentOwnerId: Float!) { + S3UploadLink(name: $name, filename: $filename, thumbnail: $thumbnail, contentOwnerId: $contentOwnerId) { + uploadUrl + downloadUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(uploadAction.type), + switchMap((action) => { + const selectedVideo = selectedVideoSelector(state$.value); + const contentOwnerId = selectedVideo.contentOwner; + const file = action.payload; + + const filename = file.name.replace(/\s/g, '_'); + + const stream$ = gqlRequest({ + query: queryGetLinksGQL, + variables: { + contentOwnerId, + filename, + }, + }).pipe( + switchMap(({ data, errors }) => { + const { uploadUrl, downloadUrl } = data.S3UploadLink; + const actions = []; + + if (!errors?.length) { + const uploadStream$ = uploadS3(uploadUrl, file).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat(of(setThumbnailDraftAction({ thumbnail: downloadUrl }))); + } + + return concat(of(setAction({ isLoading: false }))); + }), + ); + + actions.push(uploadStream$); + } else { + actions.push(of(setAction({ isLoading: false }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/selectors/index.js new file mode 100644 index 0000000..6bc8706 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/selectors/index.js @@ -0,0 +1,19 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const thumbnailDraftPreviewSelector = (state) => state[nameSpace].thumbnailDraftPreview; +export const thumbnailsSelector = (state) => state[nameSpace].thumbnails; +export const publishedSelector = (state) => state[nameSpace].published; +export const draftSelector = (state) => state[nameSpace].draft; +export const processingSelector = (state) => state[nameSpace].processing; +export const processingJobSelector = (state) => state[nameSpace].processingJob; +export const processingPercentageSelector = (state) => state[nameSpace].processingPercentage; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const isDirtySelector = (state) => state[nameSpace].isDirty; + +export const shouldOpenGenerateByAiConfirm = (state) => state[nameSpace].shouldOpenGenerateByAiConfirm; + +export const isGeneratingAvailableSelector = (state) => state[nameSpace].isGeneratingAvailable; +export const causesSelector = (state) => state[nameSpace].causes; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/slices/index.js new file mode 100644 index 0000000..79c9bb1 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Thumbnail/redux/slices/index.js @@ -0,0 +1,60 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + thumbnailDraftPreview: null, + thumbnails: null, + published: false, + draft: false, + processing: false, + processingJob: null, + + processingPercentage: null, + + isLoading: false, + isDirty: false, + + shouldOpenGenerateByAiConfirm: false, + + isGeneratingAvailable: false, + causes: [], +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/THUMBNAIL', + initialState, + reducers: { + getThumbnailAction: (state) => state, + setThumbnailDraftAction: (state) => state, + generateByAiAction: (state) => state, + uploadAction: (state) => state, + publishAction: (state) => state, + setThumbnailFromVideoFrameAction: (state) => state, + updateThumbnailInVideoListOrDetailAction: (state) => state, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + toggleConfirmForGenerateByAiConfirmAction: (state, action) => { + state.shouldOpenGenerateByAiConfirm = action.payload; + }, + unmountAction: (state) => { + state.thumbnailDraftPreview = null; + state.thumbnails = null; + state.isDirty = false; + }, + }, +}); + +export const { + getThumbnailAction, + setThumbnailDraftAction, + generateByAiAction, + uploadAction, + publishAction, + setThumbnailFromVideoFrameAction, + setAction, + toggleConfirmForGenerateByAiConfirmAction, + unmountAction, + updateThumbnailInVideoListOrDetailAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/constants/index.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/constants/index.js new file mode 100644 index 0000000..d451eae --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/constants/index.js @@ -0,0 +1,16 @@ +export const JOB_TYPE_CC_TRANSLATION_PROCESSING = 'CC_TRANSLATION_PROCESSING'; +export const JOB_TYPE_SPEECH_RECOGNITION = 'SPEECH_RECOGNITION'; +export const JOB_TYPE_PUBLISH_TRANSCRIPT = 'PUBLISH_VIDEO_TRANSCRIPT'; + +export const JOB_STATE_START = 'STARTED'; +export const JOB_STATE_DONE = 'DONE'; +export const JOB_STATE_PROCESSING = 'PROCESSING'; +export const JOB_STATE_ERROR = 'ERROR'; + +export const FILE_VERSION_AUTO = 'AUTO'; +export const FILE_VERSION_MANUAL = 'MANUAL'; + +export const TOKEN_DATA_ATTR_START_TIME = 'data-lemmastarttime'; +export const TOKEN_DATA_ATTR_END_TIME = 'data-lemmaendtime'; +export const TOKEN_DATA_VALUE_ATTR_EMPTY = '[data-lemmaempty="empty"]'; +export const ROW_DATA_VALUE_ATTR_EMPTY = '[data-rowlemmaempty="empty"]'; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/addTranslationFile.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/addTranslationFile.js new file mode 100644 index 0000000..70d743b --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/addTranslationFile.js @@ -0,0 +1,77 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { addTranslationFileAction, getTranslationFilesAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { reloadSelectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchTranslationAddFileQuery( + $videoId: String! + $file: String! + $lang: String! + ) { + aiWorkbenchTranslationAddFile( + videoId: $videoId + file: $file + lang: $lang + ) { + file + lang + langName + sizeInBytes + source + version + state + operationId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(addTranslationFileAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + file: action.payload.file, + lang: action.payload.lang, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors?.length) { + actions.push( + of( + setAction({ + isLoading: false, + }), + ), + of(getTranslationFilesAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Translation was uploaded successfully.', + }), + ), + of(reloadSelectedVideoAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranscript.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranscript.js new file mode 100644 index 0000000..bfd932e --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranscript.js @@ -0,0 +1,72 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { FILE_VERSION_MANUAL, JOB_TYPE_SPEECH_RECOGNITION } from '../../constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { translationFilesSelector } from '../selectors'; +import { generateTranscriptByAiAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchTranscriptGenerateByAiQuery( + $videoId: String!, + $feedId: Int!, + ) { + aiWorkbenchTranscriptGenerateByAi( + videoId: $videoId, + feedId: $feedId, + ) { + complete + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(generateTranscriptByAiAction.type), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + const translations = translationFilesSelector(state$.value); + + const shouldShowErrorIfLangIsManual = translations.some( + (file) => file.lang === video.lang[0] && file.version === FILE_VERSION_MANUAL, + ); + + if (shouldShowErrorIfLangIsManual) { + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Manual transcript should be removed before the transcript generation', + }), + ), + ); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + feedId: video.feedSourceId, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push(of(setAction({ processing: true, currentProcessingJobType: JOB_TYPE_SPEECH_RECOGNITION }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranslation.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranslation.js new file mode 100644 index 0000000..b601663 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/generateByAiTranslation.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { JOB_TYPE_CC_TRANSLATION_PROCESSING } from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { generateByAiAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchTranslationGenerateByAiQuery( + $videoId: String!, + $targetLanguages: [String], + ) { + aiWorkbenchTranslationGenerateByAi( + videoId: $videoId, + targetLanguages: $targetLanguages, + ) { + complete + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(generateByAiAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const targetLanguages = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + targetLanguages, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push( + of(setAction({ processing: true, currentProcessingJobType: JOB_TYPE_CC_TRANSLATION_PROCESSING })), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getSubtitleByLang.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getSubtitleByLang.js new file mode 100644 index 0000000..51b969a --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getSubtitleByLang.js @@ -0,0 +1,75 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { getSubtitleAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query AiWorkbenchGetTranslationSubtitleQuery( + $videoId: String!, + $lang: String!, + ) { + aiWorkbenchGetTranslationSubtitle( + videoId: $videoId, + lang: $lang, + ) { + data { + uid + startTime + endTime + text + tokens { + segmentId + startTime + endTime + value + originalValue + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getSubtitleAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const lang = action.payload; + + if (!video?.uid) { + return concat(of(setAction({ selectedTranslateFile: null }))); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + lang, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.aiWorkbenchGetTranslationSubtitle; + const subtitle = response?.data ?? []; + + if (!errors?.length) { + actions.push( + of( + setAction({ + subtitle, + isLoading: false, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getTranslationFiles.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getTranslationFiles.js new file mode 100644 index 0000000..7f32957 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/getTranslationFiles.js @@ -0,0 +1,117 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { selectedTranslateFileSelector } from '../selectors'; +import { getSubtitleAction, getTranslationFilesAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query AiWorkbenchGetTranslationFilesQuery( + $videoId: String!, + ) { + aiWorkbenchGetTranslationFiles( + videoId: $videoId, + ) { + data { + file + lang + langName + sizeInBytes + source + version + state + operationId + } + } + aiWorkbenchGetTranslationCcSegments( + videoId: $videoId + ) { + data { + uid + transcript + provider + speakerId + startTime + endTime + tokens { + startTime + endTime + word + kind + confidence + } + } + } + aiWorkbenchGetTranslationCcLanguages { + id + name + } + aiWorkbenchGetTranslationTranslateLanguages { + id + name + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getTranslationFilesAction.type), + switchMap((action) => { + const video = selectedVideoSelector(state$.value); + const selectedTranslateFile = selectedTranslateFileSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const { + aiWorkbenchGetTranslationFiles, + aiWorkbenchGetTranslationCcSegments, + aiWorkbenchGetTranslationCcLanguages, + aiWorkbenchGetTranslationTranslateLanguages, + } = data; + + const translationFiles = aiWorkbenchGetTranslationFiles?.data ?? []; + const selectedTranslateFileByDefault = + selectedTranslateFile || + translationFiles.find((file) => file.lang === video.lang[0])?.lang || + translationFiles[0]?.lang; + + const ccSegments = aiWorkbenchGetTranslationCcSegments?.data; + + if (!errors?.length) { + const state = { + translationFiles, + ccSegments, + selectedTranslateFile: selectedTranslateFileByDefault ?? null, + languages: aiWorkbenchGetTranslationCcLanguages, + translateLanguages: aiWorkbenchGetTranslationTranslateLanguages, + isLoading: selectedTranslateFileByDefault, + }; + + if (action.payload?.shouldNotRequestSubtitle) { + state.isLoading = false; + } else { + state.subtitle = null; + } + + actions.push(of(setAction(state))); + + if (selectedTranslateFileByDefault && !action.payload?.shouldNotRequestSubtitle) { + actions.push(of(getSubtitleAction(selectedTranslateFileByDefault))); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/index.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/index.js new file mode 100644 index 0000000..cde2734 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/index.js @@ -0,0 +1,27 @@ +import { combineEpics } from 'redux-observable'; + +import addTranslationFile from './addTranslationFile'; +import generateByAiTranscript from './generateByAiTranscript'; +import generateByAiTranslation from './generateByAiTranslation'; +import getSubtitleBytLang from './getSubtitleByLang'; +import getTranslationFile from './getTranslationFiles'; +import monitoringPublishTrancript from './monitoringPublishTrancript'; +import monitoringTranscript from './monitoringTrancript'; +import monitoringTranslations from './monitoringTranslations'; +import removeTranslationFile from './removeTranslationFile'; +import setSubtitle from './setSubtitle'; +import upload from './upload'; + +export default combineEpics( + getTranslationFile, + getSubtitleBytLang, + removeTranslationFile, + addTranslationFile, + upload, + generateByAiTranslation, + monitoringTranslations, + monitoringPublishTrancript, + generateByAiTranscript, + monitoringTranscript, + setSubtitle, +); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringPublishTrancript.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringPublishTrancript.js new file mode 100644 index 0000000..c64b8b1 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringPublishTrancript.js @@ -0,0 +1,64 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + JOB_STATE_DONE, + JOB_STATE_ERROR, + JOB_STATE_PROCESSING, + JOB_STATE_START, + JOB_TYPE_PUBLISH_TRANSCRIPT, +} from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingSelector } from '../selectors'; +import { getTranslationFilesAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +let IS_INITIAL = true; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE_PUBLISH_TRANSCRIPT); + const processing = processingSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!video?.uid || !job || (IS_INITIAL && job?.state === JOB_STATE_DONE)) { + return EMPTY; + } + + const actions = []; + IS_INITIAL = false; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingPercentage: job.progress === 100 ? 99 : job.progress, + currentProcessingJobType: JOB_TYPE_PUBLISH_TRANSCRIPT, + }), + ), + ); + } + + if ((job.state === JOB_STATE_DONE || job.state === JOB_STATE_ERROR) && processing) { + IS_INITIAL = true; + actions.push( + of( + setAction({ + processing: false, + isEditMode: false, + processingPercentage: null, + currentProcessingJobType: null, + }), + ), + of(getTranslationFilesAction()), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTrancript.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTrancript.js new file mode 100644 index 0000000..81f1677 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTrancript.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + JOB_STATE_DONE, + JOB_STATE_ERROR, + JOB_STATE_PROCESSING, + JOB_STATE_START, + JOB_TYPE_SPEECH_RECOGNITION, +} from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingSelector } from '../selectors'; +import { getTranslationFilesAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +let IS_INITIAL = true; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE_SPEECH_RECOGNITION); + const processing = processingSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!video?.uid || !job || (IS_INITIAL && job?.state === JOB_STATE_DONE)) { + return EMPTY; + } + + const actions = []; + IS_INITIAL = false; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingPercentage: job.progress === 100 ? 99 : job.progress, + currentProcessingJobType: JOB_TYPE_SPEECH_RECOGNITION, + }), + ), + ); + } + + if ((job.state === JOB_STATE_DONE || job.state === JOB_STATE_ERROR) && processing) { + IS_INITIAL = true; + actions.push( + of(setAction({ processing: false, processingPercentage: null, currentProcessingJobType: null })), + of(getTranslationFilesAction()), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTranslations.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTranslations.js new file mode 100644 index 0000000..0d73413 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/monitoringTranslations.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + JOB_STATE_DONE, + JOB_STATE_ERROR, + JOB_STATE_PROCESSING, + JOB_STATE_START, + JOB_TYPE_CC_TRANSLATION_PROCESSING, +} from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { processingSelector } from '../selectors'; +import { getTranslationFilesAction, setAction } from '../slices'; +import { monitoringDataAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +let IS_INITIAL = true; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringDataAction.type), + switchMap((action) => { + const job = action.payload.find((o) => o.type === JOB_TYPE_CC_TRANSLATION_PROCESSING); + const processing = processingSelector(state$.value); + const video = selectedVideoSelector(state$.value); + + if (!video?.uid || !job || (IS_INITIAL && job?.state === JOB_STATE_DONE)) { + return EMPTY; + } + + const actions = []; + IS_INITIAL = false; + + if (job.state === JOB_STATE_START || job.state === JOB_STATE_PROCESSING) { + actions.push( + of( + setAction({ + processing: true, + processingPercentage: job.progress === 100 ? 99 : job.progress, + currentProcessingJobType: JOB_TYPE_CC_TRANSLATION_PROCESSING, + }), + ), + ); + } + + if ((job.state === JOB_STATE_DONE || job.state === JOB_STATE_ERROR) && processing) { + IS_INITIAL = true; + actions.push( + of(setAction({ processing: false, processingPercentage: null, currentProcessingJobType: null })), + of(getTranslationFilesAction({ shouldNotRequestSubtitle: true })), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/removeTranslationFile.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/removeTranslationFile.js new file mode 100644 index 0000000..ab0368b --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/removeTranslationFile.js @@ -0,0 +1,86 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { selectedTranslateFileSelector, translationFilesSelector } from '../selectors'; +import { getTranslationFilesAction, removeTranslationFileAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { reloadSelectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchTranslationRemoveFileQuery( + $videoId: String! + $file: String + $lang: String + $langName: String + $sizeInBytes: Int + $source: String + $version: String + $state: String + $operationId: String + ) { + aiWorkbenchTranslationRemoveFile( + videoId: $videoId + file: $file + lang: $lang + langName: $langName + sizeInBytes: $sizeInBytes + source: $source + version: $version + state: $state + operationId: $operationId + ) { + complete + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(removeTranslationFileAction.type), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + const translationFiles = translationFilesSelector(state$.value); + const selectedTranslationFile = selectedTranslateFileSelector(state$.value); + const translationFileObjectToRemove = translationFiles.find((file) => file.lang === selectedTranslationFile); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: video.uid, + ...translationFileObjectToRemove, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors?.length) { + actions.push( + of( + setAction({ + isLoading: false, + selectedTranslateFile: null, + }), + ), + of(getTranslationFilesAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Translation was removed successfully.', + }), + ), + of(reloadSelectedVideoAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/setSubtitle.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/setSubtitle.js new file mode 100644 index 0000000..56025fa --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/setSubtitle.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { JOB_TYPE_PUBLISH_TRANSCRIPT } from '../../constants'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { editDataSelector, selectedTranslateFileSelector } from '../selectors'; +import { setAction, setSubtitleAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { monitoringStartAction } from '@/modules/editorial/editorialSearch/redux/slices'; + +const queryGQL = ` + mutation AiWorkbenchSetSubtitleQuery($payload: AiWorkbenchSetSubtitleInputType) { + aiWorkbenchSetSubtitle(payload: $payload) + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setSubtitleAction.type), + switchMap(() => { + const video = selectedVideoSelector(state$.value); + const lang = selectedTranslateFileSelector(state$.value); + const editData = editDataSelector(state$.value); + + const payload = { + videoId: video.uid, + lang, + lemmas: Object.entries(editData).map(([uid, lemma]) => ({ + uid, + startTime: lemma.startTime, + endTime: lemma.endTime, + tokens: Object.values(lemma.tokens), + })), + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + payload, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + actions.push(of(monitoringStartAction())); + actions.push(of(setAction({ processing: true, currentProcessingJobType: JOB_TYPE_PUBLISH_TRANSCRIPT }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/upload.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/upload.js new file mode 100644 index 0000000..4357ffb --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/epics/upload.js @@ -0,0 +1,67 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../../../AiWorkbench/redux/selectors'; +import { addTranslationFileAction, setAction, uploadAction } from '../slices'; +import { gqlRequest, uploadS3 } from '@/modules/@common/request'; + +const queryGetLinksGQL = ` + query Query($name: String, $filename: String!, $thumbnail: String, $contentOwnerId: Float!) { + S3UploadLink(name: $name, filename: $filename, thumbnail: $thumbnail, contentOwnerId: $contentOwnerId) { + uploadUrl + downloadUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(uploadAction.type), + switchMap((action) => { + const selectedVideo = selectedVideoSelector(state$.value); + const contentOwnerId = selectedVideo.contentOwner; + const { file, language } = action.payload; + const filename = file.name.replace(/\s/g, '_'); + + const stream$ = gqlRequest({ + query: queryGetLinksGQL, + variables: { + contentOwnerId, + filename, + }, + }).pipe( + switchMap(({ data, errors }) => { + const { uploadUrl, downloadUrl } = data.S3UploadLink; + const actions = []; + + if (!errors?.length) { + const uploadStream$ = uploadS3(uploadUrl, file).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + addTranslationFileAction({ + file: downloadUrl, + lang: language, + }), + ), + ); + } + + return concat(of(setAction({ isLoading: false }))); + }), + ); + + actions.push(uploadStream$); + } else { + actions.push(of(setAction({ isLoading: false }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/selectors/index.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/selectors/index.js new file mode 100644 index 0000000..c603643 --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/selectors/index.js @@ -0,0 +1,22 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const translationFilesSelector = (state) => state[nameSpace].translationFiles; +export const selectedTranslateFileSelector = (state) => state[nameSpace].selectedTranslateFile; +export const getSubtitleSelector = (state) => state[nameSpace].subtitle; +export const getCcSegmentsSelector = (state) => state[nameSpace].ccSegments; +export const getLanguagesSelector = (state) => state[nameSpace].languages; +export const getTranslateLanguagesSelector = (state) => state[nameSpace].translateLanguages; +export const publishedSelector = (state) => state[nameSpace].published; +export const processingSelector = (state) => state[nameSpace].processing; +export const currentProcessingJobTypeSelector = (state) => state[nameSpace].currentProcessingJobType; +export const processingPercentageSelector = (state) => state[nameSpace].processingPercentage; +export const shouldOpenGenerateByAiConfirmSelector = (state) => state[nameSpace].shouldOpenGenerateByAiConfirm; + +export const isEditModeSelector = (state) => state[nameSpace].isEditMode; +export const editDataSelector = (state) => state[nameSpace].editData; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const isAutoscrollSelector = (state) => state[nameSpace].isAutoscroll; +export const isHighlightSpeechSelector = (state) => state[nameSpace].isHighlightSpeech; diff --git a/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/slices/index.js b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/slices/index.js new file mode 100644 index 0000000..07221dd --- /dev/null +++ b/anyclip/src/modules/editorial/aiWorkbench/Translations/redux/slices/index.js @@ -0,0 +1,86 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + translationFiles: null, + selectedTranslateFile: null, + ccSegments: null, + languages: [], + translateLanguages: [], + subtitle: null, + published: false, + processing: false, + currentProcessingJobType: null, + processingPercentage: null, + isLoading: false, + shouldOpenGenerateByAiConfirm: false, + + isEditMode: false, + // shape + // { uid: { tokensId: token } } + editData: {}, + + // toolbox + isAutoscroll: true, + isHighlightSpeech: false, +}; + +export const slice = createSlice({ + name: '@@AIWORKBENCH/TRANSLATES', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getTranslationFilesAction: (state) => state, + removeTranslationFileAction: (state) => state, + getSubtitleAction: (state, action) => { + state.selectedTranslateFile = action.payload; + }, + uploadAction: (state) => state, + addTranslationFileAction: (state) => state, + toggleConfirmForGenerateByAiConfirmAction: (state, action) => { + state.shouldOpenGenerateByAiConfirm = action.payload; + }, + generateByAiAction: (state) => state, + generateTranscriptByAiAction: (state) => state, + setEditDataAction: (state, action) => { + const { uid, startTime, endTime, token } = action.payload; + + if (!state.editData[uid]) { + state.editData[uid] = { + startTime, + endTime, + tokens: {}, + }; + } + + state.editData[uid].tokens[token.startTime] = token; + }, + setSubtitleAction: (state) => state, + unmountAction: (state) => { + state.translationFiles = null; + state.selectedTranslateFile = null; + state.subtitle = null; + state.ccSegments = null; + state.isEditMode = false; + state.editData = {}; + }, + }, +}); + +export const { + setAction, + getTranslationFilesAction, + removeTranslationFileAction, + getSubtitleAction, + uploadAction, + addTranslationFileAction, + toggleConfirmForGenerateByAiConfirmAction, + generateByAiAction, + generateTranscriptByAiAction, + setEditDataAction, + setSubtitleAction, + unmountAction, +} = slice.actions; diff --git a/src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionAddTags/BulkActionAddTags.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionArchive/BulkActionArchive.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionArchive/BulkActionArchive.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionArchive/BulkActionArchive.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionArchive/BulkActionArchive.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionPanelSuccess.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionPanelSuccess.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionPanelSuccess.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionPanelSuccess.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionsPanelSuccess.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionsPanelSuccess.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionsPanelSuccess.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionPanelSuccess/BulkActionsPanelSuccess.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceHubs/BulkActionReplaceHubs.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/BulkActionReplaceTags.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/BulkActionReplaceTags.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionReplaceTags/BulkActionReplaceTags.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/BulkActionReplaceTags.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionReplaceTags/components/TagsItem/TagsItem.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/BulkActionShare.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ChangeAccessLevel/ChangeAccessLevel.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/ShareToUsers/ShareToUsers.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/components/Title/Title.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionShare/constants/index.js b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/constants/index.js similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionShare/constants/index.js rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/constants/index.js diff --git a/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getHubs.js b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getHubs.js new file mode 100644 index 0000000..977a86d --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getHubs.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_HUBS_FOR_SHARE_ACTION } from '@/graphql/services/videoBulkActions/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/videoBulkActions/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_HUBS_FOR_SHARE_ACTION}($payload: ${PAYLOAD_NAME}) { + ${GET_HUBS_FOR_SHARE_ACTION}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_HUBS_FOR_SHARE_ACTION].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getUsers.js b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getUsers.js new file mode 100644 index 0000000..6b0ade3 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/getUsers.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_USERS_FOR_SHARE_ACTION } from '@/graphql/services/videoBulkActions/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/videoBulkActions/types/payload/user'; + +import { getUserOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_USERS_FOR_SHARE_ACTION}($payload: ${PAYLOAD_NAME}) { + ${GET_USERS_FOR_SHARE_ACTION}(payload: $payload) { + records { + id + email + firstName + lastName + } + } + } +`; + +const getResponse = ({ data }) => data[GET_USERS_FOR_SHARE_ACTION].records; + +export default (action$) => + action$.pipe( + ofType(getUserOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + userOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/index.js b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/index.js new file mode 100644 index 0000000..9c837af --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getHubs from './getHubs'; +import getUsers from './getUsers'; + +export default combineEpics(getUsers, getHubs); diff --git a/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/selectors/index.js b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/selectors/index.js new file mode 100644 index 0000000..52cb1a8 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/selectors/index.js @@ -0,0 +1,13 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const isUserShareModeSelector = (state) => state[nameSpace].isUserShareMode; +export const usersSelector = (state) => state[nameSpace].users; +export const userOptionsSelector = (state) => state[nameSpace].userOptions; +export const messageSelector = (state) => state[nameSpace].message; + +export const accessLevelSelector = (state) => state[nameSpace].accessLevel; +export const hubsSelector = (state) => state[nameSpace].hubs; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; +export const sendHubsNotificationSelector = (state) => state[nameSpace].sendHubsNotification; diff --git a/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/slices/index.js b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/slices/index.js new file mode 100644 index 0000000..49bb912 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/components/BulkActionShare/redux/slices/index.js @@ -0,0 +1,34 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + isUserShareMode: false, + users: [], + userOptions: null, + message: '', + + accessLevel: '', + hubs: [], + hubOptions: null, + sendHubsNotification: false, +}; + +export const slice = createSlice({ + name: '@@bulkActions/share', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + clearAction: (state) => { + Object.keys(initialState).forEach((key) => { + state[key] = initialState[key]; + }); + }, + getUserOptionsAction: (state) => state, + getHubOptionsAction: (state) => state, + }, +}); + +export const { setAction, clearAction, getUserOptionsAction, getHubOptionsAction } = slice.actions; diff --git a/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/BulkActionStatusDialog.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/BulkActionStatusDialog.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionStatusDialog/BulkActionStatusDialog.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/BulkActionStatusDialog.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Completed/Completed.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionStatusDialog/components/Processing/Processing.module.scss diff --git a/src/modules/editorial/bulkActions/components/BulkActionsActivateButton/BulkActionsActivateButton.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionsActivateButton/BulkActionsActivateButton.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionsActivateButton/BulkActionsActivateButton.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionsActivateButton/BulkActionsActivateButton.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.jsx b/anyclip/src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.jsx similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.jsx rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.jsx diff --git a/src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.module.scss b/anyclip/src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.module.scss similarity index 100% rename from src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.module.scss rename to anyclip/src/modules/editorial/bulkActions/components/BulkActionsPanel/BulkActionsPanel.module.scss diff --git a/anyclip/src/modules/editorial/bulkActions/constants/index.js b/anyclip/src/modules/editorial/bulkActions/constants/index.js new file mode 100644 index 0000000..92f96a1 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/constants/index.js @@ -0,0 +1,12 @@ +export const BULK_ACTIONS_ARCHIVE = 'BULK_ACTIONS_ARCHIVE'; +export const BULK_ACTIONS_ADD_TAGS = 'BULK_ACTIONS_ADD_TAGS'; +export const BULK_ACTIONS_REPLACE_TAGS = 'BULK_ACTIONS_REPLACE_TAGS'; +export const BULK_ACTIONS_REPLACE_SITES = 'BULK_ACTIONS_REPLACE_SITES'; +export const BULK_ACTIONS_SHARE_WITH_USERS = 'BULK_ACTIONS_SHARE_WITH_USERS'; +export const BULK_ACTIONS_SHARE_ACCESS_LEVEL = 'BULK_ACTIONS_SHARE_ACCESS_LEVEL'; + +export const JOB_STATE_CREATED = 'CREATED'; +export const JOB_STATE_PROCESSING = 'PROCESSING'; +export const JOB_STATE_INTERRUPTED = 'INTERRUPTED'; +export const JOB_STATE_READY = 'READY'; +export const JOB_STATE_ERROR = 'ERROR'; diff --git a/src/modules/editorial/bulkActions/hooks/useGetSelectedVideo.js b/anyclip/src/modules/editorial/bulkActions/hooks/useGetSelectedVideo.js similarity index 100% rename from src/modules/editorial/bulkActions/hooks/useGetSelectedVideo.js rename to anyclip/src/modules/editorial/bulkActions/hooks/useGetSelectedVideo.js diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/addTags.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/addTags.js new file mode 100644 index 0000000..810a9e6 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/addTags.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { excludeVideoIdsSelector, isSelectedAllSelector, monitoringIdsSelector, videoIdsSelector } from '../selectors'; +import { addTagsAction, closeAction, getMonitoringStateAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { currentFiltersAsStringSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; + +const queryGQL = ` + mutation VideoBulkActionAddTag( + $applyToAllVideo: Boolean, + $videoIds: [String], + $excludeVideoIds: [String], + $filters: String, + $keywords: [BulkActionTagKeywordType], + $iab: [BulkActionTagIabType], + $labels: [BulkActionTagLabelType], + ) { + videoBulkActionAddTag( + applyToAllVideo: $applyToAllVideo, + videoIds: $videoIds, + excludeVideoIds: $excludeVideoIds, + filters: $filters, + keywords: $keywords, + iab: $iab, + labels: $labels, + ) { + monitoringId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(addTagsAction.type), + switchMap((action) => { + const { keywords, iab, labels } = action.payload; + const state = state$.value; + + const applyToAllVideo = isSelectedAllSelector(state); + const videoIds = videoIdsSelector(state); + const excludeVideoIds = excludeVideoIdsSelector(state); + const filters = currentFiltersAsStringSelector(state); + const monitoringIds = monitoringIdsSelector(state); + + const variables = { + applyToAllVideo, + videoIds, + excludeVideoIds, + filters, + keywords, + iab, + labels, + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.videoBulkActionAddTag; + + if (!errors?.length) { + actions.push( + of( + setAction({ + monitoringIds: [...monitoringIds, response.monitoringId], + }), + ), + of(getMonitoringStateAction()).pipe(delay(500)), + of(closeAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/archive.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/archive.js new file mode 100644 index 0000000..0de5ce6 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/archive.js @@ -0,0 +1,73 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { excludeVideoIdsSelector, isSelectedAllSelector, monitoringIdsSelector, videoIdsSelector } from '../selectors'; +import { archiveAction, closeAction, getMonitoringStateAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { currentFiltersAsStringSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; + +const queryGQL = ` + mutation VideoBulkActionArchive( + $applyToAllVideo: Boolean, + $videoIds: [String], + $excludeVideoIds: [String], + $filters: String, + ) { + videoBulkActionArchive( + applyToAllVideo: $applyToAllVideo, + videoIds: $videoIds, + excludeVideoIds: $excludeVideoIds, + filters: $filters, + ) { + monitoringId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(archiveAction.type), + switchMap(() => { + const state = state$.value; + + const applyToAllVideo = isSelectedAllSelector(state); + const videoIds = videoIdsSelector(state); + const excludeVideoIds = excludeVideoIdsSelector(state); + const filters = currentFiltersAsStringSelector(state); + const monitoringIds = monitoringIdsSelector(state); + + const variables = { + applyToAllVideo, + videoIds, + excludeVideoIds, + filters, + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.videoBulkActionArchive; + + if (!errors?.length) { + actions.push( + of( + setAction({ + monitoringIds: [...monitoringIds, response.monitoringId], + }), + ), + of(getMonitoringStateAction()).pipe(delay(500)), + of(closeAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/changeAccessLevelAction.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/changeAccessLevelAction.js new file mode 100644 index 0000000..ee5b59b --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/changeAccessLevelAction.js @@ -0,0 +1,93 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { excludeVideoIdsSelector, isSelectedAllSelector, monitoringIdsSelector, videoIdsSelector } from '../selectors'; +import { changeAccessLevelAction, closeAction, getMonitoringStateAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { + accessLevelSelector, + hubsSelector, + sendHubsNotificationSelector, +} from '@/modules/editorial/bulkActions/components/BulkActionShare/redux/selectors'; +import { currentFiltersAsStringSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; + +const queryGQL = ` + mutation VideoBulkActionChangeAccessLevel( + $applyToAllVideo: Boolean, + $videoIds: [String], + $excludeVideoIds: [String], + $filters: String, + $level: String, + $hubs: [String], + $sendHubsNotification: Boolean, + ) { + videoBulkActionChangeAccessLevel( + applyToAllVideo: $applyToAllVideo, + videoIds: $videoIds, + excludeVideoIds: $excludeVideoIds, + filters: $filters, + level: $level, + hubs: $hubs, + sendHubsNotification: $sendHubsNotification, + ) { + monitoringId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(changeAccessLevelAction.type), + switchMap(() => { + const state = state$.value; + + const applyToAllVideo = isSelectedAllSelector(state); + const videoIds = videoIdsSelector(state); + const excludeVideoIds = excludeVideoIdsSelector(state); + const filters = currentFiltersAsStringSelector(state); + const monitoringIds = monitoringIdsSelector(state); + const level = accessLevelSelector(state); + const hubs = hubsSelector(state); + const sendHubsNotification = sendHubsNotificationSelector(state); + + const variables = { + applyToAllVideo, + videoIds, + excludeVideoIds, + filters, + level, + }; + + if (hubs?.length) { + variables.hubs = hubs.map((hub) => `${hub.id}`); + variables.sendHubsNotification = sendHubsNotification; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.videoBulkActionChangeAccessLevel; + + if (!errors?.length) { + actions.push( + of( + setAction({ + monitoringIds: [...monitoringIds, response.monitoringId], + }), + ), + of(getMonitoringStateAction()).pipe(delay(500)), + of(closeAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/getHubOptions.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/getHubOptions.js new file mode 100644 index 0000000..eac9ce2 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/getHubOptions.js @@ -0,0 +1,55 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getHubsOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getVideoUserHubs( + $pageSize: Int + $searchText: String + ) { + getVideoUserHubs( + pageSize: $pageSize, + searchText: $searchText, + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { getVideoUserHubs } }) => + getVideoUserHubs.records.map((record) => ({ value: record.id, label: record.name })); + +export default (action$) => + action$.pipe( + ofType(getHubsOptionsAction.type), + switchMap(() => { + const variables = { + pageSize: 10000, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const hubOptions = getResponse(response); + + actions.push(of(setAction({ hubOptions }))); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ hubOptions: null })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/getMonitoringState.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/getMonitoringState.js new file mode 100644 index 0000000..7aa4ca0 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/getMonitoringState.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { JOB_STATE_CREATED, JOB_STATE_INTERRUPTED, JOB_STATE_PROCESSING } from '../../constants'; + +import { monitoringIdsSelector } from '../selectors'; +import { getMonitoringStateAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query VideoBulkActionGetMonitoringState( + $monitoringIds: [String], + ) { + videoBulkActionGetMonitoringState( + monitoringIds: $monitoringIds, + ) { + entityId + entityType + state + message + type + } + } +`; + +const DELAY = 1000; + +export default (action$, state$) => + action$.pipe( + ofType(getMonitoringStateAction.type), + switchMap(() => { + const monitoringIds = monitoringIdsSelector(state$.value); + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { + monitoringIds, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setAction({ isLoading: false }))]; + + if (!errors?.length) { + const response = data.videoBulkActionGetMonitoringState; + + const hasProcessingState = + response.length > 0 && + response.some((state) => + [JOB_STATE_PROCESSING, JOB_STATE_CREATED, JOB_STATE_INTERRUPTED].includes(state.state), + ); + + actions.push(of(setAction({ monitoringState: response }))); + + if (hasProcessingState) { + actions.push(of(getMonitoringStateAction()).pipe(delay(DELAY))); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/index.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/index.js new file mode 100644 index 0000000..79f7ae1 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/index.js @@ -0,0 +1,21 @@ +import { combineEpics } from 'redux-observable'; + +import addTags from './addTags'; +import archive from './archive'; +import changeAccessLevel from './changeAccessLevelAction'; +import getHubOptions from './getHubOptions'; +import getMonitoringState from './getMonitoringState'; +import replaceHubs from './replaceHubs'; +import replaceTags from './replaceTags'; +import shareWithUsers from './shareWithUsers'; + +export default combineEpics( + archive, + addTags, + replaceTags, + getMonitoringState, + getHubOptions, + replaceHubs, + shareWithUsers, + changeAccessLevel, +); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/replaceHubs.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/replaceHubs.js new file mode 100644 index 0000000..52f6d1f --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/replaceHubs.js @@ -0,0 +1,85 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { excludeVideoIdsSelector, isSelectedAllSelector, monitoringIdsSelector, videoIdsSelector } from '../selectors'; +import { closeAction, getMonitoringStateAction, replaceHubsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { currentFiltersAsStringSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; + +const queryGQL = ` + mutation VideoBulkActionReplaceHubs( + $applyToAllVideo: Boolean, + $videoIds: [String], + $excludeVideoIds: [String], + $filters: String, + $findSites: [String] + $replaceSites: [String], + ) { + videoBulkActionReplaceHubs( + applyToAllVideo: $applyToAllVideo, + videoIds: $videoIds, + excludeVideoIds: $excludeVideoIds, + filters: $filters, + findSites: $findSites, + replaceSites: $replaceSites, + ) { + monitoringId + } + } +`; + +const siteMap = (site) => `${site.value}`; + +export default (action$, state$) => + action$.pipe( + ofType(replaceHubsAction.type), + switchMap((action) => { + const { find, replace } = action.payload; + const state = state$.value; + + const applyToAllVideo = isSelectedAllSelector(state); + const videoIds = videoIdsSelector(state); + const excludeVideoIds = excludeVideoIdsSelector(state); + const filters = currentFiltersAsStringSelector(state); + const monitoringIds = monitoringIdsSelector(state); + + const variables = { + applyToAllVideo, + videoIds, + excludeVideoIds, + filters, + findSites: find.map(siteMap), + }; + + if (replace?.length) { + variables.replaceSites = replace.map(siteMap); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.videoBulkActionReplaceHubs; + + if (!errors?.length) { + actions.push( + of( + setAction({ + monitoringIds: [...monitoringIds, response.monitoringId], + }), + ), + of(getMonitoringStateAction()).pipe(delay(500)), + of(closeAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/replaceTags.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/replaceTags.js new file mode 100644 index 0000000..5030c97 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/replaceTags.js @@ -0,0 +1,92 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { excludeVideoIdsSelector, isSelectedAllSelector, monitoringIdsSelector, videoIdsSelector } from '../selectors'; +import { closeAction, getMonitoringStateAction, replaceTagsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { currentFiltersAsStringSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; + +const queryGQL = ` + mutation VideoBulkActionReplaceTags( + $applyToAllVideo: Boolean, + $videoIds: [String], + $excludeVideoIds: [String], + $filters: String, + $find: BulkActionTags + $replace: BulkActionTags, + ) { + videoBulkActionReplaceTags( + applyToAllVideo: $applyToAllVideo, + videoIds: $videoIds, + excludeVideoIds: $excludeVideoIds, + filters: $filters, + find: $find, + replace: $replace, + ) { + monitoringId + } + } +`; + +const getFindReplaceTagsObjectWithReformatedKeys = (findReplaceTagsObject) => ({ + keywords: findReplaceTagsObject.keywords, + iab: findReplaceTagsObject.iab, + labels: findReplaceTagsObject.labels, +}); + +export default (action$, state$) => + action$.pipe( + ofType(replaceTagsAction.type), + switchMap((action) => { + const { find, replace } = action.payload; + const state = state$.value; + + const applyToAllVideo = isSelectedAllSelector(state); + const videoIds = videoIdsSelector(state); + const excludeVideoIds = excludeVideoIdsSelector(state); + const filters = currentFiltersAsStringSelector(state); + const monitoringIds = monitoringIdsSelector(state); + + // Action payload has only one tag, others empty array + // example: { keywords: [], iab: [Object], labels: [] } + // const findTagObject = Object + // .keys(find) + // .reduce((acc, tagKey) => (find[tagKey].length ? { [tagKey]: find[tagKey][0] } : acc), {}); + + const variables = { + applyToAllVideo, + videoIds, + excludeVideoIds, + filters, + find: getFindReplaceTagsObjectWithReformatedKeys(find), + replace: getFindReplaceTagsObjectWithReformatedKeys(replace), + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.videoBulkActionReplaceTags; + + if (!errors?.length) { + actions.push( + of( + setAction({ + monitoringIds: [...monitoringIds, response.monitoringId], + }), + ), + of(getMonitoringStateAction()).pipe(delay(500)), + of(closeAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/epics/shareWithUsers.js b/anyclip/src/modules/editorial/bulkActions/redux/epics/shareWithUsers.js new file mode 100644 index 0000000..59b1bd8 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/epics/shareWithUsers.js @@ -0,0 +1,85 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { excludeVideoIdsSelector, isSelectedAllSelector, monitoringIdsSelector, videoIdsSelector } from '../selectors'; +import { closeAction, getMonitoringStateAction, setAction, shareWithUsersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { + messageSelector, + usersSelector, +} from '@/modules/editorial/bulkActions/components/BulkActionShare/redux/selectors'; +import { currentFiltersAsStringSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; + +const queryGQL = ` + mutation VideoBulkActionShareWithUsers( + $applyToAllVideo: Boolean, + $videoIds: [String], + $excludeVideoIds: [String], + $filters: String, + $userIds: [String], + $message: String, + ) { + videoBulkActionShareWithUsers( + applyToAllVideo: $applyToAllVideo, + videoIds: $videoIds, + excludeVideoIds: $excludeVideoIds, + filters: $filters, + userIds: $userIds, + message: $message + ) { + monitoringId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(shareWithUsersAction.type), + switchMap(() => { + const state = state$.value; + + const applyToAllVideo = isSelectedAllSelector(state); + const videoIds = videoIdsSelector(state); + const excludeVideoIds = excludeVideoIdsSelector(state); + const filters = currentFiltersAsStringSelector(state); + const monitoringIds = monitoringIdsSelector(state); + const users = usersSelector(state); + const message = messageSelector(state); + + const variables = { + applyToAllVideo, + videoIds, + excludeVideoIds, + filters, + userIds: users.map((user) => `${user.id}`), + message, + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const response = data.videoBulkActionShareWithUsers; + + if (!errors?.length) { + actions.push( + of( + setAction({ + monitoringIds: [...monitoringIds, response.monitoringId], + }), + ), + of(getMonitoringStateAction()).pipe(delay(500)), + of(closeAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/selectors/index.js b/anyclip/src/modules/editorial/bulkActions/redux/selectors/index.js new file mode 100644 index 0000000..1ff4c30 --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/selectors/index.js @@ -0,0 +1,16 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const isActiveSelector = (state) => state[nameSpace].isActive; +export const isSelectedAllSelector = (state) => state[nameSpace].isSelectedAll; +export const videoIdsSelector = (state) => state[nameSpace].videoIds; +export const excludeVideoIdsSelector = (state) => state[nameSpace].excludeVideoIds; + +export const monitoringIdsSelector = (state) => state[nameSpace].monitoringIds; +export const monitoringStateSelector = (state) => state[nameSpace].monitoringState; + +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; + +// computed +export const isVideoSelectedSelector = (videoId, state) => videoIdsSelector(state).includes(videoId); diff --git a/anyclip/src/modules/editorial/bulkActions/redux/slices/index.js b/anyclip/src/modules/editorial/bulkActions/redux/slices/index.js new file mode 100644 index 0000000..e82d8ce --- /dev/null +++ b/anyclip/src/modules/editorial/bulkActions/redux/slices/index.js @@ -0,0 +1,77 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + isActive: false, + isSelectedAll: false, + // if isSelectedAll === false used for includes video + videoIds: [], + // if isSelectedAll === true used for exclude video + excludeVideoIds: [], + + monitoringIds: [], + monitoringState: [], + + hubOptions: null, +}; + +export const slice = createSlice({ + name: '@@bulkActions', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + selectVideoAction: (state, action) => { + const { isSelectedAll } = state; + state.videoIds = [].concat(state.videoIds, action.payload); + if (isSelectedAll) { + state.excludeVideoIds = state.excludeVideoIds.filter((id) => id !== action.payload); + } + }, + unselectVideoAction: (state, action) => { + const { isSelectedAll } = state; + state.videoIds = state.videoIds.filter((id) => id !== action.payload); + if (isSelectedAll) { + state.excludeVideoIds = [].concat(state.excludeVideoIds, action.payload); + } + }, + closeAction: (state) => { + Object.keys(initialState).forEach((key) => { + if (!['monitoringIds', 'monitoringState'].includes(key)) { + state[key] = initialState[key]; + } + }); + }, + clearAction: (state) => { + Object.keys(initialState).forEach((key) => { + state[key] = initialState[key]; + }); + }, + archiveAction: (state) => state, + addTagsAction: (state) => state, + replaceTagsAction: (state) => state, + shareWithUsersAction: (state) => state, + changeAccessLevelAction: (state) => state, + getMonitoringStateAction: (state) => state, + replaceHubsAction: (state) => state, + getHubsOptionsAction: (state) => state, + }, +}); + +export const { + setAction, + selectVideoAction, + unselectVideoAction, + closeAction, + clearAction, + archiveAction, + addTagsAction, + replaceTagsAction, + shareWithUsersAction, + changeAccessLevelAction, + getMonitoringStateAction, + replaceHubsAction, + getHubsOptionsAction, +} = slice.actions; diff --git a/src/modules/editorial/common/components/NewCard/Tags/Tags.jsx b/anyclip/src/modules/editorial/common/components/NewCard/Tags/Tags.jsx similarity index 100% rename from src/modules/editorial/common/components/NewCard/Tags/Tags.jsx rename to anyclip/src/modules/editorial/common/components/NewCard/Tags/Tags.jsx diff --git a/src/modules/editorial/common/components/NewCard/Tags/Tags.module.scss b/anyclip/src/modules/editorial/common/components/NewCard/Tags/Tags.module.scss similarity index 100% rename from src/modules/editorial/common/components/NewCard/Tags/Tags.module.scss rename to anyclip/src/modules/editorial/common/components/NewCard/Tags/Tags.module.scss diff --git a/src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.jsx b/anyclip/src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.jsx similarity index 100% rename from src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.jsx rename to anyclip/src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.jsx diff --git a/src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.module.scss b/anyclip/src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.module.scss similarity index 100% rename from src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.module.scss rename to anyclip/src/modules/editorial/common/components/NewCard/Thumbnail/Thumbnail.module.scss diff --git a/src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.jsx b/anyclip/src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.jsx similarity index 100% rename from src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.jsx rename to anyclip/src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.jsx diff --git a/src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.module.scss b/anyclip/src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.module.scss similarity index 100% rename from src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.module.scss rename to anyclip/src/modules/editorial/common/components/NewCard/VideoDescription/VideoDescription.module.scss diff --git a/src/modules/editorial/common/components/NewCard/VideoName/VideoName.jsx b/anyclip/src/modules/editorial/common/components/NewCard/VideoName/VideoName.jsx similarity index 100% rename from src/modules/editorial/common/components/NewCard/VideoName/VideoName.jsx rename to anyclip/src/modules/editorial/common/components/NewCard/VideoName/VideoName.jsx diff --git a/src/modules/editorial/common/components/NewCard/VideoName/VideoName.module.scss b/anyclip/src/modules/editorial/common/components/NewCard/VideoName/VideoName.module.scss similarity index 100% rename from src/modules/editorial/common/components/NewCard/VideoName/VideoName.module.scss rename to anyclip/src/modules/editorial/common/components/NewCard/VideoName/VideoName.module.scss diff --git a/src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.jsx b/anyclip/src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.jsx similarity index 100% rename from src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.jsx rename to anyclip/src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.jsx diff --git a/src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.module.scss b/anyclip/src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.module.scss similarity index 100% rename from src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.module.scss rename to anyclip/src/modules/editorial/common/components/NewCard/VideoPlayer/VideoPlayer.module.scss diff --git a/src/modules/editorial/common/components/NewCard/useEditableComponent.js b/anyclip/src/modules/editorial/common/components/NewCard/useEditableComponent.js similarity index 100% rename from src/modules/editorial/common/components/NewCard/useEditableComponent.js rename to anyclip/src/modules/editorial/common/components/NewCard/useEditableComponent.js diff --git a/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/fetchPlayerConfigAction.js b/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/fetchPlayerConfigAction.js new file mode 100644 index 0000000..7b2ec99 --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/fetchPlayerConfigAction.js @@ -0,0 +1,40 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { fetchPlayerConfigAction, setPlayerConfigAction } from '../slices'; +import { getDirtyEvalObjectFromString, getPlayerConfigCdnEndpoint } from '@/modules/@common/PlayerWidget/helpers'; +import { parseErrorMessage } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(fetchPlayerConfigAction.type), + switchMap(({ payload }) => { + const { player, publishers } = payload; + + const playerConfigRequest = ajax({ + url: getPlayerConfigCdnEndpoint(publishers, player), + responseType: 'text', + crossDomain: true, + withCredentials: false, + }).pipe( + switchMap(({ response }) => of(setPlayerConfigAction(getDirtyEvalObjectFromString(response)))), + catchError((error) => { + const errorMessage = parseErrorMessage(error[0]); + + return of( + showNotificationAction({ + type: TYPE_ERROR, + message: errorMessage, + }), + ); + }), + ); + + return concat(playerConfigRequest); + }), + ); diff --git a/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/index.js b/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/index.js new file mode 100644 index 0000000..e519472 --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import fetchPlayerConfigAction from './fetchPlayerConfigAction'; + +export default combineEpics(fetchPlayerConfigAction); diff --git a/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/slices/index.js b/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/slices/index.js new file mode 100644 index 0000000..337d24e --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/PlayerPreview/redux/slices/index.js @@ -0,0 +1,25 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + playerConfig: null, + playerReady: false, +}; + +export const slice = createSlice({ + name: '@@STUDIO/PLAYER', + initialState, + reducers: { + fetchPlayerConfigAction: (state) => state, + setPlayerConfigAction: (state, action) => { + state.playerConfig = action.payload; + }, + setPlayerReadyAction: (state, action) => { + state.playerReady = action.payload; + }, + clearAction: () => initialState, + }, +}); + +export const { fetchPlayerConfigAction, setPlayerConfigAction, setPlayerReadyAction, clearAction } = slice.actions; + +export default slice.reducer; diff --git a/src/modules/editorial/common/components/statusBlock/index.jsx b/anyclip/src/modules/editorial/common/components/statusBlock/index.jsx similarity index 100% rename from src/modules/editorial/common/components/statusBlock/index.jsx rename to anyclip/src/modules/editorial/common/components/statusBlock/index.jsx diff --git a/src/modules/editorial/common/components/statusBlock/styles.module.scss b/anyclip/src/modules/editorial/common/components/statusBlock/styles.module.scss similarity index 100% rename from src/modules/editorial/common/components/statusBlock/styles.module.scss rename to anyclip/src/modules/editorial/common/components/statusBlock/styles.module.scss diff --git a/src/modules/editorial/common/components/trimVideo/helpers/canShowTrim.js b/anyclip/src/modules/editorial/common/components/trimVideo/helpers/canShowTrim.js similarity index 100% rename from src/modules/editorial/common/components/trimVideo/helpers/canShowTrim.js rename to anyclip/src/modules/editorial/common/components/trimVideo/helpers/canShowTrim.js diff --git a/anyclip/src/modules/editorial/common/components/trimVideo/helpers/converSecToMs.js b/anyclip/src/modules/editorial/common/components/trimVideo/helpers/converSecToMs.js new file mode 100644 index 0000000..946dc5a --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/trimVideo/helpers/converSecToMs.js @@ -0,0 +1,2 @@ +const convertSecToMs = (sec) => sec * 1000; +export default convertSecToMs; diff --git a/anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/index.js b/anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/index.js new file mode 100644 index 0000000..0d4a53e --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import trim from './trim'; + +export default combineEpics(trim); diff --git a/anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/trim.js b/anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/trim.js new file mode 100644 index 0000000..ed79f74 --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/trimVideo/redux/epics/trim.js @@ -0,0 +1,183 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { LUMINOUS_SOURCE_TYPE } from '@/modules/@common/constants/file'; +import { mapApiError } from '@/modules/@common/constants/mapApiError'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import convertSecToMs from '../../helpers/converSecToMs'; +import { + endTrimTimeSelector, + nameSelector, + originalVideoIdSelector, + originalVideoSelector, + startTrimTimeSelector, +} from '../selectors'; +import { makeTrimAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation addChildVideo( + $videoId: String!, + $name: String!, + $startTime: Float!, + $endTime: Float!, + $origin: String! + $type: String, + ) { + addChildVideo( + videoId: $videoId, + name: $name, + startTime: $startTime, + endTime: $endTime, + origin: $origin, + type: $type, + ) { + refId + access { + level + users { + id + role + name + email + firstName + lastName + } + } + type + status + name + created + updated + videoUrl + sourceVideoFile { + width + height + file + sizeInBytes + } + videoFiles { + width + height + file + sizeInBytes + } + ccUrl + thumbnailUrl + thumbnailFiles { + width + height + file + sizeInBytes + } + contentOwner + videoCreationDate + feedSource + feedDescription + videoLength + plot + uid + originalName + releaseYear + publisherLink + landingPageLink + approval { + status + updated + refUserId + } + lang + videoParent + iab { + data + } + keywords { + category + value + probability + version + id + } + label { + labelId + name + value + color + } + distributionId + aspectRatio + evergreen + origin + defaultVideoUrl + } + } +`; + +const parseResponse = ({ data: { addChildVideo } }) => addChildVideo; + +export default (action$, state$) => + action$.pipe( + ofType(makeTrimAction.type), + switchMap(() => { + const startTrimTime = startTrimTimeSelector(state$.value); + const endTrimTime = endTrimTimeSelector(state$.value); + const name = nameSelector(state$.value); + const originalVideoId = originalVideoIdSelector(state$.value); + const originalVideo = originalVideoSelector(state$.value); + + const stream$ = gqlRequest( + { + query, + variables: { + videoId: originalVideoId, + startTime: convertSecToMs(startTrimTime), + endTime: convertSecToMs(endTrimTime), + name, + origin: LUMINOUS_SOURCE_TYPE, + playbackImmediately: true, + type: 'TRIM', + }, + }, + { + mapError: mapApiError(), + }, + ).pipe( + switchMap((response) => { + if (!response.errors.length) { + const actions = []; + + const trimedVideo = parseResponse(response); + + let accessUsers = trimedVideo.access.users; + if (originalVideo.access.users?.length) { + accessUsers = accessUsers.concat(originalVideo.access.users.filter((user) => user.role !== 'OWNER')); + } + + trimedVideo.access = { + ...trimedVideo.access, + users: accessUsers, + }; + + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'New video created successfully', + }), + ), + of(setStateAction({ trimedVideo, isShowShare: true })), + ); + + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/common/components/trimVideo/redux/selectors/index.js b/anyclip/src/modules/editorial/common/components/trimVideo/redux/selectors/index.js new file mode 100644 index 0000000..7494c9c --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/trimVideo/redux/selectors/index.js @@ -0,0 +1,25 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const nameSelector = (state) => state[nameSpace].name; +export const originalVideoIdSelector = (state) => state[nameSpace].originalVideoId; + +export const isOpenSelector = (state) => state[nameSpace].isOpen; +export const isPlayedSelector = (state) => state[nameSpace].isPlayed; +export const isMutedSelector = (state) => state[nameSpace].isMuted; + +export const startTrimProgressSelector = (state) => state[nameSpace].startTrimProgress; +export const playProgressSelector = (state) => state[nameSpace].playProgress; +export const endTrimProgressSelector = (state) => state[nameSpace].endTrimProgress; + +export const currentTimeSelector = (state) => state[nameSpace].currentTime; +export const durationSelector = (state) => state[nameSpace].duration; +export const isVideoDurationLessHourSelector = (state) => state[nameSpace].isVideoDurationLessHour; + +export const startTrimTimeSelector = (state) => state[nameSpace].startTrimTime; +export const endTrimTimeSelector = (state) => state[nameSpace].endTrimTime; + +export const isShowShareSelector = (state) => state[nameSpace].isShowShare; +export const originalVideoSelector = (state) => state[nameSpace].originalVideo; +export const trimedVideoSelector = (state) => state[nameSpace].trimedVideo; diff --git a/anyclip/src/modules/editorial/common/components/trimVideo/redux/slices/index.js b/anyclip/src/modules/editorial/common/components/trimVideo/redux/slices/index.js new file mode 100644 index 0000000..4779bfe --- /dev/null +++ b/anyclip/src/modules/editorial/common/components/trimVideo/redux/slices/index.js @@ -0,0 +1,48 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + name: '', + originalVideoId: null, + + isOpen: false, + isPlayed: false, + isMuted: false, + + startTrimProgress: 0, + playProgress: 0, + endTrimProgress: 100, + + currentTime: 0, + duration: 0, + isVideoDurationLessHour: false, + + startTrimTime: 0, + endTrimTime: 0, + + // share stuff + isShowShare: false, + originalVideo: null, + trimedVideo: null, +}; + +export const slice = createSlice({ + name: '@@COMMON/TRIM_VIDEO', + initialState, + + reducers: { + makeTrimAction: (state) => state, + setStateAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + openAction: (state) => { + state.isOpen = true; + }, + clearAction: () => initialState, + }, +}); + +export const { clearAction, makeTrimAction, openAction, setStateAction } = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/editorial/constants/dnd.js b/anyclip/src/modules/editorial/constants/dnd.js new file mode 100644 index 0000000..aa3d9e7 --- /dev/null +++ b/anyclip/src/modules/editorial/constants/dnd.js @@ -0,0 +1,7 @@ +// DND TYPES +export const DND_PLAYLIST_TYPE = 'DND_PLAYLIST_TYPE'; +export const DND_PLAYLIST_VIDEO_TYPE = 'DND_PLAYLIST_VIDEO_TYPE'; +export const DND_MAIN_LIST_OF_VIDEO_TYPE = 'DND_MAIN_LIST_OF_VIDEO_TYPE'; + +// +export const DND_DRAG_OVERLAY_ID = 'dndDragOverlayId'; diff --git a/src/modules/editorial/constants/monitoringJobs.js b/anyclip/src/modules/editorial/constants/monitoringJobs.js similarity index 100% rename from src/modules/editorial/constants/monitoringJobs.js rename to anyclip/src/modules/editorial/constants/monitoringJobs.js diff --git a/anyclip/src/modules/editorial/constants/routing.js b/anyclip/src/modules/editorial/constants/routing.js new file mode 100644 index 0000000..6c749be --- /dev/null +++ b/anyclip/src/modules/editorial/constants/routing.js @@ -0,0 +1,7 @@ +export const TAB_WATCH = 'watch'; +export const TAB_PLAYLIST = 'playlist'; +export const QUERY_PARAM_TAB = 'tab'; +export const QUERY_PARAM_WATCH_ID = 'watchId'; +export const QUERY_PARAM_CHANNEL = 'channel'; +export const QUERY_PARAM_PLAYLIST = 'playlist'; +export const QUERY_PARAM_PLAYLIST_EDIT = 'playlistEdit'; diff --git a/anyclip/src/modules/editorial/constants/video.js b/anyclip/src/modules/editorial/constants/video.js new file mode 100644 index 0000000..7346899 --- /dev/null +++ b/anyclip/src/modules/editorial/constants/video.js @@ -0,0 +1,2 @@ +export const RECENT = 'RECENT'; +export const TRENDING = 'TRENDING'; diff --git a/src/modules/editorial/editorialSearch/components/search/index.jsx b/anyclip/src/modules/editorial/editorialSearch/components/search/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearch/components/search/index.jsx rename to anyclip/src/modules/editorial/editorialSearch/components/search/index.jsx diff --git a/src/modules/editorial/editorialSearch/components/search/styles.module.scss b/anyclip/src/modules/editorial/editorialSearch/components/search/styles.module.scss similarity index 100% rename from src/modules/editorial/editorialSearch/components/search/styles.module.scss rename to anyclip/src/modules/editorial/editorialSearch/components/search/styles.module.scss diff --git a/anyclip/src/modules/editorial/editorialSearch/constants/searchFilter.js b/anyclip/src/modules/editorial/editorialSearch/constants/searchFilter.js new file mode 100644 index 0000000..0f6eeb0 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/constants/searchFilter.js @@ -0,0 +1,9 @@ +export const TYPE_ALL = null; +export const TYPE_MOVIE = 'MOVIE'; +export const TYPE_SHORT_FORM = 'SHORT_FORM'; + +export const VIDEO_STATE_ACTIVE = 'ACTIVE'; +export const VIDEO_STATE_PROCESSING = 'PROCESSING'; +export const VIDEO_STATE_FAILED = 'FAILED'; + +export default TYPE_SHORT_FORM; diff --git a/anyclip/src/modules/editorial/editorialSearch/helpers/helpers/monitoring.js b/anyclip/src/modules/editorial/editorialSearch/helpers/helpers/monitoring.js new file mode 100644 index 0000000..d3c1fea --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/helpers/helpers/monitoring.js @@ -0,0 +1,23 @@ +export const isTimeouted = (job, time = Date.now()) => { + const duration1 = 1000 * 60 * 20; // 20 min + const duration2 = 1000 * 60 * 60 * 4; // 4 hours + + if (job.type === 'TAGGING' || job.type === 'SCENE_RECOGNITION') { + return time - job.updateTime > duration1; + } + + if (job.type === 'VIDEO_ENCODING') { + return time - job.updateTime > duration2; + } + + if (job.type === 'VIDEO_PROCESSING' || job.type === 'VIDEO_CHILD_PROCESSING') { + return time - job.updateTime > duration1 + duration2; + } + + return false; +}; + +export const isDone = (job) => job.state === 'DONE'; +export const isError = (job) => job.state === 'ERROR'; +export const isFinished = (job) => isDone(job) || isError(job) || isTimeouted(job); +export const hasUnfinishedJobs = (jobs) => jobs.some((job) => !isFinished(job)); diff --git a/src/modules/editorial/editorialSearch/index.js b/anyclip/src/modules/editorial/editorialSearch/index.js similarity index 100% rename from src/modules/editorial/editorialSearch/index.js rename to anyclip/src/modules/editorial/editorialSearch/index.js diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/index.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/index.js new file mode 100644 index 0000000..7fcfb3a --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/index.js @@ -0,0 +1,23 @@ +import { combineEpics } from 'redux-observable'; + +import monitoringEpic from './monitoring'; +import monitoringRepeatEpic from './monitoringRepeat'; +import monitoringStartEpic from './monitoringStart'; +import monitoringStopEpic from './monitoringStop'; +import reloadEpic from './reload'; +import videoDeleteEpic from './videoDelete'; +import videoJobEpic from './videoJob'; +import videoStatusUpdateEpic from './videoStatusUpdate'; +import videoVerificationUpdateEpic from './videoVerificationUpdate'; + +export default combineEpics( + videoDeleteEpic, + videoStatusUpdateEpic, + videoVerificationUpdateEpic, + monitoringEpic, + monitoringRepeatEpic, + monitoringStartEpic, + monitoringStopEpic, + videoJobEpic, + reloadEpic, +); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoring.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoring.js new file mode 100644 index 0000000..e4c19ee --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoring.js @@ -0,0 +1,93 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { monitoringAction, monitoringDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; + +const queryGQL = ` + query MonitoringQuery($videoId: String!, $size: Float!) { + monitoring(videoId: $videoId, size: $size) { + jobs { + videoId + childId + childType + type + state + message + startTime + updateTime + progress + properties { + key + value + } + } + } + } +`; + +const jobTypes = [ + 'TAGGING', + 'SCENE_RECOGNITION', + 'VIDEO_ENCODING', + 'VIDEO_PROCESSING', + 'VIDEO_CHILD_PROCESSING', + 'CLOSED_CAPTIONS', + 'THUMBNAIL_PROCESSING', + 'THUMBNAILS_PUBLISHING', + 'THUMBNAILS_CURRENT_FRAME_PROCESSING', + 'SPEECH_RECOGNITION', + 'AUTO_CHAPTERS', + 'VIDEO_VERSIONING', + 'VIDEO_HIGHLIGHTS_PROCESSING', + 'HIGHLIGHTS_VIDEO_PROCESSING', + 'VIDEO_SLIDES_PROCESSING', + 'VIDEO_SLIDES_PDF_PROCESSING', + 'CC_TRANSLATION_PROCESSING', + 'DESCRIPTIONS', + 'PUBLISH_VIDEO_TRANSCRIPT', +]; + +const getFilteredJobs = (jobs) => jobs.filter((job) => jobTypes.includes(job.type)); + +const getSelectedVideo = (state) => selectedVideoSelector(state); + +const monitoringEpic = (action$, state$) => + action$.pipe( + ofType(monitoringAction.type), + filter(() => { + const video = getSelectedVideo(state$.value); + if (Router.query.aiworkbench && Router.query.videoId) { + return true; + } + return !!video?.uid; + }), + switchMap(() => { + const video = getSelectedVideo(state$.value); + const videoId = Router.query.aiworkbench && Router.query.videoId ? Router.query.videoId : video.uid; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId, + size: 1000, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(monitoringDataAction(getFilteredJobs(data.monitoring.jobs)))); + } + + return concat(...actions); + }), + ); + return concat(stream$); + }), + ); + +export default monitoringEpic; diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringRepeat.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringRepeat.js new file mode 100644 index 0000000..47b9dd4 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringRepeat.js @@ -0,0 +1,18 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { timer } from 'rxjs'; +import { debounce, filter, mapTo } from 'rxjs/operators'; + +import { hasUnfinishedJobs } from '../../helpers/helpers/monitoring'; +import { monitoringAction, monitoringDataAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(monitoringDataAction.type), + debounce(() => { + const delay = Router.query?.aiworkbench ? 2000 : +process.env.APP_REQUEST_TIMEOUT; + return timer(delay); + }), + filter((action) => hasUnfinishedJobs(action.payload)), + mapTo(monitoringAction(true)), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStart.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStart.js new file mode 100644 index 0000000..788e4b2 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStart.js @@ -0,0 +1,12 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { monitoringAction, monitoringInProgressAction, monitoringStartAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(monitoringStartAction.type), + filter((action) => action.payload !== null), + switchMap(() => concat(of(monitoringInProgressAction(true)), of(monitoringAction(false)))), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStop.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStop.js new file mode 100644 index 0000000..994cc49 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/monitoringStop.js @@ -0,0 +1,14 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { hasUnfinishedJobs } from '../../helpers/helpers/monitoring'; +import { monitoringDataAction, monitoringInProgressAction } from '../slices'; +import { reloadSelectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(monitoringDataAction.type), + filter((action) => !hasUnfinishedJobs(action.payload)), + switchMap(() => concat(of(monitoringInProgressAction(false)), of(reloadSelectedVideoAction()))), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/reload.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/reload.js new file mode 100644 index 0000000..90323c9 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/reload.js @@ -0,0 +1,27 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { EDITORIAL_PAGE } from '@/modules/@common/router/constants'; + +import { monitoringSelector } from '../selectors'; +import { loadAction, monitoringInProgressAction, monitoringStartAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringInProgressAction.type), + filter((action) => action.payload === false && monitoringSelector(state$.value) === true), + switchMap(() => { + const isEditorialPage = Router.pathname === EDITORIAL_PAGE.path; + const actions = []; + + if (!isEditorialPage) { + actions.push(of(loadAction())); + } + + actions.push(of(monitoringStartAction())); + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoDelete.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoDelete.js new file mode 100644 index 0000000..7ac8d11 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoDelete.js @@ -0,0 +1,47 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { videoSelector } from '../selectors'; +import { videoDeleteAction, videoDeleteEventAction, videoDeleteProgressAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation videoDeleteMutation($id: String!) { + videoDelete(id: $id) { + result + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(videoDeleteAction.type), + switchMap(() => { + const state = state$.value; + const { uid } = videoSelector(state); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = [of(videoDeleteProgressAction(false))]; + + if (!errors.length) { + const { videoDelete } = data; + + if (videoDelete.result) { + actions.push(of(videoDeleteEventAction())); + } + } + + return concat(...actions); + }), + ); + + return concat(of(videoDeleteProgressAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoJob.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoJob.js new file mode 100644 index 0000000..1f5d52c --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoJob.js @@ -0,0 +1,62 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { videoSelector } from '../selectors'; +import { monitoringStartAction, videoJobAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; + +const queryGQL = ` + mutation VideoJobMutation( + $videoId: String!, + $job: String!, + $timestamp: Int, + $url: String, + $speechModel: String, + ) { + videoJob( + videoId: $videoId, + job: $job, + timestamp: $timestamp, + url: $url, + speechModel: $speechModel, + ) + } +`; + +const monitoringEpic = (action$, state$) => + action$.pipe( + ofType(videoJobAction.type), + switchMap((action) => { + const { jobType, timestamp, url, video, speechModel } = action.payload; + + const state = state$.value; + const selectedVideo = videoSelector(state) || video || selectedVideoSelector(state); + const { uid } = selectedVideo; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId: uid, + job: jobType, + timestamp, + url, + speechModel, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + if (!errors.length && data.videoJob === true) { + actions.push(concat(of(monitoringStartAction()))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default monitoringEpic; diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoStatusUpdate.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoStatusUpdate.js new file mode 100644 index 0000000..5517c64 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoStatusUpdate.js @@ -0,0 +1,41 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { videoSelector } from '../selectors'; +import { videoStatusAction, videoStatusUpdateProgressAction, videoUpdatedAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +import queryVideoUpdateGQL from '@/modules/@common/gql/queries/videoUpdate'; + +export default (action$, state$) => + action$.pipe( + ofType(videoStatusAction.type), + switchMap((action) => { + const approved = action.payload; + const state = state$.value; + const { uid } = videoSelector(state); + + const stream$ = gqlRequest({ + query: queryVideoUpdateGQL, + variables: { + id: uid, + video: { + status: approved ? 'ACTIVE' : 'DISABLED', + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(videoUpdatedAction(data.videoUpdate.video))); + } + + return concat(...actions); + }), + ); + + return concat(of(videoStatusUpdateProgressAction(true)), stream$, of(videoStatusUpdateProgressAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoVerificationUpdate.js b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoVerificationUpdate.js new file mode 100644 index 0000000..4b6ed86 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/epics/videoVerificationUpdate.js @@ -0,0 +1,55 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { videoSelector } from '../selectors'; +import { videoUpdatedAction, videoVerificationAction, videoVerificationUpdateProgressAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; + +import queryVideoUpdateGQL from '@/modules/@common/gql/queries/videoUpdate'; + +export default (action$, state$) => + action$.pipe( + ofType(videoVerificationAction.type), + switchMap((action) => { + const verified = action.payload; + const state = state$.value; + const { uid } = videoSelector(state); + + const stream$ = gqlRequest({ + query: queryVideoUpdateGQL, + variables: { + id: uid, + video: { + approval: { + status: verified ? 'APPROVED' : 'NOT_APPROVED', + }, + }, + }, + }).pipe( + filter((data) => data.data.videoUpdate.video), + switchMap(({ data, errors }) => { + let actions = []; + if (!errors.length) { + const { videos } = videosSelector(state); + + const newVideos = videos.map((oneVideo$) => + oneVideo$.uid !== data.videoUpdate.video.uid ? oneVideo$ : { ...oneVideo$, ...data.videoUpdate.video }, + ); + + actions = [of(videoUpdatedAction(data.videoUpdate.video)), of(videosAction(newVideos))]; + } + + return concat(...actions); + }), + ); + + return concat( + of(videoVerificationUpdateProgressAction(true)), + stream$, + of(videoVerificationUpdateProgressAction(false)), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/selectors/index.js b/anyclip/src/modules/editorial/editorialSearch/redux/selectors/index.js new file mode 100644 index 0000000..7ec3645 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/selectors/index.js @@ -0,0 +1,12 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const videoSelector = (state) => state[nameSpace].video; +export const videoStatusUpdateInProgressSelector = (state) => state[nameSpace].videoStatusUpdateInProgress; +export const videoVerificationUpdateInProgressSelector = (state) => state[nameSpace].videoVerificationUpdateInProgress; +export const videoDeleteInProgressSelector = (state) => state[nameSpace].videoDeleteInProgress; +export const monitoringSelector = (state) => state[nameSpace].monitoring; + +export const monitoringJobsSelector = (state) => state[nameSpace].monitoringJobs; +export const monitoringInProgressSelector = (state) => state[nameSpace].monitoringInProgress; diff --git a/anyclip/src/modules/editorial/editorialSearch/redux/slices/index.js b/anyclip/src/modules/editorial/editorialSearch/redux/slices/index.js new file mode 100644 index 0000000..a76cc88 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/redux/slices/index.js @@ -0,0 +1,72 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + video: null, + metaData: [], + tabIndex: 0, + videoStatusUpdateInProgress: false, + videoVerificationUpdateInProgress: false, + videoDeleteInProgress: false, + monitoringJobs: [], + monitoringInProgress: false, + monitoring: false, + inlineEdit: {}, +}; + +export const slice = createSlice({ + name: '@@details/DETAILS', + initialState, + reducers: { + loadAction: (state) => state, + taggerAction: (state) => state, + taglogTaggerAction: (state) => state, + videoStatusAction: (state) => state, + videoStatusUpdateProgressAction: (state, action) => { + state.videoStatusUpdateInProgress = action.payload; + }, + videoVerificationUpdateProgressAction: (state, action) => { + state.videoVerificationUpdateInProgress = action.payload; + }, + videoDeleteProgressAction: (state, action) => { + state.videoDeleteInProgress = action.payload; + }, + videoDeleteEventAction: (state) => state, + videoUpdatedAction: (state, action) => { + state.video = action.payload; + }, + monitoringAction: (state, action) => { + state.monitoring = action.payload; + }, + monitoringDataAction: (state, action) => { + state.monitoringJobs = action.payload; + }, + monitoringStartAction: (state) => state, + monitoringInProgressAction: (state, action) => { + state.monitoringInProgress = action.payload; + }, + videoJobAction: (state) => state, + videoVerificationAction: (state) => state, + videoDeleteAction: (state) => state, + tagsCloudClickAction: (state) => state, + }, +}); + +export const { + loadAction, + taggerAction, + taglogTaggerAction, + videoStatusAction, + videoStatusUpdateProgressAction, + videoVerificationUpdateProgressAction, + videoDeleteProgressAction, + videoDeleteEventAction, + videoUpdatedAction, + monitoringAction, + monitoringDataAction, + monitoringStartAction, + monitoringInProgressAction, + videoJobAction, + videoVerificationAction, + videoDeleteAction, + tagsCloudClickAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/index.js b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/index.js new file mode 100644 index 0000000..aac795f --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import searchSuggester from './searchSuggester'; +import showUploader from './showUploader'; + +export default combineEpics(showUploader, searchSuggester); diff --git a/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/searchSuggester.js b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/searchSuggester.js new file mode 100644 index 0000000..a83056d --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/searchSuggester.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { searchSuggesterAction, suggesterOptionsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { getUserContentOwnerIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + query autocomplete( + $prefix: String!, + $size: Int, + $videoType: String, + $videoStatus: String, + $contentOwner: Int, + $contentOwners: [Int]!, + $includeOrigin: String, + $excludeOrigin: String + ) { + autocompleteVideoNames( + prefix: $prefix, + size: $size, + videoType: $videoType, + videoStatus: $videoStatus, + contentOwner: $contentOwner, + contentOwners: $contentOwners, + includeOrigin: $includeOrigin, + excludeOrigin: $excludeOrigin + ) { + value + count + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(searchSuggesterAction.type), + filter((action) => !!getToken() && !!action.payload), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + prefix: action.payload.prefix ?? '', + size: 10, + contentOwners: getUserContentOwnerIdsSelector(state$.value), + excludeOrigin: 'RSS', + videoType: 'SHORT_FORM', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + suggesterOptionsAction( + !action.payload?.clearSuggestions ? data.autocompleteVideoNames.map(({ value }) => value) : [], + ), + ), + ); + } + + return concat(...actions); + }), + ); + + return stream$; + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/showUploader.js b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/showUploader.js new file mode 100644 index 0000000..54b367c --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/epics/showUploader.js @@ -0,0 +1,12 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { showUploaderAction } from '../slices'; +import { isOpenAction as isOpenActionNew } from '@/modules/uploaderNew/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(showUploaderAction.type), + switchMap(() => concat(of(isOpenActionNew(true)))), + ); diff --git a/anyclip/src/modules/editorial/editorialSearch/reduxSearch/selectors/index.js b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/selectors/index.js new file mode 100644 index 0000000..d6f14e5 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/selectors/index.js @@ -0,0 +1,19 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const querySelector = (state) => state[nameSpace].query; +export const fromSuggesterSelector = (state) => state[nameSpace].fromSuggester; +export const playlistTypeSelector = (state) => state[nameSpace].playlistType; +export const searchSuggesterSelector = (state) => state[nameSpace].searchSuggester; +export const suggesterOptionsSelector = (state) => state[nameSpace].suggesterOptions; + +export const getCommonState = (state$) => { + const state = state$[nameSpace]; + + return { + ...state, + }; +}; + +export default getCommonState; diff --git a/anyclip/src/modules/editorial/editorialSearch/reduxSearch/slices/index.js b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/slices/index.js new file mode 100644 index 0000000..bd3d1d5 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearch/reduxSearch/slices/index.js @@ -0,0 +1,49 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + query: '', + fromSuggester: false, + playlistType: '', + searchSuggester: null, + suggesterOptions: [], +}; + +export const slice = createSlice({ + name: '@@editorialSearch/EDITORIAL_SEARCH', + initialState, + reducers: { + queryAction: (state, action) => { + state.query = action.payload || initialState.query; + }, + playlistTypeAction: (state, action) => { + state.playlistType = action.payload || initialState.playlistType; + }, + searchSuggesterAction: (state, action) => { + state.searchSuggester = action.payload || initialState.searchSuggester; + }, + searchFromSuggesterAction: (state, action) => { + state.fromSuggester = action.payload || initialState.fromSuggester; + }, + suggesterOptionsAction: (state, action) => { + state.suggesterOptions = action.payload || initialState.suggesterOptions; + }, + clearAction: (state) => { + Object.keys(initialState.createForm).forEach((key) => { + state.createForm[key] = initialState.createForm[key]; + }); + }, + showUploaderAction: (state) => state, + searchEventAction: (state) => state, + }, +}); + +export const { + queryAction, + playlistTypeAction, + searchSuggesterAction, + searchFromSuggesterAction, + suggesterOptionsAction, + clearAction, + showUploaderAction, + searchEventAction, +} = slice.actions; diff --git a/src/modules/editorial/editorialSearchFilter/VideoTabs/VideoTabs.module.scss b/anyclip/src/modules/editorial/editorialSearchFilter/VideoTabs/VideoTabs.module.scss similarity index 100% rename from src/modules/editorial/editorialSearchFilter/VideoTabs/VideoTabs.module.scss rename to anyclip/src/modules/editorial/editorialSearchFilter/VideoTabs/VideoTabs.module.scss diff --git a/src/modules/editorial/editorialSearchFilter/VideoTabs/index.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/VideoTabs/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/VideoTabs/index.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/VideoTabs/index.jsx diff --git a/src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.jsx diff --git a/src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.module.scss b/anyclip/src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.module.scss similarity index 100% rename from src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.module.scss rename to anyclip/src/modules/editorial/editorialSearchFilter/additionalFilters/additionalFilters.module.scss diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/constants.js b/anyclip/src/modules/editorial/editorialSearchFilter/constants.js new file mode 100644 index 0000000..d8d490a --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/constants.js @@ -0,0 +1,335 @@ +import { VIDEO_ADMIN, VIDEO_FILTERS_ALL, VIDEO_FILTERS_INTERNAL_BUSINESS } from '@/modules/@common/acl/constants'; + +export const FILTER_COMPONENTS_NAMES = { + FilterTimePicker: 'FilterTimePicker', + FilterSelector: 'FilterSelector', + FilterDatePicker: 'FilterDatePicker', + FilterSuggester: 'FilterSuggester', +}; + +export const FILTERS_SHOULD_HIDE_IN_AI_BLOCK_FILTER = [ + 'account', + 'verification', + 'feedType', + 'publishStatus', + 'publishPlatform', + 'uploadDate', + 'hubs', + 'sponsored', +]; + +export const ADDITIONAL_FILTER_NAMES = { + [VIDEO_FILTERS_ALL]: { + hubs: 'hubs', + source: 'source', + mediaType: 'mediaType', + languages: 'languages', + labels: 'labels', + keywords: 'keywords', + targetingStatus: 'targetingStatus', + categories: 'categories', + people: 'people', + brands: 'brands', + uploadDate: 'upload date', + brandSafety: 'brand safety', + publishStatus: 'publishStatus', + publishPlatform: 'publishPlatform', + evergreen: 'evergreen', + sponsored: 'sponsored', + }, + [VIDEO_FILTERS_INTERNAL_BUSINESS]: { + hubs: 'hubs', + source: 'source', + mediaType: 'mediaType', + languages: 'languages', + people: 'people', + labels: 'labels', + keywords: 'keywords', + targetingStatus: 'targetingStatus', + uploadDate: 'upload date', + evergreen: 'evergreen', + sponsored: 'sponsored', + }, + [VIDEO_ADMIN]: { + account: 'account', + status: 'status', + hubs: 'hubs', + source: 'source', + verification: 'verification', + feedType: 'feedType', + people: 'people', + brands: 'brands', + languages: 'languages', + labels: 'labels', + publishStatus: 'publishStatus', + publishPlatform: 'publishPlatform', + targetingStatus: 'targetingStatus', + categories: 'categories', + uploadDate: 'upload date', + brandSafety: 'brand safety', + keywords: 'keywords', + evergreen: 'evergreen', + sponsored: 'sponsored', + }, +}; + +export const SEARCH_PARAMS_TO_NOT_EXCLUDE = ['time', 'duration', 'order', 'videoTabs']; + +export const namesFilterSelectors = [ + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].status, + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].feedType, + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].verification, + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].publishStatus, + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].publishPlatform, + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].evergreen, + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].targetingStatus, + ADDITIONAL_FILTER_NAMES[VIDEO_FILTERS_ALL].mediaType, +]; + +const EVERGREEN_TYPE_ALL = 'ALL'; +const EVERGREEN_TYPE_NO_EVERGREEN = 'NO_EVERGREEN'; +const EVERGREEN_TYPE_EVERGREEN = 'EVERGREEN'; + +const PUBLISH_PLATFORM_TYPE_ANY = 'ANY'; +const PUBLISH_PLATFORM_TYPE_FACEBOOK = 'FACEBOOK'; +const PUBLISH_PLATFORM_TYPE_YOUTUBE = 'YOUTUBE'; + +const PUBLISH_STATUS_TYPE_ANY = 'ANY'; +const PUBLISH_STATUS_PUBLISHED = 'PUBLISHED'; +const PUBLISH_STATUS_NOT_PUBLISHED = 'UNPUBLISHED'; +const PUBLISH_STATUS_PUBLISHED_PUBLISHED = 'PUBLISH_ERROR'; + +export const FEED_TYPE_ALL = 'ALL'; +export const FEED_TYPE_VIDEO = 'VIDEO'; +export const FEED_TYPE_AUDIO = 'AUDIO'; +export const FEED_TYPE_VIDEO_AUDIO = 'VIDEO_AUDIO'; +export const FEED_TYPE_TRENDING = 'TRENDING'; + +const VERIFICATION_TYPE_ALL = 'ALL'; +const VERIFICATION_TYPE_APPROVED = 'APPROVED'; +const VERIFICATION_TYPE_NOT_APPROVED = 'NOT_APPROVED'; + +const VIDEO_STATUS_ALL = 'ALL'; +const VIDEO_STATUS_ACTIVE = 'ACTIVE'; +const VIDEO_STATUS_DISABLED = 'DISABLED'; +const VIDEO_STATUS_PROCESSING = 'PROCESSING'; +const VIDEO_STATUS_FAILED = 'FAILED'; + +export const DEFAULT_EVERGREEN_TYPE_VALUE = EVERGREEN_TYPE_ALL; +export const DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE = PUBLISH_PLATFORM_TYPE_ANY; +export const DEFAULT_PUBLISH_STATUS_TYPE_VALUE = PUBLISH_STATUS_TYPE_ANY; +export const DEFAULT_FEED_TYPE_VALUE = FEED_TYPE_VIDEO; +export const DEFAULT_MEDIA_TYPE_VALUE = FEED_TYPE_VIDEO_AUDIO; +export const DEFAULT_VERIFICATION_TYPE_VALUE = VERIFICATION_TYPE_ALL; +export const DEFAULT_VIDEO_STATUS_VALUE = VIDEO_STATUS_ALL; + +export const SPONSORED_ALL = 'ALL'; +export const SPONSORED_ONLY = 'SPONSORED'; +export const SPONSORED_NONE = 'NONE'; + +export const evergreenOptions = [ + { + label: 'All', + value: EVERGREEN_TYPE_ALL, + }, + { + label: 'Non Evergreen', + value: EVERGREEN_TYPE_NO_EVERGREEN, + }, + { + label: 'Evergreen', + value: EVERGREEN_TYPE_EVERGREEN, + }, +]; + +export const filterPublishPlatformOptions = [ + { + label: 'Any', + value: PUBLISH_PLATFORM_TYPE_ANY, + }, + { + label: 'Facebook', + value: PUBLISH_PLATFORM_TYPE_FACEBOOK, + }, + { + label: 'YouTube', + value: PUBLISH_PLATFORM_TYPE_YOUTUBE, + }, +]; + +export const filterPublishStatusOptions = [ + { + label: 'Any', + value: PUBLISH_STATUS_TYPE_ANY, + }, + { + label: 'Published', + value: PUBLISH_STATUS_PUBLISHED, + }, + { + label: 'Not Published', + value: PUBLISH_STATUS_NOT_PUBLISHED, + }, + { + label: 'Publish Error', + value: PUBLISH_STATUS_PUBLISHED_PUBLISHED, + }, +]; + +export const feedTypeOptions = [ + { + label: 'Any', + value: FEED_TYPE_ALL, + }, + { + label: 'Video', + value: FEED_TYPE_VIDEO, + }, + { + label: 'Trending', + value: FEED_TYPE_TRENDING, + }, + { + label: 'Audio', + value: FEED_TYPE_AUDIO, + }, + { + label: 'Video+Audio', + value: FEED_TYPE_VIDEO_AUDIO, + }, +]; + +export const mediaTypeOptions = [ + { + label: 'Video+Audio', + value: FEED_TYPE_VIDEO_AUDIO, + }, + { + label: 'Video', + value: FEED_TYPE_VIDEO, + }, + { + label: 'Audio', + value: FEED_TYPE_AUDIO, + }, +]; + +export const verificationOptions = [ + { + label: 'Any', + value: VERIFICATION_TYPE_ALL, + }, + { + label: 'Verified', + value: VERIFICATION_TYPE_APPROVED, + }, + { + label: 'Un-Verified', + value: VERIFICATION_TYPE_NOT_APPROVED, + }, +]; + +export const videoStatusOptions = [ + { + label: 'Any', + value: VIDEO_STATUS_ALL, + }, + { + label: 'Active', + value: VIDEO_STATUS_ACTIVE, + }, + { + label: 'Archived', + value: VIDEO_STATUS_DISABLED, + }, + { + label: 'Processing', + value: VIDEO_STATUS_PROCESSING, + }, + { + label: 'Failed', + value: VIDEO_STATUS_FAILED, + }, +]; + +export const VIDEO_TABS_ENUM = { + all: 'ALL', + own: 'OWN', + shared: 'SHARED', +}; + +// Targeting filters +export const VIDEO_TARGETING_ANY = 'ANY'; +export const VIDEO_TARGETING_NONE = 'NONE'; +export const VIDEO_TARGETING_PLANNED = 'PLANNED'; +export const VIDEO_TARGETING_RUNNING = 'RUNNING'; +export const VIDEO_TARGETING_PAUSED = 'PAUSED'; +export const VIDEO_TARGETING_STOPPED = 'STOPPED'; + +export const VIDEO_TARGETING_OPTIONS = [ + { + label: 'Any', + value: VIDEO_TARGETING_ANY, + }, + { + label: 'None', + value: VIDEO_TARGETING_NONE, + }, + { + label: 'Planned', + value: VIDEO_TARGETING_PLANNED, + }, + { + label: 'Running', + value: VIDEO_TARGETING_RUNNING, + }, + { + label: 'Paused', + value: VIDEO_TARGETING_PAUSED, + }, + { + label: 'Stopped', + value: VIDEO_TARGETING_STOPPED, + }, +]; + +export const VIDEO_DURATION_ALL = 'ALL'; +export const VIDEO_DURATION_UNDER_4_MIN = 'UNDER_4_MIN'; +export const VIDEO_DURATION_4_TO_20_MIN = '4_TO_20_MIN'; +export const VIDEO_DURATION_OVER_20_MIN = 'OVER_20_MIN'; + +export const VIDEO_DURATION_VALUES = { + [VIDEO_DURATION_UNDER_4_MIN]: { + from: 0, + to: 4 * 60 * 1000 - 1, + }, + [VIDEO_DURATION_4_TO_20_MIN]: { + from: 4 * 60 * 1000, + to: 20 * 60 * 1000, + }, + [VIDEO_DURATION_OVER_20_MIN]: { + from: 20 * 60 * 1000 + 1, + }, +}; + +export const VIDEO_DURATION_LABELS = { + [VIDEO_DURATION_ALL]: 'Any Duration', + [VIDEO_DURATION_UNDER_4_MIN]: 'Under 4 minutes', + [VIDEO_DURATION_4_TO_20_MIN]: '4 - 20 minutes', + [VIDEO_DURATION_OVER_20_MIN]: 'Over 20 minutes', +}; + +export const sponsoredOptions = [ + { + label: 'All', + value: SPONSORED_ALL, + }, + { + label: 'Sponsored Content Only', + value: SPONSORED_ONLY, + }, + { + label: 'Not Sponsored Content', + value: SPONSORED_NONE, + }, +]; diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterConfig.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterConfig.js new file mode 100644 index 0000000..1061574 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterConfig.js @@ -0,0 +1,460 @@ +import { + DEFAULT_EVERGREEN_TYPE_VALUE, + DEFAULT_FEED_TYPE_VALUE, + DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE, + DEFAULT_PUBLISH_STATUS_TYPE_VALUE, + DEFAULT_VERIFICATION_TYPE_VALUE, + DEFAULT_VIDEO_STATUS_VALUE, + evergreenOptions, + FEED_TYPE_VIDEO_AUDIO, + feedTypeOptions, + FILTER_COMPONENTS_NAMES, + filterPublishPlatformOptions, + filterPublishStatusOptions, + mediaTypeOptions, + SPONSORED_ALL, + sponsoredOptions, + verificationOptions, + VIDEO_DURATION_4_TO_20_MIN, + VIDEO_DURATION_ALL, + VIDEO_DURATION_LABELS, + VIDEO_DURATION_OVER_20_MIN, + VIDEO_DURATION_UNDER_4_MIN, + VIDEO_TABS_ENUM, + VIDEO_TARGETING_ANY, + VIDEO_TARGETING_OPTIONS, + videoStatusOptions, +} from './constants'; +import { PCN_GET_DESTINATIONS, VIDEO_ACCESS_TARGETING } from '@/modules/@common/acl/constants'; + +import { deepClone } from '@/modules/@common/helpers'; + +const LAST_DAY = '1'; +const LAST_WEEK = '7'; +const LAST_MONTH = '30'; + +export const timeMap = { + days: LAST_DAY, + week: LAST_WEEK, + month: LAST_MONTH, +}; + +export const timeValueMap = { + [LAST_DAY]: { label: 'Past 24 hours', value: 'days' }, + [LAST_WEEK]: { label: 'Past Week', value: 'week' }, + [LAST_MONTH]: { label: 'Past Month', value: 'month' }, +}; + +export const DEFAULT_FILTER_NAMES = { + time: 'time', + duration: 'duration', + order: 'order', +}; + +export const INSIGHTS_FILTER_NAMES = { + timeFrame: 'timeFrame', +}; + +export const filterConfig = { + time: { + filters: [ + { label: 'Any Time', value: 'any' }, + { label: 'Past 24 hours', value: 'days' }, + { label: 'Past Week', value: 'week' }, + { label: 'Past Month', value: 'month' }, + { + label: 'Custom', + value: { + startDate: '', + endDate: '', + }, + component: FILTER_COMPONENTS_NAMES.FilterTimePicker, + }, + ], + isMobile: true, + }, + duration: { + filters: [ + { label: VIDEO_DURATION_LABELS[VIDEO_DURATION_ALL], value: VIDEO_DURATION_ALL }, + { label: VIDEO_DURATION_LABELS[VIDEO_DURATION_UNDER_4_MIN], value: VIDEO_DURATION_UNDER_4_MIN }, + { label: VIDEO_DURATION_LABELS[VIDEO_DURATION_4_TO_20_MIN], value: VIDEO_DURATION_4_TO_20_MIN }, + { label: VIDEO_DURATION_LABELS[VIDEO_DURATION_OVER_20_MIN], value: VIDEO_DURATION_OVER_20_MIN }, + ], + isMobile: false, + }, + languages: { + filters: [ + { + label: 'Languages', + value: [], + type: 'LANGUAGES', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + owner: { + filters: [ + { + label: 'Owner', + value: [], + type: 'OWNER', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + hubs: { + filters: [ + { + label: 'Hubs', + value: [], + type: 'HUBS', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + source: { + filters: [ + { + label: 'Sources', + value: [], + type: 'SOURCE', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + people: { + filters: [ + { + label: 'People', + value: [], + type: 'PEOPLE', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + brands: { + filters: [ + { + label: 'Brands', + value: [], + type: 'BRANDS', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + categories: { + filters: [ + { + label: 'Categories', + value: [], + type: 'IAB', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + brandSafety: { + filters: [ + { + label: 'Brand Safety', + value: [], + type: 'BRAND_SAFETY', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + order: { + filters: [ + { label: 'Date', value: 'creationDate', isSortable: true }, + { label: 'Upload Date', value: 'created', isSortable: true }, + { label: 'Relevance', value: 'relevance', isSortable: false }, + { label: 'Trending', value: 'trending', isSortable: false }, + { label: 'Duration', value: 'duration', isSortable: true }, + ], + isReversed: false, + isSortable: true, + isMobile: true, + }, + labels: { + filters: [ + { + label: 'Custom Tags', + value: [], + type: 'LABELS', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + uploadDate: { + filters: [ + { + label: 'Upload Date', + value: [ + { + from: null, + to: null, + }, + ], + component: FILTER_COMPONENTS_NAMES.FilterDatePicker, + }, + ], + }, + evergreen: { + filters: [ + { + label: 'Evergreen', + value: [ + { + label: 'All', + value: DEFAULT_EVERGREEN_TYPE_VALUE, + }, + ], + type: 'EVERGREEN', + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: evergreenOptions, + defaultValue: DEFAULT_EVERGREEN_TYPE_VALUE, + dataId: 'evergreen', + }, + ], + }, + sponsored: { + filters: [ + { + label: 'Sponsored', + value: [{ label: 'All', value: SPONSORED_ALL }], + type: 'SPONSORED', + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: sponsoredOptions, + defaultValue: SPONSORED_ALL, + dataId: 'sponsored-type', + }, + ], + }, + publishPlatform: { + filters: [ + { + label: 'Publish platform', + value: [ + { + label: 'All', + value: DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE, + }, + ], + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: filterPublishPlatformOptions, + defaultValue: DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE, + dataId: 'publish-platform', + permission: PCN_GET_DESTINATIONS, + }, + ], + }, + publishStatus: { + filters: [ + { + label: 'Publish status', + value: [ + { + label: 'All', + value: DEFAULT_PUBLISH_STATUS_TYPE_VALUE, + }, + ], + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: filterPublishStatusOptions, + defaultValue: DEFAULT_PUBLISH_STATUS_TYPE_VALUE, + dataId: 'publish-status', + permission: PCN_GET_DESTINATIONS, + }, + ], + }, + status: { + filters: [ + { + label: 'Video Status', + value: [{ label: 'Any', value: DEFAULT_VIDEO_STATUS_VALUE }], + type: 'VIDEO_STATUS', + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: videoStatusOptions, + defaultValue: DEFAULT_VIDEO_STATUS_VALUE, + dataId: 'video-state', + }, + ], + }, + verification: { + filters: [ + { + label: 'Verification', + value: [{ label: 'Any', value: DEFAULT_VERIFICATION_TYPE_VALUE }], + type: 'VERIFICATION', + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: verificationOptions, + defaultValue: DEFAULT_VERIFICATION_TYPE_VALUE, + dataId: 'verification', + }, + ], + }, + feedType: { + filters: [ + { + label: 'Source Type', + value: [{ label: 'Any', value: DEFAULT_FEED_TYPE_VALUE }], + type: 'FEED_TYPE', + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: feedTypeOptions, + defaultValue: DEFAULT_PUBLISH_STATUS_TYPE_VALUE, + dataId: 'feed-type', + }, + ], + }, + mediaType: { + filters: [ + { + label: 'Media Type', + value: [{ label: 'Video+Audio', value: FEED_TYPE_VIDEO_AUDIO }], + type: 'FEED_TYPE', + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: mediaTypeOptions, + defaultValue: FEED_TYPE_VIDEO_AUDIO, + dataId: 'feed-type', + }, + ], + }, + timeFrame: { + filters: [ + { label: 'Last 7 days', value: 'LAST_7_DAYS' }, + { label: 'Today', value: 'TODAY' }, + { label: 'Yesterday', value: 'YESTERDAY' }, + { label: 'Last 30 days', value: 'LAST_30_DAYS' }, + ], + }, + orderForInsights: { + filters: [ + { + label: 'Views', + value: 'insightsViews', + info: 'Total number of views of this video', + }, + { + label: 'Likes', + value: 'insightsLikes', + info: 'Total number of likes of this video', + }, + { + label: 'Shares', + value: 'insightsShares', + info: 'Total number of shares of this video', + }, + { + label: 'Min Viewed', + value: 'minutesViewed', + info: 'Total number of minutes viewed of the video', + }, + { + label: 'Avg Playback', + value: 'avgPlayback', + info: 'Average playback duration of this video', + }, + { + label: 'Trending Score', + value: 'trending', + info: 'The trending score (based on views likes shares on the last 24-36 hours) of this video', + }, + { + label: 'Interactions', + value: 'engagement', + info: 'Total number of interactions (positive player actions) of this video ', + }, + ], + isReversed: false, + }, + keywords: { + filters: [ + { + label: 'Keywords', + value: [], + type: 'KEYWORDS', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + videoTabs: { + filters: [ + { + label: 'All videos', + value: VIDEO_TABS_ENUM.all, + }, + { + label: 'My videos', + value: VIDEO_TABS_ENUM.own, + }, + { + label: 'Shared with me', + value: VIDEO_TABS_ENUM.shared, + }, + ], + }, + account: { + filters: [ + { + label: 'Account', + value: [], // { label, value, owners:[] } + type: 'ACCOUNT', + component: FILTER_COMPONENTS_NAMES.FilterSuggester, + }, + ], + }, + targetingStatus: { + filters: [ + { + label: 'Video Targeting', + value: [VIDEO_TARGETING_OPTIONS[0]], + component: FILTER_COMPONENTS_NAMES.FilterSelector, + options: VIDEO_TARGETING_OPTIONS, + defaultValue: VIDEO_TARGETING_ANY, + dataId: 'targeting-status', + permission: VIDEO_ACCESS_TARGETING, + }, + ], + }, +}; + +export const getDefaultConfig = () => { + const copiedFilters = deepClone(filterConfig); + + const getFilters = (key, filters) => { + if (key === 'orderForInsights') { + return []; + } + + if (key === 'timeFrame') { + return [filters[key].filters[3]]; + } + + return [copiedFilters[key].filters[0]]; + }; + + return Object.keys(copiedFilters).reduce( + (acc, key) => ({ + ...acc, + ...{ + [key]: { + ...copiedFilters[key], + filters: getFilters(key, copiedFilters), + }, + }, + }), + {}, + ); +}; + +export const getDefaultRelevanceOrderConfig = () => ({ + order: { + ...deepClone(filterConfig).order, + filters: [{ label: 'Relevance', value: 'relevance', isSortable: false }], + }, +}); + +export const getEmptySearchOrderConfig = () => ({ + order: { + ...deepClone(filterConfig).order, + filters: [{ label: 'Date', value: 'creationDate', isSortable: true }], + }, +}); diff --git a/src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.jsx diff --git a/src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.module.scss b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.module.scss similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.module.scss rename to anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/component/searchFilter.module.scss diff --git a/src/modules/editorial/editorialSearchFilter/filterContainer/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/index.js similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterContainer/index.js rename to anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/index.js diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/index.js new file mode 100644 index 0000000..8c7ff08 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import restoreCopiedConfig from './restoreCopiedConfig'; + +export default combineEpics(restoreCopiedConfig); diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/restoreCopiedConfig.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/restoreCopiedConfig.js new file mode 100644 index 0000000..432bfeb --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/epics/restoreCopiedConfig.js @@ -0,0 +1,30 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { configCopySelector } from '../selectors'; +import { + configAction, + configChangedEventAction, + configCopyAction, + isFilterDirtyAction, + restoreConfigAction, +} from '../slices'; +import { deepClone } from '@/modules/@common/helpers'; + +export default (action$, state$) => + action$.pipe( + ofType(restoreConfigAction.type), + filter((action) => configCopySelector(state$.value) || action.payload), + switchMap((action) => { + const actions = []; + actions.push( + of(configAction(deepClone(action.payload ? action.payload : configCopySelector(state$.value)))), + of(configChangedEventAction()), + of(configCopyAction(null)), + of(isFilterDirtyAction(true)), + ); + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors/index.js new file mode 100644 index 0000000..16c0cea --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors/index.js @@ -0,0 +1,22 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const configSelector = (state) => state[nameSpace].config; +export const configCopySelector = (state) => state[nameSpace].configCopy; +export const isMoreFiltersOpenedSelector = (state) => state[nameSpace].isMoreFiltersOpened; +export const isFilterDirtySelector = (state) => state[nameSpace].isFilterDirty; +export const isInsightsModeSelector = (state) => state[nameSpace].isInsightsMode; +export const restoreConfigSelector = (state) => state[nameSpace].restoreConfig; +export const defaultEmptyFilterOrderSelector = (state) => state[nameSpace].defaultEmptyFilterOrder; +export const tabCountersSelector = (state) => state[nameSpace].tabCounters; + +export const getCommonState = (state$) => { + const state = state$[nameSpace]; + + return { + ...state, + }; +}; + +export default getCommonState; diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/slices/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/slices/index.js new file mode 100644 index 0000000..03f9409 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterContainer/redux/slices/index.js @@ -0,0 +1,61 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { getDefaultConfig } from '../../../filterConfig'; + +const initialState = { + config: getDefaultConfig(), + configCopy: null, + + isMoreFiltersOpened: false, + isFilterDirty: false, + isInsightsMode: false, + + restoreConfig: null, + defaultEmptyFilterOrder: false, + + tabCounters: { all: null, own: null, shared: null }, +}; + +export const slice = createSlice({ + name: '@@editorialSearchFilter/EDITORIAL_SEARCH_FILTERS', + initialState, + reducers: { + configAction: (state, action) => { + state.config = action.payload || initialState.config; + }, + configCopyAction: (state, action) => { + state.configCopy = action.payload || initialState.configCopy; + }, + isMoreFiltersOpenedAction: (state, action) => { + state.isMoreFiltersOpened = action.payload || initialState.isMoreFiltersOpened; + }, + isFilterDirtyAction: (state, action) => { + state.isFilterDirty = action.payload || initialState.isFilterDirty; + }, + isInsightsModeAction: (state, action) => { + state.isInsightsMode = action.payload || initialState.isInsightsMode; + }, + restoreConfigAction: (state, action) => { + state.restoreConfig = action.payload || initialState.restoreConfig; + }, + defaultEmptyFilterOrderAction: (state, action) => { + state.defaultEmptyFilterOrder = action.payload || initialState.defaultEmptyFilterOrder; + }, + tabCountersAction: (state, action) => { + state.tabCounters = action.payload || initialState.tabCounters; + }, + configChangedEventAction: (state) => state, + }, +}); + +export const { + configAction, + configCopyAction, + isMoreFiltersOpenedAction, + isFilterDirtyAction, + isInsightsModeAction, + restoreConfigAction, + defaultEmptyFilterOrderAction, + tabCountersAction, + configChangedEventAction, +} = slice.actions; diff --git a/src/modules/editorial/editorialSearchFilter/filterDatePicker/filterDatePicker.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterDatePicker/filterDatePicker.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterDatePicker/filterDatePicker.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterDatePicker/filterDatePicker.jsx diff --git a/src/modules/editorial/editorialSearchFilter/filterItem/filterItem.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterItem/filterItem.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterItem/filterItem.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterItem/filterItem.jsx diff --git a/src/modules/editorial/editorialSearchFilter/filterSelector/index.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterSelector/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterSelector/index.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterSelector/index.jsx diff --git a/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/ActionAutocomplete.module.scss b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/ActionAutocomplete.module.scss similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/ActionAutocomplete.module.scss rename to anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/ActionAutocomplete.module.scss diff --git a/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/index.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/index.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionAutocomplete/index.jsx diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionIAB/index.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionIAB/index.jsx new file mode 100644 index 0000000..13bb791 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/ActionIAB/index.jsx @@ -0,0 +1,59 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { TagIabSelector } from '@/modules/@common/TagSelector'; + +function ActionIAB({ onChange = null, value = [], size = 'medium', placeholder = null, disabled = false, ...props }) { + const [list, setList] = useState(value); + + useEffect(() => setList(value), [value]); + + const selectedTags = useMemo( + () => + list.map((tag) => ({ + initialNode: { ...tag }, + label: tag.label, + value: tag.id, + include: tag.include, + })), + [list], + ); + + return ( + { + const newTags = tags.map((tag) => ({ + ...tag.initialNode, + include: tag.include, + })); + + setList(newTags); + + if (onChange) { + onChange(newTags); + } + }} + /> + ); +} + +ActionIAB.propTypes = { + id: PropTypes.string.isRequired, + onChange: PropTypes.func, + value: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + }), + ), + placeholder: PropTypes.string, + size: PropTypes.oneOf(['xSmall', 'small', 'medium', 'large']), + disabled: PropTypes.bool, +}; + +export default ActionIAB; diff --git a/src/modules/editorial/editorialSearchFilter/filterSuggester/component/Autocomplete/index.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/Autocomplete/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterSuggester/component/Autocomplete/index.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/Autocomplete/index.jsx diff --git a/src/modules/editorial/editorialSearchFilter/filterSuggester/component/filterSuggester.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/filterSuggester.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterSuggester/component/filterSuggester.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/filterSuggester.jsx diff --git a/src/modules/editorial/editorialSearchFilter/filterSuggester/component/types.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/types.js similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterSuggester/component/types.js rename to anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/component/types.js diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/accounts.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/accounts.js new file mode 100644 index 0000000..694217b --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/accounts.js @@ -0,0 +1,55 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { accountListAction, accountListLoadingAction, accountSearchPrefixAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetVideoFiltersAccountsQuery( + $searchText: String, + $pageSize: Int, + ) { + getVideoFiltersAccounts( + searchText: $searchText, + pageSize: $pageSize, + ) { + id + name + } + } +`; + +const getResponse = ({ data: { getVideoFiltersAccounts } }) => + getVideoFiltersAccounts.map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(accountSearchPrefixAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload?.prefix ?? '', + pageSize: 30, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push(of(accountListAction(accountOptions))); + } + + return concat(...actions); + }), + ); + + return concat(of(accountListLoadingAction(true)), stream$, of(accountListLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/contentOwnersByAccount.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/contentOwnersByAccount.js new file mode 100644 index 0000000..7bb32f9 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/contentOwnersByAccount.js @@ -0,0 +1,78 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { configSelector } from '../../../filterContainer/redux/selectors'; +import { configAction } from '../../../filterContainer/redux/slices'; +import { accountGetOwnersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetVideoFiltersContentOwnersByAccountQuery( + $accountId: Int + ) { + getVideoFiltersContentOwnersByAccount( + accountId: $accountId, + ) { + rows { + id + } + } + } +`; + +const getResponse = ({ data: { getVideoFiltersContentOwnersByAccount } }) => + getVideoFiltersContentOwnersByAccount.rows.map((co) => co.id); + +export default (action$, state$) => + action$.pipe( + ofType(accountGetOwnersAction.type), + switchMap((action) => { + const config = configSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + accountId: action.payload, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const contentOwners = getResponse(response); + + const filters = { + ...config, + account: { + ...config.account, + filters: [ + { + ...config.account.filters[0], + value: [ + { + ...config.account.filters[0].value[0], + owners: contentOwners, + }, + ], + }, + ], + }, + }; + + actions.push( + of( + configAction({ + ...filters, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/index.js new file mode 100644 index 0000000..e369459 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import accountsEpic from './accounts'; +import contentOwners from './contentOwnersByAccount'; +import keywordsEpic from './keywords'; +import publishers from './publishers'; + +export default combineEpics(keywordsEpic, accountsEpic, contentOwners, publishers); diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/keywords.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/keywords.js new file mode 100644 index 0000000..10fe1de --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/keywords.js @@ -0,0 +1,226 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { configSelector } from '../../../filterContainer/redux/selectors'; +import { keywordTypeSelector } from '../selectors'; +import { + feedDescriptionListAction, + feedDescriptionListLoadingAction, + feedDescriptionSearchPrefixAction, + keywordListAction, + keywordListLoadingAction, + keywordSearchPrefixAction, + labelListAction, + labelListLoadingAction, + labelSearchPrefixAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { getUserAccountIdSelector, getUserContentOwnersSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQLKeywords = ` + query autocompleteKeyword( + $contentOwner: [Float], + $category: String!, + $prefix: String, + $excludeOrigin: String, + $checkBrandSafetyPermission: Boolean + ) { + autocompleteKeyword( + contentOwner: $contentOwner, + category: $category, + prefix: $prefix, + excludeOrigin: $excludeOrigin, + checkBrandSafetyPermission: $checkBrandSafetyPermission + ) { + value + } + } +`; + +const queryGQLLabels = ` + query autocompleteLabelGrouped( + $accountId: Int + $prefix: String!, + $excludeOrigin: String + ) { + autocompleteLabelGrouped( + accountId: $accountId, + prefix: $prefix, + excludeOrigin: $excludeOrigin + ) { + labelGrouped { + name + values { + value + } + color + contentOwnerId + accountId + labelId + } + } + } +`; + +const queryGQLFeedDescriptionGrouped = ` + query autocompleteFeedDescriptionGrouped( + $contentOwners: [Float]!, + $contentOwnersOwnContent: [Float], + $prefix: String!, + $excludeOrigin: String + ) { + autocompleteFeedDescriptionGrouped( + contentOwners: $contentOwners, + contentOwnersOwnContent: $contentOwnersOwnContent, + prefix: $prefix, + excludeOrigin: $excludeOrigin + ) { + grouped { + ids + values { + value + } + } + } + } +`; + +const getKeywordsResponse = ({ autocompleteKeyword }) => + autocompleteKeyword.map(({ value }) => ({ value, label: value })); + +const getLabelsResponse = ({ autocompleteLabelGrouped: { labelGrouped } }) => + labelGrouped.reduce((acc, curr) => { + const labels = curr.values + ? curr.values.map((label) => ({ + label: label.value, + value: label.value, + name: curr.name, + color: curr.color, + contentOwner: curr.contentOwner, + groupBy: `${curr.name}|${curr.labelId}|${curr.color}`, + labelId: curr.labelId, + })) + : []; + + return [...acc, ...labels]; + }, []); + +const getFeedDescriptionResponse = ({ autocompleteFeedDescriptionGrouped: { grouped } }) => + grouped.reduce((acc, curr) => { + const sources = + curr.values?.map((feed) => ({ + label: feed.value, + value: feed.value, + })) ?? []; + + return [...acc, ...sources]; + }, []); + +export default (action$, state$) => + action$.pipe( + ofType(keywordSearchPrefixAction.type, labelSearchPrefixAction.type, feedDescriptionSearchPrefixAction.type), + debounceTime(+process.env.APP_CLEAR_TIMEOUT), + filter(() => !!getToken()), + switchMap((action) => { + const state = state$.value; + const userAccountId = getUserAccountIdSelector(state); + + const config = configSelector(state); + + const accountOwnersFromFilter = config.account.filters?.[0].value?.[0]?.owners ?? []; + const accountIdFromFilter = config.account.filters?.[0].value?.[0]?.value ?? null; + + const keywordType = keywordTypeSelector(state); + + let category; + let prefix; + let loadingAction; + let optionsAction; + let promiseOptions; + let owner; + let getResponse; + let contentOwnersOwnContent; + + switch (action.type) { + case keywordSearchPrefixAction.type: + ({ prefix, owner } = action.payload); + category = keywordType; + loadingAction = keywordListLoadingAction; + optionsAction = keywordListAction; + promiseOptions = { + query: queryGQLKeywords, + variables: { + contentOwner: accountOwnersFromFilter.length ? accountOwnersFromFilter : owner, + category, + prefix, + excludeOrigin: 'RSS', + checkBrandSafetyPermission: false, + }, + }; + getResponse = getKeywordsResponse; + break; + case labelSearchPrefixAction.type: + ({ prefix } = action.payload); + loadingAction = labelListLoadingAction; + optionsAction = labelListAction; + promiseOptions = { + query: queryGQLLabels, + variables: { + accountId: accountIdFromFilter || parseInt(userAccountId, 10), + prefix, + excludeOrigin: 'RSS', + }, + }; + getResponse = getLabelsResponse; + break; + case feedDescriptionSearchPrefixAction.type: + ({ prefix, owner } = action.payload); + loadingAction = feedDescriptionListLoadingAction; + optionsAction = feedDescriptionListAction; + + contentOwnersOwnContent = getUserContentOwnersSelector(state$.value) + .filter((own) => own.publisherOwnsContent) + .map((own) => own.contentOwnerId); + + promiseOptions = { + query: queryGQLFeedDescriptionGrouped, + variables: { + contentOwners: accountOwnersFromFilter.length + ? accountOwnersFromFilter + : owner.filter((own) => !contentOwnersOwnContent.includes(own)), + contentOwnersOwnContent, + prefix, + excludeOrigin: 'RSS', + }, + }; + getResponse = getFeedDescriptionResponse; + break; + default: + throw new Error('Wrong action type!'); + } + + const stream$ = gqlRequest(promiseOptions).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const responseData = getResponse(data); + + actions.push(of(optionsAction(responseData))); + } + + return concat(...actions); + }), + ); + + const actions = []; + + actions.push(of(loadingAction(true))); + actions.push(stream$); + actions.push(of(loadingAction(false))); + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/publishers.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/publishers.js new file mode 100644 index 0000000..fbaa1af --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/epics/publishers.js @@ -0,0 +1,69 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { configSelector } from '../../../filterContainer/redux/selectors'; +import { hubsListAction, hubsListLoadingAction, hubsSearchPrefixAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPublishersForVideoFilter( + $searchText: String, + $pageSize: Int, + $accountId: Int + ) { + getPublishersForVideoFilter( + searchText: $searchText, + pageSize: $pageSize, + accountId: $accountId, + ) { + id + name + } + } +`; + +const getResponse = ({ data: { getPublishersForVideoFilter } }) => + getPublishersForVideoFilter.map((entity) => ({ + value: entity.id, + label: entity.name, + })); + +export default (action$, state$) => + action$.pipe( + ofType(hubsSearchPrefixAction.type), + switchMap((action) => { + const state = state$.value; + + const config = configSelector(state); + const accountId = config.account.filters?.[0].value[0]?.value; + + const variables = { + searchText: action.payload?.prefix ?? '', + pageSize: 30, + }; + + if (accountId) { + variables.accountId = accountId; + } + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push(of(hubsListAction(accountOptions))); + } + + return concat(...actions); + }), + ); + + return concat(of(hubsListLoadingAction(true)), stream$, of(hubsListLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/selectors/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/selectors/index.js new file mode 100644 index 0000000..36b6aad --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/selectors/index.js @@ -0,0 +1,40 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const keywordTypeSelector = (state) => state[nameSpace].keywordType; +export const keywordListSelector = (state) => state[nameSpace].keywordList; +export const keywordListLoadingSelector = (state) => state[nameSpace].keywordListLoading; +export const keywordSearchPrefixSelector = (state) => state[nameSpace].keywordSearchPrefix; + +export const labelTypeSelector = (state) => state[nameSpace].labelType; +export const labelListSelector = (state) => state[nameSpace].labelList; +export const labelListLoadingSelector = (state) => state[nameSpace].labelListLoading; +export const labelSearchPrefixSelector = (state) => state[nameSpace].labelSearchPrefix; + +export const feedDescriptionTypeSelector = (state) => state[nameSpace].feedDescriptionType; +export const feedDescriptionListSelector = (state) => state[nameSpace].feedDescriptionList; +export const feedDescriptionListLoadingSelector = (state) => state[nameSpace].feedDescriptionListLoading; +export const feedDescriptionSearchPrefixSelector = (state) => state[nameSpace].feedDescriptionSearchPrefix; + +export const accountTypeSelector = (state) => state[nameSpace].accountType; +export const accountListSelector = (state) => state[nameSpace].accountList; +export const accountListLoadingSelector = (state) => state[nameSpace].accountListLoading; +export const accountSearchPrefixSelector = (state) => state[nameSpace].accountSearchPrefix; + +export const hubsTypeSelector = (state) => state[nameSpace].hubsType; +export const hubsListSelector = (state) => state[nameSpace].hubsList; +export const hubsListLoadingSelector = (state) => state[nameSpace].hubsListLoading; +export const hubsSearchPrefixSelector = (state) => state[nameSpace].hubsSearchPrefix; + +export const accountGetOwnersSelector = (state) => state[nameSpace].accountGetOwners; + +export const getCommonState = (state$) => { + const state = state$[nameSpace]; + + return { + ...state, + }; +}; + +export default getCommonState; diff --git a/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/slices/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/slices/index.js new file mode 100644 index 0000000..57450bd --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchFilter/filterSuggester/redux/slices/index.js @@ -0,0 +1,130 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + keywordType: '', + keywordList: [], + keywordListLoading: false, + keywordSearchPrefix: '', + + labelType: '', + labelList: [], + labelListLoading: false, + labelSearchPrefix: '', + + feedDescriptionType: '', + feedDescriptionList: [], + feedDescriptionListLoading: false, + feedDescriptionSearchPrefix: '', + + accountType: '', + accountList: [], + accountListLoading: false, + accountSearchPrefix: '', + + hubsType: '', + hubsList: [], + hubsListLoading: false, + hubsSearchPrefix: '', + + accountGetOwners: '', +}; + +export const slice = createSlice({ + name: '@@editorialSearchFilterSuggester/EDITORIAL_SEARCH_FILTER_SUGGESTER', + initialState, + reducers: { + keywordTypeAction: (state, action) => { + state.keywordType = action.payload || initialState.keywordType; + }, + keywordListAction: (state, action) => { + state.keywordList = action.payload || initialState.keywordList; + }, + keywordListLoadingAction: (state, action) => { + state.keywordListLoading = action.payload || initialState.keywordListLoading; + }, + keywordSearchPrefixAction: (state, action) => { + state.keywordSearchPrefix = action.payload || initialState.keywordSearchPrefix; + }, + labelTypeAction: (state, action) => { + state.labelType = action.payload || initialState.labelType; + }, + labelListAction: (state, action) => { + state.labelList = action.payload || initialState.labelList; + }, + labelListLoadingAction: (state, action) => { + state.labelListLoading = action.payload || initialState.labelListLoading; + }, + labelSearchPrefixAction: (state, action) => { + state.labelSearchPrefix = action.payload || initialState.labelSearchPrefix; + }, + feedDescriptionTypeAction: (state, action) => { + state.feedDescriptionType = action.payload || initialState.feedDescriptionType; + }, + feedDescriptionListAction: (state, action) => { + state.feedDescriptionList = action.payload || initialState.feedDescriptionList; + }, + feedDescriptionListLoadingAction: (state, action) => { + state.feedDescriptionListLoading = action.payload || initialState.feedDescriptionListLoading; + }, + feedDescriptionSearchPrefixAction: (state, action) => { + state.feedDescriptionSearchPrefix = action.payload || initialState.feedDescriptionSearchPrefix; + }, + accountTypeAction: (state, action) => { + state.accountType = action.payload || initialState.accountType; + }, + accountListAction: (state, action) => { + state.accountList = action.payload || initialState.accountList; + }, + accountListLoadingAction: (state, action) => { + state.accountListLoading = action.payload || initialState.accountListLoading; + }, + accountSearchPrefixAction: (state, action) => { + state.accountSearchPrefix = action.payload || initialState.accountSearchPrefix; + }, + accountGetOwnersAction: (state, action) => { + state.accountGetOwners = action.payload || initialState.accountGetOwners; + }, + hubsTypeAction: (state, action) => { + state.hubsType = action.payload || initialState.hubsType; + }, + hubsListAction: (state, action) => { + state.hubsList = action.payload || initialState.hubsList; + }, + hubsListLoadingAction: (state, action) => { + state.hubsListLoading = action.payload || initialState.hubsListLoading; + }, + hubsSearchPrefixAction: (state, action) => { + state.hubsSearchPrefix = action.payload || initialState.hubsSearchPrefix; + }, + clearAction: (state) => { + Object.keys(initialState).forEach((key) => { + state[key] = initialState[key]; + }); + }, + }, +}); + +export const { + keywordTypeAction, + keywordListAction, + keywordListLoadingAction, + keywordSearchPrefixAction, + labelTypeAction, + labelListAction, + labelListLoadingAction, + labelSearchPrefixAction, + feedDescriptionTypeAction, + feedDescriptionListAction, + feedDescriptionListLoadingAction, + feedDescriptionSearchPrefixAction, + accountTypeAction, + accountListAction, + accountListLoadingAction, + accountSearchPrefixAction, + accountGetOwnersAction, + hubsTypeAction, + hubsListAction, + hubsListLoadingAction, + hubsSearchPrefixAction, + clearAction, +} = slice.actions; diff --git a/src/modules/editorial/editorialSearchFilter/filterTimePicker/component/filterTimePicker.jsx b/anyclip/src/modules/editorial/editorialSearchFilter/filterTimePicker/component/filterTimePicker.jsx similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterTimePicker/component/filterTimePicker.jsx rename to anyclip/src/modules/editorial/editorialSearchFilter/filterTimePicker/component/filterTimePicker.jsx diff --git a/src/modules/editorial/editorialSearchFilter/filterTimePicker/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/filterTimePicker/index.js similarity index 100% rename from src/modules/editorial/editorialSearchFilter/filterTimePicker/index.js rename to anyclip/src/modules/editorial/editorialSearchFilter/filterTimePicker/index.js diff --git a/src/modules/editorial/editorialSearchFilter/helpers/filterComponentMapper.js b/anyclip/src/modules/editorial/editorialSearchFilter/helpers/filterComponentMapper.js similarity index 100% rename from src/modules/editorial/editorialSearchFilter/helpers/filterComponentMapper.js rename to anyclip/src/modules/editorial/editorialSearchFilter/helpers/filterComponentMapper.js diff --git a/src/modules/editorial/editorialSearchFilter/helpers/index.js b/anyclip/src/modules/editorial/editorialSearchFilter/helpers/index.js similarity index 100% rename from src/modules/editorial/editorialSearchFilter/helpers/index.js rename to anyclip/src/modules/editorial/editorialSearchFilter/helpers/index.js diff --git a/anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/constants/index.js b/anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/constants/index.js new file mode 100644 index 0000000..dae6bb0 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/constants/index.js @@ -0,0 +1,11 @@ +export const ACCESS_LEVEL_ENUM = { + public: 'PUBLIC', + private: 'PRIVATE', + site: 'SITE', +}; + +export const ACCESS_USER_ROLE = { + owner: 'OWNER', + read: 'READ', + readWrite: 'READ_WRITE', +}; diff --git a/src/modules/editorial/editorialSearchResults/components/AccessControlView/index.jsx b/anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchResults/components/AccessControlView/index.jsx rename to anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/index.jsx diff --git a/src/modules/editorial/editorialSearchResults/components/AccessControlView/index.module.scss b/anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/index.module.scss similarity index 100% rename from src/modules/editorial/editorialSearchResults/components/AccessControlView/index.module.scss rename to anyclip/src/modules/editorial/editorialSearchResults/components/AccessControlView/index.module.scss diff --git a/src/modules/editorial/editorialSearchResults/components/listItem/index.jsx b/anyclip/src/modules/editorial/editorialSearchResults/components/listItem/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchResults/components/listItem/index.jsx rename to anyclip/src/modules/editorial/editorialSearchResults/components/listItem/index.jsx diff --git a/src/modules/editorial/editorialSearchResults/components/placeholder/index.jsx b/anyclip/src/modules/editorial/editorialSearchResults/components/placeholder/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchResults/components/placeholder/index.jsx rename to anyclip/src/modules/editorial/editorialSearchResults/components/placeholder/index.jsx diff --git a/src/modules/editorial/editorialSearchResults/components/placeholder/index.module.scss b/anyclip/src/modules/editorial/editorialSearchResults/components/placeholder/index.module.scss similarity index 100% rename from src/modules/editorial/editorialSearchResults/components/placeholder/index.module.scss rename to anyclip/src/modules/editorial/editorialSearchResults/components/placeholder/index.module.scss diff --git a/src/modules/editorial/editorialSearchResults/components/searchResults/index.jsx b/anyclip/src/modules/editorial/editorialSearchResults/components/searchResults/index.jsx similarity index 100% rename from src/modules/editorial/editorialSearchResults/components/searchResults/index.jsx rename to anyclip/src/modules/editorial/editorialSearchResults/components/searchResults/index.jsx diff --git a/src/modules/editorial/editorialSearchResults/components/searchResults/styles.module.scss b/anyclip/src/modules/editorial/editorialSearchResults/components/searchResults/styles.module.scss similarity index 100% rename from src/modules/editorial/editorialSearchResults/components/searchResults/styles.module.scss rename to anyclip/src/modules/editorial/editorialSearchResults/components/searchResults/styles.module.scss diff --git a/anyclip/src/modules/editorial/editorialSearchResults/constants/index.js b/anyclip/src/modules/editorial/editorialSearchResults/constants/index.js new file mode 100644 index 0000000..4a1c084 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/constants/index.js @@ -0,0 +1,24 @@ +import { + FEED_TYPE_ALL, + FEED_TYPE_AUDIO, + FEED_TYPE_TRENDING, + FEED_TYPE_VIDEO, + FEED_TYPE_VIDEO_AUDIO, +} from '@/modules/editorial/editorialSearchFilter/constants'; + +export const ORIGIN_ALL_VALUE = null; +export const ORIGIN_VIDEO_VALUE = [ + { exclude: true, value: 'RSS' }, + { exclude: true, value: 'AUDIO' }, +]; +export const ORIGIN_AUDIO_VALUE = { exclude: false, value: 'AUDIO' }; +export const ORIGIN_VIDEO_AUDIO_VALUE = { exclude: true, value: 'RSS' }; +export const ORIGIN_TRADING_VALUE = { exclude: false, value: 'RSS' }; + +export const ORIGIN_OPTIONS = { + [FEED_TYPE_ALL]: ORIGIN_ALL_VALUE, + [FEED_TYPE_VIDEO]: ORIGIN_VIDEO_VALUE, + [FEED_TYPE_AUDIO]: ORIGIN_AUDIO_VALUE, + [FEED_TYPE_VIDEO_AUDIO]: ORIGIN_VIDEO_AUDIO_VALUE, + [FEED_TYPE_TRENDING]: ORIGIN_TRADING_VALUE, +}; diff --git a/anyclip/src/modules/editorial/editorialSearchResults/helpers/filter.js b/anyclip/src/modules/editorial/editorialSearchResults/helpers/filter.js new file mode 100644 index 0000000..55d75ee --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/helpers/filter.js @@ -0,0 +1,310 @@ +import dayjs from 'dayjs'; +import utcPlugin from 'dayjs/plugin/utc'; + +import { + DEFAULT_EVERGREEN_TYPE_VALUE, + DEFAULT_FEED_TYPE_VALUE, + DEFAULT_MEDIA_TYPE_VALUE, + DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE, + DEFAULT_PUBLISH_STATUS_TYPE_VALUE, + DEFAULT_VERIFICATION_TYPE_VALUE, + DEFAULT_VIDEO_STATUS_VALUE, + SPONSORED_ALL, + VIDEO_DURATION_ALL, + VIDEO_DURATION_VALUES, + VIDEO_TARGETING_ANY, +} from '@/modules/editorial/editorialSearchFilter/constants'; + +import filterConfig from './filterConfig'; + +dayjs.extend(utcPlugin); + +const filtersToParams = (filters, isInsightsMode, userPublisherIds, userContentOwners, userPermissions) => { + const params = {}; + const keywordFilters = []; + const keywordExcludes = []; + const labelFilters = []; + const labelExcludes = []; + const lang = []; + const langExcludes = []; + const feedDescription = []; + const feedDescriptionExcludes = []; + const iab = []; + const iabExcludes = []; + + const { + time, + videos, + people, + brands, + categories, + filterLabels, + languages, + source, + order, + isReversed, + timeFrame, + brandSafety, + keywords, + duration, + internalUserType, + evergreen, + publishPlatform, + publishStatus, + status, + verification, + feedType, + videoTab, + account, + targetingStatus, + mediaType, + sponsored, + } = filterConfig(filters, userPermissions); + + if (videoTab) { + params.videoTab = videoTab; + } + + if (time && time !== 'any') { + const isSubtractValue = ['days', 'week', 'month'].some((s) => time === s); + + const fromTime = isSubtractValue ? dayjs().subtract(1, time) : dayjs(time.startDate).clone(); + const toTime = isSubtractValue ? dayjs() : dayjs(time.endDate).clone(); + + params.dateOf = 'videoCreationDate'; + params.startDate = fromTime.utc().valueOf().toString(); + params.endDate = toTime.utc().valueOf().toString(); + } + + if (duration && duration !== VIDEO_DURATION_ALL) { + const durationFromToValue = VIDEO_DURATION_VALUES[duration]; + params.lengthFrom = durationFromToValue.from; + if (durationFromToValue.to) { + params.lengthTo = durationFromToValue.to; + } + } + + if (videos && userPublisherIds?.length && userContentOwners.length) { + params.videoAffiliation = videos; + } + + if (Array.isArray(people)) { + people.forEach((item) => { + const res = { + category: 'PEOPLE', + keyword: item.value, + }; + + if (item.include) { + keywordFilters.push(res); + } else { + keywordExcludes.push(res); + } + }); + } + + if (Array.isArray(brands)) { + brands.forEach((item) => { + const res = { + category: 'BRANDS', + keyword: item.value, + }; + + if (item.include) { + keywordFilters.push(res); + } else { + keywordExcludes.push(res); + } + }); + } + + if (Array.isArray(brandSafety)) { + brandSafety.forEach((item) => { + const res = { + category: 'BRAND_SAFETY', + keyword: item.value, + }; + + if (item.include) { + keywordFilters.push(res); + } else { + keywordExcludes.push(res); + } + }); + } + + if (Array.isArray(categories) && categories.length) { + categories.forEach((item) => { + if (item.include) { + iab.push(item.id); + } else { + iabExcludes.push(item.id); + } + }); + } + + if (Array.isArray(filterLabels)) { + filterLabels.forEach((item) => { + const res = { + name: item.name, + value: item.value, + color: item.color, + labelId: item.labelId, + }; + + if (item.include) { + labelFilters.push(res); + } else { + labelExcludes.push(res); + } + }); + } + + if (Array.isArray(languages)) { + languages.forEach((item) => { + if (item.include) { + lang.push(item.value); + } else { + langExcludes.push(item.value); + } + }); + } + + if (Array.isArray(source)) { + source.forEach((item) => { + if (item.include) { + feedDescription.push(item.value); + } else { + feedDescriptionExcludes.push(item.value); + } + }); + } + + if (Array.isArray(keywords)) { + keywords.forEach((item) => { + const res = { + category: 'KEYWORDS', + keyword: item.value, + }; + + if (item.include) { + keywordFilters.push(res); + } else { + keywordExcludes.push(res); + } + }); + } + + if (order) { + let sortBy = { + creationDate: 'videoCreationDate', + relevance: 'relevancy', + duration: 'videoLength', + trending: 'trending', + created: 'created', + }[order]; + + if (isInsightsMode && filters?.orderForInsights?.filters?.[0]?.value) { + sortBy = filters.orderForInsights.filters[0].value; + } + + if (sortBy !== undefined) { + let orderType = isReversed ? 'ASC' : 'DESC'; + + if (isInsightsMode && filters?.orderForInsights?.isReversed) { + orderType = filters?.orderForInsights?.isReversed ? 'ASC' : 'DESC'; + } + + params.sortType = sortBy; + params.sortOrder = orderType; + } + } + + if (evergreen && evergreen !== DEFAULT_EVERGREEN_TYPE_VALUE) { + params.evergreen = evergreen === 'EVERGREEN'; + } + + if (status && status !== DEFAULT_VIDEO_STATUS_VALUE && internalUserType) { + params.status = status; + } + + if (publishPlatform && publishPlatform !== DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE) { + params.publishPlatform = publishPlatform; + } + + if (publishStatus && publishStatus !== DEFAULT_PUBLISH_STATUS_TYPE_VALUE) { + params.publishStatus = publishStatus; + } + + if (verification && verification !== DEFAULT_VERIFICATION_TYPE_VALUE && internalUserType) { + params.verification = verification; + } + + if (feedType && feedType !== DEFAULT_FEED_TYPE_VALUE && internalUserType) { + params.feedType = feedType; + } + + if (mediaType && feedType !== DEFAULT_MEDIA_TYPE_VALUE) { + params.mediaType = mediaType; + } + + if (sponsored && sponsored !== SPONSORED_ALL) { + params.mediaType = mediaType; + } + + if (iab.length) { + params.iab = iab; + } + + if (iabExcludes.length) { + params.iabExcludes = iabExcludes; + } + + if (keywordFilters.length) { + params.keywordFilters = keywordFilters; + } + + if (keywordExcludes.length) { + params.keywordExcludes = keywordExcludes; + } + + if (labelFilters.length) { + params.labelFilters = labelFilters; + } + + if (labelExcludes.length) { + params.labelExcludes = labelExcludes; + } + + if (lang.length) { + params.lang = lang; + } + + if (langExcludes.length) { + params.langExcludes = langExcludes; + } + + if (feedDescription.length) { + params.feedDescription = feedDescription; + } + + if (feedDescriptionExcludes.length) { + params.feedDescriptionExcludes = feedDescriptionExcludes; + } + + if (timeFrame?.length) { + params.timeFrame = timeFrame; + } + + if (account) { + params.account = account; + } + + if (targetingStatus && targetingStatus !== VIDEO_TARGETING_ANY) { + params.targetingStatus = targetingStatus; + } + + return params; +}; + +export default filtersToParams; diff --git a/anyclip/src/modules/editorial/editorialSearchResults/helpers/filterConfig.js b/anyclip/src/modules/editorial/editorialSearchResults/helpers/filterConfig.js new file mode 100644 index 0000000..919ebc3 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/helpers/filterConfig.js @@ -0,0 +1,68 @@ +import { VIDEO_ADMIN } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +const filterConfig = (filters, userPermissions) => { + const time = filters?.time?.filters?.[0]?.value; + const duration = filters?.duration?.filters?.[0]?.value; + const videos = filters?.videos?.filters?.[0]?.value; + const people = filters?.people?.filters?.[0]?.value; + const brands = filters?.brands?.filters?.[0]?.value; + const categories = filters?.categories?.filters?.[0]?.value; + const filterLabels = filters?.labels?.filters?.[0]?.value; + const languages = filters?.languages?.filters?.[0]?.value; + const owner = filters?.owner?.filters?.[0]?.value; + const source = filters?.source?.filters?.[0]?.value; + const order = filters?.order?.filters?.[0]?.value; + const isReversed = filters?.order?.isReversed; + const timeFrame = filters?.timeFrame?.filters?.[0]?.value; + const brandSafety = filters?.brandSafety?.filters?.[0]?.value; + const keywords = filters?.keywords?.filters?.[0]?.value; + const evergreen = filters?.evergreen?.filters?.[0]?.value?.[0]?.value; + const publishPlatform = filters?.publishPlatform.filters?.[0]?.value?.[0]?.value; + const publishStatus = filters?.publishStatus.filters?.[0]?.value?.[0]?.value; + const status = filters.status?.filters?.[0]?.value?.[0]?.value; + const verification = filters.verification?.filters?.[0]?.value?.[0]?.value; + const feedType = filters.feedType?.filters?.[0]?.value?.[0]?.value; + const videoTab = filters.videoTabs?.filters?.[0]?.value; + const account = filters.account?.filters?.[0]?.value; + const targetingStatus = filters?.targetingStatus.filters?.[0]?.value?.[0]?.value; + const hubs = filters?.hubs?.filters?.[0]?.value; + const mediaType = filters.mediaType?.filters?.[0]?.value?.[0]?.value; + const sponsored = filters.sponsored?.filters?.[0]?.value?.[0]?.value; + + const internalUserType = hasPermission(VIDEO_ADMIN, userPermissions); + + return { + time, + videos, + people, + brands, + categories, + filterLabels, + languages, + owner, + source, + order, + isReversed, + timeFrame, + brandSafety, + keywords, + duration, + internalUserType, + evergreen, + publishPlatform, + publishStatus, + status, + verification, + feedType, + videoTab, + account, + targetingStatus, + hubs, + mediaType, + sponsored, + }; +}; + +export default filterConfig; diff --git a/anyclip/src/modules/editorial/editorialSearchResults/helpers/filterInternal.js b/anyclip/src/modules/editorial/editorialSearchResults/helpers/filterInternal.js new file mode 100644 index 0000000..8b867fa --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/helpers/filterInternal.js @@ -0,0 +1,341 @@ +import dayjs from 'dayjs'; +import utcPlugin from 'dayjs/plugin/utc'; + +import { + DEFAULT_EVERGREEN_TYPE_VALUE, + DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE, + DEFAULT_PUBLISH_STATUS_TYPE_VALUE, + DEFAULT_VERIFICATION_TYPE_VALUE, + DEFAULT_VIDEO_STATUS_VALUE, + VIDEO_DURATION_ALL, + VIDEO_DURATION_VALUES, + VIDEO_TARGETING_ANY, +} from '@/modules/editorial/editorialSearchFilter/constants'; + +import filterConfig from '@/modules/editorial/editorialSearchResults/helpers/filterConfig'; + +dayjs.extend(utcPlugin); + +const filtersToInternalParams = (filters, isInsightsMode, userPublisherIds, userContentOwners, userPermissions) => { + const params = {}; + const keywordsFilters = []; + const labels = []; + const language = []; + const feedDescription = []; + const iab = []; + const ranges = []; + const owners = []; + const filterSites = []; + + const uploadDate = { ...filters?.uploadDate?.filters?.[0]?.value?.[0] }; + + const { + time, + videos, + people, + brands, + categories, + filterLabels, + languages, + source, + order, + isReversed, + timeFrame, + brandSafety, + keywords, + duration, + internalUserType, + evergreen, + publishPlatform, + publishStatus, + status, + verification, + feedType, + owner, + videoTab, + account, + targetingStatus, + hubs, + mediaType, + sponsored, + } = filterConfig(filters, userPermissions); + + if (time && time !== 'any') { + const isSubtractValue = ['days', 'week', 'month'].some((s) => time === s); + + const fromTime = isSubtractValue ? dayjs().subtract(1, time) : dayjs(time.startDate).clone(); + const toTime = isSubtractValue ? dayjs() : dayjs(time.endDate).clone(); + + ranges.push({ + of: 'videoCreationDate', + from: fromTime.utc().valueOf(), + to: toTime.utc().valueOf(), + }); + } + + if (duration && duration !== VIDEO_DURATION_ALL) { + const durationFromToValue = VIDEO_DURATION_VALUES[duration]; + ranges.push({ + of: 'videoLength', + ...durationFromToValue, + }); + } + + if (videos && userPublisherIds?.length && userContentOwners.length) { + params.videoAffiliation = videos; + } + + if (Array.isArray(people)) { + people.forEach((item) => { + const res = { + category: 'PEOPLE', + value: item.value, + }; + + if (!item.include) { + res.exclude = true; + } + + keywordsFilters.push(res); + }); + } + + if (Array.isArray(brands)) { + brands.forEach((item) => { + const res = { + category: 'BRANDS', + value: item.value, + }; + + if (!item.include) { + res.exclude = true; + } + + keywordsFilters.push(res); + }); + } + + if (Array.isArray(brandSafety)) { + brandSafety.forEach((item) => { + const res = { + category: 'BRAND_SAFETY', + value: item.value, + }; + + if (!item.include) { + res.exclude = true; + } + + keywordsFilters.push(res); + }); + } + + if (Array.isArray(categories)) { + categories.forEach((item) => { + const res = { + id: item.id, + }; + + if (!item.include) { + res.exclude = true; + } + + iab.push(res); + }); + } + + if (Array.isArray(filterLabels)) { + filterLabels.forEach((item) => { + const res = { + name: item.name, + value: item.value, + }; + + if (!item.include) { + res.exclude = true; + } + + labels.push(res); + }); + } + + if (Array.isArray(languages)) { + languages.forEach((item) => { + const res = { + value: item.value, + }; + + if (!item.include) { + res.exclude = true; + } + + language.push(res); + }); + } + + if (Array.isArray(owner)) { + const ownersList = owner.map((o) => o.value); + owners.push(...ownersList); + } + + if (Array.isArray(source)) { + source.forEach((item) => { + const res = { + value: item.value, + }; + + if (!item.include) { + res.exclude = true; + } + + feedDescription.push(res); + }); + } + + if (Array.isArray(hubs)) { + hubs.forEach((item) => { + const res = { + value: `${item.value}`, + }; + + if (!item.include) { + res.exclude = true; + } + + filterSites.push(res); + }); + } + + if (Array.isArray(keywords)) { + keywords.forEach((item) => { + const res = { + category: 'KEYWORDS', + value: item.value, + }; + + if (!item.include) { + res.exclude = true; + } + + keywordsFilters.push(res); + }); + } + + if (order) { + let sortBy = { + creationDate: 'videoCreationDate', + relevance: 'relevancy', + duration: 'videoLength', + trending: 'trending', + created: 'created', + }[order]; + + if (isInsightsMode && filters?.orderForInsights?.filters?.[0]?.value) { + sortBy = filters.orderForInsights.filters[0].value; + } + + if (sortBy !== undefined) { + let orderType = isReversed ? 'ASC' : 'DESC'; + + if (isInsightsMode) { + orderType = filters?.orderForInsights?.isReversed ? 'ASC' : 'DESC'; + } + + params.sortType = sortBy; + params.sortOrder = orderType; + } + } + + if (evergreen && evergreen !== DEFAULT_EVERGREEN_TYPE_VALUE) { + params.evergreen = evergreen === 'EVERGREEN'; + } + + if (publishPlatform && publishPlatform !== DEFAULT_PUBLISH_PLATFORM_TYPE_VALUE) { + params.publishPlatform = publishPlatform; + } + + if (publishStatus && publishStatus !== DEFAULT_PUBLISH_STATUS_TYPE_VALUE) { + params.publishStatus = publishStatus; + } + + if (status && status !== DEFAULT_VIDEO_STATUS_VALUE && internalUserType) { + params.status = status; + } + + if (verification && verification !== DEFAULT_VERIFICATION_TYPE_VALUE && internalUserType) { + params.verification = verification; + } + + if (feedType && internalUserType) { + params.feedType = feedType; + } + + if (mediaType && !internalUserType) { + params.mediaType = mediaType; + } + + if (sponsored) { + params.sponsored = sponsored; + } + + if (iab.length) { + params.iab = iab; + } + + if (keywordsFilters.length) { + params.keywords = keywordsFilters; + } + + if (labels.length) { + params.labels = labels; + } + + if (language.length) { + params.language = language; + } + + if (feedDescription.length) { + params.feedDescription = feedDescription; + } + + if (owners.length) { + params.owners = owners; + } + + if (uploadDate.from || uploadDate.to) { + if (!uploadDate.from) { + delete uploadDate.from; + } + if (!uploadDate.to) { + delete uploadDate.to; + } + uploadDate.of = 'created'; + ranges.push(uploadDate); + } + + if (ranges.length) { + params.ranges = ranges; + } + + if (timeFrame?.length) { + params.timeFrame = timeFrame; + } + + if (account) { + params.account = account; + } + + if (targetingStatus && targetingStatus !== VIDEO_TARGETING_ANY) { + params.targetingStatus = targetingStatus; + } + + if (filterSites.length) { + params.filterSites = filterSites; + } + + params.videoTab = videoTab; + + return params; +}; + +export default filtersToInternalParams; diff --git a/anyclip/src/modules/editorial/editorialSearchResults/helpers/index.js b/anyclip/src/modules/editorial/editorialSearchResults/helpers/index.js new file mode 100644 index 0000000..c0461d4 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/helpers/index.js @@ -0,0 +1,50 @@ +import { ORIGIN_OPTIONS } from '../constants'; +import { + DEFAULT_FEED_TYPE_VALUE, + DEFAULT_MEDIA_TYPE_VALUE, + SPONSORED_ALL, +} from '@/modules/editorial/editorialSearchFilter/constants'; + +export const getClosestKnownTimeZone = () => { + const timeZones = [ + { + name: 'UTC', + value: 0, + }, + { + name: 'EST', + value: -5, + }, + { + name: 'PST', + value: -8, + }, + ]; + + const time = -(new Date().getTimezoneOffset() / 60); + + const closestTimeZone = timeZones.reduce((a, b) => (Math.abs(b.value - time) < Math.abs(a.value - time) ? b : a)); + + return closestTimeZone?.name ?? 'UTC'; +}; + +export const getOriginValue = (filterInternalParams) => { + const getValue = (type, defaultValue) => + ORIGIN_OPTIONS[type] || ORIGIN_OPTIONS[type] === null ? ORIGIN_OPTIONS[type] : ORIGIN_OPTIONS[defaultValue]; + + if (filterInternalParams.feedType) { + return getValue(filterInternalParams.feedType, DEFAULT_FEED_TYPE_VALUE); + } + + if (filterInternalParams.mediaType) { + return getValue(filterInternalParams.mediaType, DEFAULT_MEDIA_TYPE_VALUE); + } + + if (filterInternalParams.sponsored) { + return getValue(filterInternalParams.sponsored, SPONSORED_ALL); + } + + return ORIGIN_OPTIONS[DEFAULT_MEDIA_TYPE_VALUE]; +}; + +export default {}; diff --git a/src/modules/editorial/editorialSearchResults/index.js b/anyclip/src/modules/editorial/editorialSearchResults/index.js similarity index 100% rename from src/modules/editorial/editorialSearchResults/index.js rename to anyclip/src/modules/editorial/editorialSearchResults/index.js diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/addQueries.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/addQueries.js new file mode 100644 index 0000000..00f288d --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/addQueries.js @@ -0,0 +1,92 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; + +import { EDITORIAL_PAGE } from '@/modules/@common/router/constants'; +import { namesFilterSelectors } from '@/modules/editorial/editorialSearchFilter/constants'; + +import { selectedVideoSelector } from '../selectors'; +import { addFiltersQueriesAction, selectedVideoAction } from '../slices'; +import { isEqual } from '@/modules/@common/helpers'; +import { addQueriesAction, removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { configSelector } from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors'; + +import { getDefaultConfig } from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +const formatFilterToQueries = (config, defaultConfig) => { + let result = {}; + + Object.keys(config) + .filter((key) => !isEqual(config[key], defaultConfig[key])) + .forEach((key) => { + const value = config[key]?.filters[0]?.value; + const label = config[key]?.filters[0]?.label; + + if (key === 'time' && label === 'Custom') { + result = { + ...result, + [key]: { + startDate: value.startDate, + endDate: value.endDate, + }, + }; + } else if (key === 'order') { + result = { + ...result, + [key]: { + value, + isReversed: config[key]?.isReversed, + }, + }; + } else if (namesFilterSelectors.includes(key)) { + result = { + ...result, + [key]: value[0]?.value, + }; + } else { + result = { + ...result, + [key]: value, + }; + } + }); + + return result; +}; + +export default (action$, state$) => + action$.pipe( + ofType(selectedVideoAction.type, addFiltersQueriesAction.type), + filter((action) => { + if (Router.pathname !== EDITORIAL_PAGE.path) { + return false; + } + + if (action.type === selectedVideoAction.type) { + return action.payload && action.payload.uid; + } + + return true; + }), + mergeMap(({ type }) => { + if (type === selectedVideoAction.type) { + const { uid } = selectedVideoSelector(state$.value); + return concat(of(addQueriesAction({ videoId: uid }))); + } + + const defaultConfig = getDefaultConfig(); + const config = configSelector(state$.value); + + const queriesToRemove = Object.keys(config).filter((key) => isEqual(config[key], defaultConfig[key])); + + if (queriesToRemove?.length) { + return concat( + of(addQueriesAction(formatFilterToQueries(config, defaultConfig))), + of(removeQueriesAction(queriesToRemove)), + ); + } + + return concat(of(addQueriesAction(formatFilterToQueries(config, defaultConfig)))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/canAddToPlaylist.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/canAddToPlaylist.js new file mode 100644 index 0000000..3ad7a21 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/canAddToPlaylist.js @@ -0,0 +1,28 @@ +import { ofType } from 'redux-observable'; +import { map } from 'rxjs/operators'; + +import { PCN_PUT_PLAYLIST } from '@/modules/@common/acl/constants'; +import { MODE_PLAYLIST } from '@/modules/editorial/RightSideBar/common/constants'; + +import { canAddToPlaylistAction } from '../slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { modeSelector } from '@/modules/editorial/RightSideBar/TabPlaylist/redux/selectors'; +import { setModeAction } from '@/modules/editorial/RightSideBar/TabPlaylist/redux/slices'; +import * as watchesSelectors from '@/modules/editorial/RightSideBar/TabWatch/Watches/redux/selectors'; +import { openChannelAction } from '@/modules/editorial/RightSideBar/TabWatch/Watches/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(setModeAction.type, openChannelAction.type), + map(({ type }) => { + const userPermissions = getUserPermissionsSelector(state$.value); + const mode = modeSelector(state$.value); + const canAddToPlaylist = + type === openChannelAction.type + ? !!watchesSelectors.channelIdSelector(state$.value) + : mode === MODE_PLAYLIST && hasPermission(PCN_PUT_PLAYLIST, userPermissions); + + return canAddToPlaylistAction(canAddToPlaylist); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/download.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/download.js new file mode 100644 index 0000000..0053859 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/download.js @@ -0,0 +1,86 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, EMPTY } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { switchMap } from 'rxjs/operators'; + +import { + DEFAULT_VERIFICATION_TYPE_VALUE, + DEFAULT_VIDEO_STATUS_VALUE, +} from '@/modules/editorial/editorialSearchFilter/constants'; + +import { getClosestKnownTimeZone, getOriginValue } from '../../helpers'; +import { getToken } from '@/modules/@common/token/helpers'; +import { fromSuggesterSelector, querySelector } from '@/modules/editorial/editorialSearch/reduxSearch/selectors'; +import { filterInternalParamsSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { downloadEventAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(downloadEventAction.type), + switchMap(() => { + const state = state$.value; + const query = querySelector(state); + const fromSuggester = fromSuggesterSelector(state); + const filterInternalParams = filterInternalParamsSelector(state); + const body = { + ...filterInternalParams, + queryIds: true, + typeValue: 'SHORT_FORM', + timeZone: getClosestKnownTimeZone(), + includeMaxPerformance: true, + }; + + body.origins = getOriginValue(filterInternalParams); + + if (filterInternalParams.status && filterInternalParams.status !== DEFAULT_VIDEO_STATUS_VALUE) { + body.stateValue = filterInternalParams.status; + } + + if (filterInternalParams.verification && filterInternalParams.verification !== DEFAULT_VERIFICATION_TYPE_VALUE) { + body.verificationValue = filterInternalParams.verification; + } + + if (filterInternalParams.account?.[0]?.owners?.length) { + body.owners = filterInternalParams.account[0].owners.map((o) => o.toString()); + delete body.account; + } + + if (query.length) { + body.query = query; + + if (fromSuggester) { + body.names = [query]; + } + } + + body.fromEditorial = true; + + const request = () => + ajax({ + method: 'POST', + url: '/api/export/video-list', + headers: { + 'Content-Type': 'application/json', + Authorization: getToken(), + }, + responseType: 'blob', + body, + }); + + const stream$ = defer(() => request()).pipe( + switchMap(({ response }) => { + const url = window.URL.createObjectURL(new Blob([response])); + const link = document.createElement('a'); + + link.href = url; + link.setAttribute('download', 'export_search_list.xlsx'); + document.body.appendChild(link); + link.click(); + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/filterParams.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/filterParams.js new file mode 100644 index 0000000..a3b5b65 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/filterParams.js @@ -0,0 +1,79 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TRENDING } from '@/modules/editorial/constants/video'; + +import filtersToParams from '../../helpers/filter'; +import filtersToInternalParams from '../../helpers/filterInternal'; +import { + filterInternalParamsAction, + filterParamsAction, + restartSearchEventAction, + searchFiltersAction, + startSearchEventAction, +} from '../slices'; +import { deepClone } from '@/modules/@common/helpers'; +import { + getPublisherIdsSelector, + getUserContentOwnersSelector, + getUserPermissionsSelector, +} from '@/modules/@common/user/redux/selectors'; +import { playlistTypeSelector } from '@/modules/editorial/editorialSearch/reduxSearch/selectors'; +import { searchEventAction } from '@/modules/editorial/editorialSearch/reduxSearch/slices'; +import { + configSelector, + isInsightsModeSelector, + isMoreFiltersOpenedSelector, +} from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors'; +import { aiPlaylistModeSelector } from '@/modules/editorial/RightSideBar/TabPlaylist/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(searchEventAction.type, restartSearchEventAction.type), + switchMap(() => { + const state = state$.value; + const userPermissions = getUserPermissionsSelector(state); + const configRaw = configSelector(state); + const isInsightsMode = isInsightsModeSelector(state); + const isMoreFiltersOpened = isMoreFiltersOpenedSelector(state); + + const userPublisherIds = getPublisherIdsSelector(state$.value); + const userContentOwners = getUserContentOwnersSelector(state$.value); + + const playlistType = playlistTypeSelector(state); + const aiPlaylistMode = aiPlaylistModeSelector(state$.value); + + const config = deepClone(configRaw); + + if (playlistType === TRENDING && aiPlaylistMode && isMoreFiltersOpened) { + config.order.filters[0] = { + label: 'Trending', + value: 'trending', + isSortable: false, + }; + } + + const filterParams = filtersToParams( + config, + isInsightsMode, + userPublisherIds, + userContentOwners, + userPermissions, + ); + const filterInternalParams = filtersToInternalParams( + config, + isInsightsMode, + userPublisherIds, + userContentOwners, + userPermissions, + ); + + return concat( + of(searchFiltersAction(config)), + of(filterParamsAction(filterParams)), + of(filterInternalParamsAction(filterInternalParams)), + of(startSearchEventAction()), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getEmbedCode.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getEmbedCode.js new file mode 100644 index 0000000..7c50e5b --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getEmbedCode.js @@ -0,0 +1,149 @@ +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { embedCodeAction, getEmbedCodeAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +dayjs.extend(durationPlugin); + +const queryGQL = ` + query getPlaylistEmbedCode( + $aspectRatio: String, + $playerId: Int!, + $playlistId: String, + $seo: Boolean, + $name: String, + $desc: String, + $duration: String, + $thumbnailUrl: String, + $uploadDate: String, + $views: Int, + $contentUrl: String, + ) { + getPlaylistEmbedCode( + aspectRatio: $aspectRatio, + playerId: $playerId, + playlistId: $playlistId, + seo: $seo, + name: $name, + desc: $desc, + duration: $duration, + thumbnailUrl: $thumbnailUrl, + uploadDate: $uploadDate, + views: $views + contentUrl: $contentUrl, + ) { + displayEmbedCode + embedCode + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getEmbedCodeAction.type), + filter((action) => action.payload.playerId || !!getToken()), + switchMap((action) => { + const { videoId, playerId, aspectRatio, seo, video } = action.payload; + + let videoInfo = {}; + + if (seo) { + let thumbnailUrl = ''; + const uploadDate = new Date(video.created); + + if (video.thumbnailFiles?.length) { + const thumbnailSorted = video.thumbnailFiles.slice().sort((a, b) => a.width - b.width); + const thumbnail = + thumbnailSorted.find((item) => item.width >= 480 && item.height >= 270) ?? thumbnailSorted.pop(); + thumbnailUrl = thumbnail.file ?? ''; + } + + videoInfo = { + seo, + name: video.name, + desc: video.plot ?? '', + thumbnailUrl: thumbnailUrl + .replace('cdn5.anyclip.com', 'stream.geniusplus.ai') + .replace('aceu-weavo-euprod.eu.anyclip.com', 'stream-eu.geniusplus.ai'), + duration: dayjs.duration(video.videoLength).toISOString(), + uploadDate: uploadDate.toISOString(), + views: video.performanceTotal?.numberOfViews ?? 0, + }; + + const contentUrl = video?.videoFiles.reduce((prevVideo, nextVideo) => { + const currentResolution = nextVideo.width * nextVideo.height; + const prevResolution = prevVideo.width * prevVideo.height; + + return currentResolution > prevResolution ? nextVideo : prevVideo; + }); + + if (contentUrl?.file) { + videoInfo.contentUrl = contentUrl.file; + } + + if (!videoInfo.name.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'This Video couldn’t be added to the VideoObject for SEO as the name field is missing.', + }), + ), + ); + } + + if (!videoInfo.thumbnailUrl.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: + 'This Video couldn’t be added to the VideoObject for SEO as the thumbnail URL field is missing.', + }), + ), + ); + } + + if (!videoInfo.uploadDate.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'This Video couldn’t be added to the VideoObject for SEO as the upload date field is missing.', + }), + ), + ); + } + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + aspectRatio, + playerId, + playlistId: videoId, + ...videoInfo, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(embedCodeAction(data.getPlaylistEmbedCode.embedCode))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getPlayers.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getPlayers.js new file mode 100644 index 0000000..b97ee49 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/getPlayers.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getPlayersAction, playersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getPublisherIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + query getPublisherPlayers { + getPublisherPlayers { + results { + id + name + alias + publisherId + urlPrefix + displayEmbedCode + videoObjectSupported + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPlayersAction.type), + filter(() => !!getPublisherIdsSelector(state$.value)?.length), + switchMap(() => { + const stream$ = gqlRequest({ + query: queryGQL, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of(playersAction(data.getPublisherPlayers?.results.sort((a, b) => a?.alias?.localeCompare(b?.alias)))), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/index.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/index.js new file mode 100644 index 0000000..b8d8a80 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/index.js @@ -0,0 +1,35 @@ +import { combineEpics } from 'redux-observable'; + +import addQueries from './addQueries'; +import canAddToPlaylist from './canAddToPlaylist'; +import downloadEpic from './download'; +import filterParams from './filterParams'; +import getEmbedCode from './getEmbedCode'; +import getPlayers from './getPlayers'; +import monitoring from './monitoring'; +import monitoringFinish from './monitoringFinish'; +import removeVideoIdEpic from './removeVideoIdQuery'; +import searchNextVideos from './searchNextVideos'; +import searchRestart from './searchRestart'; +import searchVideos from './searchVideos'; +import showNotification from './showNotification'; +import targetClipId from './targetClipId'; +import videosChangeProcessingStatusMonitoring from './videosChangeProcessingStatusMonitoring'; + +export default combineEpics( + addQueries, + filterParams, + searchVideos, + searchNextVideos, + targetClipId, + canAddToPlaylist, + searchRestart, + showNotification, + monitoring, + monitoringFinish, + getPlayers, + getEmbedCode, + downloadEpic, + removeVideoIdEpic, + videosChangeProcessingStatusMonitoring, +); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoring.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoring.js new file mode 100644 index 0000000..354435e --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoring.js @@ -0,0 +1,17 @@ +import { ofType } from 'redux-observable'; +import { filter, map } from 'rxjs/operators'; + +import { startAction } from '@/modules/@common/monitoring/redux/slices'; +import { selectedVideoSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { monitoringJobAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(monitoringJobAction.type), + filter(() => selectedVideoSelector(state$.value)?.uid), + map(() => { + const selectedVideo = selectedVideoSelector(state$.value); + + return startAction(selectedVideo.uid); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoringFinish.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoringFinish.js new file mode 100644 index 0000000..dc8b09c --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/monitoringFinish.js @@ -0,0 +1,96 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { endAction } from '@/modules/@common/monitoring/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector, videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { selectedVideoAction, videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import videoById from '@/modules/@common/gql/queries/videoById'; + +export default (action$, state$) => + action$.pipe( + ofType(endAction.type), + switchMap((monitoringData) => { + const state = state$.value; + + const videoId = monitoringData.payload; + + const stream$ = gqlRequest({ + query: videoById, + variables: { + uid: videoId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const videos = videosSelector(state); + const selectedVideo = selectedVideoSelector(state); + + if (!errors.length) { + const isVideoActive = data.video.status === 'ACTIVE'; + + if (data.video.status !== 'PROCESSING') { + actions.push( + of( + showNotificationAction({ + type: isVideoActive ? 'success' : 'error', + message: isVideoActive ? 'Video processing completed successfully' : 'Video processing failed', + }), + ), + ); + } + + const allowToShow = ['ACTIVE', 'PROCESSING', 'FAILED'].some((status) => data.video.status === status); + + if (allowToShow) { + let updatedVideo = null; + + const updatedVideos = videos.map((video) => { + if (video.uid === data.video.uid) { + updatedVideo = { ...video, ...data.video }; + + return { ...video, ...data.video }; + } + + return video; + }); + + if (updatedVideo) { + if (updatedVideo.uid === selectedVideo?.uid) { + actions.push(of(selectedVideoAction(updatedVideo))); + } + + actions.push(of(videosAction(updatedVideos))); + } + } else { + const updatedVideos = videos.filter((video) => video.uid !== data.video.uid); + + actions.push(of(videosAction(updatedVideos)), of(selectedVideoAction())); + } + } else { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Video processing failed', + }), + ), + ); + + if (videoId === selectedVideo?.uid) { + actions.push(of(selectedVideoAction())); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/removeVideoIdQuery.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/removeVideoIdQuery.js new file mode 100644 index 0000000..ee23c02 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/removeVideoIdQuery.js @@ -0,0 +1,19 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap } from 'rxjs/operators'; + +import { selectedVideoAction } from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(selectedVideoAction.type), + filter((action) => { + if (action.type === selectedVideoAction) { + return !action.payload?.uid; + } + + return false; + }), + mergeMap(() => concat(of(removeQueriesAction(['videoId'])))), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchNextVideos.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchNextVideos.js new file mode 100644 index 0000000..b73cc86 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchNextVideos.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { + DEFAULT_VERIFICATION_TYPE_VALUE, + DEFAULT_VIDEO_STATUS_VALUE, +} from '@/modules/editorial/editorialSearchFilter/constants'; + +import { getOriginValue } from '../../helpers'; +import * as selectors from '../selectors'; +import { isNextVideosLoadingAction, loadNextEventAction, pageAction, totalCountAction, videosAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { querySelector } from '@/modules/editorial/editorialSearch/reduxSearch/selectors'; + +import videosQuery from '@/modules/@common/gql/queries/videos'; + +export default (action$, state$) => + action$.pipe( + ofType(loadNextEventAction.type), + debounceTime(500), + filter(() => !!getToken()), + filter(() => !selectors.isVideosLoadingSelector(state$.value)), + filter(() => !selectors.isNextVideosLoadingSelector(state$.value)), + switchMap(() => { + const state = state$.value; + const query = querySelector(state); + + const page = selectors.pageSelector(state$.value); + const size = selectors.sizeSelector(state$.value); + const videos = selectors.videosSelector(state$.value); + const filterInternalParams = selectors.filterInternalParamsSelector(state$.value); + const isVideosLoading = selectors.isVideosLoadingSelector(state$.value); + + const updatedPage = page + 1; + + const requestParams = { + ...filterInternalParams, + typeValue: 'SHORT_FORM', + page: updatedPage, + size, + queryIds: true, + }; + + if (query.length) { + requestParams.query = query; + } + + requestParams.origins = getOriginValue(filterInternalParams); + + if (filterInternalParams.status && filterInternalParams.status !== DEFAULT_VIDEO_STATUS_VALUE) { + requestParams.stateValue = filterInternalParams.status; + } + + if (filterInternalParams.verification && filterInternalParams.verification !== DEFAULT_VERIFICATION_TYPE_VALUE) { + requestParams.verificationValue = filterInternalParams.verification; + } + + const stream$ = gqlRequest({ + query: videosQuery, + variables: { + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (data.videoSearch && !errors.length && !isVideosLoading) { + const updatedVideos = videos.concat(...data.videoSearch.videos); + + actions.push(of(videosAction(updatedVideos))); + actions.push(of(totalCountAction(data.videoSearch.totalCount))); + actions.push(of(pageAction(updatedPage))); + } + + return concat(...actions); + }), + ); + + return concat(of(isNextVideosLoadingAction(true)), stream$, of(isNextVideosLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchRestart.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchRestart.js new file mode 100644 index 0000000..9575507 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchRestart.js @@ -0,0 +1,31 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { searchFiltersSelector } from '../selectors'; +import { addFiltersQueriesAction, restartSearchEventAction } from '../slices'; +import { isEqual } from '@/modules/@common/helpers'; +import { configSelector } from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors'; +import { configChangedEventAction } from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(configChangedEventAction.type), + switchMap(() => { + const state = state$.value; + const searchFilters = searchFiltersSelector(state); + const config = configSelector(state); + + const actions = []; + + if (!isEqual(config, searchFilters)) { + actions.push(of(restartSearchEventAction())); + } + + if (searchFilters) { + actions.push(of(addFiltersQueriesAction())); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchVideos.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchVideos.js new file mode 100644 index 0000000..19d1058 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/searchVideos.js @@ -0,0 +1,205 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { + DEFAULT_VERIFICATION_TYPE_VALUE, + DEFAULT_VIDEO_STATUS_VALUE, +} from '@/modules/editorial/editorialSearchFilter/constants'; + +import { getClosestKnownTimeZone, getOriginValue } from '../../helpers'; +import * as selectors from '../selectors'; +import { + currentFiltersAsStringAction, + isNextVideosLoadingAction, + isSearchFilteredAction, + isTargetClipIdLoadingAction, + isVideosLoadingAction, + maxPerformanceAction, + pageAction, + selectedVideoAction, + startSearchEventAction, + targetClipIdAction, + targetVideoIdAction, + totalCountAction, + videosAction, +} from '../slices'; +import { removeQueriesAction } from '@/modules/@common/location/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { closeAction as closeMultipleBulkAction } from '@/modules/editorial/bulkActions/redux/slices'; +import { fromSuggesterSelector, querySelector } from '@/modules/editorial/editorialSearch/reduxSearch/selectors'; +import { + configSelector, + isFilterDirtySelector, + tabCountersSelector, +} from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/selectors'; +import { + configAction, + configChangedEventAction, + tabCountersAction, +} from '@/modules/editorial/editorialSearchFilter/filterContainer/redux/slices'; +import { getDefaultTab, ifAllTabAvailable } from '@/modules/editorial/helpers/videoTab'; + +import videosQuery from '@/modules/@common/gql/queries/videos'; + +const getResponse = ({ data: { videoSearch }, errors }) => ({ videoSearch, errors }); + +const FETCH_VIDEO = 'FETCH_VIDEO'; + +export default (action$, state$) => + action$.pipe( + ofType(startSearchEventAction.type), + filter(() => !!getToken()), + switchMap((action) => { + const state = state$.value; + const query = querySelector(state); + const fromSuggester = fromSuggesterSelector(state); + const isFilterDirty = isFilterDirtySelector(state); + const tabCounters = tabCountersSelector(state); + const config = configSelector(state); + const size = selectors.sizeSelector(state); + const filterInternalParams = selectors.filterInternalParamsSelector(state); + const selectedVideo = selectors.selectedVideoSelector(state); + + // by default get tabs counters + // pay attention -> for counters tabView always ALL and size === 0 + const requestParams = { + ...filterInternalParams, + videoTab: 'ALL', + typeValue: 'SHORT_FORM', + page: 0, + size: 0, + timeZone: getClosestKnownTimeZone(), + queryIds: true, + includeMaxPerformance: false, + withTotalCounters: true, + }; + + if (filterInternalParams.account?.[0]?.owners?.length) { + requestParams.owners = filterInternalParams.account[0].owners.map((o) => o.toString()); + delete requestParams.account; + } + + if (action.payload?.fetch === FETCH_VIDEO && !action.payload?.onlyCounters) { + requestParams.videoTab = filterInternalParams.videoTab; + requestParams.size = size; + requestParams.includeMaxPerformance = true; + requestParams.queryIds = true; + requestParams.withTotalCounters = false; + } + + if (query.length) { + requestParams.query = query; + + if (fromSuggester || action.payload?.fromSuggester) { + requestParams.names = [query]; + } + } + + requestParams.origins = getOriginValue(filterInternalParams); + + if (filterInternalParams.status && filterInternalParams.status !== DEFAULT_VIDEO_STATUS_VALUE) { + requestParams.stateValue = filterInternalParams.status; + } + + if (filterInternalParams.verification && filterInternalParams.verification !== DEFAULT_VERIFICATION_TYPE_VALUE) { + requestParams.verificationValue = filterInternalParams.verification; + } + + const sharedVideo = Router.query.videoId; + + if (sharedVideo && !selectedVideo) { + requestParams.videoIds = [sharedVideo]; + } + + const stream$ = gqlRequest({ + query: videosQuery, + variables: { + ...requestParams, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const res = getResponse(response); + + if (res.videoSearch.counter) { + actions.push(of(tabCountersAction(res.videoSearch.counter))); + + const shouldDefineDefaultTabWhenSharedVideo = ifAllTabAvailable(getUserPermissionsSelector(state$.value)) + ? false + : !Router.query.videoTabs; + + const shouldDefineDefaultTab = + !isFilterDirty && + Object.values(tabCounters).every((tab) => tab === null) && + (!sharedVideo ? true : shouldDefineDefaultTabWhenSharedVideo); + + if (shouldDefineDefaultTab) { + const tabDefaultFilterConfig = getDefaultTab( + res.videoSearch.counter, + getUserPermissionsSelector(state$.value), + ); + const updateFilterConfig = { + ...config, + videoTabs: { + filters: [tabDefaultFilterConfig], + }, + }; + + actions.push(of(configAction(updateFilterConfig)), of(configChangedEventAction())); + } + + if (!action.payload?.onlyCounters) { + actions.push( + of( + startSearchEventAction({ + fetch: FETCH_VIDEO, + fromSuggester, + }), + ), + ); + } + } else { + if (res.videoSearch) { + actions.push( + of(currentFiltersAsStringAction(requestParams)), + of(selectedVideoAction()), + of(videosAction(res.videoSearch.videos)), + of(maxPerformanceAction(res.videoSearch.maxPerformance)), + of(totalCountAction(res.videoSearch.totalCount)), + ); + } + + if (sharedVideo && !selectedVideo && res.videoSearch.videos?.find((video) => video.uid === sharedVideo)) { + actions.push(of(selectedVideoAction(res.videoSearch.videos[0]))); + } else if (sharedVideo && selectedVideo) { + actions.push(of(removeQueriesAction('videoId'))); + } + + actions.push(of(isSearchFilteredAction(!!query.length || isFilterDirty))); + } + } + + return concat(...actions); + }), + ); + + return concat( + of(totalCountAction()), + of(targetVideoIdAction()), + of(targetClipIdAction()), + of(pageAction()), + of(isTargetClipIdLoadingAction()), + of(isNextVideosLoadingAction()), + of(isVideosLoadingAction(!action.payload?.onlyCounters)), + of(closeMultipleBulkAction()), + stream$, + of(isVideosLoadingAction(false)), + ); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/showNotification.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/showNotification.js new file mode 100644 index 0000000..ab88600 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/showNotification.js @@ -0,0 +1,16 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { showNotificationAction as showNotificationSearchResultAction } from '../slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(showNotificationSearchResultAction.type), + switchMap((action) => { + const { payload: notification } = action; + + return concat(of(showNotificationAction(notification))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/targetClipId.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/targetClipId.js new file mode 100644 index 0000000..ea675a3 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/targetClipId.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, mergeMap, switchMap } from 'rxjs/operators'; + +import { targetVideoIdSelector } from '../selectors'; +import { + isTargetClipIdLoadingAction, + targetClipIdAction, + targetClipIdEventAction, + targetVideoIdAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const clipsQuery = ` +query video($id: String!) { + video(id: $id) { + distributionId + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(targetVideoIdAction.type), + filter(() => targetVideoIdSelector(state$.value)), + mergeMap(() => { + const state = state$.value; + const targetVideoId = targetVideoIdSelector(state); + + const stream$ = gqlRequest({ + query: clipsQuery, + variables: { + id: targetVideoId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(targetClipIdAction(data.video.distributionId))); + actions.push(of(targetClipIdEventAction())); + } + + return concat(...actions); + }), + ); + + return concat(of(isTargetClipIdLoadingAction(true)), stream$, of(isTargetClipIdLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/videosChangeProcessingStatusMonitoring.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/videosChangeProcessingStatusMonitoring.js new file mode 100644 index 0000000..d6e180f --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/epics/videosChangeProcessingStatusMonitoring.js @@ -0,0 +1,82 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { EDITORIAL_PAGE } from '@/modules/@common/router/constants'; +import { VIDEO_STATE_PROCESSING } from '@/modules/editorial/editorialSearch/constants/searchFilter'; + +import { videosSelector } from '../selectors'; +import { videosAction, videosChangeProcessingStatusMonitoringAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; +import { reloadSelectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; + +import videosQuery from '@/modules/@common/gql/queries/videos'; + +const MONITORING_DELAY = 5000; + +const getResponse = ({ data: { videoSearch }, errors }) => ({ videoSearch, errors }); + +export default (action$, state$) => + action$.pipe( + ofType(videosChangeProcessingStatusMonitoringAction.type), + filter(() => Router.pathname === EDITORIAL_PAGE.path), + debounceTime(MONITORING_DELAY), + switchMap(() => { + const videos = videosSelector(state$.value); + const selectedVideo = selectedVideoSelector(state$.value); + const videosIdsWithProcessingStatus = videos + .filter((video) => video.status === VIDEO_STATE_PROCESSING) + .map((video) => video.uid); + const actions = []; + + if (videosIdsWithProcessingStatus.length) { + const stream$ = gqlRequest({ + query: videosQuery, + variables: { + videoIds: videosIdsWithProcessingStatus, + size: videosIdsWithProcessingStatus.length, + }, + }).pipe( + switchMap((response) => { + const streamActions = []; + + if (!response.errors.length) { + const { videoSearch } = getResponse(response); + const videosForUpdate = videoSearch.videos.filter((video) => video.status !== VIDEO_STATE_PROCESSING); + + if (videosForUpdate.length) { + const listOfVideos = videos.map((video) => { + const videoForUpdate = videosForUpdate.find((v) => v.uid === video.uid); + + if (videoForUpdate) { + return videoForUpdate; + } + + return video; + }); + + const hasOpenedVideo = + selectedVideo?.uid && videosForUpdate.find((video) => video.uid === selectedVideo.uid); + + streamActions.push(of(videosAction(listOfVideos))); + + if (hasOpenedVideo) { + streamActions.push(of(reloadSelectedVideoAction())); + } + } + } + + return concat(...streamActions); + }), + ); + + actions.push(stream$); + } + + actions.push(of(videosChangeProcessingStatusMonitoringAction())); + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/selectors/index.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/selectors/index.js new file mode 100644 index 0000000..ad6b8ec --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/selectors/index.js @@ -0,0 +1,43 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const videosSelector = (state) => state[nameSpace].videos; +export const totalCountSelector = (state) => state[nameSpace].totalCount; +export const isVideosLoadingSelector = (state) => state[nameSpace].isVideosLoading; +export const isNextVideosLoadingSelector = (state) => state[nameSpace].isNextVideosLoading; +export const filterParamsSelector = (state) => state[nameSpace].filterParams; +export const filterInternalParamsSelector = (state) => state[nameSpace].filterInternalParams; +export const searchFiltersSelector = (state) => state[nameSpace].searchFilters; +export const selectedVideoSelector = (state) => state[nameSpace].selectedVideo; +export const pageSelector = (state) => state[nameSpace].page; +export const sizeSelector = (state) => state[nameSpace].size; +export const playersSelector = (state) => state[nameSpace].players; +export const maxPerformanceSelector = (state) => state[nameSpace].maxPerformance; +export const isSearchFilteredSelector = (state) => state[nameSpace].isSearchFiltered; +export const targetVideoIdSelector = (state) => state[nameSpace].targetVideoId; +export const targetClipIdSelector = (state) => state[nameSpace].targetClipId; +export const isTargetClipIdLoadingSelector = (state) => state[nameSpace].isTargetClipIdLoading; +export const canAddToPlaylistSelector = (state) => state[nameSpace].canAddToPlaylist; +export const showNotificationSelector = (state) => state[nameSpace].showNotification; +export const startSearchEventSelector = (state) => state[nameSpace].startSearchEvent; +export const selectedPlayerSelector = (state) => state[nameSpace].selectedPlayer; +export const selectedAspectRatioSelector = (state) => state[nameSpace].selectedAspectRatio; +export const embedCodeSelector = (state) => state[nameSpace].embedCode; +export const getEmbedCodeSelector = (state) => state[nameSpace].getEmbedCode; +export const dragAndDropEndSelector = (state) => state[nameSpace].dragAndDropEnd; +export const playlistModeSelector = (state) => state[nameSpace].playlistMode; +export const showEmbedCodeButtonSelector = (state) => state[nameSpace].showEmbedCodeButton; +export const aiPlaylistIdSelector = (state) => state[nameSpace].aiPlaylistId; +export const showDividerSelector = (state) => state[nameSpace].showDivider; +export const currentFiltersAsStringSelector = (state) => state[nameSpace].currentFiltersAsString; + +export const getCommonState = (state$) => { + const state = state$[nameSpace]; + + return { + ...state, + }; +}; + +export default getCommonState; diff --git a/anyclip/src/modules/editorial/editorialSearchResults/redux/slices/index.js b/anyclip/src/modules/editorial/editorialSearchResults/redux/slices/index.js new file mode 100644 index 0000000..a4a7d04 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialSearchResults/redux/slices/index.js @@ -0,0 +1,179 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + videos: [], + totalCount: 0, + isVideosLoading: false, + isNextVideosLoading: false, + filterParams: {}, + filterInternalParams: {}, + searchFilters: null, + selectedVideo: null, + page: 0, + size: 0, + players: [], + maxPerformance: {}, + isSearchFiltered: false, + targetVideoId: null, + targetClipId: null, + isTargetClipIdLoading: false, + canAddToPlaylist: false, + showNotification: '', + startSearchEvent: null, + selectedPlayer: null, + selectedAspectRatio: null, + embedCode: '', + getEmbedCode: null, + dragAndDropEnd: null, + playlistMode: null, + showEmbedCodeButton: false, + aiPlaylistId: 0, + showDivider: false, + currentFiltersAsString: '', +}; + +export const slice = createSlice({ + name: '@@editorialSearchResults/EDITORIAL_SEARCH_RESULTS', + initialState, + reducers: { + videosAction: (state, action) => { + state.videos = action.payload || initialState.videos; + }, + totalCountAction: (state, action) => { + state.totalCount = action.payload || initialState.totalCount; + }, + isVideosLoadingAction: (state, action) => { + state.isVideosLoading = action.payload || initialState.isVideosLoading; + }, + isNextVideosLoadingAction: (state, action) => { + state.isNextVideosLoading = action.payload || initialState.isNextVideosLoading; + }, + filterParamsAction: (state, action) => { + state.filterParams = action.payload || initialState.filterParams; + }, + filterInternalParamsAction: (state, action) => { + state.filterInternalParams = action.payload || initialState.filterInternalParams; + }, + searchFiltersAction: (state, action) => { + state.searchFilters = action.payload || initialState.searchFilters; + }, + selectedVideoAction: (state, action) => { + state.selectedVideo = action.payload || initialState.selectedVideo; + }, + pageAction: (state, action) => { + state.page = action.payload || initialState.page; + }, + sizeAction: (state, action) => { + state.size = action.payload || initialState.size; + }, + playersAction: (state, action) => { + state.players = action.payload || initialState.players; + }, + maxPerformanceAction: (state, action) => { + state.maxPerformance = action.payload || initialState.maxPerformance; + }, + isSearchFilteredAction: (state, action) => { + state.isSearchFiltered = action.payload || initialState.isSearchFiltered; + }, + targetVideoIdAction: (state, action) => { + state.targetVideoId = action.payload || initialState.targetVideoId; + }, + targetClipIdAction: (state, action) => { + state.targetClipId = action.payload || initialState.targetClipId; + }, + isTargetClipIdLoadingAction: (state, action) => { + state.isTargetClipIdLoading = action.payload || initialState.isTargetClipIdLoading; + }, + canAddToPlaylistAction: (state, action) => { + state.canAddToPlaylist = action.payload || initialState.canAddToPlaylist; + }, + showNotificationAction: (state, action) => { + state.showNotification = action.payload || initialState.showNotification; + }, + startSearchEventAction: (state, action) => { + state.startSearchEvent = action.payload || initialState.startSearchEvent; + }, + selectedPlayerAction: (state, action) => { + state.selectedPlayer = action.payload || initialState.selectedPlayer; + }, + selectedAspectRatioAction: (state, action) => { + state.selectedAspectRatio = action.payload || initialState.selectedAspectRatio; + }, + embedCodeAction: (state, action) => { + state.embedCode = action.payload || initialState.embedCode; + }, + getEmbedCodeAction: (state, action) => { + state.getEmbedCode = action.payload || initialState.getEmbedCode; + }, + dragAndDropEndAction: (state, action) => { + state.dragAndDropEnd = action.payload || initialState.dragAndDropEnd; + }, + playlistModeAction: (state, action) => { + state.playlistMode = action.payload || initialState.playlistMode; + }, + showEmbedCodeButtonAction: (state, action) => { + state.showEmbedCodeButton = action.payload || initialState.showEmbedCodeButton; + }, + aiPlaylistIdAction: (state, action) => { + state.aiPlaylistId = action.payload || initialState.aiPlaylistId; + }, + showDividerAction: (state, action) => { + state.showDivider = action.payload || initialState.showDivider; + }, + clearAction: (state) => { + Object.keys(initialState).forEach((key) => { + state[key] = initialState[key]; + }); + }, + currentFiltersAsStringAction: (state, action) => { + state.currentFiltersAsString = JSON.stringify(action.payload); + }, + restartSearchEventAction: (state) => state, + targetClipIdEventAction: (state) => state, + addFiltersQueriesAction: (state) => state, + getPlayersAction: (state) => state, + downloadEventAction: (state) => state, + videosChangeProcessingStatusMonitoringAction: (state) => state, + loadNextEventAction: (state) => state, + }, +}); + +export const { + videosAction, + totalCountAction, + isVideosLoadingAction, + isNextVideosLoadingAction, + filterParamsAction, + filterInternalParamsAction, + searchFiltersAction, + selectedVideoAction, + pageAction, + sizeAction, + playersAction, + maxPerformanceAction, + isSearchFilteredAction, + targetVideoIdAction, + targetClipIdAction, + isTargetClipIdLoadingAction, + canAddToPlaylistAction, + showNotificationAction, + startSearchEventAction, + selectedPlayerAction, + selectedAspectRatioAction, + embedCodeAction, + getEmbedCodeAction, + dragAndDropEndAction, + playlistModeAction, + showEmbedCodeButtonAction, + aiPlaylistIdAction, + showDividerAction, + clearAction, + restartSearchEventAction, + targetClipIdEventAction, + addFiltersQueriesAction, + getPlayersAction, + downloadEventAction, + videosChangeProcessingStatusMonitoringAction, + loadNextEventAction, + currentFiltersAsStringAction, +} = slice.actions; diff --git a/src/modules/editorial/editorialTool/index.jsx b/anyclip/src/modules/editorial/editorialTool/index.jsx similarity index 100% rename from src/modules/editorial/editorialTool/index.jsx rename to anyclip/src/modules/editorial/editorialTool/index.jsx diff --git a/src/modules/editorial/editorialTool/styles.module.scss b/anyclip/src/modules/editorial/editorialTool/styles.module.scss similarity index 100% rename from src/modules/editorial/editorialTool/styles.module.scss rename to anyclip/src/modules/editorial/editorialTool/styles.module.scss diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/addPublishEntries.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/addPublishEntries.js new file mode 100644 index 0000000..9e2e6a0 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/addPublishEntries.js @@ -0,0 +1,86 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { addPublishEntriesAction, getPublishEntriesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation videoCreatePublishEntry($destinations: [VideoPublishEntryInputType]) { + videoCreatePublishEntry(destinations: $destinations) { + data { + id + status + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(addPublishEntriesAction.type), + switchMap((action) => { + const { destinations, videoId } = action.payload; + + const paramsToSend = destinations + .filter((dest) => dest.checked && !dest.published && dest.targetId && dest.targetName) + .map((dest) => ({ + videoId, + platform: dest.type, + accountId: dest.id, + target: { + id: dest.targetId, + name: dest.targetName, + type: dest.targetType.toUpperCase(), + }, + viewability: dest.visibility, + settings: dest.settings, + postText: dest.postText, + })); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + destinations: [...paramsToSend], + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(getPublishEntriesAction(videoId)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Video published', + }), + ), + ); + } + + const corruptedDestinations = destinations.filter( + (dest) => dest.checked && !dest.published && (!dest.targetId || !dest.targetName), + ); + + if (corruptedDestinations.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: `There are corrupted destinations (${corruptedDestinations.length})`, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/deletePublishEntries.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/deletePublishEntries.js new file mode 100644 index 0000000..0e15dbe --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/deletePublishEntries.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { deletePublishEntriesAction, getPublishEntriesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation videoDeletePublishEntry( + $videoId: String!, + $destinations: [String], + ) { + videoDeletePublishEntry( + videoId: $videoId, + destinations: $destinations, + ) { + data { + id + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(deletePublishEntriesAction.type), + switchMap((action) => { + const { entries, videoId } = action.payload; + + const paramsToSend = entries.map((entry) => entry.id); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + destinations: [...paramsToSend], + videoId, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: `${entries.length > 1 ? 'Entries' : 'Entry'} removed`, + }), + ), + of(getPublishEntriesAction(videoId)), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinations.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinations.js new file mode 100644 index 0000000..e61e491 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinations.js @@ -0,0 +1,90 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { getDestinationsAction, setDestinationsAction, setTotalDestinationsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + query videoDestinationsSearch( + $search: String, + $platform: String, + $publisherIds: [Int], + $page: Int, + $pageSize: Int, + $sortBy: String, + $sortOrder: String, + $status: String, + $name: String + ) { + videoDestinationsSearch( + search: $search, + platform: $platform, + publisherIds: $publisherIds, + page: $page, + pageSize: $pageSize, + sortBy: $sortBy, + sortOrder: $sortOrder, + status: $status, + name: $name, + ) { + recordsTotal + page + pageSize + records { + id + type + name + visibility + defaultChannel + defaultChannelName + defaultPlaylist + defaultPlaylistName + closedCaptions + title + description + thumbnail + category + manualTags + autoTags + language + status + videos + updatedAt + publisher { + name, + id + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getDestinationsAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap((action) => { + const variables = { ...action.payload }; + + if (action.payload.publisherId) { + variables.publisherIds = [action.payload.publisherId]; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data }) => { + const recordsTotal = data?.videoDestinationsSearch?.recordsTotal || 0; + const records = data?.videoDestinationsSearch?.records || []; + + return concat(of(setTotalDestinationsAction(recordsTotal)), of(setDestinationsAction(records))); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinationsPublishers.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinationsPublishers.js new file mode 100644 index 0000000..f25bb26 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getDestinationsPublishers.js @@ -0,0 +1,38 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { getPublishersAction, setPublishersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +import allPublishersGQL from '@/modules/@common/gql/queries/allPublishers'; + +export default (action$) => + action$.pipe( + ofType(getPublishersAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap(() => { + const stream$ = gqlRequest({ + query: allPublishersGQL, + variables: { + watchEnabledOnly: false, + removeDisabled: true, + removeLimit: true, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setPublishersAction(data.allPublishers))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishEntries.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishEntries.js new file mode 100644 index 0000000..dcc1b5f --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishEntries.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, mapTo, switchMap } from 'rxjs/operators'; + +import { getPublishEntriesAction, setPublishEntriesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query videoSearchPublishEntries($videoIds: [String]) { + videoSearchPublishEntries(videoIds: $videoIds) { + totalCount + data { + id + name + videoId + accountId + platform + publishedVideoId + status + viewability + postText + target { + id + type + name + } + settings { + sync + type + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPublishEntriesAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoIds: [action.payload], + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const publishEntries = data.videoSearchPublishEntries?.data; + + actions.push(of(setPublishEntriesAction(publishEntries))); + + if (publishEntries?.find((entry) => entry.status === 'PROCESSING')) { + actions.push(of(null).pipe(mapTo(getPublishEntriesAction(action.payload)), delay(4000))); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishViewabilityConfig.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishViewabilityConfig.js new file mode 100644 index 0000000..e0ee0f0 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/getPublishViewabilityConfig.js @@ -0,0 +1,42 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getViewabilityConfigAction, setViewabilityConfigAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query videoGetPublishViewabilityConfig { + videoGetPublishViewabilityConfig { + mediaPlatform + viewabilities { + name + onlyForExisting + } + transitions { + name + transitions + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getViewabilityConfigAction.type), + switchMap(() => { + const stream$ = gqlRequest({ query: queryGQL }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setViewabilityConfigAction(data.videoGetPublishViewabilityConfig))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/index.js new file mode 100644 index 0000000..ba86103 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import addPublishEntries from './addPublishEntries'; +import deletePublishEntries from './deletePublishEntries'; +import getDestinations from './getDestinations'; +import getDestinationsPublishers from './getDestinationsPublishers'; +import getPublishEntries from './getPublishEntries'; +import getPublishViewabilityConfig from './getPublishViewabilityConfig'; +import updatePublishEntry from './updatePublishEntry'; + +export default combineEpics( + getDestinations, + addPublishEntries, + deletePublishEntries, + getPublishEntries, + getDestinationsPublishers, + updatePublishEntry, + getPublishViewabilityConfig, +); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/updatePublishEntry.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/updatePublishEntry.js new file mode 100644 index 0000000..355800f --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/epics/updatePublishEntry.js @@ -0,0 +1,45 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPublishEntriesAction, updatePublishEntryAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation videoUpdatePublishEntry( + $id: String!, + $videoId: String!, + $settings: [VideoPublishEntrySettingsInputType], + $viewability: String, + $postText: String!, + ) { + videoUpdatePublishEntry( + id: $id, + videoId: $videoId, + settings: $settings, + viewability: $viewability, + postText: $postText, + ) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(updatePublishEntryAction.type), + switchMap((action) => { + const { videoId, postText, ...info } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId, + postText: postText || '', + ...info, + }, + }).pipe(switchMap(() => concat(of(getPublishEntriesAction(videoId))))); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/slices/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/slices/index.js new file mode 100644 index 0000000..6735739 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabPublish/redux/slices/index.js @@ -0,0 +1,83 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + destinations: [], + totalDestinations: 0, + + publishers: [], + + search: '', + page: 1, + pageSize: 10, + sortBy: 'id', + sortOrder: 'DESC', + platform: null, + status: null, + publisherId: null, + + publishEntries: [], + + viewabilityConfig: [], +}; + +export const slice = createSlice({ + name: '@@videoTabDestinationList', + initialState, + reducers: { + getDestinationsAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + setDestinationsAction: (state, action) => { + state.destinations = action.payload; + }, + setTotalDestinationsAction: (state, action) => { + state.totalDestinations = action.payload; + }, + setPublishersAction: (state, action) => { + state.publishers = action.payload; + }, + setPublishEntriesAction: (state, action) => { + state.publishEntries = action.payload; + }, + setFilterParamAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + clearFilterParamsAction: (state) => { + Object.keys(initialState).forEach((key) => { + if (key !== 'viewabilityConfig') { + state[key] = initialState[key]; + } + }); + }, + setViewabilityConfigAction: (state, action) => { + state.viewabilityConfig = action.payload; + }, + addPublishEntriesAction: (state) => state, + getPublishersAction: (state) => state, + getPublishEntriesAction: (state) => state, + deletePublishEntriesAction: (state) => state, + updatePublishEntryAction: (state) => state, + getViewabilityConfigAction: (state) => state, + }, +}); + +export const { + getDestinationsAction, + setDestinationsAction, + setTotalDestinationsAction, + setPublishersAction, + setPublishEntriesAction, + setFilterParamAction, + clearFilterParamsAction, + setViewabilityConfigAction, + addPublishEntriesAction, + getPublishersAction, + getPublishEntriesAction, + deletePublishEntriesAction, + updatePublishEntryAction, + getViewabilityConfigAction, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/constants/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/constants/index.js new file mode 100644 index 0000000..4b013c2 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/constants/index.js @@ -0,0 +1,52 @@ +export const STATUS_PLANNED = 'PLANNED'; +export const STATUS_RUNNING = 'RUNNING'; +export const STATUS_PAUSED = 'PAUSED'; +export const STATUS_STOPPED = 'STOPPED'; + +export const PACING_HOURLY_EVEN = 'HOURLY_EVEN'; +export const PACING_MINUTELY = 'MINUTELY'; + +export const DEVICE_DESKTOP = 'DESKTOP'; +export const DEVICE_MOBILE = 'MOBILE'; + +export const GEO_ACTION_INCLUDE = 'INCLUDE'; + +export const ACTION_PLAY = 'PLAY'; +export const ACTION_PAUSE = 'PAUSE'; +export const ACTION_STOP = 'STOP'; + +export const TARGETING_STATUS_TYPES = { + targetingStatus: 'targetingStatus', + adPlaybackStatus: 'adPlaybackStatus', + budgetControlStatus: 'budgetControlStatus', +}; + +export const TARGETING_STATUS_ON = 'ON'; +export const TARGETING_STATUS_OFF = 'OFF'; + +export const AD_PLAYBACK_STATUS_ON = 'ON'; +export const AD_PLAYBACK_STATUS_OFF = 'OFF'; + +export const BUDGET_CONTROL_STATUS_ON = 'ON'; +export const BUDGET_CONTROL_STATUS_OFF = 'OFF'; + +export const DEVICE_OPTIONS = [ + { + value: DEVICE_DESKTOP, + label: 'Desktop', + }, + { + value: DEVICE_MOBILE, + label: 'Mobile & Tablet', + }, +]; + +export const TABLE_COLUMN_START_DATE = 'startDateTime'; +export const TABLE_COLUMN_END_DATE = 'endDateTime'; +export const TABLE_COLUMN_STATUS = 'status'; +export const TABLE_COLUMN_DEVICE = 'device'; +export const TABLE_COLUMN_GEO = 'geo'; +export const TABLE_COLUMN_VIEWS_BUDGET = 'viewsBudget'; +export const TABLE_COLUMN_VIEWS_DELIVERED = 'viewsDelivered'; +export const TABLE_COLUMN_VIEWS_PACING = 'pacing'; +export const TABLE_COLUMN_VIEWS_ACTIONS = 'actions'; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/calculatePermissions.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/calculatePermissions.js new file mode 100644 index 0000000..77a1af5 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/calculatePermissions.js @@ -0,0 +1,12 @@ +import { VIDEO_ACCESS_TARGETING } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +function calculatePermissions(userPermissions) { + return { + canCreateEdit: hasPermission(VIDEO_ACCESS_TARGETING, userPermissions), + canRemove: hasPermission(VIDEO_ACCESS_TARGETING, userPermissions), + }; +} + +export default calculatePermissions; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/createDefaultRow.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/createDefaultRow.js new file mode 100644 index 0000000..45480ec --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/createDefaultRow.js @@ -0,0 +1,27 @@ +import { DEVICE_OPTIONS, GEO_ACTION_INCLUDE, PACING_HOURLY_EVEN, STATUS_PLANNED } from '../constants'; + +const addDay = (day) => day * 24 * 60 * 60 * 1000; +const addHour = (hour) => hour * (60 * 60 * 1000); + +function createDefaultRow() { + const startDateTime = new Date(new Date().getTime() + addDay(1) + addHour(1)).getTime(); // + 1 day and 1 hour + + const endDateShift = +process.env.APP_VIDEO_TARGETING_END_DATE_SHIFT; + + const endDateTime = new Date(startDateTime + addDay(endDateShift)).getTime(); // + endDateShift days + + return { + id: `${Math.random()}-${Math.random()}`, + startDateTime, + endDateTime, + status: STATUS_PLANNED, + device: DEVICE_OPTIONS, + geo: [], + geoAction: GEO_ACTION_INCLUDE, + viewsBudget: 1000, + viewsDelivered: 0, + pacing: PACING_HOURLY_EVEN, + }; +} + +export default createDefaultRow; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/index.js new file mode 100644 index 0000000..27e1ec8 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/index.js @@ -0,0 +1,6 @@ +import calculatePermissions from './calculatePermissions'; +import createDefaultRow from './createDefaultRow'; +import responseToTableRow from './responseToTableRow'; +import rowToRequestBody from './rowToRequestBody'; + +export { createDefaultRow, rowToRequestBody, responseToTableRow, calculatePermissions }; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/responseToTableRow.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/responseToTableRow.js new file mode 100644 index 0000000..e36e5e1 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/responseToTableRow.js @@ -0,0 +1,37 @@ +import { DEVICE_OPTIONS } from '../constants'; + +function getDevice(device = []) { + return device.reduce((acc, item) => { + const deviceValue = DEVICE_OPTIONS.find((option) => option.value === item); + if (deviceValue) { + acc.push(deviceValue); + } + return acc; + }, []); +} + +function getGeo(geo = [], countryOptions) { + return !geo.length ? countryOptions : geo.map((item) => countryOptions.find((option) => option.value === item)); +} + +function responseToTableRow({ countryOptions }) { + return (row) => { + const device = getDevice(row.device); + const geo = getGeo(row.geo?.geo, countryOptions); + + return { + id: row.uid, + startDateTime: row.startDateTime, + endDateTime: row.endDateTime, + status: row.status, + device, + geo, + geoAction: row.geo.action, + viewsBudget: row.viewsBudget, + viewsDelivered: row.viewsDelivered || 0, + pacing: row.pacing, + }; + }; +} + +export default responseToTableRow; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/rowToRequestBody.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/rowToRequestBody.js new file mode 100644 index 0000000..c32cda0 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/helpers/rowToRequestBody.js @@ -0,0 +1,30 @@ +import { BUDGET_CONTROL_STATUS_OFF, GEO_ACTION_INCLUDE, PACING_MINUTELY, STATUS_PAUSED } from '../constants'; + +function rowToRequestBody(row, budgetControlStatus) { + const requestData = { + uid: row.id, + startDateTime: row.startDateTime, + endDateTime: row.endDateTime, + device: row.device.map((o) => o.value || o), + geo: { + geo: row.geo.map((o) => o.value || o), + action: GEO_ACTION_INCLUDE, + }, + viewsBudget: +row.viewsBudget, + }; + + if (budgetControlStatus === BUDGET_CONTROL_STATUS_OFF) { + delete requestData.viewsBudget; + requestData.pacing = PACING_MINUTELY; + } + + if (row.status === STATUS_PAUSED) { + delete requestData.geo; + delete requestData.device; + delete requestData.startDateTime; + } + + return requestData; +} + +export default rowToRequestBody; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/changeStatuses.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/changeStatuses.js new file mode 100644 index 0000000..665e239 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/changeStatuses.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TARGETING_STATUS_TYPES } from '../../constants'; + +import { changeTargetingStatusesAction, getTargetingAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ChangeVideoTargetingStatuses( + $videoId: String + $site: [String] + $statusType: String + $statusValue: String + ) { + changeVideoTargetingStatuses( + videoId: $videoId + site: $site + statusType: $statusType + statusValue: $statusValue + ) { + targetingStatus + adPlaybackStatus + budgetControlStatus + } + } +`; + +const getResponse = ({ data: { changeVideoTargetingStatuses } }) => changeVideoTargetingStatuses; + +export default (action$) => + action$.pipe( + ofType(changeTargetingStatusesAction.type), + switchMap((action) => { + const { videoId, site, statusType, statusValue } = action.payload; + + const variables = { + videoId, + site, + statusType, + statusValue, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const actions = []; + const { targetingStatus, adPlaybackStatus } = getResponse(response); + + actions.push( + of( + setStateAction({ + targetingStatus, + adPlaybackStatus, + }), + ), + ); + + if (statusType === TARGETING_STATUS_TYPES.budgetControlStatus) { + actions.push( + of( + getTargetingAction({ + videoId, + site, + }), + ), + ); + } + + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$, of(setStateAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/create.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/create.js new file mode 100644 index 0000000..88aac61 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/create.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { responseToTableRow, rowToRequestBody } from '../../helpers'; +import * as selectors from '../selectors'; +import { createTargetingAction, getTargetingStatusesAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation CreateVideoTargeting( + $videoId: String + $site: [String] + $targeting: VideoTargetingInputType, + ) { + createVideoTargeting( + videoId:$videoId + site: $site + targeting: $targeting + ) { + uid + startDateTime + endDateTime + status + device + geo { + geo, + action + } + viewsBudget + viewsDelivered + pacing + } + } +`; + +const getResponse = ({ data: { createVideoTargeting } }) => createVideoTargeting; + +export default (action$, state$) => + action$.pipe( + ofType(createTargetingAction.type), + switchMap((action) => { + const data = selectors.data(state$.value); + const countryOptions = selectors.countryOptions(state$.value); + const budgetControlStatus = selectors.budgetControlStatus(state$.value); + const { videoId, site, row } = action.payload; + const targeting = rowToRequestBody(row, budgetControlStatus); + + const stream$ = gqlRequest({ + query, + variables: { + videoId, + site, + targeting, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const newRow = responseToTableRow({ countryOptions })(getResponse(response)); + const dataForUpdate = [].concat([newRow], data); + + return concat( + of( + setStateAction({ + data: dataForUpdate, + lastCreatedIdForOpenEdit: newRow.id, + }), + ), + of( + getTargetingStatusesAction({ + videoId, + site, + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$, of(setStateAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/get.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/get.js new file mode 100644 index 0000000..0de314a --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/get.js @@ -0,0 +1,91 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { responseToTableRow } from '../../helpers'; +import { getTargetingAction, getTargetingStatusesAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetVideoTargeting( + $videoId: String + $site: [String] + ) { + getVideoTargeting( + videoId: $videoId + site: $site + ) { + data { + uid + startDateTime + endDateTime + status + device + geo { + geo, + action + } + viewsBudget + viewsDelivered + pacing + } + totalCount + } + commonGeography { + id + uiKey + name + } + } +`; + +const getResponse = ({ data: { getVideoTargeting, commonGeography } }) => ({ getVideoTargeting, commonGeography }); + +export default (action$) => + action$.pipe( + ofType(getTargetingAction.type), + switchMap((action) => { + const { videoId, site } = action.payload; + + const variables = { + videoId, + site, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { getVideoTargeting, commonGeography } = getResponse(response); + const countryOptions = commonGeography.map((geo) => ({ + label: geo.name, + value: geo.uiKey, + })); + const data = getVideoTargeting.data.map(responseToTableRow({ countryOptions })); + + return concat( + of( + setStateAction({ + data: data.reverse(), + totalCount: getVideoTargeting.totalCount, + countryOptions, + }), + ), + of( + getTargetingStatusesAction({ + videoId, + site, + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$, of(setStateAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getPlayersWithDisabledTargeting.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getPlayersWithDisabledTargeting.js new file mode 100644 index 0000000..c60fbb6 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getPlayersWithDisabledTargeting.js @@ -0,0 +1,49 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPlayersWithDisabledTargeting, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetVideoTargetingPlayersWithDisabledTargeting { + getVideoTargetingPlayersWithDisabledTargeting { + records { + id + alias + name + } + } + } +`; + +const getResponse = ({ data: { getVideoTargetingPlayersWithDisabledTargeting } }) => + getVideoTargetingPlayersWithDisabledTargeting; + +export default (action$) => + action$.pipe( + ofType(getPlayersWithDisabledTargeting.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const res = getResponse(response); + + return concat( + of( + setStateAction({ + playersWithDisabledTargeting: res.records, + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$, of(setStateAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getStatuses.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getStatuses.js new file mode 100644 index 0000000..6ef22ff --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/getStatuses.js @@ -0,0 +1,75 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getTargetingStatusesAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; + +const query = ` + query GetVideoTargetingStatuses( + $videoId: String + $site: [String] + ) { + getVideoTargetingStatuses( + videoId: $videoId + site: $site + ) { + targetingStatus + adPlaybackStatus + budgetControlStatus + } + } +`; + +const getResponse = ({ data: { getVideoTargetingStatuses } }) => getVideoTargetingStatuses; + +export default (action$, state$) => + action$.pipe( + ofType(getTargetingStatusesAction.type), + switchMap((action) => { + const { videoId, site } = action.payload; + + const variables = { + videoId, + site, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { targetingStatus, adPlaybackStatus, budgetControlStatus } = getResponse(response); + + // add targeting status to video list + const videos = videosSelector(state$.value); + const updatedVideoList = videos.map((video) => { + const updatedVideo = { ...video }; + if (updatedVideo.uid === videoId) { + updatedVideo.targetingStatus = targetingStatus; + } + return updatedVideo; + }); + + return concat( + of( + setStateAction({ + targetingStatus, + adPlaybackStatus, + budgetControlStatus, + }), + ), + of(videosAction(updatedVideoList)), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/index.js new file mode 100644 index 0000000..9d9c2c8 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/index.js @@ -0,0 +1,21 @@ +import { combineEpics } from 'redux-observable'; + +import changeStatuses from './changeStatuses'; +import create from './create'; +import get from './get'; +import getPlayersWithDisabledTargeting from './getPlayersWithDisabledTargeting'; +import getStatuses from './getStatuses'; +import remove from './remove'; +import runAction from './runAction'; +import update from './update'; + +export default combineEpics( + get, + create, + update, + remove, + runAction, + getStatuses, + changeStatuses, + getPlayersWithDisabledTargeting, +); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/remove.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/remove.js new file mode 100644 index 0000000..9b721a9 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/remove.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import * as selectors from '../selectors'; +import { getTargetingStatusesAction, removeTargetingAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation RemoveVideoTargeting( + $videoId: String + $site: [String] + $id: String, + ) { + removeVideoTargeting( + videoId:$videoId + site: $site + id: $id + ) + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(removeTargetingAction.type), + switchMap((action) => { + const data = selectors.data(state$.value); + const { videoId, site, id } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + videoId, + site, + id, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const dataForUpdate = data.filter((oldRow) => oldRow.id !== id); + + return concat( + of(setStateAction({ data: dataForUpdate })), + of( + getTargetingStatusesAction({ + videoId, + site, + }), + ), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Targeting deleted', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$, of(setStateAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/runAction.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/runAction.js new file mode 100644 index 0000000..cdad4dc --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/runAction.js @@ -0,0 +1,89 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { responseToTableRow } from '../../helpers'; +import * as selectors from '../selectors'; +import { getTargetingStatusesAction, runActionTargetingAction, setStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation runActionVideoTargeting( + $videoId: String + $id: String + $site: [String] + $action: String, + ) { + runActionVideoTargeting( + videoId:$videoId + id: $id + site: $site + action: $action + ) { + uid + startDateTime + endDateTime + status + device + geo { + geo, + action + } + viewsBudget + viewsDelivered + pacing + } + } +`; + +const getResponse = ({ data: { runActionVideoTargeting } }) => runActionVideoTargeting; + +export default (action$, state$) => + action$.pipe( + ofType(runActionTargetingAction.type), + switchMap((action) => { + const data = selectors.data(state$.value); + const countryOptions = selectors.countryOptions(state$.value); + const { videoId, site, id, action: targetingAction } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + videoId, + id, + site, + action: targetingAction, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const newRow = responseToTableRow({ countryOptions })(getResponse(response)); + const dataForUpdate = data.map((oldRow) => (oldRow.id === id ? newRow : oldRow)); + + return concat( + of(setStateAction({ data: dataForUpdate })), + of( + getTargetingStatusesAction({ + videoId, + site, + }), + ), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Action success', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$, of(setStateAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/update.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/update.js new file mode 100644 index 0000000..469615f --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/epics/update.js @@ -0,0 +1,88 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { mapApiError } from '@/modules/@common/constants/mapApiError'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { responseToTableRow, rowToRequestBody } from '../../helpers'; +import * as selectors from '../selectors'; +import { setStateAction, updateTargetingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateVideoTargeting( + $videoId: String + $site: [String] + $targeting: VideoTargetingInputType, + ) { + updateVideoTargeting( + videoId:$videoId + site: $site + targeting: $targeting + ) { + uid + startDateTime + endDateTime + status + device + geo { + geo, + action + } + viewsBudget + viewsDelivered + pacing + } + } +`; + +const getResponse = ({ data: { updateVideoTargeting } }) => updateVideoTargeting; + +export default (action$, state$) => + action$.pipe( + ofType(updateTargetingAction.type), + switchMap((action) => { + const data = selectors.data(state$.value); + const countryOptions = selectors.countryOptions(state$.value); + const budgetControlStatus = selectors.budgetControlStatus(state$.value); + const { videoId, site, row } = action.payload; + const targeting = rowToRequestBody(row, budgetControlStatus); + + const stream$ = gqlRequest( + { + query, + variables: { + videoId, + site, + targeting, + }, + }, + { + mapError: mapApiError(), + }, + ).pipe( + switchMap((response) => { + if (!response.errors.length) { + const newRow = responseToTableRow({ countryOptions })(getResponse(response)); + const dataForUpdate = data.map((oldRow) => (oldRow.id === row.id ? newRow : oldRow)); + + return concat( + of(setStateAction({ data: dataForUpdate })), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Targeting updated', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setStateAction({ isLoading: true })), stream$, of(setStateAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/selectors/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/selectors/index.js new file mode 100644 index 0000000..a4d20c6 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/selectors/index.js @@ -0,0 +1,12 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const data = (state) => state[nameSpace].data; +export const countryOptions = (state) => state[nameSpace].countryOptions; +export const targetingStatus = (state) => state[nameSpace].targetingStatus; +export const adPlaybackStatus = (state) => state[nameSpace].adPlaybackStatus; +export const budgetControlStatus = (state) => state[nameSpace].budgetControlStatus; +export const lastCreatedIdForOpenEdit = (state) => state[nameSpace].lastCreatedIdForOpenEdit; +export const isLoading = (state) => state[nameSpace].isLoading; +export const playersWithDisabledTargetingSelector = (state) => state[nameSpace].playersWithDisabledTargeting; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/slices/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/slices/index.js new file mode 100644 index 0000000..c8df25b --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabTargeting/redux/slices/index.js @@ -0,0 +1,48 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { AD_PLAYBACK_STATUS_OFF, BUDGET_CONTROL_STATUS_OFF, TARGETING_STATUS_OFF } from '../../constants'; + +const initialState = { + data: null, + countryOptions: [], + targetingStatus: TARGETING_STATUS_OFF, + adPlaybackStatus: AD_PLAYBACK_STATUS_OFF, + budgetControlStatus: BUDGET_CONTROL_STATUS_OFF, + lastCreatedIdForOpenEdit: null, + isLoading: true, + playersWithDisabledTargeting: null, +}; + +export const slice = createSlice({ + name: '@@VIDEO_TAB_TARGETING', + initialState, + reducers: { + setStateAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + clearAction: () => initialState, + getTargetingAction: (state) => state, + createTargetingAction: (state) => state, + updateTargetingAction: (state) => state, + removeTargetingAction: (state) => state, + runActionTargetingAction: (state) => state, + getTargetingStatusesAction: (state) => state, + changeTargetingStatusesAction: (state) => state, + getPlayersWithDisabledTargeting: (state) => state, + }, +}); + +export const { + setStateAction, + clearAction, + getTargetingAction, + createTargetingAction, + updateTargetingAction, + removeTargetingAction, + runActionTargetingAction, + getTargetingStatusesAction, + changeTargetingStatusesAction, + getPlayersWithDisabledTargeting, +} = slice.actions; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/constants/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/constants/index.js new file mode 100644 index 0000000..3d4b718 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/constants/index.js @@ -0,0 +1,25 @@ +export const ROWS_PER_PAGE_DEFAULT = 5000; +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_COLUMNS = [ + { + id: 'id', + label: '#', + width: '30', + }, + { + id: 'updatedAt', + label: 'Uploaded', + width: '200', + }, + { + id: 'versionId', + label: 'Version ID', + }, + { + id: 'notes', + label: 'Notes', + }, +]; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/getData.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/getData.js new file mode 100644 index 0000000..539a927 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/getData.js @@ -0,0 +1,60 @@ +import * as selectors from '../selectors'; +import { getTableDataAction, setTableAction } from '../slices'; +import tableCreateEpicGetData from '@/modules/@common/Table/redux/epics'; + +const EMPTY_ROWS_NUMBER = 6; + +const gqlQuery = `query GetVideoVersions($videoId: String, $site: [String]) { + getVideoVersions( + videoId: $videoId + site: $site + ) { + data { + videoCreationDate + refVideoVersion + notes + videoVersion + } + totalCount + } +}`; + +const createEmptyRows = (rowsNumber) => + Array.from({ length: rowsNumber }, () => ({ + id: '', + videoCreationDate: '', + refVideoVersion: '', + notes: '', + })); + +export default tableCreateEpicGetData({ + gqlQuery, + triggerActionType: getTableDataAction.type, + processBodyRequest: (state, payload) => ({ + videoId: payload.videoId, + site: payload.site, + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + }), + processResponse: ({ data: { getVideoVersions } }) => { + const rows = getVideoVersions.data; + + const addEmptyRowsIfNeeds = () => { + const emptyRowsToAdd = EMPTY_ROWS_NUMBER - rows.length; + + if (emptyRowsToAdd) { + return rows.concat(createEmptyRows(emptyRowsToAdd)); + } + + return emptyRowsToAdd < 0 ? rows.concat(createEmptyRows(emptyRowsToAdd)) : rows; + }; + + return { + records: addEmptyRowsIfNeeds(), + recordsTotal: getVideoVersions.totalCount, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/index.js new file mode 100644 index 0000000..1775aad --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import getData from './getData'; + +export default combineEpics(getData); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/selectors/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/selectors/index.js new file mode 100644 index 0000000..d47e43b --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/selectors/index.js @@ -0,0 +1,15 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, slice.name); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/slices/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/slices/index.js new file mode 100644 index 0000000..bc3c34f --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/components/Tabs/TabVersions/redux/slices/index.js @@ -0,0 +1,27 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + ...tableSlice.state, +}; + +export const slice = createSlice({ + name: '@@videoTabVersions', + initialState, + reducers: { + getTableDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + }, +}); + +export const { getTableDataAction, setTableAction } = slice.actions; diff --git a/src/modules/editorial/editorialVideoDetails/components/index.jsx b/anyclip/src/modules/editorial/editorialVideoDetails/components/index.jsx similarity index 100% rename from src/modules/editorial/editorialVideoDetails/components/index.jsx rename to anyclip/src/modules/editorial/editorialVideoDetails/components/index.jsx diff --git a/src/modules/editorial/editorialVideoDetails/components/index.module.scss b/anyclip/src/modules/editorial/editorialVideoDetails/components/index.module.scss similarity index 100% rename from src/modules/editorial/editorialVideoDetails/components/index.module.scss rename to anyclip/src/modules/editorial/editorialVideoDetails/components/index.module.scss diff --git a/src/modules/editorial/editorialVideoDetails/components/menu/index.jsx b/anyclip/src/modules/editorial/editorialVideoDetails/components/menu/index.jsx similarity index 100% rename from src/modules/editorial/editorialVideoDetails/components/menu/index.jsx rename to anyclip/src/modules/editorial/editorialVideoDetails/components/menu/index.jsx diff --git a/src/modules/editorial/editorialVideoDetails/components/menu/styles.module.scss b/anyclip/src/modules/editorial/editorialVideoDetails/components/menu/styles.module.scss similarity index 100% rename from src/modules/editorial/editorialVideoDetails/components/menu/styles.module.scss rename to anyclip/src/modules/editorial/editorialVideoDetails/components/menu/styles.module.scss diff --git a/src/modules/editorial/editorialVideoDetails/constants/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/constants/index.js similarity index 100% rename from src/modules/editorial/editorialVideoDetails/constants/index.js rename to anyclip/src/modules/editorial/editorialVideoDetails/constants/index.js diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/helpers/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/helpers/index.js new file mode 100644 index 0000000..df14a50 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/helpers/index.js @@ -0,0 +1,225 @@ +import { + PCN_PUT_VIDEO, + VIDEO_ADMIN, + VIDEO_ADVANCED_TAB, + VIDEO_EDIT_SOURCE, + VIDEO_INFO_TAB, + VIDEO_SHOW_CONTENT_OWNER_NAME, + VIDEO_VERSION_UPLOAD, +} from '@/modules/@common/acl/constants'; +import { INTERNAL } from '@/modules/@common/user/constants/rolesType'; +import { VIDEO_STATE_PROCESSING } from '@/modules/editorial/editorialSearch/constants/searchFilter'; +import { FEED_TYPE_AUDIO } from '@/modules/editorial/editorialSearchFilter/constants'; +import { + ACCESS_LEVEL_ENUM, + ACCESS_USER_ROLE, +} from '@/modules/editorial/editorialSearchResults/components/AccessControlView/constants'; + +import { slice } from '../redux/slices'; +import { iabFindLeafNodes } from '@/modules/@common/iab/helpers'; +import { getUserPreferences, hasPermission, isOwnContent, setUserPreferences } from '@/modules/@common/user/helpers'; + +const nameSpace = slice.name; + +export const canShowVideoContentOwnerName = (userPermissions) => + hasPermission(VIDEO_SHOW_CONTENT_OWNER_NAME, userPermissions); + +export const canEditVideoByAccessControl = (video, userPublisherIds, userId) => { + if (video?.access?.level === ACCESS_LEVEL_ENUM.private && video?.access?.users) { + return video.access.users.some((user) => +user.id === userId && user.role === ACCESS_USER_ROLE.owner); + } + + if (video?.access?.level === ACCESS_LEVEL_ENUM.site) { + const videoSites = (video.access.sites || []).map((s) => parseInt(s.id, 10)); + + return (userPublisherIds || []).some((userSite) => videoSites.some((videoSite) => videoSite === userSite)); + } + + return true; +}; + +export const getContentOwnerNameAndFeedDisplayName = (video, userContentOwners, userPermissions) => + [ + canShowVideoContentOwnerName(userPermissions) && video?.contentOwnerName, + video?.feedDescription || (isOwnContent(video?.contentOwner, userContentOwners) ? 'My Videos' : 'AnyClip'), + ] + .filter(Boolean) + .join('/'); + +export const tagsGenerator = (video, searchFilters) => { + const relevantCategories = ['brands', 'people', 'categories']; + const tagFilters = + (searchFilters && + Object.entries(searchFilters).reduce( + (acc, [key, { filters }]) => + relevantCategories.includes(key) ? { ...acc, [key]: filters[0].value.map(({ value }) => value) } : acc, + {}, + )) || + {}; + + const keywords = [...(video?.keywords ?? [])]; + + const iab = video?.iab?.data ? iabFindLeafNodes(JSON.parse(video.iab.data)) : []; + + const tagsMap = { + people: [], + brands: [], + iab: [], + brandsafety: [], + keywords: [], + text: [], + custom: [], + }; + + keywords.forEach((elem) => { + const type = elem.category.toLowerCase().replace(/_/, ''); + + if (tagsMap[type]) { + const tagEntity = { + type, + text: elem.value, + filterMatch: tagFilters[type]?.includes(elem.value), + id: ['text', 'custom'].includes(type) ? elem.value : elem.id, + }; + + if (type === 'custom') { + tagEntity.labelId = elem.labelId; + tagEntity.labelName = elem.labelName; + tagEntity.color = elem.color; + } + + tagsMap[type].push(tagEntity); + } + }); + + iab.forEach((elem) => { + try { + return tagsMap.iab.push({ + type: 'iab', + text: elem.name, + id: elem.id, + filterMatch: tagFilters.categories?.includes(elem.id), + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (err) { + return null; + } + }); + + return Object.keys(tagsMap).reduce((acc, key) => [...acc, ...tagsMap[key]], []); +}; + +export const defineThumbnail = (thumbnailUrl, thumbnailFiles) => { + if (thumbnailFiles?.length) { + const sortedThumbnails = [...thumbnailFiles].sort((a, b) => b.height - a.height); + const thumbnailHighResolution = sortedThumbnails[0]; + return thumbnailHighResolution && thumbnailHighResolution.file ? thumbnailHighResolution.file : thumbnailUrl; + } + + return thumbnailUrl; +}; + +export const formatBytes = (bytes, decimals = 2) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; +}; + +export const getPreferences = () => getUserPreferences()?.[nameSpace]; + +export const setPreferences = (param, value) => { + const preferences = getPreferences(); + setUserPreferences(nameSpace, { ...preferences, [param]: value }); +}; + +export const canPermissionAccess = ( + video, + permissionGrandAccessName, + permissionContentOwnerAccessName, + userContentOwners, + userPermissions, +) => { + const { contentOwner, status } = video; + + if (status === VIDEO_STATE_PROCESSING) return false; + if (hasPermission(permissionGrandAccessName, userPermissions)) return true; + + return ( + permissionContentOwnerAccessName && + hasPermission(permissionContentOwnerAccessName, userPermissions) && + isOwnContent(contentOwner, userContentOwners) + ); +}; + +export const canAccessTabInfo = (video, userContentOwners, userPermissions) => + canPermissionAccess(video, VIDEO_ADMIN, VIDEO_INFO_TAB, userContentOwners, userPermissions); +export const canAccessTabAdvanced = (video, userContentOwners, userPermissions) => + canPermissionAccess(video, VIDEO_ADVANCED_TAB, null, userContentOwners, userPermissions); +export const canArchive = (video, userContentOwners, userPermissions) => + canPermissionAccess(video, VIDEO_ADMIN, PCN_PUT_VIDEO, userContentOwners, userPermissions); + +export const speechModelTypes = { + google: 'S2T', + deepgram: 'DEEPGRAM_ANYCLIP', + verbitAuto: 'VERBIT_AUTO', + verbitManual: 'VERBIT_MANUAL', + verbitManual24h: 'VERBIT_MANUAL_24H', +}; + +export const getCanEditVideoAttributes = ( + video, + userAccountId, + userPublisherIds, + userId, + userContentOwners, + userPermissions, + userRoleType, +) => { + const hasAccount = !!userAccountId; + const hasOwnContent = !!(video?.contentOwner && isOwnContent(video?.contentOwner, userContentOwners)); + const canEditByAccessControlRules = canEditVideoByAccessControl(video, userPublisherIds, userId); + + const canEditByPermissions = (permission) => + hasPermission(permission, userPermissions) && + canEditByAccessControlRules && + (!hasAccount || hasOwnContent) && + video.status !== VIDEO_STATE_PROCESSING; + + const canEditByAccessControlAndOwnContent = () => + canEditByAccessControlRules && hasOwnContent && video.status !== VIDEO_STATE_PROCESSING; + + const canEditSyndicatedVideo = () => + video?.access?.level === ACCESS_LEVEL_ENUM.public && hasPermission(VIDEO_ADMIN, userPermissions); + + const canOperateWithVideoVersionUpload = () => + hasPermission(VIDEO_VERSION_UPLOAD, userPermissions) && + canEditByAccessControlRules && + video?.access?.level !== ACCESS_LEVEL_ENUM.public && + video.origin !== FEED_TYPE_AUDIO; + + const attributes = { + canEditVideoName: canEditByPermissions(PCN_PUT_VIDEO), + canEditVideoDate: canEditByPermissions(PCN_PUT_VIDEO), + canEditVideoDescription: canEditByPermissions(PCN_PUT_VIDEO), + canEditVideoSource: canEditByPermissions(VIDEO_EDIT_SOURCE), + canEditTagsAndLabels: canEditByAccessControlAndOwnContent() || canEditSyndicatedVideo(), + canSeeMenu: canEditByAccessControlAndOwnContent() || canEditSyndicatedVideo(), + // used in ShareAndAccess + canEditVideo: canEditByAccessControlAndOwnContent() || canEditSyndicatedVideo(), + canOperateWithVideoVersionUpload: canOperateWithVideoVersionUpload(), + canShowTabVideoVersions: canOperateWithVideoVersionUpload() || userRoleType === INTERNAL, + }; + + return attributes; +}; + +export const enableTagLogByQueryParam = () => { + const paramName = 'taglog'; + return typeof window !== 'undefined' && window.location.search.includes(paramName); +}; diff --git a/src/modules/editorial/editorialVideoDetails/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/index.js similarity index 100% rename from src/modules/editorial/editorialVideoDetails/index.js rename to anyclip/src/modules/editorial/editorialVideoDetails/index.js diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/createTaxonomyKeyword.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/createTaxonomyKeyword.js new file mode 100644 index 0000000..4fd9954 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/createTaxonomyKeyword.js @@ -0,0 +1,82 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { createTaxonomyKeywordAction, isLoadingKeywordsAction, updateVideoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation createTaxonomyKeyword( + $category: String!, + $status: String, + $source: String, + $types: [String], + $keywords: [TaxonomyKeywordInputType]) { + createTaxonomyKeyword( + category: $category, + status: $status, + source: $source, + types: $types, + keywords: $keywords + ) { + uid, + category, + status, + source, + types, + keywords { + lang + value + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(createTaxonomyKeywordAction.type), + switchMap((action) => { + const { + payload: { value, category, video }, + } = action; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + keywords: [{ lang: 'EN', value }], + types: ['OWN_CONTENT'], + status: 'REVIEW', + source: 'OWN_CONTENT', + category, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const prevKeywords = video?.keywords ?? []; + + actions.push( + of( + updateVideoAction({ + keywords: [ + ...prevKeywords, + { + category, + value, + id: data.createTaxonomyKeyword.uid, + version: 'MANUAL_V4', + }, + ], + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(isLoadingKeywordsAction(true)), stream$, of(isLoadingKeywordsAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/deleteVideo.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/deleteVideo.js new file mode 100644 index 0000000..098519d --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/deleteVideo.js @@ -0,0 +1,64 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { gqlRequest } from '@/modules/@common/request'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { + selectedVideoAction, + startSearchEventAction, + videosAction, +} from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; +import { clearAction, deleteVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; + +const queryGQL = ` + mutation videoDeleteMutation($id: String!) { + videoDelete(id: $id) { + result + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deleteVideoAction.type), + switchMap(() => { + const { uid } = selectedVideoSelector(state$.value); + const videos = videosSelector(state$.value); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: uid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { videoDelete } = data; + + if (videoDelete.result) { + const updatedVideoList = videos.filter((originalVideo) => originalVideo.uid !== uid); + + actions.push( + of( + selectedVideoAction({ + uid: null, + }), + ), + of(clearAction()), + of(videosAction(updatedVideoList)), + of(startSearchEventAction({ onlyCounters: true })), + ); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getAdvertiser.ts b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getAdvertiser.ts new file mode 100644 index 0000000..c0fc4f8 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getAdvertiser.ts @@ -0,0 +1,85 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { getAdvertiserOptionsAction, setAdvertiserOptionsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type RequestPayloadType = { + searchText?: string; + pageSize: number; + siteIds?: number[]; +}; + +type ResponseType = { + data: { + id: string; + name: string; + }[]; +}; + +type ActionPayload = { + searchText: string; +}; + +const queryName = 'videoUploadGetAdvertiserOptions' as const; + +const getResponse = (data: ResponseType) => data.data; + +const query = ` + query VideoUploadGetAdvertiserOptions( + $pageSize: Int, + $searchText: String, + ) { + ${queryName}( + pageSize: $pageSize, + searchText: $searchText, + ) { + data { + id + name + logo + } + } + } +`; + +const getSupplyTagOptionsEpic: Epic = (action$) => + action$.pipe( + filter( + (action): action is ReturnType => + action.type === getAdvertiserOptionsAction.type, + ), + debounce((action) => { + if (!action.payload) { + return timer(0); + } + const { searchText = '' } = action.payload; + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actionPayload = action.payload! as ActionPayload; + const payload: RequestPayloadType = { + searchText: actionPayload.searchText || '', + pageSize: 500, + }; + + return gqlRequest({ + query, + variables: payload, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of(setAdvertiserOptionsAction(getResponse(response.data[queryName]))); + } + return EMPTY; + }), + ); + }), + ); + +export default getSupplyTagOptionsEpic; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getOwnersAutocomplete.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getOwnersAutocomplete.js new file mode 100644 index 0000000..7013930 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getOwnersAutocomplete.js @@ -0,0 +1,103 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { canShowVideoContentOwnerName } from '../../helpers'; +import { getOwnersOptionsAction, ownersOptionsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const FEED_TYPES = [ + 'MRSS', + 'CSV', + 'YOUTUBE', + 'S3', + 'INSTAGRAM', + 'RSS', + 'VIDEO_API', + 'STORY_API', + 'LIVE', + 'VIMEO', + 'MANUAL', + 'SITEMAP', + 'ZOOM', +]; + +const query = ` + query getFeedSources( + $feedTypeFilter: [String], + $pageSize: Int, + $searchText: String, + $isContentOwnerName: Boolean + ) { + getFeedSources( + feedTypeFilter: $feedTypeFilter, + pageSize: $pageSize, + searchText: $searchText + isContentOwnerName: $isContentOwnerName + ) { + records { + id + name + display_name + contentOwnerId + contentOwnerName + schedule_status + status + contentOwner { + name + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getOwnersOptionsAction.type), + switchMap((action) => { + const userPermissions = getUserPermissionsSelector(state$.value); + const variables = { + feedTypeFilter: FEED_TYPES.filter((type) => !['RSS', 'STORY_API', 'SITEMAP'].includes(type)), + pageSize: 100, + }; + + if (action.payload) { + variables.searchText = action.payload; + } + + variables.isContentOwnerName = canShowVideoContentOwnerName(userPermissions); + + const getResponse = ({ data: { getFeedSources } }) => + getFeedSources.records.map((record) => { + let label = record.display_name || record.name; + + if (canShowVideoContentOwnerName(userPermissions)) { + label += `/${record?.contentOwner?.name ?? 'Empty'}`; + } + + return { + ...record, + label, + value: record.id, + }; + }); + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push(of(ownersOptionsAction(getResponse(response)))); + } + + return concat(...actions); + }), + ); + + return concat(of(ownersOptionsAction(null)), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getSpeechToTextModels.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getSpeechToTextModels.js new file mode 100644 index 0000000..bf72e2a --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/getSpeechToTextModels.js @@ -0,0 +1,37 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getSpeechToTextModelsAction, speechToTextModelsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getSpeechToTextModels { + getSpeechToTextModels { + data { + model + name + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getSpeechToTextModelsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ query: queryGQL }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors?.length) { + actions.push(of(speechToTextModelsAction(data.getSpeechToTextModels?.data ?? []))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/index.js new file mode 100644 index 0000000..b1010f2 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/index.js @@ -0,0 +1,25 @@ +import { combineEpics } from 'redux-observable'; + +import createTaxonomyKeyword from './createTaxonomyKeyword'; +import deleteVideo from './deleteVideo'; +import getAdvertiser from './getAdvertiser'; +import getOwnersAutocomplete from './getOwnersAutocomplete'; +import getSpeechToTextModels from './getSpeechToTextModels'; +import reloadSelectedVideo from './reloadSelectedVideo'; +import showNotification from './showNotification'; +import taxonomyAutocomplete from './taxonomyAutocomplete'; +import updateVideoTags from './updateVideoTags'; +import videoUpdate from './videoUpdate'; + +export default combineEpics( + videoUpdate, + showNotification, + taxonomyAutocomplete, + createTaxonomyKeyword, + deleteVideo, + reloadSelectedVideo, + getSpeechToTextModels, + getOwnersAutocomplete, + updateVideoTags, + getAdvertiser, +); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/reloadSelectedVideo.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/reloadSelectedVideo.js new file mode 100644 index 0000000..0e77c3e --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/reloadSelectedVideo.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { gqlRequest } from '@/modules/@common/request'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { videosAction as videosActionsForEditorial } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; +import { reloadSelectedVideoAction, selectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; +import { setIsLoadingAction } from '@/modules/editorial/shareAndAccess/redux/slices'; + +import videoById from '@/modules/@common/gql/queries/videoById'; + +export default (action$, state$) => + action$.pipe( + ofType(reloadSelectedVideoAction.type), + filter(() => selectedVideoSelector(state$.value)?.uid), + switchMap(() => { + const state = state$.value; + const { uid: selectedVideoUid } = selectedVideoSelector(state); + + const stream$ = gqlRequest({ + query: videoById, + variables: { + uid: selectedVideoUid, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const videos = videosSelector(state); + + const newVideos = videos.map((oneVideo$) => + oneVideo$.uid !== data.video.uid ? oneVideo$ : { ...oneVideo$, ...data.video }, + ); + + if (data.video.uid === selectedVideoUid) { + actions.push(of(selectedVideoAction(data.video))); + } + + actions.push(of(videosActionsForEditorial(newVideos))); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/showNotification.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/showNotification.js new file mode 100644 index 0000000..8787ee3 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/showNotification.js @@ -0,0 +1,16 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { showNotificationAction as showDetailsNotificationAction } from '../slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(showDetailsNotificationAction.type), + switchMap((action) => { + const { payload: notification } = action; + + return concat(of(showNotificationAction(notification))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/taxonomyAutocomplete.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/taxonomyAutocomplete.js new file mode 100644 index 0000000..64f107e --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/taxonomyAutocomplete.js @@ -0,0 +1,54 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { getTaxonomyKeywordsAction, isLoadingKeywordsAction, keywordsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + query taxonomyAutocomplete($category: String!, $prefix: String, $lang: String, $size: Int) { + taxonomyAutocomplete(category: $category, prefix: $prefix, lang: $lang, size: $size){ + uid, + category, + values { + lang + value + suggestions + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getTaxonomyKeywordsAction.type), + debounceTime(+process.env.APP_CLEAR_TIMEOUT), + filter(() => !!getToken()), + switchMap((action) => { + const { + payload: { prefix, category }, + } = action; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + category, + prefix, + lang: 'EN', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(keywordsAction(data.taxonomyAutocomplete))); + } + + return concat(...actions); + }), + ); + + return concat(of(isLoadingKeywordsAction(true)), stream$, of(isLoadingKeywordsAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/updateVideoTags.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/updateVideoTags.js new file mode 100644 index 0000000..117ce34 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/updateVideoTags.js @@ -0,0 +1,70 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, switchMap } from 'rxjs/operators'; + +import { selectedVideoSelector } from '../selectors'; +import { updateVideoTagsAction } from '../slices'; +import { iabFlatToTree } from '@/modules/@common/iab/helpers'; +import { gqlRequest } from '@/modules/@common/request'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; + +import queryVideoUpdateGQL from '@/modules/@common/gql/queries/videoUpdate'; + +const DELAY = 500; + +export default (action$, state$) => + action$.pipe( + ofType(updateVideoTagsAction.type), + debounceTime(DELAY), + switchMap((action) => { + const videos = videosSelector(state$.value); + const { uid } = selectedVideoSelector(state$.value); + const { payload: updateVideo } = action; + + const updatedVideos = videos.map((originalVideo) => { + if (originalVideo.uid === uid) { + const forUpdateVideo = { ...updateVideo }; + + if (forUpdateVideo.iab) { + forUpdateVideo.iab = { + data: JSON.stringify({ + categories: iabFlatToTree(updateVideo.iab), + }), + }; + } + + return { ...originalVideo, ...forUpdateVideo }; + } + + return originalVideo; + }); + + const stream$ = gqlRequest({ + query: queryVideoUpdateGQL, + variables: { + id: uid, + video: { + ...updateVideo, + updateClip: true, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const updatedVideoList = videos.map((originalVideo) => + originalVideo.uid === uid ? { ...originalVideo, ...data.videoUpdate } : originalVideo, + ); + + actions.push(of(videosAction(updatedVideoList))); + } + + return concat(...actions); + }), + ); + + return concat(of(videosAction(updatedVideos)), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/videoUpdate.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/videoUpdate.js new file mode 100644 index 0000000..de596a1 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/epics/videoUpdate.js @@ -0,0 +1,123 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { VIDEO_ADMIN } from '@/modules/@common/acl/constants'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { selectedVideoSelector } from '../selectors'; +import { reloadSelectedVideoAction, updateVideoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { monitoringAction } from '@/modules/editorial/editorialSearch/redux/slices'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { startSearchEventAction, videosAction } from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import queryVideoUpdateGQL from '@/modules/@common/gql/queries/videoUpdate'; + +export default (action$, state$) => + action$.pipe( + ofType(updateVideoAction.type), + switchMap((action) => { + const userPermissions = getUserPermissionsSelector(state$.value); + const videos = videosSelector(state$.value); + const selectedVideo = selectedVideoSelector(state$.value); + const { payload: updateVideo } = action; + const { uid } = selectedVideo; + + if ( + action.payload?.videoCreationDate < 0 || + action.payload?.videoCreationDate > new Date(dayjs().endOf('minute')).valueOf() + ) { + if (Object.keys(action.payload).length === 1) { + return EMPTY; + } + // todo: check if date creation could be limited from Video Card + // eslint-disable-next-line no-param-reassign + delete action.payload.videoCreationDate; + } + + const updatedVideos = videos.map((originalVideo) => { + if (originalVideo.uid === uid) { + const forUpdateVideo = { ...updateVideo }; + + if (forUpdateVideo.iab) { + forUpdateVideo.iab = { + data: JSON.stringify({ categories: updateVideo.iab }), + }; + } + + return { ...originalVideo, ...forUpdateVideo }; + } + + return originalVideo; + }); + + const stream$ = gqlRequest( + { + query: queryVideoUpdateGQL, + variables: { + id: uid, + video: { + ...updateVideo, + updateClip: true, + }, + }, + }, + { showNotificationMessage: false }, + ).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const updatedVideoList = videos + .map((originalVideo) => + originalVideo.uid === uid ? { ...originalVideo, ...data.videoUpdate } : originalVideo, + ) + .filter( + (originalVideo) => originalVideo.status !== 'DISABLED' || hasPermission(VIDEO_ADMIN, userPermissions), + ); + + actions.push(of(videosAction(updatedVideoList)), of(reloadSelectedVideoAction())); + + if (updateVideo?.lang) { + actions.push(of(monitoringAction())); + } + + if (!hasPermission(VIDEO_ADMIN, userPermissions) && updateVideo.status === 'DISABLED') { + const message = { + type: TYPE_SUCCESS, + message: 'Video archived', + }; + actions.push(of(showNotificationAction(message))); + } + + if (updateVideo.status === 'DISABLED') { + actions.push(of(startSearchEventAction({ onlyCounters: true }))); + } + } else { + let message = { + type: TYPE_ERROR, + message: (errors[0] && 'A video with the same title already exist') || 'Something went wrong', + }; + + if (updateVideo?.refId) { + message = { + type: TYPE_ERROR, + message: 'Video with the GUID already exists. Try another GUID.', + }; + } + + actions.push(of(showNotificationAction(message))); + } + + return concat(...actions); + }), + ); + + return concat(of(videosAction(updatedVideos)), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/selectors/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/selectors/index.js new file mode 100644 index 0000000..46785a1 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/selectors/index.js @@ -0,0 +1,37 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const selectedVideoSelector = (state) => state[nameSpace].selectedVideo; +export const monitoringJobSelector = (state) => state[nameSpace].monitoringJob; +export const currentVideoTimeSelector = (state) => state[nameSpace].currentVideoTime; +export const updateVideoSelector = (state) => state[nameSpace].updateVideo; +export const evergreenSelector = (state) => state[nameSpace].evergreen; +export const dialogVisibleSelector = (state) => state[nameSpace].dialogVisible; +export const getPlayerEmbedCodeSelector = (state) => state[nameSpace].getPlayerEmbedCode; +export const keywordsSelector = (state) => state[nameSpace].keywords; +export const isLoadingKeywordsSelector = (state) => state[nameSpace].isLoadingKeywords; +export const newKeywordSelector = (state) => state[nameSpace].newKeyword; +export const getTaxonomyKeywordsSelector = (state) => state[nameSpace].getTaxonomyKeywords; +export const createTaxonomyKeywordSelector = (state) => state[nameSpace].createTaxonomyKeyword; +export const showNotificationSelector = (state) => state[nameSpace].showNotification; +export const deleteVideoSelector = (state) => state[nameSpace].deleteVideo; +export const speechToTextModelSelector = (state) => state[nameSpace].speechToTextModel; +export const speechToTextModelsSelector = (state) => state[nameSpace].speechToTextModels; +export const downloadTagsSelector = (state) => state[nameSpace].downloadTags; +export const getOwnersOptionsSelector = (state) => state[nameSpace].getOwnersOptions; +export const ownersOptionsSelector = (state) => state[nameSpace].ownersOptions; +export const tabIndexSelector = (state) => state[nameSpace].tabIndex; +export const advertiserOptionsSelector = (state) => state[nameSpace].advertiserOptions; + +export const getCommonState = (state$) => { + const state = state$[nameSpace]; + + return { + ...state, + }; +}; + +export const isChaptersMonitoringActive = (state) => state[nameSpace].isChaptersMonitoringActive; + +export default getCommonState; diff --git a/anyclip/src/modules/editorial/editorialVideoDetails/redux/slices/index.js b/anyclip/src/modules/editorial/editorialVideoDetails/redux/slices/index.js new file mode 100644 index 0000000..c0932bb --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoDetails/redux/slices/index.js @@ -0,0 +1,133 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + selectedVideo: {}, + monitoringJob: null, + currentVideoTime: 0, + updateVideo: null, + evergreen: false, + dialogVisible: false, + setThumbnailUrl: null, + uploadThumbnail: {}, + getPlayerEmbedCode: null, + keywords: [], + isLoadingKeywords: false, + newKeyword: null, + getTaxonomyKeywords: null, + createTaxonomyKeyword: null, + showNotification: '', + deleteVideo: null, + speechToTextModel: 'DEEPGRAM_ANYCLIP', + speechToTextModels: [], + downloadTags: '', + getOwnersOptions: null, + ownersOptions: null, + advertiserOptions: null, + tabIndex: 0, +}; + +export const slice = createSlice({ + name: '@@editorialVideoDetails/EDITORIAL_VIDEO_DETAILS', + initialState, + reducers: { + selectedVideoAction: (state, action) => { + state.selectedVideo = action.payload || initialState.selectedVideo; + }, + currentVideoTimeAction: (state, action) => { + state.currentVideoTime = action.payload || initialState.currentVideoTime; + }, + monitoringJobAction: (state, action) => { + state.monitoringJob = action.payload || initialState.monitoringJob; + }, + updateVideoAction: (state, action) => { + state.updateVideo = action.payload || initialState.updateVideo; + }, + evergreenAction: (state, action) => { + state.evergreen = action.payload || initialState.evergreen; + }, + dialogVisibleAction: (state, action) => { + state.dialogVisible = action.payload || initialState.dialogVisible; + }, + getPlayerEmbedCodeAction: (state, action) => { + state.getPlayerEmbedCode = action.payload || initialState.getPlayerEmbedCode; + }, + keywordsAction: (state, action) => { + state.keywords = action.payload || initialState.keywords; + }, + isLoadingKeywordsAction: (state, action) => { + state.isLoadingKeywords = action.payload || initialState.isLoadingKeywords; + }, + newKeywordAction: (state, action) => { + state.newKeyword = action.payload || initialState.newKeyword; + }, + getTaxonomyKeywordsAction: (state, action) => { + state.getTaxonomyKeywords = action.payload || initialState.getTaxonomyKeywords; + }, + createTaxonomyKeywordAction: (state, action) => { + state.createTaxonomyKeyword = action.payload || initialState.createTaxonomyKeyword; + }, + showNotificationAction: (state, action) => { + state.showNotification = action.payload || initialState.showNotification; + }, + deleteVideoAction: (state, action) => { + state.deleteVideo = action.payload || initialState.deleteVideo; + }, + speechToTextModelAction: (state, action) => { + state.speechToTextModel = action.payload || initialState.speechToTextModel; + }, + speechToTextModelsAction: (state, action) => { + state.speechToTextModels = action.payload || initialState.speechToTextModels; + }, + getOwnersOptionsAction: (state, action) => { + state.getOwnersOptions = action.payload || initialState.getOwnersOptions; + }, + ownersOptionsAction: (state, action) => { + state.ownersOptions = action.payload || initialState.ownersOptions; + }, + setTabIndexAction: (state, action) => { + state.tabIndex = action.payload; + }, + setAdvertiserOptionsAction: (state, action) => { + state.advertiserOptions = action.payload || initialState.advertiserOptions; + }, + reloadSelectedVideoAction: (state) => state, + getSpeechToTextModelsAction: (state) => state, + updateVideoTagsAction: (state) => state, + getAdvertiserOptionsAction: (state) => state, + clearAction: (state) => { + Object.keys(initialState).forEach((key) => { + if (key !== 'tabIndex') { + state[key] = initialState[key]; + } + }); + }, + }, +}); + +export const { + selectedVideoAction, + currentVideoTimeAction, + monitoringJobAction, + updateVideoAction, + evergreenAction, + dialogVisibleAction, + getPlayerEmbedCodeAction, + keywordsAction, + isLoadingKeywordsAction, + newKeywordAction, + getTaxonomyKeywordsAction, + createTaxonomyKeywordAction, + showNotificationAction, + deleteVideoAction, + speechToTextModelAction, + speechToTextModelsAction, + getOwnersOptionsAction, + ownersOptionsAction, + setTabIndexAction, + reloadSelectedVideoAction, + getSpeechToTextModelsAction, + updateVideoTagsAction, + clearAction, + getAdvertiserOptionsAction, + setAdvertiserOptionsAction, +} = slice.actions; diff --git a/src/modules/editorial/editorialVideoInfo/components/index.jsx b/anyclip/src/modules/editorial/editorialVideoInfo/components/index.jsx similarity index 100% rename from src/modules/editorial/editorialVideoInfo/components/index.jsx rename to anyclip/src/modules/editorial/editorialVideoInfo/components/index.jsx diff --git a/src/modules/editorial/editorialVideoInfo/components/styles.module.scss b/anyclip/src/modules/editorial/editorialVideoInfo/components/styles.module.scss similarity index 100% rename from src/modules/editorial/editorialVideoInfo/components/styles.module.scss rename to anyclip/src/modules/editorial/editorialVideoInfo/components/styles.module.scss diff --git a/anyclip/src/modules/editorial/editorialVideoInfo/helpers/index.js b/anyclip/src/modules/editorial/editorialVideoInfo/helpers/index.js new file mode 100644 index 0000000..fc3a600 --- /dev/null +++ b/anyclip/src/modules/editorial/editorialVideoInfo/helpers/index.js @@ -0,0 +1,26 @@ +export const isVideoUnsafe = (video) => (video?.keywords ?? []).some((keyword) => keyword.category === 'BRAND_SAFETY'); + +export const getPercent = (partialValue = 0, totalValue = 0) => + totalValue <= 0 ? 0 : (partialValue * 100) / totalValue; + +export const getTime = (time) => { + const MAX_TIME_VALUE = 359999; + if (time > MAX_TIME_VALUE) { + return 'N/A'; + } + const secNum = parseInt(time, 10); + let hours = Math.floor(secNum / 3600); + let minutes = Math.floor((secNum - hours * 3600) / 60); + let seconds = secNum - hours * 3600 - minutes * 60; + + if (hours < 10) { + hours = `0${hours}`; + } + if (minutes < 10) { + minutes = `0${minutes}`; + } + if (seconds < 10) { + seconds = `0${seconds}`; + } + return `${hours}:${minutes}:${seconds}`; +}; diff --git a/anyclip/src/modules/editorial/helpers/createDndId.js b/anyclip/src/modules/editorial/helpers/createDndId.js new file mode 100644 index 0000000..b3055e6 --- /dev/null +++ b/anyclip/src/modules/editorial/helpers/createDndId.js @@ -0,0 +1,4 @@ +// channel metadataObject = { type, watchId, channelId, itemIndex } +// video in channel metadataObject = { type, channelId, distributionId, itemIndex } +export const createDndId = (metadataObject) => JSON.stringify(metadataObject); +export const parseDndId = (metadataObject) => JSON.parse(metadataObject); diff --git a/anyclip/src/modules/editorial/helpers/videoTab.js b/anyclip/src/modules/editorial/helpers/videoTab.js new file mode 100644 index 0000000..317f4c7 --- /dev/null +++ b/anyclip/src/modules/editorial/helpers/videoTab.js @@ -0,0 +1,58 @@ +import { VIDEO_ALL_TAB, VIDEO_MY_TAB, VIDEO_SHARED_TAB } from '@/modules/@common/acl/constants'; +import { VIDEO_TABS_ENUM } from '@/modules/editorial/editorialSearchFilter/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +import { filterConfig } from '@/modules/editorial/editorialSearchFilter/filterConfig'; + +export const ifAllTabAvailable = (userPermissions) => hasPermission(VIDEO_ALL_TAB, userPermissions); + +export const getTabsByPermissions = (userPermissions) => ({ + [VIDEO_TABS_ENUM.all]: hasPermission(VIDEO_ALL_TAB, userPermissions), + [VIDEO_TABS_ENUM.own]: hasPermission(VIDEO_MY_TAB, userPermissions), + [VIDEO_TABS_ENUM.shared]: hasPermission(VIDEO_SHARED_TAB, userPermissions), +}); + +export const getVideoTabs = (userPermissions) => { + const availableTabs = getTabsByPermissions(userPermissions); + const tabs = filterConfig.videoTabs.filters; + const tabsWithOriginalIndex = tabs.map((tab, index) => ({ + ...tab, + originalIndex: index, + })); + const tabsByPermissions = tabsWithOriginalIndex.filter((tab) => availableTabs[tab.value]); + + return tabsByPermissions; +}; + +export const getTabDefaultValue = (tabCounters, userPermissions) => { + const availableTabs = getVideoTabs(userPermissions); + const { own, shared } = tabCounters; + let tabDefaultValue = VIDEO_TABS_ENUM.all; + + if (availableTabs.length === 1) { + tabDefaultValue = availableTabs[0].value; + } else if (own > 0) { + tabDefaultValue = VIDEO_TABS_ENUM.own; + } else if (shared > 0) { + tabDefaultValue = VIDEO_TABS_ENUM.shared; + } + + return tabDefaultValue; +}; + +export const getDefaultTab = (tabCounters, userPermissions) => { + const availableTabs = getVideoTabs(userPermissions); + const tabDefaultValue = getTabDefaultValue(tabCounters, userPermissions); + + return availableTabs.find((tab) => tab.value === tabDefaultValue); +}; + +export const getDefaultTabAfterSearchOrFilter = (userPermissions) => { + const availableTabs = getVideoTabs(userPermissions); + const defaultTab = availableTabs[0]; + + return defaultTab; +}; + +export default getVideoTabs; diff --git a/anyclip/src/modules/editorial/shareAndAccess/constants/index.js b/anyclip/src/modules/editorial/shareAndAccess/constants/index.js new file mode 100644 index 0000000..02848c1 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/constants/index.js @@ -0,0 +1,60 @@ +export const PUBLIC = 'PUBLIC'; +export const PRIVATE = 'PRIVATE'; +export const SITE = 'SITE'; + +export const ACCESS_LEVEL_CO_PUBLIC = [ + { + label: 'Private', + value: PRIVATE, + }, + { + label: 'Hub', + value: SITE, + }, + { + label: 'Syndication', + value: PUBLIC, + }, +]; + +export const ACCESS_LEVEL_CO_PRIVATE = [ + { + label: 'Private', + value: PRIVATE, + }, + { + label: 'Hub', + value: SITE, + }, +]; + +export const CONTENT_OWNER_PUBLIC = 1; +export const CONTENT_OWNER_PRIVATE = 0; + +export const ACCESS_USER_ROLES = { + owner: 'OWNER', + read: 'READ', +}; + +export const ACCESS_LEVEL_ENUM = { + private: PRIVATE, + site: SITE, + public: PUBLIC, +}; + +export const ACCESS_LEVEL_SELECT = [ + { + label: 'Private', + value: ACCESS_LEVEL_ENUM.private, + }, + { + label: 'Syndication', + value: ACCESS_LEVEL_ENUM.public, + }, + { + label: 'Hub', + value: ACCESS_LEVEL_ENUM.site, + }, +]; + +export const SEND_HUBS_NOTIFICATION = 'SEND_HUBS_NOTIFICATION'; diff --git a/src/modules/editorial/shareAndAccess/helpers/permissions.js b/anyclip/src/modules/editorial/shareAndAccess/helpers/permissions.js similarity index 100% rename from src/modules/editorial/shareAndAccess/helpers/permissions.js rename to anyclip/src/modules/editorial/shareAndAccess/helpers/permissions.js diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessAddSites.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessAddSites.js new file mode 100644 index 0000000..5bd803e --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessAddSites.js @@ -0,0 +1,75 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { reloadSelectedVideoAction } from '../../../editorialVideoDetails/redux/slices'; +import * as selectors from '../selectors'; +import { addAccessSitesAction, setFormCloseAction, setIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation accessAddSites( + $videoId: String, + $sites: [String], + $sendHubsNotification: Boolean, + ) { + accessAddSites( + videoId: $videoId, + sites: $sites, + sendHubsNotification: $sendHubsNotification, + ) { + data + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(addAccessSitesAction.type), + switchMap((action) => { + const sites = action.payload; + const videoId = selectors.videoIdSelector(state$.value); + const sendHubsNotification = selectors.sendHubsNotificationSelector(state$.value); + + const dataToSend = { + videoId, + sites, + sendHubsNotification, + }; + + if (sites) { + dataToSend.sites = sites.map((site) => site.id); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...dataToSend, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(setFormCloseAction(false)), + of(reloadSelectedVideoAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Saved', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessChangeLevel.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessChangeLevel.js new file mode 100644 index 0000000..804fdcf --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessChangeLevel.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ACCESS_LEVEL_CO_PUBLIC, SITE } from '../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { reloadSelectedVideoAction } from '../../../editorialVideoDetails/redux/slices'; +import * as selectors from '../selectors'; +import { changeAccessLevelAction, setFormCloseAction, setIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation accessChangeLevel( + $level: String, + $videoId: String, + $sites: [String], + $sendHubsNotification: Boolean, + ) { + accessChangeLevel( + level: $level, + videoId: $videoId, + sites: $sites, + sendHubsNotification: $sendHubsNotification, + ) { + data + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(changeAccessLevelAction.type), + switchMap((action) => { + const { level, sites } = action.payload; + + const videoId = selectors.videoIdSelector(state$.value); + const sendHubsNotification = selectors.sendHubsNotificationSelector(state$.value); + + const dataToSend = { + videoId, + level, + }; + + dataToSend.sendHubsNotification = level === SITE ? sendHubsNotification : false; + + if (sites) { + dataToSend.sites = sites; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...dataToSend, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + const accessLevelLabel = ACCESS_LEVEL_CO_PUBLIC.find( + (accessLevel) => accessLevel.value === level, + )?.label.toLowerCase(); + + actions.push( + of(setFormCloseAction(false)), + of(reloadSelectedVideoAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: `Video has been changed to ${accessLevelLabel}`, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessCopyAccessUsersToTrimedVideo.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessCopyAccessUsersToTrimedVideo.js new file mode 100644 index 0000000..d7464ad --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessCopyAccessUsersToTrimedVideo.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { ACCESS_USER_ROLES } from '../../constants'; + +import * as selectors from '../selectors'; +import { copyAccessUsersToTrimedVideoAction, setIsLoadingAction, updateStateByParamAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation accessAddUsers( + $videoId: String, + $users: [AccessUserDeleteType], + ) { + accessAddUsers( + videoId: $videoId, + users: $users, + ) { + data + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(copyAccessUsersToTrimedVideoAction.type), + filter((action) => !!action.payload?.length), + switchMap((action) => { + const videoId = selectors.videoIdSelector(state$.value); + const accessUsers = selectors.accessUsersSelector(state$.value); + const copiedUsers = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId, + users: copiedUsers.map((user) => ({ + id: `${user.id}`, + role: user.role, + })), + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateStateByParamAction({ + accessUsers: [accessUsers.find((user) => user.role === ACCESS_USER_ROLES.owner), ...copiedUsers], + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteSites.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteSites.js new file mode 100644 index 0000000..c5fb474 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteSites.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { reloadSelectedVideoAction } from '../../../editorialVideoDetails/redux/slices'; +import * as selectors from '../selectors'; +import { deleteAccessSitesAction, setFormCloseAction, setIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation accessDeleteSites( + $videoId: String, + $sites: [String], + ) { + accessDeleteSites( + videoId: $videoId, + sites: $sites, + ) { + data + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deleteAccessSitesAction.type), + switchMap((action) => { + const sites = action.payload; + const videoId = selectors.videoIdSelector(state$.value); + + const dataToSend = { + videoId, + sites, + }; + + if (sites) { + dataToSend.sites = sites.map((site) => site.id); + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...dataToSend, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(setFormCloseAction(false)), + of(reloadSelectedVideoAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Saved', + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteUsers.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteUsers.js new file mode 100644 index 0000000..68ace5d --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessDeleteUsers.js @@ -0,0 +1,62 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { ACCESS_USER_ROLES } from '../../constants'; + +import * as selectors from '../selectors'; +import { deleteAccessUsersAction, setIsLoadingAction, updateStateByParamAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation accessDeleteUsers( + $videoId: String, + $users: [AccessUserDeleteType], + ) { + accessDeleteUsers( + videoId: $videoId, + users: $users, + ) { + data + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deleteAccessUsersAction.type), + filter((action) => !!action.payload?.length), + switchMap((action) => { + const videoId = selectors.videoIdSelector(state$.value); + const accessUsers = selectors.accessUsersSelector(state$.value); + const usersToDelete = action.payload; + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId, + users: usersToDelete.map((id) => ({ + id: `${id}`, + role: ACCESS_USER_ROLES.read, + })), + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateStateByParamAction({ + accessUsers: accessUsers.filter((user) => !usersToDelete.includes(user.id)), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessUpdateLevel.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessUpdateLevel.js new file mode 100644 index 0000000..1216ed3 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/accessUpdateLevel.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PRIVATE, PUBLIC, SITE } from '../../constants'; + +import * as selectors from '../selectors'; +import { + addAccessSitesAction, + changeAccessLevelAction, + deleteAccessSitesAction, + updateAccessLevelAction, +} from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(updateAccessLevelAction.type), + switchMap((action) => { + const { access } = action.payload; + const accessSites = selectors.accessSitesSelector(state$.value); + const accessLevel = selectors.accessLevelSelector(state$.value); + const actions = []; + + if (accessLevel !== access.level && [PRIVATE, PUBLIC].includes(accessLevel)) { + actions.push(of(changeAccessLevelAction({ level: accessLevel }))); + } + + if (accessLevel !== access.level && accessLevel === SITE) { + actions.push( + of( + changeAccessLevelAction({ + level: accessLevel, + sites: accessSites.map((site) => site.id), + }), + ), + ); + } else if (accessLevel === access.level && accessLevel === SITE) { + if (access.sites?.length && accessSites.length) { + const sitesToDelete = access.sites.filter( + (siteToDelete) => !accessSites.find((site) => site.id === siteToDelete.id), + ); + + const sitesToAdd = accessSites.filter((siteToAdd) => !access.sites.find((site) => site.id === siteToAdd.id)); + + if (sitesToAdd.length) { + actions.push(of(addAccessSitesAction(sitesToAdd))); + } + + if (sitesToDelete.length) { + actions.push(of(deleteAccessSitesAction(sitesToDelete))); + } + } + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getPublishersByIds.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getPublishersByIds.js new file mode 100644 index 0000000..fed31e0 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getPublishersByIds.js @@ -0,0 +1,62 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getPublishersByIdsAction, setIsSitesLoadingAction, setPublishersByIdsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getPublisherIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + query getVideoUserHubs ( + $pageSize: Int, + $searchText: String + ) { + getVideoUserHubs ( + pageSize: $pageSize, + searchText: $searchText, + ) { + records { + id + name + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getPublishersByIdsAction.type), + filter(() => getPublisherIdsSelector(state$.value).length), + switchMap((action) => { + const queryParams = { + pageSize: 30, + searchText: action.payload?.searchText ?? '', + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: queryParams, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setPublishersByIdsAction( + data.getVideoUserHubs.records.map((pub) => ({ + ...pub, + id: `${pub.id}`, + })), + ), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsSitesLoadingAction(true)), stream$, of(setIsSitesLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getSharedUsersName.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getSharedUsersName.js new file mode 100644 index 0000000..17d0da3 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getSharedUsersName.js @@ -0,0 +1,76 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getSharedUsersNameAction, updateStateByParamAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query accessGetSharedUsersName( + $userIds: [Int], + $videoId: String + ) { + accessGetSharedUsersName( + userIds: $userIds, + videoId: $videoId, + ) { + records { + id + email + firstName + lastName + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getSharedUsersNameAction.type), + switchMap((action) => { + const accessUsers = action.payload; + const videoId = selectors.videoIdSelector(state$.value); + + const variables = { + userIds: accessUsers.map((u) => parseInt(u.id, 10)), + videoId, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + const updateAccessUsers = accessUsers.map((accessUser) => { + const userInfo = data.accessGetSharedUsersName.records.find((info) => +info.id === +accessUser.id); + if (userInfo) { + return { + ...accessUser, + firstName: userInfo.firstName || '', + lastName: userInfo.lastName || '', + email: userInfo.email, + }; + } + return accessUser; + }); + + if (!errors.length) { + actions.push( + of( + updateStateByParamAction({ + accessUsers: updateAccessUsers, + isAccessUsersLoading: false, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(updateStateByParamAction({ isAccessUsersLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getUsersByAccount.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getUsersByAccount.js new file mode 100644 index 0000000..d42d724 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/getUsersByAccount.js @@ -0,0 +1,73 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { getUsersByAccountAction, setIsLoadingAction, setUsersByAccountAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query accessGetUsersByAccount( + $searchText: String, + $searchIn: [String], + $page: Int, + $pageSize: Int, + $sortOrder: String, + $sortBy: String, + $videoId: String! + ) { + accessGetUsersByAccount( + searchText: $searchText, + searchIn: $searchIn, + page: $page, + pageSize: $pageSize, + sortOrder: $sortOrder, + sortBy: $sortBy, + videoId: $videoId + ) { + records { + id + email + firstName + lastName + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getUsersByAccountAction.type), + debounce((action) => timer(action.payload?.searchText?.length ? 500 : 0)), + switchMap((action) => { + const selected = action.payload?.selected ?? 0; + const variables = { + searchText: action.payload?.searchText ?? '', + searchIn: ['firstName', 'lastName', 'email'], + page: 1, + status: 1, + pageSize: 10 + selected, + sortBy: 'firstName', + sortOrder: SORT_ASC, + videoId: action.payload.videoId, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setUsersByAccountAction(data.accessGetUsersByAccount.records))); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/index.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/index.js new file mode 100644 index 0000000..f43ee03 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/index.js @@ -0,0 +1,25 @@ +import { combineEpics } from 'redux-observable'; + +import accessAddSites from './accessAddSites'; +import accessChangeLevel from './accessChangeLevel'; +import accessCopyAccessUsersToTrimedVideo from './accessCopyAccessUsersToTrimedVideo'; +import accessDeleteSites from './accessDeleteSites'; +import accessDeleteUsers from './accessDeleteUsers'; +import accessUpdateLevel from './accessUpdateLevel'; +import getPublishersByIds from './getPublishersByIds'; +import getSharedUsersName from './getSharedUsersName'; +import getUsersByAccount from './getUsersByAccount'; +import shareVideoWithUsers from './shareVideoWithUsers'; + +export default combineEpics( + getPublishersByIds, + getUsersByAccount, + shareVideoWithUsers, + accessDeleteUsers, + accessUpdateLevel, + accessChangeLevel, + accessDeleteSites, + accessAddSites, + accessCopyAccessUsersToTrimedVideo, + getSharedUsersName, +); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/epics/shareVideoWithUsers.js b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/shareVideoWithUsers.js new file mode 100644 index 0000000..12b485b --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/epics/shareVideoWithUsers.js @@ -0,0 +1,85 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { reloadSelectedVideoAction } from '../../../editorialVideoDetails/redux/slices'; +import * as selectors from '../selectors'; +import { + setIsLoadingAction, + setVideoSharedAction, + shareVideoWithUsersAction, + updateStateByParamAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query shareVideoWithUsers( + $userIds: [Int], + $message: String, + $videoId: String, + $videoUrl: String, + $name: String, + ) { + shareVideoWithUsers( + userIds: $userIds, + message: $message, + videoId: $videoId, + videoUrl: $videoUrl, + name: $name, + ) { + data + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(shareVideoWithUsersAction.type), + switchMap((action) => { + const usersToShare = selectors.usersToShareSelector(state$.value); + const message = selectors.messageSelector(state$.value); + const videoId = selectors.videoIdSelector(state$.value); + const isUsedInTrim = selectors.isUsedInTrimSelector(state$.value); + const accessUsers = selectors.accessUsersSelector(state$.value); + + const { url, name } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + userIds: usersToShare.map((user) => parseInt(user.id, 10)), + message, + videoId, + videoUrl: url, + name, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + if (isUsedInTrim) { + actions.push( + of( + updateStateByParamAction({ + accessUsers: [ + ...accessUsers, + ...usersToShare.filter((user) => accessUsers.some((accessUser) => +user.id !== +accessUser.id)), + ], + }), + ), + ); + } else { + actions.push(of(reloadSelectedVideoAction())); + } + + actions.push(of(setVideoSharedAction())); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$, of(setIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/selectors/index.js b/anyclip/src/modules/editorial/shareAndAccess/redux/selectors/index.js new file mode 100644 index 0000000..1e35090 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/selectors/index.js @@ -0,0 +1,20 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const usersSelector = (state) => state[nameSpace].users; +export const sitesSelector = (state) => state[nameSpace].sites; +export const usersToShareSelector = (state) => state[nameSpace].usersToShare; +export const messageSelector = (state) => state[nameSpace].message; +export const accessLevelSelector = (state) => state[nameSpace].accessLevel; +export const accessSitesSelector = (state) => state[nameSpace].accessSites; +export const accessUsersSelector = (state) => state[nameSpace].accessUsers; +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const isSitesLoadingSelector = (state) => state[nameSpace].isSitesLoading; +export const isAccessUsersLoadingSelector = (state) => state[nameSpace].isAccessUsersLoading; +export const isSharingSelector = (state) => state[nameSpace].isSharing; +export const isOpenSelector = (state) => state[nameSpace].isOpen; +export const videoIdSelector = (state) => state[nameSpace].videoId; +export const sendHubsNotificationSelector = (state) => state[nameSpace].sendHubsNotification; +export const isUsedInTrimSelector = (state) => state[nameSpace].isUsedInTrim; +export const isKeepSharingCheckedInTrimSelector = (state) => state[nameSpace].isKeepSharingCheckedInTrim; diff --git a/anyclip/src/modules/editorial/shareAndAccess/redux/slices/index.js b/anyclip/src/modules/editorial/shareAndAccess/redux/slices/index.js new file mode 100644 index 0000000..574c2e8 --- /dev/null +++ b/anyclip/src/modules/editorial/shareAndAccess/redux/slices/index.js @@ -0,0 +1,91 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { PUBLIC } from '@/modules/editorial/shareAndAccess/constants'; + +const initialState = { + users: [], + sites: [], + usersToShare: [], + message: '', + accessLevel: PUBLIC, + accessSites: [], + accessUsers: [], + isLoading: false, + isSitesLoading: false, + isAccessUsersLoading: false, + isSharing: false, + isOpen: false, + videoId: '', + sendHubsNotification: false, + + // trim + isUsedInTrim: false, + isKeepSharingCheckedInTrim: false, +}; + +export const slice = createSlice({ + name: '@@shareAndAccess', + initialState, + reducers: { + getUsersByAccountAction: (state) => state, + setUsersByAccountAction: (state, action) => { + state.users = action.payload; + }, + deleteAccessUsersAction: (state) => state, + getPublishersByIdsAction: (state) => state, + setPublishersByIdsAction: (state, action) => { + state.sites = action.payload; + }, + updateAccessLevelAction: (state) => state, + changeAccessLevelAction: (state) => state, + addAccessSitesAction: (state) => state, + deleteAccessSitesAction: (state) => state, + shareVideoWithUsersAction: (state) => state, + updateStateByParamAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + setIsSharingAction: (state, action) => { + state.isSharing = action.payload; + }, + setVideoSharedAction: (state) => { + state.usersToShare = []; + state.isSharing = false; + }, + clearFormAction: () => initialState, + setIsLoadingAction: (state, action) => { + state.isLoading = action.payload; + }, + setIsSitesLoadingAction: (state, action) => { + state.isSitesLoading = action.payload; + }, + setFormCloseAction: (state, action) => { + state.isOpen = action.payload; + }, + copyAccessUsersToTrimedVideoAction: (state) => state, + getSharedUsersNameAction: (state) => state, + }, +}); + +export const { + getUsersByAccountAction, + setUsersByAccountAction, + getPublishersByIdsAction, + setPublishersByIdsAction, + updateAccessLevelAction, + changeAccessLevelAction, + addAccessSitesAction, + deleteAccessSitesAction, + shareVideoWithUsersAction, + setIsSharingAction, + setVideoSharedAction, + clearFormAction, + setIsLoadingAction, + setIsSitesLoadingAction, + setFormCloseAction, + copyAccessUsersToTrimedVideoAction, + getSharedUsersNameAction, + deleteAccessUsersAction, + updateStateByParamAction, +} = slice.actions; diff --git a/anyclip/src/modules/entities/Entities/components/AliasDialog.jsx b/anyclip/src/modules/entities/Entities/components/AliasDialog.jsx new file mode 100644 index 0000000..d928680 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/components/AliasDialog.jsx @@ -0,0 +1,192 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from 'react'; +import AddRounded from '@mui/icons-material/AddRounded'; +import DeleteIcon from '@mui/icons-material/Delete'; + +import { ALIAS, MAIN } from '../constants'; + +import { formatKeywordsForGrid, formatKeywordsForUpdate, formatLanguages } from '../helpers/formatObjects'; +import { isAliasDialogButtonDisabled } from '../helpers/validations'; + +import { + Button, + DataGridPro, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + ToggleButton, + ToggleButtonGroup, +} from '@/mui/components'; + +import styles from './AliasDialog.module.scss'; + +function AliasDialog(props) { + const [data, setData] = useState(() => + formatKeywordsForGrid(props.row.keywords).map((keyword, index) => ({ ...keyword, id: `${keyword.id}${index}` })), + ); + + const formattedLanguages = formatLanguages(props.languages); + const isDisabled = isAliasDialogButtonDisabled(data); + + const handleStatusChange = (row, value) => { + if (!value) return; + if (value === ALIAS && data.some((item) => item.lang === row.lang && item.status === MAIN)) return; + + const tempData = [...data]; + + tempData.forEach((item) => { + if (item.id === row.id) { + // eslint-disable-next-line no-param-reassign + item.status = value; + } else if (item.lang === row.lang && item.status === MAIN) { + // eslint-disable-next-line no-param-reassign + item.status = ALIAS; + } + }); + + setData(tempData); + }; + + const handleRowUpdate = (row) => { + const tempData = window.structuredClone(data); + + const index = tempData.findIndex((item) => item.id === row.id); + tempData[index] = row; + + const hasMainLangRow = tempData.some( + (item) => item.lang === row.lang && item.status === MAIN && item.id !== row.id, + ); + if (hasMainLangRow) tempData[index].status = ALIAS; + + setData(tempData); + + return row; + }; + + const handleDeleteRow = (rowId) => { + const tempData = data.filter((item) => item.id !== rowId); + + setData(tempData); + }; + + const handleAddRow = () => { + const tempData = [...data]; + + tempData.push({ + id: `${Date.now()}`, + lang: 'EN', + value: '', + status: ALIAS, + }); + + setData(tempData); + }; + + const handleSave = () => { + const formattedKeywords = formatKeywordsForUpdate(data); + + props.handleRowUpdate({ ...props.row, keywords: formattedKeywords }); + }; + + console.log(data, 'data'); + + return ( + + Aliases + +
    + +
    + formattedLanguages[value], + }, + { + field: 'status', + headerName: 'Status', + width: 120, + sortable: false, + filterable: false, + disableColumnMenu: true, + renderCellValue: (cell) => ( + handleStatusChange(cell.row, value)} + > + Alias + Main + + ), + }, + { + field: 'id', + headerName: 'Alias', + align: 'center', + width: 90, + sortable: false, + filterable: false, + disableColumnMenu: true, + renderCellValue: (params) => ( + + handleDeleteRow(params.id)}> + + + + ), + }, + ]} + /> +
    + + + + +
    + ); +} + +export default AliasDialog; diff --git a/anyclip/src/modules/entities/Entities/components/AliasDialog.module.scss b/anyclip/src/modules/entities/Entities/components/AliasDialog.module.scss new file mode 100644 index 0000000..9368a55 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/components/AliasDialog.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"AliasButton":"AliasDialog_AliasButton__M5c5g"}; \ No newline at end of file diff --git a/anyclip/src/modules/entities/Entities/components/AvatarEdit.jsx b/anyclip/src/modules/entities/Entities/components/AvatarEdit.jsx new file mode 100644 index 0000000..fb8ed52 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/components/AvatarEdit.jsx @@ -0,0 +1,65 @@ +import React, { useEffect, useRef } from 'react'; + +import { Avatar, Stack } from '@/mui/components'; + +function AvatarEdit(props) { + const inputRef = useRef(null); + + const clearInput = () => { + inputRef.current.value = ''; + }; + + const readFile = () => { + const [file] = inputRef.current.files; + + if (file) { + const readerUrl = new FileReader(); + + readerUrl.addEventListener('load', () => { + // eslint-disable-next-line react/prop-types + props.onChange(readerUrl.result); + }); + + readerUrl.addEventListener('error', () => { + clearInput(); + }); + + readerUrl.readAsDataURL(file); + } + }; + + useEffect(() => { + inputRef.current.click(); + }, []); + + return ( + + null} + /> + + + ); +} + +export default AvatarEdit; diff --git a/anyclip/src/modules/entities/Entities/components/Entities.jsx b/anyclip/src/modules/entities/Entities/components/Entities.jsx new file mode 100644 index 0000000..d0a6fb6 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/components/Entities.jsx @@ -0,0 +1,647 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { AddRounded, ExpandMoreRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; +import PeopleIcon from '@mui/icons-material/People'; +import PeopleOutlineIcon from '@mui/icons-material/PeopleOutline'; + +import { + ALL, + CATEGORY_OPTIONS, + DISABLED, + ENABLED, + IMAGE_OPTIONS, + LUMINOUSX, + NEW_ROW_ID, + NOT_APPROVED, + SELECT_STATUS_OPTIONS, + STATUS_ALL, + STATUS_BRANDS, + STATUS_CELL_RENDER, + STATUS_OPTIONS, + STATUS_PEOPLE, + X_RAY_OPTIONS, + X_RAY_SELECT_OPTIONS, +} from '../constants'; +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import * as entitiesSelectors from '../redux/selectors'; +import { + createEntityAction, + getAccountOptionsAction, + getDataAction, + getLanguages, + setAction, + setTableAction, + updateEntityAction, +} from '../redux/slices'; +import { getIAB } from '@/modules/@common/iab/helpers'; +import { omitUndefinedProps } from '@/mui/helpers'; +import { createFlatTreeMap } from '@/mui/helpers/treeView'; + +import IabSelector from '@/modules/@common/iab/components/IabSelector/IabSelector'; +import CommonList from '@/modules/@common/List'; +import AliasDialog from './AliasDialog'; +import AvatarEdit from './AvatarEdit'; +import MergeEntitiesDialog from './MergeEntitiesDialog'; +import { + Autocomplete, + Button, + Chip, + DataGridPro, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + Stack, + TextField, + Tooltip, + Typography, + UserAvatar, +} from '@/mui/components'; +import DateCell from '@/mui/components/DataGridPro/components/DateCell/View'; +import DateTimeCell from '@/mui/components/DataGridPro/components/DateTimeCell/View'; + +import styles from './Entities.module.scss'; + +function Entities() { + const [flatTree] = useState( + createFlatTreeMap( + getIAB(), + { + label: 'name', + children: 'categories', + }, + [], + ), + ); + const [addEntityAnchorEl, setAddEntityAnchorEl] = useState(null); + const [rowAliasDialog, setRowAliasDialog] = useState(false); + const [selectedRows, setSelectedRows] = React.useState([]); + const [actions, setActions] = useState(null); + const [mergeEntitiesDialog, setMergeEntitiesDialog] = useState(false); + + const dispatch = useDispatch(); + + const data = useSelector(entitiesSelectors.dataSelector); + const page = useSelector(entitiesSelectors.pageSelector); + const pageSize = useSelector(entitiesSelectors.pageSizeSelector); + const totalCount = useSelector(entitiesSelectors.totalCountSelector); + const sortBy = useSelector(entitiesSelectors.sortBySelector); + const sortOrder = useSelector(entitiesSelectors.sortOrderSelector); + + const search = useSelector(entitiesSelectors.searchSelector); + const category = useSelector(entitiesSelectors.categorySelector); + const xrayStatus = useSelector(entitiesSelectors.xrayStatusSelector); + const tagStatus = useSelector(entitiesSelectors.tagStatusSelector); + const image = useSelector(entitiesSelectors.imageSelector); + const accountOptions = useSelector(entitiesSelectors.accountOptionsSelector); + const languages = useSelector(entitiesSelectors.languagesSelector); + + const sortModel = useMemo(() => [{ field: sortBy, sort: sortOrder }], [sortBy, sortOrder]); + + const getMergeEntitiesIsActive = () => { + const entities = data ? data.filter((d) => selectedRows?.includes(d.id)) : []; + + if (entities.length < 2) return false; + + const firstCategory = entities[0].category; + const firstAccount = entities[0].account ? entities[0].account.id : null; + + for (let i = 1; i < entities.length; i++) { + const entity = entities[i]; + + if (entity.category !== firstCategory) { + return false; + } + + const entityAccount = entity.account ? entity.account.id : null; + if (entityAccount !== firstAccount) { + return false; + } + } + + return true; + }; + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + const handleSorting = ([sort]) => { + if (!sort) { + handleFilter({ sortBy, sortOrder: SORT_ASC }); + return; + } + + handleFilter({ sortBy: sort.field, sortOrder: sort.sort }); + }; + + useEffect(() => { + dispatch(getDataAction()); + dispatch(getLanguages()); + }, []); + + const handleRowUpdate = (row, oldRow) => { + const tempRow = window.structuredClone(row); + + // If the row is not changed, return the row + if (JSON.stringify(tempRow) === JSON.stringify(oldRow)) return tempRow; + + if (oldRow && oldRow.value !== row.value) { + const englishIndex = row.keywords.findIndex((keyword) => keyword.lang === 'EN'); + + tempRow.keywords[englishIndex].value = row.value; + } + + if (row.birthDate) { + tempRow.metaData = { ...tempRow.metaData, birthDate: row.birthDate }; + } + + if (row.iab) { + tempRow.metaData = { ...tempRow.metaData, iab: row.iab }; + } + + if (row.types) { + if (Array.isArray(row.types)) { + tempRow.types = row.types; + } else { + const option = X_RAY_SELECT_OPTIONS.find((option$) => option$.value === row.types); + tempRow.types = option ? option.types : []; + } + } else { + tempRow.types = []; + } + + tempRow.status = typeof row.status === 'string' ? row.status : row.status.value; + + const hasLuminousX = row.types && row.types.includes(LUMINOUSX); + const isXrayEnabled = row.types === ENABLED || hasLuminousX; + + if (!isXrayEnabled) { + tempRow.types = X_RAY_SELECT_OPTIONS.find((option) => option.value === DISABLED)?.types; + } + + if (row.id === NEW_ROW_ID) return tempRow; + + dispatch(updateEntityAction(tempRow)); + return tempRow; + }; + + const handleAddNewRow = (type) => { + if (data.some((row) => row.id === NEW_ROW_ID)) return; + + if (category !== type) { + handleFilter({ category: type, page: 0 }); + } + + const newRow = { + category: type, + id: NEW_ROW_ID, + status: NOT_APPROVED, + value: `New Entity - ${Date.now()}`, + keywords: [ + { + lang: 'EN', + value: `New Entity - ${Date.now()}`, + suggestions: null, + }, + ], + }; + + dispatch(createEntityAction(newRow)); + setAddEntityAnchorEl(null); + }; + + return ( + + + {actions && ( + setActions(null)}> + { + setMergeEntitiesDialog(true); + setActions(''); + }} + > + Merge Entities + + + )} +
    + handleFilter({ search: target.value, page: 0 })} + inputProps={{ autoComplete: 'off' }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + /> +
    + + + + + + s.value === category) ?? STATUS_ALL} + options={CATEGORY_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ category: selected$?.value ?? STATUS_ALL, page: 0 })} + renderInput={(params) => } + /> + handleFilter({ account: selectedAccount, page: 0 })} + onOpen={() => { + dispatch(setAction({ accountOptions: [] })); + dispatch(getAccountOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getAccountOptionsAction(searchText))} + renderInput={(params) => ( + + )} + /> + s.value === xrayStatus) ?? null} + options={X_RAY_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ xrayStatus: selected$?.value ?? ALL, page: 0 })} + renderInput={(params) => } + disabled={tagStatus === DISABLED} + /> + s.value === tagStatus) ?? null} + options={STATUS_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ tagStatus: selected$?.value ?? ALL, page: 0 })} + renderInput={(params) => } + /> + s.value === image) ?? null} + options={IMAGE_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ image: selected$?.value ?? ALL, page: 0 })} + renderInput={(params) => } + /> + + } + renderActions={ + + + + + setAddEntityAnchorEl(null)} + > + handleAddNewRow(STATUS_PEOPLE)}>Person + handleAddNewRow(STATUS_BRANDS)}>Brand + + + } + > + { + setSelectedRows([...ids]); + }} + rows={data || []} + columns={[ + { + field: 'thumbnailFileUrl', + headerName: 'Image', + headerAlign: 'center', + width: 60, + align: 'center', + sortable: false, + filterable: false, + disableColumnMenu: true, + editable: true, + renderEditCell: (props) => ( + + ), + renderCellValue: (params) => { + const [firstName = '', lastName = ''] = params.row.value?.trim().split(/\s+/) || []; + + return ( + + ); + }, + }, + { + field: 'value', + headerAlign: 'start', + headerName: 'Name', + cellType: 'string', + editable: true, + disableColumnMenu: true, + width: 180, + renderCellValue: (params) => ( + + {params.value} + + ), + }, + { + field: 'iab', + headerName: 'IAB Category', + cellType: 'multiSelect', + width: 160, + disableColumnMenu: true, + editable: true, + sortable: false, + renderCellValue: (props) => + !props.row.metaData?.iab?.length ? ( + 'No Tags' + ) : ( + + {props.row.metaData.iab.map((nodeId) => { + const { label } = flatTree.get(nodeId); + + return ; + })} + + ), + renderEditCell: (props) => ( + { + props.onChange(value.map(({ id }) => id)); + }} + /> + ), + }, + { + field: 'status', + headerName: 'Tag Status', + cellType: 'autocompleteSelect', + width: 130, + disableColumnMenu: true, + editable: true, + valueOptions: SELECT_STATUS_OPTIONS, + renderCellValue: (params) => ( + + {STATUS_CELL_RENDER[params.row.status]?.label} + + ), + }, + { + field: 'types', + headerName: 'X-Ray Status', + width: 130, + cellType: 'singleSelect', + disableColumnMenu: true, + editable: true, + sortable: false, + valueOptions: X_RAY_SELECT_OPTIONS, + renderCellValue: (params) => ( + + {params.value?.includes(LUMINOUSX) ? 'Enabled' : 'Disabled'} + + ), + renderEditProps: (params) => { + let { value } = params; + if (Array.isArray(params.value)) { + value = params.value?.includes(LUMINOUSX) ? ENABLED : DISABLED; + } + + return { + root: { + value, + disabled: [DISABLED].includes(params.row.status), + }, + }; + }, + }, + { + field: 'account', + headerName: 'Account', + width: 140, + disableColumnMenu: true, + editable: false, + sortable: false, + renderCellValue: (params) => ( + + + {params.value?.name || ''} + + + ), + }, + { + field: 'birthDate', + headerName: 'Birthday', + headerAlign: 'start', + disableColumnMenu: true, + width: 130, + cellType: 'date', + sortable: false, + editable: category === STATUS_PEOPLE, + renderEditProps: (params) => ({ + root: { + value: params.row.metaData?.birthDate || new Date(1980, 0, 1), + minDate: new Date(100, 1, 1), + }, + }), + renderCellValue: (params) => + params.row?.metaData?.birthDate && params.row.category === STATUS_PEOPLE ? ( + + ) : ( + '' + ), + }, + { + field: 'counterVideos', + headerName: 'Videos', + width: 90, + cellType: 'number', + disableColumnMenu: true, + editable: false, + }, + { + field: 'playCount', + cellType: 'number', + headerName: 'Views', + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + width: 90, + disableColumnMenu: true, + editable: false, + }, + { + field: 'created', + headerName: 'Created', + width: 190, + disableColumnMenu: true, + cellType: 'dateTime', + editable: false, + }, + { + field: 'updated', + headerName: 'Updated', + width: 190, + disableColumnMenu: true, + editable: false, + cellType: 'dateTime', + renderCellValue: (params) => + params.value ? ( + + + + ) : ( +
    + ), + }, + { + field: 'source', + editable: false, + disableColumnMenu: true, + headerName: 'Image Source', + width: 120, + }, + { + editable: false, + field: 'keywords', + disableColumnMenu: true, + sortable: false, + align: 'center', + headerName: 'Alias', + cellType: 'actions', + width: 60, + renderCellValue: (params) => { + const hasAlias = params.value?.length > 1 || params.value[0]?.suggestions?.length; + + return ( + + setRowAliasDialog(params.row)}> + {hasAlias ? : } + + + ); + }, + }, + ]} + /> + {rowAliasDialog && ( + setRowAliasDialog(null)} + row={rowAliasDialog} + languages={languages} + /> + )} + + {mergeEntitiesDialog && ( + setMergeEntitiesDialog(false)} + /> + )} + + ); +} + +export default Entities; diff --git a/anyclip/src/modules/entities/Entities/components/Entities.module.scss b/anyclip/src/modules/entities/Entities/components/Entities.module.scss new file mode 100644 index 0000000..5cc23f5 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/components/Entities.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SearchField":"Entities_SearchField__OlZ1n","TypeSelect":"Entities_TypeSelect__m56rL","AccountSelect":"Entities_AccountSelect__3MoF_","XrayStatus":"Entities_XrayStatus__HSxRR","TagStatus":"Entities_TagStatus___o4qa","ImageFilter":"Entities_ImageFilter__MH_T_","AccountCell":"Entities_AccountCell__DU141","Avatar":"Entities_Avatar__LbJNK","RenderHeaderTitle":"Entities_RenderHeaderTitle__6bsJM"}; \ No newline at end of file diff --git a/anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.jsx b/anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.jsx new file mode 100644 index 0000000..1da2188 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.jsx @@ -0,0 +1,169 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { Delete } from '@mui/icons-material'; + +import { LUMINOUSX } from '../constants'; + +import * as entitiesSelectors from '../redux/selectors'; +import { mergeEntitiesAction } from '../redux/slices'; + +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Radio, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@/mui/components'; + +import styles from './MergeEntitiesDialog.module.scss'; + +const getApprovedEntities = (entities) => entities.filter((entity) => entity.types?.includes(LUMINOUSX)); +const getDefaultPrimary = (entities) => { + if (!entities.length) return null; + + const approvedEntities = getApprovedEntities(entities); + + if (approvedEntities.length > 0) { + // If multiple approved, take the one with the most videos + return approvedEntities.reduce((max, entity) => (entity.counterVideos > max.counterVideos ? entity : max)).id; + } + + // No approved entities, take the one with the most videos + return entities.reduce((max, entity) => (entity.counterVideos > max.counterVideos ? entity : max)).id; +}; + +function MergeEntitiesDialog(props) { + const dispatch = useDispatch(); + const data = useSelector(entitiesSelectors.dataSelector); + const isLoading = useSelector(entitiesSelectors.isMergeLoadingSelector); + + const entities = data.filter((d) => props.selectedRows.includes(d.id)); + + const [entitiesForMerge, setEntitiesForMerge] = useState(entities); + const [primary, setPrimary] = useState(null); + const [alias, setAlias] = useState([]); + + const approvedEntities = getApprovedEntities(entitiesForMerge); + const getShouldDisableApply = () => + entitiesForMerge.length <= 1 || approvedEntities.length > 1 || approvedEntities.some((ent) => ent.id !== primary); + + const handleRemove = (id) => { + if (primary === id) { + setPrimary(null); + } + setEntitiesForMerge(entitiesForMerge.filter((d) => d.id !== id)); + }; + + const handleApply = () => { + dispatch(mergeEntitiesAction({ entitiesForMerge, primary, alias, callback: props.onClose })); + }; + + useEffect(() => { + if (!primary) { + setPrimary(getDefaultPrimary(entitiesForMerge)); + } + }, [primary]); + + return ( + + Merge Entities + + + + + + + Name + Primary + Save as Alias + X-Ray Status + Videos + + + + {entitiesForMerge.map((row) => ( + + {row.value} + + { + setPrimary(row.id); + setAlias((prev) => prev.filter((a) => a !== row.id)); + }} + /> + + + + setAlias((prev) => + prev.includes(row.id) ? prev.filter((a) => a !== row.id) : [...prev, row.id], + ) + } + /> + + {row.types?.includes(LUMINOUSX) ? 'Enabled' : 'Disabled'} + + + {row.counterVideos} + handleRemove(row.id)} + > + + + + + + ))} + +
    +
    +
    +
    + + + + +
    + ); +} + +MergeEntitiesDialog.propTypes = { + open: PropTypes.bool.isRequired, + selectedRows: PropTypes.arrayOf(PropTypes.string).isRequired, + onClose: PropTypes.func.isRequired, + onApply: PropTypes.func.isRequired, +}; + +export default MergeEntitiesDialog; diff --git a/anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.module.scss b/anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.module.scss new file mode 100644 index 0000000..f9792bb --- /dev/null +++ b/anyclip/src/modules/entities/Entities/components/MergeEntitiesDialog.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"TableContainer":"MergeEntitiesDialog_TableContainer__I3YbN","Action":"MergeEntitiesDialog_Action__EpPto","WithActions":"MergeEntitiesDialog_WithActions__1K0km"}; \ No newline at end of file diff --git a/anyclip/src/modules/entities/Entities/constants/index.js b/anyclip/src/modules/entities/Entities/constants/index.js new file mode 100644 index 0000000..9f6cccf --- /dev/null +++ b/anyclip/src/modules/entities/Entities/constants/index.js @@ -0,0 +1,70 @@ +export const NEW_ROW_ID = 'NEW_ROW_ID'; + +// Type field options +export const STATUS_ALL = null; +export const STATUS_PEOPLE = 'PEOPLE'; +export const STATUS_BRANDS = 'BRANDS'; + +export const CATEGORY_OPTIONS = [ + { label: 'People', value: STATUS_PEOPLE }, + { label: 'Brands', value: STATUS_BRANDS }, +]; + +// field options +export const ALL = 'ALL'; +export const APPROVED = 'APPROVED'; +export const NOT_APPROVED = 'NOT_APPROVED'; +export const DISABLED = 'DISABLED'; +export const ENABLED = 'ENABLED'; + +export const MANUAL = 'Manual'; +export const GOOGLE = 'Google'; +export const WIKI = 'Wiki'; +export const MISSING = 'MISSING'; + +// alias status +export const MAIN = 'MAIN'; +export const ALIAS = 'ALIAS'; + +// tagStatus options +export const STATUS_OPTIONS = [ + { label: 'Approved', value: APPROVED }, + { label: 'Not Approved', value: NOT_APPROVED }, + { label: 'Disabled', value: DISABLED }, +]; + +export const SELECT_STATUS_OPTIONS = [ + { label: 'Approved', value: APPROVED }, + { label: 'Not Approved', value: NOT_APPROVED }, + { label: 'Disabled', value: DISABLED }, +]; + +// xrayStatus options +export const X_RAY_OPTIONS = [{ label: 'Enabled', value: ENABLED }]; + +export const LUMINOUSX = 'LUMINOUSX'; + +export const X_RAY_SELECT_OPTIONS = [ + { label: 'Enabled', value: ENABLED, types: [LUMINOUSX] }, + { label: 'Disabled', value: DISABLED, types: [] }, +]; + +export const IMAGE_OPTIONS = [ + { label: 'Manual', value: MANUAL }, + { label: 'Google', value: GOOGLE }, + { label: 'Wiki', value: WIKI }, + { label: 'Missing', value: MISSING }, +]; + +export const STATUS_CELL_RENDER = { + DISABLED: { label: 'Disabled', color: '#00000024' }, + APPROVED: { label: 'Approved', color: '#57AF48' }, + ENABLED: { label: 'Enabled', color: '#57AF48' }, + NOT_APPROVED: { label: 'Not Approved', color: '#FFA234' }, +}; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'counterVideos'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/entities/Entities/helpers/formatObjects.js b/anyclip/src/modules/entities/Entities/helpers/formatObjects.js new file mode 100644 index 0000000..05f291b --- /dev/null +++ b/anyclip/src/modules/entities/Entities/helpers/formatObjects.js @@ -0,0 +1,53 @@ +import { ALIAS, MAIN } from '../constants'; + +export const formatKeywordsForGrid = (keywords) => { + const tempData = []; + + keywords.forEach((keyword) => { + tempData.push({ + id: keyword.value + keyword.lang, + lang: keyword.lang, + value: keyword.value, + status: MAIN, + }); + keyword.suggestions?.forEach((suggestion) => { + tempData.push({ + id: suggestion + keyword.lang, + lang: keyword.lang, + value: suggestion, + status: ALIAS, + }); + }); + }); + + return tempData; +}; + +export const formatKeywordsForUpdate = (keywords) => { + const mainKeywords = keywords.filter((keyword) => keyword.status === MAIN); + + const formattedKeywords = mainKeywords.map((keyword) => ({ + lang: keyword.lang, + value: keyword.value, + suggestions: keywords + .filter((suggestion) => suggestion.lang === keyword.lang && suggestion.status === ALIAS) + .map((suggestion) => suggestion.value), + })); + + const formattedKeywordsResult = formattedKeywords.map((keyword) => ({ + ...keyword, + suggestions: keyword.suggestions.length ? keyword.suggestions : null, + })); + + return formattedKeywordsResult; +}; + +export const formatLanguages = (languages) => { + const formattedLanguages = {}; + + languages?.forEach((lang) => { + formattedLanguages[lang.value] = lang.label; + }); + + return formattedLanguages; +}; diff --git a/anyclip/src/modules/entities/Entities/helpers/validations.js b/anyclip/src/modules/entities/Entities/helpers/validations.js new file mode 100644 index 0000000..d7fed84 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/helpers/validations.js @@ -0,0 +1,13 @@ +import { ALIAS, MAIN } from '../constants'; + +export const isAliasDialogButtonDisabled = (keywords) => { + if (!keywords || !keywords.length) return true; + + if (keywords.some((item) => !item.value)) return true; + + // Check for each ALIAS if there is a corresponding MAIN for the same language + const aliasLanguages = keywords.filter((item) => item.status === ALIAS).map((item) => item.lang); + const mainLanguages = new Set(keywords.filter((item) => item.status === MAIN).map((item) => item.lang)); + + return aliasLanguages.some((lang) => !mainLanguages.has(lang)); +}; diff --git a/anyclip/src/modules/entities/Entities/redux/epics/createEntity.js b/anyclip/src/modules/entities/Entities/redux/epics/createEntity.js new file mode 100644 index 0000000..9058f19 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/epics/createEntity.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { addEntityAction, createEntityAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation CreateEntity( + $entity: EntitiesModuleCreateInputType + ) { + createEntity( + entity: $entity + ) { + uid, + status, + category, + created, + updated, + value, + keywords { + lang, + value, + suggestions, + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(createEntityAction.type), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + entity: { + status: payload.status, + keywords: payload.keywords, + category: payload.category, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Entity Created', + }), + ), + of(addEntityAction({ ...response.data.createEntity, id: response.data.createEntity.uid })), + ); + } + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/entities/Entities/redux/epics/getAccounts.js b/anyclip/src/modules/entities/Entities/redux/epics/getAccounts.js new file mode 100644 index 0000000..6cd346d --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/epics/getAccounts.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getEntitiesAccountsOptions( + $searchText: String, + ) { + getEntitiesAccountsOptions( + searchText: $searchText, + ) { + id + name + } + } +`; + +const getResponse = ({ data: { getEntitiesAccountsOptions } }) => + getEntitiesAccountsOptions.map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat( + of( + setAction({ + accountOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/entities/Entities/redux/epics/getData.js b/anyclip/src/modules/entities/Entities/redux/epics/getData.js new file mode 100644 index 0000000..1dd683f --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/epics/getData.js @@ -0,0 +1,92 @@ +import * as entitiesSelectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query GetEntities( + $sortBy: String + $sortOrder: String + $page: Int + $pageSize: Int + $searchText: String + $accountId: Int + $image: String + $category: String + $status: String + $xrayStatus: String + $searchIn: [String] + ) { + getEntities( + sortBy: $sortBy + sortOrder: $sortOrder + page: $page + image: $image + pageSize: $pageSize + searchText: $searchText + accountId: $accountId + category: $category + status: $status + searchIn: $searchIn + xrayStatus: $xrayStatus + ) { + recordsTotal + records { + id + uid + refUserId + thumbnailFileUrl + value + category + status + score + source + types + playCount + counterVideos + updated + created + metaData { + birthDate + iab + } + account { + id + name + } + keywords { + lang + value + suggestions + } + } + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const account = entitiesSelectors.accountSelector(state); + + const variables = { + searchText: entitiesSelectors.searchSelector(state), + page: entitiesSelectors.pageSelector(state), + pageSize: entitiesSelectors.pageSizeSelector(state), + sortBy: entitiesSelectors.sortBySelector(state), + sortOrder: entitiesSelectors.sortOrderSelector(state), + category: entitiesSelectors.categorySelector(state) || undefined, + image: entitiesSelectors.imageSelector(state), + xrayStatus: entitiesSelectors.xrayStatusSelector(state), + status: entitiesSelectors.tagStatusSelector(state), + }; + + if (account) { + variables.accountId = account.value; + } + + return variables; + }, + processResponse: ({ data: { getEntities } }) => getEntities, + setTableAction, +}); diff --git a/anyclip/src/modules/entities/Entities/redux/epics/getLanguages.js b/anyclip/src/modules/entities/Entities/redux/epics/getLanguages.js new file mode 100644 index 0000000..9a050b0 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/epics/getLanguages.js @@ -0,0 +1,52 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getLanguages, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getEntitiesLanguages { + getEntitiesLanguages { + id + name + } + } +`; + +const getResponse = ({ data: { getEntitiesLanguages } }) => + getEntitiesLanguages.map((language) => ({ + value: language.id, + label: language.name, + })); + +export default (action$) => + action$.pipe( + ofType(getLanguages.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + languages: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat( + of( + setAction({ + languages: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/entities/Entities/redux/epics/index.js b/anyclip/src/modules/entities/Entities/redux/epics/index.js new file mode 100644 index 0000000..e7ded9d --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/epics/index.js @@ -0,0 +1,10 @@ +import { combineEpics } from 'redux-observable'; + +import createEntity from './createEntity'; +import getAccounts from './getAccounts'; +import getData from './getData'; +import getLanguages from './getLanguages'; +import mergeEntities from './mergeEntities'; +import updateEntity from './updateEntity'; + +export default combineEpics(getData, getAccounts, updateEntity, createEntity, getLanguages, mergeEntities); diff --git a/anyclip/src/modules/entities/Entities/redux/epics/mergeEntities.js b/anyclip/src/modules/entities/Entities/redux/epics/mergeEntities.js new file mode 100644 index 0000000..1b0e34c --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/epics/mergeEntities.js @@ -0,0 +1,92 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { dataSelector } from '../selectors'; +import { mergeEntitiesAction, setAction, setTableAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation MergeEntity( + $entity: EntitiesModuleMergeInputType + ) { + mergeEntity( + entity: $entity + ) { + uid, + status, + category, + created, + updated, + value, + source, + keywords { + lang, + value, + suggestions, + } + metaData { + birthDate + iab + } + types + notUpdated + errorMessage + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(mergeEntitiesAction.type), + switchMap(({ payload }) => { + const { entitiesForMerge, primary, alias, callback } = payload; + const currentData = dataSelector(state$.value); + const entity = { + primary, + secondary: entitiesForMerge + .filter((entity$) => entity$.id !== primary) + .map((entity$) => ({ + id: entity$.id, + saveAsAlias: alias.includes(entity$.id), + })), + }; + const stream$ = gqlRequest({ + query, + variables: { entity }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const updatedPrimaryEntity = { ...response.data.mergeEntity, id: response.data.mergeEntity.uid }; + const updatedData = currentData.reduce((acc, entity$) => { + if (entity$.id === primary) { + acc.push({ ...entity$, ...updatedPrimaryEntity }); + } else if (!entitiesForMerge.some((entityForMerge) => entityForMerge.id === entity$.id)) { + acc.push(entity$); + } + return acc; + }, []); + + callback(); + + return concat( + of(setTableAction({ data: updatedData })), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: + 'The merging process has been started. The primary entity and the videos will be updated soon', + }), + ), + ); + } + return EMPTY; + }), + ); + + return concat(of(setAction({ isMergeLoading: true })), stream$, of(setAction({ isMergeLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/entities/Entities/redux/epics/updateEntity.js b/anyclip/src/modules/entities/Entities/redux/epics/updateEntity.js new file mode 100644 index 0000000..791a75f --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/epics/updateEntity.js @@ -0,0 +1,96 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { updateEntityAction, updateLocalEntityAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateEntity( + $entity: EntitiesModuleUpdateInputType + ) { + updateEntity( + entity: $entity + ) { + uid, + status, + category, + created, + updated, + value, + source, + keywords { + lang, + value, + suggestions, + } + metaData { + birthDate + iab + } + types + notUpdated + errorMessage + } + } +`; + +export default (action$) => + action$.pipe( + ofType(updateEntityAction.type), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + entity: { + id: payload.id, + status: payload.status, + thumbnailFileUrl: payload.thumbnailFileUrl, + types: payload.types, + metaData: { + ...(payload.metaData?.birthDate && { birthDate: payload.metaData?.birthDate }), + ...(payload.metaData?.iab && { iab: payload.metaData?.iab }), + }, + keywords: payload.keywords, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const notUpdatedWeavo = response.data.updateEntity.notUpdated; + const weavoErrorMessage = response.data.updateEntity.errorMessage; + if (notUpdatedWeavo) { + const notUpdatedEntity = response.data.updateEntity; + delete notUpdatedEntity.notUpdated; + delete notUpdatedEntity.errorMessage; + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: weavoErrorMessage, + }), + ), + of(updateLocalEntityAction({ ...payload, ...notUpdatedEntity })), + ); + } + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Entity Updated', + }), + ), + of(updateLocalEntityAction({ ...payload, ...response.data.updateEntity })), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/entities/Entities/redux/selectors/index.js b/anyclip/src/modules/entities/Entities/redux/selectors/index.js new file mode 100644 index 0000000..a05e066 --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/selectors/index.js @@ -0,0 +1,30 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; + +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; +export const categorySelector = (state) => state[nameSpace].category; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +export const languagesSelector = (state) => state[nameSpace].languages; +export const xrayStatusSelector = (state) => state[nameSpace].xrayStatus; +export const tagStatusSelector = (state) => state[nameSpace].tagStatus; +export const imageSelector = (state) => state[nameSpace].image; + +// loader +export const isMergeLoadingSelector = (state) => state[nameSpace].isMergeLoading; diff --git a/anyclip/src/modules/entities/Entities/redux/slices/index.js b/anyclip/src/modules/entities/Entities/redux/slices/index.js new file mode 100644 index 0000000..5e4964a --- /dev/null +++ b/anyclip/src/modules/entities/Entities/redux/slices/index.js @@ -0,0 +1,71 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ALL, ROWS_PER_PAGE_DEFAULT, STATUS_PEOPLE, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, + page: 0, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + accountId: null, + category: STATUS_PEOPLE, + accountOptions: [], + languages: [], + xrayStatus: ALL, + image: ALL, + tagStatus: ALL, + + // loader + isMergeLoading: false, +}; + +export const slice = createSlice({ + name: '@@ENTITIES/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getAccountOptionsAction: (state) => state, + getLanguages: (state) => state, + updateEntityAction: (state) => state, + createEntityAction: (state) => state, + addEntityAction: (state, { payload }) => { + state[TABLE_REDUX_FIELD_NAME].data.unshift(payload); + }, + updateLocalEntityAction: (state, { payload }) => { + const index = state[TABLE_REDUX_FIELD_NAME].data.findIndex((item) => item.id === payload.id); + state[TABLE_REDUX_FIELD_NAME].data[index] = payload; + }, + mergeEntitiesAction: (state) => state, + }, +}); + +export const { + getDataAction, + setTableAction, + setAction, + getLanguages, + getAccountOptionsAction, + updateEntityAction, + createEntityAction, + addEntityAction, + updateLocalEntityAction, + mergeEntitiesAction, +} = slice.actions; diff --git a/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/ConfigurationErrorDialog.tsx b/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/ConfigurationErrorDialog.tsx new file mode 100644 index 0000000..7a26762 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/ConfigurationErrorDialog.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import { DIALOG_METADATA, ERROR_SPEECH_TO_TEXT_LANGUAGE_NOT_SUPPORTED } from './constants'; +import { SPEECH_TO_TEXT_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import { metadataLanguagesSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { useAppSelector } from '@/modules/@common/store/hooks'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Typography } from '@/mui/components'; + +type PropTypes = { + open: boolean; + lang: string; + speechProvider: string; + model?: string; + code: keyof typeof DIALOG_METADATA; + configurationErrorCode?: string; + onClose: () => void; +}; + +function ConfigurationErrorDialog({ open, lang, speechProvider, code, onClose }: PropTypes) { + const langOptions = useAppSelector(metadataLanguagesSelector); + + const speechToTextProviderName = + SPEECH_TO_TEXT_OPTIONS.find((option) => option.value === speechProvider)?.label || ''; + + const langName = langOptions?.find((option) => option.code === lang)?.language || ''; + + const metadata: { title: string; body?: string[] } = DIALOG_METADATA[code]; + + let { title } = metadata; + if (code === ERROR_SPEECH_TO_TEXT_LANGUAGE_NOT_SUPPORTED) { + title = title.replaceAll('{speechToTextProviderName}', speechToTextProviderName).replaceAll('{langName}', langName); + } + + return ( + + Configuration error + + + + {title} + + {!!metadata?.body && ( + + {metadata.body.map((text, index) => ( + + {`- ${text}`} + + ))} + + )} + + + + + + + ); +} + +export default ConfigurationErrorDialog; diff --git a/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/constants/index.tsx b/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/constants/index.tsx new file mode 100644 index 0000000..3bf060c --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/constants/index.tsx @@ -0,0 +1,36 @@ +export const HIGHLIGHTS_SUPPORTED_LANGUAGES = ['EN', 'DE']; +export const AUTO_CHAPTERS_SUPPORTED_LANGUAGES = ['EN']; +export const TOPICS_SUPPORTED_LANGUAGES = ['EN']; + +export const ERROR_SPEECH_TO_TEXT_LANGUAGE_NOT_SUPPORTED = 'SPEECH_TO_TEXT_LANGUAGE_NOT_SUPPORTED'; +export const ERROR_HIGHLIGHTS_MODEL_NOT_SUPPORTED = 'ERROR_HIGHLIGHTS_MODEL_NOT_SUPPORTED'; +export const ERROR_MULTI_LANGUAGE_SUBTITLES = 'ERROR_MULTI_LANGUAGE_SUBTITLES'; +export const ERROR_CHAT_GPT_DESCRIPTION = 'ERROR_CHAT_GPT_DESCRIPTION'; +export const ERROR_AUTO_CHAPTERS = 'ERROR_AUTO_CHAPTERS'; +export const ERROR_TOPICS = 'ERROR_TOPICS'; + +export const DIALOG_METADATA = { + [ERROR_SPEECH_TO_TEXT_LANGUAGE_NOT_SUPPORTED]: { + title: 'The Speech to text provider {speechToTextProviderName} doesn’t support {langName} language.', + }, + [ERROR_HIGHLIGHTS_MODEL_NOT_SUPPORTED]: { + title: 'Highlights model requires the following Source settings:', + body: ['Source language should be set to English or German.', 'Speech To Text provider should be active.'], + }, + [ERROR_MULTI_LANGUAGE_SUBTITLES]: { + title: 'Multi Language Subtitles model requires the following Source settings:', + body: ['Speech To Text provider should be active.'], + }, + [ERROR_CHAT_GPT_DESCRIPTION]: { + title: 'CHAT_GPT_DESCRIPTION model requires the following Source settings:', + body: ['Speech To Text provider should be active.'], + }, + [ERROR_AUTO_CHAPTERS]: { + title: 'AUTO_CHAPTERS model requires the following Source settings:', + body: ['Language = ENGLISH.', 'Speech to text = Assembly AI.'], + }, + [ERROR_TOPICS]: { + title: 'TOPICS model requires the following Source settings:', + body: ['Source language should be set to English.', 'Speech To Text provider should be AssemblyAI.'], + }, +}; diff --git a/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/helpers/setupGetConfigurationErrorCode.tsx b/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/helpers/setupGetConfigurationErrorCode.tsx new file mode 100644 index 0000000..232cf1f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/helpers/setupGetConfigurationErrorCode.tsx @@ -0,0 +1,127 @@ +import { TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API } from '@/modules/feeds//constants'; +import { + AUTO_CHAPTERS_SUPPORTED_LANGUAGES, + DIALOG_METADATA, + ERROR_AUTO_CHAPTERS, + ERROR_CHAT_GPT_DESCRIPTION, + ERROR_HIGHLIGHTS_MODEL_NOT_SUPPORTED, + ERROR_MULTI_LANGUAGE_SUBTITLES, + ERROR_SPEECH_TO_TEXT_LANGUAGE_NOT_SUPPORTED, + ERROR_TOPICS, + HIGHLIGHTS_SUPPORTED_LANGUAGES, + TOPICS_SUPPORTED_LANGUAGES, +} from '@/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/constants'; +import { SPEECH_TO_TEXT_ASSEMBLY_AI_VALUE, SPEECH_TO_TEXT_DISABLED_VALUE } from '@/modules/feeds/Editor/constants'; + +import { PlatformModel } from '@/modules/feeds/Editor/types'; + +import { + langSelector, + metadataSpeechToTextLanguagesSelector, + platformModelsSelector, + speechToTextProviderSelector, + typeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { StoreType } from '@/modules/@common/store/store'; + +type PropType = { + inputLanguage?: string | undefined; + inputSpeechToTextProvider?: string | undefined; + inputModels?: PlatformModel[] | undefined; +}; + +type CodeType = keyof typeof DIALOG_METADATA; + +export type ConfigurationErrorOutputType = { + code: CodeType; + lang: string; + speechProvider: string; +} | null; + +export default function setupGetConfigurationErrorCode(store: StoreType) { + return ({ inputLanguage, inputSpeechToTextProvider, inputModels }: PropType): ConfigurationErrorOutputType => { + const state$ = store.getState(); + + // type + const type = typeSelector(state$); + if ([TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API].includes(type)) { + return null; + } + // options + const speechToTextLanguages = metadataSpeechToTextLanguagesSelector(state$) || []; + + // value + const lang = inputLanguage || langSelector(state$); + const speechProvider = inputSpeechToTextProvider || speechToTextProviderSelector(state$); + const models = inputModels || platformModelsSelector(state$); + + // error handle logic + const makeError = (code: CodeType): { code: CodeType; lang: string; speechProvider: string } => ({ + code, + lang, + speechProvider, + }); + const checkModel = (modelName: string) => models.find((m) => m.model === modelName && m.enabled); + const errors: { code: CodeType; validate: () => boolean }[] = [ + { + code: ERROR_SPEECH_TO_TEXT_LANGUAGE_NOT_SUPPORTED, + validate: () => { + const isSupportedBySpeechToTextProvider = speechToTextLanguages + .find((speech) => speech.value === speechProvider) + ?.languages.includes(lang); + return speechProvider !== SPEECH_TO_TEXT_DISABLED_VALUE && !isSupportedBySpeechToTextProvider; + }, + }, + { + code: ERROR_HIGHLIGHTS_MODEL_NOT_SUPPORTED, + validate: () => { + const isHighlightsModel = checkModel('HIGHLIGHTS'); + return !!( + isHighlightsModel && + (!HIGHLIGHTS_SUPPORTED_LANGUAGES.includes(lang) || speechProvider === SPEECH_TO_TEXT_DISABLED_VALUE) + ); + }, + }, + { + code: ERROR_MULTI_LANGUAGE_SUBTITLES, + validate: () => { + const isMultiLanguageSubtitlesModel = checkModel('MULTI_LANGUAGE_SUBTITLES'); + return !!(isMultiLanguageSubtitlesModel && speechProvider === SPEECH_TO_TEXT_DISABLED_VALUE); + }, + }, + { + code: ERROR_CHAT_GPT_DESCRIPTION, + validate: () => { + const isChatGptDescriptionModel = checkModel('CHAT_GPT_DESCRIPTION'); + return !!(isChatGptDescriptionModel && speechProvider === SPEECH_TO_TEXT_DISABLED_VALUE); + }, + }, + { + code: ERROR_AUTO_CHAPTERS, + validate: () => { + const isAutoChapterModel = checkModel('AUTO_CHAPTERS'); + return !!( + isAutoChapterModel && + (!AUTO_CHAPTERS_SUPPORTED_LANGUAGES.includes(lang) || speechProvider !== SPEECH_TO_TEXT_ASSEMBLY_AI_VALUE) + ); + }, + }, + { + code: ERROR_TOPICS, + validate: () => { + const isAutoChapterModel = checkModel('TOPICS'); + return !!( + isAutoChapterModel && + (!TOPICS_SUPPORTED_LANGUAGES.includes(lang) || speechProvider !== SPEECH_TO_TEXT_ASSEMBLY_AI_VALUE) + ); + }, + }, + ]; + + const error = errors.find((r) => r.validate()); + if (error) return makeError(error.code); + + return null; + }; +} diff --git a/anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.module.scss b/anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.module.scss new file mode 100644 index 0000000..1e13a34 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"ModelEditDialog_Container__CbqG6","JsonContent":"ModelEditDialog_JsonContent__uAaES","DescriptionContent":"ModelEditDialog_DescriptionContent__yXxaS"}; \ No newline at end of file diff --git a/anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.tsx b/anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.tsx new file mode 100644 index 0000000..6c6458f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; + +import { ConvertedModel } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { isValidJSON } from '@/modules/feeds/Editor/helpers'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + JSONEditor, + Stack, + Typography, +} from '@/mui/components'; + +import styles from './ModelEditDialog.module.scss'; + +type PropTypes = { + model: ConvertedModel; + value: string; + onClose: () => void; + onSave: (json: string) => void; +}; + +function ModelEditDialog({ model, value, onClose, onSave }: PropTypes) { + const [json, setJson] = useState(value); + const isValid = isValidJSON(json); + + const handleClose = () => onClose(); + const handleSave = () => { + onSave(json); + onClose(); + }; + + return ( + + + Edit {model.displayName}: {model.model} Parameters + + + + + + + + Parameters + + {!isValid && ( + + The JSON is invalid + + )} + + + setJson(jsonString)} /> + + + + + + Description + + +
      +
    • {`Model description: ${model.description}`}
    • + {model?.parameters?.length ? ( +
    • + Parameters: +
        + {model.parameters.map((param) => ( +
      • {param}
      • + ))} +
      +
    • + ) : null} + {model.example && ( +
    • + Example: +
      {model.example}
      +
    • + )} +
    +
    +
    +
    +
    + + + + + +
    + ); +} + +export default ModelEditDialog; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.module.scss b/anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.module.scss new file mode 100644 index 0000000..bd4781c --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Root":"AccessLevel_Root__lhYWA","Meta":"AccessLevel_Meta__09hRY"}; \ No newline at end of file diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.tsx new file mode 100644 index 0000000..b786f53 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Groups, Lock, Public } from '@mui/icons-material'; + +import { + ACCESS_LEVEL_HUB, + ACCESS_LEVEL_OPTIONS, + ACCESS_LEVEL_PRIVATE, + ACCESS_LEVEL_SYNDICATE, + ACCOUNT_TYPE_SYNDICATION, +} from '../../../constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { + accessLevelSelector, + accountSelector, + contentOwnerIsPublicSelector, +} from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select, Stack, Typography } from '@/mui/components'; + +import styles from './AccessLevel.module.scss'; + +type PropTypes = { + disabled?: boolean; +}; + +const META_TEXT = { + [ACCESS_LEVEL_PRIVATE]: 'Only the video owner and people the owner chooses can watch the video', + [ACCESS_LEVEL_HUB]: 'Only viewers associated with the hub can watch the video', + [ACCESS_LEVEL_SYNDICATE]: 'Video is open to all AnyClip users', +}; + +const META_ICON = { + [ACCESS_LEVEL_PRIVATE]: Lock, + [ACCESS_LEVEL_HUB]: Groups, + [ACCESS_LEVEL_SYNDICATE]: Public, +}; + +function AccessLevel({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const accessLevel = useAppSelector(accessLevelSelector); + const contentOwnerIsPublic = useAppSelector(contentOwnerIsPublicSelector); + const account = useAppSelector(accountSelector); + + const options = ACCESS_LEVEL_OPTIONS.filter((option) => { + if (option.value === ACCESS_LEVEL_SYNDICATE) { + return contentOwnerIsPublic || account?.type === ACCOUNT_TYPE_SYNDICATION; + } + return true; + }); + const MetaIcon = META_ICON[accessLevel]; + + return ( + + + {META_TEXT[accessLevel] && ( + + + + {META_TEXT[accessLevel]} + + + )} + + ); +} + +export default AccessLevel; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner.tsx new file mode 100644 index 0000000..9962a1e --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import type { FeedType } from '@/modules/feeds/Editor/types'; +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { + accessVideoOwnerSelector, + ownersOptionsSelector, + schemeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; +import { getOwnersOptionsAction, removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Autocomplete, TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function AccessVideoOwner({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const accessVideoOwner = useAppSelector(accessVideoOwnerSelector); + const ownersOptions = useAppSelector(ownersOptionsSelector); + + return ( + + dispatch( + setAction({ + accessVideoOwner: selected$ as FeedType['accessVideoOwner'], + }), + ) + } + onOpen={() => dispatch(getOwnersOptionsAction({ searchText: '' }))} + onInputChange={(e, searchAccountText) => dispatch(getOwnersOptionsAction({ searchText: searchAccountText }))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['accessVideoOwner']))} + /> + )} + /> + ); +} + +export default AccessVideoOwner; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Account/Account.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Account/Account.tsx new file mode 100644 index 0000000..23a0277 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Account/Account.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import type { FeedType } from '@/modules/feeds/Editor/types'; +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { accountOptionsSelector, accountSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { getAccountOptionsAction, removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Autocomplete, TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Account({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const account = useAppSelector(accountSelector); + const accountOptions = useAppSelector(accountOptionsSelector); + + return ( + + dispatch( + setAction({ + account: selected$ as FeedType['account'], + }), + ) + } + onOpen={() => dispatch(getAccountOptionsAction({ searchText: '' }))} + onInputChange={(e, searchAccountText) => dispatch(getAccountOptionsAction({ searchText: searchAccountText }))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['account']))} + /> + )} + /> + ); +} + +export default Account; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/AspectRatio/AspectRatio.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/AspectRatio/AspectRatio.tsx new file mode 100644 index 0000000..139e3bc --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/AspectRatio/AspectRatio.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { ASPECT_RATIO_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { aspectRatioSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function AspectRatio({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const aspectRatio = useAppSelector(aspectRatioSelector); + + return ( + + ); +} + +export default AspectRatio; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod.tsx new file mode 100644 index 0000000..008db96 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { AUTH_METHOD_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { authMethodSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function AuthMethod({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const authMethod = useAppSelector(authMethodSelector); + + return ( + + ); +} + +export default AuthMethod; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.module.scss b/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.module.scss new file mode 100644 index 0000000..0f1166a --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Authorization_Wrapper__LIuFl"}; \ No newline at end of file diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.tsx new file mode 100644 index 0000000..4def84c --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/Authorization.tsx @@ -0,0 +1,95 @@ +import React, { useEffect } from 'react'; +import { CheckCircle } from '@mui/icons-material'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { + OAUTH_RESPONSE_WITH_CODE, + OAUTH_RESPONSE_WITH_ERROR, +} from '@/modules/feeds/Editor/components/FormElements/Authorization/constants'; + +import { OAuthFeedType } from '@/modules/feeds/Editor/components/FormElements/Authorization/types'; + +import { isConnectedSelector, oAuthClientIdSelector, typeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { getOAuthClientIdAction, getOAuthTokenAction } from '@/modules/feeds/Editor/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Button, LinearProgress, Stack, Typography } from '@/mui/components'; + +import styles from './Authorization.module.scss'; + +const handleOpenAuthPopup = (type: OAuthFeedType, clientId: string) => { + const AUTH_BASE_URL = process.env.APP_ENV_BASE_URL; + + const WIDTH = 562; + const HEIGHT = 670; + + const y = window.top!.outerHeight / 2 + window.top!.screenY - HEIGHT / 2; + const x = window.top!.outerWidth / 2 + window.top!.screenX - WIDTH / 2; + window.open( + `${AUTH_BASE_URL}/auth/${type.toLowerCase()}?clientId=${clientId}`, + 'Anyclip Authentication', + `scrollbars=no, resizable=no, copyhistory=no, popup, width=${WIDTH}, height=${HEIGHT}, top=${y}, left=${x}`, + ); +}; + +function Authorization() { + const dispatch = useAppDispatch(); + + const isConnected = useAppSelector(isConnectedSelector); + const type = useAppSelector(typeSelector) as OAuthFeedType; + const oAuthClientId = useAppSelector(oAuthClientIdSelector); + + const handleAuthResponse = (responseEvent: { data: { type: string; payload: string } }) => { + if (responseEvent?.data?.type === OAUTH_RESPONSE_WITH_CODE) { + dispatch(getOAuthTokenAction({ code: responseEvent?.data.payload })); + } + + if (responseEvent?.data?.type === OAUTH_RESPONSE_WITH_ERROR) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: `Can't authorize. Please try again later. If the problem persists, please contact support. Error: ${ + responseEvent?.data.payload + }`, + }), + ); + } + }; + + useEffect(() => { + if (type) { + dispatch(getOAuthClientIdAction()); + } + + window.addEventListener('message', handleAuthResponse); + return () => window.removeEventListener('message', handleAuthResponse); + }, [type]); + + return ( + + {!isConnected && !oAuthClientId && ( + + + + )} + + {isConnected && ( + + + + Connected + + + )} + + {!isConnected && oAuthClientId && ( + + )} + + ); +} + +export default Authorization; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/constants/index.ts b/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/constants/index.ts new file mode 100644 index 0000000..625b23c --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Authorization/constants/index.ts @@ -0,0 +1,14 @@ +import { TYPE_INSTAGRAM, TYPE_SHAREPOINT, TYPE_TEAMS, TYPE_TIKTOK, TYPE_ZOOM } from '@/modules/feeds/constants'; + +import type { OAuthFeedType } from '../types'; + +export const OAUTH_CLIENT_IDS_PAYLOAD_MAPPER = { + [TYPE_TIKTOK]: 'tiktok', + [TYPE_SHAREPOINT]: 'sharepoint', + [TYPE_TEAMS]: 'teams', + [TYPE_INSTAGRAM]: 'instagram', + [TYPE_ZOOM]: 'zoom', +} as const satisfies Record; + +export const OAUTH_RESPONSE_WITH_CODE = 'ac_auth_code'; +export const OAUTH_RESPONSE_WITH_ERROR = 'ac_auth_error'; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport.tsx new file mode 100644 index 0000000..7b51ab1 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { scheduleStatusSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function AutoImport({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheduleStatus = useAppSelector(scheduleStatusSelector); + + return ( + dispatch(setAction({ scheduleStatus: e.target.checked }))} + /> + ); +} + +export default AutoImport; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/AutomationScript/AutomationScript.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/AutomationScript/AutomationScript.tsx new file mode 100644 index 0000000..4b51fb9 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/AutomationScript/AutomationScript.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { automationScriptSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { JSONEditor } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function AutomationScript({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + + const automationScript = useAppSelector(automationScriptSelector); + + return ( + + dispatch(setAction({ automationScript: json }))} + disabled={disabled} + /> + + ); +} + +export default AutomationScript; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate.tsx new file mode 100644 index 0000000..9d9b7be --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { isNumber } from '@/modules/@common/helpers/number'; +import { bitrateSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Bitrate({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const bitrate = useAppSelector(bitrateSelector); + + return ( + { + const value = parseInt(e.target.value, 10); + dispatch(setAction({ bitrate: isNumber(value) ? value : ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['bitrate'])} + onFocus={() => dispatch(removeErrorByPropAction(['bitrate']))} + label="" + /> + ); +} + +export default Bitrate; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ContentType/ContentType.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ContentType/ContentType.tsx new file mode 100644 index 0000000..b8fb9ac --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ContentType/ContentType.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { CONTENT_TYPE_OPTIONS, CONTENT_TYPE_VIDEO } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { contentTypeSelector, importShortsThumbnailSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ContentType({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const contentType = useAppSelector(contentTypeSelector); + const importShortsThumbnail = useAppSelector(importShortsThumbnailSelector); + + return ( + + ); +} + +export default ContentType; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector.tsx new file mode 100644 index 0000000..c91ed55 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { createClipSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function CreateClipSelector({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const createClip = useAppSelector(createClipSelector); + + return ( + dispatch(setAction({ createClip: e.target.checked }))} + /> + ); +} + +export default CreateClipSelector; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/DefaultTimeZone/DefaultTimeZone.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/DefaultTimeZone/DefaultTimeZone.tsx new file mode 100644 index 0000000..2f36be4 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/DefaultTimeZone/DefaultTimeZone.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { defaultTimezoneEnabledSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ImportParticipantsNames({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const defaultTimezoneEnabled = useAppSelector(defaultTimezoneEnabledSelector); + + return ( + dispatch(setAction({ defaultTimezoneEnabled: e.target.checked }))} + /> + ); +} + +export default ImportParticipantsNames; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips.tsx new file mode 100644 index 0000000..6516b84 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { discardLongClipsSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Restricted({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const discardLongClips = useAppSelector(discardLongClipsSelector); + + return ( + dispatch(setAction({ discardLongClips: e.target.checked }))} + /> + ); +} + +export default Restricted; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName.tsx new file mode 100644 index 0000000..b52ed09 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { descriptionSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +const MAX_LENGTH = 100; + +function DisplayName({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const description = useAppSelector(descriptionSelector); + + return ( + dispatch(setAction({ description: e.target.value }))} + {...getInputPropsByName(scheme, ['description'])} + onFocus={() => dispatch(removeErrorByPropAction(['description']))} + /> + ); +} + +export default DisplayName; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen.tsx new file mode 100644 index 0000000..50185c4 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { isEvergreenFeedSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Evergreen({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const isEvergreenFeed = useAppSelector(isEvergreenFeedSelector); + + return ( + dispatch(setAction({ isEvergreenFeed: e.target.checked }))} + /> + ); +} + +export default Evergreen; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority.tsx new file mode 100644 index 0000000..4000a5b --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { feedPrioritySelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function FeedPriority({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const feedPriority = useAppSelector(feedPrioritySelector); + + return ( + dispatch(removeErrorByPropAction(['feedPriority']))} + onChange={(e: React.ChangeEvent) => { + dispatch(setAction({ feedPriority: e.target.value as unknown as number })); + }} + /> + ); +} + +export default FeedPriority; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection.tsx new file mode 100644 index 0000000..15a3e45 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { FILE_SELECTION_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { filterOptionsByType } from '@/modules/feeds/Editor/helpers'; +import { fileSelectionSelector, typeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function FileSelection({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const fileSelection = useAppSelector(fileSelectionSelector); + const type = useAppSelector(typeSelector); + + if (!type) return null; + return ( + + ); +} + +export default FileSelection; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/FillLandingPage/FillLandingPage.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/FillLandingPage/FillLandingPage.tsx new file mode 100644 index 0000000..d84652e --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/FillLandingPage/FillLandingPage.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { fillLandingPageSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function FillLandingPage({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const fillLandingPage = useAppSelector(fillLandingPageSelector); + + return ( + dispatch(setAction({ fillLandingPage: e.target.checked }))} + /> + ); +} + +export default FillLandingPage; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Fit/Fit.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Fit/Fit.tsx new file mode 100644 index 0000000..a8d52e0 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Fit/Fit.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { FIT_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { fitSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Fit({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const fit = useAppSelector(fitSelector); + + return ( + + ); +} + +export default Fit; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Hubs/Hubs.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Hubs/Hubs.tsx new file mode 100644 index 0000000..53a9db9 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Hubs/Hubs.tsx @@ -0,0 +1,86 @@ +import React from 'react'; + +import { ALL_HUB_OPTION, ALL_OPTION_ID } from '@/modules/feeds/Editor/constants'; + +import type { FeedType } from '@/modules/feeds/Editor/types'; +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { + accessAllPublishersSelector, + accessPublishersSelector, + hubsOptionsSelector, + schemeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; +import { getHubsOptionsAction, removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Autocomplete, TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +type HubType = { + id: number; + name: string; +}; + +export const addAllHubsOption = (options: HubType[]): HubType[] => [ALL_HUB_OPTION, ...options]; + +function Account({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const accessAllPublishers = useAppSelector(accessAllPublishersSelector); + const accessPublishers = useAppSelector(accessPublishersSelector); + const hubsOptions = useAppSelector(hubsOptionsSelector); + + const hubs = accessAllPublishers ? [ALL_HUB_OPTION] : accessPublishers; + + return ( + { + const hasAllOption = accessPublishers.some((option) => option.id === ALL_OPTION_ID); + return hasAllOption ? [] : options; + }} + onChange={(e, selected) => { + const selected$ = selected as FeedType['accessPublishers']; + const hasAllOption = selected$.some((option) => option.id === ALL_OPTION_ID); + dispatch( + setAction({ + accessAllPublishers: hasAllOption, + accessPublishers: hasAllOption ? [ALL_HUB_OPTION] : selected$, + }), + ); + }} + onOpen={() => dispatch(getHubsOptionsAction({ searchText: '' }))} + onInputChange={(e, searchAccountText) => dispatch(getHubsOptionsAction({ searchText: searchAccountText }))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['accessPublishers']))} + /> + )} + /> + ); +} + +export default Account; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.module.scss b/anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.module.scss new file mode 100644 index 0000000..36f56b5 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"NoOptionsWrapper":"IabCategories_NoOptionsWrapper__PkIuv"}; \ No newline at end of file diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.tsx new file mode 100644 index 0000000..c2ac07b --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getIAB } from '@/modules/@common/iab/helpers'; +import { iabCategoriesSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; +import { createFlatTreeMap, getAggregateSelectedIds, NodeType } from '@/mui/helpers/treeView'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Autocomplete, TextField, TreeView } from '@/mui/components'; + +import styles from './IabCategories.module.scss'; + +type PropTypes = { + disabled?: boolean; +}; + +type SelectedFnType = (node: NodeType) => unknown; + +function IabCategories({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + const iabCategories = useAppSelector(iabCategoriesSelector); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [flatTree, setFlatTree] = useState( + createFlatTreeMap(getIAB() as NodeType[], { + label: 'name', + children: 'categories', + }), + ); + const [autoCompleteValue, setAutoCompleteValue] = useState(getAggregateSelectedIds(flatTree, true)); + + const updateTreeState = (selectedFn: SelectedFnType) => { + const copiedMap = new Map(); + + let hasChanges = false; + + flatTree.forEach((node) => { + const selected = selectedFn(node); + + if (selected !== node.selected) { + hasChanges = true; + } + + copiedMap.set(node.id, { + ...node, + selected, + }); + }); + + setFlatTree((prev) => (hasChanges ? copiedMap : prev)); + + if (hasChanges) { + const selected = getAggregateSelectedIds(copiedMap); + setAutoCompleteValue(selected); + dispatch(setAction({ iabCategories: JSON.stringify(selected) })); + } + }; + + const clear = () => { + setAutoCompleteValue([]); + setSearch(''); + setOpen(false); + dispatch(setAction({ iabCategories: JSON.stringify([]) })); + }; + + useEffect(() => { + if (!autoCompleteValue.length) { + try { + const values = JSON.parse(iabCategories); + updateTreeState((node) => values.includes(node.id)); + } catch (e) { + console.warn(e); + } + } + }, [iabCategories]); + + return ( + flatTree.get(id).label} + optionValueKey="id" + options={[]} + renderInput={(params) => } + classes={{ + noOptions: styles.NoOptionsWrapper, + }} + noOptionsText={ + { + updateTreeState((sourceNode: NodeType) => nodeIds.includes(sourceNode.id)); + }} + /> + } + onChange={(event, autocompleteValues, reason, value) => { + if (reason === 'removeOption') { + const targetNode = flatTree.get(value!.option); + + updateTreeState( + (sourceNode) => + sourceNode.selected && + sourceNode.id !== targetNode.id && + !targetNode.childrenIds.includes(sourceNode.id) && + !targetNode.parentPath.includes(sourceNode.id), + ); + } else if (reason === 'clear') { + clear(); + } + }} + onInputChange={(event, value) => { + setSearch(value); + }} + onOpen={() => { + setAutoCompleteValue(getAggregateSelectedIds(flatTree, true)); + setTimeout(() => setOpen(true), 0); + }} + onClose={() => { + setOpen(false); + }} + /> + ); +} + +export default IabCategories; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability.tsx new file mode 100644 index 0000000..241f153 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { immediateAvailabilitySelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ImmediateAvailability({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const immediateAvailability = useAppSelector(immediateAvailabilitySelector); + + return ( + dispatch(setAction({ immediateAvailability: e.target.checked }))} + /> + ); +} + +export default ImmediateAvailability; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ImportCCFromMrssFile/ImportCCFromMrssFile.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ImportCCFromMrssFile/ImportCCFromMrssFile.tsx new file mode 100644 index 0000000..608514a --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ImportCCFromMrssFile/ImportCCFromMrssFile.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { importCCFromMrssFileSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ImportCCFromMrssFile({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const importCCFromMrssFile = useAppSelector(importCCFromMrssFileSelector); + + return ( + dispatch(setAction({ importCCFromMrssFile: e.target.checked }))} + /> + ); +} + +export default ImportCCFromMrssFile; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot.tsx new file mode 100644 index 0000000..82b107a --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { importPlotSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function UseForDownload({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const importPlot = useAppSelector(importPlotSelector); + + return ( + dispatch(setAction({ importPlot: e.target.checked }))} + /> + ); +} + +export default UseForDownload; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ImportShortsThumbnail/ImportShortsThumbnail.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ImportShortsThumbnail/ImportShortsThumbnail.tsx new file mode 100644 index 0000000..bf4dcfa --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ImportShortsThumbnail/ImportShortsThumbnail.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { importShortsThumbnailSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ImportShortsThumbnail({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const importShortsThumbnail = useAppSelector(importShortsThumbnailSelector); + + return ( + dispatch(setAction({ importShortsThumbnail: e.target.checked }))} + /> + ); +} + +export default ImportShortsThumbnail; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/JsRendering/JsRendering.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/JsRendering/JsRendering.tsx new file mode 100644 index 0000000..e52667e --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/JsRendering/JsRendering.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { jsRenderingSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function JsRendering({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const jsRendering = useAppSelector(jsRenderingSelector); + + return ( + dispatch(setAction({ jsRendering: e.target.checked }))} + /> + ); +} + +export default JsRendering; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Keywords/Keywords.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Keywords/Keywords.tsx new file mode 100644 index 0000000..ea30c8f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Keywords/Keywords.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { keywordsSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +const NAME_MAX_LENGTH = 1000; + +function Keywords({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const keywords = useAppSelector(keywordsSelector); + + return ( + dispatch(setAction({ keywords: e.target.value }))} + /> + ); +} + +export default Keywords; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Language/Language.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Language/Language.tsx new file mode 100644 index 0000000..a7d95e0 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Language/Language.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import setupGetConfigurationErrorCode, { + ConfigurationErrorOutputType, +} from '@/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/helpers/setupGetConfigurationErrorCode'; +import { langSelector, metadataLanguagesSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector, useAppStore } from '@/modules/@common/store/hooks'; +import ConfigurationErrorDialog from '@/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/ConfigurationErrorDialog'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Language({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const store = useAppStore(); + const { size } = useFormSettings(); + const [configurationError, setConfigurationError] = useState(null); + + const lang = useAppSelector(langSelector); + const options = useAppSelector(metadataLanguagesSelector) || []; + + const getConfigurationErrorCode = setupGetConfigurationErrorCode(store); + + return ( + <> + + {!!configurationError && ( + setConfigurationError(null)} + /> + )} + + ); +} + +export default Language; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/LoadFromLastDays/LoadFromLastDays.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/LoadFromLastDays/LoadFromLastDays.tsx new file mode 100644 index 0000000..d3ce95c --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/LoadFromLastDays/LoadFromLastDays.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { isNumber } from '@/modules/@common/helpers/number'; +import { loadFromLastDaysSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function LoadFromLastDays({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const loadFromLastDays = useAppSelector(loadFromLastDaysSelector); + + return ( + dispatch(removeErrorByPropAction(['loadFromLastDays']))} + onChange={(e) => { + const value = parseInt(e.target.value, 10); + dispatch(setAction({ loadFromLastDays: isNumber(value) ? value : ('' as unknown as number) })); + }} + /> + ); +} + +export default LoadFromLastDays; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration.tsx new file mode 100644 index 0000000..acebaf8 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { maxDurationSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function MaxDuration({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const maxDuration = useAppSelector(maxDurationSelector); + + return ( + { + dispatch(setAction({ maxDuration: parseInt(e.target.value, 10) || ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['maxDuration'])} + onFocus={() => dispatch(removeErrorByPropAction(['maxDuration']))} + label="" + /> + ); +} + +export default MaxDuration; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging.tsx new file mode 100644 index 0000000..f216509 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { maxDurationForTaggingSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function MaxDurationForTagging({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const maxDurationForTagging = useAppSelector(maxDurationForTaggingSelector); + + return ( + { + dispatch(setAction({ maxDurationForTagging: parseInt(e.target.value, 10) || ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['maxDurationForTagging'])} + onFocus={() => dispatch(removeErrorByPropAction(['maxDurationForTagging']))} + label="" + /> + ); +} + +export default MaxDurationForTagging; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/MaxStories/MaxStories.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/MaxStories/MaxStories.tsx new file mode 100644 index 0000000..66b1d08 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/MaxStories/MaxStories.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { maxStoriesSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function MaxStories({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const maxStories = useAppSelector(maxStoriesSelector); + + return ( + { + dispatch(setAction({ maxStories: parseInt(e.target.value, 10) || ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['maxStories'])} + onFocus={() => dispatch(removeErrorByPropAction(['maxStories']))} + /> + ); +} + +export default MaxStories; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate.tsx new file mode 100644 index 0000000..9946f88 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { isNumber } from '@/modules/@common/helpers/number'; +import { minBitrateSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function MinBitrate({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const minBitrate = useAppSelector(minBitrateSelector); + + return ( + { + const value = parseInt(e.target.value, 10); + dispatch(setAction({ minBitrate: isNumber(value) ? value : ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['minBitrate'])} + onFocus={() => dispatch(removeErrorByPropAction(['minBitrate']))} + label="" + /> + ); +} + +export default MinBitrate; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue.tsx new file mode 100644 index 0000000..fc49c45 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { isNumber } from '@/modules/@common/helpers/number'; +import { minResolutionValueSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function MinResolutionValue({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const minResolutionValue = useAppSelector(minResolutionValueSelector); + + return ( + { + const value = parseInt(e.target.value, 10); + dispatch(setAction({ minResolutionValue: isNumber(value) ? value : ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['minResolutionValue'])} + onFocus={() => dispatch(removeErrorByPropAction(['minResolutionValue']))} + label="" + /> + ); +} + +export default MinResolutionValue; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Name/Name.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Name/Name.tsx new file mode 100644 index 0000000..d0fb5a3 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Name/Name.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { nameSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +const MAX_LENGTH = 100; + +function Name({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const name = useAppSelector(nameSelector); + + return ( + dispatch(setAction({ name: e.target.value }))} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + ); +} + +export default Name; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ParseKeywordsToLabels/ParseKeywordsToLabels.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ParseKeywordsToLabels/ParseKeywordsToLabels.tsx new file mode 100644 index 0000000..cbbb45e --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ParseKeywordsToLabels/ParseKeywordsToLabels.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { parseKeywordsToLabelsSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function UseForDownload({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const parseKeywordsToLabels = useAppSelector(parseKeywordsToLabelsSelector); + + return ( + dispatch(setAction({ parseKeywordsToLabels: e.target.checked }))} + /> + ); +} + +export default UseForDownload; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Password/Password.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Password/Password.tsx new file mode 100644 index 0000000..3ae0a93 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Password/Password.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { passwordSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; + placeholder?: string; + label?: string; +}; + +const NAME_MAX_LENGTH = 255; + +function Password({ disabled = false, placeholder = 'Enter Password', label = '' }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const password = useAppSelector(passwordSelector); + + return ( + dispatch(setAction({ password: e.target.value }))} + onFocus={() => dispatch(removeErrorByPropAction(['password']))} + /> + ); +} + +export default Password; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification.tsx new file mode 100644 index 0000000..1d638e4 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { priorityVerificationSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function PriorityVerification({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const priorityVerification = useAppSelector(priorityVerificationSelector); + + return ( + dispatch(setAction({ priorityVerification: e.target.checked }))} + /> + ); +} + +export default PriorityVerification; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ProcessVideoVersions/ProcessVideoVersions.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ProcessVideoVersions/ProcessVideoVersions.tsx new file mode 100644 index 0000000..34386ed --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ProcessVideoVersions/ProcessVideoVersions.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { processVideoVersionsSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ProcessVideoVersions({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const processVideoVersions = useAppSelector(processVideoVersionsSelector); + + return ( + dispatch(setAction({ processVideoVersions: e.target.checked }))} + /> + ); +} + +export default ProcessVideoVersions; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Resolution/Resolution.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Resolution/Resolution.tsx new file mode 100644 index 0000000..daa25de --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Resolution/Resolution.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { RESOLUTION_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { filterOptionsByType } from '@/modules/feeds/Editor/helpers'; +import { resolutionSelector, typeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Resolution({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const resolution = useAppSelector(resolutionSelector); + const type = useAppSelector(typeSelector); + + if (!type) return null; + + return ( + + ); +} + +export default Resolution; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue.tsx new file mode 100644 index 0000000..9f2bd17 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { isNumber } from '@/modules/@common/helpers/number'; +import { resolutionValueSelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ResolutionValue({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const resolutionValue = useAppSelector(resolutionValueSelector); + + return ( + { + const value = parseInt(e.target.value, 10); + dispatch(setAction({ resolutionValue: isNumber(value) ? value : ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['resolutionValue'])} + onFocus={() => dispatch(removeErrorByPropAction(['resolutionValue']))} + label="" + /> + ); +} + +export default ResolutionValue; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Restricted/Restricted.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Restricted/Restricted.tsx new file mode 100644 index 0000000..ef479d1 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Restricted/Restricted.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { isRestrictedSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function Restricted({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const isRestricted = useAppSelector(isRestrictedSelector); + + return ( + dispatch(setAction({ isRestricted: e.target.checked }))} + /> + ); +} + +export default Restricted; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency.tsx new file mode 100644 index 0000000..d317c0f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { isNumber } from '@/modules/@common/helpers/number'; +import { scheduleFrequencySelector, schemeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ScheduleStartTime({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheme = useAppSelector(schemeSelector); + const scheduleFrequency = useAppSelector(scheduleFrequencySelector); + + return ( + { + const value = parseInt(e.target.value, 10); + dispatch(setAction({ scheduleFrequency: isNumber(value) ? value : ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['scheduleFrequency'])} + onFocus={() => dispatch(removeErrorByPropAction(['scheduleFrequency']))} + label="" + /> + ); +} + +export default ScheduleStartTime; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod.tsx new file mode 100644 index 0000000..91aedda --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { SCHEDULE_TYPE_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { scheduleTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function SchedulePeriod({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheduleType = useAppSelector(scheduleTypeSelector); + + return ( + + ); +} + +export default SchedulePeriod; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime.tsx new file mode 100644 index 0000000..5da95c8 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import dayjs from 'dayjs'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { scheduleValueSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Stack, TimePicker } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function ScheduleStartTime({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const settings = useFormSettings(); + const size = settings.size as GeneralSizeProp; + + const scheduleValue = useAppSelector(scheduleValueSelector); + + return ( + + { + const isValid = dayjs(triggerTime).isValid(); + const value = dayjs(triggerTime).format('HH:mm'); + dispatch( + setAction({ + scheduleValue: isValid ? value : '00:00', + }), + ); + }} + ampm={false} + views={['hours', 'minutes']} + format="HH:mm" + disabled={disabled} + /> + + ); +} + +export default ScheduleStartTime; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips.tsx new file mode 100644 index 0000000..f94b034 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { skipTaggingForLongClipsSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function SkipTaggingForLongClips({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + + return ( + dispatch(setAction({ skipTaggingForLongClips: e.target.checked }))} + /> + ); +} + +export default SkipTaggingForLongClips; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/SpeechToTextProvider/SpeechToTextProvider.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/SpeechToTextProvider/SpeechToTextProvider.tsx new file mode 100644 index 0000000..c636ed9 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/SpeechToTextProvider/SpeechToTextProvider.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; + +import { + SPEECH_TO_TEXT_OPTIONS, + SPEECH_TO_TEXT_VERBIT_MANUAL_24H_VALUE, + SPEECH_TO_TEXT_VERBIT_MANUAL_VALUE, +} from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import setupGetConfigurationErrorCode, { + ConfigurationErrorOutputType, +} from '@/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/helpers/setupGetConfigurationErrorCode'; +import { speechToTextProviderSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector, useAppStore } from '@/modules/@common/store/hooks'; +import ConfigurationErrorDialog from '@/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/ConfigurationErrorDialog'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function SpeechToTextProvider({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + const store = useAppStore(); + + const speechToTextProvider = useAppSelector(speechToTextProviderSelector); + + const [showExpensiveWarning, setShowExpensiveWarning] = useState(undefined); + const [configurationError, setConfigurationError] = useState(null); + + const getConfigurationErrorCode = setupGetConfigurationErrorCode(store); + + const handleOnChange = (e: { target: { value: unknown } }) => { + const value = e.target.value as typeof speechToTextProvider; + + const error = getConfigurationErrorCode({ inputSpeechToTextProvider: value }); + + if (error) { + setConfigurationError(error); + return; + } + + if ([SPEECH_TO_TEXT_VERBIT_MANUAL_VALUE, SPEECH_TO_TEXT_VERBIT_MANUAL_24H_VALUE].includes(value)) { + setShowExpensiveWarning(value); + } else { + dispatch(setAction({ speechToTextProvider: value })); + } + }; + + const handleCloseDialog = () => setShowExpensiveWarning(undefined); + const handleContinueDialog = () => { + if (showExpensiveWarning) { + dispatch(setAction({ speechToTextProvider: showExpensiveWarning })); + handleCloseDialog(); + } + }; + + return ( + <> + + {showExpensiveWarning && ( + + Warning + + {SPEECH_TO_TEXT_OPTIONS.find((option) => option.value === showExpensiveWarning)?.label} incurs a significant + cost, are you sure you want to continue? + + + + + + + )} + + {!!configurationError && ( + setConfigurationError(null)} + /> + )} + + ); +} + +export default SpeechToTextProvider; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Timezone/Timezone.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Timezone/Timezone.tsx new file mode 100644 index 0000000..2ab359f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Timezone/Timezone.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import type { MetadataFeedTimezoneType } from '@/modules/feeds/Editor/types'; +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { + defaultTimezoneSelector, + metadataTimezonesSelector, + schemeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Autocomplete, TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function TimezoneSelect({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const defaultTimezone = useAppSelector(defaultTimezoneSelector); + const options = useAppSelector(metadataTimezonesSelector); + const value = options?.find((timezone) => timezone.value === defaultTimezone); + + return ( + + dispatch( + setAction({ + defaultTimezone: (selected$ as MetadataFeedTimezoneType)?.value, + }), + ) + } + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['defaultTimezone']))} + /> + )} + /> + ); +} + +export default TimezoneSelect; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/Url/Url.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/Url/Url.tsx new file mode 100644 index 0000000..87fc327 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/Url/Url.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { schemeSelector, urlSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +const NAME_MAX_LENGTH = 255; + +function Url({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const url = useAppSelector(urlSelector); + + return ( + dispatch(setAction({ url: e.target.value }))} + onFocus={() => dispatch(removeErrorByPropAction(['url']))} + /> + ); +} + +export default Url; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload.tsx new file mode 100644 index 0000000..54b1e86 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { isUseForDownloadSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { Switch } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function UseForDownload({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const isUseForDownload = useAppSelector(isUseForDownloadSelector); + + return ( + dispatch(setAction({ isUseForDownload: e.target.checked }))} + /> + ); +} + +export default UseForDownload; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/User/User.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/User/User.tsx new file mode 100644 index 0000000..2a3883f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/User/User.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { schemeSelector, userSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; + placeholder?: string; + label?: string; +}; + +const NAME_MAX_LENGTH = 255; + +function User({ disabled = false, placeholder = 'Enter Username', label = '' }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const user = useAppSelector(userSelector); + + return ( + dispatch(setAction({ user: e.target.value }))} + onFocus={() => dispatch(removeErrorByPropAction(['user']))} + /> + ); +} + +export default User; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/VersionAttributeName/VersionAttributeName.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/VersionAttributeName/VersionAttributeName.tsx new file mode 100644 index 0000000..283f100 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/VersionAttributeName/VersionAttributeName.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { schemeSelector, versionAttributeNameSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +const MAX_LENGTH = 100; + +function VersionAttributeName({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const versionAttributeName = useAppSelector(versionAttributeNameSelector); + + return ( + dispatch(setAction({ versionAttributeName: e.target.value }))} + {...getInputPropsByName(scheme, ['versionAttributeName'])} + onFocus={() => dispatch(removeErrorByPropAction(['versionAttributeName']))} + label="" + /> + ); +} + +export default VersionAttributeName; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/VideoDuration/VideoDuration.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/VideoDuration/VideoDuration.tsx new file mode 100644 index 0000000..a762a38 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/VideoDuration/VideoDuration.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { schemeSelector, videoDurationSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function VideoDuration({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const videoDuration = useAppSelector(videoDurationSelector); + + return ( + { + dispatch(setAction({ videoDuration: parseInt(e.target.value, 10) || ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['videoDuration'])} + onFocus={() => dispatch(removeErrorByPropAction(['videoDuration']))} + /> + ); +} + +export default VideoDuration; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType.tsx new file mode 100644 index 0000000..52049be --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { VIDEO_FILE_TYPE_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { videoFileTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function VideoFileType({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const videoFileType = useAppSelector(videoFileTypeSelector); + + return ( + + ); +} + +export default VideoFileType; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/VideoMaxZoom/VideoMaxZoom.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/VideoMaxZoom/VideoMaxZoom.tsx new file mode 100644 index 0000000..d9fbf32 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/VideoMaxZoom/VideoMaxZoom.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { schemeSelector, videoMaxZoomSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { NumberField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function VideoMaxZoom({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const videoMaxZoom = useAppSelector(videoMaxZoomSelector); + + return ( + { + dispatch(setAction({ videoMaxZoom: parseInt(e.target.value, 10) || ('' as unknown as number) })); + }} + {...getInputPropsByName(scheme, ['videoMaxZoom'])} + onFocus={() => dispatch(removeErrorByPropAction(['videoMaxZoom']))} + /> + ); +} + +export default VideoMaxZoom; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeChannelId/YouTubeChannelId.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeChannelId/YouTubeChannelId.tsx new file mode 100644 index 0000000..4b74c85 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeChannelId/YouTubeChannelId.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { schemeSelector, youTubeChannelIdSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { TextField } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; + placeholder?: string; +}; + +const MAX_LENGTH = 200; + +function YouTubeChannelId({ disabled = false, placeholder = 'Enter Youtube Channel ID / Playlist ID' }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const scheme = useAppSelector(schemeSelector); + const youTubeChannelId = useAppSelector(youTubeChannelIdSelector); + + return ( + dispatch(setAction({ youTubeChannelId: e.target.value }))} + {...getInputPropsByName(scheme, ['youTubeChannelId'])} + onFocus={() => dispatch(removeErrorByPropAction(['youTubeChannelId']))} + /> + ); +} + +export default YouTubeChannelId; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeLoadFromDate/YouTubeLoadFromDate.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeLoadFromDate/YouTubeLoadFromDate.tsx new file mode 100644 index 0000000..1364510 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/YouTubeLoadFromDate/YouTubeLoadFromDate.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import dayjs from 'dayjs'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { youTubeLoadFromDateSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { DateTimePicker } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function YouTubeLoadFromDate({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const youTubeLoadFromDate = useAppSelector(youTubeLoadFromDateSelector); + + return ( + dispatch(setAction({ youTubeLoadFromDate: dayjs(date).format('YYYY-MM-DD') }))} + /> + ); +} + +export default YouTubeLoadFromDate; diff --git a/anyclip/src/modules/feeds/Editor/components/FormElements/YoutubeContentType/YoutubeContentType.tsx b/anyclip/src/modules/feeds/Editor/components/FormElements/YoutubeContentType/YoutubeContentType.tsx new file mode 100644 index 0000000..5c20f89 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormElements/YoutubeContentType/YoutubeContentType.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { YT_CONTENT_TYPE_OPTIONS } from '@/modules/feeds/Editor/constants'; + +import type { GeneralSizeProp } from '@/mui/types'; + +import { youtubeContentTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useFormSettings } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import { MenuItem, Select } from '@/mui/components'; + +type PropTypes = { + disabled?: boolean; +}; + +function YoutubeContentType({ disabled = false }: PropTypes) { + const dispatch = useAppDispatch(); + const { size } = useFormSettings(); + + const youtubeContentType = useAppSelector(youtubeContentTypeSelector); + + return ( + + ); +} + +export default YoutubeContentType; diff --git a/anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/ModelTab.tsx b/anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/ModelTab.tsx new file mode 100644 index 0000000..e32bfd9 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/ModelTab.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { CheckOutlined, EditOutlined, InfoOutlined } from '@mui/icons-material'; + +import { + ConvertedModel, + createDefaultPlatformModels, + createModelsListFromMetadataPlatform, + getModelConfig, + getModelEnabled, +} from './helpers'; +import setupGetConfigurationErrorCode, { + ConfigurationErrorOutputType, +} from '@/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/helpers/setupGetConfigurationErrorCode'; +import { + metadataPlatformModelsSelector, + platformModelsSelector, + typeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector, useAppStore } from '@/modules/@common/store/hooks'; +import { TableCellActions } from '@/modules/@common/Table'; +import ConfigurationErrorDialog from '@/modules/feeds/Editor/components/Dialogs/ConfigurationErrorDialog/ConfigurationErrorDialog'; +import ModelEditDialog from '@/modules/feeds/Editor/components/Dialogs/ModelEditDialog/ModelEditDialog'; +import SpeechToTextProvider from '@/modules/feeds/Editor/components/FormElements/SpeechToTextProvider/SpeechToTextProvider'; +import { + IconButton, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@/mui/components'; + +function ModelTab() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const store = useAppStore(); + const metadataPlatform = useAppSelector(metadataPlatformModelsSelector); + const type = useAppSelector(typeSelector); + + const [editModel, setEditModel] = useState(null); + const [configurationError, setConfigurationError] = useState(null); + + const platformModels = useAppSelector(platformModelsSelector); + + const models = useMemo( + () => createModelsListFromMetadataPlatform(metadataPlatform, type as Exclude<'', typeof type>), + [metadataPlatform, type], + ); + + const isCreateNewForm = (router.query.id as string) === 'new'; + + const getConfigurationErrorCode = setupGetConfigurationErrorCode(store); + + const handleChangeModelEnabled = (model: string) => { + // LABELS should auto-enable if: + // 1) user toggles a classification model, or + // 2) user toggles LABELS while any classification model is already enabled + const CLASSIFICATION = ['KEYWORDS_CLASSIFICATION', 'IAB_CLASSIFICATION']; + const hasEnabledClassification = platformModels.some((m) => CLASSIFICATION.includes(m.model) && m.enabled); + const shouldEnableLabelsModel = CLASSIFICATION.includes(model) || (model === 'LABELS' && hasEnabledClassification); + + const changedPlatformModels = platformModels.map((m) => { + if (m.model === 'LABELS' && shouldEnableLabelsModel) { + // ensure LABELS is enabled + return m.enabled ? m : { ...m, enabled: true }; + } + + if (m.model === model) { + // toggle the clicked model + return { ...m, enabled: !m.enabled }; + } + + return m; + }); + + const error = getConfigurationErrorCode({ inputModels: changedPlatformModels }); + + if (error) { + setConfigurationError(error); + return; + } + + dispatch( + setAction({ + platformModels: changedPlatformModels, + }), + ); + }; + + const handleChangeModelConfig = (model: string, config: string) => { + const changedPlatformModels = platformModels.map((entity) => + entity.model === model && config ? { ...entity, config } : entity, + ); + + dispatch( + setAction({ + platformModels: changedPlatformModels, + }), + ); + }; + + useEffect(() => { + if (models.length !== platformModels.length) { + const defaultPlatformModels = createDefaultPlatformModels(models, platformModels, isCreateNewForm); + + dispatch( + setAction({ + platformModels: defaultPlatformModels, + }), + ); + } + }, [platformModels, models, isCreateNewForm]); + + return ( + <> + + + + Models + + + + + Category + Platform + Model + Enabled + Configuration + + + + + {models.map((model) => ( + + + + {model.category} + + + + + {model.platform} + + + + + + {model.model} + {model.description && ( + + + + )} + + + + + + handleChangeModelEnabled(model.model)} + /> + + + + {getModelConfig(model.model, platformModels) !== '{}' ? ( + + ) : ( + + - + + )} + + + + { + e.stopPropagation(); + setEditModel(model); + }} + > + + + + + + ))} + +
    +
    + {editModel && ( + setEditModel(null)} + onSave={(config) => handleChangeModelConfig(editModel?.model, config)} + /> + )} + {!!configurationError && ( + setConfigurationError(null)} + /> + )} + + ); +} + +export default ModelTab; diff --git a/anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/helpers/index.ts b/anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/helpers/index.ts new file mode 100644 index 0000000..19275f6 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormTabs/ModelTab/helpers/index.ts @@ -0,0 +1,160 @@ +import { TYPE_MS_STREAM, TYPE_SHAREPOINT, TYPE_TEAMS, TYPE_ZOOM } from '@/modules/feeds/constants'; +import { MEETINGS_SOURCES_ENABLED_BY_DEFAULT, SOURCES_DISABLED_BY_DEFAULT } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +export type ConvertedModel = { + id: string; + displayName: string; + features: string[]; + platform: string; + model: string; + category: string; + enabled: boolean; + description: string; + parameters: string[]; + example: string; +}; + +export const uidGenerator = (): string => Math.random().toString(36).split('.').pop() ?? ''; + +const compareCategories = (a: string, b: string): number => { + const a1 = a === '' ? 'ZZZZ' : a; + const b1 = b === '' ? 'ZZZZ' : b; + return a1.localeCompare(b1); +}; + +const sortArrayByFields = ( + array: ConvertedModel[], + primaryField: 'category', + secondaryField: 'displayName', +): ConvertedModel[] => + array.sort((a, b) => { + if (a[primaryField] !== b[primaryField]) { + return compareCategories(a[primaryField], b[primaryField]); + } + return a[secondaryField].localeCompare(b[secondaryField]); + }); + +const sortPlatformModels = (platformModels: ConvertedModel[]): ConvertedModel[] => + sortArrayByFields(platformModels, 'category', 'displayName'); + +const parseModelDescription = (fullDescription: string): string => { + const mainPart = fullDescription?.split('Parameters:')[0]; + if (!mainPart) return ''; + const colonIndex = mainPart.indexOf(':'); + return colonIndex !== -1 ? mainPart.substring(colonIndex + 1).trim() : mainPart.trim(); +}; + +const parseModelParameters = (fullDescription: string): string[] => { + const paramsPart = fullDescription?.split('Parameters:')[1]?.split('Example:')[0]?.trim() ?? ''; + if (!paramsPart) return []; + + return paramsPart + .split(';') + .map((param) => param.trim()) + .filter((param) => param.length > 0); +}; + +const parseModelExample = (fullDescription: string): string => { + const examplePart = fullDescription?.split('Example:')[1]?.trim(); + if (!examplePart) return ''; + + const validJsonPart = examplePart.endsWith('}') + ? examplePart + : examplePart.substring(0, examplePart.lastIndexOf('}') + 1); + + try { + const parsed = JSON.parse(validJsonPart); + return JSON.stringify(parsed, null, 2); + } catch { + return ''; + } +}; + +// logic transfer from old isEnabledPlatformModel function +// const isEnabledPlatformModel = (provider, model, type) => { +// const isMeetingSource = [ +// CONSTANTS.FEEDS.TYPES.TEAMS.value, +// CONSTANTS.FEEDS.TYPES.SHAREPOINT.value, +// CONSTANTS.FEEDS.TYPES.ZOOM.value, CONSTANTS.FEEDS.TYPES.MS_STREAM.value].includes(type); +// let isEnabled; +// if (isMeetingSource) { +// isEnabled = !!(CONSTANTS.FEEDS.PLATFORMS.DEFAULTS.MEETINGS_SOURCE.ENABLED_BY_DEFAULT +// .filter((m) => m.model === model.name && m.provider === provider.provider) +// .length); +// } else { +// isEnabled = !CONSTANTS.FEEDS.PLATFORMS.DEFAULTS.FEED.DISABLED_BY_DEFAULT +// .filter((m) => m.model === model.name && m.provider === provider.provider) +// .length; +// } +// return isEnabled; +// }; +function setDefault(model: { name: string }, provider: { provider: string }, type: FeedType) { + if (!type) return false; + + const isMeetingSource = [TYPE_ZOOM, TYPE_SHAREPOINT, TYPE_TEAMS, TYPE_MS_STREAM].includes(type); + + let isEnabled; + if (isMeetingSource) { + isEnabled = !!MEETINGS_SOURCES_ENABLED_BY_DEFAULT.filter( + (m) => m.model === model.name && m.provider === provider.provider, + ).length; + } else { + isEnabled = !SOURCES_DISABLED_BY_DEFAULT.filter((m) => m.model === model.name && m.provider === provider.provider) + .length; + } + return isEnabled; +} + +export const createModelsListFromMetadataPlatform = ( + platforms: MetadataFeedPlatformModelType[] | null, + type: FeedType, +): ConvertedModel[] => { + if (!platforms) return []; + + const converted: ConvertedModel[] = platforms.flatMap((provider) => + provider.models.map((model) => ({ + id: uidGenerator(), + displayName: provider.providerDisplayName.toUpperCase(), + features: model.features, + platform: provider.provider, + model: model.name, + category: model.category || '', + enabled: setDefault(model, provider, type), + description: parseModelDescription(model.description), + parameters: parseModelParameters(model.description), + example: parseModelExample(model.description), + })), + ); + + return sortPlatformModels(converted); +}; + +export const getModelEnabled = (model: string, platformModels: PlatformModel[]): boolean => + platformModels.find((platformModel) => platformModel.model === model)?.enabled ?? false; + +export const getModelConfig = (model: string, platformModels: PlatformModel[]): string => + platformModels.find((platformModel) => platformModel.model === model)?.config || '{}'; + +export const createDefaultPlatformModels = ( + models: ConvertedModel[], + platformModels: PlatformModel[], + isCreateNewForm: boolean, +): PlatformModel[] => + models.map((model) => ({ + config: !isCreateNewForm ? getModelConfig(model.model, platformModels) : '{}', + enabled: !isCreateNewForm ? getModelEnabled(model.model, platformModels) : model.enabled, + model: model.model, + platform: model.platform, + })); + +export const getDefaultPlatformModelsWhenCreate = ( + metadataPlatform: MetadataFeedPlatformModelType[], + type: FeedType, +) => { + const models = createModelsListFromMetadataPlatform(metadataPlatform, type); + const defaultPlatformModels = createDefaultPlatformModels(models, [], true); + return defaultPlatformModels; +}; diff --git a/anyclip/src/modules/feeds/Editor/components/FormTabs/ScriptTab/ScriptTab.tsx b/anyclip/src/modules/feeds/Editor/components/FormTabs/ScriptTab/ScriptTab.tsx new file mode 100644 index 0000000..3469b34 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormTabs/ScriptTab/ScriptTab.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { FormGroupTitle } from '@/modules/@common/Form'; +import AutomationScript from '@/modules/feeds/Editor/components/FormElements/AutomationScript/AutomationScript'; +import { Link, Stack } from '@/mui/components'; + +function ScriptTab() { + return ( + <> + + + Video Targeting Automation Script | + + How-To + + + + + + ); +} + +export default ScriptTab; diff --git a/anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.module.scss b/anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.module.scss new file mode 100644 index 0000000..cb46b74 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"FormWrapper_Wrapper__KrJ_p","Title":"FormWrapper_Title__GJHWT","Controls":"FormWrapper_Controls__j4Nu4","Tabs":"FormWrapper_Tabs__3Ioa9"}; \ No newline at end of file diff --git a/anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.tsx b/anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.tsx new file mode 100644 index 0000000..9df7a3c --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/FormWrapper/FormWrapper.tsx @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +import { FeedType } from '@/modules/feeds/types'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + getMetadataAction, + setAction, + setActiveTabIdAction, + setInitialAction, +} from '@/modules/feeds/Editor/redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import useSetStoreDefaultValues from '@/modules/feeds/Editor/hooks/useSetStoreDefaultValues'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './FormWrapper.module.scss'; + +type FormWrapperPropsType = { + type: FeedType; + sourceTitle: string; + tabs: { + title: string; + id: string; + content: React.FC; + }[]; + disabled?: boolean; + saveToServerForm: () => void; +}; + +// @todo Remove after move from js to ts FormSection +const FormSectionTyped: React.ForwardRefExoticComponent & React.RefAttributes> = + FormSection; + +function FormWrapper({ type, sourceTitle, tabs, saveToServerForm, disabled = false }: FormWrapperPropsType) { + useSetStoreDefaultValues(type); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const name = useAppSelector(selectors.nameSelector); + const displayName = useAppSelector(selectors.descriptionSelector); + + const id = parseInt(router.query.id as string, 10); + + useEffect(() => { + dispatch(getMetadataAction({ id })); + dispatch(setAction({ type })); + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + return ( +
    + + + {id ? `Source ${sourceTitle} > ${name || displayName} > Settings` : `New ${sourceTitle}`} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default FormWrapper; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Csv.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Csv.tsx new file mode 100644 index 0000000..90ad2be --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Csv.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_CSV } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function Csv() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + const status = useAppSelector(selectors.statusSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + show: true, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + show: true, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + show: true, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + show: true, + }, + ].filter((tab) => tab.show); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default Csv; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..dd4a79f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { + discardLongClipsSelector, + fileSelectionSelector, + skipTaggingForLongClipsSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import DiscardLongClips from '@/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import FeedPriority from '@/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import ImportPlot from '@/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot'; +import Keywords from '@/modules/feeds/Editor/components/FormElements/Keywords/Keywords'; +import MaxDuration from '@/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import Restricted from '@/modules/feeds/Editor/components/FormElements/Restricted/Restricted'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + const discardLongClips = useAppSelector(discardLongClipsSelector); + + return ( + <> + + + + {discardLongClips && ( + + + + )} + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..10bbc7a --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Csv/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { + accessLevelSelector, + accountSelector, + defaultTimezoneEnabledSelector, + scheduleTypeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AuthMethod from '@/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DefaultTimeZone from '@/modules/feeds/Editor/components/FormElements/DefaultTimeZone/DefaultTimeZone'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import IabCategories from '@/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import Timezone from '@/modules/feeds/Editor/components/FormElements/Timezone/Timezone'; +import Url from '@/modules/feeds/Editor/components/FormElements/Url/Url'; +import UseForDownload from '@/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + const defaultTimezoneEnabled = useAppSelector(defaultTimezoneEnabledSelector); + const account = useAppSelector(accountSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + Schedule + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + + + {defaultTimezoneEnabled && ( + + + + )} + + Authentication + + + + + + + + + + + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Manual.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Manual.tsx new file mode 100644 index 0000000..77803b4 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Manual.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_MANUAL } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; + +function Manual() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const status = useAppSelector(selectors.statusSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + }, + ]; + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( + + ); +} + +export default Manual; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..258e046 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { skipTaggingForLongClipsSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import FeedPriority from '@/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import Keywords from '@/modules/feeds/Editor/components/FormElements/Keywords/Keywords'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..884f9f3 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Manual/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { accessLevelSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import IabCategories from '@/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Mrss.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Mrss.tsx new file mode 100644 index 0000000..f960438 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Mrss.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_MRSS } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import useSetIsSelfServeUser from '@/modules/feeds/Editor/hooks/useSetIsSelfServeUser'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function Mrss() { + useSetIsSelfServeUser(); + + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const isSelfServeUser = useAppSelector(selectors.isSelfServeUserSelector); + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + const status = useAppSelector(selectors.statusSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + show: true, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + show: !isSelfServeUser, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + show: !isSelfServeUser, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + show: !isSelfServeUser, + }, + ].filter((tab) => tab.show); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default Mrss; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..208497d --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,137 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { + fileSelectionSelector, + processVideoVersionsSelector, + skipTaggingForLongClipsSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import FeedPriority from '@/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import ImportCCFromMrssFile from '@/modules/feeds/Editor/components/FormElements/ImportCCFromMrssFile/ImportCCFromMrssFile'; +import ImportPlot from '@/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot'; +import Keywords from '@/modules/feeds/Editor/components/FormElements/Keywords/Keywords'; +import MaxDuration from '@/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import ParseKeywordsToLabels from '@/modules/feeds/Editor/components/FormElements/ParseKeywordsToLabels/ParseKeywordsToLabels'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import ProcessVideoVersions from '@/modules/feeds/Editor/components/FormElements/ProcessVideoVersions/ProcessVideoVersions'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import Restricted from '@/modules/feeds/Editor/components/FormElements/Restricted/Restricted'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VersionAttributeName from '@/modules/feeds/Editor/components/FormElements/VersionAttributeName/VersionAttributeName'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + const processVideoVersions = useAppSelector(processVideoVersionsSelector); + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + return ( + <> + + + + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + + + + + + + + + + + + + + + + + + + + + {processVideoVersions && ( + + + + )} + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..975e707 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Mrss/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { + accessLevelSelector, + accountSelector, + defaultTimezoneEnabledSelector, + isSelfServeUserSelector, + scheduleTypeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AuthMethod from '@/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DefaultTimeZone from '@/modules/feeds/Editor/components/FormElements/DefaultTimeZone/DefaultTimeZone'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import IabCategories from '@/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories'; +import ImportCCFromMrssFile from '@/modules/feeds/Editor/components/FormElements/ImportCCFromMrssFile/ImportCCFromMrssFile'; +import ImportPlot from '@/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot'; +import Keywords from '@/modules/feeds/Editor/components/FormElements/Keywords/Keywords'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import Timezone from '@/modules/feeds/Editor/components/FormElements/Timezone/Timezone'; +import Url from '@/modules/feeds/Editor/components/FormElements/Url/Url'; +import UseForDownload from '@/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + const defaultTimezoneEnabled = useAppSelector(defaultTimezoneEnabledSelector); + const account = useAppSelector(accountSelector); + + const isSelfServeUser = useAppSelector(isSelfServeUserSelector); + + return ( + <> + Settings + {!isSelfServeUser && ( + + + + )} + + {!isSelfServeUser && ( + + + + )} + + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + {isSelfServeUser && ( + <> + + + + + + + + + + + + + + )} + + Schedule + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + + + {defaultTimezoneEnabled && ( + + + + )} + + Authentication + + + + + + + + + + + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/MsStream.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/MsStream.tsx new file mode 100644 index 0000000..447f74f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/MsStream.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_MS_STREAM } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import useSetIsSelfServeUser from '@/modules/feeds/Editor/hooks/useSetIsSelfServeUser'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function MsStream() { + useSetIsSelfServeUser(); + + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const isSelfServeUser = useAppSelector(selectors.isSelfServeUserSelector); + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + const status = useAppSelector(selectors.statusSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + show: true, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + show: !isSelfServeUser, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + show: !isSelfServeUser, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + show: !isSelfServeUser, + }, + ].filter((tab) => tab.show); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default MsStream; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..677925c --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { discardLongClipsSelector, skipTaggingForLongClipsSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import DiscardLongClips from '@/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import ImportPlot from '@/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot'; +import MaxDuration from '@/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import Restricted from '@/modules/feeds/Editor/components/FormElements/Restricted/Restricted'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const discardLongClips = useAppSelector(discardLongClipsSelector); + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + return ( + <> + + + + {discardLongClips && ( + + + + )} + + + + {skipTaggingForLongClips && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..b865481 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/MsStream/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { + accessLevelSelector, + accountSelector, + isSelfServeUserSelector, + scheduleTypeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; +import YouTubeChannelId from '@/modules/feeds/Editor/components/FormElements/YouTubeChannelId/YouTubeChannelId'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const isSelfServeUser = useAppSelector(isSelfServeUserSelector); + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + const account = useAppSelector(accountSelector); + + return ( + <> + Settings + {!isSelfServeUser && ( + + + + )} + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + + + + {!isSelfServeUser && ( + <> + Schedule + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + )} + + Authentication + + + + + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Rss.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Rss.tsx new file mode 100644 index 0000000..a43753e --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Rss.tsx @@ -0,0 +1,127 @@ +import React, { useEffect } from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TYPE_RSS } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + getMetadataAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function Rss() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + + const id = parseInt(router.query.id as string, 10); + + useEffect(() => { + dispatch(getMetadataAction({ id })); + dispatch(setAction({ type: TYPE_RSS })); + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + }, + ].filter(Boolean); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default Rss; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..ba9baaa --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,83 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { fileSelectionSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AspectRatio from '@/modules/feeds/Editor/components/FormElements/AspectRatio/AspectRatio'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import Fit from '@/modules/feeds/Editor/components/FormElements/Fit/Fit'; +import MaxStories from '@/modules/feeds/Editor/components/FormElements/MaxStories/MaxStories'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import VideoDuration from '@/modules/feeds/Editor/components/FormElements/VideoDuration/VideoDuration'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; +import VideoMaxZoom from '@/modules/feeds/Editor/components/FormElements/VideoMaxZoom/VideoMaxZoom'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + return ( + <> + + + + + + + + + + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..6fcb290 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Rss/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { accessLevelSelector, scheduleTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AuthMethod from '@/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import JsRendering from '@/modules/feeds/Editor/components/FormElements/JsRendering/JsRendering'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import Url from '@/modules/feeds/Editor/components/FormElements/Url/Url'; +import UseForDownload from '@/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + Auth + + + + + + + + + + + + + + + + + + + + Schedule Settings + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Sitemap.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Sitemap.tsx new file mode 100644 index 0000000..c8fb7e8 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Sitemap.tsx @@ -0,0 +1,127 @@ +import React, { useEffect } from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TYPE_SITEMAP } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + getMetadataAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function Sitemap() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + + const id = parseInt(router.query.id as string, 10); + + useEffect(() => { + dispatch(getMetadataAction({ id })); + dispatch(setAction({ type: TYPE_SITEMAP })); + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + }, + ].filter(Boolean); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default Sitemap; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..ba9baaa --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,83 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { fileSelectionSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AspectRatio from '@/modules/feeds/Editor/components/FormElements/AspectRatio/AspectRatio'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import Fit from '@/modules/feeds/Editor/components/FormElements/Fit/Fit'; +import MaxStories from '@/modules/feeds/Editor/components/FormElements/MaxStories/MaxStories'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import VideoDuration from '@/modules/feeds/Editor/components/FormElements/VideoDuration/VideoDuration'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; +import VideoMaxZoom from '@/modules/feeds/Editor/components/FormElements/VideoMaxZoom/VideoMaxZoom'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + return ( + <> + + + + + + + + + + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..6fcb290 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Sitemap/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { accessLevelSelector, scheduleTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AuthMethod from '@/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import JsRendering from '@/modules/feeds/Editor/components/FormElements/JsRendering/JsRendering'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import Url from '@/modules/feeds/Editor/components/FormElements/Url/Url'; +import UseForDownload from '@/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + Auth + + + + + + + + + + + + + + + + + + + + Schedule Settings + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/StoryApi.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/StoryApi.tsx new file mode 100644 index 0000000..7bcba2f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/StoryApi.tsx @@ -0,0 +1,127 @@ +import React, { useEffect } from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TYPE_STORY_API } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + getMetadataAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function StoryApi() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + + const id = parseInt(router.query.id as string, 10); + + useEffect(() => { + dispatch(getMetadataAction({ id })); + dispatch(setAction({ type: TYPE_STORY_API })); + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + }, + ].filter(Boolean); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default StoryApi; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..a46dfb6 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { fileSelectionSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AspectRatio from '@/modules/feeds/Editor/components/FormElements/AspectRatio/AspectRatio'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import Fit from '@/modules/feeds/Editor/components/FormElements/Fit/Fit'; +import MaxStories from '@/modules/feeds/Editor/components/FormElements/MaxStories/MaxStories'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import VideoDuration from '@/modules/feeds/Editor/components/FormElements/VideoDuration/VideoDuration'; +import VideoMaxZoom from '@/modules/feeds/Editor/components/FormElements/VideoMaxZoom/VideoMaxZoom'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + return ( + <> + + + + + + + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..a92360b --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/StoryApi/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { accessLevelSelector, scheduleTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AuthMethod from '@/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import Url from '@/modules/feeds/Editor/components/FormElements/Url/Url'; +import UseForDownload from '@/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + Auth + + + + + + + + + + + + + + + + + Schedule Settings + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Auth/Auth.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Auth/Auth.tsx new file mode 100644 index 0000000..ff2425d --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Auth/Auth.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +import { + OAUTH_RESPONSE_WITH_CODE, + OAUTH_RESPONSE_WITH_ERROR, +} from '@/modules/feeds/Editor/components/FormElements/Authorization/constants'; + +function TiktokAuth() { + const router = useRouter(); + const { clientId, code, error } = router.query; + + useEffect(() => { + if (clientId) { + router.replace({ + pathname: 'https://www.tiktok.com/v2/auth/authorize', + query: { + client_key: clientId, + redirect_uri: `${window.location.origin}/auth/tiktok`, + //redirect_uri: `https://tt.test/auth/tiktok`, + response_type: 'code', + scope: 'user.info.basic,video.list', + }, + }); + } + }, [clientId]); + + useEffect(() => { + if (code || error) { + const payload = code || error; + const type = code ? OAUTH_RESPONSE_WITH_CODE : OAUTH_RESPONSE_WITH_ERROR; + window.opener.postMessage({ type, payload }, '*'); + window.close(); + } + }, [code, error]); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + +export default TiktokAuth; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..150eec4 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { + discardLongClipsSelector, + isConnectedSelector, + skipTaggingForLongClipsSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import DiscardLongClips from '@/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import FillLandingPage from '@/modules/feeds/Editor/components/FormElements/FillLandingPage/FillLandingPage'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import MaxDuration from '@/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import Restricted from '@/modules/feeds/Editor/components/FormElements/Restricted/Restricted'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + const discardLongClips = useAppSelector(discardLongClipsSelector); + const isConnected = useAppSelector(isConnectedSelector); + + return ( + <> + + + + + + + + + + {discardLongClips && ( + + + + )} + + + + {skipTaggingForLongClips && ( + + + + )} + + + + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..1f4a6d9 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { + accessLevelSelector, + isConnectedSelector, + isSelfServeUserSelector, + scheduleTypeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import Authorization from '@/modules/feeds/Editor/components/FormElements/Authorization/Authorization'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import LoadFromLastDays from '@/modules/feeds/Editor/components/FormElements/LoadFromLastDays/LoadFromLastDays'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + const isConnected = useAppSelector(isConnectedSelector); + + const isSelfServeUser = useAppSelector(isSelfServeUserSelector); + + return ( + <> + + + + Settings + {!isSelfServeUser && ( + + + + )} + + {!isSelfServeUser && ( + + + + )} + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + Schedule + + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tiktok.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tiktok.tsx new file mode 100644 index 0000000..a0d9b94 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Tiktok/Tiktok.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_TIKTOK } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import useSetIsSelfServeUser from '@/modules/feeds/Editor/hooks/useSetIsSelfServeUser'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function Tiktok() { + useSetIsSelfServeUser(); + + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + const isConnected = useAppSelector(selectors.isConnectedSelector); + const status = useAppSelector(selectors.statusSelector); + + const isSelfServeUser = useAppSelector(selectors.isSelfServeUserSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + show: true, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + show: !isSelfServeUser, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + show: !isSelfServeUser, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + show: !isSelfServeUser, + }, + ].filter((tab) => tab.show); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default Tiktok; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..dd4a79f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { + discardLongClipsSelector, + fileSelectionSelector, + skipTaggingForLongClipsSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import DiscardLongClips from '@/modules/feeds/Editor/components/FormElements/DiscardLongClips/DiscardLongClips'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import FeedPriority from '@/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import ImportPlot from '@/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot'; +import Keywords from '@/modules/feeds/Editor/components/FormElements/Keywords/Keywords'; +import MaxDuration from '@/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import Restricted from '@/modules/feeds/Editor/components/FormElements/Restricted/Restricted'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + const discardLongClips = useAppSelector(discardLongClipsSelector); + + return ( + <> + + + + {discardLongClips && ( + + + + )} + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..165815d --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { accessLevelSelector, accountSelector, scheduleTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AuthMethod from '@/modules/feeds/Editor/components/FormElements/AuthMethod/AuthMethod'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import IabCategories from '@/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import Url from '@/modules/feeds/Editor/components/FormElements/Url/Url'; +import UseForDownload from '@/modules/feeds/Editor/components/FormElements/UseForDownload/UseForDownload'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + const account = useAppSelector(accountSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + Schedule + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + Authentication + + + + + + + + + + + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/VideoApi.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/VideoApi.tsx new file mode 100644 index 0000000..8a1ba57 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/VideoApi/VideoApi.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_VIDEO_API } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function VideoApi() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + const status = useAppSelector(selectors.statusSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + show: true, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + show: true, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + show: true, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + show: true, + }, + ].filter((tab) => tab.show); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default VideoApi; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..8253b95 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,112 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { fileSelectionSelector, skipTaggingForLongClipsSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import FeedPriority from '@/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import ImportPlot from '@/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot'; +import Keywords from '@/modules/feeds/Editor/components/FormElements/Keywords/Keywords'; +import MaxDuration from '@/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import Restricted from '@/modules/feeds/Editor/components/FormElements/Restricted/Restricted'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + + return ( + <> + + + + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..af4bcd4 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE, SCHEDULE_TYPE_CUSTOM } from '@/modules/feeds/Editor/constants'; + +import { accessLevelSelector, accountSelector, scheduleTypeSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import IabCategories from '@/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import LoadFromLastDays from '@/modules/feeds/Editor/components/FormElements/LoadFromLastDays/LoadFromLastDays'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import Password from '@/modules/feeds/Editor/components/FormElements/Password/Password'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import User from '@/modules/feeds/Editor/components/FormElements/User/User'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + const account = useAppSelector(accountSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + Schedule + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + Authentication + + + + + + + + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Vimeo.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Vimeo.tsx new file mode 100644 index 0000000..078a984 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Vimeo/Vimeo.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_VIMEO } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function Vimeo() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + const status = useAppSelector(selectors.statusSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + show: true, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + show: true, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + show: true, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + show: true, + }, + ].filter((tab) => tab.show); + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default Vimeo; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/AdvancedTab/AdvancedTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/AdvancedTab/AdvancedTab.tsx new file mode 100644 index 0000000..24b67d5 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/AdvancedTab/AdvancedTab.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import { + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, +} from '@/modules/feeds/Editor/constants'; + +import { fileSelectionSelector, skipTaggingForLongClipsSelector } from '@/modules/feeds/Editor/redux/selectors'; + +import { FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import Bitrate from '@/modules/feeds/Editor/components/FormElements/Bitrate/Bitrate'; +import CreateClipSelector from '@/modules/feeds/Editor/components/FormElements/CreateClipSelector/CreateClipSelector'; +import Evergreen from '@/modules/feeds/Editor/components/FormElements/Evergreen/Evergreen'; +import FeedPriority from '@/modules/feeds/Editor/components/FormElements/FeedPriority/FeedPriority'; +import FileSelection from '@/modules/feeds/Editor/components/FormElements/FileSelection/FileSelection'; +import ImmediateAvailability from '@/modules/feeds/Editor/components/FormElements/ImmediateAvailability/ImmediateAvailability'; +import ImportPlot from '@/modules/feeds/Editor/components/FormElements/ImportPlot/ImportPlot'; +import Keywords from '@/modules/feeds/Editor/components/FormElements/Keywords/Keywords'; +import MaxDuration from '@/modules/feeds/Editor/components/FormElements/MaxDuration/MaxDuration'; +import MaxDurationForTagging from '@/modules/feeds/Editor/components/FormElements/MaxDurationForTagging/MaxDurationForTagging'; +import MinBitrate from '@/modules/feeds/Editor/components/FormElements/MinBitrate/MinBitrate'; +import MinResolutionValue from '@/modules/feeds/Editor/components/FormElements/MinResolutionValue/MinResolutionValue'; +import PriorityVerification from '@/modules/feeds/Editor/components/FormElements/PriorityVerification/PriorityVerification'; +import Resolution from '@/modules/feeds/Editor/components/FormElements/Resolution/Resolution'; +import ResolutionValue from '@/modules/feeds/Editor/components/FormElements/ResolutionValue/ResolutionValue'; +import Restricted from '@/modules/feeds/Editor/components/FormElements/Restricted/Restricted'; +import SkipTaggingForLongClips from '@/modules/feeds/Editor/components/FormElements/SkipTaggingForLongClips/SkipTaggingForLongClips'; +import VideoFileType from '@/modules/feeds/Editor/components/FormElements/VideoFileType/VideoFileType'; + +function AdvancedTab() { + const fileSelection = useAppSelector(fileSelectionSelector); + const skipTaggingForLongClips = useAppSelector(skipTaggingForLongClipsSelector); + return ( + <> + + + + + + + + + + + + + + + + {fileSelection !== FILE_SELECTION_NONE && ( + <> + {fileSelection === FILE_SELECTION_RESOLUTION && ( + <> + + + + + + + + + + + )} + + {fileSelection === FILE_SELECTION_BITRATE && ( + <> + + + + + + + + )} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/GeneralTab/GeneralTab.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/GeneralTab/GeneralTab.tsx new file mode 100644 index 0000000..abbd608 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Tabs/GeneralTab/GeneralTab.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { + ACCESS_LEVEL_HUB, + ACCESS_LEVEL_PRIVATE, + CONTENT_TYPE_SHORTS, + SCHEDULE_TYPE_CUSTOM, +} from '@/modules/feeds/Editor/constants'; + +import { + accessLevelSelector, + accountSelector, + contentTypeSelector, + scheduleTypeSelector, +} from '@/modules/feeds/Editor/redux/selectors'; + +import { FormGroupTitle, FormRow } from '@/modules/@common/Form'; +import { useAppSelector } from '@/modules/@common/store/hooks'; +import AccessLevel from '@/modules/feeds/Editor/components/FormElements/AccessLevel/AccessLevel'; +import AccessVideoOwner from '@/modules/feeds/Editor/components/FormElements/AccessVideoOwner/AccessVideoOwner'; +import Account from '@/modules/feeds/Editor/components/FormElements/Account/Account'; +import AutoImport from '@/modules/feeds/Editor/components/FormElements/AutoImport/AutoImport'; +import ContentType from '@/modules/feeds/Editor/components/FormElements/ContentType/ContentType'; +import DisplayName from '@/modules/feeds/Editor/components/FormElements/DisplayName/DisplayName'; +import FillLandingPage from '@/modules/feeds/Editor/components/FormElements/FillLandingPage/FillLandingPage'; +import Hubs from '@/modules/feeds/Editor/components/FormElements/Hubs/Hubs'; +import IabCategories from '@/modules/feeds/Editor/components/FormElements/IabCategories/IabCategories'; +import ImportShortsThumbnail from '@/modules/feeds/Editor/components/FormElements/ImportShortsThumbnail/ImportShortsThumbnail'; +import Language from '@/modules/feeds/Editor/components/FormElements/Language/Language'; +import Name from '@/modules/feeds/Editor/components/FormElements/Name/Name'; +import ScheduleFrequency from '@/modules/feeds/Editor/components/FormElements/ScheduleFrequency/ScheduleFrequency'; +import SchedulePeriod from '@/modules/feeds/Editor/components/FormElements/SchedulePeriod/SchedulePeriod'; +import ScheduleStartTime from '@/modules/feeds/Editor/components/FormElements/ScheduleStartTime/ScheduleStartTime'; +import YouTubeChannelId from '@/modules/feeds/Editor/components/FormElements/YouTubeChannelId/YouTubeChannelId'; +import YoutubeContentType from '@/modules/feeds/Editor/components/FormElements/YoutubeContentType/YoutubeContentType'; +import YouTubeLoadFromDate from '@/modules/feeds/Editor/components/FormElements/YouTubeLoadFromDate/YouTubeLoadFromDate'; + +function GeneralTab() { + const router = useRouter(); + const id = parseInt(router.query.id as string, 10); + + const accessLevel = useAppSelector(accessLevelSelector); + const scheduleType = useAppSelector(scheduleTypeSelector); + const account = useAppSelector(accountSelector); + const contentType = useAppSelector(contentTypeSelector); + + return ( + <> + Settings + + + + + + + + + + + + + + + + + + + {accessLevel === ACCESS_LEVEL_HUB && ( + + + + )} + {accessLevel === ACCESS_LEVEL_PRIVATE && ( + + + + )} + + Schedule + + + + + + + + + + {scheduleType === SCHEDULE_TYPE_CUSTOM && ( + + + + )} + + Authentication + + + + + + + + + + + + + {contentType === CONTENT_TYPE_SHORTS && ( + + + + )} + + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Youtube.tsx b/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Youtube.tsx new file mode 100644 index 0000000..7894298 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/components/Forms/Youtube/Youtube.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { STATUS_ARCHIVED, TYPE_YOUTUBE } from '@/modules/feeds/constants'; +import { TAB_ADVANCED, TAB_GENERAL, TAB_MODEL, TAB_SCRIPT } from '@/modules/feeds/Editor/constants'; + +import * as selectors from '@/modules/feeds/Editor/redux/selectors'; +import { + createItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import FormWrapper from '@/modules/feeds/Editor/components/FormWrapper/FormWrapper'; +import ModelTab from '../../FormTabs/ModelTab/ModelTab'; +import ScriptTab from '../../FormTabs/ScriptTab/ScriptTab'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@/mui/components'; + +function Youtube() { + const store = useStore(); + const dispatch = useAppDispatch(); + const router = useRouter(); + const [shouldShowAutoImportWarning, setShouldShowAutoImportWarning] = React.useState(false); + + const activeTabId = useAppSelector(selectors.activeTabIdSelector); + const isAutoImport = useAppSelector(selectors.scheduleStatusSelector); + const status = useAppSelector(selectors.statusSelector); + + const id = parseInt(router.query.id as string, 10); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + show: true, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + }, + { + title: 'Models', + id: TAB_MODEL, + content: ModelTab, + }, + { + title: 'Automation Script', + id: TAB_SCRIPT, + content: ScriptTab, + }, + ]; + + const handleSubmitForm = () => { + if (id) { + dispatch(updateItemAction({ id })); + } else { + dispatch(createItemAction()); + } + }; + + const saveToServerForm = () => { + const state = store.getState() as RootState; + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }: { tabId: string }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }: { fieldName: string }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + if (!isAutoImport) { + setShouldShowAutoImportWarning(true); + return; + } + + handleSubmitForm(); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const handleCloseImportWarning = () => setShouldShowAutoImportWarning(false); + const handleApplyImportWarning = () => { + handleSubmitForm(); + handleCloseImportWarning(); + }; + + return ( + <> + + + {shouldShowAutoImportWarning && ( + + Disable automated import? + + Selecting this option means your videos will not be imported. Would you like to continue? + + + + + + + )} + + ); +} + +export default Youtube; diff --git a/anyclip/src/modules/feeds/Editor/constants/index.ts b/anyclip/src/modules/feeds/Editor/constants/index.ts new file mode 100644 index 0000000..242fa69 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/constants/index.ts @@ -0,0 +1,301 @@ +import { TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API } from '@/modules/feeds/constants'; + +import { FeedType } from '@/modules/feeds/types'; + +export const TAB_GENERAL = 'general'; +export const TAB_ADVANCED = 'advanced'; +export const TAB_SCRIPT = 'script'; +export const TAB_MODEL = 'model'; + +export const REDUX_FIELD_NAME = 'commonForm'; + +export const ACCESS_LEVEL_HUB = 'SITE'; +export const ACCESS_LEVEL_PRIVATE = 'PRIVATE'; +export const ACCESS_LEVEL_SYNDICATE = 'PUBLIC'; +export const ACCESS_LEVEL_OPTIONS = [ + { + label: 'Private', + value: ACCESS_LEVEL_PRIVATE, + }, + { + label: 'Hub', + value: ACCESS_LEVEL_HUB, + }, + { + label: 'Syndication', + value: ACCESS_LEVEL_SYNDICATE, + }, +]; +export const ACCESS_LEVEL_ALL_OPTIONS = [ + { + label: 'Private', + value: ACCESS_LEVEL_PRIVATE, + }, + { + label: 'Hub', + value: ACCESS_LEVEL_HUB, + }, + { + label: 'Syndication', + value: ACCESS_LEVEL_SYNDICATE, + }, +]; + +export const ALL_OPTION_ID = 999999999; +export const ALL_HUB_OPTION = { id: ALL_OPTION_ID, name: 'All Hubs' }; + +export const AUTH_METHOD_NOAUTH = 'NoAuth'; +export const AUTH_METHOD_BASIC = 'Basic'; +export const AUTH_METHOD_DIGEST = 'Digest'; +export const AUTH_METHOD_NTLM = 'Ntlm'; +export const AUTH_METHOD_OPTIONS = [ + { + label: 'No Auth', + value: AUTH_METHOD_NOAUTH, + }, + { + label: 'Basic', + value: AUTH_METHOD_BASIC, + }, + { + label: 'Digest', + value: AUTH_METHOD_DIGEST, + }, + { + label: 'NTLM', + value: AUTH_METHOD_NTLM, + }, +]; + +export const SCHEDULE_TYPE_DAILY = 'DAILY'; +export const SCHEDULE_TYPE_CUSTOM = 'CUSTOM'; +export const SCHEDULE_TYPE_OPTIONS = [ + { + label: 'Daily', + value: SCHEDULE_TYPE_DAILY, + }, + { + label: 'Custom', + value: SCHEDULE_TYPE_CUSTOM, + }, +]; + +export const FILE_SELECTION_NONE = 'NONE'; +export const FILE_SELECTION_RESOLUTION = 'RESOLUTION'; +export const FILE_SELECTION_BITRATE = 'BIT_RATE'; +export const FILE_SELECTION_OPTIONS = [ + { + label: 'None', + value: FILE_SELECTION_NONE, + }, + { + label: ' Bit Rate', + value: FILE_SELECTION_BITRATE, + shouldHideForTypes: [TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API] as FeedType[], + }, + { + label: 'Resolution', + value: FILE_SELECTION_RESOLUTION, + }, +]; + +export const RESOLUTION_WIDTH = 'WIDTH'; +export const RESOLUTION_HEIGHT = 'HEIGHT'; +export const RESOLUTION_OPTIONS = [ + { + label: 'Width', + value: RESOLUTION_WIDTH, + shouldHideForTypes: [TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API] as FeedType[], + }, + { + label: 'Height', + value: RESOLUTION_HEIGHT, + }, +]; + +export const VIDEO_FILE_TYPE_MP4 = 'MP4'; +export const VIDEO_FILE_TYPE_M3U8 = 'm3u8'; +export const VIDEO_FILE_TYPE_MP4M3U8 = 'MP4_m3u8'; +export const VIDEO_FILE_TYPE_OPTIONS = [ + { + label: 'MP4 & m3u8 (HLS)', + value: VIDEO_FILE_TYPE_MP4M3U8, + }, + { + label: 'MP4', + value: VIDEO_FILE_TYPE_MP4, + }, + { + label: 'm3u8 (HLS)', + value: VIDEO_FILE_TYPE_M3U8, + }, +]; + +export const ASPECT_RATIO_16_9 = '16_9'; +export const ASPECT_RATIO_9_16 = '9_16'; +export const ASPECT_RATIO_4_3 = '4_3'; +export const ASPECT_RATIO_1_1 = '1_1'; +export const ASPECT_RATIO_OPTIONS = [ + { + label: '16:9', + value: ASPECT_RATIO_16_9, + }, + { + label: '9:16', + value: ASPECT_RATIO_9_16, + }, + { + label: '4:3', + value: ASPECT_RATIO_4_3, + }, + { + label: '1:1', + value: ASPECT_RATIO_1_1, + }, +]; + +export const FIT_FILL = 'FILL'; +export const FIT_FIT = 'FIT'; +export const FIT_STRETCH = 'STRETCH'; +export const FIT_OPTIONS = [ + { + label: 'Fill (lock aspect ratio)', + value: FIT_FILL, + }, + { + label: 'Fit (lock aspect ratio)', + value: FIT_FIT, + }, + { + label: 'Stretch', + value: FIT_STRETCH, + }, +]; + +export const SPEECH_TO_TEXT_DISABLED_VALUE = 'Disabled'; +export const SPEECH_TO_TEXT_GOOGLE_VALUE = 'Google'; +export const SPEECH_TO_TEXT_VERBIT_AUTO_VALUE = 'VERBIT_AUTO'; +export const SPEECH_TO_TEXT_VERBIT_MANUAL_VALUE = 'VERBIT_MANUAL'; +export const SPEECH_TO_TEXT_VERBIT_MANUAL_24H_VALUE = 'VERBIT_MANUAL_24H'; +export const SPEECH_TO_TEXT_ASSEMBLY_AI_VALUE = 'AssemblyAI'; +export const SPEECH_TO_TEXT_ANYCLIP_S2T_VALUE = 'ANYCLIP_S2T'; + +export const SPEECH_TO_TEXT_OPTIONS = [ + { + label: 'Disabled', + value: SPEECH_TO_TEXT_DISABLED_VALUE, + }, + { + label: 'Google', + value: SPEECH_TO_TEXT_GOOGLE_VALUE, + }, + { + label: 'Verbit - automated transcript', + value: SPEECH_TO_TEXT_VERBIT_AUTO_VALUE, + }, + { + label: 'Verbit - manual transcript (4 hrs)', + value: SPEECH_TO_TEXT_VERBIT_MANUAL_VALUE, + }, + { + label: 'Verbit - manual transcript (24 hrs)', + value: SPEECH_TO_TEXT_VERBIT_MANUAL_24H_VALUE, + }, + { + label: 'AssemblyAI', + value: SPEECH_TO_TEXT_ASSEMBLY_AI_VALUE, + }, + { + label: 'Anyclip Speech2Text', + value: SPEECH_TO_TEXT_ANYCLIP_S2T_VALUE, + }, +] as const; + +export const CONTENT_OWNER_LIVE_EVENT_TYPE = 99; +export const CONTENT_OWNER_STATUS_DISABLED = 0; + +export const MEETINGS_SOURCES_ENABLED_BY_DEFAULT = [ + { + model: 'TEXT_DETECTION', + provider: 'GOOGLE', + }, + { + model: 'ENCODE', + provider: 'ANYCLIP', + }, + { + model: 'VIDEO_PREVIEW', + provider: 'ANYCLIP', + }, + { + model: 'VIDEO_THUMBNAIL', + provider: 'ANYCLIP', + }, + { + model: 'NLP_CLASSIFICATION', + provider: 'ANYCLIP', + }, +]; + +export const SOURCES_DISABLED_BY_DEFAULT = [ + { model: 'HIGHLIGHTS', provider: 'ANYCLIP' }, + { model: 'AUTO_CHAPTERS', provider: 'ANYCLIP' }, + { model: 'IAB_CLASSIFICATION', provider: 'ANYCLIP' }, + { model: 'BRAND_SAFETY', provider: 'ANYCLIP' }, + + { model: 'ALCOHOL', provider: 'WEAVO' }, + { model: 'FIREARMS', provider: 'WEAVO' }, + { model: 'NUDITY', provider: 'WEAVO' }, + { model: 'WAR_AND_TERROR', provider: 'WEAVO' }, + + { model: 'MULTI_LANGUAGE_SUBTITLES', provider: 'GOOGLE' }, + { model: 'SAFE_SEARCH_DETECTION', provider: 'GOOGLE' }, + { model: 'LOGO_DETECTION', provider: 'GOOGLE' }, + { model: 'LABEL_DETECTION', provider: 'GOOGLE' }, + { model: 'TEXT_DETECTION', provider: 'GOOGLE' }, + + { model: 'CELEBRITY', provider: 'CLARIFAI' }, + { model: 'LOGO', provider: 'CLARIFAI' }, + { model: 'GENERAL', provider: 'CLARIFAI' }, + { model: 'MODERATION', provider: 'CLARIFAI' }, + + { model: 'CHAT_GPT_DESCRIPTION', provider: 'OPENAI' }, + { model: 'TOPICS', provider: 'OPENAI' }, + + { model: 'CUSTOM_PEOPLE', provider: 'AMAZON' }, + { model: 'MODERATION_LABELS', provider: 'AMAZON' }, + { model: 'CELEBRITIES', provider: 'AMAZON' }, +]; + +export const ACCOUNT_TYPE_SYNDICATION = 'SYNDICATION'; + +export const YT_CONTENT_TYPE_CHANNEL = 'CHANNEL'; +export const YT_CONTENT_TYPE_PLAYLIST = 'PLAYLIST'; +export const YT_CONTENT_TYPE_OPTIONS = [ + { + label: 'Channel', + value: YT_CONTENT_TYPE_CHANNEL, + }, + { + label: 'Playlist', + value: YT_CONTENT_TYPE_PLAYLIST, + }, +]; + +export const CONTENT_TYPE_ALL = 'ALL'; +export const CONTENT_TYPE_SHORTS = 'SHORTS'; +export const CONTENT_TYPE_VIDEO = 'VIDEO'; +export const CONTENT_TYPE_OPTIONS = [ + { + label: 'All', + value: CONTENT_TYPE_ALL, + }, + { + label: 'Shorts', + value: CONTENT_TYPE_SHORTS, + }, + { + label: 'Video', + value: CONTENT_TYPE_VIDEO, + }, +]; diff --git a/anyclip/src/modules/feeds/Editor/helpers/index.ts b/anyclip/src/modules/feeds/Editor/helpers/index.ts new file mode 100644 index 0000000..08a08f5 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/index.ts @@ -0,0 +1,23 @@ +import type { FeedType } from '@/modules/feeds/types'; + +export const isValidJSON = (str: string): boolean => { + try { + const parsed = JSON.parse(str); + return typeof parsed === 'object' && parsed !== null; + } catch (_: unknown) { + console.warn(_); + return false; + } +}; + +export const pick = (obj: T, keys: readonly K[]) => + Object.fromEntries(keys.filter((key) => key in obj).map((key) => [key, obj[key]])); + +export const filterOptionsByType = ( + options: { + shouldHideForTypes?: FeedType[]; + label: string; + value: string; + }[], + feedType: FeedType, +) => options.filter((option) => !option.shouldHideForTypes?.includes(feedType)); diff --git a/anyclip/src/modules/feeds/Editor/helpers/injectNewFeedFormUtil.ts b/anyclip/src/modules/feeds/Editor/helpers/injectNewFeedFormUtil.ts new file mode 100644 index 0000000..0c0bdc7 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/injectNewFeedFormUtil.ts @@ -0,0 +1,64 @@ +import Router from 'next/router'; + +import { + TYPE_CSV, + TYPE_MANUAL, + TYPE_MRSS, + TYPE_MS_STREAM, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_TIKTOK, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_YOUTUBE, +} from '@/modules/feeds/constants'; + +import { featureFlags } from '@/modules/@common/helpers/featureFlags'; + +export const supportedNewFeedFormsByType = [ + TYPE_MRSS, + TYPE_TIKTOK, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_MANUAL, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_MS_STREAM, +] as const; +export const supportedNewFeedFormRoutePathnames: Record<(typeof supportedNewFeedFormsByType)[number], string> = { + [TYPE_MRSS]: '/feeds/[id]/mrss', + [TYPE_TIKTOK]: '/feeds/[id]/tiktok', + [TYPE_RSS]: '/feeds/[id]/rss', + [TYPE_SITEMAP]: '/feeds/[id]/sitemap', + [TYPE_STORY_API]: '/feeds/[id]/story-api', + [TYPE_MANUAL]: '/feeds/[id]/manual', + [TYPE_YOUTUBE]: '/feeds/[id]/youtube', + [TYPE_CSV]: '/feeds/[id]/csv', + [TYPE_VIDEO_API]: '/feeds/[id]/video-api', + [TYPE_VIMEO]: '/feeds/[id]/vimeo', + [TYPE_MS_STREAM]: '/feeds/[id]/ms-stream', +}; + +export const redirectToSupportedNewFeedForm = ( + type: (typeof supportedNewFeedFormsByType)[number], + id: number | 'new', +): boolean => { + const redirectUrlTemplate = supportedNewFeedFormRoutePathnames[type]; + + if (!redirectUrlTemplate) { + return false; + } + + if (featureFlags.showOldFormsInFeedModule) { + return false; + } + + const url = redirectUrlTemplate.replace('[id]', id.toString()); + Router.push(url); + + return true; +}; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getAllFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getAllFields.ts new file mode 100644 index 0000000..a106780 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getAllFields.ts @@ -0,0 +1,73 @@ +import type { FeedType } from '../../types'; + +import * as selectors from '../../redux/selectors'; + +const getAllFields = (state: RootState): FeedType => ({ + id: selectors.idSelector(state), + type: selectors.typeSelector(state), + account: selectors.accountSelector(state), + source: selectors.sourceSelector(state), + name: selectors.nameSelector(state), + description: selectors.descriptionSelector(state), + lang: selectors.langSelector(state), + url: selectors.urlSelector(state), + speechToTextProvider: selectors.speechToTextProviderSelector(state), + authMethod: selectors.authMethodSelector(state), + priorityVerification: selectors.priorityVerificationSelector(state), + feedPriority: selectors.feedPrioritySelector(state), + scheduleType: selectors.scheduleTypeSelector(state), + immediateAvailability: selectors.immediateAvailabilitySelector(state), + skipTaggingForLongClips: selectors.skipTaggingForLongClipsSelector(state), + maxDurationForTagging: selectors.maxDurationForTaggingSelector(state), + accessLevel: selectors.accessLevelSelector(state), + accessAllPublishers: selectors.accessAllPublishersSelector(state), + parseKeywordsToLabels: selectors.parseKeywordsToLabelsSelector(state), + iabCategories: selectors.iabCategoriesSelector(state), + accessVideoOwner: selectors.accessVideoOwnerSelector(state), + accessPublishers: selectors.accessPublishersSelector(state), + user: selectors.userSelector(state), + password: selectors.passwordSelector(state), + isUseForDownload: selectors.isUseForDownloadSelector(state), + scheduleStatus: selectors.scheduleStatusSelector(state), + scheduleValue: selectors.scheduleValueSelector(state), + scheduleFrequency: selectors.scheduleFrequencySelector(state), + defaultTimezoneEnabled: selectors.defaultTimezoneEnabledSelector(state), + defaultTimezone: selectors.defaultTimezoneSelector(state), + isEvergreenFeed: selectors.isEvergreenFeedSelector(state), + isRestricted: selectors.isRestrictedSelector(state), + keywords: selectors.keywordsSelector(state), + maxDuration: selectors.maxDurationSelector(state), + importPlot: selectors.importPlotSelector(state), + videoFileType: selectors.videoFileTypeSelector(state), + fileSelection: selectors.fileSelectionSelector(state), + resolution: selectors.resolutionSelector(state), + resolutionValue: selectors.resolutionValueSelector(state), + minResolutionValue: selectors.minResolutionValueSelector(state), + bitrate: selectors.bitrateSelector(state), + minBitrate: selectors.minBitrateSelector(state), + importCCFromMrssFile: selectors.importCCFromMrssFileSelector(state), + createClip: selectors.createClipSelector(state), + processVideoVersions: selectors.processVideoVersionsSelector(state), + versionAttributeName: selectors.versionAttributeNameSelector(state), + automationScript: selectors.automationScriptSelector(state), + platformModels: selectors.platformModelsSelector(state), + maxStories: selectors.maxStoriesSelector(state), + videoDuration: selectors.videoDurationSelector(state), + aspectRatio: selectors.aspectRatioSelector(state), + fit: selectors.fitSelector(state), + videoMaxZoom: selectors.videoMaxZoomSelector(state), + jsRendering: selectors.jsRenderingSelector(state), + loadFromLastDays: selectors.loadFromLastDaysSelector(state), + discardLongClips: selectors.discardLongClipsSelector(state), + fillLandingPage: selectors.fillLandingPageSelector(state), + oAuthToken: selectors.oAuthTokenSelector(state), + youtubeContentType: selectors.youtubeContentTypeSelector(state), + youTubeChannelId: selectors.youTubeChannelIdSelector(state), + youTubeLoadFromDate: selectors.youTubeLoadFromDateSelector(state), + contentType: selectors.contentTypeSelector(state), + importShortsThumbnail: selectors.importShortsThumbnailSelector(state), +}); + +export type AllFieldsType = ReturnType; + +export default getAllFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getCsvFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getCsvFields.ts new file mode 100644 index 0000000..35278ba --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getCsvFields.ts @@ -0,0 +1,140 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type CsvFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name?: string; + description: string; + lang: string; + url: string; + speechToTextProvider: string; + auth_method: string; + priorityVerification: boolean; + feedPriority: number; + schedule_type: string; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + accessLevel: string; + accessAllPublishers: boolean; + iabCategories: string; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + user: string; + password: string; + use_for_download: boolean; + schedule_status: boolean; + schedule_value: string; + schedule_frequency: number; + defaultTimezoneEnabled: boolean; + defaultTimezone: string; + isEvergreenFeed: boolean; + isRestricted: boolean; + keywords: string; + maxDuration: number; + importPlot: boolean; + videoFileType: string; + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + createClip: boolean; + automationScript: string; + platformModels: PlatformModel[]; + discardLongClips: boolean; + + keywords_parser_type: string; + contentXmlStructure: string; + thumbnailsXmlStructure: string; +}; + +const getCsvFields = ( + fields: AllFieldsType, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): Partial => { + const payload: CsvFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + url: fields.url, + speechToTextProvider: fields.speechToTextProvider, + auth_method: fields.authMethod, + priorityVerification: fields.priorityVerification, + feedPriority: fields.feedPriority, + schedule_type: fields.scheduleType, + immediateAvailability: fields.immediateAvailability, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + iabCategories: fields.iabCategories || '[]', + user: fields.user, + password: fields.password, + use_for_download: fields.isUseForDownload, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + defaultTimezoneEnabled: fields.defaultTimezoneEnabled, + defaultTimezone: fields.defaultTimezone, + isEvergreenFeed: fields.isEvergreenFeed, + isRestricted: fields.isRestricted, + keywords: fields.keywords, + maxDuration: fields.maxDuration, + importPlot: fields.importPlot, + videoFileType: fields.videoFileType, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + createClip: fields.createClip, + automationScript: fields.automationScript || '{}', + platformModels: fields.platformModels, + accessPublishers: [], + discardLongClips: fields.discardLongClips, + + // default values + contentXmlStructure: 'DEFAULT', + thumbnailsXmlStructure: 'DEFAULT', + keywords_parser_type: 'DEFAULT', + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.feedPriority) { + payload.feedPriority = parseFloat(fields.feedPriority as unknown as string); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + return payload; +}; + +export default getCsvFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getManualFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getManualFields.ts new file mode 100644 index 0000000..8ada573 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getManualFields.ts @@ -0,0 +1,86 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type ManualFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name: string; + description: string; + speechToTextProvider: string; + priorityVerification: boolean; + feedPriority: number; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + accessLevel: string; + accessAllPublishers: boolean; + iabCategories: string; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + keywords: string; + importPlot: boolean; + videoFileType: string; + createClip: boolean; + automationScript: string; + platformModels: PlatformModel[]; +}; + +const getManualFields = ( + fields: AllFieldsType, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): ManualFeedPayloadType => { + const payload: ManualFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + speechToTextProvider: fields.speechToTextProvider, + priorityVerification: fields.priorityVerification, + feedPriority: fields.feedPriority, + immediateAvailability: fields.immediateAvailability, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + iabCategories: fields.iabCategories || '[]', + keywords: fields.keywords, + importPlot: fields.importPlot, + videoFileType: fields.videoFileType, + createClip: fields.createClip, + automationScript: fields.automationScript || '{}', + platformModels: fields.platformModels, + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.feedPriority) { + payload.feedPriority = parseFloat(fields.feedPriority as unknown as string); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + return payload; +}; + +export default getManualFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMrssFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMrssFields.ts new file mode 100644 index 0000000..8307ba2 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMrssFields.ts @@ -0,0 +1,182 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { pick } from '../../helpers'; +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type MrssFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name?: string; + description: string; + lang: string; + url: string; + speechToTextProvider: string; + auth_method: string; + priorityVerification: boolean; + feedPriority: number; + schedule_type: string; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + accessLevel: string; + accessAllPublishers: boolean; + parseKeywordsToLabels: boolean; + iabCategories: string; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + user: string; + password: string; + use_for_download: boolean; + schedule_status: boolean; + schedule_value: string; + schedule_frequency: number; + defaultTimezoneEnabled: boolean; + defaultTimezone: string; + isEvergreenFeed: boolean; + isRestricted: boolean; + keywords: string; + maxDuration: number; + importPlot: boolean; + videoFileType: string; + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + importCCFromMrssFile: boolean; + createClip: boolean; + processVideoVersions: boolean; + versionAttributeName: string; + automationScript: string; + platformModels: PlatformModel[]; + + keywords_parser_type: string; + contentXmlStructure: string; + thumbnailsXmlStructure: string; +}; + +const getMrssFields = ( + fields: AllFieldsType, + isSelfServeUser: boolean, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): Partial => { + const payload: MrssFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + url: fields.url, + speechToTextProvider: fields.speechToTextProvider, + auth_method: fields.authMethod, + priorityVerification: fields.priorityVerification, + feedPriority: fields.feedPriority, + schedule_type: fields.scheduleType, + immediateAvailability: fields.immediateAvailability, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + parseKeywordsToLabels: fields.parseKeywordsToLabels, + iabCategories: fields.iabCategories || '[]', + user: fields.user, + password: fields.password, + use_for_download: fields.isUseForDownload, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + defaultTimezoneEnabled: fields.defaultTimezoneEnabled, + defaultTimezone: fields.defaultTimezone, + isEvergreenFeed: fields.isEvergreenFeed, + isRestricted: fields.isRestricted, + keywords: fields.keywords, + maxDuration: fields.maxDuration, + importPlot: fields.importPlot, + videoFileType: fields.videoFileType, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + importCCFromMrssFile: fields.importCCFromMrssFile, + createClip: fields.createClip, + processVideoVersions: fields.processVideoVersions, + versionAttributeName: fields.versionAttributeName, + automationScript: fields.automationScript || '{}', + platformModels: fields.platformModels, + accessPublishers: [], + + // default values + contentXmlStructure: 'DEFAULT', + thumbnailsXmlStructure: 'DEFAULT', + keywords_parser_type: 'DEFAULT', + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.feedPriority) { + payload.feedPriority = parseFloat(fields.feedPriority as unknown as string); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + // if selfServe + if (isSelfServeUser) { + return pick(payload, [ + 'id', + 'type', + 'description', + 'lang', + 'iabCategories', + 'accessLevel', + 'accessAllPublishers', + 'accessVideoOwnerId', + 'accessPublishers', + 'url', + + 'isEvergreenFeed', + 'keywords', + 'importPlot', + 'importCCFromMrssFile', + + 'schedule_status', + 'schedule_type', + 'schedule_value', + 'schedule_frequency', + + 'defaultTimezoneEnabled', + 'defaultTimezone', + + 'user', + 'password', + 'auth_method', + 'use_for_download', + ]); + } + + return payload; +}; + +export default getMrssFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMsStreamFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMsStreamFields.ts new file mode 100644 index 0000000..e37daa8 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getMsStreamFields.ts @@ -0,0 +1,130 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { pick } from '../../helpers'; +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type MsStreamFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name: string; + description: string; + lang: string; + discardLongClips: boolean; + speechToTextProvider: string; + priorityVerification: boolean; + schedule_type: string; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + accessLevel: string; + accessAllPublishers: boolean; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + schedule_status: boolean; + schedule_value: string; + schedule_frequency: number; + isEvergreenFeed: boolean; + isRestricted: boolean; + keywords: string; + maxDuration: number; + importPlot: boolean; + videoFileType: string; + createClip: boolean; + automationScript: string; + platformModels: PlatformModel[]; + + user: string; + password: string; + youTubeChannelId: string; + + status: number; +}; + +const getMsStreamFields = ( + fields: AllFieldsType, + isSelfServeUser: boolean, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): Partial => { + const payload: MsStreamFeedPayloadType = { + type: fields.type, + name: fields.description, // in old UI name === description + description: fields.description, + lang: fields.lang, + discardLongClips: fields.discardLongClips, + speechToTextProvider: fields.speechToTextProvider, + priorityVerification: fields.priorityVerification, + schedule_type: fields.scheduleType, + immediateAvailability: fields.immediateAvailability, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + isEvergreenFeed: fields.isEvergreenFeed, + isRestricted: fields.isRestricted, + keywords: fields.keywords, + maxDuration: fields.maxDuration, + importPlot: fields.importPlot, + videoFileType: fields.videoFileType, + createClip: fields.createClip, + automationScript: fields.automationScript, + platformModels: fields.platformModels, + + user: fields.user, + password: fields.password, + youTubeChannelId: fields.youTubeChannelId, + + status: 1, + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + // if selfServe + if (isSelfServeUser) { + return pick(payload, [ + 'id', + 'type', + 'description', + 'lang', + 'accessLevel', + 'accessAllPublishers', + 'accessVideoOwnerId', + 'accessPublishers', + 'user', + 'password', + 'youTubeChannelId', + 'status', + 'schedule_status', + 'platformModels', + ]); + } + + return payload; +}; + +export default getMsStreamFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getRssFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getRssFields.ts new file mode 100644 index 0000000..2c1f789 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getRssFields.ts @@ -0,0 +1,114 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type RssFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name: string; + description: string; + lang: string; + accessLevel: string; + accessPublishers?: { publisherId: number }[]; + accessVideoOwnerId?: number | null; + accessAllPublishers: boolean; + url: string; + user: string; + password: string; + auth_method: string; + use_for_download: boolean; + schedule_status: boolean; + schedule_type: string; + schedule_value: string; + schedule_frequency: number; + + maxStories: number; + videoDuration: number; + aspectRatio: string; + fit: string; + videoMaxZoom: number; + + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + + keywords_parser_type: string; + contentXmlStructure: string; + thumbnailsXmlStructure: string; + createClip: boolean; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + importPlot: boolean; + keywords: string; + videoFileType: string; + jsRendering: boolean; +}; + +const getMrssFields = (fields: AllFieldsType): RssFeedPayloadType => { + const payload: RssFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + url: fields.url, + auth_method: fields.authMethod, + schedule_type: fields.scheduleType, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + user: fields.user, + password: fields.password, + use_for_download: fields.isUseForDownload, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + maxStories: fields.maxStories, + videoDuration: fields.videoDuration, + aspectRatio: fields.aspectRatio, + fit: fields.fit, + videoMaxZoom: fields.videoMaxZoom, + videoFileType: fields.videoFileType, + jsRendering: fields.jsRendering, + + // default values + contentXmlStructure: 'DEFAULT', + thumbnailsXmlStructure: 'DEFAULT', + keywords_parser_type: 'DEFAULT', + createClip: true, + immediateAvailability: false, + skipTaggingForLongClips: true, + maxDurationForTagging: 300, + importPlot: true, + keywords: '', + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + return payload; +}; + +export default getMrssFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getSitemapFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getSitemapFields.ts new file mode 100644 index 0000000..e83d5c1 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getSitemapFields.ts @@ -0,0 +1,114 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type SitemapFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name: string; + description: string; + lang: string; + accessLevel: string; + accessPublishers?: { publisherId: number }[]; + accessVideoOwnerId?: number | null; + accessAllPublishers: boolean; + url: string; + user: string; + password: string; + auth_method: string; + use_for_download: boolean; + schedule_status: boolean; + schedule_type: string; + schedule_value: string; + schedule_frequency: number; + + maxStories: number; + videoDuration: number; + aspectRatio: string; + fit: string; + videoMaxZoom: number; + + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + + keywords_parser_type: string; + contentXmlStructure: string; + thumbnailsXmlStructure: string; + createClip: boolean; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + importPlot: boolean; + keywords: string; + videoFileType: string; + jsRendering: boolean; +}; + +const getSitemapFields = (fields: AllFieldsType): SitemapFeedPayloadType => { + const payload: SitemapFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + url: fields.url, + auth_method: fields.authMethod, + schedule_type: fields.scheduleType, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + user: fields.user, + password: fields.password, + use_for_download: fields.isUseForDownload, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + maxStories: fields.maxStories, + videoDuration: fields.videoDuration, + aspectRatio: fields.aspectRatio, + fit: fields.fit, + videoMaxZoom: fields.videoMaxZoom, + videoFileType: fields.videoFileType, + jsRendering: fields.jsRendering, + + // default values + contentXmlStructure: 'DEFAULT', + thumbnailsXmlStructure: 'DEFAULT', + keywords_parser_type: 'DEFAULT', + createClip: true, + immediateAvailability: false, + skipTaggingForLongClips: true, + maxDurationForTagging: 300, + importPlot: true, + keywords: '', + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + return payload; +}; + +export default getSitemapFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getStoryApiFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getStoryApiFields.ts new file mode 100644 index 0000000..e9224eb --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getStoryApiFields.ts @@ -0,0 +1,110 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type StoryApiFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name: string; + description: string; + lang: string; + accessLevel: string; + accessPublishers?: { publisherId: number }[]; + accessVideoOwnerId?: number | null; + accessAllPublishers: boolean; + url: string; + user: string; + password: string; + auth_method: string; + use_for_download: boolean; + schedule_status: boolean; + schedule_type: string; + schedule_value: string; + schedule_frequency: number; + + maxStories: number; + videoDuration: number; + aspectRatio: string; + fit: string; + videoMaxZoom: number; + + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + + keywords_parser_type: string; + contentXmlStructure: string; + thumbnailsXmlStructure: string; + createClip: boolean; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + importPlot: boolean; + keywords: string; +}; + +const getStoryApiFields = (fields: AllFieldsType): StoryApiFeedPayloadType => { + const payload: StoryApiFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + url: fields.url, + auth_method: fields.authMethod, + schedule_type: fields.scheduleType, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + user: fields.user, + password: fields.password, + use_for_download: fields.isUseForDownload, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + maxStories: fields.maxStories, + videoDuration: fields.videoDuration, + aspectRatio: fields.aspectRatio, + fit: fields.fit, + videoMaxZoom: fields.videoMaxZoom, + + // default values + contentXmlStructure: 'DEFAULT', + thumbnailsXmlStructure: 'DEFAULT', + keywords_parser_type: 'DEFAULT', + createClip: true, + immediateAvailability: false, + skipTaggingForLongClips: true, + maxDurationForTagging: 300, + importPlot: true, + keywords: '', + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + return payload; +}; + +export default getStoryApiFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getTikTokFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getTikTokFields.ts new file mode 100644 index 0000000..b280c43 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getTikTokFields.ts @@ -0,0 +1,152 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { pick } from '../../helpers'; +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type TikTokFeedPayloadType = { + token?: string; + id?: number; + type: string; + accountId?: number; + name?: string; + description: string; + lang: string; + accessLevel: string; + accessAllPublishers: boolean; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + schedule_value: string; + schedule_frequency: number; + loadFromLastDays: number; + + videoFileType: string; + isEvergreenFeed: boolean; + discardLongClips: boolean; + maxDuration: number; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + immediateAvailability: boolean; + isRestricted: boolean; + createClip: boolean; + fillLandingPage: boolean; + priorityVerification: boolean; + + speechToTextProvider: string; + platformModels: PlatformModel[]; + automationScript: string; + + schedule_type: string; + schedule_status: boolean; + importPlot: boolean; + + // default + status: number; + source: { + deleteAfterImport: boolean; + importParticipantsNames: boolean; + }; +}; + +const getTikTokFields = ( + fields: AllFieldsType, + isSelfServeUser: boolean, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): Partial => { + const payload: TikTokFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + loadFromLastDays: fields.loadFromLastDays, + + videoFileType: fields.videoFileType, + isEvergreenFeed: fields.isEvergreenFeed, + discardLongClips: fields.discardLongClips, + maxDuration: fields.maxDuration, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + immediateAvailability: fields.immediateAvailability, + isRestricted: fields.isRestricted, + createClip: fields.createClip, + fillLandingPage: fields.fillLandingPage, + priorityVerification: fields.priorityVerification, + + speechToTextProvider: fields.speechToTextProvider, + platformModels: fields.platformModels, + automationScript: fields.automationScript, + accessPublishers: [], + + // default values + status: 1, + source: { + deleteAfterImport: false, + importParticipantsNames: false, + }, + + schedule_type: fields.scheduleType, + schedule_status: fields.scheduleStatus, + importPlot: fields.importPlot, + }; + + if (fields.id) { + payload.id = fields.id; + delete payload.name; + } + + if (fields.oAuthToken) { + payload.token = fields.oAuthToken; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + // if selfServe + if (isSelfServeUser) { + return pick(payload, [ + 'id', + 'token', + 'type', + 'description', + 'lang', + 'accessLevel', + 'accessAllPublishers', + 'accountId', + 'accessVideoOwnerId', + 'accessPublishers', + + 'schedule_value', + 'schedule_frequency', + 'loadFromLastDays', + + 'status', + 'source', + 'schedule_status', + 'schedule_type', + ]); + } + + return payload; +}; + +export default getTikTokFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVideoApiFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVideoApiFields.ts new file mode 100644 index 0000000..64e9b63 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVideoApiFields.ts @@ -0,0 +1,136 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type VideoApiFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name?: string; + description: string; + lang: string; + url: string; + speechToTextProvider: string; + auth_method: string; + priorityVerification: boolean; + feedPriority: number; + schedule_type: string; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + accessLevel: string; + accessAllPublishers: boolean; + iabCategories: string; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + user: string; + password: string; + use_for_download: boolean; + schedule_status: boolean; + schedule_value: string; + schedule_frequency: number; + isEvergreenFeed: boolean; + isRestricted: boolean; + keywords: string; + maxDuration: number; + importPlot: boolean; + videoFileType: string; + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + createClip: boolean; + automationScript: string; + platformModels: PlatformModel[]; + discardLongClips: boolean; + + keywords_parser_type: string; + contentXmlStructure: string; + thumbnailsXmlStructure: string; +}; + +const getVideoApiFields = ( + fields: AllFieldsType, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): Partial => { + const payload: VideoApiFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + url: fields.url, + speechToTextProvider: fields.speechToTextProvider, + auth_method: fields.authMethod, + priorityVerification: fields.priorityVerification, + feedPriority: fields.feedPriority, + schedule_type: fields.scheduleType, + immediateAvailability: fields.immediateAvailability, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + iabCategories: fields.iabCategories || '[]', + user: fields.user, + password: fields.password, + use_for_download: fields.isUseForDownload, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + isEvergreenFeed: fields.isEvergreenFeed, + isRestricted: fields.isRestricted, + keywords: fields.keywords, + maxDuration: fields.maxDuration, + importPlot: fields.importPlot, + videoFileType: fields.videoFileType, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + createClip: fields.createClip, + automationScript: fields.automationScript || '{}', + platformModels: fields.platformModels, + accessPublishers: [], + discardLongClips: fields.discardLongClips, + + // default values + contentXmlStructure: 'DEFAULT', + thumbnailsXmlStructure: 'DEFAULT', + keywords_parser_type: 'DEFAULT', + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.feedPriority) { + payload.feedPriority = parseFloat(fields.feedPriority as unknown as string); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + return payload; +}; + +export default getVideoApiFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVimeoFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVimeoFields.ts new file mode 100644 index 0000000..07ad537 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getVimeoFields.ts @@ -0,0 +1,121 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type VimeoFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name?: string; + description: string; + lang: string; + speechToTextProvider: string; + priorityVerification: boolean; + feedPriority: number; + schedule_type: string; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + accessLevel: string; + accessAllPublishers: boolean; + iabCategories: string; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + user: string; + password: string; + schedule_status: boolean; + schedule_value: string; + schedule_frequency: number; + isEvergreenFeed: boolean; + isRestricted: boolean; + keywords: string; + maxDuration: number; + importPlot: boolean; + videoFileType: string; + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + createClip: boolean; + automationScript: string; + platformModels: PlatformModel[]; + loadFromLastDays: number; +}; + +const getVimeoApiFields = ( + fields: AllFieldsType, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): Partial => { + const payload: VimeoFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + speechToTextProvider: fields.speechToTextProvider, + priorityVerification: fields.priorityVerification, + feedPriority: fields.feedPriority, + schedule_type: fields.scheduleType, + immediateAvailability: fields.immediateAvailability, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + iabCategories: fields.iabCategories || '[]', + user: fields.user, + password: fields.password, + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + isEvergreenFeed: fields.isEvergreenFeed, + isRestricted: fields.isRestricted, + keywords: fields.keywords, + maxDuration: fields.maxDuration, + importPlot: fields.importPlot, + videoFileType: fields.videoFileType, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + createClip: fields.createClip, + automationScript: fields.automationScript || '{}', + platformModels: fields.platformModels, + accessPublishers: [], + loadFromLastDays: fields.loadFromLastDays || 1, + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.feedPriority) { + payload.feedPriority = parseFloat(fields.feedPriority as unknown as string); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + return payload; +}; + +export default getVimeoApiFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getYoutubeFields.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getYoutubeFields.ts new file mode 100644 index 0000000..dc3db0b --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/getYoutubeFields.ts @@ -0,0 +1,128 @@ +import { ACCESS_LEVEL_HUB, ACCESS_LEVEL_PRIVATE } from '@/modules/feeds/Editor/constants'; + +import { MetadataFeedPlatformModelType, PlatformModel } from '@/modules/feeds/Editor/types'; +import type { FeedType } from '@/modules/feeds/types'; + +import { getDefaultPlatformModelsWhenCreate } from '@/modules/feeds/Editor/components/FormTabs/ModelTab/helpers'; +import { AllFieldsType } from '@/modules/feeds/Editor/helpers/requestPayload/getAllFields'; + +export type YoutubeFeedPayloadType = { + id?: number; + type: string; + accountId?: number; + name: string; + description: string; + lang: string; + speechToTextProvider: string; + priorityVerification: boolean; + feedPriority: number; + schedule_type: string; + immediateAvailability: boolean; + skipTaggingForLongClips: boolean; + maxDurationForTagging: number; + accessLevel: string; + accessAllPublishers: boolean; + iabCategories: string; + accessVideoOwnerId?: number | null; + accessPublishers?: { publisherId: number }[]; + schedule_status: boolean; + schedule_value: string; + schedule_frequency: number; + isEvergreenFeed: boolean; + isRestricted: boolean; + keywords: string; + maxDuration: number; + importPlot: boolean; + videoFileType: string; + fileSelection: string; + resolution: string; + resolutionValue: number; + minResolutionValue: number; + bitrate: number; + minBitrate: number; + createClip: boolean; + automationScript: string; + platformModels: PlatformModel[]; + + youtubeContentType: string; + youTubeChannelId: string; + youTubeLoadFromDate: string; + contentType: string; + importShortsThumbnail: boolean; + fillLandingPage: boolean; +}; + +const getYoutubeFields = ( + fields: AllFieldsType, + metadataPlatformModels: MetadataFeedPlatformModelType[] | null, +): YoutubeFeedPayloadType => { + const payload: YoutubeFeedPayloadType = { + type: fields.type, + name: fields.name, + description: fields.description, + lang: fields.lang, + speechToTextProvider: fields.speechToTextProvider, + priorityVerification: fields.priorityVerification, + feedPriority: fields.feedPriority, + schedule_type: fields.scheduleType, + immediateAvailability: fields.immediateAvailability, + skipTaggingForLongClips: fields.skipTaggingForLongClips, + maxDurationForTagging: fields.maxDurationForTagging, + accessLevel: fields.accessLevel, + accessAllPublishers: fields.accessAllPublishers, + iabCategories: fields.iabCategories || '[]', + schedule_status: fields.scheduleStatus, + schedule_value: fields.scheduleValue, + schedule_frequency: fields.scheduleFrequency, + isEvergreenFeed: fields.isEvergreenFeed, + isRestricted: fields.isRestricted, + keywords: fields.keywords, + maxDuration: fields.maxDuration, + importPlot: fields.importPlot, + videoFileType: fields.videoFileType, + fileSelection: fields.fileSelection, + resolution: fields.resolution, + resolutionValue: fields.resolutionValue, + minResolutionValue: fields.minResolutionValue, + bitrate: fields.bitrate, + minBitrate: fields.minBitrate, + createClip: fields.createClip, + automationScript: fields.automationScript || '{}', + platformModels: fields.platformModels, + + youtubeContentType: fields.youtubeContentType, + youTubeChannelId: fields.youTubeChannelId, + youTubeLoadFromDate: fields.youTubeLoadFromDate, + contentType: fields.contentType, + importShortsThumbnail: fields.importShortsThumbnail, + fillLandingPage: fields.fillLandingPage, + }; + + if (fields.id) { + payload.id = fields.id; + } + + if (fields.account?.id && !fields.id) { + payload.accountId = fields.account.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_PRIVATE && fields.accessVideoOwner?.id) { + payload.accessVideoOwnerId = fields.accessVideoOwner.id; + } + + if (fields.accessLevel === ACCESS_LEVEL_HUB && fields.accessPublishers?.length && !fields.accessAllPublishers) { + payload.accessPublishers = fields.accessPublishers.map((hub) => ({ publisherId: hub.id })); + } + + if (fields.feedPriority) { + payload.feedPriority = parseFloat(fields.feedPriority as unknown as string); + } + + if (fields.platformModels.length === 0 && metadataPlatformModels && !fields.id) { + payload.platformModels = getDefaultPlatformModelsWhenCreate(metadataPlatformModels, fields.type as FeedType); + } + + return payload; +}; + +export default getYoutubeFields; diff --git a/anyclip/src/modules/feeds/Editor/helpers/requestPayload/index.ts b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/index.ts new file mode 100644 index 0000000..47dd8d5 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/requestPayload/index.ts @@ -0,0 +1,65 @@ +import { + TYPE_CSV, + TYPE_MANUAL, + TYPE_MRSS, + TYPE_MS_STREAM, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_TIKTOK, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_YOUTUBE, +} from '@/modules/feeds/constants'; + +import type { FeedType } from '@/modules/feeds/types'; + +import * as selectors from '../../redux/selectors'; + +import getAllFields from './getAllFields'; +import getCsvFields, { CsvFeedPayloadType } from './getCsvFields'; +import getManualFields, { ManualFeedPayloadType } from './getManualFields'; +import getMrssFields, { MrssFeedPayloadType } from './getMrssFields'; +import getMsStreamFields, { MsStreamFeedPayloadType } from './getMsStreamFields'; +import getRssFields, { RssFeedPayloadType } from './getRssFields'; +import getSitemapFields, { SitemapFeedPayloadType } from './getSitemapFields'; +import getStoryApiFields, { StoryApiFeedPayloadType } from './getStoryApiFields'; +import getTikTokFields, { TikTokFeedPayloadType } from './getTikTokFields'; +import getVideoApiFields, { VideoApiFeedPayloadType } from './getVideoApiFields'; +import getVimeoApiFields, { VimeoFeedPayloadType } from './getVimeoFields'; +import getYoutubeFields, { YoutubeFeedPayloadType } from './getYoutubeFields'; + +export function getRequestPayload( + type: FeedType, + state: RootState, +): + | MrssFeedPayloadType + | Partial + | RssFeedPayloadType + | SitemapFeedPayloadType + | StoryApiFeedPayloadType + | ManualFeedPayloadType + | YoutubeFeedPayloadType + | CsvFeedPayloadType + | VideoApiFeedPayloadType + | VimeoFeedPayloadType + | MsStreamFeedPayloadType + | null { + const fields = getAllFields(state); + const isSelfServeUser = selectors.isSelfServeUserSelector(state); + const metadataPlatformModels = selectors.metadataPlatformModelsSelector(state); + + if (type === TYPE_MRSS) return getMrssFields(fields, isSelfServeUser, metadataPlatformModels); + if (type === TYPE_TIKTOK) return getTikTokFields(fields, isSelfServeUser, metadataPlatformModels); + if (type === TYPE_RSS) return getRssFields(fields); + if (type === TYPE_SITEMAP) return getSitemapFields(fields); + if (type === TYPE_STORY_API) return getStoryApiFields(fields); + if (type === TYPE_MANUAL) return getManualFields(fields, metadataPlatformModels); + if (type === TYPE_YOUTUBE) return getYoutubeFields(fields, metadataPlatformModels); + if (type === TYPE_CSV) return getCsvFields(fields, metadataPlatformModels); + if (type === TYPE_VIDEO_API) return getVideoApiFields(fields, metadataPlatformModels); + if (type === TYPE_VIMEO) return getVimeoApiFields(fields, metadataPlatformModels); + if (type === TYPE_MS_STREAM) return getMsStreamFields(fields, isSelfServeUser, metadataPlatformModels); + + return null; +} diff --git a/anyclip/src/modules/feeds/Editor/helpers/validationScheme.ts b/anyclip/src/modules/feeds/Editor/helpers/validationScheme.ts new file mode 100644 index 0000000..f08ebdc --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/helpers/validationScheme.ts @@ -0,0 +1,620 @@ +import { + ACCESS_LEVEL_HUB, + ACCESS_LEVEL_PRIVATE, + CONTENT_OWNER_LIVE_EVENT_TYPE, + CONTENT_OWNER_STATUS_DISABLED, + FILE_SELECTION_BITRATE, + FILE_SELECTION_NONE, + FILE_SELECTION_RESOLUTION, + SCHEDULE_TYPE_CUSTOM, + TAB_ADVANCED, + TAB_GENERAL, +} from '../constants'; +import { + TYPE_CSV, + TYPE_MANUAL, + TYPE_MRSS, + TYPE_MS_STREAM, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_TIKTOK, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_YOUTUBE, +} from '@/modules/feeds/constants'; + +import { isNumber } from '@/modules/@common/helpers/number'; +import { StateType } from '@/modules/feeds/Editor/redux/slices'; + +const urlRegex = /^(https?):\/\/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)/; +const versionAttributeNameRegex = /^[a-zA-Z]+$/; + +const requiredFieldsForAdminFeedsByType = { + name: [ + TYPE_MRSS, + TYPE_TIKTOK, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_MANUAL, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + ], + account: [ + TYPE_MRSS, + TYPE_TIKTOK, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_MANUAL, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_MS_STREAM, + ], + description: [ + TYPE_MRSS, + TYPE_TIKTOK, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_MANUAL, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_MS_STREAM, + ], + accessPublishers: [ + TYPE_MRSS, + TYPE_TIKTOK, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_MANUAL, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_MS_STREAM, + ], + accessVideoOwner: [ + TYPE_MRSS, + TYPE_TIKTOK, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_MANUAL, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_MS_STREAM, + ], + url: [TYPE_MRSS, TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API, TYPE_CSV, TYPE_VIDEO_API], + feedPriority: [TYPE_MRSS, TYPE_MANUAL, TYPE_YOUTUBE, TYPE_CSV, TYPE_VIDEO_API, TYPE_VIMEO], + maxDuration: [TYPE_MRSS, TYPE_YOUTUBE, TYPE_TIKTOK, TYPE_CSV, TYPE_VIDEO_API, TYPE_VIMEO, TYPE_MS_STREAM], + maxDurationForTagging: [ + TYPE_MRSS, + TYPE_MANUAL, + TYPE_YOUTUBE, + TYPE_TIKTOK, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_MS_STREAM, + ], + versionAttributeName: [TYPE_MRSS, TYPE_MANUAL], + loadFromLastDays: [TYPE_TIKTOK, TYPE_VIMEO], + scheduleFrequency: [ + TYPE_MRSS, + TYPE_TIKTOK, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + TYPE_MS_STREAM, + ], + resolutionValue: [ + TYPE_MRSS, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + ], + minResolutionValue: [ + TYPE_MRSS, + TYPE_RSS, + TYPE_SITEMAP, + TYPE_STORY_API, + TYPE_YOUTUBE, + TYPE_CSV, + TYPE_VIDEO_API, + TYPE_VIMEO, + ], + bitrate: [TYPE_MRSS, TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API, TYPE_YOUTUBE, TYPE_CSV, TYPE_VIDEO_API, TYPE_VIMEO], + minBitrate: [TYPE_MRSS, TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API, TYPE_YOUTUBE, TYPE_CSV, TYPE_VIDEO_API, TYPE_VIMEO], + maxStories: [TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API], + videoDuration: [TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API], + videoMaxZoom: [TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API], + youTubeChannelId: [TYPE_YOUTUBE, TYPE_MS_STREAM], + user: [TYPE_VIMEO, TYPE_MS_STREAM], + password: [TYPE_VIMEO, TYPE_MS_STREAM], +} as const; + +const requiredFieldsForSelfServeFeedsByType = { + description: [TYPE_TIKTOK, TYPE_MRSS, TYPE_MS_STREAM], + accessPublishers: [TYPE_TIKTOK, TYPE_MRSS, TYPE_MS_STREAM], + accessVideoOwner: [TYPE_TIKTOK, TYPE_MRSS, TYPE_MS_STREAM], + url: [TYPE_MRSS], + scheduleFrequency: [TYPE_MRSS], + loadFromLastDays: [TYPE_TIKTOK], + user: [TYPE_MS_STREAM], + password: [TYPE_MS_STREAM], + youTubeChannelId: [TYPE_MS_STREAM], +} as const; + +const shouldValidate = (fieldName: string, type: string, isSelfServeUser: boolean) => { + const fields = (isSelfServeUser + ? requiredFieldsForSelfServeFeedsByType + : requiredFieldsForAdminFeedsByType) as unknown as Record; + + return !!fields[fieldName]?.includes(type); +}; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value: string, formFields: StateType) => { + if (!shouldValidate('name', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'account', + tabId: TAB_GENERAL, + validation: (value: StateType['account'], formFields: StateType) => { + if (!shouldValidate('account', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value?.contentOwners) { + const isAllowedContentOwners = value.contentOwners.some( + (co) => co.type !== CONTENT_OWNER_LIVE_EVENT_TYPE && co.status !== CONTENT_OWNER_STATUS_DISABLED, + ); + + if (!isAllowedContentOwners) { + return 'Only allowed content owners are: Not Live Event, Not Disabled.'; + } + } + + return ''; + }, + }, + { + fieldName: 'description', + tabId: TAB_GENERAL, + validation: (value: string, formFields: StateType) => { + if (!shouldValidate('description', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'accessPublishers', + tabId: TAB_GENERAL, + validation: (value: unknown[], formFields: StateType) => { + if (!shouldValidate('accessPublishers', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (formFields.accessLevel !== ACCESS_LEVEL_HUB || formFields.accessAllPublishers) { + return ''; + } + + if (!value.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'accessVideoOwner', + tabId: TAB_GENERAL, + validation: (value: unknown, formFields: StateType) => { + if (!shouldValidate('accessVideoOwner', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (formFields.accessLevel !== ACCESS_LEVEL_PRIVATE) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'url', + tabId: TAB_GENERAL, + validation: (value: string, formFields: StateType) => { + if (!shouldValidate('url', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (!urlRegex.test(value)) { + return 'Please provide correct url'; + } + + return ''; + }, + }, + { + fieldName: 'scheduleFrequency', + tabId: TAB_GENERAL, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('scheduleFrequency', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (formFields.scheduleType !== SCHEDULE_TYPE_CUSTOM) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 1 || value > 10000) { + return 'Please enter a value between 1 and 10,000'; + } + + return ''; + }, + }, + { + fieldName: 'feedPriority', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('feedPriority', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value < 0.01 || value > 1) { + return 'Please enter a value between 0.01 and 1'; + } + + return ''; + }, + }, + { + fieldName: 'maxDuration', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('maxDuration', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value < 10 || value > 7200) { + return 'Please enter a value between 10 and 7200'; + } + + return ''; + }, + }, + { + fieldName: 'maxDurationForTagging', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('maxDurationForTagging', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value < 1 || value > 10000) { + return 'Please enter a value between 1 and 10.000'; + } + + return ''; + }, + }, + { + fieldName: 'versionAttributeName', + tabId: TAB_ADVANCED, + validation: (value: string, formFields: StateType) => { + if (!shouldValidate('versionAttributeName', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!formFields.processVideoVersions) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (!versionAttributeNameRegex.test(value)) { + return 'Letters only, no spaces'; + } + + return ''; + }, + }, + { + fieldName: 'loadFromLastDays', + tabId: TAB_GENERAL, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('loadFromLastDays', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value < 1 || value > 9999) { + return 'Field should be 1 to 9999'; + } + + return ''; + }, + }, + { + fieldName: 'youTubeChannelId', + tabId: TAB_GENERAL, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('youTubeChannelId', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'user', + tabId: TAB_GENERAL, + validation: (value: string, formFields: StateType) => { + if (!shouldValidate('user', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'password', + tabId: TAB_GENERAL, + validation: (value: string, formFields: StateType) => { + if (!shouldValidate('password', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'resolutionValue', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('resolutionValue', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (formFields.fileSelection === FILE_SELECTION_NONE || formFields.fileSelection !== FILE_SELECTION_RESOLUTION) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 0 || value > 10000) { + return 'Please enter a value between 0 and 10,000'; + } + + return ''; + }, + }, + { + fieldName: 'minResolutionValue', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('minResolutionValue', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (formFields.fileSelection === FILE_SELECTION_NONE || formFields.fileSelection !== FILE_SELECTION_RESOLUTION) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 190 || value > 10000) { + return 'Please enter a value between 190 and 10,000'; + } + + return ''; + }, + }, + { + fieldName: 'bitrate', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('bitrate', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (formFields.fileSelection === FILE_SELECTION_NONE || formFields.fileSelection !== FILE_SELECTION_BITRATE) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 0 || value > 10000) { + return 'Please enter a value between 0 and 10,000'; + } + + return ''; + }, + }, + { + fieldName: 'minBitrate', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('minBitrate', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (formFields.fileSelection === FILE_SELECTION_NONE || formFields.fileSelection !== FILE_SELECTION_BITRATE) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 0 || value > 10000) { + return 'Please enter a value between 0 and 10,000'; + } + + return ''; + }, + }, + { + fieldName: 'maxStories', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('maxStories', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 1 || value > 10000) { + return 'Please enter a value between 1 and 10,000'; + } + + return ''; + }, + }, + { + fieldName: 'videoDuration', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('videoDuration', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 1 || value > 7200) { + return 'Please enter a value between 1 and 7,200'; + } + + return ''; + }, + }, + { + fieldName: 'videoMaxZoom', + tabId: TAB_ADVANCED, + validation: (value: number, formFields: StateType) => { + if (!shouldValidate('videoMaxZoom', formFields.type, formFields.isSelfServeUser)) { + return ''; + } + + if (!isNumber(value)) { + return 'Field cannot be empty'; + } + + if (value < 1 || value > 100) { + return 'Please enter a value between 1 and 100'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/feeds/Editor/hooks/useSetIsSelfServeUser.tsx b/anyclip/src/modules/feeds/Editor/hooks/useSetIsSelfServeUser.tsx new file mode 100644 index 0000000..24c7bec --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/hooks/useSetIsSelfServeUser.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +import { PCN_GET_SELF_SERVE_SOURCES } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { setAction } from '@/modules/feeds/Editor/redux/slices'; + +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; + +export default function useSetIsSelfServeUser() { + const dispatch = useAppDispatch(); + const userPermissions = useAppSelector(getUserPermissionsSelector); + const hasSelfServeSourcePermission = hasPermission(PCN_GET_SELF_SERVE_SOURCES, userPermissions); + + useEffect(() => { + dispatch(setAction({ isSelfServeUser: hasSelfServeSourcePermission })); + }, [hasSelfServeSourcePermission]); +} diff --git a/anyclip/src/modules/feeds/Editor/hooks/useSetStoreDefaultValues.tsx b/anyclip/src/modules/feeds/Editor/hooks/useSetStoreDefaultValues.tsx new file mode 100644 index 0000000..ddd3730 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/hooks/useSetStoreDefaultValues.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +import { FeedType } from '@/modules/feeds/types'; + +import { defaultStoreValuesDependsOnFeedType, setAction, StateType } from '../redux/slices'; + +import { useAppDispatch } from '@/modules/@common/store/hooks'; + +export default function useSetStoreDefaultValues(type: FeedType): void { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!type) return; + + const defaults = Object.fromEntries( + (Object.keys(defaultStoreValuesDependsOnFeedType) as Array) + .map((key) => [key, defaultStoreValuesDependsOnFeedType[key]?.[type]] as const) + .filter(([, value]) => value !== undefined), + ) as Partial; + + dispatch(setAction(defaults)); + }, [type]); +} diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/createItemAction.ts b/anyclip/src/modules/feeds/Editor/redux/epics/createItemAction.ts new file mode 100644 index 0000000..b0d7ac2 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/createItemAction.ts @@ -0,0 +1,69 @@ +import Router from 'next/router'; +import type { Action } from 'redux'; +import { type Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { CREATE_FEED_ITEM } from '@/graphql/services/feeds/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/feedItem'; +import type { FeedType } from '@/modules/feeds/types'; + +import * as selectors from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getRequestPayload } from '@/modules/feeds/Editor/helpers/requestPayload'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import type { RootState } from '@/modules/@common/store/store'; + +type ResponseType = { + data: { + id: number; + }; + errors: unknown[]; +}; + +const query = ` + mutation ${CREATE_FEED_ITEM}($payload: ${PAYLOAD_NAME}) { + ${CREATE_FEED_ITEM}(payload: $payload) { + id + } + } +`; + +const getAccountOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter((action): action is ReturnType => action.type === createItemAction.type), + switchMap(() => { + const type = selectors.typeSelector(state$.value) as FeedType; + const payload = getRequestPayload(type, state$.value); + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: ResponseType) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Feed created successfully', + }), + ), + ); + + Router.push('/feeds'); + } + + return concat(...actions); + }), + ); + }), + ); + +export default getAccountOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/getAccountOptions.ts b/anyclip/src/modules/feeds/Editor/redux/epics/getAccountOptions.ts new file mode 100644 index 0000000..24d775b --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/getAccountOptions.ts @@ -0,0 +1,71 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { GET_FEED_ACCOUNT_OPTIONS } from '@/graphql/services/feeds/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/accounts'; +import type { FeedType } from '@/modules/feeds/Editor/types'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type PayloadType = { + searchText: string; + pageSize: number; +}; + +const getResponse = (data: FeedType['account'][]) => data; + +const query = ` + query ${GET_FEED_ACCOUNT_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_FEED_ACCOUNT_OPTIONS}(payload: $payload) { + id + name + type + contentOwners { + type + status + } + } + } +`; + +const getAccountOptionsEpic: Epic = (action$) => + action$.pipe( + filter( + (action): action is ReturnType => action.type === getAccountOptionsAction.type, + ), + debounce((action) => { + const { searchText } = action.payload; + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const payload: PayloadType = { + searchText: action.payload.searchText, + pageSize: 30, + }; + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response.data[GET_FEED_ACCOUNT_OPTIONS]), + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getAccountOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/getHubsOptions.ts b/anyclip/src/modules/feeds/Editor/redux/epics/getHubsOptions.ts new file mode 100644 index 0000000..407b77f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/getHubsOptions.ts @@ -0,0 +1,75 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { GET_FEED_HUBS_OPTIONS } from '@/graphql/services/feeds/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/hubs'; + +import { accountSelector, isSelfServeUserSelector } from '../selectors'; +import { getHubsOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; +import { getUserAccountIdSelector } from '@/modules/@common/user/redux/selectors'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type PayloadType = { + searchText: string; + pageSize: number; + accountId: number; +}; + +type HubType = { + id: number; + name: string; +}; + +const getResponse = (data: HubType[]) => data; + +const query = ` + query ${GET_FEED_HUBS_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_FEED_HUBS_OPTIONS}(payload: $payload) { + id + name + } + } +`; + +const getHubsOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getHubsOptionsAction.type), + debounce((action) => { + const { searchText } = action.payload; + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const isSelfServeUser = isSelfServeUserSelector(state$.value); + const userAccountId = getUserAccountIdSelector(state$.value); + const accountId = accountSelector(state$.value)?.id; + const payload: PayloadType = { + searchText: action.payload.searchText, + pageSize: 30, + accountId: isSelfServeUser ? +userAccountId! : accountId!, + }; + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + setAction({ + hubsOptions: getResponse(response.data[GET_FEED_HUBS_OPTIONS]), + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getHubsOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/getItem.ts b/anyclip/src/modules/feeds/Editor/redux/epics/getItem.ts new file mode 100644 index 0000000..520dd57 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/getItem.ts @@ -0,0 +1,170 @@ +import Router from 'next/router'; +import type { Action } from 'redux'; +import { type Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { GET_FEED_ITEM } from '@/graphql/services/feeds/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/feedItem'; +import type { FeedType } from '@/modules/feeds/Editor/types'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import type { RootState } from '@/modules/@common/store/store'; + +type PayloadType = { + id: number; +}; + +type ResponseType = { + data: { + [key in T]: Entity; + }; + errors: unknown[]; +}; + +const query = ` + query ${GET_FEED_ITEM}($payload: ${PAYLOAD_NAME}) { + ${GET_FEED_ITEM}(payload: $payload) { + id + type + name + description + status + source { + isConnected + importParticipantsNames + } + account { + id + name + } + lang + accessLevel + accessAllPublishers + accessPublishers { + id + name + } + accessVideoOwner { + id + name + } + url + user + password + isUseForDownload + authMethod + + scheduleStatus + scheduleType + scheduleValue + scheduleFrequency + + feedPriority + priorityVerification + + iabCategories + defaultTimezone + defaultTimezoneEnabled + + isEvergreenFeed + isRestricted + keywords + parseKeywordsToLabels + maxDuration + importPlot + fileSelection + resolution + resolutionValue + minResolutionValue + bitrate + minBitrate + videoFileType + importCCFromMrssFile + createClip + skipTaggingForLongClips + maxDurationForTagging + immediateAvailability + processVideoVersions + versionAttributeName + maxStories + videoDuration + aspectRatio + fit + jsRendering + speechToTextProvider + loadFromLastDays + discardLongClips + fillLandingPage + videoMaxZoom + + contentOwnerIsPublic + + youtubeContentType + youTubeChannelId + youTubeLoadFromDate + contentType + importShortsThumbnail + + automationScript + platformModels { + config + enabled + model + platform + } + } + } +`; + +const getResponse = (data: FeedType) => data; + +const getAccountOptionsEpic: Epic = (action$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getItemAction.type), + switchMap((action) => { + const payload: PayloadType = { + id: action.payload.id, + }; + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: ResponseType) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open for edit", + }), + ), + ); + + Router.push('/feeds'); + } else { + const feedData = getResponse(response.data[GET_FEED_ITEM]); + actions.push( + of( + setAction({ + ...feedData, + }), + ), + ); + } + + return concat(...actions); + }), + ); + }), + ); + +export default getAccountOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/getMetadata.ts b/anyclip/src/modules/feeds/Editor/redux/epics/getMetadata.ts new file mode 100644 index 0000000..651e7d8 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/getMetadata.ts @@ -0,0 +1,93 @@ +import Router from 'next/router'; +import type { Action } from 'redux'; +import { type Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { GET_FEED_METADATA } from '@/graphql/services/feeds/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import type { MetadataFeed } from '@/modules/feeds/Editor/types'; + +import { getItemAction, getMetadataAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import type { RootState } from '@/modules/@common/store/store'; + +type ResponseType = { + data: { + [key in T]: Entity; + }; + errors: unknown[]; +}; + +const query = ` + query ${GET_FEED_METADATA} { + ${GET_FEED_METADATA} { + languages { + language + id + code + } + platformModels { + provider + providerDisplayName + models { + name + category + description + features + } + } + speechToTextLanguages { + name + value + languages + } + timezones { + name + value + } + } + } +`; + +const getResponse = (data: MetadataFeed) => data; + +const getAccountOptionsEpic: Epic = (action$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getMetadataAction.type), + switchMap((action) => + gqlRequest({ + query, + }).pipe( + switchMap((response: ResponseType) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't get feed metadata", + }), + ), + ); + + Router.push('/feeds'); + } else { + const metadata = getResponse(response.data[GET_FEED_METADATA]); + actions.push(of(setAction({ metadata }))); + if (action.payload.id) { + actions.push(of(getItemAction({ id: action.payload.id }))); + } + } + + return concat(...actions); + }), + ), + ), + ); + +export default getAccountOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/getOAuthClientId.ts b/anyclip/src/modules/feeds/Editor/redux/epics/getOAuthClientId.ts new file mode 100644 index 0000000..ff7f793 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/getOAuthClientId.ts @@ -0,0 +1,65 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { GET_OAUTH_CLIENT_ID } from '@/graphql/services/feeds/constants'; +import { OAUTH_CLIENT_IDS_PAYLOAD_MAPPER } from '@/modules/feeds/Editor/components/FormElements/Authorization/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/oAuthClient'; +import type { OAuthFeedType } from '@/modules/feeds/Editor/components/FormElements/Authorization/types'; + +import { typeSelector } from '../selectors'; +import { getOAuthClientIdAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type PayloadType = { + type: (typeof OAUTH_CLIENT_IDS_PAYLOAD_MAPPER)[OAuthFeedType]; +}; + +type ResType = { + clientId: string; +}; + +const getResponse = (data: ResType) => data; + +const query = ` + query ${GET_OAUTH_CLIENT_ID}($payload: ${PAYLOAD_NAME}) { + ${GET_OAUTH_CLIENT_ID}(payload: $payload) { + clientId + } + } +`; + +const getHubsOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter( + (action): action is ReturnType => + action.type === getOAuthClientIdAction.type && typeSelector(state$.value) in OAUTH_CLIENT_IDS_PAYLOAD_MAPPER, + ), + switchMap(() => { + const type = typeSelector(state$.value) as OAuthFeedType; + const payload: PayloadType = { type: OAUTH_CLIENT_IDS_PAYLOAD_MAPPER[type] }; + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + setAction({ + oAuthClientId: getResponse(response.data[GET_OAUTH_CLIENT_ID]).clientId, + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getHubsOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/getOAuthToken.ts b/anyclip/src/modules/feeds/Editor/redux/epics/getOAuthToken.ts new file mode 100644 index 0000000..ffc9803 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/getOAuthToken.ts @@ -0,0 +1,68 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { GET_OAUTH_TOKEN } from '@/graphql/services/feeds/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/feedItem'; +import { OAuthFeedType } from '@/modules/feeds/Editor/components/FormElements/Authorization/types'; + +import { typeSelector } from '../selectors'; +import { getOAuthTokenAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; +import { getRequestPayload } from '@/modules/feeds/Editor/helpers/requestPayload'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type PayloadType = { + code: string; + type: OAuthFeedType; +}; + +type ResType = { + token: string; +}; + +const getResponse = (data: ResType) => data; + +const query = ` + query ${GET_OAUTH_TOKEN}($payload: ${PAYLOAD_NAME}) { + ${GET_OAUTH_TOKEN}(payload: $payload) { + token + } + } +`; + +const getHubsOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getOAuthTokenAction.type), + switchMap((action) => { + const { code } = action.payload; + const type = typeSelector(state$.value) as OAuthFeedType; + const body = getRequestPayload(type, state$.value); + const payload = { code, ...body }; + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + setAction({ + oAuthToken: getResponse(response.data[GET_OAUTH_TOKEN]).token, + source: { + isConnected: true, + }, + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getHubsOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/getOwnerOptions.ts b/anyclip/src/modules/feeds/Editor/redux/epics/getOwnerOptions.ts new file mode 100644 index 0000000..646331f --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/getOwnerOptions.ts @@ -0,0 +1,74 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { GET_FEED_OWNERS_OPTIONS } from '@/graphql/services/feeds/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/owners'; +import type { FeedType } from '@/modules/feeds/Editor/types'; + +import { accountSelector, isSelfServeUserSelector } from '../selectors'; +import { getOwnersOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; +import { getUserAccountIdSelector } from '@/modules/@common/user/redux/selectors'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type PayloadType = { + searchText: string; + pageSize: number; + accountId: number; +}; + +const getResponse = (data: FeedType['accessVideoOwner'][]) => data; + +const query = ` + query ${GET_FEED_OWNERS_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_FEED_OWNERS_OPTIONS}(payload: $payload) { + id + name + } + } +`; + +const getAccountOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter( + (action): action is ReturnType => action.type === getOwnersOptionsAction.type, + ), + debounce((action) => { + const { searchText } = action.payload; + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const isSelfServeUser = isSelfServeUserSelector(state$.value); + const userAccountId = getUserAccountIdSelector(state$.value); + const accountId = accountSelector(state$.value)?.id; + + const payload: PayloadType = { + searchText: action.payload.searchText, + pageSize: 30, + accountId: isSelfServeUser ? +userAccountId! : accountId!, + }; + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + setAction({ + ownersOptions: getResponse(response.data[GET_FEED_OWNERS_OPTIONS]), + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getAccountOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/index.ts b/anyclip/src/modules/feeds/Editor/redux/epics/index.ts new file mode 100644 index 0000000..e8e2d9d --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/index.ts @@ -0,0 +1,23 @@ +import { combineEpics } from 'redux-observable'; + +import createItemAction from './createItemAction'; +import getAccountOptions from './getAccountOptions'; +import getHubsOptions from './getHubsOptions'; +import getItems from './getItem'; +import getMetadata from './getMetadata'; +import getOAuthClientId from './getOAuthClientId'; +import getOAuthToken from './getOAuthToken'; +import getOwnerOptions from './getOwnerOptions'; +import updateItemAction from './updateItemAction'; + +export default combineEpics( + getItems, + getAccountOptions, + getMetadata, + getHubsOptions, + getOwnerOptions, + updateItemAction, + createItemAction, + getOAuthClientId, + getOAuthToken, +); diff --git a/anyclip/src/modules/feeds/Editor/redux/epics/updateItemAction.ts b/anyclip/src/modules/feeds/Editor/redux/epics/updateItemAction.ts new file mode 100644 index 0000000..220acd4 --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/epics/updateItemAction.ts @@ -0,0 +1,69 @@ +import Router from 'next/router'; +import type { Action } from 'redux'; +import { type Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { UPDATE_FEED_ITEM } from '@/graphql/services/feeds/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/feedItem'; +import type { FeedType } from '@/modules/feeds/types'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getRequestPayload } from '@/modules/feeds/Editor/helpers/requestPayload'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import type { RootState } from '@/modules/@common/store/store'; + +type ResponseType = { + data: { + id: number; + }; + errors: unknown[]; +}; + +const query = ` + mutation ${UPDATE_FEED_ITEM}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_FEED_ITEM}(payload: $payload) { + id + } + } +`; + +const getAccountOptionsEpic: Epic = (action$, state$) => + action$.pipe( + filter((action): action is ReturnType => action.type === updateItemAction.type), + switchMap(() => { + const type = selectors.typeSelector(state$.value) as FeedType; + const payload = getRequestPayload(type, state$.value); + + return gqlRequest({ + query, + variables: { payload }, + }).pipe( + switchMap((response: ResponseType) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Feed updated successfully', + }), + ), + ); + + Router.push('/feeds'); + } + + return concat(...actions); + }), + ); + }), + ); + +export default getAccountOptionsEpic; diff --git a/anyclip/src/modules/feeds/Editor/redux/selectors/index.ts b/anyclip/src/modules/feeds/Editor/redux/selectors/index.ts new file mode 100644 index 0000000..5ed1cff --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/selectors/index.ts @@ -0,0 +1,96 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +import type { RootState } from '@/modules/@common/store/store'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const idSelector = (state: RootState) => state[nameSpace].id; +export const nameSelector = (state: RootState) => state[nameSpace].name; +export const typeSelector = (state: RootState) => state[nameSpace].type; +export const descriptionSelector = (state: RootState) => state[nameSpace].description; +export const isConnectedSelector = (state: RootState) => state[nameSpace].source.isConnected; +export const sourceSelector = (state: RootState) => state[nameSpace].source; +export const accountSelector = (state: RootState) => state[nameSpace].account; +export const langSelector = (state: RootState) => state[nameSpace].lang; +export const accessLevelSelector = (state: RootState) => state[nameSpace].accessLevel; +export const accessAllPublishersSelector = (state: RootState) => state[nameSpace].accessAllPublishers; +export const accessPublishersSelector = (state: RootState) => state[nameSpace].accessPublishers; +export const accessVideoOwnerSelector = (state: RootState) => state[nameSpace].accessVideoOwner; + +export const metadataLanguagesSelector = (state: RootState) => state[nameSpace].metadata.languages; +export const metadataTimezonesSelector = (state: RootState) => state[nameSpace].metadata.timezones; +export const metadataPlatformModelsSelector = (state: RootState) => state[nameSpace].metadata.platformModels; +export const metadataSpeechToTextLanguagesSelector = (state: RootState) => + state[nameSpace].metadata.speechToTextLanguages; +export const platformModelsSelector = (state: RootState) => state[nameSpace].platformModels; +export const accountOptionsSelector = (state: RootState) => state[nameSpace].accountOptions; +export const activeTabIdSelector = (state: RootState) => state[nameSpace].activeTabId; +export const hubsOptionsSelector = (state: RootState) => state[nameSpace].hubsOptions; +export const ownersOptionsSelector = (state: RootState) => state[nameSpace].ownersOptions; +export const urlSelector = (state: RootState) => state[nameSpace].url; +export const userSelector = (state: RootState) => state[nameSpace].user; +export const passwordSelector = (state: RootState) => state[nameSpace].password; +export const isUseForDownloadSelector = (state: RootState) => state[nameSpace].isUseForDownload; +export const authMethodSelector = (state: RootState) => state[nameSpace].authMethod; +export const scheduleStatusSelector = (state: RootState) => state[nameSpace].scheduleStatus; +export const scheduleTypeSelector = (state: RootState) => state[nameSpace].scheduleType; +export const scheduleValueSelector = (state: RootState) => state[nameSpace].scheduleValue; +export const scheduleFrequencySelector = (state: RootState) => state[nameSpace].scheduleFrequency; +export const feedPrioritySelector = (state: RootState) => state[nameSpace].feedPriority; +export const priorityVerificationSelector = (state: RootState) => state[nameSpace].priorityVerification; +export const iabCategoriesSelector = (state: RootState) => state[nameSpace].iabCategories; +export const defaultTimezoneSelector = (state: RootState) => state[nameSpace].defaultTimezone; +export const defaultTimezoneEnabledSelector = (state: RootState) => state[nameSpace].defaultTimezoneEnabled; +export const isEvergreenFeedSelector = (state: RootState) => state[nameSpace].isEvergreenFeed; +export const isRestrictedSelector = (state: RootState) => state[nameSpace].isRestricted; +export const keywordsSelector = (state: RootState) => state[nameSpace].keywords; +export const parseKeywordsToLabelsSelector = (state: RootState) => state[nameSpace].parseKeywordsToLabels; +export const maxDurationSelector = (state: RootState) => state[nameSpace].maxDuration; +export const importPlotSelector = (state: RootState) => state[nameSpace].importPlot; +export const fileSelectionSelector = (state: RootState) => state[nameSpace].fileSelection; +export const resolutionSelector = (state: RootState) => state[nameSpace].resolution; +export const resolutionValueSelector = (state: RootState) => state[nameSpace].resolutionValue; +export const minResolutionValueSelector = (state: RootState) => state[nameSpace].minResolutionValue; +export const bitrateSelector = (state: RootState) => state[nameSpace].bitrate; +export const minBitrateSelector = (state: RootState) => state[nameSpace].minBitrate; +export const videoFileTypeSelector = (state: RootState) => state[nameSpace].videoFileType; +export const maxStoriesSelector = (state: RootState) => state[nameSpace].maxStories; +export const videoDurationSelector = (state: RootState) => state[nameSpace].videoDuration; +export const aspectRatioSelector = (state: RootState) => state[nameSpace].aspectRatio; +export const fitSelector = (state: RootState) => state[nameSpace].fit; +export const videoMaxZoomSelector = (state: RootState) => state[nameSpace].videoMaxZoom; + +export const importCCFromMrssFileSelector = (state: RootState) => state[nameSpace].importCCFromMrssFile; +export const createClipSelector = (state: RootState) => state[nameSpace].createClip; +export const skipTaggingForLongClipsSelector = (state: RootState) => state[nameSpace].skipTaggingForLongClips; +export const maxDurationForTaggingSelector = (state: RootState) => state[nameSpace].maxDurationForTagging; +export const immediateAvailabilitySelector = (state: RootState) => state[nameSpace].immediateAvailability; +export const processVideoVersionsSelector = (state: RootState) => state[nameSpace].processVideoVersions; +export const versionAttributeNameSelector = (state: RootState) => state[nameSpace].versionAttributeName; +export const automationScriptSelector = (state: RootState) => state[nameSpace].automationScript; +export const speechToTextProviderSelector = (state: RootState) => state[nameSpace].speechToTextProvider; +export const jsRenderingSelector = (state: RootState) => state[nameSpace].jsRendering; +export const loadFromLastDaysSelector = (state: RootState) => state[nameSpace].loadFromLastDays; +export const discardLongClipsSelector = (state: RootState) => state[nameSpace].discardLongClips; +export const fillLandingPageSelector = (state: RootState) => state[nameSpace].fillLandingPage; +export const youtubeContentTypeSelector = (state: RootState) => state[nameSpace].youtubeContentType; +export const youTubeChannelIdSelector = (state: RootState) => state[nameSpace].youTubeChannelId; +export const youTubeLoadFromDateSelector = (state: RootState) => state[nameSpace].youTubeLoadFromDate; +export const contentTypeSelector = (state: RootState) => state[nameSpace].contentType; +export const importShortsThumbnailSelector = (state: RootState) => state[nameSpace].importShortsThumbnail; + +export const oAuthClientIdSelector = (state: RootState) => state[nameSpace].oAuthClientId; +export const oAuthTokenSelector = (state: RootState) => state[nameSpace].oAuthToken; + +export const isSelfServeUserSelector = (state: RootState) => state[nameSpace].isSelfServeUser; +export const statusSelector = (state: RootState) => state[nameSpace].status; +export const contentOwnerIsPublicSelector = (state: RootState) => state[nameSpace].contentOwnerIsPublic; + +// forms +export const scrollFieldSelector = (state: RootState) => formSelectors.getScrollField(state); +export const schemeSelector = (state: RootState) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state: RootState) => state[nameSpace]; diff --git a/anyclip/src/modules/feeds/Editor/redux/slices/index.ts b/anyclip/src/modules/feeds/Editor/redux/slices/index.ts new file mode 100644 index 0000000..a407edc --- /dev/null +++ b/anyclip/src/modules/feeds/Editor/redux/slices/index.ts @@ -0,0 +1,261 @@ +import dayjs from 'dayjs'; +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +import { + ACCESS_LEVEL_HUB, + ASPECT_RATIO_16_9, + AUTH_METHOD_NOAUTH, + CONTENT_TYPE_ALL, + FILE_SELECTION_NONE, + FIT_FILL, + REDUX_FIELD_NAME, + RESOLUTION_HEIGHT, + SCHEDULE_TYPE_DAILY, + SPEECH_TO_TEXT_ANYCLIP_S2T_VALUE, + SPEECH_TO_TEXT_DISABLED_VALUE, + SPEECH_TO_TEXT_OPTIONS, + TAB_GENERAL, + VIDEO_FILE_TYPE_MP4M3U8, + YT_CONTENT_TYPE_CHANNEL, +} from '../../constants'; +import { STATUS_ACTIVE, TYPE_RSS, TYPE_SITEMAP, TYPE_STORY_API } from '@/modules/feeds/constants'; + +import { FeedType, MetadataFeed } from '../../types'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; +import { applyPartial } from '@/modules/@common/store/helpers'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export type StateType = FeedType & { + contentOwnerIsPublic: boolean; + accountOptions: FeedType['account'][] | null; + hubsOptions: FeedType['accessPublishers'] | null; + ownersOptions: FeedType['accessVideoOwner'][] | null; + activeTabId: string; + metadata: MetadataFeed; + oAuthClientId: string | null; + isSelfServeUser: boolean; +}; + +/** + * A lookup table of default values for different store state keys, + * where each state key can have different defaults depending on the feed type. + * + * This is used inside the `@/modules/feeds/Editor/hooks/useSetStoreDefaultValues` hook to reassign + * store state defaults dynamically based on the selected feed type. + * + * Structure: + * - Top-level keys: properties from `StateType` (e.g., `minResolutionValue`) + * - Second-level keys: specific `FeedType['type']` values or `'default'` for fallback + * - Values: can be of any type (`unknown`), but usually numbers, strings, booleans, arrays, or objects + * + * Example: + * defaultValuesByFeedType.minResolutionValue?.[TYPE_MRSS] // 720 + * defaultValuesByFeedType.minResolutionValue?.default // 720 (fallback) + */ +export const defaultStoreValuesDependsOnFeedType: Partial< + Record>> +> = { + minResolutionValue: { + initialStateValue: 720, + [TYPE_RSS]: 190, + [TYPE_SITEMAP]: 190, + [TYPE_STORY_API]: 190, + }, + speechToTextProvider: { + initialStateValue: SPEECH_TO_TEXT_ANYCLIP_S2T_VALUE, + [TYPE_RSS]: SPEECH_TO_TEXT_DISABLED_VALUE, + [TYPE_SITEMAP]: SPEECH_TO_TEXT_DISABLED_VALUE, + [TYPE_STORY_API]: SPEECH_TO_TEXT_DISABLED_VALUE, + }, +}; + +const initialState: StateType = { + type: '', + id: null, + name: '', + description: '', + status: STATUS_ACTIVE, + source: { + isConnected: false, + importParticipantsNames: false, + }, + account: null, + lang: 'EN', + accessLevel: ACCESS_LEVEL_HUB, + accessAllPublishers: false, + accessPublishers: [], + accessVideoOwner: null, + // auth + url: '', + user: '', + password: '', + isUseForDownload: false, + authMethod: AUTH_METHOD_NOAUTH, + + scheduleStatus: false, + scheduleType: SCHEDULE_TYPE_DAILY, + scheduleValue: '00:00', + scheduleFrequency: 60, + + feedPriority: 0.5, + priorityVerification: false, + + iabCategories: '[]', + + defaultTimezone: 'UTC', + defaultTimezoneEnabled: false, + + isEvergreenFeed: false, + isRestricted: false, + keywords: '', + parseKeywordsToLabels: false, + + maxDuration: 300, + importPlot: true, + + fileSelection: FILE_SELECTION_NONE, + resolution: RESOLUTION_HEIGHT, + resolutionValue: 720, + minResolutionValue: defaultStoreValuesDependsOnFeedType.minResolutionValue!.initialStateValue as number, + bitrate: 7000, + minBitrate: 4500, + + videoFileType: VIDEO_FILE_TYPE_MP4M3U8, + + importCCFromMrssFile: false, + createClip: true, + skipTaggingForLongClips: true, + maxDurationForTagging: 300, + immediateAvailability: false, + processVideoVersions: false, + versionAttributeName: '', + automationScript: '{}', + platformModels: [], + speechToTextProvider: defaultStoreValuesDependsOnFeedType.speechToTextProvider! + .initialStateValue as (typeof SPEECH_TO_TEXT_OPTIONS)[number]['value'], + loadFromLastDays: 1, + discardLongClips: false, + fillLandingPage: false, + youtubeContentType: YT_CONTENT_TYPE_CHANNEL, + youTubeChannelId: '', + youTubeLoadFromDate: dayjs(new Date()).format('YYYY-MM-DD'), + contentType: CONTENT_TYPE_ALL, + importShortsThumbnail: false, + // rss + maxStories: 12, + videoDuration: 6, + aspectRatio: ASPECT_RATIO_16_9, + fit: FIT_FILL, + videoMaxZoom: 10, + jsRendering: false, + + accountOptions: null, + hubsOptions: null, + ownersOptions: null, + activeTabId: TAB_GENERAL, + contentOwnerIsPublic: false, + metadata: { + languages: null, + platformModels: null, + speechToTextLanguages: null, + timezones: null, + }, + oAuthClientId: null, + oAuthToken: null, + isSelfServeUser: false, + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@FEEDS/EDITOR', + initialState, + reducers: { + setAction: (state: StateType, action: PayloadAction>) => { + applyPartial(state, action.payload); + }, + getMetadataAction: (state: StateType, action: PayloadAction<{ id: number | undefined }>) => { + if (action.payload) { + return state; + } + + return state; + }, + getItemAction: (state: StateType, action: PayloadAction<{ id: number }>) => { + if (action.payload) { + return state; + } + + return state; + }, + getAccountOptionsAction: (state: StateType, action: PayloadAction<{ searchText: string }>) => { + if (action.payload) { + return state; + } + + return state; + }, + getHubsOptionsAction: (state: StateType, action: PayloadAction<{ searchText: string }>) => { + if (action.payload) { + return state; + } + + return state; + }, + getOwnersOptionsAction: (state: StateType, action: PayloadAction<{ searchText: string }>) => { + if (action.payload) { + return state; + } + + return state; + }, + updateItemAction: (state: StateType, action: PayloadAction<{ id: number }>) => { + if (action.payload) { + return state; + } + + return state; + }, + createItemAction: (state: StateType) => state, + getOAuthClientIdAction: (state: StateType) => state, + getOAuthTokenAction: (state: StateType, action: PayloadAction<{ code: string }>) => { + if (action.payload) { + return state; + } + + return state; + }, + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + setInitialAction: (state: StateType) => { + applyPartial(state, initialState, ['isSelfServeUser']); + }, + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: (state: StateType, action) => + formSlice.actions.removeErrorByFieldNameAction(state, action), + }, +}); + +export const { + setAction, + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, + getMetadataAction, + getItemAction, + getAccountOptionsAction, + getHubsOptionsAction, + getOwnersOptionsAction, + setActiveTabIdAction, + setInitialAction, + updateItemAction, + createItemAction, + getOAuthClientIdAction, + getOAuthTokenAction, +} = slice.actions; + +export const { validateFields, validateSingleField } = formSlice; diff --git a/anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.module.scss b/anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.module.scss new file mode 100644 index 0000000..327a077 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__bY6NJ","EmptyContent":"Empty_EmptyContent__hqsGp"}; \ No newline at end of file diff --git a/anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.tsx b/anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.tsx new file mode 100644 index 0000000..f8b117c --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/components/Empty/Empty.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { AddRounded } from '@mui/icons-material'; + +import { TYPE_MENU_OPTIONS } from '@/modules/feeds/SelfServeList/constants'; + +import { typeToUrlSegment } from '@/modules/feeds/SelfServeList/helpers'; + +import { Button, Grid, Menu, MenuItem, Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +function Empty() { + const router = useRouter(); + const [showCreateMenu, setShowCreateMenu] = useState(null); + + return ( + + + empty-logo + + Click below to create your first source + + <> + + setShowCreateMenu(null)}> + {TYPE_MENU_OPTIONS.map((menu) => ( + router.push(`/feeds/new/${typeToUrlSegment(menu.value)}`)}> + {menu.label} + + ))} + + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/feeds/SelfServeList/components/ImportDialog/ImportDialog.tsx b/anyclip/src/modules/feeds/SelfServeList/components/ImportDialog/ImportDialog.tsx new file mode 100644 index 0000000..57ec635 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/components/ImportDialog/ImportDialog.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; + +import { importArchivedAction } from '../../redux/slices'; + +import { useAppDispatch } from '@/modules/@common/store/hooks'; +import { + Button, + Checkbox, + Chip, + DatePicker, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + InputAdornment, + Stack, + Typography, +} from '@/mui/components'; + +type Props = { + feedId: number; + onClose: () => void; +}; + +export default function ImportDialog({ feedId, onClose }: Props) { + const dispatch = useAppDispatch(); + + const [date, setDate] = useState(new Date()); + const [isNotify, setIsNotify] = useState(true); + + const handleImport = () => { + dispatch(importArchivedAction({ feedId, date, isEmailNotification: isNotify })); + onClose(); + }; + + return ( + + Import archived recordings + + + setDate(d ? d.toDate() : new Date())} + disableFuture + format="MM/DD/YYYY" + slotProps={{ + textField: { + size: 'small', + InputProps: { + startAdornment: ( + + + + ), + }, + }, + }} + /> + + + + + setIsNotify(!isNotify)} color="primary" /> + } + label={ + + Notify me by email when video is ready + + } + /> + + + + + + + + ); +} diff --git a/anyclip/src/modules/feeds/SelfServeList/components/List.module.scss b/anyclip/src/modules/feeds/SelfServeList/components/List.module.scss new file mode 100644 index 0000000..b58b443 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"StatusSelect":"List_StatusSelect__YyDrl","AutoImportSelect":"List_AutoImportSelect__FAGX2","TypeSelect":"List_TypeSelect__idqrR","AccountSelect":"List_AccountSelect__R_dUW","Row":"List_Row__zwwZQ","NoWrap":"List_NoWrap__Tc_FX"}; \ No newline at end of file diff --git a/anyclip/src/modules/feeds/SelfServeList/components/List.tsx b/anyclip/src/modules/feeds/SelfServeList/components/List.tsx new file mode 100644 index 0000000..b06ec79 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/components/List.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useState } from 'react'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useRouter } from 'next/router'; +import { AddRounded, DownloadRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; + +import { STATUS_ACTIVE, TYPE_INSTAGRAM, TYPE_SHAREPOINT } from '../../constants'; +import { SEARCH_TEXT_MAX_LENGTH, STATUS_OPTIONS, TYPE_MENU_OPTIONS } from '../constants'; + +import type { FeedsDataRecordType, StateFiltersType, StateTableSliceType } from '../types'; + +import { getConfigHeaders, typeToUrlSegment } from '../helpers'; +import * as selectors from '../redux/selectors'; +import { getDataAction, setAction, setTableAction } from '../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import { useAppDispatch, useAppSelector } from '@/modules/@common/store/hooks'; +import CommonTable, { TableCellActions } from '@/modules/@common/Table'; +import Empty from '../components/Empty/Empty'; +import ImportDialog from '../components/ImportDialog/ImportDialog'; +import StatusCell from '../components/StatusCell/StatusCell'; +import TypeCell from '../components/TypeCell/TypeCell'; +import { + Autocomplete, + Button, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './List.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +type StateFlattenType = StateTableSliceType & StateFiltersType; + +function List() { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const [showCreateMenu, setShowCreateMenu] = useState(null); + const [showImportDialog, setShowImportDialog] = useState(null); + + const data = useAppSelector(selectors.dataSelector); + const page = useAppSelector(selectors.pageSelector); + const pageSize = useAppSelector(selectors.pageSizeSelector); + const totalCount = useAppSelector(selectors.totalCountSelector); + const sortBy = useAppSelector(selectors.sortBySelector); + const sortOrder = useAppSelector(selectors.sortOrderSelector); + const selected = useAppSelector(selectors.selectedSelector); + + // filters + const searchText = useAppSelector(selectors.searchTextSelector); + const status = useAppSelector(selectors.statusSelector); + + const shouldShowEmpty = useAppSelector(selectors.shouldShowEmptySelector); + + const handleFilter = (filter: Partial) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + const res = omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }); + + dispatch(setTableAction(res)); + + dispatch(setAction({ ...mainState })); + dispatch(getDataAction()); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + +
    + handleFilter({ searchText: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
    + + + + + + + o.value === status) || null} + options={STATUS_OPTIONS} + renderInput={(params$) => } + onChange={(e, selected$) => { + const selectedStatus = selected$ as { value: StateFlattenType['status'] }; + handleFilter({ + status: selectedStatus?.value || null, + page: 1, + }); + }} + /> + + } + renderActions={ + + <> + + setShowCreateMenu(null)}> + {TYPE_MENU_OPTIONS.map((menu) => ( + router.push(`/feeds/new/${typeToUrlSegment(menu.value)}`)}> + {menu.label} + + ))} + + + + } + > + {shouldShowEmpty ? ( + + ) : ( + { + const shouldShowImport = + row.status === STATUS_ACTIVE && + row.source?.isConnected && + !row.archived && + ![TYPE_SHAREPOINT, TYPE_INSTAGRAM].includes(row.type); + + return ( + router.push(`/feeds/${row.id}/${typeToUrlSegment(row.type)}`)} + > + +
    {row.id}
    +
    + {row.type && } + +
    {row.description}
    +
    + +
    {row.lang}
    +
    + +
    + +
    +
    + +
    {row.schedule_status ? 'On' : 'Off'}
    +
    + +
    {row.updatedBy}
    +
    + +
    {dayjs(row.updateDate).format('MMM D, YYYY hh:mm A')}
    +
    + + {shouldShowImport && ( + + { + e.stopPropagation(); + setShowImportDialog(row.id); + }} + > + + + + )} + +
    + ); + }} + data={data || []} + selected={selected as unknown as never[]} // TypeScript see CommonTable selected props as never + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onFilter={handleFilter} + /> + )} + + {showImportDialog && setShowImportDialog(null)} />} +
    + ); +} + +export default List; diff --git a/anyclip/src/modules/feeds/SelfServeList/components/StatusCell/StatusCell.tsx b/anyclip/src/modules/feeds/SelfServeList/components/StatusCell/StatusCell.tsx new file mode 100644 index 0000000..8c0ccba --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/components/StatusCell/StatusCell.tsx @@ -0,0 +1,50 @@ +import React, { type ReactNode } from 'react'; +import { CheckCircle, DoDisturbAlt, LinkOff } from '@mui/icons-material'; + +import { STATUS_ACTIVE, STATUS_ARCHIVED, STATUS_DISCONNECTED, TYPE_MRSS, TYPE_MS_STREAM } from '../../../constants'; +import { STATUS_ACTIVE_TITLE, STATUS_ARCHIVED_TITLE, STATUS_DISCONNECTED_TITLE } from '../../constants'; + +import { FeedsDataRecordType, FeedsType, StatusType } from '@/modules/feeds/SelfServeList/types'; + +import { Stack, Typography } from '@/mui/components'; + +type Props = { + status: StatusType; + source?: FeedsDataRecordType['source']; + type: FeedsType; +}; + +const ICONS: Record = { + [STATUS_ACTIVE]: , + [STATUS_ARCHIVED]: , + [STATUS_DISCONNECTED]: , +}; + +const TITLES: Record = { + [STATUS_ACTIVE]: STATUS_ACTIVE_TITLE, + [STATUS_ARCHIVED]: STATUS_ARCHIVED_TITLE, + [STATUS_DISCONNECTED]: STATUS_DISCONNECTED_TITLE, +}; + +const normalizeStatus = (status: number, type: FeedsType, source?: FeedsDataRecordType['source']): number => { + if (status === -1) return STATUS_ARCHIVED; + if (status === 1 && type === TYPE_MRSS) return STATUS_ACTIVE; + + if (type === TYPE_MS_STREAM) return STATUS_ACTIVE; + + if (!source?.isConnected) return STATUS_DISCONNECTED; + if (source?.isConnected && status === 1) return STATUS_ACTIVE; + return status; +}; + +export default function StatusCell({ status, source, type }: Props) { + const s = normalizeStatus(status, type, source); + return ( + + {ICONS[s]} + + {TITLES[s]} + + + ); +} diff --git a/anyclip/src/modules/feeds/SelfServeList/components/TypeCell/TypeCell.tsx b/anyclip/src/modules/feeds/SelfServeList/components/TypeCell/TypeCell.tsx new file mode 100644 index 0000000..a494567 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/components/TypeCell/TypeCell.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import Image from 'next/image'; + +import { + TYPE_INSTAGRAM, + TYPE_MRSS, + TYPE_MS_STREAM, + TYPE_SHAREPOINT, + TYPE_TEAMS, + TYPE_TIKTOK, + TYPE_ZOOM, +} from '@/modules/feeds/constants'; + +import { FeedsType } from '@/modules/feeds/SelfServeList/types'; + +import instagram from '@/assets/img/source-icons/instagram.svg'; +import mrss from '@/assets/img/source-icons/mrss.svg'; +import msStream from '@/assets/img/source-icons/ms_stream.svg'; +import sharepoint from '@/assets/img/source-icons/sharepoint.svg'; +import teams from '@/assets/img/source-icons/teams.svg'; +import tiktok from '@/assets/img/source-icons/tiktok.svg'; +import zoom from '@/assets/img/source-icons/zoom.svg'; + +const ICONS = { + [TYPE_INSTAGRAM]: instagram, + [TYPE_MS_STREAM]: msStream, + [TYPE_SHAREPOINT]: sharepoint, + [TYPE_TEAMS]: teams, + [TYPE_ZOOM]: zoom, + [TYPE_TIKTOK]: tiktok, + [TYPE_MRSS]: mrss, +} as const; + +type Props = { + type: FeedsType; +}; + +export default function TypeCell({ type }: Props) { + const Icon = ICONS[type] as HTMLImageElement; + return {type}; +} diff --git a/anyclip/src/modules/feeds/SelfServeList/constants/index.tsx b/anyclip/src/modules/feeds/SelfServeList/constants/index.tsx new file mode 100644 index 0000000..cc83991 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/constants/index.tsx @@ -0,0 +1,67 @@ +import { + STATUS_ACTIVE, + STATUS_ARCHIVED, + STATUS_DISCONNECTED, + TYPE_INSTAGRAM, + TYPE_MRSS, + TYPE_MS_STREAM, + TYPE_SHAREPOINT, + TYPE_TEAMS, + TYPE_TIKTOK, + TYPE_ZOOM, +} from '../../constants'; + +export const ROWS_PER_PAGE_DEFAULT = 15; +export const TABLE_SORT_BY = 'updatedDate'; +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const STATUS_ACTIVE_TITLE = 'Active'; +export const STATUS_ARCHIVED_TITLE = 'Archived'; +export const STATUS_DISCONNECTED_TITLE = 'Disconnected'; + +export const STATUS_OPTIONS = [ + { + label: STATUS_ACTIVE_TITLE, + value: STATUS_ACTIVE, + }, + { + label: STATUS_DISCONNECTED_TITLE, + value: STATUS_DISCONNECTED, + }, + { + label: STATUS_ARCHIVED_TITLE, + value: STATUS_ARCHIVED, + }, +]; + +export const TYPE_MENU_OPTIONS = [ + { + label: 'Microsoft Teams', + value: TYPE_TEAMS, + }, + { + label: 'Zoom', + value: TYPE_ZOOM, + }, + { + label: 'SharePoint', + value: TYPE_SHAREPOINT, + }, + { + label: 'Instagram', + value: TYPE_INSTAGRAM, + }, + { + label: 'TikTok', + value: TYPE_TIKTOK, + }, + { + label: 'Microsoft Stream', + value: TYPE_MS_STREAM, + }, + { + label: 'MRSS', + value: TYPE_MRSS, + }, +]; diff --git a/anyclip/src/modules/feeds/SelfServeList/helpers/index.tsx b/anyclip/src/modules/feeds/SelfServeList/helpers/index.tsx new file mode 100644 index 0000000..031d7ee --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/helpers/index.tsx @@ -0,0 +1,62 @@ +type HeaderType = { + id: string; + label: string; + sortable?: boolean; + width?: string; +}; + +export const getConfigHeaders = (): HeaderType[] => [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '100', + }, + { + id: 'type', + label: 'Type', + sortable: true, + width: '100', + }, + { + id: 'description', + label: 'Name', + sortable: true, + }, + { + id: 'lang', + label: 'Language', + sortable: true, + width: '100', + }, + { + id: 'status', + label: 'Status', + sortable: true, + width: '100', + }, + { + id: 'schedule_status', + label: 'Auto Import', + sortable: true, + width: '100', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: '100', + }, + { + id: 'updatedDate', + label: 'Updated Date', + sortable: true, + width: '100', + }, + { + id: 'actions', + label: '', + }, +]; + +export const typeToUrlSegment = (type: string) => type.replace(/_/g, '-').toLowerCase(); diff --git a/anyclip/src/modules/feeds/SelfServeList/redux/epics/getData.ts b/anyclip/src/modules/feeds/SelfServeList/redux/epics/getData.ts new file mode 100644 index 0000000..7806034 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/redux/epics/getData.ts @@ -0,0 +1,92 @@ +import { GET_SELF_SERVE_FEEDS } from '@/graphql/services/feeds/constants'; + +import { FeedsDataRecordsType } from '../../types'; +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/selfserve/list'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type RequestVariablesType = { + page: number; + pageSize: number; + sortBy: string; + sortOrder: string; + searchText?: string; + searchIn: string[]; + filters?: string[]; + filtersValues?: number[]; +}; + +type DataType = Record< + string, + { + records: FeedsDataRecordsType; + recordsTotal: number; + allRecordsCount: number; + } +>; + +const gqlQuery = ` + query ${GET_SELF_SERVE_FEEDS}($payload: ${PAYLOAD_NAME}) { + ${GET_SELF_SERVE_FEEDS}(payload: $payload) { + records { + id + type + description + lang + status + schedule_status + source { + isConnected + } + archived + updatedBy + updateDate + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state: RootState) => { + const status = selectors.statusSelector(state); + const variables: RequestVariablesType = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchTextSelector(state), + searchIn: ['id', 'name', 'description'], + }; + + if (typeof status === 'number') { + variables.filters = ['status']; + variables.filtersValues = [status]; + } + + // API for data has updatedDate but for filter request updateDate + if (variables.sortBy === 'updatedDate') { + variables.sortBy = 'updateDate'; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }: { data: DataType }) => { + const feeds = data[GET_SELF_SERVE_FEEDS]; + + return { + records: feeds.records, + recordsTotal: feeds.recordsTotal, + allRecordsCount: feeds.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/feeds/SelfServeList/redux/epics/importArchived.ts b/anyclip/src/modules/feeds/SelfServeList/redux/epics/importArchived.ts new file mode 100644 index 0000000..9c3f69f --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/redux/epics/importArchived.ts @@ -0,0 +1,51 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { IMPORT_ARCHIVED_SELF_SERVE_FEEDS } from '@/graphql/services/feeds/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/feeds/types/payload/selfserve/importArchived'; + +import { importArchivedAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import type { RootState } from '@/modules/@common/store/store'; + +const query = ` + mutation ${IMPORT_ARCHIVED_SELF_SERVE_FEEDS}($payload: ${PAYLOAD_NAME}) { + ${IMPORT_ARCHIVED_SELF_SERVE_FEEDS}(payload: $payload) + } +`; + +const importArchived: Epic = (action$) => + action$.pipe( + filter((action): action is ReturnType => action.type === importArchivedAction.type), + switchMap((action) => { + const $stream = gqlRequest({ + query, + variables: { payload: action.payload }, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Your recordings are being imported into AnyClip.', + }), + ), + ); + } + return EMPTY; + }), + ); + + return concat($stream); + }), + ); + +export default importArchived; diff --git a/anyclip/src/modules/feeds/SelfServeList/redux/epics/index.ts b/anyclip/src/modules/feeds/SelfServeList/redux/epics/index.ts new file mode 100644 index 0000000..cd609d8 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/redux/epics/index.ts @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getData from './getData'; +import importArchived from './importArchived'; + +export default combineEpics(getData, importArchived); diff --git a/anyclip/src/modules/feeds/SelfServeList/redux/selectors/index.ts b/anyclip/src/modules/feeds/SelfServeList/redux/selectors/index.ts new file mode 100644 index 0000000..f1c0e0a --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/redux/selectors/index.ts @@ -0,0 +1,40 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; +import { STATUSES_ALL } from '@/modules/xRay/creatives/List/constants'; + +import type { FeedsDataRecordsType } from '../../types'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +import type { RootState } from '@/modules/@common/store/store'; + +type OrderType = typeof SORT_ASC | typeof SORT_DESC; + +const nameSpace = slice.name; +const tableSelectors = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// table +export const dataSelector = (state: RootState) => tableSelectors.dataSelector(state) as FeedsDataRecordsType; +export const pageSelector = (state: RootState) => tableSelectors.pageSelector(state) as number; +export const pageSizeSelector = (state: RootState) => tableSelectors.pageSizeSelector(state) as number; +export const totalCountSelector = (state: RootState) => tableSelectors.totalCountSelector(state) as number; +export const sortBySelector = (state: RootState) => tableSelectors.sortBySelector(state) as string; +export const sortOrderSelector = (state: RootState) => tableSelectors.sortOrderSelector(state) as OrderType; +export const isLoadingSelector = (state: RootState) => tableSelectors.isLoadingSelector(state) as boolean; +export const selectedSelector = (state: RootState) => tableSelectors.selectedSelector(state) as number[]; + +// filters +export const searchTextSelector = (state: RootState) => state[nameSpace].searchText; +export const statusSelector = (state: RootState) => state[nameSpace].status; + +// emptyState +export const shouldShowEmptySelector = (state: RootState) => { + const data = dataSelector(state); + const page = pageSelector(state); + const search = searchTextSelector(state); + const status = statusSelector(state); + const isLoading = isLoadingSelector(state); + + return !isLoading && Array.isArray(data) && !data.length && page === 1 && !search && status === STATUSES_ALL; +}; diff --git a/anyclip/src/modules/feeds/SelfServeList/redux/slices/index.ts b/anyclip/src/modules/feeds/SelfServeList/redux/slices/index.ts new file mode 100644 index 0000000..931228f --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/redux/slices/index.ts @@ -0,0 +1,50 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import type { StateTableSliceType, StateType } from '../../types'; + +import { applyPartial } from '@/modules/@common/store/helpers'; +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState: StateType = { + [TABLE_REDUX_FIELD_NAME]: { + ...(tableSlice.state as Record)[TABLE_REDUX_FIELD_NAME], + }, + // filters + searchText: '', + status: null, +}; + +export const slice = createSlice({ + name: '@@FEEDS/SELF_SERVE_LIST', + initialState, + reducers: { + setAction: (state: StateType, action: PayloadAction>) => { + applyPartial(state, action.payload, [TABLE_REDUX_FIELD_NAME]); + }, + setTableAction: (state: StateType, action: PayloadAction>) => + tableSlice.actions.setTableAction(state, action), + getDataAction: tableSlice.actions.getTableDataAction, + importArchivedAction: ( + state: StateType, + action: PayloadAction<{ feedId: number; date: Date | null; isEmailNotification: boolean }>, + ) => { + if (action.payload) { + return state; + } + return state; + }, + }, +}); + +export const { getDataAction, setTableAction, setAction, importArchivedAction } = slice.actions; diff --git a/anyclip/src/modules/feeds/SelfServeList/useIsSelfServeNewList.tsx b/anyclip/src/modules/feeds/SelfServeList/useIsSelfServeNewList.tsx new file mode 100644 index 0000000..177ba99 --- /dev/null +++ b/anyclip/src/modules/feeds/SelfServeList/useIsSelfServeNewList.tsx @@ -0,0 +1,22 @@ +// This hook checks whether the current feed is a SelfServe List. +// @todo Remove this hook and all references to it after migrating to the new UI components. +import { useRouter } from 'next/router'; + +import { PCN_GET_SELF_SERVE_SOURCES } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import { useAppSelector } from '@/modules/@common/store/hooks'; + +export default function useIsSelfServeNewList() { + const router = useRouter(); + const userPermissions = useAppSelector(getUserPermissionsSelector); + + const { params = [] } = router.query; + const hasSelfServeSourcePermission = hasPermission(PCN_GET_SELF_SERVE_SOURCES, userPermissions); + const isListOfFeedsViewRoute = params.length === 0; + const shouldShowSelfServeList = hasSelfServeSourcePermission && isListOfFeedsViewRoute; + + return shouldShowSelfServeList; +} diff --git a/anyclip/src/modules/feeds/constants/index.ts b/anyclip/src/modules/feeds/constants/index.ts new file mode 100644 index 0000000..fa17b01 --- /dev/null +++ b/anyclip/src/modules/feeds/constants/index.ts @@ -0,0 +1,19 @@ +export const TYPE_ZOOM = 'ZOOM'; +export const TYPE_TEAMS = 'TEAMS'; +export const TYPE_SHAREPOINT = 'SHAREPOINT'; +export const TYPE_MS_STREAM = 'MS_STREAM'; +export const TYPE_INSTAGRAM = 'INSTAGRAM'; +export const TYPE_CSV = 'CSV'; +export const TYPE_MANUAL = 'MANUAL'; +export const TYPE_MRSS = 'MRSS'; +export const TYPE_RSS = 'RSS'; +export const TYPE_SITEMAP = 'SITEMAP'; +export const TYPE_STORY_API = 'STORY_API'; +export const TYPE_VIDEO_API = 'VIDEO_API'; +export const TYPE_VIMEO = 'VIMEO'; +export const TYPE_YOUTUBE = 'YOUTUBE'; +export const TYPE_TIKTOK = 'TIKTOK'; + +export const STATUS_ACTIVE = 1; +export const STATUS_ARCHIVED = -1; +export const STATUS_DISCONNECTED = -2; diff --git a/src/modules/forms/common/components/DownloadResponse/index.jsx b/anyclip/src/modules/forms/common/components/DownloadResponse/index.jsx similarity index 100% rename from src/modules/forms/common/components/DownloadResponse/index.jsx rename to anyclip/src/modules/forms/common/components/DownloadResponse/index.jsx diff --git a/anyclip/src/modules/forms/common/components/DynamicFields/DynamicFields.module.scss b/anyclip/src/modules/forms/common/components/DynamicFields/DynamicFields.module.scss new file mode 100644 index 0000000..18dee87 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/DynamicFields/DynamicFields.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Field":"DynamicFields_Field__KEZ6y","Field___selected":"DynamicFields_Field___selected__nXQld","Field___dragging":"DynamicFields_Field___dragging__ysMoD","MenuElement":"DynamicFields_MenuElement__vnSvA","DynamicAction":"DynamicFields_DynamicAction__hTqAW","SettingsList":"DynamicFields_SettingsList__k9T_M","FieldDelete":"DynamicFields_FieldDelete__HtI0l","SettingsWrapper":"DynamicFields_SettingsWrapper__OpR7y","SettingsWrapper___active":"DynamicFields_SettingsWrapper___active__U8efC"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/common/components/DynamicFields/index.jsx b/anyclip/src/modules/forms/common/components/DynamicFields/index.jsx new file mode 100644 index 0000000..389300c --- /dev/null +++ b/anyclip/src/modules/forms/common/components/DynamicFields/index.jsx @@ -0,0 +1,449 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { useTheme } from '@mui/material/styles'; +import { + AddOutlined, + ArrowDropDownCircleRounded, + CalendarMonthRounded, + CheckBoxRounded, + CloseRounded, + DeleteRounded, + DragIndicatorRounded, + EmailRounded, + FormatColorTextRounded, + RadioButtonCheckedRounded, + ViewHeadlineRounded, +} from '@mui/icons-material'; + +import { + FIELD_CHECKBOX, + FIELD_DATE, + FIELD_EMAIL, + FIELD_RADIO, + FIELD_SELECT, + FIELD_TEXT, + FIELD_TEXTAREA, + FIELDS_OPTIONS, +} from '@/modules/forms/editor/constants'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { createDefaultField, createDynamicField, createOption } from '@/modules/forms/helpers/createDynamicField'; + +import SortableItem from '@/modules/@common/dnd/SortableItem/SortableItem'; +import { FormGroup, FormRow, useFormSettings } from '@/modules/@common/Form'; +import { + Button, + Checkbox, + Chip, + FormControlLabel, + IconButton, + InputAdornment, + MenuItem, + Paper, + Radio, + Stack, + Switch, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './DynamicFields.module.scss'; + +const ICON_MAP = { + [FIELD_TEXT]: FormatColorTextRounded, + [FIELD_TEXTAREA]: ViewHeadlineRounded, + [FIELD_EMAIL]: EmailRounded, + [FIELD_DATE]: CalendarMonthRounded, + [FIELD_SELECT]: ArrowDropDownCircleRounded, + [FIELD_RADIO]: RadioButtonCheckedRounded, + [FIELD_CHECKBOX]: CheckBoxRounded, +}; + +const OPTION_MAX_LENGTH = 40; + +function DynamicFields({ disabled = false, ...props }) { + const theme = useTheme(); + const { size } = useFormSettings(); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // fields handlers + const handleDragEnd = ({ active, over }) => { + const getIndex = (neededIndex) => props.fields.findIndex((o) => o.id === neededIndex); + const oldIndex = getIndex(active.id); + const newIndex = getIndex(over.id); + const dynamicFields = arrayMove(props.fields, oldIndex, newIndex); + + props.onSetField({ dynamicFields }); + }; + + // need discuss about this behavior + // const handleSetActive = (fieldId, value) => { + // const dynamicFields = props.fields.map((field) => ({ + // ...field, + // isSelected: field.id === fieldId ? value : false, + // })); + // + // return props.onSetField({ dynamicFields }); + // }; + + const handleAdd = () => { + const dynamicFields = [].concat(props.fields, createDefaultField()); + return props.onSetField({ dynamicFields }); + }; + + const handleRemove = (fieldId) => { + const dynamicFields = props.fields.filter((field) => field.id !== fieldId); + return props.onSetField({ dynamicFields }); + }; + + const handleFieldSetValue = (fieldId, keyOfValue, value) => { + const dynamicFields = props.fields.map((field) => ({ + ...field, + [keyOfValue]: field.id === fieldId ? value : field[keyOfValue], + })); + + return props.onSetField({ dynamicFields }); + }; + + const handleChangeComponentType = (fieldId, componentType) => { + const newComponent = createDynamicField(componentType); + + const dynamicFields = props.fields.map((field) => { + if (field.id === fieldId) { + return { + ...newComponent, + id: field.id, + isSelected: field.isSelected, + }; + } + + return field; + }); + + return props.onSetField({ dynamicFields }); + }; + + // fields options handlers + const updateFieldOptions = (fieldId, options) => { + const dynamicFields = props.fields.map((field) => { + if (field.id === fieldId) { + return { ...field, options }; + } + return field; + }); + + return props.onSetField({ dynamicFields }); + }; + + const handleAddOption = (fieldId, currentOptions) => { + const maxId = Math.max(...currentOptions.map((o) => o.id)); + const options = [].concat(currentOptions, createOption(maxId + 1)); + + updateFieldOptions(fieldId, options); + }; + + const handleSetEditModeOption = (fieldId, optionId, currentOptions) => { + const options = currentOptions.map((option) => ({ + ...option, + isEditable: option.id === optionId, + })); + + updateFieldOptions(fieldId, options); + }; + + const handleSetCloseEditModeOption = (fieldId, optionId, currentOptions) => { + const options = currentOptions.map((option) => ({ + ...option, + isEditable: option.id === optionId ? false : option.isEditable, + })); + + updateFieldOptions(fieldId, options); + }; + + const handleRemoveOption = (fieldId, optionId, currentOptions) => { + const options = currentOptions.filter((option) => option.id !== optionId); + + updateFieldOptions(fieldId, options); + }; + + const handleSetOptionValue = (fieldId, optionId, value, currentOption) => { + const options = currentOption.map((option) => ({ + ...option, + name: option.id === optionId ? value : option.name, + })); + + updateFieldOptions(fieldId, options); + }; + + const handleSetDefaultOption = (field, optionId, value) => { + const { id: fieldId, options: currentOptions, component } = field; + const getOptionValue = (optionIsDefaultValue) => { + if (component === FIELD_RADIO) { + return value === optionIsDefaultValue ? false : value; + } + return value; + }; + const getDefaultOptionValue = (optionIsDefaultValue) => + component === FIELD_CHECKBOX ? optionIsDefaultValue : false; + const options = currentOptions.map((option) => ({ + ...option, + isDefault: option.id === optionId ? getOptionValue(option.isDefault) : getDefaultOptionValue(option.isDefault), + })); + + updateFieldOptions(fieldId, options); + }; + + return ( + + + field.id)} strategy={verticalListSortingStrategy}> + {props.fields.map((field) => ( + + {(sortableItemProps) => ( + + + + {props.fields.length > 1 && ( + + + + )} + + + + ), + }} + onChange={({ target }) => handleFieldSetValue(field.id, 'value', target.value)} + // eslint-disable-next-line react/prop-types + {...getInputPropsByName(props.scheme, ['dynamicFields', field.id, 'value'])} + // eslint-disable-next-line react/prop-types + onFocus={() => props.removeErrorByPropAction(['dynamicFields', field.id, 'value'])} + /> + handleChangeComponentType(field.id, target.value)} + > + {FIELDS_OPTIONS.map((option) => { + const Icon = ICON_MAP[option.id]; + + return ( + + + + + {option.name} + + + + ); + })} + + handleFieldSetValue(field.id, 'isRequire', target.checked)} + /> + } + labelPlacement="start" + label="Required" + /> + {props.fields.length > 1 && ( + handleRemove(field.id)} + className={styles.DynamicAction} + > + + + )} + + + {field?.options?.length && ( + + {field.options.map((option, fieldIndex) => { + const setDefaultControlProps = { + color: 'primary', + checked: option.isDefault, + onClick: ({ target }) => handleSetDefaultOption(field, option.id, target.checked), + }; + const Control = { + [FIELD_SELECT]: ( + + + {fieldIndex + 1} + + + ), + [FIELD_CHECKBOX]: , + [FIELD_RADIO]: , + }[field.component]; + + return ( +
    + + {Control} + = field.options.length - 1} + disabled={disabled} + onChange={({ target }) => + handleSetOptionValue(field.id, option.id, target.value, field.options) + } + onBlur={() => handleSetCloseEditModeOption(field.id, option.id, field.options)} + style={{ + maxWidth: `calc(${OPTION_MAX_LENGTH}ch + ${theme.spacing(13)})`, + }} + inputProps={{ + maxLength: OPTION_MAX_LENGTH, + readOnly: !option.isEditable, + }} + InputProps={{ + endAdornment: ( + + + + ), + }} + // eslint-disable-next-line react/prop-types + {...getInputPropsByName(props.scheme, [ + 'dynamicFields', + field.id, + 'options', + option.id, + 'name', + ])} + label="" + onFocus={() => { + // eslint-disable-next-line react/prop-types + props.removeErrorByPropAction([ + 'dynamicFields', + field.id, + 'options', + option.id, + 'name', + ]); + + return handleSetEditModeOption(field.id, option.id, field.options); + }} + /> + {field.options.length > 1 && ( + handleRemoveOption(field.id, option.id, field.options)} + > + + + )} + +
    + ); + })} +
    + +
    +
    + )} +
    +
    + )} +
    + ))} +
    +
    + + + +
    + ); +} + +DynamicFields.propTypes = { + fields: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + onSetField: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +export default DynamicFields; diff --git a/anyclip/src/modules/forms/common/components/FormPreview/FormPreview.module.scss b/anyclip/src/modules/forms/common/components/FormPreview/FormPreview.module.scss new file mode 100644 index 0000000..ef6b2c6 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/FormPreview/FormPreview.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Iframe":"FormPreview_Iframe__omShl"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.jsx b/anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.jsx new file mode 100644 index 0000000..7ebea70 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.jsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; + +import styles from './PlayerFormPreview.module.scss'; + +const playerContainerId = 'player-container'; + +function PlayerFormPreview() { + useEffect(() => { + const playerContainer = document.getElementById(playerContainerId); + + const script$ = document.createElement('script'); + script$.src = process.env.APP_PLAYER_FORM_BUILDER; + + script$.addEventListener('load', () => { + const formBuilder = window.createLreFormBuilder(); + formBuilder.target = playerContainer; + formBuilder.setTarget(playerContainer); + formBuilder.showBackground(false); + + window.dispatchEvent(new CustomEvent('FormBuilderReady', { detail: formBuilder })); + }); + + playerContainer.appendChild(script$); + + return () => { + playerContainer.innerHTML = ''; + }; + }, []); + + return
    ; +} + +export default PlayerFormPreview; diff --git a/anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.module.scss b/anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.module.scss new file mode 100644 index 0000000..5200e23 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/FormPreview/PlayerFormPreview/PlayerFormPreview.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"PlayerFormPreview_Wrapper__5wP6x"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/common/components/FormPreview/index.jsx b/anyclip/src/modules/forms/common/components/FormPreview/index.jsx new file mode 100644 index 0000000..9b6ab8f --- /dev/null +++ b/anyclip/src/modules/forms/common/components/FormPreview/index.jsx @@ -0,0 +1,51 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Paper } from '@/mui/components'; + +import styles from './FormPreview.module.scss'; + +const FORM_BUILDER_TAG = 'ac-lre-form-builder'; +const FORM_BUILDER_CONTAINER = '.form-container'; + +function FormPreview(props) { + const [formBuilderInstanceRef, setBuilderInstanceRef] = useState(null); + const [iframeHeight, setIframeHeight] = useState('auto'); + + useEffect(() => { + if (formBuilderInstanceRef) { + formBuilderInstanceRef.showForm(props.metadata); + const $formBuilderContent = formBuilderInstanceRef.target + ?.querySelector(FORM_BUILDER_TAG) + ?.querySelector(FORM_BUILDER_CONTAINER); + + if ($formBuilderContent) { + $formBuilderContent.style.overflow = 'hidden'; + } + + setIframeHeight(formBuilderInstanceRef.getSize()?.content?.height || 'auto'); + } + }, [formBuilderInstanceRef, props.metadata]); + + return ( + { + event.target.contentDocument.defaultView.addEventListener('FormBuilderReady', (event$) => { + setBuilderInstanceRef(event$.detail); + }); + }} + /> + ); +} + +FormPreview.propTypes = { + metadata: PropTypes.shape({}).isRequired, +}; + +export default FormPreview; diff --git a/anyclip/src/modules/forms/common/components/RichEditor/RichEditor.module.scss b/anyclip/src/modules/forms/common/components/RichEditor/RichEditor.module.scss new file mode 100644 index 0000000..70e75d4 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/RichEditor/RichEditor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"RichEditor_Wrapper__KXtYK","Wrapper___error":"RichEditor_Wrapper___error__Z_NNt","Label":"RichEditor_Label__QmIY1","FormAnchor":"RichEditor_FormAnchor__QjCf7","Editor":"RichEditor_Editor__xwRi9","MaxLength":"RichEditor_MaxLength__ihhCc"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/common/components/RichEditor/RichEditor.tsx b/anyclip/src/modules/forms/common/components/RichEditor/RichEditor.tsx new file mode 100644 index 0000000..5692ac7 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/RichEditor/RichEditor.tsx @@ -0,0 +1,146 @@ +import React, { useEffect } from 'react'; +import classNames from 'clsx'; +import { TextStyle } from '@tiptap/extension-text-style'; +import { EditorContent, useEditor } from '@tiptap/react'; +import { StarterKit } from '@tiptap/starter-kit'; + +import { getCharacterCount } from '@/modules/forms/helpers'; + +import MenuBar from './components/MenuBar/MenuBar'; +import { Box, FormHelperText, FormLabel, Stack, Typography } from '@/mui/components'; + +import styles from './RichEditor.module.scss'; + +const TextStyleWithFontSize = TextStyle.extend({ + addAttributes() { + return { + ...(this.parent?.() ?? {}), + fontSize: { + default: null, + parseHTML: (element: HTMLElement) => (element as HTMLElement).style.fontSize || '', + renderHTML: (attrs) => (!attrs.fontSize ? {} : { style: `font-size: ${attrs.fontSize}` }), + }, + fontFamily: { + default: null, + parseHTML: (element: HTMLElement) => (element as HTMLElement).style.fontFamily || '', + renderHTML: (attrs) => (!attrs.fontFamily ? {} : { style: `font-family: ${attrs.fontFamily}` }), + }, + color: { + default: null, + parseHTML: (element: HTMLElement) => (element as HTMLElement).style.color || '', + renderHTML: (attrs) => (!attrs.color ? {} : { style: `color: ${attrs.color}` }), + }, + }; + }, +}); + +export type RichEditorProps = { + disabled?: boolean; + error?: boolean; + helperText?: string; + label?: string; + maxLength: number; + name?: string; + placeholder?: string; + readOnly?: boolean; + required?: boolean; + type: 'title' | 'description'; + value?: string; + onChange?: (html: string) => void; + onFocus?: () => void; +}; + +function RichEditor({ + value, + name, + onChange, + onFocus, + label, + required, + type, + helperText, + error, + disabled, + maxLength, + readOnly, + // placeholder = 'Paste content here...', +}: RichEditorProps) { + const editor = useEditor({ + immediatelyRender: false, + editable: !readOnly, + extensions: [ + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: true, + }, + orderedList: { + keepMarks: true, + keepAttributes: true, + }, + link: { + openOnClick: false, + autolink: true, + linkOnPaste: true, + defaultProtocol: 'https', + protocols: ['https', 'http', 'mailto', 'tel'], + HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' }, + }, + }), + TextStyleWithFontSize, + ], + content: value, + onUpdate: (props$) => onChange?.(props$.editor.getHTML()), + }); + + useEffect(() => { + if (editor && typeof value === 'string') { + const current = editor.getHTML(); + + if (current !== value) { + editor.commands.setContent(value, {}); + } + } + }, [editor, value]); + + if (!editor) return null; + + return ( + + + + {label && ( + + {label} + + )} + + + + + + {helperText} + + + {getCharacterCount(value || '')} + / + {maxLength} + + + + ); +} + +export default RichEditor; diff --git a/anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.module.scss b/anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.module.scss new file mode 100644 index 0000000..304c5fb --- /dev/null +++ b/anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Header":"MenuBar_Header__7WXXB","Select":"MenuBar_Select__Eo9AN","Select___family":"MenuBar_Select___family__JB1W_","Select___size":"MenuBar_Select___size__cLhD7","Divider":"MenuBar_Divider__d55pp","ColorWrapper":"MenuBar_ColorWrapper__TYrjm"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.tsx b/anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.tsx new file mode 100644 index 0000000..4e2f658 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/RichEditor/components/MenuBar/MenuBar.tsx @@ -0,0 +1,480 @@ +// StaticColorPicker, +import React, { CSSProperties, useEffect, useState } from 'react'; +import classNames from 'clsx'; +import { createSvgIcon } from '@mui/material'; +import { type Editor, useEditorState } from '@tiptap/react'; +import { + ColorLensOutlined, + DoNotDisturbAltRounded, + FormatBoldRounded, + FormatItalicRounded, + FormatListBulletedRounded, + FormatListNumberedRounded, + FormatUnderlinedRounded, + InsertLinkRounded, + SquareRounded, +} from '@mui/icons-material'; + +import { + Button, + ClickAwayListener, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + MenuItem, + Paper, + Popper, + Select, + Stack, + StaticColorPicker, + TextField, +} from '@/mui/components'; + +import styles from './MenuBar.module.scss'; + +type Props = { + editor: Editor; + disabled?: boolean; + type: 'title' | 'description'; +}; + +const fontFamilies = [ + { + label: 'Arial', + value: 'arial,helvetica,sans-serif', + }, + { + label: 'Helvetica', + value: 'helvetica', + }, + { + label: 'Times New Roman', + value: 'times new roman,times', + }, + { + label: 'Times Courier New', + value: 'courier new,courier', + }, + { + label: 'Courier', + value: 'courier', + }, +]; + +const fontSizes = Array.from({ length: 15 }, (_, i) => 10 + i); + +const colors = [ + '#000000', + '#6255BA', + '#52B5D5', + '#5DB182', + '#EC6240', + '#808080', + '#FF0000', + '#FFFF00', + '#008000', + '#0000FF', +]; + +const EMPTY_FONT_SIZE = -1; + +const TextColorEditorIcon = createSvgIcon( + <> + + + , + 'TextColorEditorIcon', +); + +function MenuBar({ editor, disabled, type }: Props) { + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [colorPickerColor, setColorPickerColor] = useState('#000000'); + + const [linkOpen, setLinkOpen] = useState(false); + const [linkUrl, setLinkUrl] = useState(''); + + const [anchorEl, setAnchorEl] = useState(null); + + // Read the current editor's state, and re-render the component when it changes + const editorState = useEditorState({ + editor, + selector: (ctx) => ({ + fontSize: parseInt(ctx.editor.getAttributes('textStyle')?.fontSize, 10) || EMPTY_FONT_SIZE, + fontFamily: ctx.editor.getAttributes('textStyle')?.fontFamily || '', + color: ctx.editor.getAttributes('textStyle')?.color || '', + isBold: ctx.editor.isActive('bold') ?? false, + canBold: ctx.editor.can().chain().toggleBold().run() ?? false, + + isUnderline: ctx.editor.isActive('underline') ?? false, + canUnderline: ctx.editor.can().chain().toggleUnderline().run() ?? false, + + isItalic: ctx.editor.isActive('italic') ?? false, + canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false, + + isBulletList: ctx.editor.isActive('bulletList') ?? false, + isOrderedList: ctx.editor.isActive('orderedList') ?? false, + + isLink: ctx.editor.isActive('link') ?? false, + }), + }); + + const applyTextStyle = (propertyName: string, value$: string) => { + const addChain = editor + .chain() + .focus() + .setMark('textStyle', { [propertyName]: value$ }); + + if (value$) { + addChain.run(); + } else { + addChain.removeEmptyTextStyle().run(); + } + }; + + const applyLink = () => { + let href = linkUrl.trim(); + + if (href && !/^(https?:\/\/|mailto:|tel:)/i.test(href)) { + href = `https://${href}`; + } + + if (!href) { + editor.chain().focus().extendMarkRange('link').unsetLink().run(); + } else { + editor.chain().focus().extendMarkRange('link').setLink({ href }).run(); + } + + setLinkUrl(''); + setLinkOpen(false); + }; + + useEffect(() => { + const { dom } = editor.view; + + const handler = (event: KeyboardEvent) => { + const isK = event.key.toLowerCase() === 'k'; + + if ((event.metaKey || event.ctrlKey) && isK) { + event.preventDefault(); + + const current = (editor.getAttributes('link').href as string | undefined) ?? ''; + setLinkUrl(current || ''); + setLinkOpen(true); + } + }; + + if (type !== 'title' && !disabled) { + dom.addEventListener('keydown', handler); + } + + return () => { + if (dom && handler) { + dom.removeEventListener('keydown', handler); + } + }; + }, [editor, type, disabled]); + + const size = 'medium'; + + return ( + <> + + + + + + + + + + )} + {linkOpen && ( + setLinkOpen(false)} fullWidth maxWidth="sm"> + setLinkOpen(false)}> + {editor.isActive('link') ? 'Edit link' : 'Insert link'} + + + setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + applyLink(); + setLinkOpen(false); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setLinkOpen(false); + } + }} + placeholder="https://example.com" + /> + + + {editor.isActive('link') && ( + + )} + + + + + )} + + ); +} + +export default MenuBar; diff --git a/anyclip/src/modules/forms/common/components/StylesSettings/components/ColorPicker/index.jsx b/anyclip/src/modules/forms/common/components/StylesSettings/components/ColorPicker/index.jsx new file mode 100644 index 0000000..cbb9c8b --- /dev/null +++ b/anyclip/src/modules/forms/common/components/StylesSettings/components/ColorPicker/index.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ButtonColorPicker, Stack, Typography } from '@/mui/components'; + +function ColorPicker({ hideLabel = false, title = '', disabled = false, ...props }) { + return ( + + {title && ( + + {title} + + )} + + props.onChange(c.hex)} + /> + {!hideLabel && ( + + {props.color?.toUpperCase() ?? '#fff'} + + )} + + + ); +} + +ColorPicker.propTypes = { + color: PropTypes.string.isRequired, + hideLabel: PropTypes.bool, + title: PropTypes.string, + disabled: PropTypes.bool, + onChange: PropTypes.func.isRequired, +}; + +export default ColorPicker; diff --git a/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/UploadImage.module.scss b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/UploadImage.module.scss new file mode 100644 index 0000000..6b2bb97 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/UploadImage.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"ImageWrapper":"UploadImage_ImageWrapper__7JRPs","Image":"UploadImage_Image__FRhO9","ControllerWrapper":"UploadImage_ControllerWrapper__r6hZs"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/index.jsx b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/index.jsx new file mode 100644 index 0000000..fed1f9b --- /dev/null +++ b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadImage/index.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Image from 'next/image'; +import { useTheme } from '@mui/material/styles'; +import { DeleteRounded, FileUploadRounded, ImageRounded } from '@mui/icons-material'; + +import { IconButton, Stack, Typography } from '@/mui/components'; + +import styles from './UploadImage.module.scss'; + +function UploadImage(props) { + const theme = useTheme(); + // eslint-disable-next-line react/prop-types + const backgroundColor = props.backgroundColor ?? theme.palette.background.paper; + + return ( + +
    +
    + {!props.image && ( + + )} + {props.image && } +
    +
    + + + + + + {props.image && ( + + + + )} + + + {`${props.accept.map((item) => item.toUpperCase()).join(', ')}. Max File Size ${props.size}kb`} + + +
    + ); +} + +UploadImage.propTypes = { + image: PropTypes.string.isRequired, + accept: PropTypes.arrayOf(PropTypes.string).isRequired, + size: PropTypes.number.isRequired, + disabled: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, +}; + +export default UploadImage; diff --git a/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/UploadPictureBanner.module.scss b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/UploadPictureBanner.module.scss new file mode 100644 index 0000000..3881f80 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/UploadPictureBanner.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Dialog":"UploadPictureBanner_Dialog__Rl6hp","DialogHeader":"UploadPictureBanner_DialogHeader__hXCpH","Input":"UploadPictureBanner_Input__EZUKZ","Title":"UploadPictureBanner_Title__A0_uy","DialogBanner":"UploadPictureBanner_DialogBanner__2Bnps","DialogBannerErrorMessage":"UploadPictureBanner_DialogBannerErrorMessage__JKr8Z","PhotoChoose":"UploadPictureBanner_PhotoChoose__2t1Mn","PhotoItem":"UploadPictureBanner_PhotoItem__4Ybz8","Photo":"UploadPictureBanner_Photo__yKa6q","SectionDivider":"UploadPictureBanner_SectionDivider__lbmrR"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/index.jsx b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/index.jsx new file mode 100644 index 0000000..bb129d0 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/StylesSettings/components/UploadPictureBanner/index.jsx @@ -0,0 +1,78 @@ +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import Image from 'next/image'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid, Stack, Typography } from '@/mui/components'; + +import blue from '@/assets/img/form-banner/blue.png'; +import coffee from '@/assets/img/form-banner/coffee.png'; +import desk from '@/assets/img/form-banner/desk.png'; +import flower from '@/assets/img/form-banner/flower.png'; +import green from '@/assets/img/form-banner/green.png'; +import notebook from '@/assets/img/form-banner/notebook.png'; +import pink from '@/assets/img/form-banner/pink.png'; +import textureBlue from '@/assets/img/form-banner/textureblue.png'; + +import styles from './UploadPictureBanner.module.scss'; + +function UploadPictureBanner(props) { + const fileInputRef = useRef(null); + const defaultImageFiles = [blue, coffee, desk, flower, green, notebook, pink, textureBlue]; + + return ( + + Select a Banner + + + + {defaultImageFiles.map((item) => ( + + + + ))} + + + + Or + + +
    + +
    +
    +
    + + + +
    + ); +} + +UploadPictureBanner.propTypes = { + accept: PropTypes.arrayOf(PropTypes.string).isRequired, + disabled: PropTypes.bool.isRequired, + isOpen: PropTypes.bool.isRequired, + onChoose: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default UploadPictureBanner; diff --git a/anyclip/src/modules/forms/common/components/StylesSettings/index.jsx b/anyclip/src/modules/forms/common/components/StylesSettings/index.jsx new file mode 100644 index 0000000..89b2570 --- /dev/null +++ b/anyclip/src/modules/forms/common/components/StylesSettings/index.jsx @@ -0,0 +1,275 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +import { + FormatAlignCenterRounded, + FormatAlignLeftRounded, + FormatAlignRightRounded, + PaletteRounded, +} from '@mui/icons-material'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { + ALIGN_ITEMS_CENTER, + ALIGN_ITEMS_LEFT, + ALIGN_ITEMS_RIGHT, + ALLOWED_IMAGE_EXT, + FONTS_OPTIONS, + MAX_BANNER_SIZE_IN_BYTES, +} from '@/modules/forms/editor/constants'; + +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { FormGroup, FormRow, FormRowItem, useFormSettings } from '@/modules/@common/Form'; +import ColorPicker from './components/ColorPicker'; +import UploadImage from './components/UploadImage'; +import UploadPictureBanner from './components/UploadPictureBanner'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + MenuItem, + Select, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@/mui/components'; + +function StylesSettings({ disabled = false, ...props }) { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const [open, setOpen] = useState(true); + const [file, setFile] = useState(''); + const [isOpenUploadImageBanner, setIsOpenUploadImageBanner] = useState(false); + const [LOGO, BANNER] = props.image; + + const preUploadImage = (fileImage) => { + const fileExt = fileImage.name.split('.').pop(); + const fileSize = fileImage.size; + const fileName = fileImage.name; + + setIsOpenUploadImageBanner(false); + + if (!ALLOWED_IMAGE_EXT.includes(fileExt.toLowerCase())) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: `Invalid file extension, valid only (${ALLOWED_IMAGE_EXT.join(',')})`, + }), + ); + } else if (fileSize > MAX_BANNER_SIZE_IN_BYTES) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: 'File size exceeded the maximum size permitted.', + }), + ); + } else { + const reader = new FileReader(); + + reader.addEventListener('load', () => { + props.uploadBanner({ image: props.image, file: reader.result, fileName }); + }); + + reader.readAsDataURL(fileImage); + } + }; + + useEffect(() => { + if (file) { + fetch(file.src) + .then((res) => res.blob()) + .then((blob) => { + const fileData = new File([blob], file.src, { + type: blob.type, + }); + preUploadImage(fileData); + }); + } + }, [file]); + + const handleBannerUploadChange = ({ target }) => { + preUploadImage(target.files[0]); + }; + + const handleBannerDelete = () => { + props.setField({ + image: [LOGO, null], + }); + }; + + return ( + + setOpen(expanded)}> + + + + + Form Styles + + + + + + + props.onChange({ bgColor: hex })} + /> + + + + {[ + { + id: ALIGN_ITEMS_LEFT, + Icon: FormatAlignLeftRounded, + }, + { + id: ALIGN_ITEMS_CENTER, + Icon: FormatAlignCenterRounded, + }, + { + id: ALIGN_ITEMS_RIGHT, + Icon: FormatAlignRightRounded, + }, + ].map(({ id, Icon }) => ( + + props.onChange({ + alignItems: id, + }) + } + > + + + ))} + + + + + + + + + props.onChange({ fontColor: hex })} + hideLabel + disabled={disabled} + /> + + + + props.onChange({ submitButtonBgColor: hex })} + title="Background" + disabled={disabled} + /> + props.onChange({ submitButtonFontColor: hex })} + title="Font" + disabled={disabled} + /> + + + Image types: {ALLOWED_IMAGE_EXT.map((item) => item.toUpperCase()).join(', ')} +
    + Maximum file size: {`${MAX_BANNER_SIZE_IN_BYTES / 1024}kb.`} +
    +
    + Recommended resolution: +
    + 1280px X 80px + + } + > + setIsOpenUploadImageBanner(true)} + onChange={handleBannerUploadChange} + onDelete={handleBannerDelete} + disabled={!!disabled} + /> +
    +
    +
    +
    +
    + setIsOpenUploadImageBanner(false)} + disabled={!!disabled} + /> +
    +
    + ); +} + +StylesSettings.propTypes = { + image: PropTypes.arrayOf(PropTypes.string).isRequired, + style: PropTypes.shape({ + bgColor: PropTypes.string.isRequired, + alignItems: PropTypes.string.isRequired, + fontFamily: PropTypes.string.isRequired, + fontSize: PropTypes.number.isRequired, + fontColor: PropTypes.string.isRequired, + submitButtonBgColor: PropTypes.string.isRequired, + submitButtonFontColor: PropTypes.string.isRequired, + }).isRequired, + onChange: PropTypes.func.isRequired, + setField: PropTypes.func.isRequired, + uploadBanner: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +export default StylesSettings; diff --git a/anyclip/src/modules/forms/constants/index.js b/anyclip/src/modules/forms/constants/index.js new file mode 100644 index 0000000..18499ad --- /dev/null +++ b/anyclip/src/modules/forms/constants/index.js @@ -0,0 +1,31 @@ +export const TAB_FORM_DETAILS = 'formDetails'; +export const TAB_FORM_TRIGGER = 'formTrigger'; + +export const FORM_DOWNLOAD_STATUS_START = 'start'; +export const FORM_DOWNLOAD_STATUS_RETRY = 'retry'; +export const FORM_DOWNLOAD_STATUS_FINISH = 'finish'; + +export const FORM_MAX_DOWNLOAD_TRY_REQUESTS = 45; + +export const TEMPLATE_CATEGORY_ALL = 'all'; +export const TEMPLATE_CATEGORY_CONTENT_FEEDBACK = 1; + +export const TEMPLATE_CATEGORIES_OPTIONS = [ + { + label: 'All categories', + value: TEMPLATE_CATEGORY_ALL, + }, +]; + +export const FORM_SUBMIT_MODE_CREATE = 'CREATE'; +export const FORM_SUBMIT_MODE_UPDATE = 'UPDATE'; +export const FORM_SUBMIT_MODE_DUPLICATE = 'DUPLICATE'; + +export const TEMPLATE_ACCOUNT_ALL = { + label: 'All accounts', + value: null, +}; + +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const FORM_PREVIEW_WIDGET_ID = 'form_preview_widget_id'; diff --git a/anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.jsx b/anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.jsx new file mode 100644 index 0000000..6d9bca3 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.jsx @@ -0,0 +1,657 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { ContentCopyRounded, InfoOutlined, PlayCircleFilledRounded } from '@mui/icons-material'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { FORM_PREVIEW_WIDGET_ID } from '@/modules/forms/constants'; +import { + ALLOWED_IMAGE_EXT, + FIELD_MAX_LENGTH_DESCRIPTION, + FIELD_MAX_LENGTH_NAME, + FIELD_MAX_LENGTH_PPTEXT, + FIELD_MAX_LENGTH_SKIPBUTTONTEXT, + FIELD_MAX_LENGTH_SUBMITTEXT, + FIELD_MAX_LENGTH_TCTEXT, + FIELD_MAX_LENGTH_TITLE, + FORM_SUBMIT_MODE_UPDATE, + MAX_LOGO_SIZE_IN_BYTES, + PERIOD_DAILY, + PERIOD_MONTHLY, + PERIOD_WEEKLY, + SCHEDULE_REPORT_PERIOD_OPTIONS, + STATUS_DISABLED, +} from '@/modules/forms/editor/constants'; + +import { getFormMetadataCalculation } from '../../helpers/calculationsFromState'; +import { getErrorMessageForEmail } from '../../helpers/validationRules'; +import * as selectors from '../../redux/selectors'; +import { + getPlayerOptionsAction, + getSiteOptionsAction, + removeErrorByPropAction, + sendTestEmailAction, + setFieldAction, + uploadBannerAction, + uploadLogoAction, +} from '../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import useFormSubmitMode from '@/modules/forms/helpers/useFormSubmitMode'; +import useGetReadOnlyStatus from '@/modules/forms/helpers/useGetReadOnlyStatus'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { + FormGroup, + FormImageUploader, + FormRow, + FormRowItem, + FormSection, + useFormSettings, +} from '@/modules/@common/Form'; +import FormPreview from '@/modules/forms/common/components/FormPreview'; +import RichEditor from '@/modules/forms/common/components/RichEditor/RichEditor'; +import DynamicFields from '../../../common/components/DynamicFields'; +import StylesSettings from '../../../common/components/StylesSettings'; +import PlayerPreview from './components/PlayerPreview'; +import { + Autocomplete, + Button, + Checkbox, + Chip, + FormControlLabel, + IconButton, + InputAdornment, + MenuItem, + Select, + Stack, + Switch, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './DetailsTab.module.scss'; + +const getPeriodHint = (type) => { + const hints = { + [PERIOD_DAILY]: 'Email will be sent daily at 9:00 AM UTC', + [PERIOD_WEEKLY]: 'Email will be sent every Monday at 9:00 AM', + [PERIOD_MONTHLY]: 'Email will be sent on the 1st of every month at 9:00 AM UTC', + }; + return hints[type]; +}; + +function DetailsTab() { + const dispatch = useDispatch(); + const { size } = useFormSettings(); + const router = useRouter(); + const formMetadata = useSelector(getFormMetadataCalculation); + const image = useSelector(selectors.imageSelector); + const id = useSelector(selectors.idSelector); + const name = useSelector(selectors.nameSelector); + const site = useSelector(selectors.siteSelector); + const autocomplete = useSelector(selectors.autocompleteSelector); + const trigger = useSelector(selectors.triggerSelector); + const scheduleReport = useSelector(selectors.scheduleReportSelector); + const enableInstantDelivery = useSelector(selectors.enableInstantDeliverySelector); + const title = useSelector(selectors.titleSelector); + const description = useSelector(selectors.descriptionSelector); + const dynamicFields = useSelector(selectors.dynamicFieldsSelector); + const submitButton = useSelector(selectors.submitButtonSelector); + const privacyPolicy = useSelector(selectors.privacyPolicySelector); + const termsConditions = useSelector(selectors.termsConditionsSelector); + const skipButton = useSelector(selectors.skipButtonSelector); + const style = useSelector(selectors.styleSelector); + const status = useSelector(selectors.statusSelector); + const scheme = useSelector(selectors.schemeSelector); + const [playerPreview, togglePlayerPreview] = useState(false); + const formSubmitMode = useFormSubmitMode(router); + const readOnly = useGetReadOnlyStatus(status); + const [LOGO, BANNER] = image; + + const shouldShowFormId = id && formSubmitMode === FORM_SUBMIT_MODE_UPDATE; + + // check if autocomplete.textOptions has player based on playerAspectRatio props + // flow: + // 1. user select hub + // 2. call to getPlayers API and result assign to textOptions + const siteHasPlayers = !!(site && autocomplete.textOptions?.some((option) => option.playerAspectRatio)); + + useEffect(() => { + if (site) { + dispatch( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ); + dispatch(getPlayerOptionsAction('')); + } + }, [site]); + + const setField = (o) => dispatch(setFieldAction(o)); + const getSiteOptions = (o) => dispatch(getSiteOptionsAction(o)); + const uploadLogo = (o) => dispatch(uploadLogoAction(o)); + const uploadBanner = (o) => dispatch(uploadBannerAction(o)); + const sendTestEmail = (o) => dispatch(sendTestEmailAction(o)); + + return ( + <> + + + + + + ), + }} + disabled={readOnly} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + onChange={({ target }) => setField({ name: target.value })} + /> + {shouldShowFormId && ( + + + copyToClipboard(id)}> + + + + ), + }} + /> + + )} + + + + setField({ + site: site$, + status: site$ ? status : STATUS_DISABLED, + trigger: { + ...trigger, + isActive: site$ ? trigger.isActive : false, + watch: null, + watchChannel: null, + playerName: null, + domains: [], + }, + }) + } + onOpen={() => { + setField({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }); + getSiteOptions(''); + }} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['site']))} + onChange={({ target }) => getSiteOptions(target.value)} + /> + )} + renderOption={(props$, option) => ( +
  • + {option.label} +
  • + )} + /> +
    + + dispatch(removeErrorByPropAction(['scheduleReport.email']))} + onChange={({ target }) => + setField({ + scheduleReport: { + ...scheduleReport, + email: target.value, + }, + }) + } + /> + + + + + + + setField({ + enableInstantDelivery: target.checked, + }) + } + /> + } + label="Receive an email for each response" + /> + + + + item.toUpperCase()).join( + ', ', + )}. Max File Size ${MAX_LOGO_SIZE_IN_BYTES / 1024}kb`} + disabled={readOnly} + onValidate={(file) => { + const fileExt = file.name.split('.').pop(); + + if (!ALLOWED_IMAGE_EXT.includes(fileExt.toLowerCase())) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: `Invalid file extension, valid only (${ALLOWED_IMAGE_EXT.join(',')})`, + }), + ); + + return false; + } + if (file.size > MAX_LOGO_SIZE_IN_BYTES) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: 'File size exceeded the maximum size permitted.', + }), + ); + + return false; + } + + return true; + }} + onLoad={(event, file, readerResult) => { + uploadLogo({ + image, + file: readerResult, + fileName: file.name, + }); + }} + onRemove={() => { + setField({ + image: [null, BANNER], + }); + }} + /> + + + + { + setField({ + title: content, + }); + }} + {...getInputPropsByName(scheme, ['title'])} + onFocus={() => dispatch(removeErrorByPropAction(['title']))} + /> + + + + { + setField({ + description: content, + }); + }} + {...getInputPropsByName(scheme, ['description'])} + label="" + onFocus={() => dispatch(removeErrorByPropAction(['description']))} + /> + + + + dispatch(removeErrorByPropAction(keyArray))} + onSetField={setField} + /> + + + + + + ), + }} + {...getInputPropsByName(scheme, ['submitButton.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + onChange={({ target }) => + setField({ + submitButton: { title: target.value }, + }) + } + /> + + + + + + + + ), + }} + {...getInputPropsByName(scheme, ['privacyPolicy.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['privacyPolicy.title']))} + onChange={({ target }) => + setField({ + privacyPolicy: { + ...privacyPolicy, + title: target.value, + }, + }) + } + /> + + + dispatch(removeErrorByPropAction(['privacyPolicy.src']))} + onChange={({ target }) => + setField({ + privacyPolicy: { + ...privacyPolicy, + src: target.value, + }, + }) + } + /> + + + + + setField({ + termsConditions: { + ...termsConditions, + isActive: target.checked, + }, + }) + } + /> + + {termsConditions.isActive && ( + + + + + + ), + }} + {...getInputPropsByName(scheme, ['termsConditions.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['termsConditions.title']))} + onChange={({ target }) => + setField({ + termsConditions: { + ...termsConditions, + title: target.value, + }, + }) + } + /> + + + dispatch(removeErrorByPropAction(['termsConditions.src']))} + onChange={({ target }) => + setField({ + termsConditions: { + ...termsConditions, + src: target.value, + }, + }) + } + /> + + + )} + + + setField({ + skipButton: { + ...skipButton, + isActive: target.checked, + }, + }) + } + /> + + {skipButton.isActive && ( + + + + + + ), + }} + {...getInputPropsByName(scheme, ['skipButton.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['skipButton.title']))} + onChange={({ target }) => + setField({ + skipButton: { + ...skipButton, + title: target.value, + }, + }) + } + /> + + + )} +
    + + + setField({ + style: { + ...style, + ...value, + }, + }) + } + /> + + + + + +
    + {siteHasPlayers && } +
    +
    +
    +
    + {playerPreview && siteHasPlayers && ( + togglePlayerPreview(false)} + metadata={formMetadata} + /> + )} + + ); +} + +export default DetailsTab; diff --git a/anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.module.scss b/anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.module.scss new file mode 100644 index 0000000..b30b3e0 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/DetailsTab/DetailsTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"StickyItem":"DetailsTab_StickyItem__ovGPt","PreviewWrapper":"DetailsTab_PreviewWrapper__feImq"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/PlayerPreview.module.scss b/anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/PlayerPreview.module.scss new file mode 100644 index 0000000..4910082 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/PlayerPreview.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"FormRow":"PlayerPreview_FormRow__3RQIU","FormLabel":"PlayerPreview_FormLabel__34PAj","ResizableWrapper":"PlayerPreview_ResizableWrapper__gauG_","ResizableContainer":"PlayerPreview_ResizableContainer__asbgt","FormPreview":"PlayerPreview_FormPreview__Jykqy","ResizeHandle":"PlayerPreview_ResizeHandle__G3oMa"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/index.jsx b/anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/index.jsx new file mode 100644 index 0000000..b782968 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/DetailsTab/components/PlayerPreview/index.jsx @@ -0,0 +1,196 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { OpenInFull } from '@mui/icons-material'; + +import { getNumberInRange } from '@/modules/@common/helpers/number'; + +import FormPreview from '@/modules/forms/common/components/FormPreview'; +import { + Autocomplete, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + Stack, + TextField, +} from '@/mui/components'; + +import styles from './PlayerPreview.module.scss'; + +const MIN_WIDTH = 320; +const DEFAULT_WIDTH = 512; + +function PlayerPreview(props) { + const wrapperRef = useRef(null); + const resizableContainerRef = useRef(null); + + // eslint-disable-next-line react/prop-types + const [player, setPlayer] = useState(props.playerOptions[0]); + const [widthRatio, heightRatio] = useMemo( + () => player.playerAspectRatio.split(/[:_-]/).map(Number), + [player.playerAspectRatio], + ); + const [width, setWidth] = useState(DEFAULT_WIDTH); + const [height, setHeight] = useState(width / (widthRatio / heightRatio)); + + const setupDimensions = (widthSource) => { + const newWidth = Math.round( + getNumberInRange(widthSource, MIN_WIDTH, wrapperRef.current?.clientWidth || Number.MAX_SAFE_INTEGER), + ); + + setWidth(newWidth); + setHeight(Math.round(newWidth / (widthRatio / heightRatio))); + }; + + useEffect(() => { + const timer = setTimeout(() => setupDimensions(+width), 1000); + + return () => { + clearTimeout(timer); + }; + }, [width]); + + const resize = (e) => { + e.preventDefault(); + const newWidth = e.clientX - resizableContainerRef.current.getBoundingClientRect().left; + setupDimensions(newWidth); + }; + + const stopResize = () => { + document.removeEventListener('mousemove', resize); + document.removeEventListener('mouseup', stopResize); + document.removeEventListener('mouseleave', stopResize); + }; + + const startResize = (e) => { + e.preventDefault(); + document.addEventListener('mousemove', resize); + document.addEventListener('mouseup', stopResize); + document.addEventListener('mouseleave', stopResize); + }; + + return ( + + Player Inline View + + +
    +
    Select Player
    + + { + if (option.playerAspectRatio) { + return `${option.label}_${option.playerAspectRatio.replace('_', ':')}`; + } + return option.label || ''; + }} + size="small" + renderInput={(params) => ( + + )} + // eslint-disable-next-line react/prop-types + options={props.playerOptions} + onChange={(e, selectedPlayer) => setPlayer(selectedPlayer)} + value={player} + /> + + +
    +
    +
    Set Player Size
    + + + + + ), + }} + onChange={({ target }) => setWidth(target.value)} + /> + + + + ), + }} + onChange={({ target }) => { + setWidth(Math.round(+target.value / (heightRatio / widthRatio))); + setHeight(target.value); + }} + /> + +
    + +
    +
    + +
    + + + +
    +
    +
    +
    + + + +
    + ); +} + +PlayerPreview.propTypes = { + metadata: PropTypes.shape({}).isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default PlayerPreview; diff --git a/anyclip/src/modules/forms/editor/components/Editor.module.scss b/anyclip/src/modules/forms/editor/components/Editor.module.scss new file mode 100644 index 0000000..82c05eb --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__6pVhk","Title":"Editor_Title__GODwx","Controls":"Editor_Controls__i_Gs7","Loader":"Editor_Loader__K_o30","Tabs":"Editor_Tabs__Beuq_"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/editor/components/TriggerTab/TriggerTab.jsx b/anyclip/src/modules/forms/editor/components/TriggerTab/TriggerTab.jsx new file mode 100644 index 0000000..a274182 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/TriggerTab/TriggerTab.jsx @@ -0,0 +1,669 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import customParseFormatPlugin from 'dayjs/plugin/customParseFormat'; +import { + ApiOutlined, + FirstPageOutlined, + FolderCopyOutlined, + LastPageOutlined, + LaunchOutlined, + TimerOutlined, +} from '@mui/icons-material'; + +import { TYPE_WARNING } from '@/modules/@common/notify/constants'; +import { EDITORIAL_PAGE } from '@/modules/@common/router/constants'; +import { + DEVICE_LIST, + PLAYER_SIZE_LARGE, + PLAYER_SIZE_MEDIUM, + PLAYER_SIZE_SMALL, + PLAYER_SIZE_X_SMALL, + STATUS_ACTIVE, + STATUS_DISABLED, + TARGET_PLACEMENT_TYPE_PLAYER, + TARGET_PLACEMENT_TYPE_VIDEO, + TARGET_PLACEMENT_TYPE_WATCH, + TRIGGER_API, + TRIGGER_BEGINNING, + TRIGGER_END, + TRIGGER_OPTIONS, + TRIGGER_PERIOD_OPTIONS, + TRIGGER_TIMESTAMP, +} from '@/modules/forms/editor/constants'; + +import { + getBrandSafetyOptionsAction, + getBrandsOptionsAction, + getDomainOptionsAction, + getGeoOptionsAction, + getKeywordsOptionsAction, + getLabelOptionsAction, + getPeopleOptionsAction, + getPlayerOptionsAction, + getVideoOptionsAction, + getWatchOptionsAction, + removeErrorByPropAction, + setActiveTabIdAction, + setErrorByPropAction, + setFieldAction, + setScrollToFieldNameAction, + validateSingleField, +} from '../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import * as selectors from '@/modules/forms/editor/redux/selectors'; +import useGetReadOnlyStatus from '@/modules/forms/helpers/useGetReadOnlyStatus'; + +import { FormGroup, FormGroupTitle, FormRow, FormRowItem, FormSection, useFormSettings } from '@/modules/@common/Form'; +import ActionAutocomplete from './components/ActionAutocomplete'; +import ActionIAB from './components/ActionIAB'; +import { + Autocomplete, + Button, + Chip, + MenuItem, + Select, + Switch, + TextField, + TimePicker, + ToggleButton, + ToggleButtonGroup, +} from '@/mui/components'; + +dayjs.extend(customParseFormatPlugin); + +const getApiScript = (id) => `anyclip.widgets[0].showForm(${id})`; + +const TRIGGER_ICONS = { + [TRIGGER_TIMESTAMP]: TimerOutlined, + [TRIGGER_BEGINNING]: FirstPageOutlined, + [TRIGGER_END]: LastPageOutlined, + [TRIGGER_API]: ApiOutlined, +}; + +function TriggerTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const trigger = useSelector(selectors.triggerSelector); + const autocomplete = useSelector(selectors.autocompleteSelector); + const id = useSelector(selectors.idSelector); + const status = useSelector(selectors.statusSelector); + const scheme = useSelector(selectors.schemeSelector); + const site = useSelector(selectors.siteSelector); + + const readOnly = useGetReadOnlyStatus(status); + + const isTriggerActionApi = trigger.triggerAction === TRIGGER_API; + + const handleSetField = (value) => + dispatch( + setFieldAction({ + trigger: { + ...trigger, + ...value, + }, + }), + ); + + const handleGetTextOptionsWhenOpen = (getOptionsAction) => { + dispatch( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ); + + dispatch(getOptionsAction('')); + }; + + const handleWatchOptionsWhenOpen = (getOptionsAction) => { + dispatch( + setFieldAction({ + autocomplete: { + ...autocomplete, + watchOptions: null, + }, + }), + ); + + dispatch(getOptionsAction('')); + }; + + const handleSetWatch = (watch) => { + handleSetField({ + watch, + watchChannel: null, + playerName: null, + }); + + dispatch( + setFieldAction({ + autocomplete: { + ...autocomplete, + watchChannelOptions: watch?.channels, + }, + }), + ); + }; + + const handleSetWatchChannel = (watchChannel) => { + handleSetField({ + watchChannel, + }); + }; + + const handleSetPlayer = (playerName) => { + handleSetField({ + playerName, + watch: null, + watchChannel: null, + }); + }; + + useEffect(() => { + if (trigger.watch && !autocomplete.watchOptions) { + dispatch(getWatchOptionsAction(trigger.watch.label)); + } else if (trigger.watch && autocomplete.watchOptions && !autocomplete.watchChannelOptions) { + const watch = autocomplete.watchOptions.find((entity) => entity.value === trigger.watch.value); + dispatch( + setFieldAction({ + autocomplete: { + ...autocomplete, + watchChannelOptions: watch.channels, + }, + }), + ); + } + }, [trigger.watch, autocomplete.watchOptions, trigger.watchChannel, autocomplete.watchChannelOptions]); + + useEffect(() => { + if (id && isTriggerActionApi) { + handleSetField({ + triggerApiValue: getApiScript(id), + }); + } + }, [id, isTriggerActionApi]); + + return ( + + + { + if (site) { + dispatch( + setFieldAction({ + status: status !== STATUS_ACTIVE ? STATUS_ACTIVE : STATUS_DISABLED, + }), + ); + } else { + const validation = validateSingleField('site', site); + dispatch(setErrorByPropAction([validation])); + dispatch(setActiveTabIdAction(validation.tabId)); + dispatch(setScrollToFieldNameAction(validation.fieldName)); + dispatch( + notifyAction({ + type: TYPE_WARNING, + message: 'Please select a Hub from the list', + }), + ); + } + }} + /> + + {status === STATUS_ACTIVE && ( + <> + + + {trigger.triggerAction === TRIGGER_TIMESTAMP && ( + { + handleSetField({ + triggerTime: dayjs(triggerTime).format('HH:mm:ss'), + }); + }} + ampm={false} + views={['hours', 'minutes', 'seconds']} + format="HH:mm:ss" + disabled={readOnly} + {...getInputPropsByName(scheme, ['trigger.triggerTime'])} + /> + )} + + {!isTriggerActionApi ? 'Select view limitations' : 'Copy to clipboard'} + {isTriggerActionApi && ( + + handleSetField({ triggerApiValue: target.value })} + /> + + + )} + {!isTriggerActionApi && ( + <> + + + handleSetField({ + frequencyLimitsDisplayOnlyOnceSubmitted: target.checked, + }) + } + /> + + {[TRIGGER_END, TRIGGER_BEGINNING].includes(trigger.triggerAction) && ( + + + handleSetField({ + frequencyLimitsNotShowWhenOnceDisplayed: target.checked, + }) + } + /> + + )} + + handleSetField({ activateLimitsPeriod: target.checked })} + /> + + {trigger.activateLimitsPeriod && ( + + + + + + + + + )} + Placement + + { + if (newAlignment) { + handleSetField({ + targetPlacementType: newAlignment, + }); + } + }} + > + Watch + Player + Video + + + {trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_WATCH && ( + <> + + ( + dispatch(removeErrorByPropAction(['trigger.watch']))} + value="" + placeholder="Select Watch" + onChange={({ target }) => dispatch(getWatchOptionsAction(target.value))} + /> + )} + onChange={(e, watch) => handleSetWatch(watch)} + onOpen={() => handleWatchOptionsWhenOpen(getWatchOptionsAction)} + /> + + + ( + + )} + value={trigger.watchChannel} + onChange={(e, watchChannel) => handleSetWatchChannel(watchChannel)} + disabled={readOnly || !trigger.watch} + /> + + + )} + {trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_PLAYER && ( + <> + + ( + dispatch(removeErrorByPropAction(['trigger.playerName']))} + data-id="player-name-input" + value="" + variant="outlined" + placeholder="Select Player" + /> + )} + options={autocomplete.textOptions || []} + loading={!autocomplete.textOptions} + value={trigger.playerName} + disabled={readOnly} + onChange={(e, playerName) => handleSetPlayer(playerName)} + onOpen={() => handleGetTextOptionsWhenOpen(getPlayerOptionsAction)} + /> + + + { + handleSetField({ + playerSize: newValue, + }); + }} + > + {[ + { + title: 'XS', + id: PLAYER_SIZE_X_SMALL, + }, + { + title: 'S', + id: PLAYER_SIZE_SMALL, + }, + { + title: 'M', + id: PLAYER_SIZE_MEDIUM, + }, + { + title: 'L', + id: PLAYER_SIZE_LARGE, + }, + ].map((checkbox) => ( + + {checkbox.title} + + ))} + + + + )} + + { + let fieldProps = {}; + + if (trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_VIDEO) { + fieldProps = { + ...getInputPropsByName(scheme, ['trigger.video']), + onFocus: () => dispatch(removeErrorByPropAction(['trigger.video'])), + }; + } + + return ( + dispatch(getVideoOptionsAction(target.value))} + /> + ); + }} + loading={!autocomplete.textOptions} + options={autocomplete.textOptions || []} + value={trigger.video} + disabled={readOnly} + onChange={(e, video) => handleSetField({ video })} + onOpen={() => handleGetTextOptionsWhenOpen(getVideoOptionsAction)} + /> + {trigger.video?.value && ( + } + onDelete={() => null} + disabled={status === STATUS_DISABLED} + /> + )} + + Video Attributes + + handleSetField({ categories })} + /> + + + handleSetField({ peoples })} + onOpen={() => handleGetTextOptionsWhenOpen(getPeopleOptionsAction)} + onInputChange={(searchText) => dispatch(getPeopleOptionsAction(searchText))} + /> + + + handleSetField({ brands })} + onOpen={() => handleGetTextOptionsWhenOpen(getBrandsOptionsAction)} + onInputChange={(searchText) => dispatch(getBrandsOptionsAction(searchText))} + /> + + + handleSetField({ keywords })} + onOpen={() => handleGetTextOptionsWhenOpen(getKeywordsOptionsAction)} + onInputChange={(searchText) => dispatch(getKeywordsOptionsAction(searchText))} + /> + + + option.groupBy} + disabled={readOnly} + onChange={(labels) => handleSetField({ labels })} + onOpen={() => handleGetTextOptionsWhenOpen(getLabelOptionsAction)} + onInputChange={(searchText) => dispatch(getLabelOptionsAction(searchText))} + /> + + + handleSetField({ brandSafeties })} + onOpen={() => handleGetTextOptionsWhenOpen(getBrandSafetyOptionsAction)} + onInputChange={(searchText) => dispatch(getBrandSafetyOptionsAction(searchText))} + /> + + Filters + + handleSetField({ domains })} + onOpen={() => handleGetTextOptionsWhenOpen(getDomainOptionsAction)} + onInputChange={(searchText) => dispatch(getDomainOptionsAction(searchText))} + /> + + + handleSetField({ devices })} + /> + + + handleSetField({ geographies })} + onOpen={() => handleGetTextOptionsWhenOpen(getGeoOptionsAction)} + /> + + + )} + + )} + + ); +} + +export default TriggerTab; diff --git a/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/ActionAutocomplete.module.scss b/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/ActionAutocomplete.module.scss new file mode 100644 index 0000000..a5e0c20 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/ActionAutocomplete.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Group___indent":"ActionAutocomplete_Group___indent__LdhWx","GroupLabel":"ActionAutocomplete_GroupLabel__NC88h"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/index.jsx b/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/index.jsx new file mode 100644 index 0000000..d0834e6 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionAutocomplete/index.jsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { autocompleteClasses } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { TagSelector } from '@/modules/@common/TagSelector'; +import { List, ListItem, ListSubheader, Stack, Typography } from '@/mui/components'; + +import styles from './ActionAutocomplete.module.scss'; + +function ActionAutocomplete({ + onInputChange = () => {}, + onOpen = null, + onChange = null, + value = [], + groupBy = null, + disabled = false, + size = 'medium', + placeholder = null, + ...props +}) { + const theme = useTheme(); + const [list, setList] = useState(value || []); + + useEffect(() => setList(value || []), [value]); + + const renderGroup = (params) => { + const [name, , color] = (params.group || '').split('|'); + + const optionList = ( + + {params.children} + + ); + + return params.group ? ( + + + + + {name} + + + {optionList} + + + ) : ( + optionList + ); + }; + + return ( + !list.some((item) => item.value === option.value)) ?? []} + placeholder={placeholder} + onOpen={() => { + if (onOpen) { + onOpen(''); + } + }} + onChange={(e, tags) => { + onChange(tags); + setList(tags); + }} + onInputChange={(e) => { + if (onInputChange) { + onInputChange(e.target.value); + } + }} + /> + ); +} + +ActionAutocomplete.propTypes = { + id: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + onInputChange: PropTypes.func, + onChange: PropTypes.func, + onOpen: PropTypes.func, + value: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + }), + ), + groupBy: PropTypes.func, + isFilterDirty: PropTypes.bool, + disabled: PropTypes.bool, + size: PropTypes.oneOf(['xSmall', 'small', 'medium', 'large']), + placeholder: PropTypes.string, +}; + +export default ActionAutocomplete; diff --git a/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionIAB/index.jsx b/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionIAB/index.jsx new file mode 100644 index 0000000..13bb791 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/TriggerTab/components/ActionIAB/index.jsx @@ -0,0 +1,59 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { TagIabSelector } from '@/modules/@common/TagSelector'; + +function ActionIAB({ onChange = null, value = [], size = 'medium', placeholder = null, disabled = false, ...props }) { + const [list, setList] = useState(value); + + useEffect(() => setList(value), [value]); + + const selectedTags = useMemo( + () => + list.map((tag) => ({ + initialNode: { ...tag }, + label: tag.label, + value: tag.id, + include: tag.include, + })), + [list], + ); + + return ( + { + const newTags = tags.map((tag) => ({ + ...tag.initialNode, + include: tag.include, + })); + + setList(newTags); + + if (onChange) { + onChange(newTags); + } + }} + /> + ); +} + +ActionIAB.propTypes = { + id: PropTypes.string.isRequired, + onChange: PropTypes.func, + value: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + }), + ), + placeholder: PropTypes.string, + size: PropTypes.oneOf(['xSmall', 'small', 'medium', 'large']), + disabled: PropTypes.bool, +}; + +export default ActionIAB; diff --git a/anyclip/src/modules/forms/editor/components/index.jsx b/anyclip/src/modules/forms/editor/components/index.jsx new file mode 100644 index 0000000..4497886 --- /dev/null +++ b/anyclip/src/modules/forms/editor/components/index.jsx @@ -0,0 +1,321 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; +import { FileDownloadRounded } from '@mui/icons-material'; + +import { FORM_DOWNLOAD_STATUS_START, TAB_FORM_DETAILS, TAB_FORM_TRIGGER } from '../../constants'; +import { + FORM_SUBMIT_MODE_DUPLICATE, + FORM_SUBMIT_MODE_UPDATE, + STATUS_ARCHIVED, + TARGET_PLACEMENT_TYPE_PLAYER, + TARGET_PLACEMENT_TYPE_VIDEO, + TARGET_PLACEMENT_TYPE_WATCH, + TRIGGER_TIMESTAMP, +} from '../constants'; +import { PCN_FORMS_GET_REPORT } from '@/modules/@common/acl/constants'; + +import * as selectors from '../redux/selectors'; +import { + createFormAction, + downloadFormDataAction, + duplicateFormAction, + getFormByIdAction, + getTemplateByIdAction, + setActiveTabIdAction, + setErrorByPropAction, + setFieldAction, + setInitialAction, + setScrollToFieldNameAction, + updateFormAction, + validateFields, +} from '../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import useFormSubmitMode from '@/modules/forms/helpers/useFormSubmitMode'; +import useGetReadOnlyStatus from '@/modules/forms/helpers/useGetReadOnlyStatus'; + +import { Form, FormContent } from '@/modules/@common/Form'; +import DownloadResponse from '@/modules/forms/common/components/DownloadResponse'; +import DetailsTab from './DetailsTab/DetailsTab'; +import TriggerTab from './TriggerTab/TriggerTab'; +import { + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + Tab, + TabContent, + Tabs, + Typography, +} from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + const [param] = router.query.params; + + const [isDownloadResponseOpen, setDownloadResponseOpen] = useState(false); + const id = parseInt(param, 10); + const formSubmitMode = useFormSubmitMode(); + const activeTabId = useSelector(selectors.activeTabIdSelector); + // const isValidToSave = useSelector(checkIsValidToSaveCalculation); + const name = useSelector(selectors.nameSelector); + const status = useSelector(selectors.statusSelector); + const isFormDownloadInProgress = useSelector(selectors.isFormDownloadInProgressSelector); + const shouldShowDownloadTryRequestOverConfirm = useSelector( + selectors.shouldShowDownloadTryRequestOverConfirmSelector, + ); + const userPermissions = useSelector(getUserPermissionsSelector); + + const readOnly = useGetReadOnlyStatus(status); + + const labels = { + [FORM_SUBMIT_MODE_UPDATE]: 'Update', + [FORM_SUBMIT_MODE_DUPLICATE]: 'Duplicate', + default: 'Save', + }; + + const handleCloseRetryConfirm = () => { + dispatch( + setFieldAction({ + shouldShowDownloadTryRequestOverConfirm: false, + }), + ); + }; + + const handleDownloadForm = (startDate) => { + dispatch( + downloadFormDataAction({ + formId: id, + startDate, + status: FORM_DOWNLOAD_STATUS_START, + }), + ); + + setDownloadResponseOpen(false); + }; + + useEffect(() => { + if (id) { + dispatch( + getFormByIdAction({ + id, + isDuplicate: formSubmitMode === FORM_SUBMIT_MODE_DUPLICATE, + }), + ); + } else { + dispatch(setInitialAction()); + } + }, [id]); + + useEffect(() => { + const { templateId } = router.query; + + if (templateId) { + dispatch(getTemplateByIdAction(+templateId)); + } + }, []); + + const tabs = [ + { + title: 'General', + id: TAB_FORM_DETAILS, + content: DetailsTab, + }, + { + title: 'Trigger', + id: TAB_FORM_TRIGGER, + content: TriggerTab, + }, + ]; + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .filter(({ fieldName }) => { + if (['termsConditions.src', 'termsConditions.title'].includes(fieldName)) { + return allProps.termsConditions.isActive; + } + + if (fieldName === 'skipButton.title') { + return allProps.skipButton.isActive; + } + + if (fieldName === 'trigger.triggerTime') { + return allProps.status && allProps.trigger.triggerAction === TRIGGER_TIMESTAMP; + } + + if (fieldName === 'trigger.watch') { + return allProps.status && allProps.trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_WATCH; + } + + if (fieldName === 'trigger.playerName') { + return allProps.status && allProps.trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_PLAYER; + } + + if (fieldName === 'trigger.video') { + return allProps.status && allProps.trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_VIDEO; + } + + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (formSubmitMode === FORM_SUBMIT_MODE_UPDATE) { + dispatch(updateFormAction()); + } else if (formSubmitMode === FORM_SUBMIT_MODE_DUPLICATE) { + dispatch(duplicateFormAction()); + } else { + dispatch(createFormAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + let pageTitle = 'New Form'; + if (formSubmitMode === FORM_SUBMIT_MODE_DUPLICATE) { + pageTitle = 'Copy Form'; + } else if (id) { + pageTitle = `${name} > Settings`; + } + + return ( +
    + + + {pageTitle} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + {hasPermission(PCN_FORMS_GET_REPORT, userPermissions) && ( + <> + {isFormDownloadInProgress && ( + } + color="success" + label="Processing" + variant="text" + /> + )} + {!isFormDownloadInProgress && ( + + )} + + + )} + + + {!readOnly && ( + + )} + + +
    + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + ); + })} +
    + + {isDownloadResponseOpen && ( + setDownloadResponseOpen(false)} /> + )} + {shouldShowDownloadTryRequestOverConfirm && ( + + The process is taking longer than expected. + + + You’ll receive the report to your email when ready. + + + While this process may take some time, you can continue to work in the AnyClip Platform. + + + + + + + + )} +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/forms/editor/constants/index.js b/anyclip/src/modules/forms/editor/constants/index.js new file mode 100644 index 0000000..6be32f5 --- /dev/null +++ b/anyclip/src/modules/forms/editor/constants/index.js @@ -0,0 +1,142 @@ +export const PERIOD_DAILY = 'DAILY'; +export const PERIOD_WEEKLY = 'WEEKLY'; +export const PERIOD_MONTHLY = 'MONTHLY'; + +export const SCHEDULE_REPORT_PERIOD_OPTIONS = [ + { label: 'Daily', value: PERIOD_DAILY }, + { label: 'Weekly', value: PERIOD_WEEKLY }, + { label: 'Monthly', value: PERIOD_MONTHLY }, +]; + +export const FONTS_OPTIONS = [ + { label: 'Arial', value: 'Arial' }, + { label: 'Helvetica', value: 'Helvetica' }, + { label: 'Times New Roman', value: 'Times New Roman' }, + { label: 'Times Courier New', value: 'Times Courier New' }, + { label: 'Courier', value: 'Courier' }, +]; + +export const FIELD_TEXT = 'TextField'; +export const FIELD_TEXTAREA = 'TextAreaField'; +export const FIELD_EMAIL = 'EmailField'; +export const FIELD_DATE = 'DateField'; +export const FIELD_SELECT = 'SelectField'; +export const FIELD_RADIO = 'RadioField'; +export const FIELD_CHECKBOX = 'CheckboxOptionsField'; + +export const FIELDS_OPTIONS = [ + { + id: FIELD_TEXT, + name: 'Text', + fontSize: '13px', + }, + { + id: FIELD_TEXTAREA, + name: 'Text Area', + fontSize: '15px', + }, + { + id: FIELD_EMAIL, + name: 'Email', + fontSize: '15px', + }, + { + id: FIELD_DATE, + name: 'Date', + fontSize: '15px', + }, + { + id: FIELD_SELECT, + name: 'Dropdown', + fontSize: '15px', + }, + { + id: FIELD_RADIO, + name: 'Radio Button', + fontSize: '15px', + }, + { + id: FIELD_CHECKBOX, + name: 'Checkbox', + fontSize: '15px', + }, +]; + +export const STATUS_ACTIVE = 1; +export const STATUS_ARCHIVED = -1; +export const STATUS_DISABLED = 0; + +export const ALIGN_ITEMS_CENTER = 'center'; +export const ALIGN_ITEMS_LEFT = 'left'; +export const ALIGN_ITEMS_RIGHT = 'right'; + +export const TRIGGER_TIMESTAMP = 'TIMESTAMP'; +export const TRIGGER_BEGINNING = 'BEGINNING_OF_VIDEO'; +export const TRIGGER_END = 'END_OF_VIDEO'; +export const TRIGGER_API = 'API'; + +export const TRIGGER_OPTIONS = [ + { + id: TRIGGER_TIMESTAMP, + name: 'Timestamp', + }, + { + id: TRIGGER_BEGINNING, + name: 'Beginning of video', + }, + { + id: TRIGGER_END, + name: 'End of video', + }, + { + id: TRIGGER_API, + name: 'API', + }, +]; + +export const TRIGGER_PERIOD_OPTIONS = [ + { label: 'Per Day', value: PERIOD_DAILY }, + { label: 'Per Week', value: PERIOD_WEEKLY }, + { label: 'Per Month', value: PERIOD_MONTHLY }, +]; + +export const PLAYER_SIZE_X_SMALL = 'x-small'; +export const PLAYER_SIZE_SMALL = 'small'; +export const PLAYER_SIZE_MEDIUM = 'medium'; +export const PLAYER_SIZE_LARGE = 'large'; + +export const DEVICE_TYPE_DESKTOP = 'DESKTOP'; +export const DEVICE_TYPE_MOBILE = 'MOBILE'; +export const DEVICE_TYPE_TABLET = 'TABLET'; +export const DEVICE_TYPE_CONNECTED_TV = 'CONNECTED_TV'; +export const DEVICE_TYPE_OTHER = 'OTHER'; + +export const DEVICE_LIST = [ + { label: 'Desktop', value: DEVICE_TYPE_DESKTOP }, + { label: 'Mobile', value: DEVICE_TYPE_MOBILE }, + { label: 'Tablet', value: DEVICE_TYPE_TABLET }, + { label: 'Connected TV', value: DEVICE_TYPE_CONNECTED_TV }, + { label: 'Other', value: DEVICE_TYPE_OTHER }, +]; + +export const FORM_SUBMIT_MODE_CREATE = 'CREATE'; +export const FORM_SUBMIT_MODE_UPDATE = 'UPDATE'; +export const FORM_SUBMIT_MODE_DUPLICATE = 'DUPLICATE'; + +export const TARGET_PLACEMENT_TYPE_WATCH = 'WATCH'; +export const TARGET_PLACEMENT_TYPE_PLAYER = 'PLAYER'; +export const TARGET_PLACEMENT_TYPE_VIDEO = 'VIDEO'; + +export const ALLOWED_IMAGE_EXT = ['jpg', 'jpeg', 'png']; +export const MAX_LOGO_SIZE_IN_BYTES = 9 * 1024 * 10; // 90kb +export const MAX_BANNER_SIZE_IN_BYTES = 50 * 1024 * 10; // 500kb + +export const FIELD_MAX_LENGTH_NAME = 255; +export const FIELD_MAX_LENGTH_TITLE = 40; +export const FIELD_MAX_LENGTH_DESCRIPTION = 360; +export const FIELD_MAX_LENGTH_SUBMITTEXT = 40; +export const FIELD_MAX_LENGTH_PPTEXT = 40; +export const FIELD_MAX_LENGTH_TCTEXT = 40; +export const FIELD_MAX_LENGTH_SKIPBUTTONTEXT = 40; + +export const FORM_REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/forms/editor/helpers/calculationsFromState.js b/anyclip/src/modules/forms/editor/helpers/calculationsFromState.js new file mode 100644 index 0000000..c31d1ec --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/calculationsFromState.js @@ -0,0 +1,78 @@ +import { + STATUS_ACTIVE, + TARGET_PLACEMENT_TYPE_PLAYER, + TARGET_PLACEMENT_TYPE_VIDEO, + TARGET_PLACEMENT_TYPE_WATCH, + TRIGGER_API, +} from '../constants'; + +import * as selectors from '../redux/selectors'; + +import { createFormMetadata } from './metadata/v1/createFormMetadata'; +import { getErrorMessageForEmail, getErrorMessageForHttpsLink } from './validationRules'; + +export const getTriggerTabStateCalculation = (state$) => { + const trigger = selectors.triggerSelector(state$); + return { ...trigger }; +}; + +export const getFormMetadataCalculation = (state$) => { + const state = { + name: selectors.nameSelector(state$), + style: selectors.styleSelector(state$), + privacyPolicy: selectors.privacyPolicySelector(state$), + termsConditions: selectors.termsConditionsSelector(state$), + skipButton: selectors.skipButtonSelector(state$), + submitButton: selectors.submitButtonSelector(state$), + image: selectors.imageSelector(state$), + title: selectors.titleSelector(state$), + description: selectors.descriptionSelector(state$), + dynamicFields: selectors.dynamicFieldsSelector(state$), + }; + return createFormMetadata(state); +}; + +export const checkIsValidToSaveCalculation = (state$) => { + const status = selectors.statusSelector(state$); + const name = selectors.nameSelector(state$); + const site = selectors.siteSelector(state$); + const scheduleReport = selectors.scheduleReportSelector(state$); + const title = selectors.titleSelector(state$); + const dynamicFields = selectors.dynamicFieldsSelector(state$); + const privacyPolicy = selectors.privacyPolicySelector(state$); + const submitButton = selectors.submitButtonSelector(state$); + const termsConditions = selectors.termsConditionsSelector(state$); + const skipButton = selectors.skipButtonSelector(state$); + const trigger = selectors.triggerSelector(state$); + + const isTriggerActive = status === STATUS_ACTIVE; + const isFormDetailsValid = !!( + name && + site && + scheduleReport.email && + !getErrorMessageForEmail(scheduleReport.email) && + title && + dynamicFields.length && + dynamicFields.every((field) => field.value) && + privacyPolicy.title && + privacyPolicy.src && + !getErrorMessageForHttpsLink(privacyPolicy.src) && + submitButton.title && + (termsConditions.isActive + ? termsConditions.title && termsConditions.src && !getErrorMessageForHttpsLink(termsConditions.src) + : true) && + (skipButton.isActive ? skipButton.title : true) + ); + + const isFormTriggerValid = !!( + isFormDetailsValid && + !!( + trigger.triggerAction === TRIGGER_API || + (trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_WATCH && trigger.watch?.value) || + (trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_PLAYER && trigger.playerName?.value) || + (trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_VIDEO && trigger.video?.value) + ) + ); + + return !isTriggerActive ? isFormDetailsValid : isFormTriggerValid; +}; diff --git a/anyclip/src/modules/forms/editor/helpers/createRequestBody.js b/anyclip/src/modules/forms/editor/helpers/createRequestBody.js new file mode 100644 index 0000000..47aae8f --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/createRequestBody.js @@ -0,0 +1,187 @@ +import { + PERIOD_DAILY, + PLAYER_SIZE_LARGE, + PLAYER_SIZE_MEDIUM, + PLAYER_SIZE_SMALL, + PLAYER_SIZE_X_SMALL, + TARGET_PLACEMENT_TYPE_PLAYER, + TARGET_PLACEMENT_TYPE_WATCH, + TRIGGER_API, + TRIGGER_BEGINNING, + TRIGGER_END, + TRIGGER_TIMESTAMP, +} from '@/modules/forms/editor/constants'; + +import { getFormMetadataCalculation } from '@/modules/forms/editor/helpers/calculationsFromState'; +import * as selectors from '@/modules/forms/editor/redux/selectors'; + +const excludeIncludeFilter = (items, include = true) => items.filter((item) => item.include === include); +const getValue = (item) => item.value; +const getLabel = (item) => ({ + groupBy: item.groupBy, + labelId: item.labelId, + color: item.color, + value: item.value, + name: item.name, +}); +const getCategory = (item) => ({ + id: item.id, + name: item.name, +}); +const getGeographies = (item) => ({ + value: item.value, + label: item.label, +}); +const getTaxonomyValue = (item) => ({ + name: item.label, + taxonomyId: item.value, +}); + +export const createRequestBody = (state) => { + const name = selectors.nameSelector(state); + const site = selectors.siteSelector(state); + const status = selectors.statusSelector(state); + const title = selectors.titleSelector(state); + const scheduleReport = selectors.scheduleReportSelector(state); + const enableInstantDelivery = selectors.enableInstantDeliverySelector(state); + const image = selectors.imageSelector(state); + const trigger = selectors.triggerSelector(state); + const medataFormId = selectors.medataFormIdSelector(state); + const id = selectors.idSelector(state); + + const defaultTriggerBody = { + triggerTime: '00:00:00', + activateLimitsPeriod: false, + frequencyLimitsMaxViewPerUser: 5, + frequencyLimitsPeriod: PERIOD_DAILY, + frequencyLimitsDisplayOnlyOnceSubmitted: false, + frequencyLimitsNotShowWhenOnceDisplayed: false, + }; + + const defaultTargetingBody = { + watchId: null, + watchChannelId: null, + playerId: null, + playerSizes: [PLAYER_SIZE_X_SMALL, PLAYER_SIZE_SMALL, PLAYER_SIZE_MEDIUM, PLAYER_SIZE_LARGE], + videoId: null, + videoName: '', + includeDomains: [], + excludeDomains: [], + includeDevices: [], + excludeDevices: [], + includeGeographies: [], + excludeGeographies: [], + includeCategory: [], + excludeCategory: [], + includePeople: [], + excludePeople: [], + includeBrands: [], + excludeBrands: [], + includeKeywords: [], + excludeKeywords: [], + includeBrandSafety: [], + excludeBrandSafety: [], + includeLabels: [], + excludeLabels: [], + }; + + let body = { + name, + publisherId: site.value, + status, + title, + scheduleReportEmail: scheduleReport.email, + scheduleReportPeriod: scheduleReport.period, + enableInstantDelivery, + logo: image, + metadata: JSON.stringify(getFormMetadataCalculation(state)), + + triggerAction: trigger.triggerAction, + triggerTime: trigger.triggerTime, + triggerApiValue: trigger.triggerApiValue, + + activateLimitsPeriod: trigger.activateLimitsPeriod, + frequencyLimitsDisplayOnlyOnceSubmitted: trigger.frequencyLimitsDisplayOnlyOnceSubmitted, + frequencyLimitsNotShowWhenOnceDisplayed: trigger.frequencyLimitsNotShowWhenOnceDisplayed, + frequencyLimitsMaxViewPerUser: trigger.frequencyLimitsMaxViewPerUser, + frequencyLimitsPeriod: trigger.frequencyLimitsPeriod, + + watchId: trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_WATCH ? trigger.watch?.value : null, + watchChannelId: + trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_WATCH ? (trigger.watchChannel?.value ?? null) : null, + + playerId: trigger.targetPlacementType === TARGET_PLACEMENT_TYPE_PLAYER ? trigger.playerName?.value : null, + playerSizes: trigger.playerSize, + + videoId: trigger.video?.value || null, + videoName: trigger.video?.label || '', + + includeDomains: excludeIncludeFilter(trigger.domains, true).map(getValue), + excludeDomains: excludeIncludeFilter(trigger.domains, false).map(getValue), + + includeDevices: excludeIncludeFilter(trigger.devices, true).map(getValue), + excludeDevices: excludeIncludeFilter(trigger.devices, false).map(getValue), + + includeGeographies: excludeIncludeFilter(trigger.geographies, true).map(getGeographies), + excludeGeographies: excludeIncludeFilter(trigger.geographies, false).map(getGeographies), + + includeCategory: excludeIncludeFilter(trigger.categories, true).map(getCategory), + excludeCategory: excludeIncludeFilter(trigger.categories, false).map(getCategory), + + includePeople: excludeIncludeFilter(trigger.peoples, true).map(getTaxonomyValue), + excludePeople: excludeIncludeFilter(trigger.peoples, false).map(getTaxonomyValue), + + includeBrands: excludeIncludeFilter(trigger.brands, true).map(getTaxonomyValue), + excludeBrands: excludeIncludeFilter(trigger.brands, false).map(getTaxonomyValue), + + includeKeywords: excludeIncludeFilter(trigger.keywords, true).map(getTaxonomyValue), + excludeKeywords: excludeIncludeFilter(trigger.keywords, false).map(getTaxonomyValue), + + includeBrandSafety: excludeIncludeFilter(trigger.brandSafeties, true).map(getValue), + excludeBrandSafety: excludeIncludeFilter(trigger.brandSafeties, false).map(getValue), + + includeLabels: excludeIncludeFilter(trigger.labels, true).map(getLabel), + excludeLabels: excludeIncludeFilter(trigger.labels, false).map(getLabel), + + medataFormId, + }; + + // for update + if (id) { + body.id = id; + } + + switch (true) { + case trigger.triggerAction === TRIGGER_API: { + body = { + ...body, + ...defaultTriggerBody, + ...defaultTargetingBody, + }; + break; + } + case [TRIGGER_BEGINNING, TRIGGER_END].includes(trigger.triggerAction): { + body = { + ...body, + triggerTime: defaultTriggerBody.triggerTime, + triggerApiValue: '', + }; + break; + } + case trigger.triggerAction === TRIGGER_TIMESTAMP: { + body = { + ...body, + frequencyLimitsNotShowWhenOnceDisplayed: defaultTriggerBody.frequencyLimitsNotShowWhenOnceDisplayed, + triggerApiValue: '', + }; + break; + } + default: { + break; + } + } + + return body; +}; + +export default createRequestBody; diff --git a/anyclip/src/modules/forms/editor/helpers/metadata/v1/createFormMetadata.js b/anyclip/src/modules/forms/editor/helpers/metadata/v1/createFormMetadata.js new file mode 100644 index 0000000..c3a7798 --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/metadata/v1/createFormMetadata.js @@ -0,0 +1,128 @@ +import { createFieldWidget, createStaticWidget } from './widgetsMetadata'; + +const createStaticWidgets = (state) => { + const widgets = []; + const [LOGO, BANNER] = state.image; + + if (LOGO) { + widgets.push( + createStaticWidget.logo({ + src: LOGO, + order: widgets.length + 1, + }), + ); + } + + if (BANNER) { + widgets.push( + createStaticWidget.banner({ + src: BANNER, + order: widgets.length + 1, + }), + ); + } + + if (state.title) { + widgets.push( + createStaticWidget.title({ + text: state.title, + order: widgets.length + 1, + }), + ); + } + + if (state.description) { + widgets.push( + createStaticWidget.description({ + text: state.description, + order: widgets.length + 1, + }), + ); + } + + return widgets; +}; + +const createFieldWidgets = (state) => { + const fields = []; + + state.dynamicFields.forEach((field, index) => { + const createFieldJsonFn = createFieldWidget[field.component]; + + if (createFieldJsonFn) { + const fieldJson = createFieldJsonFn({ + ...field, + order: index, + }); + + fields.push(fieldJson); + } + }); + + return fields; +}; + +export const createFormMetadata = (state) => { + const metadata = { + globalConfig: { + name: state.name, + version: '1', + styles: { + bgColor: state.style.bgColor, + alignment: state.style.alignItems, + font: { + family: state.style.fontFamily, + size: state.style.fontSize, + color: state.style.fontColor, + }, + }, + links: { + privacy: { + title: state.privacyPolicy.title, + src: state.privacyPolicy.src, + }, + termsConditions: { + isHidden: !state.termsConditions.isActive, + title: state.termsConditions.title, + src: state.termsConditions.src, + }, + }, + }, + widget: { + component: 'Container', + props: [ + { + columns: 12, + order: 1, + widget: { + component: 'Container', + props: createStaticWidgets(state), + }, + }, + { + columns: 12, + order: 2, + widget: { + component: 'Form', + props: { + skipButton: { + isHidden: !state.skipButton.isActive, + title: state.skipButton.title, + bgColor: '', + textColor: state.style.submitButtonBgColor, + }, + submitButton: { + title: state.submitButton.title, + bgColor: state.style.submitButtonBgColor, + textColor: state.style.submitButtonFontColor, + }, + fields: createFieldWidgets(state), + }, + }, + }, + ], + }, + }; + + return metadata; +}; diff --git a/anyclip/src/modules/forms/editor/helpers/metadata/v1/parseFormMetadataToState.js b/anyclip/src/modules/forms/editor/helpers/metadata/v1/parseFormMetadataToState.js new file mode 100644 index 0000000..a1a9896 --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/metadata/v1/parseFormMetadataToState.js @@ -0,0 +1,65 @@ +import { createDynamicField } from '@/modules/forms/helpers/createDynamicField'; + +const parseMetadataToState = (metadata, isDuplicate) => { + const { globalConfig, widget } = JSON.parse(metadata); + const staticWidgets = widget.props[0].widget.props; + const formWidget = widget.props[1].widget.props; + + const { fields, submitButton: submitButtonProps, skipButton: skipButtonProps } = formWidget; + + const dynamicFields = fields.map((field) => { + const { + widget: { component, props }, + } = field; + const createdField = createDynamicField(component, { + ...props, + isTypeDisabled: !isDuplicate, + }); + + return createdField; + }); + + const description = staticWidgets.find((item) => item.widget.component === 'Description')?.widget?.props?.text ?? ''; + + const privacyPolicy = { + title: globalConfig.links.privacy.title, + src: globalConfig.links.privacy.src, + }; + + const termsConditions = { + isActive: !globalConfig.links.termsConditions.isHidden, + title: globalConfig.links.termsConditions.title, + src: globalConfig.links.termsConditions.src, + }; + + const submitButton = { + title: submitButtonProps.title, + }; + + const skipButton = { + isActive: !skipButtonProps.isHidden, + title: skipButtonProps.title, + }; + + const style = { + bgColor: globalConfig.styles.bgColor, + alignItems: globalConfig.styles.alignment, + fontFamily: globalConfig.styles.font.family, + fontSize: globalConfig.styles.font.size, + fontColor: globalConfig.styles.font.color, + submitButtonBgColor: submitButtonProps.bgColor, + submitButtonFontColor: submitButtonProps.textColor, + }; + + return { + description, + privacyPolicy, + termsConditions, + submitButton, + skipButton, + style, + dynamicFields, + }; +}; + +export default parseMetadataToState; diff --git a/anyclip/src/modules/forms/editor/helpers/metadata/v1/widgetsMetadata.js b/anyclip/src/modules/forms/editor/helpers/metadata/v1/widgetsMetadata.js new file mode 100644 index 0000000..3c2f660 --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/metadata/v1/widgetsMetadata.js @@ -0,0 +1,228 @@ +import { + FIELD_CHECKBOX, + FIELD_DATE, + FIELD_EMAIL, + FIELD_RADIO, + FIELD_SELECT, + FIELD_TEXT, + FIELD_TEXTAREA, +} from '@/modules/forms/editor/constants'; + +// static widgets +export const createWidgetLogoMetadata = ({ src, order }) => ({ + columns: 12, + order, + widget: { + component: 'Logo', + props: { + src, + }, + }, +}); + +export const createWidgetBannerMetadata = ({ src, order }) => ({ + columns: 12, + order, + widget: { + component: 'Banner', + props: { + src, + }, + }, +}); + +export const createWidgetTitleMetadata = ({ text, order }) => ({ + columns: 12, + order, + widget: { + component: 'Title', + props: { + text, + }, + }, +}); + +export const createWidgetDescriptionMetadata = ({ text, order }) => ({ + columns: 12, + order, + widget: { + component: 'Description', + props: { + text, + }, + }, +}); + +export const createStaticWidget = { + logo: createWidgetLogoMetadata, + banner: createWidgetBannerMetadata, + title: createWidgetTitleMetadata, + description: createWidgetDescriptionMetadata, +}; + +// form fields widgets +export const createWidgetTextField = ({ id, value, defaultValue, isRequire = true, order }) => ({ + columns: '12', + order, + widget: { + component: FIELD_TEXT, + props: { + type: 'text', + name: id, + placeholder: value, + defaultValue, + validation: { + required: { + value: isRequire, + errMsg: 'Is required', + }, + }, + }, + }, +}); + +export const createWidgetTextAreaField = ({ id, value, defaultValue, isRequire = true, order }) => ({ + columns: '12', + order, + widget: { + component: FIELD_TEXTAREA, + props: { + name: id, + placeholder: value, + defaultValue, + validation: { + required: { + value: isRequire, + errMsg: 'Is required', + }, + }, + }, + }, +}); + +export const createWidgetEmailField = ({ id, value, defaultValue, isRequire = true, order }) => ({ + columns: '12', + order, + widget: { + component: FIELD_EMAIL, + props: { + type: 'email', + name: id, + placeholder: value, + defaultValue, + validation: { + required: { + value: isRequire, + errMsg: 'Is required', + }, + }, + }, + }, +}); + +export const createWidgetDateField = ({ id, value, defaultValue, isRequire = true, order }) => ({ + columns: '12', + order, + widget: { + component: FIELD_DATE, + props: { + name: id, + label: value, + defaultValue, + day: { + label: 'Day', + }, + month: { + label: 'Month', + }, + year: { + label: 'Year', + }, + validation: { + required: { + value: isRequire, + errMsg: 'Is required', + }, + }, + }, + }, +}); + +export const createWidgetSelectField = ({ id, value, isRequire = true, options, order }) => ({ + columns: '12', + order, + widget: { + component: FIELD_SELECT, + props: { + name: id, + label: value, + options: options.map((option) => ({ + value: option.id, + label: option.name, + isSelected: option.isDefault, + })), + validation: { + required: { + value: isRequire, + errMsg: 'Is required', + }, + }, + }, + }, +}); + +export const createWidgetRadioField = ({ id, value, isRequire = true, options, order }) => ({ + columns: '12', + order, + widget: { + component: FIELD_RADIO, + props: { + name: id, + label: value, + options: options.map((option) => ({ + value: option.id, + label: option.name, + isSelected: option.isDefault, + })), + validation: { + required: { + value: isRequire, + errMsg: 'Is required', + }, + }, + }, + }, +}); + +export const createWidgetCheckboxField = ({ id, value, isRequire = true, options, order }) => ({ + columns: '12', + order, + widget: { + component: FIELD_CHECKBOX, + props: { + name: id, + label: value, + options: options.map((option) => ({ + value: option.id, + label: option.name, + isSelected: option.isDefault, + })), + validation: { + required: { + value: isRequire, + errMsg: 'Is required', + }, + }, + }, + }, +}); + +export const createFieldWidget = { + [FIELD_TEXT]: createWidgetTextField, + [FIELD_TEXTAREA]: createWidgetTextAreaField, + [FIELD_EMAIL]: createWidgetEmailField, + [FIELD_DATE]: createWidgetDateField, + [FIELD_SELECT]: createWidgetSelectField, + [FIELD_RADIO]: createWidgetRadioField, + [FIELD_CHECKBOX]: createWidgetCheckboxField, +}; diff --git a/anyclip/src/modules/forms/editor/helpers/parseResponseToState.js b/anyclip/src/modules/forms/editor/helpers/parseResponseToState.js new file mode 100644 index 0000000..0d3d78c --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/parseResponseToState.js @@ -0,0 +1,154 @@ +import { + FIELD_MAX_LENGTH_NAME, + TARGET_PLACEMENT_TYPE_PLAYER, + TARGET_PLACEMENT_TYPE_VIDEO, + TARGET_PLACEMENT_TYPE_WATCH, +} from '@/modules/forms/editor/constants'; + +import parseMetadataToState from '@/modules/forms/editor/helpers/metadata/v1/parseFormMetadataToState'; + +const toAutocompleteValue = (entity) => + entity + ? { + value: entity.id, + label: entity.name, + } + : null; + +const toWatchAutocompleteValue = (entity) => + entity + ? { + value: entity.id, + label: entity.title, + } + : null; + +const toIncludeExcludeAutocomplete = ({ includes = [], excludes = [] }) => + [].concat( + includes.map((include) => ({ include: true, value: include, label: include })), + excludes.map((exclude) => ({ include: false, value: exclude, label: exclude })), + ); + +const toLabelIncludeExcludeAutocomplete = ({ includes = {}, excludes = {} }) => + [].concat( + includes.map((include) => ({ include: true, label: include.value, ...include })), + excludes.map((exclude) => ({ include: false, label: exclude.value, ...exclude })), + ); + +const toObjectIncludeExcludeAutocomplete = ({ includes = {}, excludes = {} }) => + [].concat( + includes.map((include) => ({ include: true, ...include })), + excludes.map((exclude) => ({ include: false, ...exclude })), + ); + +const toTaxonomyIncludeExcludeAutocomplete = ({ includes = [], excludes = [] }) => + [].concat( + includes + .filter((include) => !!include?.taxonomyId) + .map((include) => ({ include: true, value: include.taxonomyId, label: include.name })), + excludes + .filter((exclude) => !!exclude?.taxonomyId) + .map((exclude) => ({ include: false, value: exclude.taxonomyId, label: exclude.name })), + ); + +const parseResponseState = (res, isDuplicate = false) => { + const fromMetadata = parseMetadataToState(res.metadata, isDuplicate); + + const getTargetPlacementType = (response) => { + if (toWatchAutocompleteValue(response.watch)) { + return TARGET_PLACEMENT_TYPE_WATCH; + } + + if (toAutocompleteValue(response.player)) { + return TARGET_PLACEMENT_TYPE_PLAYER; + } + + return TARGET_PLACEMENT_TYPE_VIDEO; + }; + + const initialState = { + id: res.id, + name: isDuplicate ? `CopyOf${res.name}`.slice(0, FIELD_MAX_LENGTH_NAME) : res.name, + site: toAutocompleteValue(res.publisher), + status: res.status, + title: res.title, + image: res.logo, + scheduleReport: { + email: res.scheduleReportEmail, + period: res.scheduleReportPeriod, + }, + enableInstantDelivery: res.enableInstantDelivery || false, + // data gets from metadata + description: fromMetadata.description, + dynamicFields: fromMetadata.dynamicFields, + privacyPolicy: fromMetadata.privacyPolicy, + termsConditions: fromMetadata.termsConditions, + submitButton: fromMetadata.submitButton, + skipButton: fromMetadata.skipButton, + style: fromMetadata.style, + trigger: { + triggerAction: res.triggerAction, + triggerTime: res.triggerTime, + triggerApiValue: res.triggerApiValue, + activateLimitsPeriod: res.activateLimitsPeriod, + frequencyLimitsMaxViewPerUser: res.frequencyLimitsMaxViewPerUser, + frequencyLimitsPeriod: res.frequencyLimitsPeriod, + frequencyLimitsDisplayOnlyOnceSubmitted: res.frequencyLimitsDisplayOnlyOnceSubmitted, + frequencyLimitsNotShowWhenOnceDisplayed: res.frequencyLimitsNotShowWhenOnceDisplayed, + targetPlacementType: getTargetPlacementType(res), + + watch: toWatchAutocompleteValue(res.watch), + watchChannel: toWatchAutocompleteValue(res.watchChannel), + playerName: toAutocompleteValue(res.player), + playerSize: res.playerSizes, + + domains: toIncludeExcludeAutocomplete({ + includes: res.includeDomains, + excludes: res.excludeDomains, + }), + devices: toIncludeExcludeAutocomplete({ + includes: res.includeDevices, + excludes: res.excludeDevices, + }), + geographies: toObjectIncludeExcludeAutocomplete({ + includes: res.includeGeographies, + excludes: res.excludeGeographies, + }), + + video: toAutocompleteValue({ + id: res.videoId, + name: res.videoName, + }), + + categories: toObjectIncludeExcludeAutocomplete({ + includes: res.includeCategory, + excludes: res.excludeCategory, + }), + peoples: toTaxonomyIncludeExcludeAutocomplete({ + includes: res.includePeople, + excludes: res.excludePeople, + }), + brands: toTaxonomyIncludeExcludeAutocomplete({ + includes: res.includeBrands, + excludes: res.excludeBrands, + }), + keywords: toTaxonomyIncludeExcludeAutocomplete({ + includes: res.includeKeywords, + excludes: res.excludeKeywords, + }), + brandSafeties: toIncludeExcludeAutocomplete({ + includes: res.includeBrandSafety, + excludes: res.excludeBrandSafety, + }), + labels: toLabelIncludeExcludeAutocomplete({ + includes: res.includeLabels, + excludes: res.excludeLabels, + }), + }, + medataFormId: res.medataFormId, + }; + + return initialState; +}; + +export default parseResponseState; diff --git a/anyclip/src/modules/forms/editor/helpers/parseTemplateResponseToState.js b/anyclip/src/modules/forms/editor/helpers/parseTemplateResponseToState.js new file mode 100644 index 0000000..9179a1b --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/parseTemplateResponseToState.js @@ -0,0 +1,23 @@ +import parseMetadataToState from '@/modules/forms/editor/helpers/metadata/v1/parseFormMetadataToState'; + +const parseTemplateResponseState = (res) => { + const fromMetadata = parseMetadataToState(res.metadata); + const [logo, banner] = res.logo; + const initialState = { + name: res.name, + title: res.title, + image: [logo, banner], + // data gets from metadata + description: fromMetadata.description, + dynamicFields: fromMetadata.dynamicFields, + privacyPolicy: fromMetadata.privacyPolicy, + termsConditions: fromMetadata.termsConditions, + submitButton: fromMetadata.submitButton, + skipButton: fromMetadata.skipButton, + style: fromMetadata.style, + }; + + return initialState; +}; + +export default parseTemplateResponseState; diff --git a/anyclip/src/modules/forms/editor/helpers/validationRules.js b/anyclip/src/modules/forms/editor/helpers/validationRules.js new file mode 100644 index 0000000..39379ae --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/validationRules.js @@ -0,0 +1,29 @@ +import { emailRegExp } from '@/modules/@common/constants/validation'; + +export const getErrorMessageForEmail = (value) => { + const trimmedValue = value?.trim(); + let errorMessage = ''; + + if (!trimmedValue) { + errorMessage = 'Field cannot be empty'; + } else if (!emailRegExp.test(trimmedValue)) { + errorMessage = 'Invalid email'; + } + + return errorMessage; +}; + +export const getErrorMessageForHttpsLink = (value) => { + const trimmedValue = value?.trim(); + let errorMessage = ''; + + if (!trimmedValue) { + errorMessage = 'Field cannot be empty'; + } else if (!/^https:/.test(trimmedValue)) { + errorMessage = 'URL must begin with https'; + } else if (!/^(https:\/\/)[a-z0-9]+([.-]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm.test(trimmedValue)) { + errorMessage = 'Invalid URL'; + } + + return errorMessage; +}; diff --git a/anyclip/src/modules/forms/editor/helpers/validationScheme.js b/anyclip/src/modules/forms/editor/helpers/validationScheme.js new file mode 100644 index 0000000..fb3c926 --- /dev/null +++ b/anyclip/src/modules/forms/editor/helpers/validationScheme.js @@ -0,0 +1,212 @@ +import { TAB_FORM_DETAILS, TAB_FORM_TRIGGER } from '../../constants'; +import { FIELD_MAX_LENGTH_DESCRIPTION, FIELD_MAX_LENGTH_TITLE } from '@/modules/forms/editor/constants'; + +import { getCharacterCount } from '@/modules/forms/helpers'; + +import { getErrorMessageForEmail, getErrorMessageForHttpsLink } from './validationRules'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_FORM_DETAILS, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + if (value.length < 2) { + return 'Field cannot be less then 2 symbols'; + } + + return ''; + }, + }, + { + fieldName: 'site', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'scheduleReport.email', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + const errorMessage = getErrorMessageForEmail(value); + + if (errorMessage) { + return errorMessage; + } + + return ''; + }, + }, + { + fieldName: 'title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + const length = getCharacterCount(value); + + if (!length) { + return 'Field cannot be empty'; + } + + if (length > FIELD_MAX_LENGTH_TITLE) { + return `Field can’t exceed ${FIELD_MAX_LENGTH_TITLE} characters`; + } + + return ''; + }, + }, + { + fieldName: 'description', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (getCharacterCount(value) > FIELD_MAX_LENGTH_DESCRIPTION) { + return `Field can’t exceed ${FIELD_MAX_LENGTH_DESCRIPTION} characters`; + } + + return ''; + }, + }, + // dynamic fields + { + fieldName: 'dynamicFields', + dynamic: true, + tabId: TAB_FORM_DETAILS, + validation: (dynamicFields) => + dynamicFields.reduce((acc, field) => { + acc[field.id] = { + value: !field.value ? 'Field cannot be empty' : '', + options: field.options?.reduce((acc$, option) => { + acc$[option.id] = { + name: !option.name ? 'Field cannot be empty' : '', + }; + + return acc$; + }, {}), + }; + + return acc; + }, {}), + }, + { + fieldName: 'submitButton.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'privacyPolicy.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'privacyPolicy.src', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + const errorMessage = getErrorMessageForHttpsLink(value); + + if (errorMessage) { + return errorMessage; + } + + return ''; + }, + }, + { + fieldName: 'termsConditions.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'termsConditions.src', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + const errorMessage = getErrorMessageForHttpsLink(value); + + if (errorMessage) { + return errorMessage; + } + + return ''; + }, + }, + { + fieldName: 'skipButton.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'trigger.triggerTime', + tabId: TAB_FORM_TRIGGER, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'trigger.watch', + tabId: TAB_FORM_TRIGGER, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'trigger.playerName', + tabId: TAB_FORM_TRIGGER, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'trigger.video', + tabId: TAB_FORM_TRIGGER, + validation: (value) => { + if (!value?.value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/forms/editor/index.js b/anyclip/src/modules/forms/editor/index.js new file mode 100644 index 0000000..92bb1dd --- /dev/null +++ b/anyclip/src/modules/forms/editor/index.js @@ -0,0 +1,3 @@ +import Editor from './components'; + +export default Editor; diff --git a/anyclip/src/modules/forms/editor/redux/epics/create.js b/anyclip/src/modules/forms/editor/redux/epics/create.js new file mode 100644 index 0000000..b86b7c5 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/create.js @@ -0,0 +1,62 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { createFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { createRequestBody } from '@/modules/forms/editor/helpers/createRequestBody'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation CreateForm( + $form: FormInputType + ) { + createForm( + form: $form + ) { + id + name + } + } +`; + +const getResponse = ({ data: { createForm } }) => createForm; + +export default (action$, state$) => + action$.pipe( + ofType(createFormAction.type), + switchMap(() => { + const state = state$.value; + const form = createRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + form, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const link = `/forms/${data.id}`; + + Router.push(link); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Form created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/downloadFormData.js b/anyclip/src/modules/forms/editor/redux/epics/downloadFormData.js new file mode 100644 index 0000000..fa3d214 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/downloadFormData.js @@ -0,0 +1,151 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { + FORM_DOWNLOAD_STATUS_RETRY, + FORM_DOWNLOAD_STATUS_START, + FORM_MAX_DOWNLOAD_TRY_REQUESTS, +} from '@/modules/forms/constants'; + +import * as selectors from '../selectors'; +import { downloadFormDataAction, sendFormReportToUserEmailAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query DownloadFormData( + $formId: Int! + $startDate: String! + $bucketConfig: BucketConfigInputType + ) { + downloadFormData( + formId: $formId + startDate: $startDate + bucketConfig: $bucketConfig + ) { + fileName + downloadUrl + bucketConfig { + bucket + path + } + } + } +`; + +const getResponse = ({ data: { downloadFormData } }) => downloadFormData; + +export default (action$, state$) => + action$.pipe( + ofType(downloadFormDataAction.type), + switchMap(({ payload }) => { + const { formId, startDate, bucketConfig = null, status } = payload; + const actions = []; + + const showProgressBar = (isFormDownloadInProgress) => setFieldAction({ isFormDownloadInProgress }); + + if (status === FORM_DOWNLOAD_STATUS_START) { + actions.push( + of(showProgressBar(true)), + of( + setFieldAction({ + formDownloadTryRequestCounter: 1, + }), + ), + ); + } + + const stream$ = gqlRequest({ + query, + variables: { + formId, + startDate: dayjs(startDate).format('YYYY-MM-DD'), + bucketConfig, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const formDownloadTryRequestCounter = selectors.formDownloadTryRequestCounterSelector(state$.value); + const { fileName, downloadUrl, bucketConfig: bucketConfigFromResponse } = getResponse(response); + const responseActions = []; + + const isFormDataNotFound = !downloadUrl && !bucketConfigFromResponse; + const isTryReplyDownload = + !downloadUrl && + !!bucketConfigFromResponse && + formDownloadTryRequestCounter <= FORM_MAX_DOWNLOAD_TRY_REQUESTS; + const isReadyToDownload = !!downloadUrl; + const isDownloadTryRequestOver = formDownloadTryRequestCounter >= FORM_MAX_DOWNLOAD_TRY_REQUESTS; + + switch (true) { + case isFormDataNotFound: { + responseActions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Form response is empty', + }), + ), + of(showProgressBar(false)), + ); + break; + } + case isTryReplyDownload: { + responseActions.push( + of( + setFieldAction({ + formDownloadTryRequestCounter: formDownloadTryRequestCounter + 1, + }), + ), + of( + downloadFormDataAction({ + formId, + startDate, + bucketConfig: bucketConfigFromResponse, + status: FORM_DOWNLOAD_STATUS_RETRY, + }), + ), + ); + break; + } + case isDownloadTryRequestOver: { + responseActions.push( + of(setFieldAction({ shouldShowDownloadTryRequestOverConfirm: true })), + of(showProgressBar(false)), + of( + sendFormReportToUserEmailAction({ + formId, + bucketConfig, + startDate, + }), + ), + ); + break; + } + case isReadyToDownload: { + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = fileName; + link.click(); + + responseActions.push(of(showProgressBar(false))); + + break; + } + default: + break; + } + + return concat(...responseActions); + } + + return EMPTY; + }), + ); + + return concat(...actions, stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/duplicate.js b/anyclip/src/modules/forms/editor/redux/epics/duplicate.js new file mode 100644 index 0000000..5ca602b --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/duplicate.js @@ -0,0 +1,65 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { duplicateFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { createRequestBody } from '@/modules/forms/editor/helpers/createRequestBody'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation DuplicateForm( + $form: FormInputType + ) { + duplicateForm( + form: $form + ) { + id + name + } + } +`; + +const getResponse = ({ data: { duplicateForm } }) => duplicateForm; + +export default (action$, state$) => + action$.pipe( + ofType(duplicateFormAction.type), + switchMap(() => { + const state = state$.value; + const form = createRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + form: { + ...form, + isCopiedForm: true, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const link = `/forms/${data.id}`; + + Router.push(link); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Form duplicated', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getBrandSafetyOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getBrandSafetyOptionsAutocomplete.js new file mode 100644 index 0000000..6977da4 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getBrandSafetyOptionsAutocomplete.js @@ -0,0 +1,86 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getBrandSafetyOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserContentOwnerIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const categoryEnum = { + [getBrandSafetyOptionsAction.type]: 'BRAND_SAFETY', +}; + +const query = ` + query autocompleteKeyword( + $contentOwner: [Float], + $category: String!, + $prefix: String, + $excludeOrigin: String, + $checkBrandSafetyPermission: Boolean + ) { + autocompleteKeyword( + contentOwner: $contentOwner, + category: $category, + prefix: $prefix, + excludeOrigin: $excludeOrigin, + checkBrandSafetyPermission: $checkBrandSafetyPermission + ) { + value + } + } +`; + +const getResponse = ({ data: { autocompleteKeyword } }) => + autocompleteKeyword.map(({ value }) => ({ value, label: value })); + +export default (action$, state$) => + action$.pipe( + ofType(getBrandSafetyOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap((action) => { + const autocomplete = selectors.autocompleteSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + contentOwner: getUserContentOwnerIdsSelector(state$.value), + category: categoryEnum[action.type], + prefix: action.payload ?? '', + excludeOrigin: 'RSS', + checkBrandSafetyPermission: false, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getDomainOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getDomainOptionsAutocomplete.js new file mode 100644 index 0000000..e452d54 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getDomainOptionsAutocomplete.js @@ -0,0 +1,77 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getDomainOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPublisherDomains( + $publisherId: Int, + ) { + getPublisherDomains( + publisherId: $publisherId, + ) { + records { + id + domain + } + } + } +`; + +const getResponse = ({ + data: { + getPublisherDomains: { records }, + }, +}) => records.map(({ domain }) => ({ value: domain, label: domain })); + +export default (action$, state$) => + action$.pipe( + ofType(getDomainOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap(() => { + const state = state$.value; + const site = selectors.siteSelector(state); + const autocomplete = selectors.autocompleteSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + publisherId: +site.value, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getFormById.js b/anyclip/src/modules/forms/editor/redux/epics/getFormById.js new file mode 100644 index 0000000..c189ac7 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getFormById.js @@ -0,0 +1,171 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { getFormByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import parseFormResponseToState from '@/modules/forms/editor/helpers/parseResponseToState'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query GetFormByIdQuery( + $id: Int! + $cloneLogo: Boolean + ) { + formById( + id: $id + cloneLogo: $cloneLogo + ) { + id + name + title + publisher { + id + name + } + status + logo + scheduleReportEmail + scheduleReportPeriod + metadata + triggerAction + triggerTime + triggerApiValue + activateLimitsPeriod + frequencyLimitsMaxViewPerUser + frequencyLimitsPeriod + frequencyLimitsDisplayOnlyOnceSubmitted + frequencyLimitsNotShowWhenOnceDisplayed + watch { + id + title + } + watchChannel { + id + title + } + player { + id + name + } + videoId + videoName + playerSizes + includeDomains + excludeDomains + includeDevices + excludeDevices + includeGeographies { + label + value + } + excludeGeographies { + label + value + } + includeCategory { + id + name + } + excludeCategory { + id + name + } + includePeople { + taxonomyId + name + } + excludePeople { + taxonomyId + name + } + includeBrands { + taxonomyId + name + } + excludeBrands { + taxonomyId + name + } + includeKeywords { + taxonomyId + name + } + excludeKeywords { + taxonomyId + name + } + includeBrandSafety + excludeBrandSafety + includeLabels { + groupBy + labelId + color + value + name + } + excludeLabels { + groupBy + labelId + color + value + name + } + medataFormId + enableInstantDelivery + } + } +`; + +const getResponse = ({ data: { formById } }) => formById; + +export default (action$) => + action$.pipe( + ofType(getFormByIdAction.type), + switchMap((action) => { + const { id, isDuplicate = false } = action.payload; + const variables = { id }; + + if (isDuplicate) { + variables.cloneLogo = true; + } + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const actions = []; + try { + const state = parseFormResponseToState(data, isDuplicate); + + actions.push(of(setFieldAction(state))); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Cant open form for edit. Metadata format is wrong', + }), + ), + ); + + Router.push('/forms'); + } + + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getGeoOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getGeoOptionsAutocomplete.js new file mode 100644 index 0000000..0c0bd6b --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getGeoOptionsAutocomplete.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getGeoOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query Query { + commonGeography { + id + uiKey + name + } + } +`; + +const getResponse = ({ data: { commonGeography } }) => + commonGeography.map((geo) => ({ value: geo.uiKey, label: geo.name })); + +export default (action$, state$) => + action$.pipe( + ofType(getGeoOptionsAction.type), + switchMap(() => { + const autocomplete = selectors.autocompleteSelector(state$.value); + + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getLabelOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getLabelOptionsAutocomplete.js new file mode 100644 index 0000000..7b6aded --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getLabelOptionsAutocomplete.js @@ -0,0 +1,106 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getLabelOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserAccountIdSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query autocompleteLabelGrouped( + $accountId: Int, + $prefix: String!, + $excludeOrigin: String + ) { + autocompleteLabelGrouped( + accountId: $accountId, + prefix: $prefix, + excludeOrigin: $excludeOrigin + ) { + labelGrouped { + name + values { + value + } + color + contentOwnerId + labelId + accountId + } + } + } +`; + +const getResponse = ({ + data: { + autocompleteLabelGrouped: { labelGrouped }, + }, +}) => + labelGrouped.reduce((acc, curr) => { + const labels = curr.values + ? curr.values.map((label) => ({ + label: label.value, + value: label.value, + name: curr.name, + color: curr.color, + contentOwner: curr.contentOwner, + accountId: curr.accountId, + groupBy: `${curr.name}|${curr.labelId}|${curr.color}`, + labelId: curr.labelId, + })) + : []; + + return [...acc, ...labels]; + }, []); + +export default (action$, state$) => + action$.pipe( + ofType(getLabelOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap((action) => { + const autocomplete = selectors.autocompleteSelector(state$.value); + const userAccountId = getUserAccountIdSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + accountId: userAccountId ? parseInt(userAccountId, 10) : null, + prefix: action.payload ?? '', + excludeOrigin: 'RSS', + checkBrandSafetyPermission: false, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getPlayerOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getPlayerOptionsAutocomplete.js new file mode 100644 index 0000000..9b6c484 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getPlayerOptionsAutocomplete.js @@ -0,0 +1,79 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getPlayerOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getFormPublisherPlayers($publisherId: Int) { + getFormPublisherPlayers(publisherId: $publisherId) { + results { + id + name + playerAspectRatio + type + } + } + } +`; + +const getResponse = ({ + data: { + getFormPublisherPlayers: { results: players }, + }, +}) => + players.map((player) => ({ + value: player.id, + label: player.name, + playerAspectRatio: player.playerAspectRatio, + })); + +export default (action$, state$) => + action$.pipe( + ofType(getPlayerOptionsAction.type), + switchMap(() => { + const state = state$.value; + const autocomplete = selectors.autocompleteSelector(state); + const site = selectors.siteSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + publisherId: site.value, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getSiteOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getSiteOptionsAutocomplete.js new file mode 100644 index 0000000..1e90a48 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getSiteOptionsAutocomplete.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getSiteOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const getResponse = ({ data: { getFormHubs } }) => + getFormHubs.map((publisher) => ({ value: publisher.id, label: publisher.name })); + +const query = ` + query GetFormHubs( + $pageSize: Int + $searchText: String + ) { + getFormHubs( + searchText: $searchText + pageSize: $pageSize + ) { + id + name + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getSiteOptionsAction.type), + switchMap((action) => { + const autocomplete = selectors.autocompleteSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getTemplateById.js b/anyclip/src/modules/forms/editor/redux/epics/getTemplateById.js new file mode 100644 index 0000000..c817adb --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getTemplateById.js @@ -0,0 +1,77 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { getTemplateByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import parseTemplateResponseToState from '@/modules/forms/editor/helpers/parseTemplateResponseToState'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query GetFormTemplateGalleryByIdQuery( + $id: Int! + ) { + getFormTemplateGalleryById( + id: $id + ) { + id + formTemplateCategoryId + account { + id + name + } + name + title + logo + metadata + } + } +`; + +const getResponse = ({ data: { getFormTemplateGalleryById } }) => getFormTemplateGalleryById; + +export default (action$) => + action$.pipe( + ofType(getTemplateByIdAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + id: action.payload, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const actions = []; + try { + const state = parseTemplateResponseToState(data); + + actions.push(of(setFieldAction(state))); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Cant open template. Metadata format is wrong', + }), + ), + ); + + Router.push('/forms'); + } + + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getTextOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getTextOptionsAutocomplete.js new file mode 100644 index 0000000..2b0ec74 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getTextOptionsAutocomplete.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getBrandsOptionsAction, getKeywordsOptionsAction, getPeopleOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserContentOwnerIdsSelector } from '@/modules/@common/user/redux/selectors'; + +const categoryEnum = { + [getKeywordsOptionsAction.type]: 'KEYWORDS', + [getBrandsOptionsAction.type]: 'BRANDS', + [getPeopleOptionsAction.type]: 'PEOPLE', +}; + +const query = ` + query taxonomyAutocomplete($category: String!, $prefix: String, $lang: String, $size: Int) { + taxonomyAutocomplete(category: $category, prefix: $prefix, lang: $lang, size: $size){ + uid, + category, + values { + lang + value + suggestions + } + } + } +`; + +const getResponse = ({ data: { taxonomyAutocomplete } }) => + taxonomyAutocomplete.map((taxonomy) => ({ + value: taxonomy.uid, + label: taxonomy.values?.length ? taxonomy.values[0].value : '', + })); + +export default (action$, state$) => + action$.pipe( + ofType(getKeywordsOptionsAction.type, getBrandsOptionsAction.type, getPeopleOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap((action) => { + const autocomplete = selectors.autocompleteSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + contentOwner: getUserContentOwnerIdsSelector(state$.value), + category: categoryEnum[action.type], + prefix: action.payload ?? '', + lang: 'EN', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getVideoOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getVideoOptionsAutocomplete.js new file mode 100644 index 0000000..d68cc3c --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getVideoOptionsAutocomplete.js @@ -0,0 +1,71 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getVideoOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +import videosQuery from '@/modules/@common/gql/queries/videos'; + +const query = videosQuery; + +const getResponse = ({ data: { videoSearch } }) => + videoSearch.videos.map((video) => ({ value: video.uid, label: video.name })); + +export default (action$, state$) => + action$.pipe( + ofType(getVideoOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + switchMap((action) => { + const autocomplete = selectors.autocompleteSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + query: action.payload ?? '', + typeValue: 'SHORT_FORM', + videoAffiliation: 'ALL_VIDEOS', + page: 0, + queryIds: true, + includeMaxPerformance: true, + origins: { + exclude: true, + value: 'RSS', + }, + sortOrder: 'DESC', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/getWatchOptionsAutocomplete.js b/anyclip/src/modules/forms/editor/redux/epics/getWatchOptionsAutocomplete.js new file mode 100644 index 0000000..af53b60 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/getWatchOptionsAutocomplete.js @@ -0,0 +1,105 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getWatchOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetFormWatchesQuery( + $page: Int, + $pageSize: Int, + $publisherIds: [Int], + $searchText: String, + $sortBy: String, + $sortOrder: String + ) { + getFormWatches( + page: $page, + pageSize: $pageSize, + publisherIds: $publisherIds, + searchText: $searchText, + sortBy: $sortBy, + sortOrder: $sortOrder + ) { + records { + id + title + watchChannels { + id + title + } + } + } + } +`; + +const toOptions = (entity, extra = {}) => ({ + value: entity.id, + label: entity.title, + ...extra, +}); +const getResponse = ({ + data: { + getFormWatches: { records }, + }, +}) => + records.map((watch) => + toOptions(watch, { + channels: watch.watchChannels.map((channel) => toOptions(channel)), + }), + ); + +export default (action$, state$) => + action$.pipe( + ofType(getWatchOptionsAction.type), + debounce((action) => timer(action.payload?.length ? 1000 : 0)), + filter(() => selectors.siteSelector(state$.value)), + switchMap((action) => { + const state = state$.value; + const site = selectors.siteSelector(state); + const autocomplete = selectors.autocompleteSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + page: 1, + pageSize: 30, + publisherIds: [site.value], + searchText: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + watchOptions: getResponse(response), + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + watchOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/index.js b/anyclip/src/modules/forms/editor/redux/epics/index.js new file mode 100644 index 0000000..9d96975 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/index.js @@ -0,0 +1,43 @@ +import { combineEpics } from 'redux-observable'; + +import create from './create'; +import downloadFormData from './downloadFormData'; +import duplicate from './duplicate'; +import getBrandSafetyOptionsAutocomplete from './getBrandSafetyOptionsAutocomplete'; +import getDomainOptionsAutocomplete from './getDomainOptionsAutocomplete'; +import getFormById from './getFormById'; +import getGeoOptionsAutocomplete from './getGeoOptionsAutocomplete'; +import getLabelOptionsAutocomplete from './getLabelOptionsAutocomplete'; +import getPlayerOptionsAutocomplete from './getPlayerOptionsAutocomplete'; +import getSiteOptionsAutocomplete from './getSiteOptionsAutocomplete'; +import getTemplateById from './getTemplateById'; +import getTextOptionsAutocomplete from './getTextOptionsAutocomplete'; +import getVideoOptionsAutocomplete from './getVideoOptionsAutocomplete'; +import getWatchOptionsAutocomplete from './getWatchOptionsAutocomplete'; +import sendFormReportToUserEmail from './sendFormReportToUserEmail'; +import sendTestEmail from './sendTestEmail'; +import update from './update'; +import uploadBanner from './uploadBanner'; +import uploadLogo from './uploadLogo'; + +export default combineEpics( + getTextOptionsAutocomplete, + getLabelOptionsAutocomplete, + getDomainOptionsAutocomplete, + getGeoOptionsAutocomplete, + getSiteOptionsAutocomplete, + getWatchOptionsAutocomplete, + getVideoOptionsAutocomplete, + getPlayerOptionsAutocomplete, + create, + uploadLogo, + uploadBanner, + getFormById, + update, + duplicate, + downloadFormData, + sendTestEmail, + getBrandSafetyOptionsAutocomplete, + sendFormReportToUserEmail, + getTemplateById, +); diff --git a/anyclip/src/modules/forms/editor/redux/epics/sendFormReportToUserEmail.js b/anyclip/src/modules/forms/editor/redux/epics/sendFormReportToUserEmail.js new file mode 100644 index 0000000..8ba5556 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/sendFormReportToUserEmail.js @@ -0,0 +1,52 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { sendFormReportToUserEmailAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation SendFormReportToUserEmail( + $formId: Int!, + $bucket: String!, + $path: String!, + $startDate: String!, + $endDate: String!, + ) { + sendFormReportToUserEmail( + formId: $formId, + bucket: $bucket, + path: $path, + startDate: $startDate, + endDate: $endDate, + ) { + status + } + } +`; + +export default (action$) => + action$.pipe( + ofType(sendFormReportToUserEmailAction.type), + switchMap((action) => { + const { + formId, + bucketConfig: { bucket, path }, + startDate, + } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + formId, + bucket, + path, + startDate: dayjs(startDate).format('YYYY-MM-DD'), + endDate: dayjs(startDate).add(30, 'days').format('YYYY-MM-DD'), + }, + }).pipe(switchMap(() => EMPTY)); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/sendTestEmail.js b/anyclip/src/modules/forms/editor/redux/epics/sendTestEmail.js new file mode 100644 index 0000000..647a56f --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/sendTestEmail.js @@ -0,0 +1,49 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { sendTestEmailAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation SendTestEmail( + $email: String!, + ) { + sendTestEmail( + email: $email + ) { + email + } + } +`; + +export default (action$) => + action$.pipe( + ofType(sendTestEmailAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + email: action.payload, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + notifyAction({ + type: TYPE_SUCCESS, + message: `Successfully sent email ${action.payload}`, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/update.js b/anyclip/src/modules/forms/editor/redux/epics/update.js new file mode 100644 index 0000000..7c3d514 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/update.js @@ -0,0 +1,65 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { getFormByIdAction, updateFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { createRequestBody } from '@/modules/forms/editor/helpers/createRequestBody'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateForm( + $form: FormInputType + ) { + updateForm( + form: $form + ) { + id + name + } + } +`; + +const getResponse = ({ data: { updateForm } }) => updateForm; + +export default (action$, state$) => + action$.pipe( + ofType(updateFormAction.type), + switchMap(() => { + const state = state$.value; + const form = createRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + form, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const link = `/forms/${data.id}`; + + Router.push(link); + + return concat( + of(getFormByIdAction({ id: data.id })), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Form updated', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/uploadBanner.js b/anyclip/src/modules/forms/editor/redux/epics/uploadBanner.js new file mode 100644 index 0000000..10c62f3 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/uploadBanner.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { setFieldAction, uploadBannerAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UploadImage( + $picture: String, + $fileName: String, + ) { + uploadFormBanner( + picture: $picture + fileName: $fileName + ) { + url + } + } +`; + +export default (action$) => + action$.pipe( + ofType(uploadBannerAction.type), + switchMap((action) => { + const { image, file, fileName } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + picture: file, + fileName, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { url: banner } = response.data.uploadFormBanner; + const [logo] = image; + + return of( + setFieldAction({ + image: [logo, banner], + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/epics/uploadLogo.js b/anyclip/src/modules/forms/editor/redux/epics/uploadLogo.js new file mode 100644 index 0000000..dacd4e3 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/epics/uploadLogo.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { setFieldAction, uploadLogoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UploadImage( + $logo: String, + $fileName: String, + ) { + uploadFormLogo( + logo: $logo + fileName: $fileName + ) { + url + } + } +`; + +export default (action$) => + action$.pipe( + ofType(uploadLogoAction.type), + switchMap((action) => { + const { image, file, fileName } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + logo: file, + fileName, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { url: logo } = response.data.uploadFormLogo; + const [, banner] = image; + + return of( + setFieldAction({ + image: [logo, banner], + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/editor/redux/selectors/index.js b/anyclip/src/modules/forms/editor/redux/selectors/index.js new file mode 100644 index 0000000..393a5f4 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/selectors/index.js @@ -0,0 +1,37 @@ +import { FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const idSelector = (state) => state[nameSpace].id; +export const statusSelector = (state) => state[nameSpace].status; +export const nameSelector = (state) => state[nameSpace].name; +export const siteSelector = (state) => state[nameSpace].site; +export const scheduleReportSelector = (state) => state[nameSpace].scheduleReport; +export const enableInstantDeliverySelector = (state) => state[nameSpace].enableInstantDelivery; +export const imageSelector = (state) => state[nameSpace].image; +export const titleSelector = (state) => state[nameSpace].title; +export const descriptionSelector = (state) => state[nameSpace].description; +export const dynamicFieldsSelector = (state) => state[nameSpace].dynamicFields; +export const submitButtonSelector = (state) => state[nameSpace].submitButton; +export const privacyPolicySelector = (state) => state[nameSpace].privacyPolicy; +export const termsConditionsSelector = (state) => state[nameSpace].termsConditions; +export const skipButtonSelector = (state) => state[nameSpace].skipButton; +export const styleSelector = (state) => state[nameSpace].style; +export const triggerSelector = (state) => state[nameSpace].trigger; +export const medataFormIdSelector = (state) => state[nameSpace].medataFormId; +export const autocompleteSelector = (state) => state[nameSpace].autocomplete; +export const isFormDownloadInProgressSelector = (state) => state[nameSpace].isFormDownloadInProgress; +export const shouldShowDownloadTryRequestOverConfirmSelector = (state) => + state[nameSpace].shouldShowDownloadTryRequestOverConfirm; +export const formDownloadTryRequestCounterSelector = (state) => state[nameSpace].formDownloadTryRequestCounter; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +const formSelectors = createFormSelector(FORM_REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/forms/editor/redux/slices/index.js b/anyclip/src/modules/forms/editor/redux/slices/index.js new file mode 100644 index 0000000..b758dc9 --- /dev/null +++ b/anyclip/src/modules/forms/editor/redux/slices/index.js @@ -0,0 +1,187 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { TAB_FORM_DETAILS } from '../../../constants'; +import { + ALIGN_ITEMS_RIGHT, + FONTS_OPTIONS, + FORM_REDUX_FIELD_NAME, + PERIOD_DAILY, + PLAYER_SIZE_LARGE, + PLAYER_SIZE_MEDIUM, + PLAYER_SIZE_SMALL, + PLAYER_SIZE_X_SMALL, + STATUS_DISABLED, + TARGET_PLACEMENT_TYPE_WATCH, + TRIGGER_TIMESTAMP, +} from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; +import { createDefaultField } from '@/modules/forms/helpers/createDynamicField'; + +const formSlice = createFormSlice(FORM_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + status: STATUS_DISABLED, + name: '', + site: null, // { value, label } + scheduleReport: { + email: '', + period: PERIOD_DAILY, + }, + enableInstantDelivery: false, + image: [null, null], + title: '', + description: '', + dynamicFields: [ + createDefaultField({ + name: 'defaultDynamicFieldName', + }), + ], + submitButton: { + title: 'Submit', + }, + privacyPolicy: { + title: 'Privacy Policy', + src: '', + }, + termsConditions: { + isActive: true, + title: 'Terms & Conditions', + src: '', + }, + skipButton: { + isActive: true, + title: 'Skip', + }, + style: { + bgColor: '#fff', + alignItems: ALIGN_ITEMS_RIGHT, + fontFamily: FONTS_OPTIONS[0].value, + fontSize: 14, + fontColor: '#666', + submitButtonBgColor: '#2869d1', + submitButtonFontColor: '#fff', + }, + trigger: { + triggerAction: TRIGGER_TIMESTAMP, + triggerTime: '00:00:00', + triggerApiValue: '', + activateLimitsPeriod: false, + frequencyLimitsMaxViewPerUser: 5, + frequencyLimitsPeriod: PERIOD_DAILY, + frequencyLimitsDisplayOnlyOnceSubmitted: false, + frequencyLimitsNotShowWhenOnceDisplayed: false, + targetPlacementType: TARGET_PLACEMENT_TYPE_WATCH, + + watch: null, + watchChannel: null, + playerName: null, + playerSize: [PLAYER_SIZE_X_SMALL, PLAYER_SIZE_SMALL, PLAYER_SIZE_MEDIUM, PLAYER_SIZE_LARGE], + domains: [], + devices: [], + geographies: [], + + video: null, + categories: [], + peoples: [], + brands: [], + keywords: [], + labels: [], + brandSafeties: [], + }, + medataFormId: null, // need for API which update metadata + autocomplete: { + textOptions: null, // used for brands, peoples, brands_safety, keywords + watchOptions: null, + watchChannelOptions: null, + }, + isFormDownloadInProgress: false, + + shouldShowDownloadTryRequestOverConfirm: false, + formDownloadTryRequestCounter: 1, + + activeTabId: TAB_FORM_DETAILS, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@FORM/EDITOR', + initialState, + reducers: { + getBrandSafetyOptionsAction: (state) => state, + getKeywordsOptionsAction: (state) => state, + getBrandsOptionsAction: (state) => state, + getPeopleOptionsAction: (state) => state, + getLabelOptionsAction: (state) => state, + getDomainOptionsAction: (state) => state, + getGeoOptionsAction: (state) => state, + getSiteOptionsAction: (state) => state, + getWatchOptionsAction: (state) => state, + getVideoOptionsAction: (state) => state, + getPlayerOptionsAction: (state) => state, + createFormAction: (state) => state, + uploadLogoAction: (state) => state, + uploadBannerAction: (state) => state, + getFormByIdAction: (state) => state, + updateFormAction: (state) => state, + duplicateFormAction: (state) => state, + downloadFormDataAction: (state) => state, + sendTestEmailAction: (state) => state, + sendFormReportToUserEmailAction: (state) => state, + getTemplateByIdAction: (state) => state, + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + createFormAction, + downloadFormDataAction, + duplicateFormAction, + getBrandSafetyOptionsAction, + getBrandsOptionsAction, + getDomainOptionsAction, + getFormByIdAction, + getGeoOptionsAction, + getKeywordsOptionsAction, + getLabelOptionsAction, + getPeopleOptionsAction, + getPlayerOptionsAction, + getSiteOptionsAction, + getTemplateByIdAction, + getVideoOptionsAction, + getWatchOptionsAction, + sendFormReportToUserEmailAction, + sendTestEmailAction, + setFieldAction, + setInitialAction, + updateFormAction, + uploadBannerAction, + uploadLogoAction, + setActiveTabIdAction, + + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/forms/forms/components/Forms.module.scss b/anyclip/src/modules/forms/forms/components/Forms.module.scss similarity index 100% rename from src/modules/forms/forms/components/Forms.module.scss rename to anyclip/src/modules/forms/forms/components/Forms.module.scss diff --git a/src/modules/forms/forms/components/TemplateGallery/TemplateGallery.module.scss b/anyclip/src/modules/forms/forms/components/TemplateGallery/TemplateGallery.module.scss similarity index 100% rename from src/modules/forms/forms/components/TemplateGallery/TemplateGallery.module.scss rename to anyclip/src/modules/forms/forms/components/TemplateGallery/TemplateGallery.module.scss diff --git a/src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/TemplateItem.module.scss b/anyclip/src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/TemplateItem.module.scss similarity index 100% rename from src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/TemplateItem.module.scss rename to anyclip/src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/TemplateItem.module.scss diff --git a/src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/index.jsx b/anyclip/src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/index.jsx similarity index 100% rename from src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/index.jsx rename to anyclip/src/modules/forms/forms/components/TemplateGallery/components/TemplateItem/index.jsx diff --git a/src/modules/forms/forms/components/TemplateGallery/index.jsx b/anyclip/src/modules/forms/forms/components/TemplateGallery/index.jsx similarity index 100% rename from src/modules/forms/forms/components/TemplateGallery/index.jsx rename to anyclip/src/modules/forms/forms/components/TemplateGallery/index.jsx diff --git a/src/modules/forms/forms/components/index.jsx b/anyclip/src/modules/forms/forms/components/index.jsx similarity index 100% rename from src/modules/forms/forms/components/index.jsx rename to anyclip/src/modules/forms/forms/components/index.jsx diff --git a/anyclip/src/modules/forms/forms/constants/index.js b/anyclip/src/modules/forms/forms/constants/index.js new file mode 100644 index 0000000..9091923 --- /dev/null +++ b/anyclip/src/modules/forms/forms/constants/index.js @@ -0,0 +1,71 @@ +export const STATUS_ACTIVE = 1; +export const STATUS_ARCHIVE = -1; +export const STATUS_DISABLED = 0; + +export const STATUS_OPTIONS = [ + { + label: 'Active', + value: STATUS_ACTIVE, + }, + { + label: 'Archived', + value: STATUS_ARCHIVE, + }, + { + label: 'Disabled', + value: STATUS_DISABLED, + }, +]; + +export const ROWS_PER_PAGE = 15; + +export const TABLE_HEADER = [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '50', + }, + { + id: 'name', + label: 'Name', + width: '255', + sortable: true, + }, + { + id: 'title', + label: 'Title', + sortable: true, + }, + { + id: 'site', + label: 'Hub', + sortable: false, + }, + { + id: 'status', + label: 'Status', + sortable: true, + width: '90', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + }, + { + id: 'updatedAt', + label: 'Update Date', + sortable: true, + width: '180', + }, + { + id: null, + label: '', + autoWidth: true, + padding: 'none', + sortable: false, + }, +]; + +export const GET_TEMPLATE_STATUS_INITIAL = 'initial'; diff --git a/src/modules/forms/forms/helpers/calculationsFromState.js b/anyclip/src/modules/forms/forms/helpers/calculationsFromState.js similarity index 100% rename from src/modules/forms/forms/helpers/calculationsFromState.js rename to anyclip/src/modules/forms/forms/helpers/calculationsFromState.js diff --git a/anyclip/src/modules/forms/forms/helpers/upsertLastUsedTemplates.js b/anyclip/src/modules/forms/forms/helpers/upsertLastUsedTemplates.js new file mode 100644 index 0000000..803ee08 --- /dev/null +++ b/anyclip/src/modules/forms/forms/helpers/upsertLastUsedTemplates.js @@ -0,0 +1,22 @@ +import { getStorageItem, setBrowserStorageItem } from '@/modules/@common/storage/helpers'; + +const STORAGE_NAME = 'user/LAST_USED_TEMPLATES'; + +export const getTemplateIds = () => JSON.parse(getStorageItem(STORAGE_NAME)) || []; + +// only 3 items should be present in store +export const setTemplateId = (templateId) => { + let savedTemplateIds = getTemplateIds(); + const notExist = !savedTemplateIds.find((id) => templateId === id); + + if (notExist) { + savedTemplateIds.push(templateId); + } + + if (savedTemplateIds.length > 3) { + savedTemplateIds = savedTemplateIds.slice(1); + } + + // todo: possible move to cookies + setBrowserStorageItem(STORAGE_NAME, JSON.stringify(savedTemplateIds)); +}; diff --git a/src/modules/forms/forms/index.js b/anyclip/src/modules/forms/forms/index.js similarity index 100% rename from src/modules/forms/forms/index.js rename to anyclip/src/modules/forms/forms/index.js diff --git a/anyclip/src/modules/forms/forms/redux/epics/archive.js b/anyclip/src/modules/forms/forms/redux/epics/archive.js new file mode 100644 index 0000000..27bae63 --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/archive.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { STATUS_ARCHIVE } from '@/modules/forms/forms/constants'; + +import { getFormsAction, toArchiveAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateForm( + $form: FormInputType + ) { + updateForm( + form: $form + ) { + id + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(toArchiveAction.type), + switchMap(({ payload: id }) => { + const stream$ = gqlRequest({ + query, + variables: { + form: { + id, + status: STATUS_ARCHIVE, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of(getFormsAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Form archived', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/forms/redux/epics/downloadFormData.js b/anyclip/src/modules/forms/forms/redux/epics/downloadFormData.js new file mode 100644 index 0000000..197ac02 --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/downloadFormData.js @@ -0,0 +1,168 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { FORM_DOWNLOAD_STATUS_START, FORM_MAX_DOWNLOAD_TRY_REQUESTS } from '@/modules/forms/constants'; + +import { formDownloadProcessingNotificationKeySelector, formDownloadTryRequestCounterSelector } from '../selectors'; +import { downloadFormDataAction, sendFormReportToUserEmailAction, setFieldAction } from '../slices'; +import { closeNotifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query DownloadFormData( + $formId: Int! + $startDate: String! + $bucketConfig: BucketConfigInputType + ) { + downloadFormData( + formId: $formId + startDate: $startDate + bucketConfig: $bucketConfig + ) { + fileName + downloadUrl + bucketConfig { + bucket + path + } + } + } +`; + +const getResponse = ({ data: { downloadFormData } }) => downloadFormData; + +export default (action$, state$) => + action$.pipe( + ofType(downloadFormDataAction.type), + switchMap(({ payload }) => { + const { formId, startDate, bucketConfig = null, status } = payload; + + const actions = []; + const showProgressBar = (isFormDownloadInProgress, notificationKey = null) => + setFieldAction({ + isFormDownloadInProgress, + formDownloadProcessingNotificationKey: notificationKey, + }); + + if (status === FORM_DOWNLOAD_STATUS_START) { + const key = `${Math.random()}-${Math.random()}`; + + actions.push( + of(showProgressBar(true, key)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Processing...', + key, + persist: true, + }), + ), + of( + setFieldAction({ + formDownloadTryRequestCounter: 1, + }), + ), + ); + } + + const stream$ = gqlRequest({ + query, + variables: { + formId, + startDate: dayjs(startDate).format('YYYY-MM-DD'), + bucketConfig, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const state = state$.value; + const formDownloadProcessingNotificationKey = formDownloadProcessingNotificationKeySelector(state); + const formDownloadTryRequestCounter = formDownloadTryRequestCounterSelector(state); + + const { fileName, downloadUrl, bucketConfig: bucketConfigFromResponse } = getResponse(response); + const requestActions = []; + + const isFormDataNotFound = !downloadUrl && !bucketConfigFromResponse; + const isTryReplyDownload = + !downloadUrl && + !!bucketConfigFromResponse && + formDownloadTryRequestCounter <= FORM_MAX_DOWNLOAD_TRY_REQUESTS; + const isReadyToDownload = !!downloadUrl; + const isDownloadTryRequestOver = formDownloadTryRequestCounter >= FORM_MAX_DOWNLOAD_TRY_REQUESTS; + + switch (true) { + case isFormDataNotFound: { + requestActions.push( + of(showProgressBar(false)), + of(closeNotifyAction(formDownloadProcessingNotificationKey)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Form response is empty', + }), + ), + ); + break; + } + case isTryReplyDownload: { + requestActions.push( + of( + setFieldAction({ + formDownloadTryRequestCounter: formDownloadTryRequestCounter + 1, + }), + ), + of( + downloadFormDataAction({ + formId, + startDate, + bucketConfig: bucketConfigFromResponse, + }), + ), + ); + break; + } + case isDownloadTryRequestOver: { + requestActions.push( + of(closeNotifyAction(formDownloadProcessingNotificationKey)), + of(showProgressBar(false)), + of(setFieldAction({ shouldShowDownloadTryRequestOverConfirm: true })), + of( + sendFormReportToUserEmailAction({ + formId, + bucketConfig, + startDate, + }), + ), + ); + break; + } + case isReadyToDownload: { + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = fileName; + link.click(); + + requestActions.push( + of(showProgressBar(false)), + of(closeNotifyAction(formDownloadProcessingNotificationKey)), + ); + break; + } + default: + break; + } + + return concat(...requestActions); + } + + return EMPTY; + }), + ); + + return concat(...actions, stream$); + }), + ); diff --git a/anyclip/src/modules/forms/forms/redux/epics/getForms.js b/anyclip/src/modules/forms/forms/redux/epics/getForms.js new file mode 100644 index 0000000..822f41d --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/getForms.js @@ -0,0 +1,106 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { setInitialAction } from '../../../editor/redux/slices'; +import * as formsSelector from '../selectors'; +import { getFormsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetForms( + $sortBy: String + $sortOrder: String + $page: Int + $pageSize: Int + $searchText: String + $publisherId: Int + $status: Int + $searchIn: [String] + ) { + getForms( + sortBy: $sortBy + sortOrder: $sortOrder + page: $page + pageSize: $pageSize + searchText: $searchText + publisherId: $publisherId + status: $status + searchIn: $searchIn + ) { + records { + id + name + publisherId + status + title + createdAt + createdBy + updatedAt + updatedBy + publisher { + id + name + } + } + recordsTotal + } + } +`; + +const getResponse = ({ data: { getForms } }) => getForms; + +export default (action$, state$) => + action$.pipe( + ofType(getFormsAction.type), + debounce(() => { + const search = formsSelector.searchSelector(state$.value); + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap(() => { + const state = state$.value; + + const page = formsSelector.pageSelector(state); + const pageSize = formsSelector.rowsPerPageSelector(state); + const searchText = formsSelector.searchSelector(state); + const sortBy = formsSelector.sortBySelector(state); + const sortOrder = formsSelector.sortOrderSelector(state); + const status = formsSelector.statusSelector(state); + const site = formsSelector.siteSelector(state); + + const variables = { + sortBy, + sortOrder, + page, + pageSize, + searchText, + status, + }; + + if (site?.value) { + variables.publisherId = +site.value; + } + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + + return of( + setFieldAction({ + forms: data.records, + totalCount: data.recordsTotal, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(of(setInitialAction()), stream$); + }), + ); diff --git a/anyclip/src/modules/forms/forms/redux/epics/getSiteOptionsAutocomplete.js b/anyclip/src/modules/forms/forms/redux/epics/getSiteOptionsAutocomplete.js new file mode 100644 index 0000000..00a5f44 --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/getSiteOptionsAutocomplete.js @@ -0,0 +1,55 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getSiteOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const getResponse = ({ data: { getFormHubs } }) => + getFormHubs.map((publisher) => ({ value: publisher.id, label: publisher.name })); + +const query = ` + query GetFormHubs( + $pageSize: Int + $searchText: String + ) { + getFormHubs( + searchText: $searchText + pageSize: $pageSize + ) { + id + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getSiteOptionsAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload ?? '', + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + siteOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ siteOptions: null })), stream$); + }), + ); diff --git a/anyclip/src/modules/forms/forms/redux/epics/getTemplateGallery.js b/anyclip/src/modules/forms/forms/redux/epics/getTemplateGallery.js new file mode 100644 index 0000000..a8f1fbd --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/getTemplateGallery.js @@ -0,0 +1,103 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { TEMPLATE_CATEGORY_ALL } from '@/modules/forms/constants'; +import { GET_TEMPLATE_STATUS_INITIAL } from '@/modules/forms/forms/constants'; + +import * as formsSelector from '../selectors'; +import { getTemplateGalleryAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getTemplateIds } from '@/modules/forms/forms/helpers/upsertLastUsedTemplates'; + +const query = ` + query GetFormTemplateGallery( + $searchText: String + $formTemplateCategoryIds: [Int] + ) { + getFormTemplateGallery( + searchText: $searchText + formTemplateCategoryIds: $formTemplateCategoryIds + ) { + records { + id + formTemplateCategory { + id + name + } + account { + id + name + } + name + title + logo + updatedAt + updatedBy + } + recordsTotal + } + } +`; + +const getResponse = ({ data: { getFormTemplateGallery } }) => getFormTemplateGallery; + +export default (action$, state$) => + action$.pipe( + ofType(getTemplateGalleryAction.type), + debounce(() => { + const templateSearchText = formsSelector.templateSearchTextSelector(state$.value); + return timer(templateSearchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const moduleState = state$.value; + + const templateSearchText = formsSelector.templateSearchTextSelector(moduleState); + const templateCategory = formsSelector.templateCategorySelector(moduleState); + + const variables = { + searchText: templateSearchText, + }; + + if (templateCategory !== TEMPLATE_CATEGORY_ALL) { + variables.formTemplateCategoryIds = [templateCategory]; + } + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { records: templates } = getResponse(response); + const state = { + templates, + }; + + if (action.payload === GET_TEMPLATE_STATUS_INITIAL) { + const lastUsedTemplateIds = getTemplateIds(); + const lastUsed = templates.filter((template) => lastUsedTemplateIds.includes(template.id)); + + if (lastUsed.length) { + state.lastUsedTemplates = lastUsed; + } else { + // get random slice with 3 templates + const maxLength = 3; + const randomIndex = Math.floor(Math.random() * templates.length); + const endIndex = randomIndex > maxLength ? randomIndex : maxLength; + const startIndex = endIndex - maxLength; + + state.lastUsedTemplates = templates.slice(startIndex, endIndex); + } + } + + return of(setFieldAction(state)); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/forms/redux/epics/getTemplateGalleryCategory.js b/anyclip/src/modules/forms/forms/redux/epics/getTemplateGalleryCategory.js new file mode 100644 index 0000000..d692eab --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/getTemplateGalleryCategory.js @@ -0,0 +1,60 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TEMPLATE_CATEGORIES_OPTIONS } from '@/modules/forms/constants'; + +import { getTemplateGalleryCategoryAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getFormTemplateGalleryCategories( + $pageSize: Int, + ) { + getFormTemplateGalleryCategories( + pageSize: $pageSize, + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { getFormTemplateGalleryCategories } }) => + getFormTemplateGalleryCategories.records.map((cat) => ({ + value: cat.id, + label: cat.name, + })); + +export default (action$) => + action$.pipe( + ofType(getTemplateGalleryCategoryAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: { + pageSize: 30, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + templateCategoryOptions: [].concat(TEMPLATE_CATEGORIES_OPTIONS, getResponse(response)), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/forms/redux/epics/index.js b/anyclip/src/modules/forms/forms/redux/epics/index.js new file mode 100644 index 0000000..e494628 --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import archive from './archive'; +import downloadFormData from './downloadFormData'; +import getForms from './getForms'; +import getSiteOptionsAutocomplete from './getSiteOptionsAutocomplete'; +import getTemplateGallery from './getTemplateGallery'; +import getTemplateGalleryCategory from './getTemplateGalleryCategory'; +import sendFormReportToUserEmail from './sendFormReportToUserEmail'; + +export default combineEpics( + getSiteOptionsAutocomplete, + getForms, + archive, + downloadFormData, + sendFormReportToUserEmail, + getTemplateGallery, + getTemplateGalleryCategory, +); diff --git a/anyclip/src/modules/forms/forms/redux/epics/sendFormReportToUserEmail.js b/anyclip/src/modules/forms/forms/redux/epics/sendFormReportToUserEmail.js new file mode 100644 index 0000000..8ba5556 --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/epics/sendFormReportToUserEmail.js @@ -0,0 +1,52 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { sendFormReportToUserEmailAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation SendFormReportToUserEmail( + $formId: Int!, + $bucket: String!, + $path: String!, + $startDate: String!, + $endDate: String!, + ) { + sendFormReportToUserEmail( + formId: $formId, + bucket: $bucket, + path: $path, + startDate: $startDate, + endDate: $endDate, + ) { + status + } + } +`; + +export default (action$) => + action$.pipe( + ofType(sendFormReportToUserEmailAction.type), + switchMap((action) => { + const { + formId, + bucketConfig: { bucket, path }, + startDate, + } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + formId, + bucket, + path, + startDate: dayjs(startDate).format('YYYY-MM-DD'), + endDate: dayjs(startDate).add(30, 'days').format('YYYY-MM-DD'), + }, + }).pipe(switchMap(() => EMPTY)); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/forms/redux/selectors/index.js b/anyclip/src/modules/forms/forms/redux/selectors/index.js new file mode 100644 index 0000000..e054def --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/selectors/index.js @@ -0,0 +1,25 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const formsSelector = (state) => state[nameSpace].forms; +export const pageSelector = (state) => state[nameSpace].page; +export const totalCountSelector = (state) => state[nameSpace].totalCount; +export const rowsPerPageSelector = (state) => state[nameSpace].rowsPerPage; +export const searchSelector = (state) => state[nameSpace].search; +export const sortBySelector = (state) => state[nameSpace].sortBy; +export const sortOrderSelector = (state) => state[nameSpace].sortOrder; +export const statusSelector = (state) => state[nameSpace].status; +export const siteSelector = (state) => state[nameSpace].site; +export const siteOptionsSelector = (state) => state[nameSpace].siteOptions; +export const isFormDownloadInProgressSelector = (state) => state[nameSpace].isFormDownloadInProgress; +export const formDownloadProcessingNotificationKeySelector = (state) => + state[nameSpace].formDownloadProcessingNotificationKey; +export const shouldShowDownloadTryRequestOverConfirmSelector = (state) => + state[nameSpace].shouldShowDownloadTryRequestOverConfirm; +export const formDownloadTryRequestCounterSelector = (state) => state[nameSpace].formDownloadTryRequestCounter; +export const templatesSelector = (state) => state[nameSpace].templates; +export const lastUsedTemplatesSelector = (state) => state[nameSpace].lastUsedTemplates; +export const templateCategorySelector = (state) => state[nameSpace].templateCategory; +export const templateCategoryOptionsSelector = (state) => state[nameSpace].templateCategoryOptions; +export const templateSearchTextSelector = (state) => state[nameSpace].templateSearchText; diff --git a/anyclip/src/modules/forms/forms/redux/slices/index.js b/anyclip/src/modules/forms/forms/redux/slices/index.js new file mode 100644 index 0000000..8f43a38 --- /dev/null +++ b/anyclip/src/modules/forms/forms/redux/slices/index.js @@ -0,0 +1,61 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { SORT_DESC } from '@/modules/@common/constants/sort'; +import { TEMPLATE_CATEGORIES_OPTIONS, TEMPLATE_CATEGORY_ALL } from '@/modules/forms/constants'; +import { ROWS_PER_PAGE } from '@/modules/forms/forms/constants'; + +const initialState = { + forms: [], + page: 1, + totalCount: 0, + rowsPerPage: ROWS_PER_PAGE, + search: '', + sortBy: 'updatedAt', + sortOrder: SORT_DESC, + status: 1, + site: null, + siteOptions: [], + isFormDownloadInProgress: false, + formDownloadProcessingNotificationKey: null, + + shouldShowDownloadTryRequestOverConfirm: false, + formDownloadTryRequestCounter: 1, + // templates + templates: [], + lastUsedTemplates: [], + templateCategory: TEMPLATE_CATEGORY_ALL, + templateCategoryOptions: TEMPLATE_CATEGORIES_OPTIONS, + templateSearchText: '', +}; + +export const slice = createSlice({ + name: '@@FORMS/LIST', + initialState, + + reducers: { + getSiteOptionsAction: (state) => state, + getFormsAction: (state) => state, + toArchiveAction: (state) => state, + downloadFormDataAction: (state) => state, + sendFormReportToUserEmailAction: (state) => state, + getTemplateGalleryAction: (state) => state, + getTemplateGalleryCategoryAction: (state) => state, + setFieldAction: (state, action) => ({ + ...state, + ...action.payload, + }), + }, +}); + +export const { + downloadFormDataAction, + getFormsAction, + getSiteOptionsAction, + getTemplateGalleryAction, + getTemplateGalleryCategoryAction, + sendFormReportToUserEmailAction, + setFieldAction, + toArchiveAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/forms/helpers/createDynamicField.js b/anyclip/src/modules/forms/helpers/createDynamicField.js new file mode 100644 index 0000000..bd78656 --- /dev/null +++ b/anyclip/src/modules/forms/helpers/createDynamicField.js @@ -0,0 +1,83 @@ +import { + FIELD_CHECKBOX, + FIELD_DATE, + FIELD_EMAIL, + FIELD_RADIO, + FIELD_SELECT, + FIELD_TEXT, + FIELD_TEXTAREA, +} from '@/modules/forms/editor/constants'; + +const createBase = (component, props = {}) => ({ + id: props.name || `name-${Date.now()}`, + isSelected: props.isSelected || false, + component, + value: '', + placeholder: 'Enter Label *', + isRequire: props.validation ? props.validation?.required?.value : true, + isTypeDisabled: props.isTypeDisabled || false, +}); + +export const createOption = (id = 1) => ({ + id, + name: '', + placeholder: `Option ${id}`, + isEditable: false, + isDefault: false, +}); + +export const createOptions = (options) => + options.map((option) => ({ + id: option.value, + name: option.label, + placeholder: `Option ${option.value}`, + isDefault: option.isSelected, + isEditable: false, + })); + +export const createDynamicField = (component, props = {}) => { + const base = createBase(component, props); + + switch (component) { + case FIELD_TEXT: + case FIELD_TEXTAREA: + return { + ...base, + value: props.placeholder || '', + }; + case FIELD_EMAIL: + return { + ...base, + value: props.placeholder || '', + }; + case FIELD_DATE: + return { + ...base, + value: props.label || '', + }; + case FIELD_RADIO: + return { + ...base, + value: props.label || '', + options: props.options?.length ? createOptions(props.options) : [createOption()], + }; + case FIELD_SELECT: + return { + ...base, + value: props.label || '', + options: props.options?.length ? createOptions(props.options) : [createOption()], + }; + case FIELD_CHECKBOX: + return { + ...base, + value: props.label || '', + options: props.options?.length ? createOptions(props.options) : [createOption()], + }; + default: + return base; + } +}; + +export const createDefaultField = (props) => createDynamicField(FIELD_TEXT, props); + +export default createDynamicField; diff --git a/anyclip/src/modules/forms/helpers/index.ts b/anyclip/src/modules/forms/helpers/index.ts new file mode 100644 index 0000000..14bdc89 --- /dev/null +++ b/anyclip/src/modules/forms/helpers/index.ts @@ -0,0 +1,6 @@ +export const getCharacterCount = (html: string): number => { + const div = document.createElement('div'); + div.innerHTML = html; + + return div.textContent.trim().length; +}; diff --git a/anyclip/src/modules/forms/helpers/useFormSubmitMode.js b/anyclip/src/modules/forms/helpers/useFormSubmitMode.js new file mode 100644 index 0000000..6a8fe94 --- /dev/null +++ b/anyclip/src/modules/forms/helpers/useFormSubmitMode.js @@ -0,0 +1,25 @@ +import { useRouter } from 'next/router'; + +import { + FORM_SUBMIT_MODE_CREATE, + FORM_SUBMIT_MODE_DUPLICATE, + FORM_SUBMIT_MODE_UPDATE, +} from '@/modules/forms/editor/constants'; + +const useFormSubmitMode = () => { + const router = useRouter(); + const [entityParam, duplicateParam] = router.query.params; + const id = parseInt(entityParam, 10); + const isDuplicate = !!duplicateParam; + + switch (true) { + case id && !isDuplicate: + return FORM_SUBMIT_MODE_UPDATE; + case id && isDuplicate: + return FORM_SUBMIT_MODE_DUPLICATE; + default: + return FORM_SUBMIT_MODE_CREATE; + } +}; + +export default useFormSubmitMode; diff --git a/anyclip/src/modules/forms/helpers/useGetReadOnlyStatus.js b/anyclip/src/modules/forms/helpers/useGetReadOnlyStatus.js new file mode 100644 index 0000000..4ad63d3 --- /dev/null +++ b/anyclip/src/modules/forms/helpers/useGetReadOnlyStatus.js @@ -0,0 +1,35 @@ +import { useSelector } from 'react-redux'; + +import { PCN_POST_FORMS_EDITOR, PCN_PUT_FORMS_EDITOR } from '@/modules/@common/acl/constants'; +import { + FORM_SUBMIT_MODE_CREATE, + FORM_SUBMIT_MODE_DUPLICATE, + FORM_SUBMIT_MODE_UPDATE, + STATUS_ARCHIVED, +} from '@/modules/forms/editor/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import useFormSubmitMode from './useFormSubmitMode'; + +function useGetReadOnlyStatus(status) { + const formSubmitMode = useFormSubmitMode(); + const userPermissions = useSelector(getUserPermissionsSelector); + + if (status === STATUS_ARCHIVED) { + return true; + } + + if (formSubmitMode === FORM_SUBMIT_MODE_UPDATE) { + return !hasPermission(PCN_PUT_FORMS_EDITOR, userPermissions); + } + + if ([FORM_SUBMIT_MODE_CREATE, FORM_SUBMIT_MODE_DUPLICATE].includes(formSubmitMode)) { + return !hasPermission(PCN_POST_FORMS_EDITOR, userPermissions); + } + + return false; +} + +export default useGetReadOnlyStatus; diff --git a/anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.jsx b/anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.jsx new file mode 100644 index 0000000..8c8ce6d --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.jsx @@ -0,0 +1,494 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { ContentCopyRounded } from '@mui/icons-material'; + +import { POST_FORM_TEMPLATES, PUT_FORM_TEMPLATES } from '@/modules/@common/acl/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { + FORM_PREVIEW_WIDGET_ID, + FORM_SUBMIT_MODE_CREATE, + FORM_SUBMIT_MODE_DUPLICATE, + FORM_SUBMIT_MODE_UPDATE, +} from '@/modules/forms/constants'; +import { + ALLOWED_IMAGE_EXT, + FIELD_MAX_LENGTH_DESCRIPTION, + FIELD_MAX_LENGTH_NAME, + FIELD_MAX_LENGTH_PPTEXT, + FIELD_MAX_LENGTH_SKIPBUTTONTEXT, + FIELD_MAX_LENGTH_SUBMITTEXT, + FIELD_MAX_LENGTH_TCTEXT, + FIELD_MAX_LENGTH_TITLE, + MAX_LOGO_SIZE_IN_BYTES, +} from '@/modules/forms/editor/constants'; + +import { getFormMetadataCalculation } from '../../helpers/calculationsFromState'; +import * as selectors from '../../redux/selectors'; +import { + getAccountOptionsAction, + getCategoryOptionsAction, + removeErrorByPropAction, + setFieldAction, + uploadBannerAction, + uploadLogoAction, +} from '../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserAccountIdSelector, getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import useFormSubmitMode from '@/modules/forms/helpers/useFormSubmitMode'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { + FormGroup, + FormImageUploader, + FormRow, + FormRowItem, + FormSection, + useFormSettings, +} from '@/modules/@common/Form'; +import FormPreview from '@/modules/forms/common/components/FormPreview'; +import RichEditor from '@/modules/forms/common/components/RichEditor/RichEditor'; +import DynamicFields from '../../../common/components/DynamicFields'; +import StylesSettings from '../../../common/components/StylesSettings'; +import { Autocomplete, Chip, IconButton, InputAdornment, MenuItem, Select, Switch, TextField } from '@/mui/components'; + +import styles from './DetailsTab.module.scss'; + +function DetailsTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + const formSubmitMode = useFormSubmitMode(router); + const id = useSelector(selectors.idSelector); + const name = useSelector(selectors.nameSelector); + const account = useSelector(selectors.accountSelector); + const category = useSelector(selectors.categorySelector); + const categoryOptions = useSelector(selectors.categoryOptionsSelector); + const image = useSelector(selectors.imageSelector); + const title = useSelector(selectors.titleSelector); + const description = useSelector(selectors.descriptionSelector); + const submitButton = useSelector(selectors.submitButtonSelector); + const privacyPolicy = useSelector(selectors.privacyPolicySelector); + const termsConditions = useSelector(selectors.termsConditionsSelector); + const skipButton = useSelector(selectors.skipButtonSelector); + const style = useSelector(selectors.styleSelector); + const dynamicFields = useSelector(selectors.dynamicFieldsSelector); + const autocomplete = useSelector(selectors.autocompleteSelector); + const scheme = useSelector(selectors.schemeSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + const userAccountId = useSelector(getUserAccountIdSelector); + const formMetadata = useSelector(getFormMetadataCalculation); + + const readOnly = + (formSubmitMode === FORM_SUBMIT_MODE_UPDATE && !hasPermission(PUT_FORM_TEMPLATES, userPermissions)) || + ([FORM_SUBMIT_MODE_CREATE, FORM_SUBMIT_MODE_DUPLICATE].includes(formSubmitMode) && + !hasPermission(POST_FORM_TEMPLATES, userPermissions)); + + useEffect(() => { + dispatch(getCategoryOptionsAction()); + }, []); + + const setField = (o) => dispatch(setFieldAction(o)); + const getAccountOptions = (o) => dispatch(getAccountOptionsAction(o)); + const uploadLogo = (o) => dispatch(uploadLogoAction(o)); + const uploadBanner = (o) => dispatch(uploadBannerAction(o)); + + const [LOGO, BANNER] = image; + + const shouldShowFormId = id && formSubmitMode === FORM_SUBMIT_MODE_UPDATE; + + return ( + <> + + + + + + ), + }} + disabled={readOnly} + onChange={({ target }) => setField({ name: target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + {shouldShowFormId && ( + + + copyToClipboard(id)}> + + + + ), + }} + /> + + )} + + + + + {!userAccountId && ( + + + setField({ + account: account$, + }) + } + onOpen={() => { + setField({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }); + getAccountOptions(''); + }} + renderInput={(params) => ( + getAccountOptions(target.value)} + onFocus={() => dispatch(removeErrorByPropAction(['account']))} + /> + )} + /> + + )} + + item.toUpperCase()).join( + ', ', + )}. Max File Size ${MAX_LOGO_SIZE_IN_BYTES / 1024}kb`} + disabled={readOnly} + onValidate={(file) => { + const fileExt = file.name.split('.').pop(); + + if (!ALLOWED_IMAGE_EXT.includes(fileExt.toLowerCase())) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: `Invalid file extension, valid only (${ALLOWED_IMAGE_EXT.join(',')})`, + }), + ); + + return false; + } + if (file.size > MAX_LOGO_SIZE_IN_BYTES) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: 'File size exceeded the maximum size permitted.', + }), + ); + + return false; + } + + return true; + }} + onLoad={(event, file, readerResult) => { + uploadLogo({ + image, + file: readerResult, + fileName: file.name, + }); + }} + onRemove={() => { + setField({ + image: [null, BANNER], + }); + }} + /> + + + + { + setField({ + title: content, + }); + }} + {...getInputPropsByName(scheme, ['title'])} + onFocus={() => dispatch(removeErrorByPropAction(['title']))} + /> + + + + { + setField({ + description: content, + }); + }} + {...getInputPropsByName(scheme, ['description'])} + label="" + onFocus={() => dispatch(removeErrorByPropAction(['description']))} + /> + + + + dispatch(removeErrorByPropAction(keyArray))} + /> + + + + setField({ + submitButton: { title: target.value }, + }) + } + InputProps={{ + endAdornment: ( + + + + ), + }} + {...getInputPropsByName(scheme, ['submitButton.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['submitButton.title']))} + /> + + + + + + setField({ + privacyPolicy: { + ...privacyPolicy, + title: target.value, + }, + }) + } + InputProps={{ + endAdornment: ( + + + + ), + }} + {...getInputPropsByName(scheme, ['privacyPolicy.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['privacyPolicy.title']))} + /> + + + + + setField({ + termsConditions: { + ...termsConditions, + isActive: target.checked, + }, + }) + } + /> + + {termsConditions.isActive && ( + + + + setField({ + termsConditions: { + ...termsConditions, + title: target.value, + }, + }) + } + InputProps={{ + endAdornment: ( + + + + ), + }} + {...getInputPropsByName(scheme, ['termsConditions.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['termsConditions.title']))} + /> + + + )} + + + setField({ + skipButton: { + ...skipButton, + isActive: target.checked, + }, + }) + } + /> + + {skipButton.isActive && ( + + + + setField({ + skipButton: { + ...skipButton, + title: target.value, + }, + }) + } + InputProps={{ + endAdornment: ( + + + + ), + }} + {...getInputPropsByName(scheme, ['skipButton.title'])} + onFocus={() => dispatch(removeErrorByPropAction(['skipButton.title']))} + /> + + + )} + + + + setField({ + style: { + ...style, + ...value, + }, + }) + } + /> + + +
    + +
    +
    +
    +
    + + ); +} + +export default DetailsTab; diff --git a/anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.module.scss b/anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.module.scss new file mode 100644 index 0000000..a054764 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/components/DetailsTab/DetailsTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"StickyItem":"DetailsTab_StickyItem__MIzlL","PreviewWrapper":"DetailsTab_PreviewWrapper__K29vU"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/templateEditor/components/TemplateEditor.module.scss b/anyclip/src/modules/forms/templateEditor/components/TemplateEditor.module.scss new file mode 100644 index 0000000..c165a20 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/components/TemplateEditor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"TemplateEditor_Wrapper__n2wyg","Title":"TemplateEditor_Title__bsfGa","Controls":"TemplateEditor_Controls__APYen","Loader":"TemplateEditor_Loader__ONGol","Tabs":"TemplateEditor_Tabs__YY1GK"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/templateEditor/components/index.jsx b/anyclip/src/modules/forms/templateEditor/components/index.jsx new file mode 100644 index 0000000..677dd6d --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/components/index.jsx @@ -0,0 +1,182 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { + FORM_PREVIEW_WIDGET_ID, + FORM_SUBMIT_MODE_DUPLICATE, + FORM_SUBMIT_MODE_UPDATE, + TAB_FORM_DETAILS, +} from '../../constants'; +import { PUT_FORM_TEMPLATES } from '@/modules/@common/acl/constants'; + +import * as selectors from '../redux/selectors'; +import { + getTemplateByIdAction, + saveProcessAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + validateFields, +} from '../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserAccountIdSelector, getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import useFormSubmitMode from '@/modules/forms/helpers/useFormSubmitMode'; + +import { Form, FormContent } from '@/modules/@common/Form'; +import DetailsTab from './DetailsTab/DetailsTab'; +import { Button, Stack, TabContent, Typography } from '@/mui/components'; + +import styles from './TemplateEditor.module.scss'; + +function Editor() { + const router = useRouter(); + const store = useStore(); + const [param] = router.query.params; + const id = parseInt(param, 10); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const formSubmitMode = useFormSubmitMode(); + const dispatch = useDispatch(); + const userPermissions = useSelector(getUserPermissionsSelector); + const name = useSelector(selectors.nameSelector); + const userAccountId = useSelector(getUserAccountIdSelector); + const isReadOnlyMode = !hasPermission(PUT_FORM_TEMPLATES, userPermissions); + + const saveToServerForm = async () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ fieldName }) => { + if (['termsConditions.src', 'termsConditions.title'].includes(fieldName)) { + return allProps.termsConditions.isActive; + } + + if (fieldName === 'skipButton.title') { + return allProps.skipButton.isActive; + } + + if (fieldName === 'account') { + return !userAccountId; + } + + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else { + const $el = document.getElementById(FORM_PREVIEW_WIDGET_ID); + const $iframe = $el.querySelector('iframe'); + const { body } = $iframe.contentWindow.document; + + const iframeWidth = $iframe.contentWindow.innerWidth; + const iframeHeight = $iframe.contentWindow.innerHeight; + + const MAX_WIDTH = 113; + + const ratio = MAX_WIDTH / iframeWidth; + + const { toCanvas } = await import('html-to-image'); + const canvas = await toCanvas(body, { + width: iframeWidth, + height: iframeHeight, + }); + + const scaledCanvas = document.createElement('canvas'); + scaledCanvas.width = iframeWidth * ratio; + scaledCanvas.height = iframeHeight * ratio; + + const ctx = scaledCanvas.getContext('2d'); + ctx.drawImage(canvas, 0, 0, iframeWidth, iframeHeight, 0, 0, scaledCanvas.width, scaledCanvas.height); + + dispatch( + saveProcessAction({ + thumbFile: scaledCanvas.toDataURL('image/png'), + formSubmitMode, + }), + ); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const labels = { + [FORM_SUBMIT_MODE_UPDATE]: 'Update', + [FORM_SUBMIT_MODE_DUPLICATE]: 'Duplicate', + default: 'Save', + }; + + useEffect(() => { + if (id) { + dispatch( + getTemplateByIdAction({ + id: parseInt(id, 10), + isDuplicate: formSubmitMode === FORM_SUBMIT_MODE_DUPLICATE, + }), + ); + } else { + dispatch(setInitialAction()); + } + }, [id]); + + let pageTitle = 'New Form Template'; + if (formSubmitMode === FORM_SUBMIT_MODE_DUPLICATE) { + pageTitle = 'Copy Form Template'; + } else if (id) { + pageTitle = `${name} > Settings`; + } + + return ( +
    + + + {pageTitle} + + + + + + {!isReadOnlyMode && ( + + )} + + +
    + + + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/forms/templateEditor/constants/index.js b/anyclip/src/modules/forms/templateEditor/constants/index.js new file mode 100644 index 0000000..9249db1 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/constants/index.js @@ -0,0 +1 @@ +export const FORM_REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/forms/templateEditor/helpers/calculationsFromState.js b/anyclip/src/modules/forms/templateEditor/helpers/calculationsFromState.js new file mode 100644 index 0000000..477354b --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/helpers/calculationsFromState.js @@ -0,0 +1,42 @@ +import * as selectors from '../redux/selectors'; +import { createFormMetadata } from '@/modules/forms/editor/helpers/metadata/v1/createFormMetadata'; + +// todo: delete +export const checkIsValidToSaveCalculation = (state$) => { + const name = selectors.nameSelector(state$); + const title = selectors.titleSelector(state$); + const dynamicFields = selectors.dynamicFieldsSelector(state$); + const privacyPolicy = selectors.privacyPolicySelector(state$); + const submitButton = selectors.submitButtonSelector(state$); + const termsConditions = selectors.termsConditionsSelector(state$); + const skipButton = selectors.skipButtonSelector(state$); + + const isValid = !!( + name && + title && + dynamicFields.length && + dynamicFields.every((field) => field.value) && + privacyPolicy.title && + submitButton.title && + (termsConditions.isActive ? termsConditions.title : true) && + (skipButton.isActive ? skipButton.title : true) + ); + + return isValid; +}; + +export const getFormMetadataCalculation = (state$) => { + const state = { + name: selectors.nameSelector(state$), + style: selectors.styleSelector(state$), + privacyPolicy: selectors.privacyPolicySelector(state$), + termsConditions: selectors.termsConditionsSelector(state$), + skipButton: selectors.skipButtonSelector(state$), + submitButton: selectors.submitButtonSelector(state$), + image: selectors.imageSelector(state$), + title: selectors.titleSelector(state$), + description: selectors.descriptionSelector(state$), + dynamicFields: selectors.dynamicFieldsSelector(state$), + }; + return createFormMetadata(state); +}; diff --git a/anyclip/src/modules/forms/templateEditor/helpers/createRequestBody.js b/anyclip/src/modules/forms/templateEditor/helpers/createRequestBody.js new file mode 100644 index 0000000..5844913 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/helpers/createRequestBody.js @@ -0,0 +1,29 @@ +import * as selectors from '../redux/selectors'; + +import { getFormMetadataCalculation } from './calculationsFromState'; + +const createRequestBody = (state) => { + const id = selectors.idSelector(state); + const formTemplateCategoryId = selectors.categorySelector(state); + const account = selectors.accountSelector(state); + const name = selectors.nameSelector(state); + const title = selectors.titleSelector(state); + const logo = selectors.imageSelector(state); + + const body = { + formTemplateCategoryId, + accountId: account.value || null, + name, + title, + logo, + metadata: JSON.stringify(getFormMetadataCalculation(state)), + }; + + if (id) { + body.id = id; + } + + return body; +}; + +export default createRequestBody; diff --git a/anyclip/src/modules/forms/templateEditor/helpers/parseResponseToState.js b/anyclip/src/modules/forms/templateEditor/helpers/parseResponseToState.js new file mode 100644 index 0000000..158febe --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/helpers/parseResponseToState.js @@ -0,0 +1,36 @@ +import { TEMPLATE_ACCOUNT_ALL } from '@/modules/forms/constants'; + +import parseMetadataToState from '@/modules/forms/editor/helpers/metadata/v1/parseFormMetadataToState'; + +const toAutocompleteValue = (entity) => + entity + ? { + value: entity.id, + label: entity.name, + } + : TEMPLATE_ACCOUNT_ALL; + +const parseResponseState = (res, isDuplicate = false) => { + const fromMetadata = parseMetadataToState(res.metadata, isDuplicate); + + const initialState = { + id: res.id, + category: res.formTemplateCategoryId, + account: toAutocompleteValue(res.account), + name: isDuplicate ? '' : res.name, + title: res.title, + image: res.logo, + // data gets from metadata + description: fromMetadata.description, + dynamicFields: fromMetadata.dynamicFields, + privacyPolicy: fromMetadata.privacyPolicy, + termsConditions: fromMetadata.termsConditions, + submitButton: fromMetadata.submitButton, + skipButton: fromMetadata.skipButton, + style: fromMetadata.style, + }; + + return initialState; +}; + +export default parseResponseState; diff --git a/anyclip/src/modules/forms/templateEditor/helpers/validationScheme.js b/anyclip/src/modules/forms/templateEditor/helpers/validationScheme.js new file mode 100644 index 0000000..2884c24 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/helpers/validationScheme.js @@ -0,0 +1,116 @@ +import { TAB_FORM_DETAILS } from '../../constants'; +import { FIELD_MAX_LENGTH_DESCRIPTION, FIELD_MAX_LENGTH_TITLE } from '@/modules/forms/editor/constants'; + +import { getCharacterCount } from '@/modules/forms/helpers'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_FORM_DETAILS, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + if (value.length < 2) { + return 'Field cannot be less then 2 symbols'; + } + + return ''; + }, + }, + { + fieldName: 'title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + const length = getCharacterCount(value); + + if (!length) { + return 'Field cannot be empty'; + } + + if (length > FIELD_MAX_LENGTH_TITLE) { + return `Field can’t exceed ${FIELD_MAX_LENGTH_TITLE} characters`; + } + + return ''; + }, + }, + { + fieldName: 'description', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (getCharacterCount(value) > FIELD_MAX_LENGTH_DESCRIPTION) { + return `Field can’t exceed ${FIELD_MAX_LENGTH_DESCRIPTION} characters`; + } + + return ''; + }, + }, + // dynamic fields + { + fieldName: 'dynamicFields', + dynamic: true, + tabId: TAB_FORM_DETAILS, + validation: (dynamicFields) => + dynamicFields.reduce((acc, field) => { + acc[field.id] = { + value: !field.value ? 'Field cannot be empty' : '', + options: field.options?.reduce((acc$, option) => { + acc$[option.id] = { + name: !option.name ? 'Field cannot be empty' : '', + }; + + return acc$; + }, {}), + }; + + return acc; + }, {}), + }, + { + fieldName: 'submitButton.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'privacyPolicy.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'termsConditions.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'skipButton.title', + tabId: TAB_FORM_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/forms/templateEditor/index.jsx b/anyclip/src/modules/forms/templateEditor/index.jsx new file mode 100644 index 0000000..ea92f69 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/index.jsx @@ -0,0 +1,3 @@ +import FormTemplateEditor from './components'; + +export default FormTemplateEditor; diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/create.js b/anyclip/src/modules/forms/templateEditor/redux/epics/create.js new file mode 100644 index 0000000..5196b77 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/create.js @@ -0,0 +1,62 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { createTemplateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import createRequestBody from '@/modules/forms/templateEditor/helpers/createRequestBody'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation CreateFormTemplate( + $template: FormTemplateInputType + ) { + createFormTemplate( + template: $template + ) { + id + name + } + } +`; + +const getResponse = ({ data: { createFormTemplate } }) => createFormTemplate; + +export default (action$, state$) => + action$.pipe( + ofType(createTemplateAction.type), + switchMap(() => { + const state = state$.value; + const template = createRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + template, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const link = `/form-templates/${data.id}`; + + Router.push(link); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Template created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/duplicate.js b/anyclip/src/modules/forms/templateEditor/redux/epics/duplicate.js new file mode 100644 index 0000000..c6f25eb --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/duplicate.js @@ -0,0 +1,62 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { duplicateTemplateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import createRequestBody from '@/modules/forms/templateEditor/helpers/createRequestBody'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation DuplicateFormTemplate( + $template: FormTemplateInputType + ) { + duplicateFormTemplate( + template: $template + ) { + id + name + } + } +`; + +const getResponse = ({ data: { duplicateFormTemplate } }) => duplicateFormTemplate; + +export default (action$, state$) => + action$.pipe( + ofType(duplicateTemplateAction.type), + switchMap(() => { + const state = state$.value; + const template = createRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + template, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const link = `/form-templates/${data.id}`; + + Router.push(link); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Template duplicated', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/getAccountsAutocomplete.js b/anyclip/src/modules/forms/templateEditor/redux/epics/getAccountsAutocomplete.js new file mode 100644 index 0000000..5cd02a0 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/getAccountsAutocomplete.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TEMPLATE_ACCOUNT_ALL } from '@/modules/forms/constants'; + +import * as selectors from '../selectors'; +import { getAccountOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getFormTemplateAccounts( + $searchText: String, + $pageSize: Int + ) { + getFormTemplateAccounts( + searchText: $searchText, + pageSize: $pageSize, + ) { + id + name + } + } +`; + +const getResponse = ({ data: { getFormTemplateAccounts } }) => + getFormTemplateAccounts.map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$, state$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + switchMap((action) => { + const autocomplete = selectors.autocompleteSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const textOptions = getResponse(response); + + if (!action.payload?.length) { + textOptions.unshift(TEMPLATE_ACCOUNT_ALL); + } + + actions.push( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions, + }, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setFieldAction({ + autocomplete: { + ...autocomplete, + textOptions: null, + }, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/getById.js b/anyclip/src/modules/forms/templateEditor/redux/epics/getById.js new file mode 100644 index 0000000..6e13c8f --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/getById.js @@ -0,0 +1,84 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { getTemplateByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import parseResponseToState from '@/modules/forms/templateEditor/helpers/parseResponseToState'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query GetTemplateByIdQuery( + $id: Int! + $cloneLogo: Boolean + ) { + getTemplateById( + id: $id + cloneLogo: $cloneLogo + ) { + id + formTemplateCategoryId + account { + id + name + } + name + title + logo + metadata + } + } +`; + +const getResponse = ({ data: { getTemplateById } }) => getTemplateById; + +export default (action$) => + action$.pipe( + ofType(getTemplateByIdAction.type), + switchMap((action) => { + const { id, isDuplicate = false } = action.payload; + const variables = { id }; + + if (isDuplicate) { + variables.cloneLogo = true; + } + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const actions = []; + try { + const state = parseResponseToState(data, isDuplicate); + + actions.push(of(setFieldAction(state))); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Cant open template for edit. Metadata format is wrong', + }), + ), + ); + + Router.push('/form-templates'); + } + + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/getCategory.js b/anyclip/src/modules/forms/templateEditor/redux/epics/getCategory.js new file mode 100644 index 0000000..7d057de --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/getCategory.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCategoryOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getFormTemplateCategories( + $pageSize: Int, + ) { + getFormTemplateCategories( + pageSize: $pageSize, + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { getFormTemplateCategories } }) => + getFormTemplateCategories.records.map((cat) => ({ + value: cat.id, + label: cat.name, + })); + +export default (action$) => + action$.pipe( + ofType(getCategoryOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: { + pageSize: 30, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + categoryOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/index.js b/anyclip/src/modules/forms/templateEditor/redux/epics/index.js new file mode 100644 index 0000000..d148406 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/index.js @@ -0,0 +1,23 @@ +import { combineEpics } from 'redux-observable'; + +import create from './create'; +import duplicate from './duplicate'; +import getAccountsAutocomplete from './getAccountsAutocomplete'; +import getById from './getById'; +import getCategory from './getCategory'; +import saveProcess from './saveProcess'; +import update from './update'; +import uploadBanner from './uploadBanner'; +import uploadLogo from './uploadLogo'; + +export default combineEpics( + getAccountsAutocomplete, + uploadLogo, + uploadBanner, + create, + getById, + update, + duplicate, + getCategory, + saveProcess, +); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/saveProcess.js b/anyclip/src/modules/forms/templateEditor/redux/epics/saveProcess.js new file mode 100644 index 0000000..7093e47 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/saveProcess.js @@ -0,0 +1,87 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + FORM_SUBMIT_MODE_CREATE, + FORM_SUBMIT_MODE_DUPLICATE, + FORM_SUBMIT_MODE_UPDATE, +} from '@/modules/forms/constants'; + +import * as selectors from '../selectors'; +import { + createTemplateAction, + duplicateTemplateAction, + saveProcessAction, + setFieldAction, + updateTemplateAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UploadFormTemplateImage( + $picture: String, + $fileName: String, + ) { + uploadFormTemplateImage( + picture: $picture + fileName: $fileName + ) { + url + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(saveProcessAction.type), + switchMap((action) => { + const { thumbFile, formSubmitMode } = action.payload; + + const image = selectors.imageSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + picture: thumbFile, + fileName: 'thumb.png', + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { url: thumb } = response.data.uploadFormTemplateImage; + const [logo, banner] = image; + const actionMap = [ + { + action: createTemplateAction, + isActive: formSubmitMode === FORM_SUBMIT_MODE_CREATE, + }, + { + action: updateTemplateAction, + isActive: formSubmitMode === FORM_SUBMIT_MODE_UPDATE, + }, + { + action: duplicateTemplateAction, + isActive: formSubmitMode === FORM_SUBMIT_MODE_DUPLICATE, + }, + ]; + + const run = actionMap.find((a) => a.isActive); + + return concat( + of( + setFieldAction({ + image: [logo, banner, thumb], + }), + ), + of(run.action()), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/update.js b/anyclip/src/modules/forms/templateEditor/redux/epics/update.js new file mode 100644 index 0000000..1f92099 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/update.js @@ -0,0 +1,65 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { getTemplateByIdAction, updateTemplateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import createRequestBody from '@/modules/forms/templateEditor/helpers/createRequestBody'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateFormTemplate( + $template: FormTemplateInputType + ) { + updateFormTemplate( + template: $template + ) { + id + name + } + } +`; + +const getResponse = ({ data: { updateFormTemplate } }) => updateFormTemplate; + +export default (action$, state$) => + action$.pipe( + ofType(updateTemplateAction.type), + switchMap(() => { + const state = state$.value; + const template = createRequestBody(state); + + const stream$ = gqlRequest({ + query, + variables: { + template, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + const link = `/form-templates/${data.id}`; + + Router.push(link); + + return concat( + of(getTemplateByIdAction({ id: data.id })), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Template updated', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/uploadBanner.js b/anyclip/src/modules/forms/templateEditor/redux/epics/uploadBanner.js new file mode 100644 index 0000000..e39d7f0 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/uploadBanner.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { setFieldAction, uploadBannerAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UploadFormTemplateImage( + $picture: String, + $fileName: String, + ) { + uploadFormTemplateImage( + picture: $picture + fileName: $fileName + ) { + url + } + } +`; + +export default (action$) => + action$.pipe( + ofType(uploadBannerAction.type), + switchMap((action) => { + const { image, file, fileName } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + picture: file, + fileName, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { url: banner } = response.data.uploadFormTemplateImage; + const [logo] = image; + + return of( + setFieldAction({ + image: [logo, banner], + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/epics/uploadLogo.js b/anyclip/src/modules/forms/templateEditor/redux/epics/uploadLogo.js new file mode 100644 index 0000000..33ac1f8 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/epics/uploadLogo.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { setFieldAction, uploadLogoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UploadFormTemplateLogo( + $logo: String, + $fileName: String, + ) { + uploadFormTemplateLogo( + logo: $logo + fileName: $fileName + ) { + url + } + } +`; + +export default (action$) => + action$.pipe( + ofType(uploadLogoAction.type), + switchMap((action) => { + const { image, file, fileName } = action.payload; + + const stream$ = gqlRequest({ + query, + variables: { + logo: file, + fileName, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { url: logo } = response.data.uploadFormTemplateLogo; + const [, banner, thumb] = image; + + return of( + setFieldAction({ + image: [logo, banner, thumb], + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templateEditor/redux/selectors/index.js b/anyclip/src/modules/forms/templateEditor/redux/selectors/index.js new file mode 100644 index 0000000..ade270c --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/selectors/index.js @@ -0,0 +1,29 @@ +import { FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const idSelector = (state) => state[nameSpace].id; +export const nameSelector = (state) => state[nameSpace].name; +export const categorySelector = (state) => state[nameSpace].category; +export const categoryOptionsSelector = (state) => state[nameSpace].categoryOptions; +export const accountSelector = (state) => state[nameSpace].account; +export const imageSelector = (state) => state[nameSpace].image; +export const titleSelector = (state) => state[nameSpace].title; +export const descriptionSelector = (state) => state[nameSpace].description; +export const dynamicFieldsSelector = (state) => state[nameSpace].dynamicFields; +export const submitButtonSelector = (state) => state[nameSpace].submitButton; +export const privacyPolicySelector = (state) => state[nameSpace].privacyPolicy; +export const termsConditionsSelector = (state) => state[nameSpace].termsConditions; +export const skipButtonSelector = (state) => state[nameSpace].skipButton; +export const styleSelector = (state) => state[nameSpace].style; +export const autocompleteSelector = (state) => state[nameSpace].autocomplete; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +const formSelectors = createFormSelector(FORM_REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/forms/templateEditor/redux/slices/index.js b/anyclip/src/modules/forms/templateEditor/redux/slices/index.js new file mode 100644 index 0000000..5271020 --- /dev/null +++ b/anyclip/src/modules/forms/templateEditor/redux/slices/index.js @@ -0,0 +1,113 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { TAB_FORM_DETAILS, TEMPLATE_ACCOUNT_ALL, TEMPLATE_CATEGORY_CONTENT_FEEDBACK } from '../../../constants'; +import { ALIGN_ITEMS_RIGHT, FONTS_OPTIONS } from '../../../editor/constants'; +import { FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; +import { createDefaultField } from '@/modules/forms/helpers/createDynamicField'; + +const formSlice = createFormSlice(FORM_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + name: '', + category: TEMPLATE_CATEGORY_CONTENT_FEEDBACK, + categoryOptions: [], + account: TEMPLATE_ACCOUNT_ALL, // { value, label } + image: [null, null, null], // [logo, banner, thumb] + title: '', + description: '', + dynamicFields: [ + createDefaultField({ + name: 'defaultDynamicFieldName', + }), + ], + submitButton: { + title: 'Submit', + }, + privacyPolicy: { + title: 'Privacy Policy', + src: '', + }, + termsConditions: { + isActive: true, + title: 'Terms & Conditions', + src: '', + }, + skipButton: { + isActive: true, + title: 'Skip', + }, + style: { + bgColor: '#fff', + alignItems: ALIGN_ITEMS_RIGHT, + fontFamily: FONTS_OPTIONS[0].value, + fontSize: 14, + fontColor: '#666', + submitButtonBgColor: '#2869d1', + submitButtonFontColor: '#fff', + }, + medataFormId: null, // need for API which update metadata + autocomplete: { + textOptions: null, + }, + + activeTabId: TAB_FORM_DETAILS, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@TEMPLATE/EDITOR', + initialState, + reducers: { + getAccountOptionsAction: (state) => state, + getCategoryOptionsAction: (state) => state, + createTemplateAction: (state) => state, + uploadLogoAction: (state) => state, + uploadBannerAction: (state) => state, + getTemplateByIdAction: (state) => state, + updateTemplateAction: (state) => state, + duplicateTemplateAction: (state) => state, + saveProcessAction: (state) => state, + setFieldAction: (state, action) => ({ + ...state, + ...action.payload, + }), + setInitialAction: () => ({ + ...initialState, + }), + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + createTemplateAction, + duplicateTemplateAction, + getAccountOptionsAction, + getCategoryOptionsAction, + getTemplateByIdAction, + saveProcessAction, + setFieldAction, + setInitialAction, + updateTemplateAction, + uploadBannerAction, + uploadLogoAction, + setActiveTabIdAction, + + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/forms/templates/components/Templates.module.scss b/anyclip/src/modules/forms/templates/components/Templates.module.scss new file mode 100644 index 0000000..11948e0 --- /dev/null +++ b/anyclip/src/modules/forms/templates/components/Templates.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Title":"Templates_Title__AaCIe","Controls":"Templates_Controls__3wAja","TableWrapper":"Templates_TableWrapper__qTMQ5","Row":"Templates_Row__0XQ4Q","StatusSelect":"Templates_StatusSelect__4fhWE","NoWrap":"Templates_NoWrap__Yga2j","Search":"Templates_Search__c5R_Z","FilterSeparator":"Templates_FilterSeparator__EAHjW","FilterStatusControl":"Templates_FilterStatusControl__lWd9d","Pagination":"Templates_Pagination__wkR5z"}; \ No newline at end of file diff --git a/anyclip/src/modules/forms/templates/components/index.jsx b/anyclip/src/modules/forms/templates/components/index.jsx new file mode 100644 index 0000000..72316b7 --- /dev/null +++ b/anyclip/src/modules/forms/templates/components/index.jsx @@ -0,0 +1,308 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { AddRounded, ContentCopyRounded, DeleteRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; + +import { DELETE_FORM_TEMPLATES, POST_FORM_TEMPLATES } from '@/modules/@common/acl/constants'; +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; +import { SEARCH_TEXT_MAX_LENGTH, TEMPLATE_CATEGORY_ALL } from '@/modules/forms/constants'; +import { TABLE_HEADER } from '@/modules/forms/templates/constants'; + +import * as templateSelectors from '../redux/selectors'; +import { deleteTemplateAction, getCategoryOptionsAction, getTemplatesAction, setFieldAction } from '../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserAccountIdSelector, getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { strippedHtml } from '@/modules/forms/templates/helpers'; + +import { TableCellActions } from '@/modules/@common/Table'; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Grid, + IconButton, + InputAdornment, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableScroll, + TableSortLabel, + TextField, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './Templates.module.scss'; + +function Templates() { + const router = useRouter(); + const [removeConfirm, setRemoveConfirm] = useState(null); + const dispatch = useDispatch(); + const templates = useSelector(templateSelectors.templatesSelector); + const page = useSelector(templateSelectors.pageSelector); + const pageSize = useSelector(templateSelectors.pageSizeSelector); + const totalCount = useSelector(templateSelectors.totalCountSelector); + const searchText = useSelector(templateSelectors.searchTextSelector); + const sortBy = useSelector(templateSelectors.sortBySelector); + const sortOrder = useSelector(templateSelectors.sortOrderSelector); + const formTemplateCategoryId = useSelector(templateSelectors.formTemplateCategoryIdSelector); + const formTemplateCategoryOptions = useSelector(templateSelectors.formTemplateCategoryOptionsSelector); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const userAccountId = useSelector(getUserAccountIdSelector); + + const hasAccount = !!userAccountId; + + const tableHeaders = TABLE_HEADER.filter((header) => !hasAccount || header.id !== 'account.name'); + + const handleFilter = (filterObject) => { + dispatch(setFieldAction(filterObject)); + dispatch(getTemplatesAction()); + }; + + const handleShowRemoveConfirm = (template) => setRemoveConfirm(template); + + const handleRemoveConfirm = () => { + dispatch(deleteTemplateAction(removeConfirm.id)); + setRemoveConfirm(null); + }; + + useEffect(() => { + dispatch(getTemplatesAction()); + }, []); + + useEffect(() => { + dispatch(getCategoryOptionsAction()); + }, []); + + const formTemplateCategoryValue = useMemo(() => { + const res = formTemplateCategoryOptions.find(({ value }) => value === formTemplateCategoryId); + + return !res || res?.value === TEMPLATE_CATEGORY_ALL ? TEMPLATE_CATEGORY_ALL : res; + }, [formTemplateCategoryOptions]); + + // todo: migrate to + return ( +
    + + + + Form Templates + + + + + + + + handleFilter({ searchText: target.value, page: 1 })} + placeholder="Search" + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + /> + + + + + + + + + value !== TEMPLATE_CATEGORY_ALL)} + size="small" + onChange={(e, selected$) => + handleFilter({ + formTemplateCategoryId: !selected$?.value ? TEMPLATE_CATEGORY_ALL : selected$.value, + page: 1, + }) + } + renderInput={(params) => } + /> + + + + {hasPermission(POST_FORM_TEMPLATES, userPermissions) && ( + + )} + + + + + + + + + + {tableHeaders.map((header) => ( + + {header.sortable && ( + + handleFilter({ + sortBy: header.id, + sortOrder: sortOrder === SORT_ASC ? SORT_DESC : SORT_ASC, + page: 1, + }) + } + > + {header.label} + + )} + {!header.sortable && header.label} + + ))} + + + + {templates.map((row) => ( + router.push(`/form-templates/${row.id}`)} + key={row.id} + className={styles.Row} + > + + {row.id} + + +
    {row.formTemplateCategory?.name}
    +
    + {!hasAccount && ( + +
    {row.account?.name ?? 'All accounts'}
    +
    + )} + +
    {row.name}
    +
    + +
    {strippedHtml(row.title)}
    +
    + +
    {row.updatedBy}
    +
    + +
    {dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
    +
    + + {hasPermission(POST_FORM_TEMPLATES, userPermissions) && ( + + { + e.stopPropagation(); + router.push(`/form-templates/${row.id}/duplicate`); + }} + > + + + + )} + {hasPermission(DELETE_FORM_TEMPLATES, userPermissions) && ( + + { + e.stopPropagation(); + handleShowRemoveConfirm(row); + }} + > + + + + )} + +
    + ))} +
    +
    +
    + {!!totalCount && ( + handleFilter({ page: selectedPage })} + onRowsPerPageChange={(event) => + handleFilter({ + pageSize: parseInt(event.target.value, 10), + page: 1, + }) + } + component="div" + /> + )} +
    +
    + + setRemoveConfirm(null)} maxWidth="md"> + Remove Confirm + {`Are you sure you want to remove template "${removeConfirm?.name}" ?`} + + + + + +
    + ); +} + +export default Templates; diff --git a/anyclip/src/modules/forms/templates/constants/index.js b/anyclip/src/modules/forms/templates/constants/index.js new file mode 100644 index 0000000..079c3e6 --- /dev/null +++ b/anyclip/src/modules/forms/templates/constants/index.js @@ -0,0 +1,52 @@ +export const ROWS_PER_PAGE = 15; + +export const TABLE_HEADER = [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '70', + }, + { + id: 'formTemplateCategory.name', + label: 'Category', + width: '180', + sortable: true, + }, + { + id: 'account.name', + label: 'Account', + sortable: true, + }, + { + id: 'name', + label: 'Name', + width: '390', + sortable: true, + }, + { + id: 'title', + label: 'Title', + width: '340', + sortable: true, + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: '200', + }, + { + id: 'updatedAt', + label: 'Update Date', + sortable: true, + width: '200', + }, + { + id: null, + label: '', + sortable: false, + padding: 'none', + autoWidth: true, + }, +]; diff --git a/anyclip/src/modules/forms/templates/helpers/index.js b/anyclip/src/modules/forms/templates/helpers/index.js new file mode 100644 index 0000000..65ea3d8 --- /dev/null +++ b/anyclip/src/modules/forms/templates/helpers/index.js @@ -0,0 +1,6 @@ +export const strippedHtml = (text) => { + const strippedTag = text.replace(/(<([^>]+)>)/gi, ''); + const strippedEntities = strippedTag.replace(/&#{0,1}[a-z0-9]+;/gi, ''); + + return strippedEntities; +}; diff --git a/anyclip/src/modules/forms/templates/index.js b/anyclip/src/modules/forms/templates/index.js new file mode 100644 index 0000000..efda431 --- /dev/null +++ b/anyclip/src/modules/forms/templates/index.js @@ -0,0 +1,3 @@ +import Templates from './components'; + +export default Templates; diff --git a/anyclip/src/modules/forms/templates/redux/epics/deleteTemplate.js b/anyclip/src/modules/forms/templates/redux/epics/deleteTemplate.js new file mode 100644 index 0000000..22373c7 --- /dev/null +++ b/anyclip/src/modules/forms/templates/redux/epics/deleteTemplate.js @@ -0,0 +1,67 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import * as templateSelector from '../selectors'; +import { deleteTemplateAction, getTemplatesAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation DeleteFormTemplate( + $id: Int + ) { + deleteFormTemplate( + id: $id + ) + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(deleteTemplateAction.type), + switchMap(({ payload: id }) => { + const state = state$.value; + const templates = templateSelector.templatesSelector(state); + const page = templateSelector.pageSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { id }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const actions = []; + + if (templates.length === 1) { + actions.push( + of( + setFieldAction({ + page: page === 1 ? page : page - 1, + }), + ), + ); + } + + actions.push( + of(getTemplatesAction()), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Template deleted', + }), + ), + ); + + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templates/redux/epics/getCategory.js b/anyclip/src/modules/forms/templates/redux/epics/getCategory.js new file mode 100644 index 0000000..ba45068 --- /dev/null +++ b/anyclip/src/modules/forms/templates/redux/epics/getCategory.js @@ -0,0 +1,60 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TEMPLATE_CATEGORIES_OPTIONS } from '@/modules/forms/constants'; + +import { getCategoryOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getFormTemplateCategories( + $pageSize: Int, + ) { + getFormTemplateCategories( + pageSize: $pageSize, + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { getFormTemplateCategories } }) => + getFormTemplateCategories.records.map((cat) => ({ + value: cat.id, + label: cat.name, + })); + +export default (action$) => + action$.pipe( + ofType(getCategoryOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: { + pageSize: 30, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setFieldAction({ + formTemplateCategoryOptions: [].concat(TEMPLATE_CATEGORIES_OPTIONS, getResponse(response)), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templates/redux/epics/getTemplates.js b/anyclip/src/modules/forms/templates/redux/epics/getTemplates.js new file mode 100644 index 0000000..98d8098 --- /dev/null +++ b/anyclip/src/modules/forms/templates/redux/epics/getTemplates.js @@ -0,0 +1,104 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { TEMPLATE_CATEGORY_ALL } from '@/modules/forms/constants'; + +import * as templateSelectors from '../selectors'; +import { getTemplatesAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetFormTemplates( + $sortBy: [String] + $sortOrder: [String] + $page: Int + $pageSize: Int + $searchText: String + $searchIn: [String] + $formTemplateCategoryIds: [Int] + ) { + getFormTemplates( + sortBy: $sortBy + sortOrder: $sortOrder + page: $page + pageSize: $pageSize + searchText: $searchText + searchIn: $searchIn + formTemplateCategoryIds: $formTemplateCategoryIds + ) { + records { + id + formTemplateCategory { + id + name + } + account { + id + name + } + name + title + updatedAt + updatedBy + } + recordsTotal + } + } +`; + +const getResponse = ({ data: { getFormTemplates } }) => getFormTemplates; + +export default (action$, state$) => + action$.pipe( + ofType(getTemplatesAction.type), + debounce(() => { + const searchText = templateSelectors.searchTextSelector(state$.value); + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap(() => { + const state = state$.value; + const page = templateSelectors.pageSelector(state); + const pageSize = templateSelectors.pageSizeSelector(state); + const searchIn = templateSelectors.searchInSelector(state); + const searchText = templateSelectors.searchTextSelector(state); + const sortBy = templateSelectors.sortBySelector(state); + const sortOrder = templateSelectors.sortOrderSelector(state); + const formTemplateCategoryId = templateSelectors.formTemplateCategoryIdSelector(state); + + const variables = { + sortBy: [sortBy], + sortOrder: [sortOrder], + page, + pageSize, + searchIn, + searchText, + }; + + if (formTemplateCategoryId !== TEMPLATE_CATEGORY_ALL) { + variables.formTemplateCategoryIds = [formTemplateCategoryId]; + } + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const data = getResponse(response); + + return of( + setFieldAction({ + templates: data.records, + totalCount: data.recordsTotal, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/forms/templates/redux/epics/index.js b/anyclip/src/modules/forms/templates/redux/epics/index.js new file mode 100644 index 0000000..3a1b4d7 --- /dev/null +++ b/anyclip/src/modules/forms/templates/redux/epics/index.js @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import deleteTemplate from './deleteTemplate'; +import getCategory from './getCategory'; +import getTemplates from './getTemplates'; + +export default combineEpics(getTemplates, getCategory, deleteTemplate); diff --git a/anyclip/src/modules/forms/templates/redux/selectors/index.js b/anyclip/src/modules/forms/templates/redux/selectors/index.js new file mode 100644 index 0000000..8ded3f8 --- /dev/null +++ b/anyclip/src/modules/forms/templates/redux/selectors/index.js @@ -0,0 +1,14 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const templatesSelector = (state) => state[nameSpace].templates; +export const totalCountSelector = (state) => state[nameSpace].totalCount; +export const pageSelector = (state) => state[nameSpace].page; +export const pageSizeSelector = (state) => state[nameSpace].pageSize; +export const searchTextSelector = (state) => state[nameSpace].searchText; +export const searchInSelector = (state) => state[nameSpace].searchIn; +export const sortBySelector = (state) => state[nameSpace].sortBy; +export const sortOrderSelector = (state) => state[nameSpace].sortOrder; +export const formTemplateCategoryIdSelector = (state) => state[nameSpace].formTemplateCategoryId; +export const formTemplateCategoryOptionsSelector = (state) => state[nameSpace].formTemplateCategoryOptions; diff --git a/anyclip/src/modules/forms/templates/redux/slices/index.js b/anyclip/src/modules/forms/templates/redux/slices/index.js new file mode 100644 index 0000000..a4bbc78 --- /dev/null +++ b/anyclip/src/modules/forms/templates/redux/slices/index.js @@ -0,0 +1,37 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { SORT_DESC } from '@/modules/@common/constants/sort'; +import { TEMPLATE_CATEGORY_ALL } from '@/modules/forms/constants'; +import { ROWS_PER_PAGE } from '@/modules/forms/templates/constants'; + +const initialState = { + templates: [], + totalCount: 0, + page: 1, + pageSize: ROWS_PER_PAGE, + searchText: '', + searchIn: ['id', 'name', 'title'], + sortBy: 'updatedAt', + sortOrder: SORT_DESC, + formTemplateCategoryId: TEMPLATE_CATEGORY_ALL, + formTemplateCategoryOptions: [], +}; + +export const slice = createSlice({ + name: '@@FORMS_TEMPLATES/LIST', + initialState, + + reducers: { + getTemplatesAction: (state) => state, + deleteTemplateAction: (state) => state, + getCategoryOptionsAction: (state) => state, + setFieldAction: (state, action) => ({ + ...state, + ...action.payload, + }), + }, +}); + +export const { deleteTemplateAction, getCategoryOptionsAction, getTemplatesAction, setFieldAction } = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/hostedWatch/Watches/redux/epics/getHubs.js b/anyclip/src/modules/hostedWatch/Watches/redux/epics/getHubs.js new file mode 100644 index 0000000..24dff40 --- /dev/null +++ b/anyclip/src/modules/hostedWatch/Watches/redux/epics/getHubs.js @@ -0,0 +1,41 @@ +import { ofType } from 'redux-observable'; +import { of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { getHubsAction, setHubsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` +query hostedWatchHubs ($searchText: String, $watchId: Int) { + hostedWatchHubs (searchText: $searchText, watchId: $watchId) { + id + name + } +}`; + +export default (action$) => + action$.pipe( + ofType(getHubsAction.type), + debounce(() => timer(500)), + filter(() => !!getToken()), + switchMap(({ payload: { searchText, watchId } }) => + gqlRequest({ + query: queryGQL, + variables: { + searchText, + watchId, + }, + }).pipe( + switchMap(({ data, errors }) => { + let hubs = []; + + if (!errors.length) { + hubs = data.hostedWatchHubs.map(({ id, name }) => ({ id, title: name })); + } + + return of(setHubsAction(hubs)); + }), + ), + ), + ); diff --git a/anyclip/src/modules/hostedWatch/Watches/redux/epics/getWatches.js b/anyclip/src/modules/hostedWatch/Watches/redux/epics/getWatches.js new file mode 100644 index 0000000..57153f0 --- /dev/null +++ b/anyclip/src/modules/hostedWatch/Watches/redux/epics/getWatches.js @@ -0,0 +1,95 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { selectedHubSelector, selectedWatchIdSelector, watchesPageSelector, watchesSelector } from '../selectors'; +import { getWatchesAction, setSelectedWatchAction, setWatchesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` +query hostedWatches( + $publisherId: Int, + $searchText: String, + $page: Int, +) { + hostedWatches( + publisherId: $publisherId, + searchText: $searchText, + page: $page, + ) { + recordsTotal + page + pageSize + records { + id + title + folderName + watchChannels { + id + title + } + } + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(getWatchesAction.type), + debounce((action) => timer(action.payload?.searchText ? 500 : 0)), + filter(() => !!getToken()), + switchMap(({ payload: { searchText = '', isNext = false } = {} }) => { + const selectedWatchId = selectedWatchIdSelector(state$.value); + + return gqlRequest({ + query: queryGQL, + variables: { + publisherId: selectedHubSelector(state$.value).id, + page: isNext ? watchesPageSelector(state$.value) + 1 : 1, + searchText, + }, + }).pipe( + switchMap(({ data, errors }) => { + let watches = {}; + + const actions = []; + + if (!errors.length) { + watches = data.hostedWatches; + } + + const watchRecords = isNext + ? { + ...watches, + records: [...watchesSelector(state$.value), ...watches.records], + } + : watches; + + actions.push(of(setWatchesAction(watchRecords))); + + if (!watchRecords.records.some((watch) => watch.id === selectedWatchId)) { + const [firstWatch] = watchRecords.records; + + Router.push(`/watch${firstWatch ? `/${firstWatch.id}` : ''}`); + + const watchData = firstWatch + ? { + id: firstWatch.id, + title: firstWatch.title, + folderName: firstWatch.folderName, + } + : { + id: null, + title: null, + folderName: null, + }; + + actions.push(of(setSelectedWatchAction(watchData))); + } + + return concat(...actions); + }), + ); + }), + ); diff --git a/anyclip/src/modules/hostedWatch/Watches/redux/epics/index.js b/anyclip/src/modules/hostedWatch/Watches/redux/epics/index.js new file mode 100644 index 0000000..81d5fec --- /dev/null +++ b/anyclip/src/modules/hostedWatch/Watches/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getHubs from './getHubs'; +import getWatches from './getWatches'; + +export default combineEpics(getHubs, getWatches); diff --git a/anyclip/src/modules/hostedWatch/Watches/redux/selectors/index.js b/anyclip/src/modules/hostedWatch/Watches/redux/selectors/index.js new file mode 100644 index 0000000..e668f98 --- /dev/null +++ b/anyclip/src/modules/hostedWatch/Watches/redux/selectors/index.js @@ -0,0 +1,33 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +// ui +export const loadingSelector = (state) => state[nameSpace].loading; +export const menuIsExpandedSelector = (state) => state[nameSpace].menuIsExpanded; +// account +export const accountLogoUrlSelector = (state) => state[nameSpace].accountLogoUrl; +export const accountUrlSelector = (state) => state[nameSpace].accountUrl; +// hub +export const hubsSelector = (state) => state[nameSpace].hubs; +export const selectedHubSelector = (state) => state[nameSpace].selectedHub; +export const hubsLoadingSelector = (state) => state[nameSpace].hubsLoading; +// watch +export const watchesSelector = (state) => state[nameSpace].watches; +export const watchesLoadingSelector = (state) => state[nameSpace].watchesLoading; +export const watchesPageSelector = (state) => state[nameSpace].watchesPage; +export const watchesPageSizeSelector = (state) => state[nameSpace].watchesPageSize; +export const watchesRecordsTotalSelector = (state) => state[nameSpace].watchesRecordsTotal; +// myWatch +export const myWatchIdSelector = (state) => state[nameSpace].myWatchId; +export const myWatchTitleSelector = (state) => state[nameSpace].myWatchTitle; +export const myWatchFolderNameSelector = (state) => state[nameSpace].myWatchFolderName; +export const myWatchChannelsSelector = (state) => state[nameSpace].myWatchChannels; +// externalShare +export const externalShareIdSelector = (state) => state[nameSpace].externalShareId; +export const externalShareTitleSelector = (state) => state[nameSpace].externalShareTitle; +export const externalShareFolderNameSelector = (state) => state[nameSpace].externalShareFolderName; +// selectedWatch +export const selectedWatchIdSelector = (state) => state[nameSpace].selectedWatchId; +export const selectedWatchTitleSelector = (state) => state[nameSpace].selectedWatchTitle; +export const selectedWatchFolderNameSelector = (state) => state[nameSpace].selectedWatchFolderName; diff --git a/anyclip/src/modules/hostedWatch/Watches/redux/slices/index.js b/anyclip/src/modules/hostedWatch/Watches/redux/slices/index.js new file mode 100644 index 0000000..164cefc --- /dev/null +++ b/anyclip/src/modules/hostedWatch/Watches/redux/slices/index.js @@ -0,0 +1,116 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + // ui + loading: true, + menuIsExpanded: true, + // account + accountLogoUrl: null, + accountUrl: null, + // hub + hubs: [], + selectedHub: {}, + hubsLoading: false, + // watch + watches: [], + watchesLoading: false, + watchesPage: null, + watchesPageSize: null, + watchesRecordsTotal: null, + // myWatch + myWatchId: null, + myWatchTitle: null, + myWatchFolderName: null, + myWatchChannels: [], + // externalShare + externalShareId: null, + externalShareTitle: null, + externalShareFolderName: null, + // selectedWatch + selectedWatchId: null, + selectedWatchTitle: null, + selectedWatchFolderName: null, +}; + +export const slice = createSlice({ + name: '@@HOSTED/WATCHES', + initialState, + reducers: { + setInitialDataAction: (state, action) => { + // account + state.accountLogoUrl = action.payload.accountLogoUrl; + state.accountUrl = action.payload.accountUrl; + // hub + state.hubs = action.payload.hubs; + state.selectedHub = action.payload.selectedHub; + // watches + state.watches = action.payload.watches.records; + state.watchesPage = action.payload.watches.page; + state.watchesPageSize = action.payload.watches.pageSize; + state.watchesRecordsTotal = action.payload.watches.recordsTotal; + // myWatch + state.myWatchId = action.payload.myWatch.id; + state.myWatchTitle = action.payload.myWatch.title; + state.myWatchFolderName = action.payload.myWatch.folderName; + state.myWatchChannels = action.payload.myWatch.watchChannels; + // shared + state.externalShareId = action.payload.shared.id; + state.externalShareTitle = action.payload.shared.title; + state.externalShareFolderName = action.payload.shared.folderName; + // selectedWatch + state.selectedWatchId = action.payload.selectedWatch.id; + state.selectedWatchTitle = action.payload.selectedWatch.title; + state.selectedWatchFolderName = action.payload.selectedWatch.folderName; + + state.loading = false; + }, + + toggleMenuAction: (state, action) => { + state.menuIsExpanded = action.payload; + }, + + getHubsAction: (state) => { + state.hubsLoading = true; + }, + setHubsAction: (state, action) => { + state.hubs = action.payload; + state.hubsLoading = false; + }, + setHubAction: (state, action) => { + state.selectedHub = action.payload; + }, + + getWatchesAction: (state) => { + state.watchesLoading = true; + }, + setWatchesAction: (state, action) => { + state.watches = action.payload.records; + state.watchesPage = action.payload.page; + state.watchesPageSize = action.payload.pageSize; + state.watchesRecordsTotal = action.payload.recordsTotal; + state.watchesLoading = false; + }, + + setSelectedWatchAction: (state, action) => { + state.selectedWatchId = action.payload.id; + state.selectedWatchTitle = action.payload.title; + state.selectedWatchFolderName = action.payload.folderName; + }, + + clearAllAction: () => initialState, + }, +}); + +export const { + setInitialDataAction, + toggleMenuAction, + getHubsAction, + setHubsAction, + setHubAction, + getWatchesAction, + setWatchesAction, + setSelectedWatchAction, + clearAllAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/hubs/Editor/components/Editor.jsx b/anyclip/src/modules/hubs/Editor/components/Editor.jsx new file mode 100644 index 0000000..9ba5d2b --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Editor.jsx @@ -0,0 +1,202 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { + CLEAN_RULE_TYPE_CUSTOM, + CLEAN_RULE_TYPE_REGEXP, + TAB_ADVANCED, + TAB_GENERAL, + TAB_MARKETPLACE_SELF_SERVICE, + TAB_PLAYER_SELF_SERVICE, + TAB_SYNDICATED_CONTENT, +} from '../constants'; + +import { getCanReadOnly, getHasAccount } from '../helpers/getRestrictions'; +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; +import { + getUserAccountIdSelector, + getUserAccountSelector, + getUserPermissionsSelector, + getUserRoleTypeSelector, +} from '@/modules/@common/user/redux/selectors'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import MarketplaceSelfServiceTab from './Tabs/MarketplaceSelfServiceTab/MarketplaceSelfServiceTab'; +import PlayerSelfServiceTab from './Tabs/PlayerSelfServiceTab/PlayerSelfServiceTab'; +import SyndicatedContentTab from './Tabs/SyndicatedContentTab/SyndicatedContentTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const name = useSelector(selectors.nameSelector); + const activeTabId = useSelector(selectors.activeTabIdSelector); + + const userAccount = useSelector(getUserAccountSelector); + const userAccountId = useSelector(getUserAccountIdSelector); + const userRoleType = useSelector(getUserRoleTypeSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + + const id = parseInt(router.query.id, 10); + + const hasAccount = getHasAccount(userRoleType); + const canReadOnly = getCanReadOnly(id, userPermissions); + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id })); + } + }, [id]); + + useEffect(() => { + if (hasAccount) { + dispatch( + setAction({ + account: { + value: userAccountId, + label: userAccount?.name, + }, + }), + ); + } + }, [hasAccount]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + !hasAccount && { + title: 'Syndicated Content', + id: TAB_SYNDICATED_CONTENT, + content: SyndicatedContentTab, + }, + !hasAccount && { + title: 'Player Self-Service', + id: TAB_PLAYER_SELF_SERVICE, + content: PlayerSelfServiceTab, + }, + !hasAccount && { + title: 'Marketplace Self-Service', + id: TAB_MARKETPLACE_SELF_SERVICE, + content: MarketplaceSelfServiceTab, + }, + !hasAccount && { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .filter(({ fieldName }) => { + if (fieldName === 'demandAccount') { + return allProps.mpSelfService; + } + + if (fieldName === 'defaultRule.cleanUrlRuleValue') { + return [CLEAN_RULE_TYPE_CUSTOM, CLEAN_RULE_TYPE_REGEXP].includes(allProps.defaultRule.cleanUrlRuleType); + } + + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( +
    + + + {id ? `${name} > Settings` : 'New Hub'} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + + {!canReadOnly && ( + + )} + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/hubs/Editor/components/Editor.module.scss b/anyclip/src/modules/hubs/Editor/components/Editor.module.scss new file mode 100644 index 0000000..b3a9baf --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__vzE76","Title":"Editor_Title__p3l2T","Controls":"Editor_Controls__O9C_8","Tabs":"Editor_Tabs__YEaiR"}; \ No newline at end of file diff --git a/anyclip/src/modules/hubs/Editor/components/ItemList/ItemList.jsx b/anyclip/src/modules/hubs/Editor/components/ItemList/ItemList.jsx new file mode 100644 index 0000000..9b51ba3 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/ItemList/ItemList.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Delete } from '@mui/icons-material'; + +import { + IconButton, + Stack, + Table, + TableCell, + TableContainer, + TableRow, + TableScroll, + Typography, +} from '@/mui/components'; + +function ItemList(props) { + if (!props.items.length) { + return null; + } + + return ( + + + + {props.items.map((item, index) => ( + + + + + + {item.label} + + + {!(props.disabled || item?.isDisabled) && ( +
    + props.onDelete(index)}> + + +
    + )} +
    +
    +
    + ))} +
    +
    +
    + ); +} + +ItemList.propTypes = { + items: PropTypes.arrayOf({}).isRequired, + disabled: PropTypes.bool.isRequired, + onDelete: PropTypes.func.isRequired, +}; + +export default ItemList; diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx new file mode 100644 index 0000000..77e07db --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx @@ -0,0 +1,306 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + CLEAN_RULE_OPTIONS, + CLEAN_RULE_TYPE_ALL, + CLEAN_RULE_TYPE_CUSTOM, + CLEAN_RULE_TYPE_REGEXP, +} from '../../../constants'; + +import * as selectors from '../../../redux/selectors'; +import { removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormGroupTitle, FormRow, useFormSettings } from '@/modules/@common/Form'; +import RuleList from './components/RuleList'; +import { Button, MenuItem, Select, Switch, TextField } from '@/mui/components'; + +const ruleValuePlaceholderMapper = { + [CLEAN_RULE_TYPE_CUSTOM]: 'Enter comma-separated list of params to keep for all domains', + [CLEAN_RULE_TYPE_REGEXP]: 'Enter REGEX formula for all domains', +}; + +const customRuleDefault = { + cleanUrl: '', + cleanUrlRuleType: CLEAN_RULE_TYPE_ALL, + cleanUrlRuleValue: '', +}; + +const infoUrl = 'https://anyclip.atlassian.net/wiki/spaces/RD/pages/243499011/Augmentor+DMPs+-+support+unique+URLs'; + +function validateCustomRuleDomain(customRuleDomain, rules) { + let error = ''; + + // this regExp was taken from PCN for this module + + const regExp = + // eslint-disable-next-line max-len + /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i; + + if (!regExp.test(customRuleDomain)) { + error = 'Specify valid url'; + } + + const isNotUniq = rules.some((rule) => { + // remove http, https + const removeProtocol = (u) => u.replace(/(^\w+:|^)\/\//, ''); + return removeProtocol(rule.cleanUrl) === removeProtocol(customRuleDomain); + }); + + if (isNotUniq) { + error = 'Url must be unique'; + } + + return { + error, + }; +} + +function transformRefreshAttributesToInt(value = '') { + if (!value) { + return ''; + } + + const MAX_VALUE = 999; + const newValue = parseInt(value, 10); + + if (newValue > MAX_VALUE) { + return MAX_VALUE; + } + + return newValue; +} + +function AdvancedTab() { + const { size } = useFormSettings(); + const [customRule, setCustomRule] = useState(customRuleDefault); + const [customRuleValidationDomain, setCustomRuleValidationDomain] = useState({ error: '' }); + + const dispatch = useDispatch(); + + const pageRefresh = useSelector(selectors.pageRefreshSelector); + const refreshIntervalMinutes = useSelector(selectors.refreshIntervalMinutesSelector); + const refreshTimesLimit = useSelector(selectors.refreshTimesLimitSelector); + const ddSpecificPublisher = useSelector(selectors.ddSpecificPublisherSelector); + const defaultRule = useSelector(selectors.defaultRuleSelector); + const cleanUrl = useSelector(selectors.cleanUrlSelector); + const scheme = useSelector(selectors.schemeSelector); + + const handleSetState = (state) => dispatch(setAction(state)); + + const handleSetCustomRule = (state = {}) => setCustomRule((rule) => ({ ...rule, ...state })); + + const handleAddCustomRule = () => { + const { error } = validateCustomRuleDomain(customRule.cleanUrl, cleanUrl); + + setCustomRuleValidationDomain({ error }); + + if (!error) { + const cleanUrlRule = { + cleanOrder: cleanUrl.length + 1, + cleanUrl: customRule.cleanUrl, + cleanUrlRuleType: customRule.cleanUrlRuleType, + cleanUrlRuleValue: customRule.cleanUrlRuleValue, + }; + + handleSetState({ cleanUrl: [].concat(cleanUrlRule, cleanUrl) }); + + setCustomRule(customRuleDefault); + } + }; + + return ( + <> + + {'The interval for AnyClip Page Analyzer to be called to refresh the page content that can be\n'} + {'needed for news sites, which content changes much more often than the default refresh period.\n'} +
    + } + > + handleSetState({ pageRefresh: e.target.checked })} + /> + + + + handleSetState({ + refreshIntervalMinutes: transformRefreshAttributesToInt(e.target.value), + }) + } + /> + + + + handleSetState({ + refreshTimesLimit: transformRefreshAttributesToInt(e.target.value), + }) + } + /> + + + handleSetState({ ddSpecificPublisher: e.target.checked })} + /> + + Cache URL Rules + +
    Traffic Manager uses URLs as a part of the key in the cache.
    +
    + Cache URL Rules are used by Traffic Manager to clean the URL from unnecessary parts (i.e. such as ‘user’ + or ‘id’ query parameters). +
    +
    + Default Rule is applied to all URLs except those, which are specified for the domains in Custom Rules. +
    + + + } + > + + {[CLEAN_RULE_TYPE_CUSTOM, CLEAN_RULE_TYPE_REGEXP].includes(defaultRule.cleanUrlRuleType) && ( + + handleSetState({ + defaultRule: { + ...defaultRule, + cleanUrlRuleValue: e.target.value, + }, + }) + } + {...getInputPropsByName(scheme, ['defaultRule.cleanUrlRuleValue'])} + onFocus={() => dispatch(removeErrorByPropAction(['defaultRule.cleanUrlRuleValue']))} + /> + )} +
    + + handleSetCustomRule({ cleanUrl: e.target.value })} + /> + + + + {[CLEAN_RULE_TYPE_CUSTOM, CLEAN_RULE_TYPE_REGEXP].includes(customRule.cleanUrlRuleType) && ( + + handleSetCustomRule({ + cleanUrlRuleValue: e.target.value, + }) + } + /> + )} + + + + + handleSetState({ + cleanUrl: cleanUrlRules, + }) + } + onDelete={(orderIndex) => + handleSetState({ + cleanUrl: cleanUrl.filter((o) => o.cleanOrder !== parseInt(orderIndex, 10)), + }) + } + /> + + + ); +} + +export default AdvancedTab; diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/RuleList/index.jsx b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/RuleList/index.jsx new file mode 100644 index 0000000..a8c4827 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/RuleList/index.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; + +import { + CLEAN_RULE_TYPE_ALL, + CLEAN_RULE_TYPE_CUSTOM, + CLEAN_RULE_TYPE_NONE, + CLEAN_RULE_TYPE_REGEXP, +} from '../../../../../constants'; + +import SortableRuleItem from '../SortableRuleItem'; +import { Table, TableContainer } from '@/mui/components'; + +function getLabel(o) { + switch (o.cleanUrlRuleType) { + case CLEAN_RULE_TYPE_ALL: + return `${o.cleanUrl} - [All]`; + case CLEAN_RULE_TYPE_NONE: + return `${o.cleanUrl} - [None]`; + case CLEAN_RULE_TYPE_CUSTOM: + case CLEAN_RULE_TYPE_REGEXP: + return `${o.cleanUrl} - [${o.cleanUrlRuleValue}]`; + default: + return o.cleanUrl; + } +} + +function RuleList(props) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event) => { + const { active, over } = event; + + if (active.id !== over?.id) { + const oldIndex = props.items.findIndex((o) => o.cleanOrder === parseInt(active.id, 10)); + const newIndex = props.items.findIndex((o) => o.cleanOrder === parseInt(over?.id, 10)); + const sortedValue = arrayMove(props.items, oldIndex, newIndex); + + props.onChangeSort(sortedValue); + } + }; + + return ( + + `${o.cleanOrder}`)} strategy={rectSortingStrategy}> + + + {props.items.map((o) => ( + + ))} +
    +
    +
    +
    + ); +} + +RuleList.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + clearOrder: PropTypes.number.isRequired, + cleanUrl: PropTypes.string.isRequired, + cleanUrlRuleType: PropTypes.string.isRequired, + cleanUrlRuleValue: PropTypes.string.isRequired, + }), + ).isRequired, + onChangeSort: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, +}; + +export default RuleList; diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/SortableRuleItem.module.scss b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/SortableRuleItem.module.scss new file mode 100644 index 0000000..8134db4 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/SortableRuleItem.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"DragControl":"SortableRuleItem_DragControl__M4AKQ"}; \ No newline at end of file diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/index.jsx b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/index.jsx new file mode 100644 index 0000000..093bfad --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/AdvancedTab/components/SortableRuleItem/index.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Delete, DragIndicator } from '@mui/icons-material'; + +import { IconButton, Stack, TableCell, TableRow, Typography } from '@/mui/components'; + +import styles from './SortableRuleItem.module.scss'; + +function SortableRuleItem(props) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: props.value }); + + const style = { + transform: CSS.Translate.toString(transform), + transition: transition || undefined, + }; + + return ( + + + + + + + + + {props.label} + + + + props.onDelete(props.value)}> + + + + + + + ); +} + +SortableRuleItem.propTypes = { + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + onDelete: PropTypes.func.isRequired, + size: PropTypes.oneOf(['xSmall', 'small', 'medium', 'large']), +}; + +export default SortableRuleItem; diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/hubs/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..74fb01a --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,190 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Close } from '@mui/icons-material'; + +import { REDUX_ERROR_PROP_NAME } from '@/modules/@common/Form/constants'; + +import { getDefaultDomain } from '../../../helpers/getDefaultDomain'; +import { getCanReadOnly, getHasAccount, shouldDisablePublish } from '../../../helpers/getRestrictions'; +import * as selectors from '../../../redux/selectors'; +import { + getAccountOptionsAction, + removeErrorByPropAction, + setAction, + setErrorByPropAction, + setInitialAction, + validateSingleField, +} from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { getUserPermissionsSelector, getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import ItemList from '../../ItemList/ItemList'; +import { Autocomplete, Button, IconButton, InputAdornment, Switch, TextField } from '@/mui/components'; + +const NAME_MAX_LENGTH = 45; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + // selectors + const account = useSelector(selectors.accountSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + const name = useSelector(selectors.nameSelector); + const domains = useSelector(selectors.domainsSelector); + const publish = useSelector(selectors.publishSelector); + const scheme = useSelector(selectors.schemeSelector); + const userRoleType = useSelector(getUserRoleTypeSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + + // local state + const [domain, setDomain] = useState(''); + + const id = parseInt(router.query.id, 10); + const hasAccount = getHasAccount(userRoleType); + const canReadOnly = getCanReadOnly(id, userPermissions); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + const handleAddDomain = () => { + const regExp = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/; + + const newDomainsList = [].concat(domains, { + label: domain, + isDisabled: false, + }); + const data = validateSingleField('domains', newDomainsList); + + if (!data[REDUX_ERROR_PROP_NAME] && !regExp.test(domain)) { + data[REDUX_ERROR_PROP_NAME] = 'Invalid domain name'; + } + + if (!data[REDUX_ERROR_PROP_NAME]) { + handleSetState({ + domains: newDomainsList, + }); + setDomain(''); + } + + dispatch(setErrorByPropAction([data])); + }; + + const handleDeleteDomain = (indexForDelete) => { + handleSetState({ + domains: domains.filter((_, index) => index !== indexForDelete), + }); + }; + + useEffect(() => { + dispatch(setInitialAction()); + if (!id) { + const defaultDomain = getDefaultDomain(); + handleSetState({ + domains: defaultDomain ? [{ label: defaultDomain, isDisabled: true }] : [], + }); + } + }, []); + + return ( + <> + {!hasAccount && ( + + ( + dispatch(getAccountOptionsAction({ searchText: e.target.value }))} + {...getInputPropsByName(scheme, ['account'])} + onFocus={() => dispatch(removeErrorByPropAction(['account']))} + /> + )} + onOpen={() => dispatch(getAccountOptionsAction())} + onChange={(e, account$) => handleSetState({ account: account$ })} + /> + + )} + + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + + + setDomain('')}> + + + + ) : null, + }} + onChange={({ target: { value } }) => setDomain(value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleAddDomain(); + } + }} + {...getInputPropsByName(scheme, ['domains'])} + onFocus={() => dispatch(removeErrorByPropAction(['domains']))} + /> + + + + + + + handleSetState({ publish: e.target.checked })} + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/MarketplaceSelfServiceTab/MarketplaceSelfServiceTab.jsx b/anyclip/src/modules/hubs/Editor/components/Tabs/MarketplaceSelfServiceTab/MarketplaceSelfServiceTab.jsx new file mode 100644 index 0000000..3e187af --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/MarketplaceSelfServiceTab/MarketplaceSelfServiceTab.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import * as selectors from '../../../redux/selectors'; +import { getDemandAccountsOptionsAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Switch, TextField } from '@/mui/components'; + +function MarketplaceSelfServiceTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const scheme = useSelector(selectors.schemeSelector); + const mpSelfService = useSelector(selectors.mpSelfServiceSelector); + const demandAccount = useSelector(selectors.demandAccountSelector); + const demandAccountsOptions = useSelector(selectors.demandAccountsOptionsSelector); + + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + { + handleSetState({ + mpSelfService: target.checked, + ...(!target.checked && { + demandAccount: null, + demandAccountId: null, + }), + }); + }} + /> + + {mpSelfService && ( + + + dispatch( + getDemandAccountsOptionsAction({ + searchText: '', + }), + ) + } + onChange={(_, selected) => + handleSetState({ + demandAccount: selected, + demandAccountId: selected?.id ?? null, + }) + } + onInputChange={(_, searchText) => { + dispatch( + getDemandAccountsOptionsAction({ + searchText, + }), + ); + }} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['demandAccount']))} + /> + )} + /> + + )} + + ); +} + +export default MarketplaceSelfServiceTab; diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/PlayerSelfServiceTab/PlayerSelfServiceTab.jsx b/anyclip/src/modules/hubs/Editor/components/Tabs/PlayerSelfServiceTab/PlayerSelfServiceTab.jsx new file mode 100644 index 0000000..ac0ce2e --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/PlayerSelfServiceTab/PlayerSelfServiceTab.jsx @@ -0,0 +1,309 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + TYPE_INTELLIGENT, + TYPE_INTELLIGENT_AMP, + TYPE_OUTSTREAM, + TYPE_STORIES, + TYPE_STORIES_AMP, + TYPE_VERTICAL, +} from '@/modules/@common/constants/playerTypes'; + +import * as selectors from '../../../redux/selectors'; +import { getTemplatePlayerOptionsAction, setAction } from '../../../redux/slices'; + +import { FormGroupTitle, FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Switch, TextField, Typography } from '@/mui/components'; + +function PlayerSelfServiceTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + // selectors + const selfService = useSelector(selectors.selfServiceSelector); + const templatePlayerLumX = useSelector(selectors.templatePlayerLumXSelector); + const templatePlayerLumXAmp = useSelector(selectors.templatePlayerLumXAmpSelector); + const templatePlayerLumN = useSelector(selectors.templatePlayerLumNSelector); + const templatePlayerLumNAmp = useSelector(selectors.templatePlayerLumNAmpSelector); + const templatePlayerVertical = useSelector(selectors.templatePlayerVerticalSelector); + const templatePlayerOutstream = useSelector(selectors.templatePlayerOutstreamSelector); + const templatePlayerOptions = useSelector(selectors.templatePlayerOptionsSelector); + const monetization = useSelector(selectors.monetizationSelector); + const firstLook = useSelector(selectors.firstLookSelector); + const cpm = useSelector(selectors.cpmSelector); + const account = useSelector(selectors.accountSelector); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + handleSetState({ selfService: e.target.checked })} + /> + + {selfService && ( + <> + Player Templates + + + Choose the players that will be the baseline for the hub’s created players. +
    + Hub’s players will be based on the player’s settings. +
    +
    + + ( + + dispatch( + getTemplatePlayerOptionsAction({ + searchText: e.target.value, + type: TYPE_INTELLIGENT, + accountId: account.value, + }), + ) + } + /> + )} + onOpen={() => + dispatch(getTemplatePlayerOptionsAction({ type: TYPE_INTELLIGENT, accountId: account.value })) + } + onChange={(e, templatePlayerLumX$) => handleSetState({ templatePlayerLumX: templatePlayerLumX$ })} + /> + + + ( + + dispatch( + getTemplatePlayerOptionsAction({ + searchText: e.target.value, + type: TYPE_INTELLIGENT_AMP, + accountId: account.value, + }), + ) + } + /> + )} + onOpen={() => + dispatch(getTemplatePlayerOptionsAction({ type: TYPE_INTELLIGENT_AMP, accountId: account.value })) + } + onChange={(e, templatePlayerLumXAmp$) => + handleSetState({ + templatePlayerLumXAmp: templatePlayerLumXAmp$, + }) + } + /> + + + ( + + dispatch( + getTemplatePlayerOptionsAction({ + searchText: e.target.value, + type: TYPE_STORIES, + accountId: account.value, + }), + ) + } + /> + )} + onOpen={() => dispatch(getTemplatePlayerOptionsAction({ type: TYPE_STORIES, accountId: account.value }))} + onChange={(e, templatePlayerLumN$) => + handleSetState({ + templatePlayerLumN: templatePlayerLumN$, + }) + } + /> + + + ( + + dispatch( + getTemplatePlayerOptionsAction({ + searchText: e.target.value, + type: TYPE_STORIES_AMP, + accountId: account.value, + }), + ) + } + /> + )} + onOpen={() => + dispatch(getTemplatePlayerOptionsAction({ type: TYPE_STORIES_AMP, accountId: account.value })) + } + onChange={(e, templatePlayerLumNAmp$) => + handleSetState({ + templatePlayerLumNAmp: templatePlayerLumNAmp$, + }) + } + /> + + + ( + + dispatch( + getTemplatePlayerOptionsAction({ + searchText: e.target.value, + type: TYPE_VERTICAL, + accountId: account.value, + }), + ) + } + /> + )} + onOpen={() => dispatch(getTemplatePlayerOptionsAction({ type: TYPE_VERTICAL, accountId: account.value }))} + onChange={(e, templatePlayerVertical$) => + handleSetState({ + templatePlayerVertical: templatePlayerVertical$, + }) + } + /> + + + ( + + dispatch( + getTemplatePlayerOptionsAction({ + searchText: e.target.value, + type: TYPE_OUTSTREAM, + accountId: account.value, + }), + ) + } + /> + )} + onOpen={() => + dispatch(getTemplatePlayerOptionsAction({ type: TYPE_OUTSTREAM, accountId: account.value })) + } + onChange={(e, templatePlayerOutstream$) => + handleSetState({ + templatePlayerOutstream: templatePlayerOutstream$, + }) + } + /> + + + handleSetState({ monetization: e.target.checked })} + /> + + + handleSetState({ firstLook: e.target.checked })} + /> + + + { + handleSetState({ cpm: e.target.value }); + }} + /> + + + )} + + ); +} + +export default PlayerSelfServiceTab; diff --git a/anyclip/src/modules/hubs/Editor/components/Tabs/SyndicatedContentTab/SyndicatedContentTab.jsx b/anyclip/src/modules/hubs/Editor/components/Tabs/SyndicatedContentTab/SyndicatedContentTab.jsx new file mode 100644 index 0000000..85ccd89 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/components/Tabs/SyndicatedContentTab/SyndicatedContentTab.jsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import * as selectors from '../../../redux/selectors'; +import { getAccountOptionsAction, setAction } from '../../../redux/slices'; + +import { FormGroup, FormRow, useFormSettings } from '@/modules/@common/Form'; +import ItemList from '../../ItemList/ItemList'; +import { Autocomplete, Button, Switch, TextField } from '@/mui/components'; + +function SyndicatedContentTab() { + const { size } = useFormSettings(); + const [account, setAccount] = useState(null); + const dispatch = useDispatch(); + + const includePublicContent = useSelector(selectors.includePublicContentSelector); + const accounts = useSelector(selectors.accountsSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + + const handleSetState = (state) => dispatch(setAction(state)); + + const handleAddAccount = () => { + handleSetState({ accounts: [].concat(accounts, account) }); + setAccount(null); + }; + const handleDeleteAccount = (indexForDelete) => { + handleSetState({ accounts: accounts.filter((_, index) => index !== indexForDelete) }); + }; + + return ( + <> + + handleSetState({ includePublicContent: e.target.checked })} + /> + + {!includePublicContent && ( + + + { + const filteredOptions = options.filter((o) => + accounts.length ? !accounts.some((oo) => o.value === oo.value) : true, + ); + + return filteredOptions; + }} + renderInput={(params) => ( + + dispatch(getAccountOptionsAction({ searchText: e.target.value, isSyndicationOnly: true })) + } + /> + )} + onOpen={() => dispatch(getAccountOptionsAction({ isSyndicationOnly: true }))} + onChange={(e, account$) => setAccount(account$)} + /> + + + + + + + )} + + ); +} + +export default SyndicatedContentTab; diff --git a/anyclip/src/modules/hubs/Editor/constants/index.js b/anyclip/src/modules/hubs/Editor/constants/index.js new file mode 100644 index 0000000..4c42ad2 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/constants/index.js @@ -0,0 +1,21 @@ +export const CLEAN_RULE_TYPE_ALL = 'DEFAULT'; +export const CLEAN_RULE_TYPE_NONE = 'CLEAN_ALL'; +export const CLEAN_RULE_TYPE_CUSTOM = 'WHITE_LIST'; +export const CLEAN_RULE_TYPE_REGEXP = 'REGEX'; + +export const CLEAN_RULE_OPTIONS = [ + { label: 'All', value: CLEAN_RULE_TYPE_ALL }, + { label: 'None', value: CLEAN_RULE_TYPE_NONE }, + { label: 'Custom', value: CLEAN_RULE_TYPE_CUSTOM }, + { label: 'RegEx', value: CLEAN_RULE_TYPE_REGEXP }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 30; + +export const TAB_GENERAL = 'general'; +export const TAB_SYNDICATED_CONTENT = 'syndicatedContent'; +export const TAB_PLAYER_SELF_SERVICE = 'playerSelfService'; +export const TAB_MARKETPLACE_SELF_SERVICE = 'marketplaceSelfService'; +export const TAB_ADVANCED = 'Advanced'; + +export const REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/hubs/Editor/helpers/createRequestBody.js b/anyclip/src/modules/hubs/Editor/helpers/createRequestBody.js new file mode 100644 index 0000000..d8367bc --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/helpers/createRequestBody.js @@ -0,0 +1,73 @@ +import * as selectors from '../redux/selectors'; + +const createRequestBody = (state, isUpdate = false) => { + const account = selectors.accountSelector(state); + const accounts = selectors.accountsSelector(state); + const cleanUrl = selectors.cleanUrlSelector(state); + const cpm = parseFloat(selectors.cpmSelector(state)); + const ddSpecificPublisher = selectors.ddSpecificPublisherSelector(state); + const defaultRule = selectors.defaultRuleSelector(state); + const domains = selectors.domainsSelector(state); + const firstLook = selectors.firstLookSelector(state); + const includePublicContent = selectors.includePublicContentSelector(state); + const monetization = selectors.monetizationSelector(state); + const name = selectors.nameSelector(state); + const pageRefresh = selectors.pageRefreshSelector(state); + const publish = selectors.publishSelector(state); + const refreshIntervalMinutes = parseInt(selectors.refreshIntervalMinutesSelector(state), 10); + const refreshTimesLimit = parseInt(selectors.refreshTimesLimitSelector(state), 10); + const selfService = selectors.selfServiceSelector(state); + const templatePlayerLumN = selectors.templatePlayerLumNSelector(state); + const templatePlayerLumNAmp = selectors.templatePlayerLumNAmpSelector(state); + const templatePlayerLumX = selectors.templatePlayerLumXSelector(state); + const templatePlayerLumXAmp = selectors.templatePlayerLumXAmpSelector(state); + const templatePlayerVertical = selectors.templatePlayerVerticalSelector(state); + const templatePlayerOutstream = selectors.templatePlayerOutstreamSelector(state); + const mpSelfService = selectors.mpSelfServiceSelector(state); + const demandAccountId = selectors.demandAccountIdSelector(state); + + const body = { + accountId: parseInt(account.value, 10), + accounts: accounts.map((entity) => ({ + id: entity.value, + name: entity.label, + salesforceId: entity.salesforceId, + })), + cleanUrl: cleanUrl.map((o, i) => ({ ...o, cleanOrder: i + 1 })), + cpm, + ddSpecificPublisher, + defaultRule: { + cleanOrder: 0, + cleanUrl: name, + ...defaultRule, + }, + domains: domains.map((o) => o.label), + firstLook, + includePublicContent, + monetization, + name, + pageRefresh, + publish, + refreshIntervalMinutes, + refreshTimesLimit, + selfService, + mpSelfService, + demandAccountId, + templatePlayerLumN: templatePlayerLumN?.value ?? null, + templatePlayerLumNAmp: templatePlayerLumNAmp?.value ?? null, + templatePlayerLumX: templatePlayerLumX?.value ?? null, + templatePlayerLumXAmp: templatePlayerLumXAmp?.value ?? null, + templatePlayerVertical: templatePlayerVertical?.value ?? null, + templatePlayerOutstream: templatePlayerOutstream?.value ?? null, + }; + + if (isUpdate) { + body.id = selectors.idSelector(state); + + delete body.accountId; + } + + return body; +}; + +export default createRequestBody; diff --git a/anyclip/src/modules/hubs/Editor/helpers/getDefaultDomain.js b/anyclip/src/modules/hubs/Editor/helpers/getDefaultDomain.js new file mode 100644 index 0000000..7f9c262 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/helpers/getDefaultDomain.js @@ -0,0 +1,5 @@ +export function getDefaultDomain() { + return process.env.APP_ENV_BASE_URL.replace(/https?:\/\//i, ''); +} + +export default {}; diff --git a/anyclip/src/modules/hubs/Editor/helpers/getRestrictions.js b/anyclip/src/modules/hubs/Editor/helpers/getRestrictions.js new file mode 100644 index 0000000..7283939 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/helpers/getRestrictions.js @@ -0,0 +1,9 @@ +import { PCN_GET_DESTINATIONS, PCN_POST_PUBLISHER, PCN_PUT_PUBLISHER } from '@/modules/@common/acl/constants'; +import { ACCOUNT } from '@/modules/@common/user/constants/rolesType'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +export const getHasAccount = (userRoleType) => userRoleType === ACCOUNT; +export const getCanReadOnly = (id, userPermissions) => + !(id ? hasPermission(PCN_PUT_PUBLISHER, userPermissions) : hasPermission(PCN_POST_PUBLISHER, userPermissions)); +export const shouldDisablePublish = (userPermissions) => !hasPermission(PCN_GET_DESTINATIONS, userPermissions); diff --git a/anyclip/src/modules/hubs/Editor/helpers/parseResponseToState.js b/anyclip/src/modules/hubs/Editor/helpers/parseResponseToState.js new file mode 100644 index 0000000..c87d43d --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/helpers/parseResponseToState.js @@ -0,0 +1,95 @@ +import { getDefaultDomain } from './getDefaultDomain'; + +function parseResponseToState(response) { + const state = { + id: response.id, + // tab general + account: { + value: response.account.id, + label: response.account.name, + salesforceId: response.account.salesforceId, + }, + name: response.name, + domains: response.publisherDomains.map((o) => ({ + label: o.domain, + isDisabled: !!o.players.length || o.domain === getDefaultDomain(), + })), + publish: response.publish, + // syndicate content tab + includePublicContent: response.includePublicContent, + accounts: response.publisherContentOwners + // distinct + .reduce((acc, o) => { + if (!o.account) { + return acc; + } + + const isExit = acc.some((oo) => oo?.account.id === o.account.id); + if (!isExit) { + acc.push(o); + } + + return acc; + }, []) + .filter((o) => o.account && o.account.id !== response.account.id) + .map((o) => ({ + value: o.account.id, + label: o.account.name, + })), + // player self service tab + selfService: response.selfService, + templatePlayerLumX: response.templatePlayerLumX + ? { + value: response.templatePlayerLumX, + label: response.templatePlayerLumXName || 'Need return name from API', + } + : null, + templatePlayerLumXAmp: response.templatePlayerLumXAmp + ? { + value: response.templatePlayerLumXAmp, + label: response.templatePlayerLumXAmpName || 'Need return name from API', + } + : null, + templatePlayerLumN: response.templatePlayerLumN + ? { + value: response.templatePlayerLumN, + label: response.templatePlayerLumNName || 'Need return name from API', + } + : null, + templatePlayerLumNAmp: response.templatePlayerLumNAmp + ? { + value: response.templatePlayerLumNAmp, + label: response.templatePlayerLumNAmpName || 'Need return name from API', + } + : null, + templatePlayerVertical: response.templatePlayerVertical + ? { + value: response.templatePlayerVertical, + label: response.templatePlayerVerticalName || 'Need return name from API', + } + : null, + templatePlayerOutstream: response.templatePlayerOutstream + ? { + value: response.templatePlayerOutstream, + label: response.templatePlayerOutstreamName || 'Need return name from API', + } + : null, + monetization: response.monetization, + firstLook: response.firstLook, + cpm: response.cpm, + // advanced tab + pageRefresh: response.pageRefresh, + refreshIntervalMinutes: response.refreshIntervalMinutes, + refreshTimesLimit: response.refreshTimesLimit, + ddSpecificPublisher: response.ddSpecificPublisher, + defaultRule: response.defaultRule, + cleanUrl: response.cleanUrl.sort((a, b) => (a.cleanOrder > b.cleanOrder ? 1 : -1)), + mpSelfService: response.mpSelfService, + demandAccountId: response.demandAccountId, + demandAccount: response.demandAccount, + }; + + return state; +} + +export default parseResponseToState; diff --git a/anyclip/src/modules/hubs/Editor/helpers/validationScheme.js b/anyclip/src/modules/hubs/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..bfec660 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/helpers/validationScheme.js @@ -0,0 +1,81 @@ +import { TAB_ADVANCED, TAB_GENERAL, TAB_MARKETPLACE_SELF_SERVICE } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'account', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + if (value.length < 2) { + return 'Field cannot be less then 2 symbols'; + } + + return ''; + }, + }, + { + fieldName: 'domains', + tabId: TAB_GENERAL, + validation: (domains) => { + let error = ''; + + const hasDuplicates = () => + domains.reduce( + (acc, current) => { + if (acc.seen[current.label]) { + acc.hasDuplicates = true; + } else { + acc.seen[current.label] = true; + } + return acc; + }, + { seen: {}, hasDuplicates: false }, + ).hasDuplicates; + + if (!domains.length) { + error = 'Please add at least one domain'; + } else if (hasDuplicates()) { + error = 'Domain must be unique'; + } + + return error; + }, + }, + { + fieldName: 'demandAccount', + tabId: TAB_MARKETPLACE_SELF_SERVICE, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'defaultRule.cleanUrlRuleValue', + tabId: TAB_ADVANCED, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/hubs/Editor/redux/epics/createItem.js b/anyclip/src/modules/hubs/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..693251d --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/epics/createItem.js @@ -0,0 +1,52 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { ITEM_INPUT_TYPE_NAME } from '@/graphql/services/hubs/types/input/itemCreate'; + +import createRequestBody from '../../helpers/createRequestBody'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` +mutation CreateHub($entity: ${ITEM_INPUT_TYPE_NAME}) { + createHub(entity: $entity) { + id + name + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: { + entity: createRequestBody(state$.value), + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/hubs'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Hub created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/hubs/Editor/redux/epics/getAccountOptions.js b/anyclip/src/modules/hubs/Editor/redux/epics/getAccountOptions.js new file mode 100644 index 0000000..93b5365 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/epics/getAccountOptions.js @@ -0,0 +1,75 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_BUSINESS, TYPE_PUBLISHER, TYPE_SYNDICATION, TYPE_VAST } from '@/modules/@common/constants/account'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getHubsAccountsOptions( + $searchText: String, + $pageSize: Int, + $filtersValues: [String] + ) { + getHubsAccountsOptions( + searchText: $searchText, + pageSize: $pageSize, + filtersValues: $filtersValues, + ) { + id + name + } + } +`; + +const getResponse = ({ data: { getHubsAccountsOptions } }) => + getHubsAccountsOptions.map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + switchMap((action) => { + const { searchText = '', isSyndicationOnly = false } = action.payload ?? {}; + + const stream$ = gqlRequest({ + query, + variables: { + searchText, + pageSize: 30, + filtersValues: isSyndicationOnly ? [TYPE_SYNDICATION] : [TYPE_PUBLISHER, TYPE_BUSINESS, TYPE_VAST], + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push( + of( + setAction({ + accountOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + accountOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/hubs/Editor/redux/epics/getDemandAccountsOptions.js b/anyclip/src/modules/hubs/Editor/redux/epics/getDemandAccountsOptions.js new file mode 100644 index 0000000..35b676d --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/epics/getDemandAccountsOptions.js @@ -0,0 +1,66 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { ROWS_PER_PAGE_DEFAULT } from '../../constants'; + +import { getDemandAccountsOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getHubsDemandAccountsOptions( + $searchText: String + $pageSize: Int + ) { + getHubsDemandAccountsOptions( + searchText: $searchText + pageSize: $pageSize + ) { + records{ + id + name + } + recordsTotal + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getDemandAccountsOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actions = []; + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload.searchText ?? '', + pageSize: ROWS_PER_PAGE_DEFAULT, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + actions.push( + of( + setAction({ + demandAccountsOptions: response.data.getHubsDemandAccountsOptions?.records, + }), + ), + ); + } + return concat(...actions); + }), + ); + return concat( + of( + setAction({ + demandAccountsOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/hubs/Editor/redux/epics/getItem.js b/anyclip/src/modules/hubs/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..32f4396 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/epics/getItem.js @@ -0,0 +1,143 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import parseResponseToState from '../../helpers/parseResponseToState'; +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query GetHubByIdQuery( + $id: Int! + ) { + getHubById( + id: $id + ) { + id + account { + id + name + salesforceId + } + demandAccountId + demandAccount { + id + name + salesforceId + } + mpSelfService + name + publisherDomains { + id + domain + players { + id + } + } + publish + + includePublicContent + publisherContentOwners { + account { + id + name + } + } + + selfService + templatePlayerLumX + templatePlayerLumXName + templatePlayerLumXAmp + templatePlayerLumXAmpName + templatePlayerLumN + templatePlayerLumNName + templatePlayerLumNAmp + templatePlayerLumNAmpName + templatePlayerVertical + templatePlayerVerticalName + templatePlayerOutstream + templatePlayerOutstreamName + monetization + firstLook + cpm + + pageRefresh + refreshIntervalMinutes + refreshTimesLimit + ddSpecificPublisher + defaultRule { + id + publisherId + cleanOrder + cleanUrl + cleanUrlRuleType + cleanUrlRuleValue + } + cleanUrl { + id + publisherId + cleanOrder + cleanUrl + cleanUrlRuleType + cleanUrlRuleValue + } + } + } +`; + +const getResponse = ({ data: { getHubById } }) => getHubById; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query, + variables: { + id: action.payload.id, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + const setError = () => { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open hub for edit", + }), + ), + ); + + Router.push('/hubs'); + }; + + if (response.errors.length) { + setError(); + } else { + const data = getResponse(response); + try { + const state = parseResponseToState(data); + actions.push(of(setAction(state))); + } catch { + setError(); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/hubs/Editor/redux/epics/getTemplatePlayerOptions.js b/anyclip/src/modules/hubs/Editor/redux/epics/getTemplatePlayerOptions.js new file mode 100644 index 0000000..8831960 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/epics/getTemplatePlayerOptions.js @@ -0,0 +1,76 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getTemplatePlayerOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query hubsTemplatePlayers( + $searchText: String, + $pageSize: Int, + $type: Int, + $accountId: Int, + ) { + hubsTemplatePlayers( + searchText: $searchText, + pageSize: $pageSize, + type: $type, + accountId: $accountId, + ) { + id + name + } + } +`; + +const getResponse = ({ data: { hubsTemplatePlayers } }) => + hubsTemplatePlayers.map((player) => ({ + value: player.id, + label: player.name, + })); + +export default (action$) => + action$.pipe( + ofType(getTemplatePlayerOptionsAction.type), + switchMap((action) => { + const { searchText = '', type = null, accountId = null } = action.payload ?? {}; + + const stream$ = gqlRequest({ + query, + variables: { + searchText, + pageSize: 30, + type, + accountId, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const templatePlayerOptions = getResponse(response); + + actions.push( + of( + setAction({ + templatePlayerOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + templatePlayerOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/hubs/Editor/redux/epics/index.js b/anyclip/src/modules/hubs/Editor/redux/epics/index.js new file mode 100644 index 0000000..5daadc9 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/epics/index.js @@ -0,0 +1,17 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getAccountOptions from './getAccountOptions'; +import getDemandAccountsOptions from './getDemandAccountsOptions'; +import getItem from './getItem'; +import getTemplatePlayerOptions from './getTemplatePlayerOptions'; +import updateItem from './updateItem'; + +export default combineEpics( + getAccountOptions, + getDemandAccountsOptions, + getTemplatePlayerOptions, + getItem, + createItem, + updateItem, +); diff --git a/anyclip/src/modules/hubs/Editor/redux/epics/updateItem.js b/anyclip/src/modules/hubs/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..21d1ad5 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/epics/updateItem.js @@ -0,0 +1,54 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { ITEM_INPUT_TYPE_NAME } from '@/graphql/services/hubs/types/input/itemCreate'; + +import createRequestBody from '../../helpers/createRequestBody'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` +mutation UpdateHub($entity: ${ITEM_INPUT_TYPE_NAME}) { + updateHub(entity: $entity) { + id + name + } +} +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: { + entity: createRequestBody(state$.value, true), + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/hubs'); + + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Hub updated', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/hubs/Editor/redux/selectors/index.js b/anyclip/src/modules/hubs/Editor/redux/selectors/index.js new file mode 100644 index 0000000..781a5ca --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/selectors/index.js @@ -0,0 +1,49 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const idSelector = (state) => state[nameSpace].id; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +// tab general +export const accountSelector = (state) => state[nameSpace].account; +export const nameSelector = (state) => state[nameSpace].name; +export const domainsSelector = (state) => state[nameSpace].domains; +export const publishSelector = (state) => state[nameSpace].publish; +// syndicate content tab +export const includePublicContentSelector = (state) => state[nameSpace].includePublicContent; +export const accountsSelector = (state) => state[nameSpace].accounts; +// player self service tab +export const selfServiceSelector = (state) => state[nameSpace].selfService; +export const templatePlayerLumXSelector = (state) => state[nameSpace].templatePlayerLumX; +export const templatePlayerLumXAmpSelector = (state) => state[nameSpace].templatePlayerLumXAmp; +export const templatePlayerLumNSelector = (state) => state[nameSpace].templatePlayerLumN; +export const templatePlayerLumNAmpSelector = (state) => state[nameSpace].templatePlayerLumNAmp; +export const templatePlayerVerticalSelector = (state) => state[nameSpace].templatePlayerVertical; +export const templatePlayerOutstreamSelector = (state) => state[nameSpace].templatePlayerOutstream; +export const templatePlayerOptionsSelector = (state) => state[nameSpace].templatePlayerOptions; +export const monetizationSelector = (state) => state[nameSpace].monetization; +export const firstLookSelector = (state) => state[nameSpace].firstLook; +export const cpmSelector = (state) => state[nameSpace].cpm; +// marketplace self-service +export const mpSelfServiceSelector = (state) => state[nameSpace].mpSelfService; +export const demandAccountSelector = (state) => state[nameSpace].demandAccount; +export const demandAccountIdSelector = (state) => state[nameSpace].demandAccountId; +export const demandAccountsOptionsSelector = (state) => state[nameSpace].demandAccountsOptions; +// advanced tab +export const pageRefreshSelector = (state) => state[nameSpace].pageRefresh; +export const refreshIntervalMinutesSelector = (state) => state[nameSpace].refreshIntervalMinutes; +export const refreshTimesLimitSelector = (state) => state[nameSpace].refreshTimesLimit; +export const ddSpecificPublisherSelector = (state) => state[nameSpace].ddSpecificPublisher; +export const defaultRuleSelector = (state) => state[nameSpace].defaultRule; +export const cleanUrlSelector = (state) => state[nameSpace].cleanUrl; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/hubs/Editor/redux/slices/index.js b/anyclip/src/modules/hubs/Editor/redux/slices/index.js new file mode 100644 index 0000000..f27e5c8 --- /dev/null +++ b/anyclip/src/modules/hubs/Editor/redux/slices/index.js @@ -0,0 +1,101 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { CLEAN_RULE_TYPE_NONE, REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + accountOptions: null, // used in tab general and syndicate content tab + // tab general + account: null, + name: '', + domains: [], // [{ label: String, isDisabled: Boolean }] + publish: false, + // syndicate content tab + includePublicContent: false, // include All Syndication Accounts + accounts: [], + // player self service + selfService: false, + templatePlayerLumX: null, // Intelligent Template + templatePlayerLumXAmp: null, // Intelligent-AMP Template + templatePlayerLumN: null, // Stories Template + templatePlayerLumNAmp: null, // Stories-AMP Template + templatePlayerVertical: null, // Vertical Template + templatePlayerOutstream: null, // Outstream Template + templatePlayerOptions: null, + monetization: false, + firstLook: false, + cpm: 0, + // marketplace self-service + mpSelfService: false, + demandAccountId: null, + demandAccount: null, + demandAccountsOptions: [], + // advanced tab + pageRefresh: false, + refreshIntervalMinutes: 0, + refreshTimesLimit: 0, + ddSpecificPublisher: false, + defaultRule: { + cleanUrlRuleType: CLEAN_RULE_TYPE_NONE, + cleanUrlRuleValue: '', + }, // { id: 1, cleanOrder: 0, cleanUrl: , cleanUrlRuleRType: , cleanUrlRuleValue: '' }, + cleanUrl: [], // [{ cleanOrder: 0, cleanUrl: , cleanUrlRuleType: , cleanUrlRuleValue: '' }], + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@HUBS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getAccountOptionsAction: (state) => state, + getDemandAccountsOptionsAction: (state) => state, + getTemplatePlayerOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getAccountOptionsAction, + getDemandAccountsOptionsAction, + getTemplatePlayerOptionsAction, + createItemAction, + updateItemAction, + + setActiveTabIdAction, + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/hubs/List/components/Empty/Empty.jsx b/anyclip/src/modules/hubs/List/components/Empty/Empty.jsx similarity index 100% rename from src/modules/hubs/List/components/Empty/Empty.jsx rename to anyclip/src/modules/hubs/List/components/Empty/Empty.jsx diff --git a/src/modules/hubs/List/components/Empty/Empty.module.scss b/anyclip/src/modules/hubs/List/components/Empty/Empty.module.scss similarity index 100% rename from src/modules/hubs/List/components/Empty/Empty.module.scss rename to anyclip/src/modules/hubs/List/components/Empty/Empty.module.scss diff --git a/src/modules/hubs/List/components/List.jsx b/anyclip/src/modules/hubs/List/components/List.jsx similarity index 100% rename from src/modules/hubs/List/components/List.jsx rename to anyclip/src/modules/hubs/List/components/List.jsx diff --git a/src/modules/hubs/List/components/List.module.scss b/anyclip/src/modules/hubs/List/components/List.module.scss similarity index 100% rename from src/modules/hubs/List/components/List.module.scss rename to anyclip/src/modules/hubs/List/components/List.module.scss diff --git a/anyclip/src/modules/hubs/List/constants/index.js b/anyclip/src/modules/hubs/List/constants/index.js new file mode 100644 index 0000000..ea57b33 --- /dev/null +++ b/anyclip/src/modules/hubs/List/constants/index.js @@ -0,0 +1,23 @@ +// Actions Select +export const ACTIONS_SHOW_PLAYERS = 'showPlayers'; + +export const ACTIONS_OPTIONS = [{ label: 'Show Players', value: ACTIONS_SHOW_PLAYERS }]; + +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +// Status Select +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = 0; + +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Inactive', value: STATUSES_INACTIVE }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/src/modules/invitations/List/helpers/computedState.js b/anyclip/src/modules/hubs/List/helpers/computedState.js similarity index 100% rename from src/modules/invitations/List/helpers/computedState.js rename to anyclip/src/modules/hubs/List/helpers/computedState.js diff --git a/src/modules/hubs/List/helpers/index.js b/anyclip/src/modules/hubs/List/helpers/index.js similarity index 100% rename from src/modules/hubs/List/helpers/index.js rename to anyclip/src/modules/hubs/List/helpers/index.js diff --git a/anyclip/src/modules/hubs/List/redux/epics/getAccounts.js b/anyclip/src/modules/hubs/List/redux/epics/getAccounts.js new file mode 100644 index 0000000..c486135 --- /dev/null +++ b/anyclip/src/modules/hubs/List/redux/epics/getAccounts.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getHubsAccountsOptions( + $searchText: String, + $pageSize: Int + ) { + getHubsAccountsOptions( + searchText: $searchText, + pageSize: $pageSize, + ) { + id + name + } + } +`; + +const getResponse = ({ data: { getHubsAccountsOptions } }) => + getHubsAccountsOptions.map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/hubs/List/redux/epics/getData.js b/anyclip/src/modules/hubs/List/redux/epics/getData.js new file mode 100644 index 0000000..0984825 --- /dev/null +++ b/anyclip/src/modules/hubs/List/redux/epics/getData.js @@ -0,0 +1,71 @@ +import { STATUSES_ALL } from '../../constants'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query GetHubs( + $sortBy: String + $sortOrder: String + $page: Int + $pageSize: Int + $searchText: String + $accountId: Int + $status: Int + $searchIn: [String] + ) { + getHubs( + sortBy: $sortBy + sortOrder: $sortOrder + page: $page + pageSize: $pageSize + searchText: $searchText + accountId: $accountId + status: $status + searchIn: $searchIn + ) { + records { + id + name + accountName + players { + id + publisherId + } + updatedAt + updatedBy + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const account = selectors.accountSelector(state); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = +status; + } + + if (account) { + variables.accountId = account.value; + } + + return variables; + }, + processResponse: ({ data: { getHubs } }) => getHubs, + setTableAction, +}); diff --git a/anyclip/src/modules/hubs/List/redux/epics/index.js b/anyclip/src/modules/hubs/List/redux/epics/index.js new file mode 100644 index 0000000..ae14d03 --- /dev/null +++ b/anyclip/src/modules/hubs/List/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getAccounts from './getAccounts'; +import getData from './getData'; + +export default combineEpics(getData, getAccounts); diff --git a/anyclip/src/modules/hubs/List/redux/selectors/index.js b/anyclip/src/modules/hubs/List/redux/selectors/index.js new file mode 100644 index 0000000..32c733c --- /dev/null +++ b/anyclip/src/modules/hubs/List/redux/selectors/index.js @@ -0,0 +1,23 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +export const statusSelector = (state) => state[nameSpace].status; diff --git a/anyclip/src/modules/hubs/List/redux/slices/index.js b/anyclip/src/modules/hubs/List/redux/slices/index.js new file mode 100644 index 0000000..0629e54 --- /dev/null +++ b/anyclip/src/modules/hubs/List/redux/slices/index.js @@ -0,0 +1,41 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ALL, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + account: null, + accountOptions: null, // null need for loading state + status: STATUSES_ALL, +}; + +export const slice = createSlice({ + name: '@@HUBS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getAccountOptionsAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, getAccountOptionsAction } = slice.actions; diff --git a/src/modules/invitations/List/components/CopyTooltip/CopyTooltip.jsx b/anyclip/src/modules/invitations/List/components/CopyTooltip/CopyTooltip.jsx similarity index 100% rename from src/modules/invitations/List/components/CopyTooltip/CopyTooltip.jsx rename to anyclip/src/modules/invitations/List/components/CopyTooltip/CopyTooltip.jsx diff --git a/src/modules/invitations/List/components/Empty/Empty.jsx b/anyclip/src/modules/invitations/List/components/Empty/Empty.jsx similarity index 100% rename from src/modules/invitations/List/components/Empty/Empty.jsx rename to anyclip/src/modules/invitations/List/components/Empty/Empty.jsx diff --git a/src/modules/invitations/List/components/Empty/Empty.module.scss b/anyclip/src/modules/invitations/List/components/Empty/Empty.module.scss similarity index 100% rename from src/modules/invitations/List/components/Empty/Empty.module.scss rename to anyclip/src/modules/invitations/List/components/Empty/Empty.module.scss diff --git a/src/modules/invitations/List/components/List.jsx b/anyclip/src/modules/invitations/List/components/List.jsx similarity index 100% rename from src/modules/invitations/List/components/List.jsx rename to anyclip/src/modules/invitations/List/components/List.jsx diff --git a/src/modules/invitations/List/components/List.module.scss b/anyclip/src/modules/invitations/List/components/List.module.scss similarity index 100% rename from src/modules/invitations/List/components/List.module.scss rename to anyclip/src/modules/invitations/List/components/List.module.scss diff --git a/anyclip/src/modules/invitations/List/constants/index.js b/anyclip/src/modules/invitations/List/constants/index.js new file mode 100644 index 0000000..0148070 --- /dev/null +++ b/anyclip/src/modules/invitations/List/constants/index.js @@ -0,0 +1,20 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +// Status Select +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 'Active'; +export const STATUSES_PENDING = 'Pending'; +export const STATUSES_REVOKED = 'Revoked'; + +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Pending', value: STATUSES_PENDING }, + { label: 'Revoked', value: STATUSES_REVOKED }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/src/modules/users/List/helpers/computedState.js b/anyclip/src/modules/invitations/List/helpers/computedState.js similarity index 100% rename from src/modules/users/List/helpers/computedState.js rename to anyclip/src/modules/invitations/List/helpers/computedState.js diff --git a/src/modules/invitations/List/helpers/index.js b/anyclip/src/modules/invitations/List/helpers/index.js similarity index 100% rename from src/modules/invitations/List/helpers/index.js rename to anyclip/src/modules/invitations/List/helpers/index.js diff --git a/anyclip/src/modules/invitations/List/redux/epics/getAccounts.js b/anyclip/src/modules/invitations/List/redux/epics/getAccounts.js new file mode 100644 index 0000000..db0969e --- /dev/null +++ b/anyclip/src/modules/invitations/List/redux/epics/getAccounts.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_ACCOUNTS } from '@/graphql/services/invitations/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/invitations/types/payload/account'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_ACCOUNTS}($payload: ${PAYLOAD_NAME}) { + ${GET_ACCOUNTS}(payload: $payload) { + id + name + } + } +`; + +const getResponse = ({ data }) => + data[GET_ACCOUNTS].map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/invitations/List/redux/epics/getData.js b/anyclip/src/modules/invitations/List/redux/epics/getData.js new file mode 100644 index 0000000..20c6f56 --- /dev/null +++ b/anyclip/src/modules/invitations/List/redux/epics/getData.js @@ -0,0 +1,68 @@ +import { STATUSES_ALL } from '../../constants'; +import { GET_INVITATIONS } from '@/graphql/services/invitations/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/invitations/types/payload/list'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_INVITATIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_INVITATIONS}(payload: $payload) { + records { + id + email + invitedById + accountId + url + videoId + createdAt + updatedAt + invStatus + invitedByFullName + videoData { + videoName + videoId + } + accountName + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const account = selectors.accountSelector(state); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = status; + } + + if (account) { + variables.accountId = account.value; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => ({ + records: data[GET_INVITATIONS].records, + recordsTotal: data[GET_INVITATIONS].recordsTotal, + allRecordsCount: data[GET_INVITATIONS].recordsTotal, + }), + setTableAction, +}); diff --git a/anyclip/src/modules/invitations/List/redux/epics/index.js b/anyclip/src/modules/invitations/List/redux/epics/index.js new file mode 100644 index 0000000..1bfa71f --- /dev/null +++ b/anyclip/src/modules/invitations/List/redux/epics/index.js @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import getAccounts from './getAccounts'; +import getData from './getData'; +import revokeInvitation from './revokeInvitation'; + +export default combineEpics(getData, getAccounts, revokeInvitation); diff --git a/anyclip/src/modules/invitations/List/redux/epics/revokeInvitation.js b/anyclip/src/modules/invitations/List/redux/epics/revokeInvitation.js new file mode 100644 index 0000000..9db80a5 --- /dev/null +++ b/anyclip/src/modules/invitations/List/redux/epics/revokeInvitation.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { REVOKE_INVITATION } from '@/graphql/services/invitations/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/invitations/types/payload/item'; + +import { revokeAction, revokeActionSuccess } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${PAYLOAD_NAME}($payload: ${PAYLOAD_NAME}) { + ${REVOKE_INVITATION}(payload: $payload) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(revokeAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: action.payload, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Invitation revoked successfully', + }), + revokeActionSuccess(action.payload), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/invitations/List/redux/selectors/index.js b/anyclip/src/modules/invitations/List/redux/selectors/index.js new file mode 100644 index 0000000..32c733c --- /dev/null +++ b/anyclip/src/modules/invitations/List/redux/selectors/index.js @@ -0,0 +1,23 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +export const statusSelector = (state) => state[nameSpace].status; diff --git a/anyclip/src/modules/invitations/List/redux/slices/index.js b/anyclip/src/modules/invitations/List/redux/slices/index.js new file mode 100644 index 0000000..1f2b249 --- /dev/null +++ b/anyclip/src/modules/invitations/List/redux/slices/index.js @@ -0,0 +1,55 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + ROWS_PER_PAGE_DEFAULT, + STATUSES_ALL, + STATUSES_REVOKED, + TABLE_REDUX_FIELD_NAME, + TABLE_SORT_BY, +} from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + account: null, + accountOptions: null, // null need for loading state + status: STATUSES_ALL, +}; + +export const slice = createSlice({ + name: '@@INVITATIONS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getAccountOptionsAction: (state) => state, + revokeAction: (state) => state, + revokeActionSuccess: (state, action) => { + const index = state[TABLE_REDUX_FIELD_NAME].data.findIndex((row) => row.id === action.payload); + + state[TABLE_REDUX_FIELD_NAME].data[index].invStatus = STATUSES_REVOKED; + }, + }, +}); + +export const { getDataAction, setTableAction, setAction, getAccountOptionsAction, revokeAction, revokeActionSuccess } = + slice.actions; diff --git a/src/modules/layout/components/FloatBlock/FloatBlock.jsx b/anyclip/src/modules/layout/components/FloatBlock/FloatBlock.jsx similarity index 100% rename from src/modules/layout/components/FloatBlock/FloatBlock.jsx rename to anyclip/src/modules/layout/components/FloatBlock/FloatBlock.jsx diff --git a/src/modules/layout/components/FloatBlock/FloatBlock.module.scss b/anyclip/src/modules/layout/components/FloatBlock/FloatBlock.module.scss similarity index 100% rename from src/modules/layout/components/FloatBlock/FloatBlock.module.scss rename to anyclip/src/modules/layout/components/FloatBlock/FloatBlock.module.scss diff --git a/src/modules/layout/components/FloatBlock/components/Container/Container.jsx b/anyclip/src/modules/layout/components/FloatBlock/components/Container/Container.jsx similarity index 100% rename from src/modules/layout/components/FloatBlock/components/Container/Container.jsx rename to anyclip/src/modules/layout/components/FloatBlock/components/Container/Container.jsx diff --git a/src/modules/layout/components/FloatBlock/components/Container/Container.module.scss b/anyclip/src/modules/layout/components/FloatBlock/components/Container/Container.module.scss similarity index 100% rename from src/modules/layout/components/FloatBlock/components/Container/Container.module.scss rename to anyclip/src/modules/layout/components/FloatBlock/components/Container/Container.module.scss diff --git a/src/modules/layout/components/index.jsx b/anyclip/src/modules/layout/components/index.jsx similarity index 100% rename from src/modules/layout/components/index.jsx rename to anyclip/src/modules/layout/components/index.jsx diff --git a/src/modules/layout/components/index.module.scss b/anyclip/src/modules/layout/components/index.module.scss similarity index 100% rename from src/modules/layout/components/index.module.scss rename to anyclip/src/modules/layout/components/index.module.scss diff --git a/src/modules/layout/components/menu/ItemMenu/index.jsx b/anyclip/src/modules/layout/components/menu/ItemMenu/index.jsx similarity index 100% rename from src/modules/layout/components/menu/ItemMenu/index.jsx rename to anyclip/src/modules/layout/components/menu/ItemMenu/index.jsx diff --git a/src/modules/layout/components/menu/ItemMenu/index.module.scss b/anyclip/src/modules/layout/components/menu/ItemMenu/index.module.scss similarity index 100% rename from src/modules/layout/components/menu/ItemMenu/index.module.scss rename to anyclip/src/modules/layout/components/menu/ItemMenu/index.module.scss diff --git a/src/modules/layout/components/menu/SubMenuItem/SubMenu.module.scss b/anyclip/src/modules/layout/components/menu/SubMenuItem/SubMenu.module.scss similarity index 100% rename from src/modules/layout/components/menu/SubMenuItem/SubMenu.module.scss rename to anyclip/src/modules/layout/components/menu/SubMenuItem/SubMenu.module.scss diff --git a/src/modules/layout/components/menu/SubMenuItem/index.jsx b/anyclip/src/modules/layout/components/menu/SubMenuItem/index.jsx similarity index 100% rename from src/modules/layout/components/menu/SubMenuItem/index.jsx rename to anyclip/src/modules/layout/components/menu/SubMenuItem/index.jsx diff --git a/anyclip/src/modules/layout/components/menu/constants/index.js b/anyclip/src/modules/layout/components/menu/constants/index.js new file mode 100644 index 0000000..b72e940 --- /dev/null +++ b/anyclip/src/modules/layout/components/menu/constants/index.js @@ -0,0 +1,8 @@ +import { IN_HOUSE_ITEMS_TYPE } from '@/modules/analytics/general/constants'; + +export const ANALYTIC_MENU = { + componentName: 'AnalyticsMenu', + inHouseItemTypes: IN_HOUSE_ITEMS_TYPE, +}; + +export default {}; diff --git a/src/modules/layout/components/menu/index.jsx b/anyclip/src/modules/layout/components/menu/index.jsx similarity index 100% rename from src/modules/layout/components/menu/index.jsx rename to anyclip/src/modules/layout/components/menu/index.jsx diff --git a/src/modules/layout/components/menu/index.module.scss b/anyclip/src/modules/layout/components/menu/index.module.scss similarity index 100% rename from src/modules/layout/components/menu/index.module.scss rename to anyclip/src/modules/layout/components/menu/index.module.scss diff --git a/anyclip/src/modules/layout/helpers/index.js b/anyclip/src/modules/layout/helpers/index.js new file mode 100644 index 0000000..f5bc7b1 --- /dev/null +++ b/anyclip/src/modules/layout/helpers/index.js @@ -0,0 +1,330 @@ +import { + InsertChartOutlinedRounded, + PeopleOutlined, + PlayCircleOutlined, + SensorsRounded, + SettingsOutlined, + TvOutlined, + VideoLibraryOutlined, +} from '@mui/icons-material'; + +import { PCN_GET_PLAYER } from '@/modules/@common/acl/constants'; +import { + AD_SERVERS_PAGE, + ADVERTISERS_PAGE, + ANALYTICS_CUSTOM_REPORTS, + ANALYTICS_LIVE_DASHBOARD, + ANALYTICS_MONETIZATION_DASHBOARD, + ANALYTICS_PAGE, + ANALYTICS_VIDEO_CONTENT_PERFORMANCE, + CONFIG_PAGE, + CUSTOM_REPORTS_PAGE, + EDITORIAL_PAGE, + ENTITIES_PAGE, + FEEDS_PAGE, + FORM_TEMPLATES, + FORMS, + HOSTED_WATCH_PAGE, + HUBS_PAGE, + INVENTORY_PAGE, + INVITATIONS_PAGE, + LIVE_PAGE, + MARKETPLACE_DASHBOARD_PAGE, + MARKETPLACE_HB_CONNECTORS_PAGE, + MARKETPLACE_SELF_SERVE, + NOTIFICATIONS_PAGE, + ONLINE_HELP_CONFIG_PAGE, + PARTNERS_ACCOUNTS_PAGE, + PERMISSIONS_PAGE, + PLAYER_PAGE, + PLAYER_PCN_PAGE, + PUBLISHING_PAGE, + ROLES_PERMISSIONS_PAGE, + SSO_PAGE, + // HOSTED_WATCH, + USER_RULES_SETTINGS, + USERS_PAGE, + X_RAY_CAMPAIGNS_PAGE, + X_RAY_CREATIVES_PAGE, + X_RAY_LINE_ITEMS_PAGE, +} from '@/modules/@common/router/constants'; +import { ANALYTIC_MENU } from '@/modules/layout/components/menu/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +import { CustomAnalyticsFilled, CustomInteractionsFilled } from '@/mui/components/CustomIcon'; + +const getTitleKey = (sourceString) => + sourceString.toLowerCase().replace(/\W/g, (match) => (/\d/.test(match) ? match : '-')); + +const getHref = (userPermissions, item) => + item.permissions.some((permission) => hasPermission(permission, userPermissions)) ? item.path : null; + +const filterItemsAndAddSpecialProps = (menu, nesting) => + menu.reduce((acc, item) => { + const id = `${getTitleKey(item.title)}-${nesting}`; + if (item.href) { + return acc.concat({ + ...item, + id, + nesting, + }); + } + + if (item.subMenu?.length) { + const uSubMenu = filterItemsAndAddSpecialProps(item.subMenu, nesting + 1); + + if (uSubMenu.length) { + return acc.concat({ + ...item, + subMenu: uSubMenu, + id, + nesting, + }); + } + } + + return acc; + }, []); + +export const getMenuCollection = (accountId, userPermissions = []) => { + const menu = [ + { + title: 'Watch', + icon: VideoLibraryOutlined, + href: getHref(userPermissions, HOSTED_WATCH_PAGE), + }, + { + title: accountId ? 'Account' : 'Admin', + iconType: 'light', + icon: SettingsOutlined, + subMenu: [ + { + title: 'Publish Destinations', + href: getHref(userPermissions, PUBLISHING_PAGE), + }, + { + title: 'Hubs', + href: getHref(userPermissions, HUBS_PAGE), + }, + { + title: 'Users', + href: getHref(userPermissions, USERS_PAGE), + }, + { + title: 'Roles & Permissions', + href: getHref(userPermissions, ROLES_PERMISSIONS_PAGE), + }, + { + title: 'Permissions', + href: getHref(userPermissions, PERMISSIONS_PAGE), + }, + { + title: 'Sources', + href: getHref(userPermissions, FEEDS_PAGE), + }, + { + title: 'Notifications', + href: getHref(userPermissions, NOTIFICATIONS_PAGE), + }, + { + title: 'Single Sign-On', + href: getHref(userPermissions, SSO_PAGE), + }, + { + title: 'Invitations', + href: getHref(userPermissions, INVITATIONS_PAGE), + }, + { + title: 'Reports', + subMenu: [ + { + title: 'Custom Dashboards', + href: getHref(userPermissions, CUSTOM_REPORTS_PAGE), + }, + ], + }, + { + title: 'Entities', + href: getHref(userPermissions, ENTITIES_PAGE), + }, + { + title: 'Online Help Configuration', + href: getHref(userPermissions, ONLINE_HELP_CONFIG_PAGE), + }, + { + title: 'Luminous Configurations', + href: getHref(userPermissions, CONFIG_PAGE), + }, + ], + }, + { + title: 'Partners', + icon: PeopleOutlined, + subMenu: [ + { + title: 'Accounts', + href: getHref(userPermissions, PARTNERS_ACCOUNTS_PAGE), + }, + { + title: 'Advertisers', + href: getHref(userPermissions, ADVERTISERS_PAGE), + }, + ], + }, + { + title: 'Players', + icon: PlayCircleOutlined, + href: getHref(userPermissions, userPermissions.includes(PCN_GET_PLAYER) ? PLAYER_PCN_PAGE : PLAYER_PAGE), + }, + { + title: 'Marketplace', + icon: InsertChartOutlinedRounded, + isMobile: true, + subMenu: [ + { + title: 'Dashboard', + href: getHref(userPermissions, MARKETPLACE_DASHBOARD_PAGE), + isMobile: true, + }, + { + title: 'Ad Servers', + href: getHref(userPermissions, AD_SERVERS_PAGE), + }, + { + title: 'HB Connectors', + href: getHref(userPermissions, MARKETPLACE_HB_CONNECTORS_PAGE), + }, + { + title: 'Inventory Manager', + href: getHref(userPermissions, INVENTORY_PAGE), + }, + ], + }, + { + title: 'Marketplace', + icon: InsertChartOutlinedRounded, + href: getHref(userPermissions, MARKETPLACE_SELF_SERVE), + isMobile: true, + }, + { + title: 'Studio', + icon: TvOutlined, + href: getHref(userPermissions, EDITORIAL_PAGE), + isMobile: true, + }, + { + title: 'Live', + icon: SensorsRounded, + href: getHref(userPermissions, LIVE_PAGE), + }, + { + title: 'Interactions', + icon: CustomInteractionsFilled, + subMenu: [ + { + title: 'X-Ray Creatives', + href: getHref(userPermissions, X_RAY_CREATIVES_PAGE), + }, + { + title: 'X-Ray Campaigns', + href: getHref(userPermissions, X_RAY_CAMPAIGNS_PAGE), + }, + { + title: 'X-Ray Line Items', + href: getHref(userPermissions, X_RAY_LINE_ITEMS_PAGE), + }, + { + title: 'Form Templates', + href: getHref(userPermissions, FORM_TEMPLATES), + }, + { + title: 'Forms', + href: getHref(userPermissions, FORMS), + }, + ], + }, + { + title: 'Analytics', + icon: CustomAnalyticsFilled, + subMenu: [ + { + title: 'Analytics', + href: getHref(userPermissions, ANALYTICS_PAGE), + componentName: ANALYTIC_MENU.componentName, + componentProps: { + inHouseMenuSettings: { + /* + Pay attention -> Inhouse menu will be returned from API + with lookerId in the next format anyclip_analytics::video_performance_external + - "anyclip_analytics" substring means its inhouse + - "video_performance_external" its type + Visibility controls on the Account dashboard settings page + */ + [ANALYTIC_MENU.inHouseItemTypes.videoPerformanceExternal]: { + title: 'Video Performance', + href: ANALYTICS_VIDEO_CONTENT_PERFORMANCE.path, + }, + [ANALYTIC_MENU.inHouseItemTypes.liveEvents]: { + title: 'Live Events', + href: ANALYTICS_LIVE_DASHBOARD.path, + }, + [ANALYTIC_MENU.inHouseItemTypes.monetization]: { + title: 'Monetization', + href: ANALYTICS_MONETIZATION_DASHBOARD.path, + }, + [ANALYTIC_MENU.inHouseItemTypes.customReports]: { + title: 'Custom Reports', + href: ANALYTICS_CUSTOM_REPORTS.path, + }, + }, + }, + }, + ], + }, + ]; + + return filterItemsAndAddSpecialProps(menu, -1); +}; + +export const getUserMenuCollections = (userPermissions = []) => { + const menu = [ + { + title: 'Personal Settings', + href: getHref(userPermissions, USER_RULES_SETTINGS), + }, + ]; + + return filterItemsAndAddSpecialProps(menu, -1); +}; + +export const getActiveIdOfParentMenuItem = (menu, pathname) => { + let activeId = null; + + const check = (menu$, globalParentId) => { + menu$.forEach((item) => { + if (item.href) { + const parent = item.href.split(/[/]/g)[1]; + const firstOccurrency = `${pathname.split(/[/]/g)[1]}`; + + if ( + parent === firstOccurrency || + // need because we have 2 separate routes for old and new analytics + (parent === 'analytics' && firstOccurrency === 'analytics-new') + ) { + activeId = globalParentId || item.id; + } + } else { + check(item.subMenu, item.id); + } + }); + }; + + check(menu); + + return activeId; +}; + +export const isMobileApp = false; + +export default {}; diff --git a/src/modules/layout/index.js b/anyclip/src/modules/layout/index.js similarity index 100% rename from src/modules/layout/index.js rename to anyclip/src/modules/layout/index.js diff --git a/anyclip/src/modules/layout/redux/epics/clear.js b/anyclip/src/modules/layout/redux/epics/clear.js new file mode 100644 index 0000000..1009490 --- /dev/null +++ b/anyclip/src/modules/layout/redux/epics/clear.js @@ -0,0 +1,13 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, switchMap } from 'rxjs/operators'; + +import { loadingAction } from '../slices'; +import { locationChangedEventAction, locationLoadedEventAction } from '@/modules/@common/location/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(locationChangedEventAction.type, locationLoadedEventAction.type), + delay(+process.env.APP_REQUEST_TIMEOUT), + switchMap(() => concat(of(loadingAction()))), + ); diff --git a/anyclip/src/modules/layout/redux/epics/error.js b/anyclip/src/modules/layout/redux/epics/error.js new file mode 100644 index 0000000..dab37c9 --- /dev/null +++ b/anyclip/src/modules/layout/redux/epics/error.js @@ -0,0 +1,21 @@ +import { ofType } from 'redux-observable'; +import { of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { errorEventAction } from '@/modules/@common/request/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(errorEventAction.type), + switchMap((action) => + of( + notifyAction({ + variant: TYPE_ERROR, + message: action.payload || 'Please try again!', + }), + ), + ), + ); diff --git a/anyclip/src/modules/layout/redux/epics/followToHelpLink.js b/anyclip/src/modules/layout/redux/epics/followToHelpLink.js new file mode 100644 index 0000000..0646642 --- /dev/null +++ b/anyclip/src/modules/layout/redux/epics/followToHelpLink.js @@ -0,0 +1,39 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { followToHelpLinkAction, showNotificationAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(followToHelpLinkAction.type), + switchMap(() => { + const suffix = window.location.pathname; + + return ajax({ + method: 'GET', + url: `${process.env.APP_PCN_API_BASE_URL_FE}/public/online-help?suffix=${suffix}`, + crossDomain: true, + withCredentials: true, + }).pipe( + switchMap(({ response: redirectUrl }) => { + window.open(redirectUrl, '_blank'); + + return EMPTY; + }), + catchError(({ response }) => + concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: response?.message ?? 'Connection error', + }), + ), + ), + ), + ); + }), + ); diff --git a/anyclip/src/modules/layout/redux/epics/index.js b/anyclip/src/modules/layout/redux/epics/index.js new file mode 100644 index 0000000..a83d93e --- /dev/null +++ b/anyclip/src/modules/layout/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import clearEpic from './clear'; +import errorEpic from './error'; +import followToHelpLink from './followToHelpLink'; +import loadingEpic from './loading'; + +export default combineEpics(clearEpic, loadingEpic, errorEpic, followToHelpLink); diff --git a/anyclip/src/modules/layout/redux/epics/loading.js b/anyclip/src/modules/layout/redux/epics/loading.js new file mode 100644 index 0000000..da6f52f --- /dev/null +++ b/anyclip/src/modules/layout/redux/epics/loading.js @@ -0,0 +1,12 @@ +import { ofType } from 'redux-observable'; +import { of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { loadingAction } from '../slices'; +import { errorEventAction, requestEventAction, responseEventAction } from '@/modules/@common/request/redux/slices'; + +export default (action$) => + action$.pipe( + ofType(requestEventAction.type, responseEventAction.type, errorEventAction.type), + switchMap((action) => of(loadingAction(action.type === requestEventAction.type))), + ); diff --git a/src/modules/layout/redux/selectors/index.js b/anyclip/src/modules/layout/redux/selectors/index.js similarity index 100% rename from src/modules/layout/redux/selectors/index.js rename to anyclip/src/modules/layout/redux/selectors/index.js diff --git a/anyclip/src/modules/layout/redux/slices/index.js b/anyclip/src/modules/layout/redux/slices/index.js new file mode 100644 index 0000000..94615a0 --- /dev/null +++ b/anyclip/src/modules/layout/redux/slices/index.js @@ -0,0 +1,30 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { notifyAction } from '@/modules/@common/notify/redux/slices'; + +const initialState = { + message: '', + loading: false, +}; + +export const slice = createSlice({ + name: '@layout', + initialState, + reducers: { + messageAction: (state, action) => { + state.message = action.payload || initialState.message; + }, + loadingAction: (state, action) => { + state.loading = action.payload || initialState.loading; + }, + followToHelpLinkAction: (state) => state, + }, +}); + +export const { messageAction, loadingAction, followToHelpLinkAction } = slice.actions; + +export const showNotificationAction = notifyAction; + +export const nameSpace = slice.name; + +export default slice.reducer; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/FilterSuggester.jsx b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/FilterSuggester.jsx new file mode 100644 index 0000000..851b5c8 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/FilterSuggester.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Autocomplete, TextField } from '@/mui/components'; + +import styles from './styles.module.scss'; + +function FilterSuggester({ options = [], ...props }) { + const filterValue = options?.find((option) => option.id === props.value); + + const handleSearch = (event) => { + props.onSearch(event?.target?.value || ''); + }; + + const handleChange = (event, value) => { + props.onChange(props.type, value?.id); + }; + + return ( + option.id === value.id} + renderInput={(params) => } + /> + ); +} + +FilterSuggester.propTypes = { + type: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({})), + onSearch: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string.isRequired, +}; + +export default FilterSuggester; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/Search.jsx b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/Search.jsx new file mode 100644 index 0000000..482d4fc --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/Search.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { SearchRounded } from '@mui/icons-material'; + +import { queryParamsSelector } from '../../redux/selectors'; +import { getLiveEventsAction } from '../../redux/slices'; + +import { IconButton, InputAdornment, TextField } from '@/mui/components'; + +import styles from './styles.module.scss'; + +function LiveEventsListSearch() { + const dispatch = useDispatch(); + const queryParams = useSelector(queryParamsSelector); + + const handleSearchChange = (event) => { + dispatch( + getLiveEventsAction({ + ...queryParams, + search: event.target.value, + page: 1, + }), + ); + }; + + const handleSearchClick = () => { + dispatch( + getLiveEventsAction({ + ...queryParams, + page: 1, + }), + ); + }; + + return ( + + + + + + ), + }} + /> + ); +} + +export default LiveEventsListSearch; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/index.jsx b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/index.jsx new file mode 100644 index 0000000..88581d6 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/index.jsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import NextLink from 'next/link'; +import { AddRounded, DeleteRounded, FilterAltRounded } from '@mui/icons-material'; + +import { PCN_DELETE_LIVE_EVENTS, PCN_POST_LIVE_EVENTS } from '@/modules/@common/acl/constants'; + +import { + liveEventsSelector, + playersSelector, + publishersSelector, + queryParamsSelector, + selectedEventsSelector, +} from '../../redux/selectors'; +import { + archiveLiveEventsAction, + getLiveEventsAction, + getPlayersAction, + getPublishersAction, +} from '../../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import FilterSuggester from './FilterSuggester'; +import Search from './Search'; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './styles.module.scss'; + +function LiveEventsHeader() { + const dispatch = useDispatch(); + const selectedEvents = useSelector(selectedEventsSelector); + const liveEvents = useSelector(liveEventsSelector); + const players = useSelector(playersSelector); + const publishers = useSelector(publishersSelector); + const queryParams = useSelector(queryParamsSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + const [isDialogOpen, setDialogOpen] = useState(false); + + const handleFilterChange = (filterType, filterValue) => { + dispatch( + getLiveEventsAction({ + ...queryParams, + [filterType]: filterValue, + page: 1, + }), + ); + }; + + const handleArchiveClick = () => { + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleArchiveEventClick = () => { + setDialogOpen(false); + dispatch( + archiveLiveEventsAction( + selectedEvents.filter((id) => liveEvents.find((event) => event.id === id)?.status !== -1), + ), + ); + }; + + const filters = [ + { + type: 'eventFilter', + placeholder: 'Period', + options: [ + { + label: 'Past', + value: 'PAST', + }, + { + label: 'Current', + value: 'CURRENT', + }, + { + label: 'Future', + value: 'FUTURE', + }, + ], + value: queryParams.eventFilter, + visibility: true, + }, + { + type: 'status', + placeholder: 'Status', + options: [ + { + label: 'Active', + value: 'active', + }, + { + label: 'Archived', + value: 'archived', + }, + ], + disableClearable: true, + value: queryParams.status, + visibility: true, + }, + ]; + + const suggesters = [ + { + type: 'publisherId', + label: 'Hub', + options: publishers, + onSearch: (params) => dispatch(getPublishersAction(params)), + value: queryParams.publisherId, + }, + { + type: 'playerId', + label: 'Player', + options: players, + onSearch: (params) => dispatch(getPlayersAction(params)), + value: queryParams.playerId, + }, + ]; + + return ( + <> + + + Live Events + + + +
    + +
    + + + + + {filters + .filter((f) => f.visibility) + .map((filter) => ( + s.value === filter.value) ?? null} + options={filter.options} + disableClearable={filter.disableClearable} + size="small" + onChange={(e, selected$) => handleFilterChange(filter.type, selected$?.value ?? null)} + renderInput={(params) => } + /> + ))} + {suggesters.map((filter) => ( +
    + +
    + ))} + + {hasPermission(PCN_DELETE_LIVE_EVENTS, userPermissions) && + selectedEvents.some((id) => liveEvents.find((event) => event.id === id)?.status !== -1) && ( + + )} + +
    + + {isDialogOpen && ( + + Archive Event Confirmation + + {selectedEvents.length > 1 ? 'Events ' : 'Event '} + will be archived permanently from library. Are you sure you would like to archive? + + + + + + + )} + + ); +} + +export default LiveEventsHeader; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/styles.module.scss b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/styles.module.scss new file mode 100644 index 0000000..35f680e --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsHeader/styles.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Text":"styles_Text__4z70V","WrapperTitle":"styles_WrapperTitle__TfWSC","Search":"styles_Search__g0l5p","Filter":"styles_Filter__vu7Zt","Suggester":"styles_Suggester__TECpJ","AddButtonWrapper":"styles_AddButtonWrapper__KzyqZ","StatusSelect":"styles_StatusSelect__MeKQG"}; \ No newline at end of file diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Header.jsx b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Header.jsx new file mode 100644 index 0000000..2d85f89 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Header.jsx @@ -0,0 +1,125 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; + +import { Checkbox, TableCell, TableHead, TableRow, TableSortLabel } from '@/mui/components'; + +import styles from './styles.module.scss'; + +export default function EnhancedTableHead(props) { + const createSortHandler = (property) => (event) => { + props.onRequestSort(event, property); + }; + + const headCells = [ + { + id: 'id', + label: 'Id', + sortable: true, + }, + { + id: 'name', + label: 'Name', + sortable: true, + }, + { + id: 'publisher', + label: 'Hub', + sortable: false, + }, + { + id: 'description', + label: 'Description', + sortable: true, + }, + { + id: 'startTime', + label: 'Start Time', + sortable: true, + }, + { + id: 'endTime', + label: 'End Time', + sortable: true, + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + }, + { + id: 'status', + label: 'Status', + sortable: true, + }, + { + id: 'updatedAt', + label: 'Last Update', + sortable: true, + }, + { + id: 'actions', + label: '', + sortable: false, + autoWidth: true, + padding: 'none', + }, + ]; + + return ( + + + + + + {headCells.map((headCell) => { + let { padding } = headCell; + + if (!padding && !headCell.label) { + padding = 'checkbox'; + } + + return ( + + {headCell.sortable && ( + + {headCell.label} + {props.sortBy === headCell.id ? ( + + {props.sortOrder === SORT_DESC ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + )} + {!headCell.sortable && headCell.label} + + ); + })} + + + ); +} + +EnhancedTableHead.propTypes = { + numSelected: PropTypes.number.isRequired, + onRequestSort: PropTypes.func.isRequired, + onSelectAllClick: PropTypes.func.isRequired, + sortOrder: PropTypes.oneOf([SORT_ASC, SORT_DESC]).isRequired, + sortBy: PropTypes.string.isRequired, + rowCount: PropTypes.number.isRequired, +}; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Live.jsx b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Live.jsx new file mode 100644 index 0000000..60630d2 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/Live.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useTheme } from '@mui/material/styles'; + +import { Chip } from '@/mui/components'; + +function LiveCell({ startTime = 0, endTime = 0, ...props }) { + const theme = useTheme(); + const now = Date.now(); + let status = 'Live'; + + if (props.status !== -1 && now < startTime) { + status = 'Ready'; + } else if ( + (props.status !== -1 && now > endTime && !props.recurrent) || + (props.recurrent && props.recurrentEndDate <= Date.now()) + ) { + status = 'Ended'; + } else if (props.status === -1) { + status = 'Archived'; + } + + return ( + + ); +} + +LiveCell.propTypes = { + indicationColor: PropTypes.string.isRequired, + startTime: PropTypes.number, + endTime: PropTypes.number, + status: PropTypes.number.isRequired, + recurrent: PropTypes.bool.isRequired, + recurrentEndDate: PropTypes.number.isRequired, +}; + +export default LiveCell; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/index.jsx b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/index.jsx new file mode 100644 index 0000000..c4ae738 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/index.jsx @@ -0,0 +1,337 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import { useRouter } from 'next/router'; +import { CodeRounded, ContentCopyRounded, Inventory2Rounded, SyncRounded } from '@mui/icons-material'; + +import { PCN_DELETE_LIVE_EVENTS, PCN_POST_LIVE_EVENTS } from '@/modules/@common/acl/constants'; + +import { + embedCodeSelector, + liveEventsSelector, + queryParamsSelector, + selectedEventsSelector, + statusSelector, + totalEventsSelector, +} from '../../redux/selectors'; +import { + archiveLiveEventsAction, + getEmbedCodeAction, + getLiveEventsAction, + setEmbedCodeAction, + setSelectedEventsAction, +} from '../../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import EmbedCodePopup from '@/modules/@common/EmbedCodePopup'; +import { TableCellActions } from '@/modules/@common/Table'; +import Header from './Header'; +import Live from './Live'; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TablePagination, + TableRow, + TableScroll, + Tooltip, +} from '@/mui/components'; + +import styles from './styles.module.scss'; + +const ARCHIVE_STATUS = -1; + +function LiveEventsList() { + const dispatch = useDispatch(); + const router = useRouter(); + const queryParams = useSelector(queryParamsSelector); + const embedCode = useSelector(embedCodeSelector); + const liveEvents = useSelector(liveEventsSelector); + const totalEvents = useSelector(totalEventsSelector); + const selectedEvents = useSelector(selectedEventsSelector); + const status = useSelector(statusSelector); + const [isDialogOpen, setDialogOpen] = useState(false); + const [showEmbedPopup, setShowEmbedPopup] = useState(false); + const [eventId, setEventId] = useState(null); + + const userPermissions = useSelector(getUserPermissionsSelector); + + useEffect(() => { + dispatch( + getLiveEventsAction({ + ...queryParams, + page: 1, + }), + ); + }, []); + + const handleRequestSort = (event, property) => { + const isAsc = queryParams.sortBy === property && queryParams.sortOrder === 'ASC'; + dispatch( + getLiveEventsAction({ + ...queryParams, + page: 1, + sortBy: property, + sortOrder: isAsc ? 'DESC' : 'ASC', + }), + ); + }; + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = liveEvents.map((item) => item.id); + dispatch(setSelectedEventsAction(newSelecteds)); + return; + } + dispatch(setSelectedEventsAction([])); + }; + + const handleCheckClick = (event, id) => { + if (event.target.checked) { + dispatch(setSelectedEventsAction([...selectedEvents, id])); + return; + } + const unselected = selectedEvents.filter((item) => item !== id); + dispatch(setSelectedEventsAction(unselected)); + }; + + const showEmbedCode = (id) => { + dispatch(setEmbedCodeAction('')); + setShowEmbedPopup(true); + dispatch(getEmbedCodeAction(id)); + }; + + const handleEmbedOptionClick = (id) => { + showEmbedCode(id); + }; + + const handleArchiveOptionClick = (id) => { + setEventId(id); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setShowEmbedPopup(false); + }; + + const handleArchiveEventClick = () => { + setDialogOpen(false); + dispatch(archiveLiveEventsAction([eventId])); + }; + + const handleChangePage = (event, newPage) => { + dispatch( + getLiveEventsAction({ + ...queryParams, + page: newPage, + }), + ); + }; + + const handleChangeRowsPerPage = (event) => { + dispatch( + getLiveEventsAction({ + ...queryParams, + page: 1, + pageSize: parseInt(event.target.value, 10), + }), + ); + }; + + const handleCellClick = (e, id) => { + if (e.target.type === 'checkbox') return; + + router.push(`/live/${id}`); + }; + + const isSelected = (id) => !!selectedEvents.find((item) => item === id); + + return ( + <> +
    + + + +
    + + {liveEvents.map((row, index) => { + const isItemSelected = isSelected(row.id); + const labelId = `enhanced-table-checkbox-${index}`; + + const isArchiveStatus = status === ARCHIVE_STATUS; + + const options = [ + { + text: 'Embed', + icon: CodeRounded, + style: 'regular', + onClick: () => handleEmbedOptionClick(row.id), + show: !isArchiveStatus, + title: 'Show Embed code', + }, + { + text: 'Duplicate', + icon: ContentCopyRounded, + onClick: () => router.push(`/live/new?copied=${row.id}`), + show: hasPermission(PCN_POST_LIVE_EVENTS, userPermissions), + title: 'Duplicate Event', + }, + { + text: 'Archive', + icon: Inventory2Rounded, + onClick: () => handleArchiveOptionClick(row.id), + show: !isArchiveStatus && hasPermission(PCN_DELETE_LIVE_EVENTS, userPermissions), + title: 'Archive Event', + }, + ].filter((option) => option.show); + + return ( + handleCellClick(event, row.id)} + role="checkbox" + aria-checked={isItemSelected} + tabIndex={-1} + key={row.id} + selected={isItemSelected} + className={styles.Row} + > + + handleCheckClick(event, row.id)} + inputProps={{ 'aria-labelledby': labelId }} + /> + + + {row.id} + + + {row.name} + + + {row.player?.publisher?.name} + + + {row.description} + + + {!!row.liveEventSchedules.length && + new Date(row.liveEventSchedules[0].startTime).toLocaleString()}{' '} + {row.recurrent && row.recurrentEndDate > Date.now() && ( + + )} + + + {!!row.liveEventSchedules.length && + new Date(row.liveEventSchedules[row.liveEventSchedules.length - 1].endTime).toLocaleString()} + + + {row.updatedBy} + + + + + + {new Date(row.updatedAt).toLocaleString()} + + + {options.map((option) => { + const Icon = option.icon; + + return ( + + { + event.stopPropagation(); + + option.onClick(event); + }} + > + + + + ); + })} + + + ); + })} + +
    +
    + {!!liveEvents.length && ( + + )} +
    +
    + {isDialogOpen && ( + + Archive Event Confirmation + + Event will be archived permanently from library. Are you sure you would like to archive? + + + + + + + )} + {showEmbedPopup && ( + + )} + + ); +} + +export default LiveEventsList; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/styles.module.scss b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/styles.module.scss new file mode 100644 index 0000000..4d9cb70 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/LiveEventsTable/styles.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"styles_Wrapper__mTgpM","Table":"styles_Table__kLrJ0","Row":"styles_Row__M_trC","Cell":"styles_Cell__ozP56","Cell___date":"styles_Cell___date__ZzaHa","Icon":"styles_Icon__6y4Di","VisuallyHidden":"styles_VisuallyHidden__kA5YQ","CellSyncIndicator":"styles_CellSyncIndicator__aLYO8"}; \ No newline at end of file diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/components/index.jsx b/anyclip/src/modules/liveEvents/LiveEventsList/components/index.jsx new file mode 100644 index 0000000..ebcfc41 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/components/index.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import LiveEventsHeader from './LiveEventsHeader'; +import LiveEventsTable from './LiveEventsTable'; + +function liveEventsComponent() { + // todo: migrate to + return ( + <> + + + + ); +} + +export default liveEventsComponent; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/index.js b/anyclip/src/modules/liveEvents/LiveEventsList/index.js new file mode 100644 index 0000000..68db5e0 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/index.js @@ -0,0 +1,3 @@ +import LiveEventsList from './components'; + +export default LiveEventsList; diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/archiveLiveEvents.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/archiveLiveEvents.js new file mode 100644 index 0000000..7a5d109 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/archiveLiveEvents.js @@ -0,0 +1,47 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { queryParamsSelector } from '../selectors'; +import { archiveLiveEventsAction, getLiveEventsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + mutation liveEventsArchive($ids: [Int]) { + liveEventsArchive(ids: $ids) { + result { + status + message + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(archiveLiveEventsAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ids: action.payload, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + const query = queryParamsSelector(state$.value); + actions.push(of(getLiveEventsAction(query))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getEmbedCode.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getEmbedCode.js new file mode 100644 index 0000000..65f91c3 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getEmbedCode.js @@ -0,0 +1,42 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; + +import { getLiveEventEmbedCodeAction, setLiveEventEmbedCodeAction } from '../../../liveEvent/redux/slices'; +import { getEmbedCodeAction, setEmbedCodeAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getEventEmbedCode($eventId: Int!) { + getEventEmbedCode(eventId: $eventId) { + embedCode + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getEmbedCodeAction.type, getLiveEventEmbedCodeAction.type), + mergeMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + eventId: action.payload, + }, + }).pipe( + map(({ data, errors }) => { + if (!errors.length) { + const { embedCode } = data.getEventEmbedCode; + + return action.type === getLiveEventEmbedCodeAction.type + ? setLiveEventEmbedCodeAction(embedCode) + : setEmbedCodeAction(embedCode); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPlayers.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPlayers.js new file mode 100644 index 0000000..42415c2 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPlayers.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { TYPE_LIVE } from '@/modules/@common/constants/playerTypes'; + +import { getPlayersAction, setPlayersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + query getLiveEventPublisherPlayers($type: Int!) { + getLiveEventPublisherPlayers(type: $type){ + results { + id + name + alias + publisherId + urlPrefix + displayEmbedCode + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlayersAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap(() => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + type: TYPE_LIVE, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const players = data.getLiveEventPublisherPlayers.results + .map((player) => ({ + id: player.id, + name: player.alias || player.name, + })) + .sort((a, b) => a?.name?.localeCompare(b?.name)); + + actions.push(of(setPlayersAction(players))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPublishers.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPublishers.js new file mode 100644 index 0000000..6c1c65a --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEventPublishers.js @@ -0,0 +1,42 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getPublishersAction, setPublishersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + query getLiveEventPublishers { + getLiveEventPublishers{ + id + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPublishersAction.type), + filter(() => !!getToken()), + switchMap(() => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + removeLimit: true, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setPublishersAction(data.getLiveEventPublishers))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEvents.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEvents.js new file mode 100644 index 0000000..4ac7848 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/getLiveEvents.js @@ -0,0 +1,89 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { getLiveEventsAction, setLiveEventsAction, setTotalEventsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +const queryGQL = ` + query liveEventsSearch($search: String, $playerId: Int, $eventFilter: String, $publisherIds: [Int], $page: Int, $pageSize: Int, $sortBy: String, $sortOrder: String, $status: String) { + liveEventsSearch(search: $search, playerId: $playerId, eventFilter: $eventFilter, publisherIds: $publisherIds, page: $page, pageSize: $pageSize, sortBy: $sortBy, sortOrder: $sortOrder, status: $status){ + total + page + pageSize + results { + id + status + name + description + updatedAt + updatedBy + timezone + streamId + indicationText + indicationColor + playerId + guid + preEventText + postEventText + countdown + transitionText + preEventType + preEventId + postEventType + postEventId + player { + name, + id, + publisher { + name, + id + } + } + liveEventSchedules { + title, + startTime, + endTime, + durationIn + } + aspectRatio + status + recurrent, + recurrentEndDate, + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getLiveEventsAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap((action) => { + const { publisherId, ...variables } = action.payload; + + if (publisherId) { + variables.publisherIds = [publisherId]; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { total, results } = data.liveEventsSearch; + + actions.push(of(setTotalEventsAction(total)), of(setLiveEventsAction(results))); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/index.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/index.js new file mode 100644 index 0000000..b11fcdb --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/epics/index.js @@ -0,0 +1,15 @@ +import { combineEpics } from 'redux-observable'; + +import archiveLiveEvents from './archiveLiveEvents'; +import getEmbedCode from './getEmbedCode'; +import getLiveEventPlayers from './getLiveEventPlayers'; +import getLiveEventPublishers from './getLiveEventPublishers'; +import getLiveEvents from './getLiveEvents'; + +export default combineEpics( + getLiveEvents, + getEmbedCode, + archiveLiveEvents, + getLiveEventPlayers, + getLiveEventPublishers, +); diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/selectors/index.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/selectors/index.js new file mode 100644 index 0000000..c054fc8 --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/selectors/index.js @@ -0,0 +1,31 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const liveEventsSelector = (state) => state[nameSpace].liveEvents; +export const totalEventsSelector = (state) => state[nameSpace].totalEvents; +export const selectedEventsSelector = (state) => state[nameSpace].selectedEvents; +export const playersSelector = (state) => state[nameSpace].players; +export const publishersSelector = (state) => state[nameSpace].publishers; +export const embedCodeSelector = (state) => state[nameSpace].embedCode; +export const searchSelector = (state) => state[nameSpace].search; +export const pageSelector = (state) => state[nameSpace].page; +export const pageSizeSelector = (state) => state[nameSpace].pageSize; +export const sortBySelector = (state) => state[nameSpace].sortBy; +export const sortOrderSelector = (state) => state[nameSpace].sortOrder; +export const eventFilterSelector = (state) => state[nameSpace].eventFilter; +export const statusSelector = (state) => state[nameSpace].status; +export const publisherIdSelector = (state) => state[nameSpace].publisherId; +export const playerIdSelector = (state) => state[nameSpace].playerId; + +export const queryParamsSelector = (state) => ({ + search: state[nameSpace].search, + page: state[nameSpace].page, + pageSize: state[nameSpace].pageSize, + status: state[nameSpace].status, + sortBy: state[nameSpace].sortBy, + sortOrder: state[nameSpace].sortOrder, + playerId: state[nameSpace].playerId, + eventFilter: state[nameSpace].eventFilter, + publisherId: state[nameSpace].publisherId, +}); diff --git a/anyclip/src/modules/liveEvents/LiveEventsList/redux/slices/index.js b/anyclip/src/modules/liveEvents/LiveEventsList/redux/slices/index.js new file mode 100644 index 0000000..714836e --- /dev/null +++ b/anyclip/src/modules/liveEvents/LiveEventsList/redux/slices/index.js @@ -0,0 +1,75 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + liveEvents: [], + totalEvents: 0, + + selectedEvents: [], + + players: null, + publishers: [], + + embedCode: '', + + search: '', + page: 1, + pageSize: 15, + sortBy: 'updatedAt', + sortOrder: 'DESC', + eventFilter: null, + status: 'active', + publisherId: null, + playerId: null, +}; + +export const slice = createSlice({ + name: '@@LIVE_EVENTS/LIVE_EVENTS_LIST', + initialState, + + reducers: { + archiveLiveEventsAction: (state) => state, + getPlayersAction: (state) => state, + getPublishersAction: (state) => state, + getEmbedCodeAction: (state) => state, + getLiveEventsAction: (state, action) => { + Object.keys(action.payload ?? {}).forEach((key) => { + state[key] = action.payload[key]; + }); + state.selectedEvents = []; + }, + setLiveEventsAction: (state, action) => { + state.liveEvents = action.payload; + }, + setTotalEventsAction: (state, action) => { + state.totalEvents = action.payload; + }, + setSelectedEventsAction: (state, action) => { + state.selectedEvents = action.payload; + }, + setPlayersAction: (state, action) => { + state.players = action.payload; + }, + setPublishersAction: (state, action) => { + state.publishers = action.payload; + }, + setEmbedCodeAction: (state, action) => { + state.embedCode = action.payload; + }, + }, +}); + +export const { + archiveLiveEventsAction, + getEmbedCodeAction, + getLiveEventsAction, + getPlayersAction, + getPublishersAction, + setEmbedCodeAction, + setLiveEventsAction, + setPlayersAction, + setPublishersAction, + setSelectedEventsAction, + setTotalEventsAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/EventDelivery.module.scss b/anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/EventDelivery.module.scss new file mode 100644 index 0000000..45a1b54 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/EventDelivery.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"ProcessIconWrapper":"EventDelivery_ProcessIconWrapper__AEPGe","ProcessIcon___spin":"EventDelivery_ProcessIcon___spin__2xu9M","rotate":"EventDelivery_rotate__GBgrx"}; \ No newline at end of file diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/index.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/index.jsx new file mode 100644 index 0000000..cef1d18 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/EventDelivery/index.jsx @@ -0,0 +1,499 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import { + CheckCircleRounded, + ContentCopyRounded, + PendingRounded, + SensorsRounded, + SyncRounded, +} from '@mui/icons-material'; + +import { + EVENT_STATUS_ARCHIVED, + EXTERNAL_ENDPOINT, + FAILED, + IDLE, + INTERNAL_ENDPOINT, + MAX_FIELD_INPUT_SIZE, + PROCESSING, + RUNNING, + SAVED, +} from '../../constants'; +import languages, { booleanList } from '../../constants/livecc'; +import regions, { REGIONS } from '../../constants/regions'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { + deliverySelector, + errorsSelector, + eventStatusSelector, + idSelector, + isLoadingSelector, + liveCcEnabledSelector, + liveCcLangSelector, + nameSelector, + playerIdSelector, + regionSelector, + rtmpInputUrlSelector, + rtmpSecondaryInputUrlSelector, + rtmpSecondaryStreamKeySelector, + rtmpStreamKeySelector, + schedulesSelector, + statusSelector, + streamIdSelector, + urlSelector, +} from '../../redux/selectors'; +import { + cancelLiveStreamRequestAction, + createLiveEventEndpointAction, + createLiveEventFlowAction, + setLiveStreamDataAction, + testLiveEventEndPointAction, +} from '../../redux/slices'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { FormGroup, FormGroupTitle, FormRow, useFormSettings } from '@/modules/@common/Form'; +import useIsAllowedToCreateOrEdit from '../../hooks/useIsAllowedToCreateOrEdit'; +import { + Button, + Chip, + FormControl, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './EventDelivery.module.scss'; + +function EventDelivery(props) { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const isAllowedToCreateOrEdit = useIsAllowedToCreateOrEdit(); + const region = useSelector(regionSelector); + const delivery = useSelector(deliverySelector); + const rtmpInputUrl = useSelector(rtmpInputUrlSelector); + const rtmpStreamKey = useSelector(rtmpStreamKeySelector); + const url = useSelector(urlSelector); + const schedules = useSelector(schedulesSelector); + const status = useSelector(statusSelector); + const streamId = useSelector(streamIdSelector); + const playerId = useSelector(playerIdSelector); + const name = useSelector(nameSelector); + const id = useSelector(idSelector); + const isLoading = useSelector(isLoadingSelector); + const eventStatus = useSelector(eventStatusSelector); + const errors = useSelector(errorsSelector); + const rtmpSecondaryInputUrl = useSelector(rtmpSecondaryInputUrlSelector); + const rtmpSecondaryStreamKey = useSelector(rtmpSecondaryStreamKeySelector); + const liveCcEnabled = useSelector(liveCcEnabledSelector); + const liveCcLang = useSelector(liveCcLangSelector); + + const isArchived = eventStatus === EVENT_STATUS_ARCHIVED; + + const isRegionNotSupported = () => [REGIONS.nordics, REGIONS.apac, REGIONS.india].includes(region); + + const handleCreateEndpoint = () => { + if (!streamId && !id) { + dispatch(createLiveEventFlowAction()); + return; + } + dispatch(createLiveEventEndpointAction(streamId)); + }; + + const handleTestEndpoint = () => dispatch(testLiveEventEndPointAction()); + + useEffect(() => { + if (isRegionNotSupported()) { + props.handleOnChange('liveCcEnabled', false); + } + }, [region]); + + useEffect(() => { + dispatch(cancelLiveStreamRequestAction()); + + if (status === FAILED) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: 'Try to create endpoint again', + }), + ); + } else if (streamId && [PROCESSING, RUNNING, SAVED].includes(status) && delivery === INTERNAL_ENDPOINT) { + dispatch(setLiveStreamDataAction({ repeatRequest: true })); + } + + return () => dispatch(cancelLiveStreamRequestAction()); + }, [streamId, status]); + + const testEndpointProps = { + label: streamId && url ? 'Test Endpoint' : 'Create Endpoint', + disabled: + streamId && url + ? !isAllowedToCreateOrEdit || status !== IDLE || !streamId || isLoading || isArchived + : !isAllowedToCreateOrEdit || + schedules.length === 0 || + !playerId || + !name || + (url && streamId && status !== FAILED) || + [PROCESSING, SAVED, IDLE].includes(status) || + isLoading || + isArchived || + (liveCcEnabled && !liveCcLang) || + Object.keys(errors).some((key) => !errors[key].isValid), + onClick: streamId && url ? handleTestEndpoint : handleCreateEndpoint, + }; + + const { StatusIcon, statusIconColor, StatusChipIcon, statusChipColor, statusLabel, statusTooltip } = + { + [RUNNING]: { + statusLabel: 'Endpoint is deployed', + statusTooltip: 'live', + StatusIcon: SensorsRounded, + statusIconColor: 'success.main', + StatusChipIcon: CheckCircleRounded, + statusChipColor: 'success', + StatusLabel: null, + }, + [IDLE]: { + statusLabel: 'Endpoint is deployed', + statusTooltip: 'Idle', + StatusIcon: SyncRounded, + statusIconColor: 'shades.800', + StatusChipIcon: CheckCircleRounded, + statusChipColor: 'success', + StatusLabel: null, + }, + [PROCESSING]: { + statusLabel: url + ? 'Endpoint is going live; this may take up to 5 min' + : 'Endpoint is deploying; this can take up to 10 min', + statusTooltip: 'Processing', + StatusIcon: SyncRounded, + statusIconColor: 'primary.main', + StatusChipIcon: PendingRounded, + statusChipColor: 'primary', + StatusLabel: null, + }, + }[status] || {}; + + return ( + <> + + + + <> + {delivery === INTERNAL_ENDPOINT ? ( + <> + + + + + + + {liveCcEnabled && ( + + + + Required + + + + + )} + + + + {StatusIcon && ( + <> + + + + + + + } + /> + + + )} + + PRIMARY + + + + { + copyToClipboard(rtmpInputUrl).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'RTMP Input URL Copied Successfully', + }), + ); + }); + }} + > + + + + ), + }} + /> + + + + { + copyToClipboard(rtmpStreamKey).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'RTMP Stream Key Copied Successfully', + }), + ); + }); + }} + > + + + + ), + }} + /> + + + SECONDARY + + + + { + copyToClipboard(rtmpSecondaryInputUrl).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'RTMP Input URL Copied Successfully', + }), + ); + }); + }} + > + + + + ), + }} + /> + + + + { + copyToClipboard(rtmpSecondaryStreamKey).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'RTMP Stream Key Copied Successfully', + }), + ); + }); + }} + > + + + + ), + }} + /> + + + + + { + copyToClipboard(url).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'HLS Output URL Copied Successfully', + }), + ); + }); + }} + > + + + + ), + }} + /> + + + ) : ( + + props.handleOnChange('url', event.target.value)} + inputProps={{ + maxlength: MAX_FIELD_INPUT_SIZE, + }} + /> + + )} + + + ); +} + +EventDelivery.propTypes = { + handleOnChange: PropTypes.func.isRequired, +}; + +export default EventDelivery; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/EventPrePost/index.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/EventPrePost/index.jsx new file mode 100644 index 0000000..4d9a4b9 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/EventPrePost/index.jsx @@ -0,0 +1,247 @@ +import React, { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { InfoOutlined } from '@mui/icons-material'; + +import { + ASPECT_RATIO, + EVENT_STATUS_ARCHIVED, + EVENT_UX_TYPES_IMAGE, + EVENT_UX_TYPES_SAME, + MAX_FIELD_INPUT_SIZE, + POST_EVENT, + PRE_EVENT, +} from '../../constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { + countdownSelector, + eventStatusSelector, + playerAspectRatioSelector, + playerIdSelector, + postEventImagesSelector, + postEventTextSelector, + postEventTypeSelector, + preEventImagesSelector, + preEventTextSelector, + preEventTypeSelector, + transitionTextSelector, +} from '../../redux/selectors'; +import { getLiveEventDefaultImagesAction, uploadLiveEventImageFlowAction } from '../../redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { FormImageUploader, FormRow, useFormSettings } from '@/modules/@common/Form'; +import useIsAllowedToCreateOrEdit from '../../hooks/useIsAllowedToCreateOrEdit'; +import { Checkbox, FormControlLabel, MenuItem, Select, Stack, TextField, Tooltip, Typography } from '@/mui/components'; + +import noImage from '@/assets/img/no-image-portrait.svg'; + +function EventPrePost(props) { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const inputPreThumbRef = useRef(undefined); + const inputPostThumbRef = useRef(undefined); + const isAllowedToCreateOrEdit = useIsAllowedToCreateOrEdit(); + const preEventText = useSelector(preEventTextSelector); + const countdown = useSelector(countdownSelector); + const preEventType = useSelector(preEventTypeSelector); + const preEventImages = useSelector(preEventImagesSelector); + const postEventImages = useSelector(postEventImagesSelector); + const postEventText = useSelector(postEventTextSelector); + const postEventType = useSelector(postEventTypeSelector); + const transitionText = useSelector(transitionTextSelector); + const playerId = useSelector(playerIdSelector); + const playerAspectRatio = useSelector(playerAspectRatioSelector); + const eventStatus = useSelector(eventStatusSelector); + + const isArchived = eventStatus === EVENT_STATUS_ARCHIVED; + + const handleImageUpload = (image, type) => { + dispatch( + uploadLiveEventImageFlowAction({ + type, + image, + }), + ); + + if (inputPreThumbRef.current) { + inputPreThumbRef.current.value = ''; + } + if (inputPostThumbRef.current) { + inputPostThumbRef.current.value = ''; + } + }; + + useEffect(() => { + if (playerId && playerAspectRatio && !preEventImages?.length) { + const imagesAspectRatio = ASPECT_RATIO[playerAspectRatio]; + dispatch( + getLiveEventDefaultImagesAction({ + eventType: PRE_EVENT, + aspectRatio: imagesAspectRatio, + }), + ); + } + }, [playerId, preEventImages, playerAspectRatio]); + + const handleAddImage = (eventType) => { + const imagesAspectRatio = ASPECT_RATIO[playerAspectRatio]; + dispatch( + getLiveEventDefaultImagesAction({ + eventType, + aspectRatio: imagesAspectRatio, + }), + ); + }; + + const onValidate = (file) => { + const maxSize = 2 * 100 * 1000; // 2 Mb + const allowedExtensions = ['jpg', 'jpeg', 'png']; + + const extension = file.name.split('.').pop(); + + if (!allowedExtensions.some((ext) => ext === extension)) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: `Invalid file extension, valid only (${allowedExtensions.join(',')})`, + }), + ); + + return false; + } + + if (file.size > maxSize) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: 'File size exceeded the maximum size permitted', + }), + ); + return false; + } + + return true; + }; + + return ( + <> + + props.handleOnChange('countdown', !countdown)} + /> + } + label={ + + Show Countdown + + + + + } + /> + props.handleOnChange('preEventText', event.target.value)} + inputProps={{ + maxlength: MAX_FIELD_INPUT_SIZE, + }} + /> + + + + + {!!playerId && [EVENT_UX_TYPES_IMAGE].includes(preEventType) && ( + + handleImageUpload(file, PRE_EVENT)} + onRemove={() => handleAddImage(PRE_EVENT)} + /> + + )} + + props.handleOnChange('transitionText', e.target.value)} + inputProps={{ + maxlength: MAX_FIELD_INPUT_SIZE, + }} + /> + + + props.handleOnChange('postEventText', e.target.value)} + inputProps={{ + maxlength: MAX_FIELD_INPUT_SIZE, + }} + /> + + + + + {([EVENT_UX_TYPES_IMAGE].includes(postEventType) || + ([EVENT_UX_TYPES_IMAGE].includes(preEventType) && postEventType === EVENT_UX_TYPES_SAME)) && + !!playerId && ( + + handleImageUpload(file, POST_EVENT)} + onRemove={() => handleAddImage(POST_EVENT)} + /> + + )} + + ); +} + +EventPrePost.propTypes = { + handleOnChange: PropTypes.func.isRequired, +}; + +export default EventPrePost; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/EventSchedule.module.scss b/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/EventSchedule.module.scss new file mode 100644 index 0000000..2728e3d --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/EventSchedule.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Cell":"EventSchedule_Cell__b6qea","Cell___error":"EventSchedule_Cell___error__PmvoD","NotchedOutline___viewMode":"EventSchedule_NotchedOutline___viewMode__R33ZP","InputRoot___viewMode":"EventSchedule_InputRoot___viewMode__RBkuX","DatePicker":"EventSchedule_DatePicker__qzh37","ScheduleTitle":"EventSchedule_ScheduleTitle__u4Cys","ScheduleStartDate":"EventSchedule_ScheduleStartDate__BFCN8","ScheduleDuration":"EventSchedule_ScheduleDuration__yKRLa","ScheduleDurationIn":"EventSchedule_ScheduleDurationIn__ObvXk","ScheduleEndDate":"EventSchedule_ScheduleEndDate__CN3ZI","HelperText":"EventSchedule_HelperText__jtgSD"}; \ No newline at end of file diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/Recurring.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/Recurring.jsx new file mode 100644 index 0000000..eb5a38f --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/Recurring.jsx @@ -0,0 +1,262 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; + +import { DAY, EXTERNAL_ENDPOINT, MONTH, NON_RECURRING, WEEK } from '../../constants'; + +import validateField from '../../helpers/validation'; +import { + deliverySelector, + errorsSelector, + recurrentEndDateSelector, + recurrentPeriodSelector, +} from '../../redux/selectors'; +import { setErrorAction, updateLiveEventFormAction } from '../../redux/slices'; + +import { FormGroup, FormRow, useFormSettings } from '@/modules/@common/Form'; +import { + Button, + DatePicker, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, +} from '@/mui/components'; + +dayjs.extend(durationPlugin); + +const weekOfMonth = { + 1: 'first', + 2: 'second', + 3: 'third', + 4: 'fourth', + 5: 'last', +}; + +function Recurring({ eventStartDate = null, eventEndDate = null, ...props }) { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const recurrentPeriod = useSelector(recurrentPeriodSelector); + const recurrentEndDate = useSelector(recurrentEndDateSelector); + const errors = useSelector(errorsSelector); + const delivery = useSelector(deliverySelector); + const [isDialogOpen, setDialogOpen] = useState(false); + + useEffect(() => { + if (eventStartDate && !recurrentEndDate) { + props.handleOnChange('recurrentEndDate', dayjs(eventStartDate).add(1, 'year').toDate().getTime()); + } + }, [eventStartDate]); + + useEffect(() => { + if (!eventStartDate || !eventEndDate) { + return; + } + + validateField( + { + fieldName: 'recurrentPeriod', + fieldValue: recurrentPeriod, + }, + (params) => dispatch(setErrorAction(params)), + { + duration: Math.ceil(dayjs.duration(dayjs(eventEndDate).diff(dayjs(eventStartDate))).asDays()), + }, + ); + }, [eventStartDate, eventEndDate, recurrentPeriod]); + + useEffect(() => { + if (!eventEndDate || recurrentPeriod === NON_RECURRING) { + return; + } + + validateField( + { + fieldName: 'recurrentEndDate', + fieldValue: recurrentEndDate, + }, + (params) => dispatch(setErrorAction(params)), + { + recurrentPeriod, + timeDifference: Math.floor(dayjs.duration(dayjs(recurrentEndDate).diff(dayjs(eventEndDate))).asDays()), + daysInMonth: dayjs(eventEndDate).daysInMonth(), + }, + ); + }, [eventEndDate, recurrentPeriod, recurrentEndDate]); + + useEffect(() => { + if (delivery === EXTERNAL_ENDPOINT) { + dispatch( + updateLiveEventFormAction({ + recurrentPeriod: NON_RECURRING, + recurrentEndDate: 0, + }), + ); + } + }, [delivery]); + + const getRecurrentPeriod = (startTime, endTime) => { + if (!startTime || !endTime) return []; + + const labels = { + [NON_RECURRING]: 'Non recurring event', + [DAY]: 'Day', + [WEEK]: `Week on ${dayjs(startTime).format('dddd')}`, + + [MONTH]: `Month on the ${weekOfMonth[Math.ceil(dayjs(startTime).date() / 7)]} ${dayjs(startTime).format('dddd')}`, + }; + + const duration = Math.ceil(dayjs.duration(dayjs(endTime).diff(dayjs(startTime))).asDays()); + + if (duration > 1 && duration < 7) { + return [ + { + value: NON_RECURRING, + label: labels[NON_RECURRING], + }, + { + value: WEEK, + label: labels[WEEK], + }, + { + value: MONTH, + label: labels[MONTH], + }, + ]; + } + + if (duration > 7 && duration < dayjs(endTime).daysInMonth()) { + return [ + { + value: NON_RECURRING, + label: labels[NON_RECURRING], + }, + { + value: MONTH, + label: labels[MONTH], + }, + ]; + } + + return [ + { + value: NON_RECURRING, + label: labels[NON_RECURRING], + }, + { + value: DAY, + label: labels[DAY], + }, + { + value: WEEK, + label: labels[WEEK], + }, + { + value: MONTH, + label: labels[MONTH], + }, + ]; + }; + + const getMinEndDate = (endDate, recurrentPeriod$) => { + const daysInMonth = ![DAY, WEEK].includes(recurrentPeriod$) ? dayjs(endDate).daysInMonth() : 1; + const addType = recurrentPeriod$ === WEEK ? 'week' : 'day'; + + return dayjs(endDate).add(daysInMonth, addType).endOf('day').toDate().getTime(); + }; + + const handleChangeRecurrent = (value) => { + if (value === NON_RECURRING) { + return setDialogOpen(true); + } + + return props.handleOnChange('recurrentPeriod', value); + }; + + const handleConfirm = () => { + props.handleOnChange('recurrentPeriod', NON_RECURRING); + setDialogOpen(false); + }; + + return ( + + + + + Required + + + + + {recurrentPeriod !== NON_RECURRING && ( + + props.handleOnChange('recurrentEndDate', date.endOf('day').toDate().getTime())} + format="MM/DD/YYYY" + slotProps={{ + textField: { + required: true, + placeholder: 'Enter End Date', + }, + }} + /> + + )} + {isDialogOpen && ( + setDialogOpen(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + setDialogOpen(false)}>Recurring events + Are you sure you would like to cancel all future recurring events? + + + + + + )} + + ); +} + +Recurring.propTypes = { + handleOnChange: PropTypes.func.isRequired, + eventStartDate: PropTypes.number, + eventEndDate: PropTypes.number, + disabled: PropTypes.bool.isRequired, +}; + +export default Recurring; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/index.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/index.jsx new file mode 100644 index 0000000..3cee20a --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/EventSchedule/index.jsx @@ -0,0 +1,561 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useRouter } from 'next/router'; +import { AddRounded, CheckRounded, CloseRounded, DeleteRounded, EditRounded } from '@mui/icons-material'; + +import { EVENT_STATUS_ARCHIVED, EXTERNAL_ENDPOINT } from '../../constants'; + +import timezones from '../../helpers/timezones'; +import { getMinStartDate, getTimeWithoutS, validateSchedules } from '../../helpers/validation'; +import { + deliverySelector, + eventStatusSelector, + isTimezoneDisabledSelector, + schedulesSelector, + timezoneSelector, +} from '../../redux/selectors'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import useIsAllowedToCreateOrEdit from '../../hooks/useIsAllowedToCreateOrEdit'; +import Recurring from './Recurring'; +import { + Button, + DateTimePicker, + IconButton, + MenuItem, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableScroll, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './EventSchedule.module.scss'; + +dayjs.extend(durationPlugin); +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +function EventSchedule(props) { + const { size } = useFormSettings(); + const router = useRouter(); + const isAllowedToCreateOrEdit = useIsAllowedToCreateOrEdit(); + const timezone = useSelector(timezoneSelector); + const schedules = useSelector(schedulesSelector); + const eventStatus = useSelector(eventStatusSelector); + const isTimezoneDisabled = useSelector(isTimezoneDisabledSelector); + const delivery = useSelector(deliverySelector); + + const { id: existedEventId } = router.query; + const isArchived = eventStatus === EVENT_STATUS_ARCHIVED; + + const getEmptySchedule = () => ({ + title: '', + startTime: getMinStartDate(), + duration: 1, + durationIn: 'HOURS', + endTime: getMinStartDate() + 60 * 60 * 1000, + isEditDisabled: false, + isValid: true, + errors: {}, + }); + + const [editingSchedules, setEditingSchedules] = useState([getEmptySchedule()]); + const [editingSchedule, setEditingSchedule] = useState(getEmptySchedule()); + const [editMode, setEditMode] = useState({ 0: false }); + const [minStartDate, setMinStartDate] = useState(getMinStartDate()); + + useEffect(() => { + setEditMode({ + 0: !existedEventId && !schedules.length, + }); + }, []); + + useEffect(() => { + if (schedules.length) { + setEditingSchedules([ + ...schedules + .map((schedule) => { + const modifiedSchedule = { ...schedule }; + modifiedSchedule.duration = dayjs + .duration((schedule?.endTime ?? 0) - (schedule?.startTime ?? 0)) + .as(schedule?.durationIn); + + return { ...modifiedSchedule }; + }) + .sort((a, b) => a.startTime < b.startTime), + ]); + } + }, [schedules]); + + const timezoneChange = (e) => { + if (schedules.length) { + const shiftedSchedules = [ + ...schedules.map((schedule$) => { + const offsetBefore = dayjs.tz(schedule$.startTime, timezone).utcOffset(); + const offsetAfter = dayjs.tz(schedule$.startTime, e.target.value).utcOffset(); + let offsetShift = 0; + + if (offsetBefore === 0) { + offsetShift += offsetAfter; + } else { + offsetShift = offsetAfter - offsetBefore; + } + + return { + ...schedule$, + startTime: schedule$.startTime - offsetShift * 60 * 1000, + endTime: schedule$.endTime - offsetShift * 60 * 1000, + }; + }), + ]; + + props.handleOnChange('schedules', [...validateSchedules(shiftedSchedules)]); + } else { + const offsetBefore = dayjs.tz(editingSchedules[0].startTime.startTime, timezone).utcOffset(); + const offsetAfter = dayjs.tz(editingSchedules[0].startTime.startTime, e.target.value).utcOffset(); + let offsetShift = 0; + + if (offsetBefore === 0) { + offsetShift += offsetAfter; + } else { + offsetShift = offsetAfter - offsetBefore; + } + + setEditingSchedules([ + { + ...editingSchedules[0], + startTime: editingSchedules[0].startTime - offsetShift * 60 * 1000, + endTime: editingSchedules[0].endTime - offsetShift * 60 * 1000, + }, + ]); + } + props.handleOnChange('timezone', e.target.value); + }; + + const addSchedule = () => { + const schedule = { + ...getEmptySchedule(), + }; + + const schedules$ = [...editingSchedules]; + + schedules$.push({ + ...schedule, + }); + + setEditingSchedule({ ...schedule }); + + setEditMode({ + [schedules$.length - 1]: true, + }); + + setEditingSchedules([...schedules$]); + + setMinStartDate(getMinStartDate()); + }; + + const deleteSchedule = (index) => { + const schedules$ = [...editingSchedules]; + schedules$.splice(index, 1); + + const validatedSchedules = validateSchedules(schedules$.sort((a, b) => a.startTime - b.startTime)); + + props.handleOnChange('schedules', [...validatedSchedules]); + }; + + const editSchedule = (index) => { + if (schedules[index]) { + setEditingSchedule({ + ...schedules[index], + }); + } + + setEditMode({ + [index]: true, + }); + setMinStartDate(getMinStartDate()); + }; + + const cancelEditSchedule = (index) => { + const schedules$ = [...editingSchedules]; + setEditMode({ + [index]: false, + }); + + if (editingSchedule.startTime !== null) { + schedules$[index] = { ...editingSchedule }; + } else { + schedules$.splice(index, 1); + } + setEditingSchedules([...schedules$]); + }; + + const endTimeChange = (value, index) => { + const schedules$ = [...editingSchedules]; + const { startTime } = editingSchedules[index]; + + if (startTime) { + const delta = schedules$[index].durationIn === 'HOURS' ? value * 60 * 60 * 1000 : value * 60 * 1000; + schedules$[index].endTime = startTime + delta; + } + + return setEditingSchedules([...schedules$]); + }; + + const durationTypeChange = (durationType, index) => { + const schedules$ = [...editingSchedules]; + const { duration, startTime } = schedules$[index]; + const resDuration = durationType === 'MINUTES' ? duration * 60 : Math.floor(duration / 60); + schedules$[index].duration = resDuration; + schedules$[index].durationIn = durationType; + + if (startTime) { + const delta = schedules$[index].durationIn === 'HOURS' ? resDuration * 60 * 60 * 1000 : resDuration * 60 * 1000; + schedules$[index].endTime = startTime + delta; + } + + return setEditingSchedules([...schedules$]); + }; + + const startTimeChange = (value, index) => { + const schedules$ = [...editingSchedules]; + const { duration } = schedules$[index]; + schedules$[index].startTime = getTimeWithoutS(value); + const delta = schedules$[index].durationIn === 'HOURS' ? duration * 60 * 60 * 1000 : duration * 60 * 1000; + schedules$[index].endTime = getTimeWithoutS(value + delta); + + return setEditingSchedules([...schedules$]); + }; + + const titleChange = (index, value) => { + const schedules$ = [...editingSchedules]; + schedules$[index].title = value; + + return setEditingSchedules([...schedules$]); + }; + + const saveSchedule = (index) => { + setEditMode({ + [index]: false, + }); + + const validatedSchedules = validateSchedules(editingSchedules.sort((a, b) => a.startTime - b.startTime)); + + props.handleOnChange('schedules', [...validatedSchedules]); + }; + + return ( + <> + + + + + + + + + + + + + # + Title + Start + Duration + End + + + + + {editingSchedules.map((schedule, index) => { + const isViewMode = !editMode[index]; + const key = index; + + return ( + + + {index + 1} + + + titleChange(index, e.target.value)} + error={!schedule.isValid && !!schedule.errors.title} + helperText={!schedule.isValid && schedule.errors.title} + /> + + +
    + startTimeChange(date.toDate().getTime(), index)} + minDate={dayjs.tz(minStartDate, timezone)} + format="DD/MM/YYYY hh:mm A" + slotProps={{ + textField: { + onBlur: ({ target: { value } }) => { + if (!isViewMode) { + const [day, month, year, hour, minute, period] = value.split(/[\s/:]+/); + + // Adjust hour based on AM/PM + let adjustedHour = + period === 'PM' && hour !== '12' ? parseInt(hour, 10) + 12 : hour; + adjustedHour = period === 'AM' && hour === '12' ? '00' : adjustedHour; + + const date = new Date(year, month - 1, day, adjustedHour, minute); + const timestamp = date.getTime(); + + startTimeChange(timestamp, index); + } + }, + size: 'small', + shape: 'square', + InputProps: { + classes: { + root: classNames(styles.InputRoot, { + [styles.InputRoot___viewMode]: isViewMode, + }), + notchedOutline: classNames(styles.NotchedOutline, { + [styles.NotchedOutline___viewMode]: isViewMode, + }), + }, + }, + FormHelperTextProps: { + classes: { + root: styles.HelperText, + }, + }, + error: !schedule.isValid && !!schedule.errors.startTime, + helperText: !schedule.isValid && schedule.errors.startTime, + }, + }} + /> +
    +
    + + + { + editingSchedules[index].duration = e.target.value; + return setEditingSchedules([...editingSchedules]); + }} + onBlur={(e) => endTimeChange(e.target.value, index)} + error={!schedule.isValid && !!schedule.errors.duration} + /> + + + {!schedule.isValid && ( + + {schedule.errors.duration} + + )} + + + + + + + saveSchedule(index) : () => editSchedule(index)} + > + {editMode[index] ? : } + + cancelEditSchedule(index) : () => deleteSchedule(index)} + disabled={ + !isAllowedToCreateOrEdit || + editingSchedules.length <= 1 || + schedule?.isEditDisabled || + isArchived + } + > + {editMode[index] ? : } + + + +
    + ); + })} +
    +
    +
    +
    +
    + + + + ); +} + +EventSchedule.propTypes = { + handleOnChange: PropTypes.func.isRequired, +}; + +export default EventSchedule; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/EventSetting/index.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/EventSetting/index.jsx new file mode 100644 index 0000000..08ea913 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/EventSetting/index.jsx @@ -0,0 +1,265 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { useTheme } from '@mui/material/styles'; + +import { + ASPECT_RATIO, + EVENT_STATUS_ARCHIVED, + MAX_FIELD_INDICATOR_TEXT, + MAX_FIELD_INPUT_SIZE, + PRE_EVENT, +} from '../../constants'; + +import { + descriptionSelector, + errorsSelector, + eventStatusSelector, + guidSelector, + indicationColorSelector, + indicationTextSelector, + nameSelector, + playerAspectRatioSelector, + playerIdSelector, + playersSelector, + preEventImagesSelector, + publisherIdSelector, + publisherSelector, + publishersSelector, +} from '../../redux/selectors'; +import { getLiveEventDefaultImagesAction, getPublishersAction, updateLiveEventFormAction } from '../../redux/slices'; + +import { FormRow, FormRowItem, useFormSettings } from '@/modules/@common/Form'; +import useIsAllowedToCreateOrEdit from '../../hooks/useIsAllowedToCreateOrEdit'; +import { + Autocomplete, + ButtonColorPicker, + Chip, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, +} from '@/mui/components'; + +function EventSetting(props) { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + const name = useSelector(nameSelector); + const description = useSelector(descriptionSelector); + const indicationText = useSelector(indicationTextSelector); + const indicationColor = useSelector(indicationColorSelector); + const publishers = useSelector(publishersSelector); + const publisher = useSelector(publisherSelector); + const publisherId = useSelector(publisherIdSelector); + const playerId = useSelector(playerIdSelector); + const players = useSelector(playersSelector); + const playerAspectRatio = useSelector(playerAspectRatioSelector); + const guid = useSelector(guidSelector); + const errors = useSelector(errorsSelector); + const preEventImages = useSelector(preEventImagesSelector); + const eventStatus = useSelector(eventStatusSelector); + + const { id } = router.query; + const existedEventId = parseInt(id, 10); + + const theme = useTheme(); + + const isArchived = eventStatus === EVENT_STATUS_ARCHIVED; + + const isAllowedToCreateOrEdit = useIsAllowedToCreateOrEdit(); + + useEffect(() => { + dispatch(getPublishersAction()); + }, []); + + useEffect(() => { + if (playerId) { + const player = players.find((item) => item.id === playerId); + dispatch( + updateLiveEventFormAction({ + player, + playerAspectRatio: player?.playerAspectRatio, + }), + ); + } + + if (playerId && playerAspectRatio && !preEventImages?.length) { + const imagesAspectRatio = ASPECT_RATIO[playerAspectRatio]; + dispatch( + getLiveEventDefaultImagesAction({ + eventType: PRE_EVENT, + aspectRatio: imagesAspectRatio, + }), + ); + } + }, [preEventImages, playerId, playerAspectRatio, players]); + + return ( + <> + + props.handleOnChange('name', e.target.value)} + inputProps={{ + maxlength: MAX_FIELD_INPUT_SIZE, + }} + /> + + + props.handleOnChange('description', e.target.value)} + inputProps={{ + maxlength: MAX_FIELD_INPUT_SIZE, + }} + /> + + + + { + const { value } = target; + if (value.length <= MAX_FIELD_INDICATOR_TEXT) { + props.handleOnChange('indicationText', value); + } + }} + inputProps={{ + maxlength: MAX_FIELD_INDICATOR_TEXT, + }} + /> + + + + + + props.handleOnChange('indicationColor', hex)} + /> + + + + {!existedEventId && ( + + pub.id === publisherId) || publisher} + options={publishers} + onOpen={() => dispatch(getPublishersAction())} + onChange={(_, newValue) => { + props.handleOnChange('publisherId', newValue?.id); + dispatch( + updateLiveEventFormAction({ + playerId: 0, + }), + ); + }} + renderInput={(params) => ( + dispatch(getPublishersAction())} + label="Required" + required + /> + )} + /> + + )} + {!!publisherId && ( + + + + Required + + + + Aspect Ratio + + + + + )} + + props.handleOnChange('guid', event.target.value)} + /> + + + ); +} + +EventSetting.propTypes = { + handleOnChange: PropTypes.func.isRequired, +}; + +export default EventSetting; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ForсePoster/ForсePoster.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ForсePoster/ForсePoster.jsx new file mode 100644 index 0000000..75f3ccc --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ForсePoster/ForсePoster.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { FORCE_POSTER_OPTIONS, SAME_AS_PRE_EVENT } from '../../../../constants/forcePoster'; + +import { forcePosterSelector, forcePosterTypeSelector } from '../../../../redux/selectors'; +import { updateForcePosterStatusAction, updateLiveEventFormAction } from '../../../../redux/slices'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import useIsAllowedToCreateOrEdit from '../../../../hooks/useIsAllowedToCreateOrEdit'; +import { MenuItem, Select, Switch } from '@/mui/components'; + +function ForcePoster() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const forcePoster = useSelector(forcePosterSelector); + const forcePosterType = useSelector(forcePosterTypeSelector); + + const handleChangeForcePosterState = ({ target }) => { + dispatch( + updateLiveEventFormAction({ + forcePoster: target.checked, + forcePosterType: target.checked ? SAME_AS_PRE_EVENT : null, + }), + ); + + dispatch(updateForcePosterStatusAction()); + }; + + const handleChangeForcePosterType = ({ target }) => { + dispatch( + updateLiveEventFormAction({ + forcePosterType: target.value, + }), + ); + + dispatch(updateForcePosterStatusAction()); + }; + + return ( + + + {forcePoster && ( + + )} + + ); +} + +export default ForcePoster; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.jsx new file mode 100644 index 0000000..e93cc51 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.jsx @@ -0,0 +1,54 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +import { getPlayerEndpoint } from '@/modules/@common/PlayerWidget/helpers'; + +import styles from './Preview.module.scss'; + +const playerContainerId = 'player-container'; + +function Preview() { + const router = useRouter(); + + const options = router.query; + + useEffect(() => { + document.body.style.overflow = 'hidden'; + const playerContainer = document.getElementById(playerContainerId); + + const script = document.createElement('script'); + + script.setAttribute('pubname', options.publisher); + script.setAttribute('widgetname', options.widget); + script.setAttribute('data-tm-leid', options.id); + script.setAttribute('ac-embed-mode', 'in-iframe'); + + script.src = getPlayerEndpoint(); + + playerContainer.appendChild(script); + + const messageSubscribeFn = (event) => { + if (event.origin === window.location.origin) { + const reg = /lre:playerReady:\/\//; + + if (reg.test(event.data)) { + const { sessionId } = JSON.parse(event.data.replace(reg, '')); + const playerWidget = window.anyclip.getWidget(null, sessionId); + + window.dispatchEvent(new CustomEvent('PlayerLiveReady', { detail: playerWidget })); + } + } + }; + + window.addEventListener('message', messageSubscribeFn); + + return () => { + window.removeEventListener('message', messageSubscribeFn); + playerContainer.innerHTML = ''; + }; + }, []); + + return
    ; +} + +export default Preview; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.module.scss b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.module.scss new file mode 100644 index 0000000..2486034 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/Preview/Preview.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Preview_Wrapper__r0kZt"}; \ No newline at end of file diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ViewersCounter/ViewersCounter.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ViewersCounter/ViewersCounter.jsx new file mode 100644 index 0000000..9b26080 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/components/ViewersCounter/ViewersCounter.jsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + getViewersCounterAction, + updateLiveEventFormAction, + updateViewersCounterStatusAction, +} from '../../../../redux/slices'; +import { abbreviateNumber } from '@/modules/@common/helpers/number'; +import { displayViewersCounterSelector, viewersAmountSelector } from '@/modules/liveEvents/liveEvent/redux/selectors'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import useIsAllowedToCreateOrEdit from '@/modules/liveEvents/liveEvent/hooks/useIsAllowedToCreateOrEdit'; +import { FormControlLabel, Switch } from '@/mui/components'; + +const formatViewersCount = (viewers) => (typeof viewers === 'number' ? abbreviateNumber(viewers) : 'N/A'); + +function ViewersCounter() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const isAllowedToCreateOrEdit = useIsAllowedToCreateOrEdit(); + const displayViewersCounter = useSelector(displayViewersCounterSelector); + const viewersAmount = useSelector(viewersAmountSelector); + + useEffect(() => { + dispatch(getViewersCounterAction()); + + const getViewersInterval = setInterval(() => dispatch(getViewersCounterAction()), 60000); + + return () => { + clearInterval(getViewersInterval); + }; + }, []); + + return ( + + { + dispatch( + updateLiveEventFormAction({ + displayViewersCounter: !displayViewersCounter, + }), + ); + dispatch(updateViewersCounterStatusAction()); + }} + /> + } + label="Display on Player" + /> + + ); +} + +export default ViewersCounter; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/index.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/index.jsx new file mode 100644 index 0000000..88c5641 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/index.jsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Paper } from '@/mui/components'; + +import styles from './styles.module.scss'; + +function PlayerPreview({ + id, + onWidgetUpdate, + conf = null, + publisherId = 'lre_demo_page', + widgetId = 'widget_noAds_TR', +}) { + const iframeRef = useRef(null); + const [iframeHeight, setIframeHeight] = useState('auto'); + + useEffect(() => { + const timer = setInterval(() => { + const body = iframeRef.current?.contentWindow?.document?.body; + + if (body) { + setIframeHeight(body.scrollHeight ?? 'auto'); + } + }, 500); + + return () => { + clearInterval(timer); + }; + }, []); + + return ( + { + event.target.contentWindow.ac_lre_conf_override = { ...conf }; + + event.target.contentWindow.addEventListener('PlayerLiveReady', (e) => onWidgetUpdate(e.detail)); + }} + /> + ); +} + +PlayerPreview.propTypes = { + conf: PropTypes.shape({}).isRequired, + id: PropTypes.string.isRequired, + publisherId: PropTypes.string, + widgetId: PropTypes.string, + onWidgetUpdate: PropTypes.func.isRequired, +}; + +export default PlayerPreview; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/styles.module.scss b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/styles.module.scss new file mode 100644 index 0000000..fd03f5e --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/PlayerPreview/styles.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"PlayerIframe":"styles_PlayerIframe__BCXhp"}; \ No newline at end of file diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/index.jsx b/anyclip/src/modules/liveEvents/liveEvent/components/index.jsx new file mode 100644 index 0000000..7eb5ffc --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/index.jsx @@ -0,0 +1,555 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'next/navigation'; +import { useRouter } from 'next/router'; +import { CodeRounded } from '@mui/icons-material'; + +import { + ASPECT_RATIO, + EVENT_STATUS_ARCHIVED, + EVENT_UX_TYPES_IMAGE, + EVENT_UX_TYPES_RECOMMENDED, + EVENT_UX_TYPES_SAME, + INTERNAL_ENDPOINT, + POST_EVENT, + PRE_EVENT, + TAB_EVENT_DELIVERY, + TAB_EVENT_DETAILS, + TAB_EVENT_PRE_POST, + TAB_EVENT_SCHEDULE, +} from '../constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import validateField, { getTimeWithoutS } from '../helpers/validation'; +import { + activeTabIdSelector, + aspectRatioSelector, + createdPostEventPlaylistSelector, + createdPreEventPlaylistSelector, + deliverySelector, + embedCodeSelector, + errorsSelector, + eventStatusSelector, + idSelector, + isEditContinueSelector, + isLoadingSelector, + liveCcEnabledSelector, + liveCcLangSelector, + liveStreamRequestFailedSelector, + nameSelector, + playerAspectRatioSelector, + playerConfigSelector, + playerIdSelector, + playerSelector, + postEventIdSelector, + postEventImagesSelector, + postEventTypeSelector, + preEventIdSelector, + preEventImagesSelector, + preEventTypeSelector, + publisherIdSelector, + publishersSelector, + recurrentEndDateSelector, + recurrentSelector, + schedulesSelector, + tmPlaylistSelector, + urlSelector, +} from '../redux/selectors'; +import { + clearFormAction, + createLiveEventFlowAction, + deleteLiveEventPlaylistAction, + getLiveEventByIdAction, + getLiveEventContentOwnerAction, + getLiveEventDefaultImagesAction, + getLiveEventEmbedCodeAction, + getLiveEventImageAction, + getPlayerConfigAction, + getPublisherInfoByIdAction, + getPublisherPlayersAction, + setActiveTabIdAction, + setErrorAction, + updateLiveEventFlowAction, + updateLiveEventFormAction, +} from '../redux/slices'; +import { getPlaylistApiEndpoint } from '@/modules/@common/PlayerWidget/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import EmbedCodePopup from '@/modules/@common/EmbedCodePopup'; +import { Form, FormContent, FormGroup, FormRow, FormSection } from '@/modules/@common/Form'; +import useForceReload from '../hooks/useForceReload'; +import useIsAllowedToCreateOrEdit from '../hooks/useIsAllowedToCreateOrEdit'; +import EventDelivery from './EventDelivery'; +import EventPrePost from './EventPrePost'; +import EventSchedule from './EventSchedule'; +import EventSetting from './EventSetting'; +import PlayerPreview from './PlayerPreview'; +import ForcePoster from './PlayerPreview/components/ForсePoster/ForсePoster'; +import ViewersCounter from './PlayerPreview/components/ViewersCounter/ViewersCounter'; +import { Button, Divider, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './styles.module.scss'; + +const LIVE_EVENT_DISABLED_STATUS = -1; + +function LiveEvent() { + const dispatch = useDispatch(); + const router = useRouter(); + const { id: paramsId } = useParams(); + const id = useSelector(idSelector) ?? 0; + const name = useSelector(nameSelector); + const url = useSelector(urlSelector); + const publisherId = useSelector(publisherIdSelector); + const playerId = useSelector(playerIdSelector); + const playerAspectRatio = useSelector(playerAspectRatioSelector); + const preEventType = useSelector(preEventTypeSelector); + const preEventId = useSelector(preEventIdSelector); + const postEventType = useSelector(postEventTypeSelector); + const postEventId = useSelector(postEventIdSelector); + const errors = useSelector(errorsSelector); + const isEditContinue = useSelector(isEditContinueSelector); + const embedCode = useSelector(embedCodeSelector); + const preEventImages = useSelector(preEventImagesSelector); + const postEventImages = useSelector(postEventImagesSelector); + const activeTabId = useSelector(activeTabIdSelector); + const createdPreEventPlaylist = useSelector(createdPreEventPlaylistSelector); + const createdPostEventPlaylist = useSelector(createdPostEventPlaylistSelector); + const isLoading = useSelector(isLoadingSelector); + const playerConfig = useSelector(playerConfigSelector); + const eventStatus = useSelector(eventStatusSelector); + const liveStreamRequestFailed = useSelector(liveStreamRequestFailedSelector); + const schedules = useSelector(schedulesSelector); + const publishers = useSelector(publishersSelector); + const liveCcEnabled = useSelector(liveCcEnabledSelector); + const liveCcLang = useSelector(liveCcLangSelector); + const recurrentEndDate = useSelector(recurrentEndDateSelector); + const delivery = useSelector(deliverySelector); + const tmPlaylist = useSelector(tmPlaylistSelector); + const aspectRatio = useSelector(aspectRatioSelector); + const player = useSelector(playerSelector); + const recurrent = useSelector(recurrentSelector); + const [playerWidget, setPlayerWidget] = useState(null); + + const [force, reload] = useForceReload(100); + + const { id: existedEventId } = router.query; + const isArchived = eventStatus === EVENT_STATUS_ARCHIVED; + const [showEmbedPopup, setShowEmbedPopup] = useState(false); + + const copiedEventId = router.query.copied; + const liveEventId = +existedEventId || copiedEventId; + + const isAllowedToCreateOrEdit = useIsAllowedToCreateOrEdit(); + + const updatePlayerPreview = (fieldName, fieldValue) => { + if (playerWidget && tmPlaylist) { + const tmPlaylist$ = { + geo: { + ...tmPlaylist.geo, + }, + liveEvent: { + ...tmPlaylist.liveEvent, + }, + userAgent: { + ...tmPlaylist.userAgent, + }, + }; + + if (fieldValue) { + tmPlaylist$.liveEvent[fieldName] = fieldValue; + } + + // if (fieldName === 'url') { + // tmPlaylist$.liveEvent.liveUrl = fieldName; + // } + + if (fieldName === 'poster' && preEventImages.length) { + tmPlaylist$.liveEvent.prePoster = [[...preEventImages]]; + } + + if (fieldName === 'poster' && postEventImages.length) { + tmPlaylist$.liveEvent.postPoster = [[...postEventImages]]; + } + + if (playerWidget.setExternalPlaylistResponse) { + playerWidget.setExternalPlaylistResponse(tmPlaylist$); + } + + dispatch( + updateLiveEventFormAction({ + tmPlaylist: tmPlaylist$, + }), + ); + } + }; + + useEffect(() => { + if (publisherId) { + dispatch(getPublisherInfoByIdAction(publisherId)); + } + }, [publisherId]); + + useEffect(() => () => dispatch(clearFormAction()), []); + + useEffect(() => { + updatePlayerPreview('poster'); + }, [preEventImages, postEventImages, playerWidget]); + + useEffect(() => { + updatePlayerPreview('aspectRatio', aspectRatio); + }, [aspectRatio]); + + const handleEmbedLiveEvent = (eventId) => { + setShowEmbedPopup(true); + dispatch(getLiveEventEmbedCodeAction(eventId)); + }; + + const handleOnSaveClick = () => { + if (!schedules[0].isEditDisabled && schedules[0].startTime + 5 * 60 * 1000 < getTimeWithoutS(Date.now())) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: 'The event may not be set more than 5 minutes in the past', + }), + ); + return; + } + + if (id) { + dispatch(updateLiveEventFlowAction()); + return; + } + + dispatch(createLiveEventFlowAction()); + }; + + const handleOnChange = (fieldName, fieldValue) => { + if (fieldName === 'description' && fieldValue.length > 250) { + return; + } + dispatch( + updateLiveEventFormAction({ + [fieldName]: fieldValue, + }), + ); + + if ( + [ + 'preEventText', + 'postEventText', + 'transitionText', + 'countdown', + 'indicationText', + 'indicationColor', + 'name', + ].includes(fieldName) + ) { + updatePlayerPreview(fieldName, fieldValue); + reload(); + } + + if (['name', 'url', 'playerId'].includes(fieldName)) { + validateField( + { + fieldName, + fieldValue, + }, + (params) => dispatch(setErrorAction(params)), + ); + } + + if (['preEventType', 'postEventType'].includes(fieldName)) { + const fieldIdToUpdate = fieldName === 'preEventType' ? 'preEventId' : 'postEventId'; + + if (fieldValue === EVENT_UX_TYPES_RECOMMENDED) { + dispatch( + updateLiveEventFormAction({ + [fieldIdToUpdate]: null, + }), + ); + } + + if (fieldValue === EVENT_UX_TYPES_IMAGE) { + const type = fieldName === 'preEventType' ? PRE_EVENT : POST_EVENT; + const imagesAspectRatio = ASPECT_RATIO[playerAspectRatio]; + dispatch( + updateLiveEventFormAction({ + [fieldIdToUpdate]: null, + }), + ); + dispatch( + getLiveEventDefaultImagesAction({ + eventType: type, + aspectRatio: imagesAspectRatio, + }), + ); + } + + if (fieldValue === EVENT_UX_TYPES_SAME) { + dispatch( + updateLiveEventFormAction({ + postEventId: preEventId, + postEventImages: preEventImages, + }), + ); + } + } + }; + + const tabs = [ + { + title: 'General', + id: TAB_EVENT_DETAILS, + dataId: 'eventSetting', + content: EventSetting, + }, + { + title: 'Schedule', + id: TAB_EVENT_SCHEDULE, + dataId: 'eventSchedule', + content: EventSchedule, + }, + { + title: 'Delivery', + id: TAB_EVENT_DELIVERY, + dataId: 'eventDelivery', + content: EventDelivery, + }, + { + title: 'Pre/Post', + id: TAB_EVENT_PRE_POST, + dataId: 'eventPrePost', + content: EventPrePost, + }, + ]; + + useEffect(() => { + if (!isEditContinue && liveEventId) { + dispatch( + getLiveEventByIdAction({ + id: +liveEventId, + copy: !!copiedEventId, + publisherId, + }), + ); + } + }, [liveEventId]); + + useEffect(() => { + if (publisherId) { + dispatch( + getPublisherPlayersAction({ + publisherId, + }), + ); + dispatch( + getLiveEventContentOwnerAction({ + publisherId, + }), + ); + } + + if (preEventId && preEventType === EVENT_UX_TYPES_IMAGE && !preEventImages?.length) { + dispatch( + getLiveEventImageAction({ + id: preEventId, + type: PRE_EVENT, + }), + ); + } + + if (postEventId && postEventType === EVENT_UX_TYPES_IMAGE && !postEventImages?.length) { + dispatch( + getLiveEventImageAction({ + id: postEventId, + type: POST_EVENT, + }), + ); + } + }, [publisherId, preEventType, postEventType, preEventImages]); + + useEffect(() => { + if (!playerConfig && !!playerId && !!id && !!url && !!player && publishers.length) { + dispatch(getPlayerConfigAction()); + } + }, [playerId, playerConfig, id, url, liveStreamRequestFailed, player, publishers]); + + useEffect(() => { + if ( + (postEventId === preEventId && preEventType === postEventType) || + (!postEventId && !preEventId && postEventType === preEventType) || + postEventType === EVENT_UX_TYPES_SAME + ) { + dispatch( + updateLiveEventFormAction({ + postEventType: EVENT_UX_TYPES_SAME, + postEventId: preEventId, + postEventImages: preEventImages, + }), + ); + } + }, [preEventId, postEventId, preEventImages, postEventType]); + + useEffect(() => { + if (!liveEventId) { + dispatch(clearFormAction()); + } + }, [router, liveEventId]); + + const handleClose = () => { + router.push('/live'); + + if (createdPreEventPlaylist) { + dispatch( + deleteLiveEventPlaylistAction({ + id: +preEventId, + publisherId, + }), + ); + } + if (createdPostEventPlaylist) { + dispatch( + deleteLiveEventPlaylistAction({ + id: +postEventId, + publisherId, + }), + ); + } + dispatch(clearFormAction()); + }; + + const isSaveDisabled = () => + [name, url, playerId, publisherId].some((item) => !item) || + Object.keys(errors).some((key) => !errors[key].isValid) || + schedules.some((schedule) => !schedule.isValid) || + !schedules.length || + isLoading || + (delivery === INTERNAL_ENDPOINT && liveCcEnabled && !liveCcLang); + + const cachedPlayerContent = useMemo( + () => + playerConfig && force ? ( + setPlayerWidget(widget)} + /> + ) : null, + [playerConfig, force], + ); + + const shouldShowViewCounter = + eventStatus !== LIVE_EVENT_DISABLED_STATUS && + Date.now() > schedules[0]?.startTime && + (Date.now() < schedules[schedules.length - 1]?.endTime || (recurrent && Date.now() < recurrentEndDate)); + + const newEvent = paramsId === 'new'; + + return ( +
    + + + {newEvent ? 'New Live Event' : `${name} > Settings`} + + + + {tabs.length > 1 && ( + { + dispatch(setActiveTabIdAction(value)); + }} + > + {tabs.map((tab) => ( + + ))} + + )} + + + + + {isAllowedToCreateOrEdit && ( + + )} + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + ); + })} + {(!newEvent || (!!newEvent && !!cachedPlayerContent)) && ( + + +
    {cachedPlayerContent}
    +
    + {!!cachedPlayerContent && ( + + {shouldShowViewCounter && } + + + )} +
    + )} +
    +
    + {showEmbedPopup && ( + setShowEmbedPopup(false)} + /> + )} +
    + ); +} + +export default LiveEvent; diff --git a/anyclip/src/modules/liveEvents/liveEvent/components/styles.module.scss b/anyclip/src/modules/liveEvents/liveEvent/components/styles.module.scss new file mode 100644 index 0000000..58bb3e1 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/components/styles.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"styles_Wrapper___itah","Title":"styles_Title__UtpOY","Controls":"styles_Controls__Kr9Yh","Loader":"styles_Loader__oSlbp","Tabs":"styles_Tabs__JnIDs","PlayerWrapper":"styles_PlayerWrapper__3bYdd"}; \ No newline at end of file diff --git a/anyclip/src/modules/liveEvents/liveEvent/constants/forcePoster.js b/anyclip/src/modules/liveEvents/liveEvent/constants/forcePoster.js new file mode 100644 index 0000000..3dea814 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/constants/forcePoster.js @@ -0,0 +1,13 @@ +export const SAME_AS_PRE_EVENT = 'SAME_AS_PRE_EVENT'; +export const SAME_AS_POST_EVENT = 'SAME_AS_POST_EVENT'; + +export const FORCE_POSTER_OPTIONS = [ + { + label: 'Same as Pre-Event', + value: SAME_AS_PRE_EVENT, + }, + { + label: 'Same as Post-Event', + value: SAME_AS_POST_EVENT, + }, +]; diff --git a/anyclip/src/modules/liveEvents/liveEvent/constants/index.js b/anyclip/src/modules/liveEvents/liveEvent/constants/index.js new file mode 100644 index 0000000..a9c3a45 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/constants/index.js @@ -0,0 +1,39 @@ +export const TAB_EVENT_DETAILS = 'DETAILS'; +export const TAB_EVENT_SCHEDULE = 'SCHEDULE'; +export const TAB_EVENT_DELIVERY = 'DELIVERY'; +export const TAB_EVENT_PRE_POST = 'PRE_POST'; + +export const EXTERNAL_ENDPOINT = 'EXTERNAL'; +export const INTERNAL_ENDPOINT = 'INTERNAL'; +export const PRE_EVENT = 'PRE_EVENT'; +export const POST_EVENT = 'POST_EVENT'; + +export const EVENT_UX_TYPES_RECOMMENDED = 'RECOMMENDED'; +export const EVENT_UX_TYPES_IMAGE = 'IMAGE'; +export const EVENT_UX_TYPES_SAME = 'SAME_AS_PRE_EVENT'; + +export const ASPECT_RATIO = { + '1_1': 'ASPECT_RATIO_1x1', + '3_4': 'ASPECT_RATIO_3x4', + '4_3': 'ASPECT_RATIO_4x3', + '16_9': 'ASPECT_RATIO_16x9', + '9_16': 'ASPECT_RATIO_9x16', +}; + +export const SAVED = 'SAVED'; +export const PROCESSING = 'PROCESSING'; +export const IDLE = 'IDLE'; +export const RUNNING = 'RUNNING'; +export const FAILED = 'FAILED'; +export const DELETED = 'DELETED'; + +export const EVENT_STATUS_ARCHIVED = -1; +export const EVENT_STATUS_ACTIVE = 1; + +export const NON_RECURRING = 'NON_RECURRING'; +export const DAY = 'DAY'; +export const WEEK = 'WEEK'; +export const MONTH = 'MONTH'; + +export const MAX_FIELD_INPUT_SIZE = 100; +export const MAX_FIELD_INDICATOR_TEXT = 15; diff --git a/anyclip/src/modules/liveEvents/liveEvent/constants/livecc.js b/anyclip/src/modules/liveEvents/liveEvent/constants/livecc.js new file mode 100644 index 0000000..8385d00 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/constants/livecc.js @@ -0,0 +1,63 @@ +const languages = [ + { + value: 'ZH_CN', + label: 'Chinese (PRC)', + }, + { + value: 'EN_AU', + label: 'English (Australia)', + }, + { + value: 'EN_GB', + label: 'English (United Kingdom)', + }, + { + value: 'EN_US', + label: 'English (United States)', + }, + { + value: 'FR', + label: 'French', + }, + { + value: 'FR_CA', + label: 'French (Canada)', + }, + { + value: 'DE', + label: 'German', + }, + { + value: 'IT', + label: 'Italian', + }, + { + value: 'JA', + label: 'Japanese', + }, + { + value: 'KO', + label: 'Korean', + }, + { + value: 'PT_BR', + label: 'Portuguese (Brazil)', + }, + { + value: 'ES_US', + label: 'Spanish (United States)', + }, +]; + +export const booleanList = [ + { + value: false, + label: 'Disabled', + }, + { + value: true, + label: 'Enabled', + }, +]; + +export default languages; diff --git a/anyclip/src/modules/liveEvents/liveEvent/constants/regions.js b/anyclip/src/modules/liveEvents/liveEvent/constants/regions.js new file mode 100644 index 0000000..e1a8e51 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/constants/regions.js @@ -0,0 +1,57 @@ +export const REGIONS = { + usEast: 'US_EAST', + usWest: 'US_WEST', + europe: 'EUROPE', + uk: 'UK', + nordics: 'NORDICS', + apac: 'APAC', + india: 'INDIA', + japan: 'JAPAN', + australia: 'AUSTRALIA', + southAmerica: 'SOUTH_AMERICA', +}; + +const regions = [ + { + value: REGIONS.usEast, + label: 'US East (Default)', + }, + { + value: REGIONS.usWest, + label: 'US West', + }, + { + value: REGIONS.europe, + label: 'Europe', + }, + { + value: REGIONS.uk, + label: 'UK', + }, + { + value: REGIONS.nordics, + label: 'Nordics', + }, + { + value: REGIONS.apac, + label: 'APAC', + }, + { + value: REGIONS.india, + label: 'India', + }, + { + value: REGIONS.japan, + label: 'Japan', + }, + { + value: REGIONS.australia, + label: 'Australia', + }, + { + value: REGIONS.southAmerica, + label: 'South America', + }, +]; + +export default regions; diff --git a/anyclip/src/modules/liveEvents/liveEvent/helpers/request.js b/anyclip/src/modules/liveEvents/liveEvent/helpers/request.js new file mode 100644 index 0000000..fddea59 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/helpers/request.js @@ -0,0 +1,139 @@ +import { EXTERNAL_ENDPOINT, NON_RECURRING } from '../constants'; + +export const getEventData = (state, updateSpecificProperties, curUserId) => { + const source = updateSpecificProperties + ? { + ...state, + ...state.prevValue, + ...updateSpecificProperties.getProperties(state), + } + : state; + + const { + name, + description, + timezone, + schedules, + streamId, + playerId, + indicationText, + indicationColor, + playerAspectRatio, + guid, + preEventText, + preEventType, + preEventId, + postEventText, + postEventType, + postEventId, + transitionText, + countdown, + userId, + aspectRatio, + recurrentEndDate, + recurrentPeriod, + displayViewersCounter, + forcePoster, + forcePosterType, + } = source; + + return { + name, + description, + timezone, + liveEventSchedules: schedules.map((schedule) => { + const newSchedule = { + title: schedule.title, + startTime: schedule.startTime, + endTime: schedule.endTime, + durationIn: schedule.durationIn, + }; + + if (schedule?.pcnId) { + newSchedule.id = schedule.pcnId; + } + + return newSchedule; + }), + streamId, + playerId, + indicationText, + indicationColor, + aspectRatio: aspectRatio || playerAspectRatio, + guid, + preEventText, + preEventType, + preEventId, + postEventText, + postEventType, + postEventId, + transitionText, + countdown, + userId: userId || curUserId, + startTime: schedules[0]?.startTime ?? 0, + endTime: schedules[schedules.length - 1]?.endTime ?? 0, + recurrentEndDate: recurrentPeriod !== NON_RECURRING ? recurrentEndDate : null, + recurrent: recurrentPeriod !== NON_RECURRING, + displayViewersCounter, + forcePoster, + forcePosterType, + }; +}; + +export const getImageData = (state) => { + const { name, contentOwner, url } = state; + + return { + name, + contentOwner, + url, + }; +}; + +export const getStreamData = (state) => { + const { + streamId, + name, + url, + description, + schedules, + region, + delivery, + publisherId, + feedId, + liveCcEnabled, + liveCcLang, + recurrentEndDate, + recurrentPeriod, + timezone, + } = state; + + return { + id: streamId, + name, + url: delivery === EXTERNAL_ENDPOINT ? url : null, + description, + delivery, + schedules: schedules.map((schedule) => { + const editedSchedule = { + title: schedule.title, + startTime: schedule.startTime, + endTime: schedule.endTime, + durationIn: schedule.durationIn, + }; + if (schedule.id) { + editedSchedule.id = schedule.id; + } + return editedSchedule; + }), + region, + publisherId, + feedId, + liveCcEnabled: delivery !== EXTERNAL_ENDPOINT ? liveCcEnabled : null, + liveCcLang: delivery !== EXTERNAL_ENDPOINT && liveCcEnabled ? liveCcLang : null, + recurrent: recurrentPeriod !== NON_RECURRING, + recurrentPeriod: recurrentPeriod !== NON_RECURRING ? recurrentPeriod : null, + recurrentEndDate, + timeZone: timezone, + }; +}; diff --git a/anyclip/src/modules/liveEvents/liveEvent/helpers/timezones.js b/anyclip/src/modules/liveEvents/liveEvent/helpers/timezones.js new file mode 100644 index 0000000..d6b0cb4 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/helpers/timezones.js @@ -0,0 +1,444 @@ +const timezones = [ + { + value: 'UTC', + label: '(GMT 00:00) UTC', + }, + { + value: 'Pacific/Pago_Pago', + label: '(GMT-11:00) Pago Pago', + }, + { + value: 'Pacific/Honolulu', + label: '(GMT-10:00) Hawaii Time', + }, + { + value: 'America/Los_Angeles', + label: '(GMT-08:00) Pacific Time', + }, + { + value: 'America/Tijuana', + label: '(GMT-08:00) Pacific Time - Tijuana', + }, + { + value: 'America/Denver', + label: '(GMT-07:00) Mountain Time', + }, + { + value: 'America/Phoenix', + label: '(GMT-07:00) Mountain Time - Arizona', + }, + { + value: 'America/Mazatlan', + label: '(GMT-07:00) Mountain Time - Chihuahua, Mazatlan', + }, + { + value: 'America/Chicago', + label: '(GMT-06:00) Central Time', + }, + { + value: 'America/Mexico_City', + label: '(GMT-06:00) Central Time - Mexico City', + }, + { + value: 'America/Regina', + label: '(GMT-06:00) Central Time - Regina', + }, + { + value: 'America/Guatemala', + label: '(GMT-06:00) Guatemala', + }, + { + value: 'America/Bogota', + label: '(GMT-05:00) Bogota', + }, + { + value: 'America/New_York', + label: '(GMT-05:00) Eastern Time', + }, + { + value: 'America/Lima', + label: '(GMT-05:00) Lima', + }, + { + value: 'America/Caracas', + label: '(GMT-04:00) Caracas', + }, + { + value: 'America/Halifax', + label: '(GMT-04:00) Atlantic Time - Halifax', + }, + { + value: 'America/Guyana', + label: '(GMT-04:00) Guyana', + }, + { + value: 'America/La_Paz', + label: '(GMT-04:00) La Paz', + }, + { + value: 'America/Argentina/Buenos_Aires', + label: '(GMT-03:00) Buenos Aires', + }, + { + value: 'America/Godthab', + label: '(GMT-03:00) Godthab', + }, + { + value: 'America/Montevideo', + label: '(GMT-03:00) Montevideo', + }, + { + value: 'America/St_Johns', + label: '(GMT-03:30) Newfoundland Time - St. Johns', + }, + { + value: 'America/Santiago', + label: '(GMT-03:00) Santiago', + }, + { + value: 'America/Sao_Paulo', + label: '(GMT-03:00) Sao Paulo', + }, + { + value: 'Atlantic/South_Georgia', + label: '(GMT-02:00) South Georgia', + }, + { + value: 'Atlantic/Azores', + label: '(GMT-01:00) Azores', + }, + { + value: 'Atlantic/Cape_Verde', + label: '(GMT-01:00) Cape Verde', + }, + { + value: 'Europe/Dublin', + label: '(GMT+00:00) Dublin', + }, + { + value: 'Europe/Lisbon', + label: '(GMT+00:00) Lisbon', + }, + { + value: 'Europe/London', + label: '(GMT+00:00) London', + }, + { + value: 'Africa/Monrovia', + label: '(GMT+00:00) Monrovia', + }, + { + value: 'Africa/Algiers', + label: '(GMT+01:00) Algiers', + }, + { + value: 'Africa/Casablanca', + label: '(GMT+01:00) Casablanca', + }, + { + value: 'Europe/Amsterdam', + label: '(GMT+01:00) Amsterdam', + }, + { + value: 'Europe/Berlin', + label: '(GMT+01:00) Berlin', + }, + { + value: 'Europe/Brussels', + label: '(GMT+01:00) Brussels', + }, + { + value: 'Europe/Budapest', + label: '(GMT+01:00) Budapest', + }, + { + value: 'Europe/Belgrade', + label: '(GMT+01:00) Central European Time - Belgrade', + }, + { + value: 'Europe/Prague', + label: '(GMT+01:00) Central European Time - Prague', + }, + { + value: 'Europe/Copenhagen', + label: '(GMT+01:00) Copenhagen', + }, + { + value: 'Europe/Madrid', + label: '(GMT+01:00) Madrid', + }, + { + value: 'Europe/Paris', + label: '(GMT+01:00) Paris', + }, + { + value: 'Europe/Rome', + label: '(GMT+01:00) Rome', + }, + { + value: 'Europe/Stockholm', + label: '(GMT+01:00) Stockholm', + }, + { + value: 'Europe/Vienna', + label: '(GMT+01:00) Vienna', + }, + { + value: 'Europe/Warsaw', + label: '(GMT+01:00) Warsaw', + }, + { + value: 'Europe/Athens', + label: '(GMT+02:00) Athens', + }, + { + value: 'Europe/Bucharest', + label: '(GMT+02:00) Bucharest', + }, + { + value: 'Africa/Cairo', + label: '(GMT+02:00) Cairo', + }, + { + value: 'Asia/Jerusalem', + label: '(GMT+02:00) Jerusalem', + }, + { + value: 'Africa/Johannesburg', + label: '(GMT+02:00) Johannesburg', + }, + { + value: 'Europe/Helsinki', + label: '(GMT+02:00) Helsinki', + }, + { + value: 'Europe/Kiev', + label: '(GMT+02:00) Kiev', + }, + { + value: 'Europe/Kaliningrad', + label: '(GMT+02:00) Moscow-01 - Kaliningrad', + }, + { + value: 'Europe/Riga', + label: '(GMT+02:00) Riga', + }, + { + value: 'Europe/Sofia', + label: '(GMT+02:00) Sofia', + }, + { + value: 'Europe/Tallinn', + label: '(GMT+02:00) Tallinn', + }, + { + value: 'Europe/Vilnius', + label: '(GMT+02:00) Vilnius', + }, + { + value: 'Europe/Istanbul', + label: '(GMT+03:00) Istanbul', + }, + { + value: 'Asia/Baghdad', + label: '(GMT+03:00) Baghdad', + }, + { + value: 'Africa/Nairobi', + label: '(GMT+03:00) Nairobi', + }, + { + value: 'Europe/Minsk', + label: '(GMT+03:00) Minsk', + }, + { + value: 'Asia/Riyadh', + label: '(GMT+03:00) Riyadh', + }, + { + value: 'Europe/Moscow', + label: '(GMT+03:00) Moscow+00 - Moscow', + }, + { + value: 'Asia/Tehran', + label: '(GMT+03:30) Tehran', + }, + { + value: 'Asia/Baku', + label: '(GMT+04:00) Baku', + }, + { + value: 'Europe/Samara', + label: '(GMT+04:00) Moscow+01 - Samara', + }, + { + value: 'Asia/Tbilisi', + label: '(GMT+04:00) Tbilisi', + }, + { + value: 'Asia/Yerevan', + label: '(GMT+04:00) Yerevan', + }, + { + value: 'Asia/Kabul', + label: '(GMT+04:30) Kabul', + }, + { + value: 'Asia/Karachi', + label: '(GMT+05:00) Karachi', + }, + { + value: 'Asia/Yekaterinburg', + label: '(GMT+05:00) Moscow+02 - Yekaterinburg', + }, + { + value: 'Asia/Tashkent', + label: '(GMT+05:00) Tashkent', + }, + { + value: 'Asia/Colombo', + label: '(GMT+05:30) Colombo', + }, + { + value: 'Asia/Almaty', + label: '(GMT+06:00) Almaty', + }, + { + value: 'Asia/Dhaka', + label: '(GMT+06:00) Dhaka', + }, + { + value: 'Asia/Rangoon', + label: '(GMT+06:30) Rangoon', + }, + { + value: 'Asia/Bangkok', + label: '(GMT+07:00) Bangkok', + }, + { + value: 'Asia/Jakarta', + label: '(GMT+07:00) Jakarta', + }, + { + value: 'Asia/Krasnoyarsk', + label: '(GMT+07:00) Moscow+04 - Krasnoyarsk', + }, + { + value: 'Asia/Shanghai', + label: '(GMT+08:00) China Time - Beijing', + }, + { + value: 'Asia/Hong_Kong', + label: '(GMT+08:00) Hong Kong', + }, + { + value: 'Asia/Kuala_Lumpur', + label: '(GMT+08:00) Kuala Lumpur', + }, + { + value: 'Asia/Irkutsk', + label: '(GMT+08:00) Moscow+05 - Irkutsk', + }, + { + value: 'Asia/Singapore', + label: '(GMT+08:00) Singapore', + }, + { + value: 'Asia/Taipei', + label: '(GMT+08:00) Taipei', + }, + { + value: 'Asia/Ulaanbaatar', + label: '(GMT+08:00) Ulaanbaatar', + }, + { + value: 'Australia/Perth', + label: '(GMT+08:00) Western Time - Perth', + }, + { + value: 'Asia/Yakutsk', + label: '(GMT+09:00) Moscow+06 - Yakutsk', + }, + { + value: 'Asia/Seoul', + label: '(GMT+09:00) Seoul', + }, + { + value: 'Asia/Tokyo', + label: '(GMT+09:00) Tokyo', + }, + { + value: 'Australia/Darwin', + label: '(GMT+09:30) Central Time - Darwin', + }, + { + value: 'Australia/Brisbane', + label: '(GMT+10:00) Eastern Time - Brisbane', + }, + { + value: 'Pacific/Guam', + label: '(GMT+10:00) Guam', + }, + { + value: 'Asia/Vladivostok', + label: '(GMT+10:00) Moscow+07 - Yuzhno-Sakhalinsk', + }, + { + value: 'Pacific/Port_Moresby', + label: '(GMT+10:00) Port Moresby', + }, + { + value: 'Australia/Adelaide', + label: '(GMT+10:30) Central Time - Adelaide', + }, + { + value: 'Asia/Magadan', + label: '(GMT+11:00) Moscow+07 - Magadan', + }, + { + value: 'Australia/Hobart', + label: '(GMT+11:00) Eastern Time - Hobart', + }, + { + value: 'Australia/Sydney', + label: '(GMT+11:00) Eastern Time - Melbourne, Sydney', + }, + { + value: 'Pacific/Guadalcanal', + label: '(GMT+11:00) Guadalcanal', + }, + { + value: 'Pacific/Noumea', + label: '(GMT+11:00) Noumea', + }, + { + value: 'Pacific/Majuro', + label: '(GMT+12:00) Majuro', + }, + { + value: 'Asia/Kamchatka', + label: '(GMT+12:00) Moscow+09 - Petropavlovsk-Kamchatskiy', + }, + { + value: 'Pacific/Auckland', + label: '(GMT+13:00) Auckland', + }, + { + value: 'Pacific/Fakaofo', + label: '(GMT+13:00) Fakaofo', + }, + { + value: 'Pacific/Fiji', + label: '(GMT+13:00) Fiji', + }, + { + value: 'Pacific/Tongatapu', + label: '(GMT+13:00) Tongatapu', + }, + { + value: 'Pacific/Apia', + label: '(GMT+14:00) Apia', + }, +]; + +export default timezones; diff --git a/anyclip/src/modules/liveEvents/liveEvent/helpers/validation.js b/anyclip/src/modules/liveEvents/liveEvent/helpers/validation.js new file mode 100644 index 0000000..b6d8939 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/helpers/validation.js @@ -0,0 +1,165 @@ +/* eslint-disable no-useless-escape */ +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; + +import { DAY, MONTH, WEEK } from '../constants'; + +dayjs.extend(durationPlugin); + +const validateUrl = (url) => /^(https:\/\/)[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test(url); + +const validateField = ({ fieldName, fieldValue }, setErrorAction, optional) => { + if (!fieldValue) { + return setErrorAction({ + [fieldName]: { + isValid: false, + message: 'This field cannot be empty', + }, + }); + } + + if (fieldName === 'url' && !validateUrl(fieldValue)) { + return setErrorAction({ + [fieldName]: { + isValid: false, + message: "Field must be in the format of 'https://' URL'", + }, + }); + } + + if (fieldName === 'recurrentPeriod') { + if (fieldValue === DAY && optional?.duration > 1) { + return setErrorAction({ + [fieldName]: { + isValid: false, + message: "A daily event can't be longer than a day", + }, + }); + } + + if (fieldValue === WEEK && optional?.duration > 7) { + return setErrorAction({ + [fieldName]: { + isValid: false, + message: "A weekly event can't be longer than a week", + }, + }); + } + } + + if (fieldName === 'recurrentEndDate') { + if (optional?.recurrentPeriod === DAY && optional.timeDifference < 1) { + return setErrorAction({ + [fieldName]: { + isValid: false, + message: 'The end date must be a day after the last event date', + }, + }); + } + if (optional?.recurrentPeriod === WEEK && optional.timeDifference < 7) { + return setErrorAction({ + [fieldName]: { + isValid: false, + message: 'The end date must be a week after the last event date', + }, + }); + } + + if (optional.recurrentPeriod === MONTH && optional.timeDifference < optional.daysInMonth) { + return setErrorAction({ + [fieldName]: { + isValid: false, + message: 'The end date must be a month after the last event date', + }, + }); + } + } + + return setErrorAction({ + [fieldName]: { + isValid: true, + message: '', + }, + }); +}; + +export const getTimeWithoutS = (time) => time - (time % 60000); + +export const getMinStartDate = () => getTimeWithoutS(Date.now()); + +export const validateSchedules = (schedules) => { + const startTime = dayjs(schedules[0]?.startTime); + const endTime = dayjs(schedules[schedules.length - 1]?.endTime); + + const validatedSchedules = schedules.map((schedule, index) => { + if (schedule.isEditDisabled) { + return schedule; + } + const errors = {}; + + if (Math.ceil(dayjs.duration(endTime.diff(startTime)).asDays()) > 7) { + errors.duration = 'The maximum duration of the whole schedule can be up to 7 days'; + } + + if (!schedule.title.length) { + errors.title = 'Please, enter the title'; + } + + if (schedule.title.length > 100) { + errors.title = 'Title size is limited by 100 characters'; + } + + if (schedule.startTime < getMinStartDate()) { + errors.startTime = 'The schedule line couldn’t be set in the past'; + } + + schedules.forEach((item, itemIndex) => { + if (itemIndex === index) { + return; + } + + if (schedule.startTime >= item.startTime && schedule.startTime <= item.endTime) { + errors.startTime = 'The schedule line’s start/end time couldn’t be overlapped with another schedule line'; + } + + if ( + (schedule.endTime > item.startTime && schedule.endTime < item.endTime) || + (schedule.startTime <= item.startTime && schedule.endTime > item.startTime) + ) { + errors.endTime = 'The schedule line’s start/end time couldn’t be overlapped with another schedule line'; + } + }); + + if (!schedule.duration) { + errors.duration = 'The duration cannot be empty'; + } + + if (schedule.durationIn === 'MINUTES' && schedule.duration < 10) { + errors.duration = 'The minimum duration can be 10 minutes'; + } + + if (schedule.durationIn === 'MINUTES' && schedule.duration > 1440) { + errors.duration = 'The maximum duration can be 1440 minutes'; + } + + if (schedule.durationIn === 'HOURS' && schedule.duration < 1) { + errors.duration = 'The minimum duration can be 1 hour'; + } + + if (schedule.durationIn === 'HOURS' && schedule.duration > 24) { + errors.duration = 'The maximum duration can be 24 hours'; + } + + const validatedSchedule = { + ...schedule, + isValid: !Object.keys(errors).length, + errors, + }; + + return validatedSchedule; + }); + + return validatedSchedules; +}; + +export default validateField; diff --git a/anyclip/src/modules/liveEvents/liveEvent/hooks/useForceReload.ts b/anyclip/src/modules/liveEvents/liveEvent/hooks/useForceReload.ts new file mode 100644 index 0000000..eebb019 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/hooks/useForceReload.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export default function useForceReload(delay: number = 300): [number, () => void] { + const [force, setForce] = useState(1); + const timeoutRef = useRef(undefined); + + const reload = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setForce((prev) => prev + 1); + }, delay); + }, [delay]); + + useEffect( + () => () => { + if (timeoutRef.current !== undefined) { + clearTimeout(timeoutRef.current); + } + }, + [], + ); + + return [force, reload]; +} diff --git a/anyclip/src/modules/liveEvents/liveEvent/hooks/useIsAllowedToCreateOrEdit.js b/anyclip/src/modules/liveEvents/liveEvent/hooks/useIsAllowedToCreateOrEdit.js new file mode 100644 index 0000000..52c9adc --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/hooks/useIsAllowedToCreateOrEdit.js @@ -0,0 +1,18 @@ +import { useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { PCN_POST_LIVE_EVENTS, PCN_PUT_LIVE_EVENTS } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +export default function useIsAllowedToCreateOrEdit() { + const router = useRouter(); + const { id } = router.query; + const userPermissions = useSelector(getUserPermissionsSelector); + const isAllowedToCreateOrEdit = +id + ? hasPermission(PCN_PUT_LIVE_EVENTS, userPermissions) + : hasPermission(PCN_POST_LIVE_EVENTS, userPermissions); + + return isAllowedToCreateOrEdit; +} diff --git a/anyclip/src/modules/liveEvents/liveEvent/index.js b/anyclip/src/modules/liveEvents/liveEvent/index.js new file mode 100644 index 0000000..593584f --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/index.js @@ -0,0 +1,3 @@ +import LiveEvent from './components'; + +export default LiveEvent; diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEvent.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEvent.js new file mode 100644 index 0000000..bcc1640 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEvent.js @@ -0,0 +1,152 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; + +import { EVENT_UX_TYPES_SAME } from '../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { getEventData } from '../../helpers/request'; +import { createLiveEventAction, setIsLoadingAction, slice, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserIdSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation createLiveEvent( + $name: String!, + $description: String, + $guid: String, + $streamId: String!, + $timezone: String, + $indicationText: String, + $indicationColor: String, + $playerId: Float!, + $preEventText: String, + $preEventType: String, + $preEventId: String, + $postEventText: String, + $postEventType: String, + $postEventId: String, + $transitionText: String, + $countdown: Boolean, + $liveEventSchedules: [LiveEventSchedulesInputType]!, + $startTime: Float, + $endTime: Float, + $aspectRatio: String!, + $recurrent: Boolean, + $recurrentEndDate: Float, + $displayViewersCounter: Boolean, + $forcePoster: Boolean, + $forcePosterType: String, + ) { + createLiveEvent( + name: $name, + description: $description, + guid: $guid, + streamId: $streamId, + timezone: $timezone, + indicationText: $indicationText, + indicationColor: $indicationColor, + playerId: $playerId, + preEventText: $preEventText, + preEventType: $preEventType, + preEventId: $preEventId, + postEventText: $postEventText, + postEventType: $postEventType, + postEventId: $postEventId, + transitionText: $transitionText, + countdown: $countdown, + liveEventSchedules: $liveEventSchedules, + startTime: $startTime, + endTime: $endTime, + aspectRatio: $aspectRatio, + recurrent: $recurrent, + recurrentEndDate: $recurrentEndDate, + displayViewersCounter: $displayViewersCounter, + forcePoster: $forcePoster, + forcePosterType: $forcePosterType, + ) { + id + name, + description, + guid, + streamId, + timezone, + indicationText, + indicationColor, + playerId, + preEventText, + preEventType, + preEventId, + postEventText, + postEventType, + postEventId, + transitionText, + countdown, + liveEventSchedules { + title + startTime + endTime + durationIn + }, + aspectRatio, + status, + recurrent, + recurrentEndDate, + displayViewersCounter, + forcePoster, + forcePosterType, + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createLiveEventAction.type), + switchMap((action) => { + const userId = getUserIdSelector(state$.value); + const body = getEventData(state$.value[slice.name], false, userId); + + if (body.postEventType === EVENT_UX_TYPES_SAME) { + body.postEventType = body.preEventType; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...action.payload, + ...body, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + actions.push( + of( + updateLiveEventFormAction({ + id: data.createLiveEvent.id, + }), + ), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Event saved', + }), + ), + ); + } + + return concat(...actions); + }), + tap(({ payload }) => { + if (payload?.id) { + Router.replace(`/live/${payload.id}`); + } + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventEndpoint.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventEndpoint.js new file mode 100644 index 0000000..2711c02 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventEndpoint.js @@ -0,0 +1,39 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { createLiveEventEndpointAction, setIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query createLiveEventEndpoint($id: String!) { + createLiveEventEndpoint(id: $id) { + data + } + } +`; + +export default (action$) => + action$.pipe( + ofType(createLiveEventEndpointAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: action.payload, + }, + }).pipe( + concatMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setIsLoadingAction(false))); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventFlow.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventFlow.js new file mode 100644 index 0000000..0722198 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventFlow.js @@ -0,0 +1,15 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { createLiveEventFlowAction, createLiveStreamAction } from '../slices'; +import { contentOwnerIdSelector } from '@/modules/liveEvents/liveEvent/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(createLiveEventFlowAction.type), + switchMap(() => { + const contentOwnerId = contentOwnerIdSelector(state$.value); + return concat(of(createLiveStreamAction({ contentOwner: contentOwnerId }))); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventImage.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventImage.js new file mode 100644 index 0000000..8ec370f --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveEventImage.js @@ -0,0 +1,78 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PRE_EVENT } from '../../constants'; + +import { getImageData } from '../../helpers/request'; +import { + createLiveEventImageAction, + getLiveEventImageAction, + setIsLoadingAction, + updateLiveEventFormAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation createLiveEventImage( + $name: String!, + $contentOwner: Int!, + $url: String! + ) { + createLiveEventImage( + name: $name, + contentOwner: $contentOwner, + url: $url + ) { + uid, + name, + files { + file + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(createLiveEventImageAction.type), + switchMap((action) => { + const body = getImageData(action.payload); + const { type } = action.payload; + const id = type === PRE_EVENT ? 'preEventId' : 'postEventId'; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...body, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + const { uid } = data.createLiveEventImage; + + actions.push( + of( + updateLiveEventFormAction({ + [id]: uid, + isLoading: false, + }), + ), + of( + getLiveEventImageAction({ + type, + id: uid, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveStream.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveStream.js new file mode 100644 index 0000000..b9ded22 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/createLiveStream.js @@ -0,0 +1,118 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { getStreamData } from '../../helpers/request'; +import { + createLiveEventAction, + createLiveStreamAction, + setIsLoadingAction, + slice, + updateLiveEventFormAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation createLiveStream( + $name: String!, + $contentOwner: Int!, + $description: String, + $schedules: [LiveEventSchedulesInputType]!, + $delivery: String!, + $region: String, + $url: String, + $publisherId: Int!, + $feedId: Int!, + $liveCcEnabled: Boolean, + $liveCcLang: String, + $recurrent: Boolean, + $recurrentPeriod: String, + $recurrentEndDate: Float, + $timeZone: String, + ) { + createLiveStream( + name: $name, + contentOwner: $contentOwner, + description: $description, + schedules: $schedules, + delivery: $delivery, + region: $region, + url: $url, + publisherId: $publisherId, + feedId: $feedId, + liveCcEnabled: $liveCcEnabled, + liveCcLang: $liveCcLang, + recurrent: $recurrent, + recurrentPeriod: $recurrentPeriod, + recurrentEndDate: $recurrentEndDate, + timeZone: $timeZone, + ) { + uid, + name, + url, + contentOwner, + description, + rtmpInputUrl, + rtmpSecondaryInputUrl, + rtmpStreamKey, + rtmpSecondaryStreamKey, + status, + delivery, + region, + schedules { + id + title + startTime + endTime + durationIn + }, + liveCcEnabled, + liveCcLang, + recurrent, + recurrentPeriod, + recurrentEndDate, + timeZone, + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createLiveStreamAction.type), + switchMap((action) => { + const body = getStreamData(state$.value[slice.name]); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...action.payload, + ...body, + }, + }).pipe( + concatMap(({ data, errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + const { uid } = data.createLiveStream; + + actions.push( + of( + updateLiveEventFormAction({ + streamId: uid, + }), + ), + of( + createLiveEventAction({ + streamId: uid, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getDefaultImages.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getDefaultImages.js new file mode 100644 index 0000000..76ef244 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getDefaultImages.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PRE_EVENT } from '../../constants'; + +import { getLiveEventDefaultImagesAction, setIsLoadingAction, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getLiveEventDefaultImages($eventType: String!, $aspectRatio: String!) { + getLiveEventDefaultImages( + eventType: $eventType, + aspectRatio: $aspectRatio + ) { + uid, + name, + files { + file + width + height + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getLiveEventDefaultImagesAction.type), + switchMap((action) => { + const { eventType } = action.payload; + const fieldId = eventType === PRE_EVENT ? 'preEventId' : 'postEventId'; + const fieldImages = eventType === PRE_EVENT ? 'preEventImages' : 'postEventImages'; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + const { uid, files } = data.getLiveEventDefaultImages; + + actions.push( + of( + updateLiveEventFormAction({ + [fieldId]: uid, + [fieldImages]: files, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getImageUploadUrl.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getImageUploadUrl.js new file mode 100644 index 0000000..ea72f9d --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getImageUploadUrl.js @@ -0,0 +1,82 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { setIsLoadingAction, uploadLiveEventImageFlowAction, uploadLiveEventImageToS3Action } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { contentOwnerIdSelector } from '@/modules/liveEvents/liveEvent/redux/selectors'; + +const queryGQL = ` + query S3UploadLink( + $name: String, + $filename: String!, + $thumbnail: String, + $contentOwnerId: Float!, + $fromLiveEvent: Boolean + ) { + S3UploadLink( + name: $name, + filename: $filename, + thumbnail: $thumbnail, + contentOwnerId: $contentOwnerId, + fromLiveEvent: $fromLiveEvent + ) { + uploadUrl + downloadUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(uploadLiveEventImageFlowAction.type), + switchMap((action) => { + const contentOwnerId = contentOwnerIdSelector(state$.value); + + const { image, type } = action.payload; + + if (image && image.size > 5000000) { + const message = { + type: TYPE_ERROR, + message: 'Maximum thumbnail file size is 5MB', + }; + return concat(of(showNotificationAction(message))); + } + + const requestParams = { + contentOwnerId, + filename: image?.name, + fromLiveEvent: true, + }; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: requestParams, + }).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length && data.S3UploadLink) { + const s3uploadParams = { ...data.S3UploadLink }; + actions.push( + of( + uploadLiveEventImageToS3Action({ + contentOwner: contentOwnerId, + image, + type, + ...s3uploadParams, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventById.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventById.js new file mode 100644 index 0000000..c80d7e9 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventById.js @@ -0,0 +1,143 @@ +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { EVENT_STATUS_ACTIVE } from '../../constants'; + +import { + getLiveEventByIdAction, + getPublisherPlayersAction, + setIsLoadingAction, + setLiveEventDataAction, + setLiveStreamDataAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +dayjs.extend(durationPlugin); + +const queryGQL = ` + query getLiveEventById($id: Int!) { + getLiveEventById(id: $id) { + id + name, + description, + guid, + streamId, + timezone, + indicationText, + indicationColor, + playerId, + preEventText, + preEventType, + preEventId, + postEventText, + postEventType, + postEventId, + transitionText, + countdown, + liveEventSchedules { + id + title + startTime + endTime + durationIn + }, + player { + name, + id, + publisher { + name, + id + } + }, + aspectRatio, + status, + recurrent, + recurrentEndDate, + displayViewersCounter, + forcePoster, + forcePosterType, + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getLiveEventByIdAction.type), + switchMap((action) => { + const { copy } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: action.payload.id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + const { getLiveEventById } = data; + + if (!Object.keys(getLiveEventById).length) { + Router.push('/live'); + } else { + const liveEventSchedules = copy + ? getLiveEventById.liveEventSchedules.filter((schedule) => schedule.startTime > Date.now()) + : getLiveEventById.liveEventSchedules; + + const liveEventSchedulesResult = liveEventSchedules.map((schedule) => { + const duration = dayjs + .duration(schedule.endTime - schedule.startTime) + .as(schedule.durationIn.toLowerCase()); + + return { + ...schedule, + duration, + }; + }); + + const liveEvent = { + ...getLiveEventById, + eventStatus: copy ? EVENT_STATUS_ACTIVE : getLiveEventById.status, + id: copy ? null : getLiveEventById.id, + name: copy ? `Copy of ${getLiveEventById.name}` : getLiveEventById.name, + streamId: copy ? '' : getLiveEventById.streamId, + liveEventSchedules: liveEventSchedulesResult, + description: getLiveEventById.description ?? '', + guid: getLiveEventById.guid ?? '', + }; + + actions.push( + of( + setLiveEventDataAction({ + ...liveEvent, + prevValue: liveEvent, + }), + ), + of( + setLiveStreamDataAction({ + repeatRequest: false, + copy, + streamId: getLiveEventById.streamId, + }), + ), + of( + getPublisherPlayersAction({ + publisherId: liveEvent.player.publisher.id, + }), + ), + ); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventContentOwner.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventContentOwner.js new file mode 100644 index 0000000..05852ca --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventContentOwner.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { getLiveEventContentOwnerAction, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getLiveEventFeedContentOwner($publisherId: Int!) { + getLiveEventFeedContentOwner(publisherId: $publisherId) { + contentOwner { + id + name + }, + feed { + id + name + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getLiveEventContentOwnerAction.type), + switchMap((action) => { + const { publisherId } = action.payload; + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + publisherId, + }, + }).pipe( + concatMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { contentOwner, feed } = data.getLiveEventFeedContentOwner; + + actions.push( + of( + updateLiveEventFormAction({ + contentOwnerId: contentOwner.id, + feedId: feed.id, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventImage.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventImage.js new file mode 100644 index 0000000..01abb89 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventImage.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { PRE_EVENT } from '../../constants'; + +import { getLiveEventImageAction, setIsLoadingAction, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getLiveEventImage($id: String!) { + getLiveEventImage(id: $id) { + uid, + name, + files { + file + width + height + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getLiveEventImageAction.type), + concatMap((action) => { + const { id, type } = action.payload; + + const fieldId = type === PRE_EVENT ? 'preEventId' : 'postEventId'; + const fieldImages = type === PRE_EVENT ? 'preEventImages' : 'postEventImages'; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + actions.push( + of( + updateLiveEventFormAction({ + [fieldId]: data.getLiveEventImage?.uid, + [fieldImages]: data.getLiveEventImage?.files, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventPublishers.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventPublishers.js new file mode 100644 index 0000000..1b0532d --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveEventPublishers.js @@ -0,0 +1,44 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { getPublishersAction, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +import allPublishersGQL from '../../../../@common/gql/queries/allPublishers'; + +export default (action$) => + action$.pipe( + ofType(getPublishersAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap(() => { + const stream$ = gqlRequest({ + query: allPublishersGQL, + variables: { + watchEnabledOnly: false, + removeDisabled: true, + removeLimit: true, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateLiveEventFormAction({ + publishers: data.allPublishers, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveStream.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveStream.js new file mode 100644 index 0000000..c4ee790 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getLiveStream.js @@ -0,0 +1,152 @@ +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import { ofType } from 'redux-observable'; +import { concat } from 'rxjs'; +import { concatMap, delay, filter, map, repeat, takeUntil } from 'rxjs/operators'; + +import { INTERNAL_ENDPOINT, NON_RECURRING } from '../../constants'; + +import { liveEventSchedulesSelector, streamIdSelector } from '../selectors'; +import { cancelLiveStreamRequestAction, setLiveStreamDataAction, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +dayjs.extend(durationPlugin); + +const queryGQL = ` + query getLiveStream($id: String!) { + getLiveStream(id: $id) { + url + schedules { + id + title + startTime + endTime + durationIn + } + rtmpInputUrl + rtmpSecondaryInputUrl + rtmpStreamKey + rtmpSecondaryStreamKey + status + delivery + region + liveCcEnabled + liveCcLang + recurrent + recurrentPeriod + timeZone + recurrentEndDate + } + } +`; + +// there 2 places where stored scheduler +// stream weavo and pcn api +// need add id from pcn +function addIdFromPcnLiveEventSchedules(pcnLiveEventSchedules, streamSchedules) { + return streamSchedules.map((schedule) => { + const scheduleFromPcnLiveEventSchedules = pcnLiveEventSchedules?.find( + (pcnLiveEventSchedule) => + pcnLiveEventSchedule.endTime === schedule.endTime && + pcnLiveEventSchedule.fromTime === schedule.fromTime && + pcnLiveEventSchedule.durationIn === schedule.durationIn, + ); + + if (scheduleFromPcnLiveEventSchedules) { + // eslint-disable-next-line no-param-reassign + schedule.pcnId = scheduleFromPcnLiveEventSchedules.id; + } + + return schedule; + }); +} + +function getModifiedSchedules(schedules, copy, pcnLiveEventSchedules) { + const enhancedSchedules = addIdFromPcnLiveEventSchedules(pcnLiveEventSchedules, schedules); + return copy + ? enhancedSchedules + .filter((schedule) => schedule.startTime > Date.now()) + .map((schedule) => ({ + ...schedule, + duration: dayjs.duration(schedule.endTime - schedule.startTime).as(schedule.durationIn), + isEditDisabled: false, + isValid: true, + errors: {}, + })) + : enhancedSchedules.map((schedule) => ({ + ...schedule, + duration: dayjs.duration(schedule.endTime - schedule.startTime).as(schedule.durationIn), + isEditDisabled: schedule.startTime <= Date.now(), + isValid: true, + errors: {}, + })); +} + +export default (action$, state$) => + action$.pipe( + ofType(setLiveStreamDataAction.type), + concatMap(({ payload }) => { + const { repeatRequest, copy } = payload; + const streamId = payload?.streamId ?? streamIdSelector(state$.value); + const liveEventSchedules = liveEventSchedulesSelector(state$.value); + + const additionalArgs = repeatRequest + ? [ + delay(10000), + filter(() => !!getToken()), + repeat(), + takeUntil(action$.pipe(ofType(cancelLiveStreamRequestAction.type))), + ] + : []; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: streamId, + }, + }).pipe( + map(({ data, errors }) => { + if (errors.length) { + return updateLiveEventFormAction({ + liveStreamRequestFailed: true, + }); + } + + let modifiedSchedules = []; + + if (data.getLiveStream?.schedules) { + modifiedSchedules = getModifiedSchedules(data.getLiveStream.schedules, copy, liveEventSchedules); + } + + const isInternalCopy = data.getLiveStream.delivery === INTERNAL_ENDPOINT && copy; + + const resData = { + ...data.getLiveStream, + url: isInternalCopy ? '' : data.getLiveStream.url, + rtmpInputUrl: isInternalCopy ? '' : data.getLiveStream.rtmpInputUrl, + rtmpSecondaryInputUrl: isInternalCopy ? '' : data.getLiveStream.rtmpSecondaryInputUrl, + rtmpStreamKey: isInternalCopy ? '' : data.getLiveStream.rtmpStreamKey, + rtmpSecondaryStreamKey: isInternalCopy ? '' : data.getLiveStream.rtmpSecondaryStreamKey, + region: isInternalCopy ? 'US_EAST' : data.getLiveStream.region, + status: copy ? '' : data.getLiveStream.status, + schedules: modifiedSchedules, + isTimezoneDisabled: modifiedSchedules[0]?.startTime < Date.now(), + recurrentPeriod: data.getLiveStream.recurrent ? data.getLiveStream.recurrentPeriod : NON_RECURRING, + }; + + if (repeatRequest) { + delete resData.schedules; + delete resData.recurrent; + delete resData.recurrentPeriod; + delete resData.recurrentEndDate; + } + + return updateLiveEventFormAction(resData); + }), + ...additionalArgs, + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPlayerConfig.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPlayerConfig.js new file mode 100644 index 0000000..d2bcf36 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPlayerConfig.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { playerSelector, publisherInfoSelector } from '../selectors'; +import { getPlayerConfigAction, getTmPlaylistAction, updateLiveEventFormAction } from '../slices'; +import { getDirtyEvalObjectFromString, getPlayerConfigCdnEndpoint } from '@/modules/@common/PlayerWidget/helpers'; +import { parseErrorMessage } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(getPlayerConfigAction.type), + filter((action) => { + const { player: actionPlayer, publishers: actionPublisher } = action?.payload || {}; + const statePlayer = playerSelector(state$.value); + const statePublisher = publisherInfoSelector(state$.value); + + const player = actionPlayer || statePlayer; + const publisher = actionPublisher || statePublisher; + + return player && publisher; + }), + switchMap((action) => { + const { player: actionPlayer, publishers: actionPublisher } = action?.payload || {}; + const statePlayer = playerSelector(state$.value); + const statePublisher = publisherInfoSelector(state$.value); + + const player = actionPlayer || statePlayer?.name; + const publisher = actionPublisher || statePublisher?.slug; + + // TODO: try to replace xmlhttprequest into rxjs ajax + const stream$ = defer(() => + new Promise((resolve) => { + const url = getPlayerConfigCdnEndpoint(publisher, player); + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.send(); + xhr.onload = () => { + const succeeded = xhr.status === 200; + + resolve({ + data: succeeded ? xhr.responseText : null, + errors: succeeded ? [] : [new Error(`Cannot find file: \n${url}`)], + }); + }; + }).then((data) => data), + ).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateLiveEventFormAction({ + playerConfig: getDirtyEvalObjectFromString(data), + }), + ), + ); + if (!actionPlayer) { + actions.push(of(getTmPlaylistAction())); + } + } else { + const errorMessage = parseErrorMessage(errors[0]); + + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: errorMessage, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherInfoById.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherInfoById.js new file mode 100644 index 0000000..03c9024 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherInfoById.js @@ -0,0 +1,47 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPublisherInfoByIdAction, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getPublisherInfoById($id: Int!) { + getPublisherInfoById(id: $id) { + id + name, + slug, + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPublisherInfoByIdAction.type), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateLiveEventFormAction({ + publisherInfo: data.getPublisherInfoById, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherPlayers.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherPlayers.js new file mode 100644 index 0000000..3be4103 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getPublisherPlayers.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_LIVE } from '@/modules/@common/constants/playerTypes'; + +import { getPublisherPlayersAction, updateLiveEventFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getLiveEventPublisherPlayers($publisherId: Int, $type: Int!) { + getLiveEventPublisherPlayers(publisherId: $publisherId, type: $type) { + results { + id + name + alias + publisherId + displayEmbedCode + playerAspectRatio + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPublisherPlayersAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...action.payload, + type: TYPE_LIVE, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const players = data.getLiveEventPublisherPlayers.results.sort((a, b) => a?.alias?.localeCompare(b?.alias)); + + actions.push( + of( + updateLiveEventFormAction({ + players, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmPlaylist.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmPlaylist.js new file mode 100644 index 0000000..5282f73 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmPlaylist.js @@ -0,0 +1,70 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { getTmPlaylistAction, updateLiveEventFormAction } from '../slices'; +import { getPlaylistApiEndpoint } from '@/modules/@common/PlayerWidget/helpers'; +import { parseErrorMessage } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { idSelector, playerConfigSelector } from '@/modules/liveEvents/liveEvent/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(getTmPlaylistAction.type), + switchMap(() => { + const playerConfig = playerConfigSelector(state$.value); + const id = idSelector(state$.value); + + const params = { + dev: 'desktop', + publisherId: playerConfig.publisherId, + widgetId: playerConfig.widgetId, + session: `${Math.random()}`, + url: `${window.location.href}`, + }; + + if (id) { + params.leid = id; + } + const stream$ = defer(() => + ajax({ + method: 'POST', + url: `${getPlaylistApiEndpoint()}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + ...params, + }), + }), + ).pipe( + switchMap(({ response }) => + of( + updateLiveEventFormAction({ + tmPlaylist: response, + }), + ), + ), + catchError(({ response }) => { + const errorMessage = parseErrorMessage(response); + + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: errorMessage, + }), + ), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmViewersCounter.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmViewersCounter.js new file mode 100644 index 0000000..ae3212a --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/getTmViewersCounter.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, EMPTY, of } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { getViewersCounterAction, updateLiveEventFormAction } from '../slices'; +import { getToken } from '@/modules/@common/token/helpers'; +import { idSelector, playerConfigSelector } from '@/modules/liveEvents/liveEvent/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(getViewersCounterAction.type), + switchMap(() => { + const playerConfig = playerConfigSelector(state$.value); + const id = idSelector(state$.value); + + const params = { + widgetId: playerConfig.widgetId, + leid: id, + }; + + const stream$ = defer(() => + ajax({ + method: 'POST', + url: '/api/trafficmanager/api/v3/player/live/counter', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: getToken() || '', + }, + crossDomain: true, + withCredentials: true, + body: JSON.stringify({ + ...params, + }), + }), + ).pipe( + switchMap(({ response }) => + of( + updateLiveEventFormAction({ + viewersAmount: response?.viewersAmount, + }), + ), + ), + catchError(() => EMPTY), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/index.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/index.js new file mode 100644 index 0000000..9099e75 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/index.js @@ -0,0 +1,51 @@ +import { combineEpics } from 'redux-observable'; + +import createLiveEvent from './createLiveEvent'; +import createLiveEventEndpoint from './createLiveEventEndpoint'; +import createLiveEventFlow from './createLiveEventFlow'; +import createLiveEventImage from './createLiveEventImage'; +import createLiveStream from './createLiveStream'; +import getDefaultImages from './getDefaultImages'; +import getImageUploadUrl from './getImageUploadUrl'; +import getLiveEventById from './getLiveEventById'; +import getLiveEventFeedContentOwner from './getLiveEventContentOwner'; +import getLiveEventImage from './getLiveEventImage'; +import getLiveEventPublishers from './getLiveEventPublishers'; +import getLiveStream from './getLiveStream'; +import getPlayerConfig from './getPlayerConfig'; +import getPublisherInfoById from './getPublisherInfoById'; +import getPublisherPlayers from './getPublisherPlayers'; +import getTmPlaylist from './getTmPlaylist'; +import getTmViewersCounter from './getTmViewersCounter'; +import testLiveEventEndpoint from './testLiveEventEndpoint'; +import updateLiveEvent from './updateLiveEvent'; +import updateLiveEventFlow from './updateLiveEventFlow'; +import updateLiveEventImage from './updateLiveEventImage'; +import updateLiveStream from './updateLiveStream'; +import uploadLiveEventImage from './uploadLiveEventImage'; + +export default combineEpics( + createLiveEventFlow, + createLiveEventImage, + createLiveEvent, + createLiveStream, + getDefaultImages, + getPublisherPlayers, + getLiveEventFeedContentOwner, + getLiveEventImage, + getLiveEventPublishers, + getLiveStream, + getImageUploadUrl, + updateLiveEvent, + updateLiveEventImage, + updateLiveStream, + updateLiveEventFlow, + uploadLiveEventImage, + testLiveEventEndpoint, + createLiveEventEndpoint, + getPlayerConfig, + getTmPlaylist, + getLiveEventById, + getPublisherInfoById, + getTmViewersCounter, +); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/testLiveEventEndpoint.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/testLiveEventEndpoint.js new file mode 100644 index 0000000..457efe4 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/testLiveEventEndpoint.js @@ -0,0 +1,47 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap, switchMap } from 'rxjs/operators'; + +import { setIsLoadingAction, setLiveStreamDataAction, testLiveEventEndPointAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { streamIdSelector } from '@/modules/liveEvents/liveEvent/redux/selectors'; + +const queryGQL = ` + query testLiveEventEndpoint($id: String!) { + testLiveEventEndpoint(id: $id) { + data + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(testLiveEventEndPointAction.type), + switchMap(() => { + const streamId = streamIdSelector(state$.value); + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id: streamId, + }, + }).pipe( + concatMap(({ errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + actions.push( + of( + setLiveStreamDataAction({ + repeatRequest: true, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEvent.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEvent.js new file mode 100644 index 0000000..18171f5 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEvent.js @@ -0,0 +1,184 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { EVENT_UX_TYPES_SAME } from '../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { getEventData } from '../../helpers/request'; +import { + getLiveEventByIdAction, + setIsLoadingAction, + slice, + updateForcePosterStatusAction, + updateLiveEventAction, + updateViewersCounterStatusAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { idSelector } from '@/modules/liveEvents/liveEvent/redux/selectors'; + +const queryGQL = ` + mutation updateLiveEvent( + $id: Int!, + $name: String!, + $description: String, + $guid: String, + $streamId: String!, + $timezone: String, + $liveEventSchedules: [LiveEventSchedulesInputType]!, + $indicationText: String, + $indicationColor: String, + $playerId: Float!, + $preEventText: String, + $preEventType: String, + $preEventId: String, + $postEventText: String, + $postEventType: String, + $postEventId: String, + $transitionText: String, + $countdown: Boolean, + $startTime: Float, + $endTime: Float, + $aspectRatio: String!, + $recurrent: Boolean, + $recurrentEndDate: Float, + $displayViewersCounter: Boolean, + $forcePoster: Boolean, + $forcePosterType: String, + ) { + updateLiveEvent( + id: $id, + name: $name, + description: $description, + guid: $guid, + streamId: $streamId, + timezone: $timezone, + liveEventSchedules: $liveEventSchedules, + indicationText: $indicationText, + indicationColor: $indicationColor, + playerId: $playerId, + preEventText: $preEventText, + preEventType: $preEventType, + preEventId: $preEventId, + postEventText: $postEventText, + postEventType: $postEventType, + postEventId: $postEventId, + transitionText: $transitionText, + countdown: $countdown, + startTime: $startTime, + endTime: $endTime, + aspectRatio: $aspectRatio, + recurrent: $recurrent, + recurrentEndDate: $recurrentEndDate, + displayViewersCounter: $displayViewersCounter, + forcePoster: $forcePoster, + forcePosterType: $forcePosterType, + ) { + id + name + description + timezone + streamId + indicationText + indicationColor + playerId + guid + preEventText + postEventText + countdown + transitionText + preEventType + preEventId + postEventType + postEventId + liveEventSchedules { + id + title + startTime + endTime + durationIn + } + player { + name + id + publisher { + name + id + } + }, + aspectRatio + status + recurrent, + recurrentEndDate, + displayViewersCounter, + forcePoster, + forcePosterType, + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateLiveEventAction.type, updateViewersCounterStatusAction.type, updateForcePosterStatusAction.type), + filter(() => !!idSelector(state$.value)), + switchMap((action) => { + const getUpdateSpecificProperties = { + [updateViewersCounterStatusAction.type]: { + getProperties: (state) => ({ + displayViewersCounter: state.displayViewersCounter, + forcePoster: state.forcePoster, + forcePosterType: state.forcePosterType, + }), + notificationMessage: 'Display on Player Saved', + }, + [updateForcePosterStatusAction.type]: { + getProperties: (state) => ({ + forcePoster: state.forcePoster, + forcePosterType: state.forcePosterType, + displayViewersCounter: state.displayViewersCounter, + }), + notificationMessage: 'Force Poster is Saved', + }, + }; + + const updateSpecificProperties = getUpdateSpecificProperties[action.type]; + const data = getEventData(state$.value[slice.name], updateSpecificProperties); + const id = idSelector(state$.value); + + if (data.postEventType === EVENT_UX_TYPES_SAME) { + data.postEventType = data.preEventType; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + id, + ...data, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: !updateSpecificProperties ? 'Event Saved' : updateSpecificProperties.notificationMessage, + }), + ), + ); + + if (!updateSpecificProperties) { + actions.push(of(getLiveEventByIdAction({ id }))); + } + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventFlow.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventFlow.js new file mode 100644 index 0000000..d97b9bc --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventFlow.js @@ -0,0 +1,11 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { updateLiveEventFlowAction, updateLiveStreamAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(updateLiveEventFlowAction.type), + concatMap(() => concat(of(updateLiveStreamAction()))), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventImage.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventImage.js new file mode 100644 index 0000000..4efae54 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveEventImage.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getImageData } from '../../helpers/request'; +import { setIsLoadingAction, slice, updateLiveEventImageAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query Query( + $id: String!, + $name: String!, + $contentOwner: Int!, + $url: String! + ) { + updateLiveEventImage( + id: $id, + name: $name, + contentOwner: $contentOwner, + url: $url + ) { + imageId + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateLiveEventImageAction.type), + switchMap(() => { + const data = getImageData(state$.value[slice.name]); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...data, + }, + }).pipe( + switchMap(() => { + const actions = [of(setIsLoadingAction(false))]; + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveStream.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveStream.js new file mode 100644 index 0000000..67278ad --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/updateLiveStream.js @@ -0,0 +1,80 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getStreamData } from '../../helpers/request'; +import { setIsLoadingAction, slice, updateLiveEventAction, updateLiveStreamAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation updateLiveStream( + $id: String!, + $name: String!, + $url: String, + $description: String, + $schedules: [LiveEventSchedulesInputType]!, + $region: String, + $recurrent: Boolean, + $recurrentPeriod: String, + $timeZone: String, + $recurrentEndDate: Float, + ) { + updateLiveStream( + id: $id, + name: $name, + url: $url, + description: $description, + schedules: $schedules, + region: $region, + recurrent: $recurrent, + recurrentPeriod: $recurrentPeriod, + timeZone: $timeZone, + recurrentEndDate: $recurrentEndDate, + ) { + uid, + name, + url, + description, + schedules { + id + title + startTime + endTime + durationIn + } + delivery, + region, + status, + recurrent, + recurrentPeriod, + timeZone, + recurrentEndDate, + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateLiveStreamAction.type), + switchMap(() => { + const data = getStreamData(state$.value[slice.name]); + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...data, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = [of(setIsLoadingAction(false))]; + + if (!errors.length) { + actions.push(of(updateLiveEventAction())); + } + + return concat(...actions); + }), + ); + + return concat(of(setIsLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/epics/uploadLiveEventImage.js b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/uploadLiveEventImage.js new file mode 100644 index 0000000..724adbd --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/epics/uploadLiveEventImage.js @@ -0,0 +1,37 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { createLiveEventImageAction, uploadLiveEventImageToS3Action } from '../slices'; +import { uploadS3 } from '@/modules/@common/request'; + +export default (action$) => + action$.pipe( + ofType(uploadLiveEventImageToS3Action.type), + switchMap((action) => { + const { uploadUrl, downloadUrl, image, type, contentOwner } = action.payload; + + const stream$ = uploadS3(uploadUrl, image).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + createLiveEventImageAction({ + url: downloadUrl, + name: image.name, + contentOwner, + type, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/selectors/index.js b/anyclip/src/modules/liveEvents/liveEvent/redux/selectors/index.js new file mode 100644 index 0000000..31a1643 --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/selectors/index.js @@ -0,0 +1,76 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +/** @deprecated */ +export const getCommonState = (state$) => { + const state = state$[nameSpace]; + + return { + ...state, + }; +}; + +export const idSelector = (state) => state[nameSpace].id; +export const nameSelector = (state) => state[nameSpace].name; +export const descriptionSelector = (state) => state[nameSpace].description; +export const timezoneSelector = (state) => state[nameSpace].timezone; +export const regionSelector = (state) => state[nameSpace].region; +export const deliverySelector = (state) => state[nameSpace].delivery; +export const urlSelector = (state) => state[nameSpace].url; +export const rtmpInputUrlSelector = (state) => state[nameSpace].rtmpInputUrl; +export const rtmpSecondaryInputUrlSelector = (state) => state[nameSpace].rtmpSecondaryInputUrl; +export const rtmpStreamKeySelector = (state) => state[nameSpace].rtmpStreamKey; +export const rtmpSecondaryStreamKeySelector = (state) => state[nameSpace].rtmpSecondaryStreamKey; +export const statusSelector = (state) => state[nameSpace].status; +export const schedulesSelector = (state) => state[nameSpace].schedules; +export const scheduleErrorsSelector = (state) => state[nameSpace].scheduleErrors; +export const streamIdSelector = (state) => state[nameSpace].streamId; +export const indicationTextSelector = (state) => state[nameSpace].indicationText; +export const indicationColorSelector = (state) => state[nameSpace].indicationColor; +export const publisherIdSelector = (state) => state[nameSpace].publisherId; +export const contentOwnerIdSelector = (state) => state[nameSpace].contentOwnerId; +export const playerIdSelector = (state) => state[nameSpace].playerId; +export const userIdSelector = (state) => state[nameSpace].userId; +export const playerAspectRatioSelector = (state) => state[nameSpace].playerAspectRatio; +export const guidSelector = (state) => state[nameSpace].guid; +export const preEventTextSelector = (state) => state[nameSpace].preEventText; +export const preEventTypeSelector = (state) => state[nameSpace].preEventType; +export const preEventIdSelector = (state) => state[nameSpace].preEventId; +export const postEventTextSelector = (state) => state[nameSpace].postEventText; +export const postEventTypeSelector = (state) => state[nameSpace].postEventType; +export const postEventIdSelector = (state) => state[nameSpace].postEventId; +export const transitionTextSelector = (state) => state[nameSpace].transitionText; +export const countdownSelector = (state) => state[nameSpace].countdown; +export const preEventImagesSelector = (state) => state[nameSpace].preEventImages; +export const postEventImagesSelector = (state) => state[nameSpace].postEventImages; +export const publishersSelector = (state) => state[nameSpace].publishers; +export const publisherSelector = (state) => state[nameSpace].publisher; +export const playersSelector = (state) => state[nameSpace].players; +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const errorsSelector = (state) => state[nameSpace].errors; +export const isEditContinueSelector = (state) => state[nameSpace].isEditContinue; +export const embedCodeSelector = (state) => state[nameSpace].embedCode; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; +export const createdPreEventPlaylistSelector = (state) => state[nameSpace].createdPreEventPlaylist; +export const createdPostEventPlaylistSelector = (state) => state[nameSpace].createdPostEventPlaylist; +export const playerConfigSelector = (state) => state[nameSpace].playerConfig; +export const eventStatusSelector = (state) => state[nameSpace].eventStatus; +export const liveStreamRequestFailedSelector = (state) => state[nameSpace].liveStreamRequestFailed; +export const liveCcEnabledSelector = (state) => state[nameSpace].liveCcEnabled; +export const liveCcLangSelector = (state) => state[nameSpace].liveCcLang; +export const isTimezoneDisabledSelector = (state) => state[nameSpace].isTimezoneDisabled; +export const recurrentPeriodSelector = (state) => state[nameSpace].recurrentPeriod; +export const recurrentEndDateSelector = (state) => state[nameSpace].recurrentEndDate; +export const displayViewersCounterSelector = (state) => state[nameSpace].displayViewersCounter; +export const viewersAmountSelector = (state) => state[nameSpace].viewersAmount; +export const forcePosterSelector = (state) => state[nameSpace].forcePoster; +export const forcePosterTypeSelector = (state) => state[nameSpace].forcePosterType; +export const prevValueSelector = (state) => state[nameSpace].prevValue; + +export const tmPlaylistSelector = (state) => state[nameSpace].tmPlaylist; +export const playerSelector = (state) => state[nameSpace].player; +export const recurrentSelector = (state) => state[nameSpace].recurrent; +export const publisherInfoSelector = (state) => state[nameSpace].publisherInfo; +export const liveEventSchedulesSelector = (state) => state[nameSpace].liveEventSchedules; +export const aspectRatioSelector = (state) => state[nameSpace].aspectRatio; diff --git a/anyclip/src/modules/liveEvents/liveEvent/redux/slices/index.js b/anyclip/src/modules/liveEvents/liveEvent/redux/slices/index.js new file mode 100644 index 0000000..84e419d --- /dev/null +++ b/anyclip/src/modules/liveEvents/liveEvent/redux/slices/index.js @@ -0,0 +1,187 @@ +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { createSlice } from '@reduxjs/toolkit'; + +import { + EVENT_STATUS_ACTIVE, + EVENT_UX_TYPES_IMAGE, + EVENT_UX_TYPES_SAME, + NON_RECURRING, + TAB_EVENT_DETAILS, +} from '../../constants'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +const initialState = { + id: null, + name: '', + description: '', + timezone: dayjs.tz.guess(), + region: 'US_EAST', + delivery: 'INTERNAL', + url: '', + rtmpInputUrl: '', + rtmpSecondaryInputUrl: '', + rtmpStreamKey: '', + rtmpSecondaryStreamKey: '', + status: '', + schedules: [], + scheduleErrors: {}, + streamId: '', + indicationText: 'Live', + // the custom color which independent of theme + indicationColor: '#2F56E7', + publisherId: 0, + contentOwnerId: 0, + playerId: 0, + userId: null, + playerAspectRatio: '', + guid: '', + preEventText: 'Live event will start in', + preEventType: EVENT_UX_TYPES_IMAGE, + preEventId: null, + postEventText: 'Live event ended', + postEventType: EVENT_UX_TYPES_SAME, + postEventId: null, + transitionText: 'Live event will start shortly', + countdown: true, + preEventImages: [], + postEventImages: [], + publishers: [], + publisher: null, + players: [], + isLoading: true, + errors: {}, + isEditContinue: false, + embedCode: '', + activeTabId: TAB_EVENT_DETAILS, + createdPreEventPlaylist: false, + createdPostEventPlaylist: false, + playerConfig: null, + eventStatus: EVENT_STATUS_ACTIVE, + liveStreamRequestFailed: false, + liveCcEnabled: false, + liveCcLang: '', + isTimezoneDisabled: false, + recurrentPeriod: NON_RECURRING, + recurrentEndDate: 0, + displayViewersCounter: false, + viewersAmount: null, + forcePoster: false, + forcePosterType: null, + prevValue: {}, + + tmPlaylist: null, + player: null, + recurrent: null, + publisherInfo: null, + liveEventSchedules: null, + aspectRatio: '', +}; + +export const slice = createSlice({ + name: '@@LIVE_EVENTS/LIVE_EVENT', + initialState, + + reducers: { + getLiveEventDefaultImagesAction: (state) => state, + getLiveEventImageAction: (state) => state, + getLiveEventContentOwnerAction: (state) => state, + getPublishersAction: (state) => state, + getPublisherPlayersAction: (state) => state, + getLiveEventByIdAction: (state) => state, + getLiveEventEmbedCodeAction: (state) => state, + createLiveEventFlowAction: (state) => state, + createLiveEventAction: (state) => state, + createLiveStreamAction: (state) => state, + createLiveEventImageAction: (state) => state, + uploadLiveEventImageFlowAction: (state) => state, + uploadLiveEventImageToS3Action: (state) => state, + updateLiveEventFlowAction: (state) => state, + updateLiveEventAction: (state) => state, + updateLiveStreamAction: (state) => state, + updateLiveEventImageAction: (state) => state, + setIsLoadingAction: (state, action) => { + state.isLoading = action.payload; + }, + setLiveEventDefaultImagesAction: (state) => state, + setLiveEventDataAction: (state, action) => { + Object.keys(action.payload ?? {}).forEach((key) => { + state[key] = action.payload[key]; + }); + state.publisherId = action.payload.player?.publisher?.id; + }, + setLiveStreamDataAction: (state) => state, + setLiveEventEmbedCodeAction: (state, action) => { + state.embedCode = action.payload; + }, + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + setLiveEventSchedulesAction: (state) => state, + updateLiveEventByParamAction: (state) => state, + deleteLiveEventPlaylistAction: (state) => state, + clearFormAction: () => initialState, + setErrorAction: (state, action) => { + Object.keys(action.payload ?? {}).forEach((key) => { + state.errors[key] = action.payload[key]; + }); + }, + testLiveEventEndPointAction: (state) => state, + cancelLiveStreamRequestAction: (state) => state, + createLiveEventEndpointAction: (state) => state, + getPlayerConfigAction: (state) => state, + getTmPlaylistAction: (state) => state, + getPublisherInfoByIdAction: (state) => state, + getViewersCounterAction: (state) => state, + updateViewersCounterStatusAction: (state) => state, + updateForcePosterStatusAction: (state) => state, + updateLiveEventFormAction: (state, action) => { + Object.keys(action.payload ?? {}).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + }, +}); + +export const { + cancelLiveStreamRequestAction, + clearFormAction, + createLiveEventAction, + createLiveEventEndpointAction, + createLiveEventFlowAction, + createLiveEventImageAction, + createLiveStreamAction, + deleteLiveEventPlaylistAction, + getLiveEventByIdAction, + getLiveEventContentOwnerAction, + getLiveEventDefaultImagesAction, + getLiveEventEmbedCodeAction, + getLiveEventImageAction, + getPlayerConfigAction, + getPublisherInfoByIdAction, + getPublisherPlayersAction, + getPublishersAction, + getTmPlaylistAction, + getViewersCounterAction, + setActiveTabIdAction, + setErrorAction, + setIsLoadingAction, + setLiveEventDataAction, + setLiveEventEmbedCodeAction, + setLiveStreamDataAction, + testLiveEventEndPointAction, + updateForcePosterStatusAction, + updateLiveEventAction, + updateLiveEventFlowAction, + updateLiveEventFormAction, + updateLiveEventImageAction, + updateLiveStreamAction, + updateViewersCounterStatusAction, + uploadLiveEventImageFlowAction, + uploadLiveEventImageToS3Action, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/marketplace/account/components/Account.module.scss b/anyclip/src/modules/marketplace/account/components/Account.module.scss new file mode 100644 index 0000000..c85c626 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Account.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Account":"Account_Account__BjmJ8","Header":"Account_Header__h5W3W","SecondHeader":"Account_SecondHeader__5HVw_","Breadcrumbs":"Account_Breadcrumbs__yetn2","Id":"Account_Id__BibXh","Name":"Account_Name__0_Q7w","LastUpdate":"Account_LastUpdate__XRkrI","Tabs":"Account_Tabs__rrqWu","TabContent":"Account_TabContent__yTSpS","TooltipMark":"Account_TooltipMark__70hDM","DuplicateLink":"Account_DuplicateLink__5jnr6","DuplicateIcon":"Account_DuplicateIcon__75Qtf","Tooltip":"Account_Tooltip__iE4l7","TooltipBtn":"Account_TooltipBtn___CjKa","Filters":"Account_Filters__5YKng","SelectControl":"Account_SelectControl__D70Ob"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/components/AdvertiserPricingTab/index.jsx b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/components/AdvertiserPricingTab/index.jsx new file mode 100644 index 0000000..aeddb55 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/components/AdvertiserPricingTab/index.jsx @@ -0,0 +1,203 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { Add } from '@mui/icons-material'; + +import { DEMAND_TAG_PRICING_TYPE } from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import useLocalPagination from '@/modules/marketplace/account/helpers/useLocalPagination'; +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Form, FormContent, FormRow, FormSection } from '@/modules/@common/Form'; +import PricingFormLineItem from '../../../DemandSettings/components/PricingTab/components/FormLineItem'; +import PricingLineItem from '../../../DemandSettings/components/PricingTab/components/LineItem'; +import { Button, Stack, TablePagination } from '@/mui/components'; + +function AdvertiserPricingTab() { + const dispatch = useDispatch(); + + const advertiserSettings = useSelector(selectors.advertiserSettingsSelector); + const { revShare } = advertiserSettings; + + const pageConfig = useSelector(selectors.pageConfigSelector); + const isCreatingNew = pageConfig?.isCreatingNew; + const isDuplicate = pageConfig?.isDuplicate; + + const [showNewRevShareForm, toggleNewRevShareForm] = useState(false); + const paginationRevShare = useLocalPagination(25, 1); + + const onAddNewPrice = (key, data) => { + const state = advertiserSettings[key]; + if (isCreatingNew || isDuplicate) { + dispatch( + actions.setAdvertiserSettingsTabAction({ + [key]: [ + { + id: `${state.length}`, + ...data, + }, + ...state, + ], + }), + ); + } else { + // update with server + dispatch( + actions.updateAdvertiserAction({ + revShare: data, + }), + ); + } + }; + + const onEditPrice = (key, data) => { + dispatch( + actions.setAdvertiserSettingsTabAction({ + [key]: advertiserSettings[key]?.map((i) => (i.id === data.id ? { ...data } : i)), + }), + ); + }; + + const onDeletePrice = (key, id) => { + dispatch( + actions.setAdvertiserSettingsTabAction({ + [key]: advertiserSettings[key]?.filter((i) => i.id !== id), + }), + ); + }; + + return ( +
    + + + + {!!revShare.length && !isDuplicate && ( + + )} + {!revShare.length && !showNewRevShareForm && ( + + )} + + + + + {showNewRevShareForm && ( + { + toggleNewRevShareForm(false); + onAddNewPrice('revShare', { + ...data, + value: parseNumberWithRound(data.rate / 100), + }); + }} + onCancel={() => { + toggleNewRevShareForm(false); + }} + /> + )} + {revShare.slice(paginationRevShare.startIndex, paginationRevShare.endIndex).map((item, index) => { + const canUpdateEndDate = + paginationRevShare.currentPage === 1 && + index === 0 && + ((item.endDate && dayjs(item.endDate).isAfter(new Date().getTime())) || !item.endDate); + // only the last one can be edited and fee end date > current time + // cannot be edited if fee is already closed + return ( + { + onEditPrice('revShare', { + ...data, + value: parseNumberWithRound(data.rate / 100), + }); + // updating end date for last fee + if (canUpdateEndDate && !isCreatingNew && !isDuplicate) { + onAddNewPrice('revShare', { + ...data, + startDate: null, // server update fee only without startDate + rate: null, // server update fee only without value + }); + } + }} + onDelete={(id) => { + onDeletePrice('revShare', id); + }} + /> + ); + })} + {!isCreatingNew && !isDuplicate && !!revShare.length && ( + { + paginationRevShare.setCurrentPage(page); + }} + onRowsPerPageChange={(event) => { + paginationRevShare.setItemsPerPage(+event.target.value); + }} + /> + )} + + + + +
    + ); +} + +export default AdvertiserPricingTab; diff --git a/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/constants/index.ts b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/constants/index.ts new file mode 100644 index 0000000..5351dbb --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/constants/index.ts @@ -0,0 +1 @@ +export const DEMAND_ADVERTISER_SETTINGS_VALIDATION_REDUX_FIELD = 'demandAdvertiserSettingsValidation'; diff --git a/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/helpers/validationScheme.ts b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/helpers/validationScheme.ts new file mode 100644 index 0000000..a43a326 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/helpers/validationScheme.ts @@ -0,0 +1,41 @@ +import { DEMAND_ADVERTISER_PAGE_CONFIG_TAB_SETTINGS_ID } from '@/modules/marketplace/account/constants'; + +export const validationScheme = [ + { + fieldName: 'advertiserSettings.name', + tabId: DEMAND_ADVERTISER_PAGE_CONFIG_TAB_SETTINGS_ID, + validation: (value: string) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'advertiserSettings.demandAccountId', + tabId: DEMAND_ADVERTISER_PAGE_CONFIG_TAB_SETTINGS_ID, + validation: (value: string, store: { pageConfig: { isCreateNew: boolean } }) => { + if (!store.pageConfig.isCreateNew) { + return ''; + } + + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'advertiserSettings.frequencyCapAdjustmentThreshold', + tabId: DEMAND_ADVERTISER_PAGE_CONFIG_TAB_SETTINGS_ID, + validation: (value: number) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/index.jsx b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/index.jsx new file mode 100644 index 0000000..b738f62 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/AdvertiserSettings/index.jsx @@ -0,0 +1,332 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { DEMAND_TAG_DEFAULT_TIER_VALUES } from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../redux/selectors'; +import * as actions from '../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Form, FormContent, FormGroupTitle, FormRow, FormSection } from '@/modules/@common/Form'; +import FormImageUploader from '@/modules/@common/Form/FormImageUploader/FormImageUploader'; +import ActionAutocomplete from '../DemandSettings/components/TargetingTab/components/ActionAutocomplete'; +import { Autocomplete, NumberField, Switch, TextField, ToggleButton, ToggleButtonGroup } from '@/mui/components'; + +function AdvertiserSettingsTab() { + const dispatch = useDispatch(); + + const scheme = useSelector(selectors.advertiserSettingsValidationSchemeSelector); + + const userPermissions = useSelector(getUserPermissionsSelector); + const advertiserSettings = useSelector(selectors.advertiserSettingsSelector); + + const { + countries, + tier, + name, + demandAccountId, + pbsEnabled, + profitability, + frequencyCapAdjustment, + frequencyCapAdjustmentPerSupply, + frequencyCapAdjustmentThreshold, + errors, + logo, + } = advertiserSettings; + const userDemandAccounts = useSelector(selectors.userDemandAccountsSelector); + const pageConfig = useSelector(selectors.pageConfigSelector); + const targeting = useSelector(selectors.targetingSelector); + const { countriesList } = targeting; + const info = useSelector(selectors.infoSelector); + + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + return ( +
    + + + Details + + { + const { name: nameError, ...restErrors } = errors; + dispatch( + actions.setAdvertiserSettingsTabAction({ + name: target.value, + errors: restErrors, + }), + ); + }} + {...getInputPropsByName(scheme, ['advertiserSettings.name'])} + onFocus={() => + dispatch(actions.advertiserSettingsValidationRemoveErrorByPropAction(['advertiserSettings.name'])) + } + /> + + + item.id === demandAccountId) + : { id: demandAccountId, name: info?.accountName ?? '' } + } + options={userDemandAccounts} + optionLabelKey="name" + onChange={(e, option) => { + const { demandAccountId: error, ...restErrors } = errors; + dispatch( + actions.setAdvertiserSettingsTabAction({ + demandAccountId: option?.id ?? null, + errors: restErrors, + }), + ); + }} + onOpen={() => { + if (isAdminMP) { + dispatch(actions.getHubsAndDemandAccountsAction()); + } + }} + size="small" + disabled={!pageConfig?.isCreatingNew} + renderInput={(params) => ( + { + if (isAdminMP) { + dispatch( + actions.getHubsAndDemandAccountsAction({ + search: target.value, + }), + ); + } + }} + size="small" + {...getInputPropsByName(scheme, ['advertiserSettings.demandAccountId'])} + onFocus={() => + dispatch( + actions.advertiserSettingsValidationRemoveErrorByPropAction([ + 'advertiserSettings.demandAccountId', + ]), + ) + } + /> + )} + /> + + + + dispatch(actions.setAdvertiserSettingsTabAction({ countries: data }))} + size="small" + /> + + + + + dispatch( + actions.setAdvertiserSettingsTabAction({ + pbsEnabled: !pbsEnabled, + }), + ) + } + /> + + + {isAdminMP && ( + <> + + + {DEMAND_TAG_DEFAULT_TIER_VALUES.map((item) => ( + + dispatch( + actions.setAdvertiserSettingsTabAction({ + tier: item.value, + }), + ) + } + > + {item.value} + + ))} + + + + + + dispatch( + actions.setAdvertiserSettingsTabAction({ + profitability: !profitability, + }), + ) + } + /> + + + )} + + + { + const nextValue = !frequencyCapAdjustment; + + dispatch( + actions.setAdvertiserSettingsTabAction({ + frequencyCapAdjustment: nextValue, + }), + ); + + if (nextValue) { + dispatch( + actions.setAdvertiserSettingsTabAction({ + frequencyCapAdjustmentPerSupply: nextValue, + }), + ); + } + }} + /> + + + {frequencyCapAdjustment && ( + <> + + + dispatch( + actions.setAdvertiserSettingsTabAction({ + frequencyCapAdjustmentPerSupply: !frequencyCapAdjustmentPerSupply, + }), + ) + } + /> + + + { + const { frequencyCapAdjustmentThreshold: skipError, ...restErrors } = errors; + dispatch( + actions.setAdvertiserSettingsTabAction({ + frequencyCapAdjustmentThreshold: target.value?.length + ? parseNumberWithRound(Math.trunc(target.value) / 100) + : '', + errors: restErrors, + }), + ); + }} + {...getInputPropsByName(scheme, ['advertiserSettings.frequencyCapAdjustmentThreshold'])} + onFocus={() => + dispatch( + actions.advertiserSettingsValidationRemoveErrorByPropAction([ + 'advertiserSettings.frequencyCapAdjustmentThreshold', + ]), + ) + } + /> + + + )} + + + { + const allowedExtensions = ['jpg', 'jpeg', 'png']; + const extension = file.name.split('.').pop(); + + if (!allowedExtensions.some((ext) => ext === extension)) { + dispatch( + showNotificationAction({ + type: TYPE_ERROR, + message: `Invalid file extension, valid only (${allowedExtensions.join(',')})`, + }), + ); + + return false; + } + + return true; + }} + onRemove={() => { + dispatch(actions.setAdvertiserSettingsTabAction({ logo: '', logoFile: null })); + }} + onLoad={(event, logoFile, logoBase64Url) => { + dispatch(actions.setAdvertiserSettingsTabAction({ logo: logoBase64Url, logoFile })); + }} + /> + + + +
    + ); +} + +export default AdvertiserSettingsTab; diff --git a/anyclip/src/modules/marketplace/account/components/Breadcrumbs/Breadcrumbs.module.scss b/anyclip/src/modules/marketplace/account/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 0000000..c8a4355 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Breadcrumbs":"Breadcrumbs_Breadcrumbs__lVahc","Breadcrumbs_truncated":"Breadcrumbs_Breadcrumbs_truncated__Z8sIN","BreadcrumbsList":"Breadcrumbs_BreadcrumbsList__u1QQ6","BreadcrumbsItem":"Breadcrumbs_BreadcrumbsItem__R_5d9"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Breadcrumbs/index.jsx b/anyclip/src/modules/marketplace/account/components/Breadcrumbs/index.jsx new file mode 100644 index 0000000..5f94f7b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Breadcrumbs/index.jsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import NextLink from 'next/link'; +import { ContentCopyRounded } from '@mui/icons-material'; + +import copyToClipboard from '@/modules/@common/helpers/copy'; + +import { Breadcrumbs, IconButton, Link, Skeleton, Stack, Tooltip, Typography } from '@/mui/components'; + +import styles from './Breadcrumbs.module.scss'; + +function LinkRouter(props) { + return ; +} + +function BreadcrumbsHeader({ info = null, ...props }) { + const ref = useRef(null); + const lastBreadcrumb = props.breadcrumbs[props.breadcrumbs.length - 1].label; + + const [showTooltip, setShowTooltip] = useState(false); + + useEffect(() => { + const handleResize = () => { + const breadcrumbs = ref.current; + setShowTooltip(breadcrumbs.scrollWidth > breadcrumbs.offsetWidth); + }; + + handleResize(); + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [info]); + + return props.breadcrumbs?.length ? ( + + {props.breadcrumbs + .filter((_, i) => i !== props.breadcrumbs.length - 1) + .map((item) => ( + + {!item.label && } + {item.label && ( + + {item.label} + + )} + + ))} + + + {showTooltip ? ( + + {lastBreadcrumb} + copyToClipboard(lastBreadcrumb)} size="small"> + + + + } + placement="top-start" + leaveDelay={1000} + > +
    + + {lastBreadcrumb} + +
    + + ) : ( + + {lastBreadcrumb} + + )} + +
    + ) : null; +} + +BreadcrumbsHeader.propTypes = { + breadcrumbs: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + link: PropTypes.string, + }), + ).isRequired, + info: PropTypes.shape({ + created: PropTypes.number, + updated: PropTypes.number, + updatedBy: PropTypes.string, + }), +}; + +export default BreadcrumbsHeader; diff --git a/anyclip/src/modules/marketplace/account/components/Cells/IdCell.jsx b/anyclip/src/modules/marketplace/account/components/Cells/IdCell.jsx new file mode 100644 index 0000000..2fdea7c --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Cells/IdCell.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ContentCopyRounded } from '@mui/icons-material'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import copyToClipboard from '@/modules/@common/helpers/copy'; + +import { IconButton, Tooltip } from '@/mui/components'; + +import styles from './IdCell.module.scss'; + +function IdCell(props) { + const handleCopy = (e) => { + e.preventDefault(); + copyToClipboard(props.cell).then(() => { + // eslint-disable-next-line react/prop-types + props.showNotification({ + type: TYPE_SUCCESS, + message: 'Tag ID was copied to clipboard', + }); + }); + }; + + return ( + + {props.cell} + + + +
    + } + > +
    {props.cell}
    + + ); +} + +IdCell.propTypes = { + cell: PropTypes.string.isRequired, +}; + +export default IdCell; diff --git a/anyclip/src/modules/marketplace/account/components/Cells/IdCell.module.scss b/anyclip/src/modules/marketplace/account/components/Cells/IdCell.module.scss new file mode 100644 index 0000000..67a2b9d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Cells/IdCell.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"IdCell":"IdCell_IdCell__5AiEv","Tooltip":"IdCell_Tooltip__nFGZ9"}; \ No newline at end of file diff --git a/src/modules/marketplace/account/components/Cells/NameCell.jsx b/anyclip/src/modules/marketplace/account/components/Cells/NameCell.jsx similarity index 100% rename from src/modules/marketplace/account/components/Cells/NameCell.jsx rename to anyclip/src/modules/marketplace/account/components/Cells/NameCell.jsx diff --git a/src/modules/marketplace/account/components/Cells/NameCell.module.scss b/anyclip/src/modules/marketplace/account/components/Cells/NameCell.module.scss similarity index 100% rename from src/modules/marketplace/account/components/Cells/NameCell.module.scss rename to anyclip/src/modules/marketplace/account/components/Cells/NameCell.module.scss diff --git a/src/modules/marketplace/account/components/Cells/TargetingCell.jsx b/anyclip/src/modules/marketplace/account/components/Cells/TargetingCell.jsx similarity index 100% rename from src/modules/marketplace/account/components/Cells/TargetingCell.jsx rename to anyclip/src/modules/marketplace/account/components/Cells/TargetingCell.jsx diff --git a/src/modules/marketplace/account/components/Cells/TargetingCell.module.scss b/anyclip/src/modules/marketplace/account/components/Cells/TargetingCell.module.scss similarity index 100% rename from src/modules/marketplace/account/components/Cells/TargetingCell.module.scss rename to anyclip/src/modules/marketplace/account/components/Cells/TargetingCell.module.scss diff --git a/anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.jsx b/anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.jsx new file mode 100644 index 0000000..9152ce0 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.jsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { TextField } from '@/mui/components'; + +import styles from './TextFieldCell.module.scss'; + +function TextFieldCell({ + validate = null, + id = null, + stopRefreshInterval = null, + resetRefreshInterval = null, + disabled = false, + ...props +}) { + const [value, setValue] = useState(null); + + const handleChange = (e) => { + if (validate) { + const newValue = validate(e.target.value); + if (newValue !== null) { + setValue(newValue); + if (newValue !== '') { + props.onChange({ value: newValue, id }); + } + } + } else { + setValue(e.target.value); + props.onChange(e.target.value); + } + }; + + return ( + { + e.stopPropagation(); + e.preventDefault(); + }} + onFocus={() => { + if (stopRefreshInterval) { + stopRefreshInterval(); + } + setValue(props.cell); + }} + onBlur={() => { + if (resetRefreshInterval) { + resetRefreshInterval(); + } + setValue(null); + }} + variant="outlined" + size="small" + disabled={disabled} + /> + ); +} + +TextFieldCell.propTypes = { + cell: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + validate: PropTypes.func, + id: PropTypes.string, + stopRefreshInterval: PropTypes.func, + resetRefreshInterval: PropTypes.func, + disabled: PropTypes.bool, +}; + +export default TextFieldCell; diff --git a/anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.module.scss b/anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.module.scss new file mode 100644 index 0000000..42b7f8b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Cells/TextFieldCell.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"TextField":"TextFieldCell_TextField__KzXvW"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Cells/TierCell.jsx b/anyclip/src/modules/marketplace/account/components/Cells/TierCell.jsx new file mode 100644 index 0000000..3cc07ee --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Cells/TierCell.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { MenuItem, Select } from '@/mui/components'; + +import styles from './TierCell.module.scss'; + +function TierCell({ id = null, disabled = false, ...props }) { + const handleChange = (e) => { + props.onChange({ value: e.target.value, id }); + }; + + return ( +
    { + e.stopPropagation(); + e.preventDefault(); + }} + aria-hidden="true" + > + +
    + ); +} + +TierCell.propTypes = { + cell: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + id: PropTypes.string, + stopRefreshInterval: PropTypes.func.isRequired, + resetRefreshInterval: PropTypes.func.isRequired, + disabled: PropTypes.bool, + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.number, + }), + ).isRequired, +}; + +export default TierCell; diff --git a/anyclip/src/modules/marketplace/account/components/Cells/TierCell.module.scss b/anyclip/src/modules/marketplace/account/components/Cells/TierCell.module.scss new file mode 100644 index 0000000..01488e4 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Cells/TierCell.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SelectWrap":"TierCell_SelectWrap__8Ia7G"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Chart/Chart.module.scss b/anyclip/src/modules/marketplace/account/components/Chart/Chart.module.scss new file mode 100644 index 0000000..6f41a01 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Chart/Chart.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Chart":"Chart_Chart__V_EL1","ChartFilters":"Chart_ChartFilters__dETNe","Select":"Chart_Select__QYMhe"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Chart/index.jsx b/anyclip/src/modules/marketplace/account/components/Chart/index.jsx new file mode 100644 index 0000000..3f4101d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Chart/index.jsx @@ -0,0 +1,242 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useTheme } from '@mui/material/styles'; + +import { CHART_TABS, CHART_TIME_INTERVAL_TAB } from '../../constants'; +import { + PRIMARY_LEFT_Y_AXIS_ID, + PRIMARY_RIGHT_Y_AXIS_ID, + SECOND_LEFT_Y_AXIS_ID, + SECOND_RIGHT_Y_AXIS_ID, +} from '@/modules/marketplace/common/constants'; + +import { abbreviateNumber } from '@/modules/@common/helpers/number'; +import { tooltipValueFormatter } from '@/modules/marketplace/common/helpers/histogram'; + +import Histogram from '@/modules/marketplace/common/Chart'; +import { FormControl, MenuItem, Select, ToggleButton, ToggleButtonGroup } from '@/mui/components'; + +import styles from './Chart.module.scss'; + +function Chart({ timeIntervalFilter = null, comparisonFilter = null, ...props }) { + const theme = useTheme(); + + let yAxis = [ + { + key: 0, + id: PRIMARY_RIGHT_Y_AXIS_ID, + orientation: 'right', + stroke: theme.palette['-graph'][13], + tickFormatter: (tick) => `${abbreviateNumber(tick)}%`, + }, + { + key: 1, + id: SECOND_RIGHT_Y_AXIS_ID, + orientation: 'right', + stroke: props.chartTab === CHART_TIME_INTERVAL_TAB ? theme.palette['-graph'][14] : 'transparent', + tickFormatter: (tick) => `$${abbreviateNumber(tick)}`, + }, + ]; + + if (props.chartTab === CHART_TIME_INTERVAL_TAB) { + yAxis = [ + ...yAxis, + { + key: 3, + id: PRIMARY_LEFT_Y_AXIS_ID, + orientation: 'left', + stroke: theme.palette['-graph'][15], + tickFormatter: (tick) => abbreviateNumber(tick), + }, + { + key: 4, + id: SECOND_LEFT_Y_AXIS_ID, + orientation: 'left', + stroke: props.chartTab === CHART_TIME_INTERVAL_TAB ? theme.palette['-graph'][16] : 'transparent', + tickFormatter: (tick) => abbreviateNumber(tick), + }, + { + key: 5, + id: 'log', + orientation: 'right', + stroke: 'black', + domain: [0, 'dataMax + 3'], + hide: true, + }, + ]; + } + + if (props.chartTab !== CHART_TIME_INTERVAL_TAB) { + yAxis = [ + ...yAxis, + { + key: 3, + id: PRIMARY_LEFT_Y_AXIS_ID, + orientation: 'left', + tickFormatter: (tick) => { + if ( + ['REQUESTS_FILL', 'OVERALL_FILL', 'OPPORTUNITIES_FILL', 'SUPPLY_REQUESTS_FILL'].includes(comparisonFilter) + ) { + return `${abbreviateNumber(tick)}%`; + } + if ( + [ + 'REVENUE', + 'GROSS_REVENUE', + 'PUB_REVENUE', + 'MEDIA_COST', + 'RPM', + 'CPM', + 'ERPM', + 'PUB_REQ_ERPM', + 'PUB_ERPM', + 'PUB_PLAYER_ERPM', + 'PLAYER_ERPM', + 'GROSS_PLAYER_ERPM', + 'AC_AD_RPM', + 'GROSS_RPM', + 'PUB_AD_RPM', + ].includes(comparisonFilter) + ) { + return `$${abbreviateNumber(tick)}`; + } + + return abbreviateNumber(tick); + }, + }, + { + key: 4, + id: SECOND_LEFT_Y_AXIS_ID, + orientation: 'left', + stroke: props.chartTab === CHART_TIME_INTERVAL_TAB ? theme.palette['-graph'][16] : 'transparent', + tickFormatter: (tick) => abbreviateNumber(tick), + }, + { + key: 5, + id: 'log', + orientation: 'right', + stroke: 'black', + domain: [0, 'dataMax + 3'], + hide: true, + }, + ]; + } + + const chartData = [...(props.chartData ?? [])]; + + if ( + props.chartTab === CHART_TIME_INTERVAL_TAB && + (timeIntervalFilter?.interval === '1m' || timeIntervalFilter?.interval === '5m') && + props.chartHistory?.length && + chartData?.length + ) { + props.chartHistory.forEach((log) => { + const closestChartItem = chartData.reduce((acc, cur, index) => { + if (Math.abs(log.created - cur.time) < Math.abs(log.created - acc.time)) { + return { ...cur, index }; + } + return acc; + }); + + if (closestChartItem) { + chartData[closestChartItem.index] = { + ...closestChartItem, + LOG: 2, + logValues: [...(closestChartItem?.logValues ?? []), log], + }; + } + }); + } + return ( +
    +
    + + {CHART_TABS.map((tab) => ( + { + props.resetRefreshInterval(); + props.setChartTab(tab.value); + }} + > + {tab.label} + + ))} + + + + + +
    + {!props.isChartLoading && !!props.chartData?.length && ( + + tooltipValueFormatter(props.chartTab !== CHART_TIME_INTERVAL_TAB ? comparisonFilter : name, value) + } + /> + )} +
    + ); +} + +Chart.propTypes = { + chartTab: PropTypes.string.isRequired, + chartData: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + chartHistory: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + pageConfig: PropTypes.shape({ + timeIntervalParams: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + comparisonParams: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + timeIntervalOptions: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + comparisonOptions: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + chartLogParams: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + }).isRequired, + setChartTab: PropTypes.func.isRequired, + timeIntervalFilter: PropTypes.shape({ + interval: PropTypes.string, + }), + comparisonFilter: PropTypes.string, + setTimeIntervalFilter: PropTypes.func.isRequired, + setComparisonFilter: PropTypes.func.isRequired, + isChartLoading: PropTypes.bool.isRequired, + stopRefreshInterval: PropTypes.func.isRequired, + resetRefreshInterval: PropTypes.func.isRequired, +}; + +export default Chart; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.jsx new file mode 100644 index 0000000..0011ca5 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.jsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { Check, Clear } from '@mui/icons-material'; + +import { + DEMAND_TAG_PAGE_BUDGET_PACING_LIST, + DEMAND_TAG_PAGE_BUDGET_PACING_TYPES, + DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_LIST, + DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES, + DEMAND_TAG_PAGE_BUDGET_TYPE_LIST, + DEMAND_TAG_PAGE_BUDGET_TYPES, +} from '@/modules/marketplace/account/constants'; + +import { integerFieldBlockInvalidChar } from '@/modules/marketplace/account/helpers/validate'; + +import { Grid, IconButton, MenuItem, Select, TextField } from '@/mui/components'; + +import styles from './index.module.scss'; + +function FormLineItem({ className = '', ...props }) { + const budget = 1; + + const [budget$, setBudget] = useState(budget); + const [type$, setType] = useState(DEMAND_TAG_PAGE_BUDGET_TYPES.impression); + const [timeFrame$, setTimeFrame] = useState(DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES.hourly); + const [pacing$, setPacing] = useState(DEMAND_TAG_PAGE_BUDGET_PACING_TYPES.even); + + return ( + + + { + setBudget(target.value); + }} + variant="outlined" + size="small" + /> + + + + + + + + + + + +
    + + props.onSubmit({ + budget: budget$, + type: type$, + timeFrame: timeFrame$, + pacing: pacing$, + }) + } + > + + +
    +
    + props.onCancel()}> + + +
    +
    +
    + ); +} + +FormLineItem.propTypes = { + className: PropTypes.string, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + checkHasDuplicate: PropTypes.func.isRequired, +}; + +export default FormLineItem; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.module.scss new file mode 100644 index 0000000..ba04ea0 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/FormLineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"FormLineItem_Container__qkpV_","Container___label":"FormLineItem_Container___label__iw6dK","Container___edit":"FormLineItem_Container___edit__lvwZu","Container___checked":"FormLineItem_Container___checked__69tjG","Item":"FormLineItem_Item__nvARi","Name":"FormLineItem_Name__C_5LS","Buttons":"FormLineItem_Buttons__AgUNN","ButtonEdit":"FormLineItem_ButtonEdit__jpkm8"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.jsx new file mode 100644 index 0000000..d9cca11 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.jsx @@ -0,0 +1,237 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { Check, Clear, Delete, Edit } from '@mui/icons-material'; + +import { + DEMAND_TAG_PAGE_BUDGET_PACING_LIST, + DEMAND_TAG_PAGE_BUDGET_PACING_TYPES, + DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_LIST, + DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES, + DEMAND_TAG_PAGE_BUDGET_TYPE_LIST, +} from '@/modules/marketplace/account/constants'; + +import { getLabelByValue } from '@/modules/marketplace/account/helpers/demandTabs'; +import { integerFieldBlockInvalidChar } from '@/modules/marketplace/account/helpers/validate'; + +import { Grid, IconButton, MenuItem, Select, TextField } from '@/mui/components'; + +import styles from './index.module.scss'; + +function LineItem({ + className = '', + isLabelRow = false, + pacing = '', + onSubmit = () => null, + onDelete = () => null, + ...props +}) { + const initType = props.type; + const initTimeFrame = props.timeFrame; + const initPacing = pacing; + + const [isEdit, setEditState] = useState(false); + const [budget$, setBudget] = useState(props.budget); + const [type$, setType] = useState(initType); + const [timeFrame$, setTimeFrame] = useState(initTimeFrame); + const [pacing$, setPacing] = useState(initPacing); + + const onCancel = () => { + setBudget(props.budget); + setType(initType); + setTimeFrame(initTimeFrame); + setPacing(initPacing); + }; + + const isDuplicateItem = (record) => { + if (initType === record.type && initTimeFrame === record.timeFrame && initPacing === record.pacing) { + return false; + } + + return props.checkHasDuplicate(record); + }; + + return ( + + + {!isEdit ? ( + props.budget + ) : ( + setBudget(target.value)} + variant="outlined" + /> + )} + + + {!isEdit ? ( + getLabelByValue(DEMAND_TAG_PAGE_BUDGET_TYPE_LIST, props.type) || props.type + ) : ( + + )} + + + {!isEdit ? ( + getLabelByValue(DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_LIST, props.timeFrame) || props.timeFrame + ) : ( + + )} + + + {!isEdit ? ( + getLabelByValue(DEMAND_TAG_PAGE_BUDGET_PACING_LIST, pacing) || pacing + ) : ( + + )} + + {!isLabelRow && ( + + {!isEdit && ( + <> +
    + { + setEditState(true); + }} + > + + +
    +
    + onDelete(props.id)} + > + + +
    + + )} + {isEdit && ( + <> +
    + { + setEditState(false); + onSubmit({ + id: props.id, + budget: budget$, + type: type$, + timeFrame: timeFrame$, + pacing: pacing$, + }); + }} + > + + +
    +
    + { + setEditState(false); + onCancel(); + }} + > + + +
    + + )} +
    + )} +
    + ); +} + +LineItem.propTypes = { + id: PropTypes.string.isRequired, + budget: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + timeFrame: PropTypes.string.isRequired, + pacing: PropTypes.string, + className: PropTypes.string, + isLabelRow: PropTypes.bool, + onSubmit: PropTypes.func, + onDelete: PropTypes.func, + checkHasDuplicate: PropTypes.func.isRequired, +}; + +export default LineItem; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.module.scss new file mode 100644 index 0000000..76391db --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/components/LineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"LineItem_Container__jA16_","Container___label":"LineItem_Container___label__0Niov","Container___edit":"LineItem_Container___edit__Cy2RM","Container___checked":"LineItem_Container___checked__Exypq","Item":"LineItem_Item__BttgN","Name":"LineItem_Name__Y3rQ_","Buttons":"LineItem_Buttons___O_eA","ButtonEdit":"LineItem_ButtonEdit__u4OB7"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/index.jsx new file mode 100644 index 0000000..95632f6 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/BudgetingTab/index.jsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Add } from '@mui/icons-material'; + +import { DEMAND_TAG_EVENT_BUDGETS_SIZE } from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { isEqual } from '@/modules/@common/helpers'; +import { getNextId } from '@/modules/marketplace/common/helpers'; + +import { Form, FormContent, FormRow, FormSection } from '@/modules/@common/Form'; +import FormLineItem from './components/FormLineItem'; +import LineItem from './components/LineItem'; +import { Button, Stack } from '@/mui/components'; + +function BudgetingTab() { + const dispatch = useDispatch(); + + const budgeting = useSelector(selectors.budgetingSelector); + const { budgetingTabList } = budgeting; + + const [showNewForm, toggleNewFrom] = useState(false); + + const isBudgetingRecordsHasDuplicate = (records) => (record) => { + const hasDuplicate = records.some(({ id, budget, ...rest }) => isEqual(rest, record)); + return hasDuplicate; + }; + return ( +
    + + + + {!!budgetingTabList.length && ( + + )} + {!budgetingTabList.length && !showNewForm && ( + + )} + + + + + {showNewForm && ( + { + toggleNewFrom(false); + dispatch( + actions.setBudgetingTabAction({ + budgetingTabList: [ + { + id: getNextId(budgetingTabList), + ...data, + budget: parseFloat(data.budget), + }, + ...budgetingTabList, + ], + }), + ); + }} + onCancel={() => { + toggleNewFrom(false); + }} + /> + )} + {budgetingTabList.map((item) => ( + + dispatch( + actions.setBudgetingTabAction({ + budgetingTabList: budgetingTabList.map((i) => + i.id === data.id ? { ...data, budget: parseFloat(data.budget) } : i, + ), + }), + ) + } + onDelete={(id) => + dispatch( + actions.setBudgetingTabAction({ + budgetingTabList: budgetingTabList.filter((i) => i.id !== id), + }), + ) + } + /> + ))} + + + + +
    + ); +} + +export default BudgetingTab; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/FrequencyCapTab/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/FrequencyCapTab/index.jsx new file mode 100644 index 0000000..61dc991 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/FrequencyCapTab/index.jsx @@ -0,0 +1,176 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + DEMAND_TAG_FREQUENCY_CAP_TIMEFRAME_LIST, + DEMAND_TAG_FREQUENCY_CAP_TYPE_LIST, + DEMAND_TAG_FREQUENCY_TAB_STATUS, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { integerFieldBlockInvalidChar } from '@/modules/marketplace/account/helpers/validate'; + +import { Form, FormContent, FormGroup, FormRow, FormRowItem, FormSection } from '@/modules/@common/Form'; +import { + FormControlLabel, + MenuItem, + Select, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, +} from '@/mui/components'; + +function FrequencyCapTab() { + const dispatch = useDispatch(); + + const frequencyCap = useSelector(selectors.frequencyCapSelector); + const { status, type, value, timeframe, amount, prevSavedData } = frequencyCap; + + const pageConfig = useSelector(selectors.pageConfigSelector); + + const { isCreatingNew, isDuplicate } = pageConfig ?? {}; + + const [isEnabled, setEnabled] = useState(DEMAND_TAG_FREQUENCY_TAB_STATUS.active === status); + + useEffect(() => { + setEnabled(DEMAND_TAG_FREQUENCY_TAB_STATUS.active === status); + }, [status]); + + const savePrevFrequencyCap = () => { + if (!isCreatingNew && !isDuplicate && !prevSavedData) { + dispatch( + actions.setFrequencyCapTabAction({ + prevSavedData: { + status, + type, + value, + timeframe, + amount, + }, + }), + ); + } + }; + + const handleChangeStatus = ({ target }) => { + savePrevFrequencyCap(); + setEnabled(target.checked); + dispatch( + actions.setFrequencyCapTabAction({ + status: target.checked ? DEMAND_TAG_FREQUENCY_TAB_STATUS.active : DEMAND_TAG_FREQUENCY_TAB_STATUS.disabled, + }), + ); + }; + + const handleSetAmount = ({ target }) => { + savePrevFrequencyCap(); + dispatch( + actions.setFrequencyCapTabAction({ + amount: +target.value > 0 ? +target.value : '', + }), + ); + }; + + const handleSetFrequency = ({ target }) => { + savePrevFrequencyCap(); + dispatch(actions.setFrequencyCapTabAction({ timeframe: target.value })); + }; + + const handleSetValue = ({ target }) => { + savePrevFrequencyCap(); + dispatch( + actions.setFrequencyCapTabAction({ + value: +target.value > 0 ? +target.value : '', + }), + ); + }; + + const handleSetType = (typeParam) => { + savePrevFrequencyCap(); + dispatch( + actions.setFrequencyCapTabAction({ + type: typeParam, + }), + ); + }; + + return ( +
    + + + + } + labelPlacement="start" + /> + + {isEnabled && ( + + + Every + + + + + + + handleSetType(DEMAND_TAG_FREQUENCY_CAP_TYPE_LIST.request)} + > + Request + + handleSetType(DEMAND_TAG_FREQUENCY_CAP_TYPE_LIST.impressions)} + > + Impressions + + + + + )} + + +
    + ); +} + +export default FrequencyCapTab; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/BusinessModel/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/BusinessModel/index.jsx new file mode 100644 index 0000000..387fd5f --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/BusinessModel/index.jsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + DEMAND_TAG_HB_GAM_PLATFORMS, + DEMAND_TAG_PAGE_BUSINESS_MODEL, + DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM, + DEMAND_TAG_PAGE_PRICING_TYPE, + DEMAND_TAG_PAGE_SOURCE, + DEMAND_TAG_PAGE_TYPE_VALUES, + DEMAND_TAG_PRICING_TYPE, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../../../redux/selectors'; +import * as actions from '../../../../../../redux/slices'; +import { getRequiredInputProps } from '@/modules/@common/Form/helpers'; + +import { FormRow } from '@/modules/@common/Form'; +import { Autocomplete, MenuItem, Select, TextField } from '@/mui/components'; + +function BusinessModel(props) { + const dispatch = useDispatch(); + + const pricing = useSelector(selectors.pricingSelector); + const settings = useSelector(selectors.settingsSelector); + + const { model, source, seatId, type, adUnitId, errors } = pricing; + + const { type: tagType, platform } = settings; + + const showAdditionalFields = model === DEMAND_TAG_PAGE_BUSINESS_MODEL[1].value; + + const modelOptions = + tagType === DEMAND_TAG_PAGE_TYPE_VALUES.hb && !DEMAND_TAG_HB_GAM_PLATFORMS.includes(platform.code) + ? DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM + : DEMAND_TAG_PAGE_BUSINESS_MODEL; + + return ( + <> + + + + {showAdditionalFields && ( + + item.value === source) : null} + options={DEMAND_TAG_PAGE_SOURCE} + onChange={(e, option) => { + dispatch( + actions.setPricingTabAction({ + source: option.value, + seatId: '', + adUnitId: '', + errors: {}, + }), + ); + }} + size="small" + disableClearable + renderInput={(params) => ( + + )} + /> + { + const { seatId: seatIdValue, ...restErrors } = errors; + dispatch(actions.setPricingTabAction({ seatId: target.value, errors: restErrors })); + }} + {...getRequiredInputProps({ + label: source !== DEMAND_TAG_PAGE_SOURCE[1].value ? 'Required' : '', + fieldName: 'seatId', + error: !!errors?.seatId?.length, + helperText: errors?.seatId && errors?.seatId !== 'Required' ? errors?.seatId : '', + })} + disabled={source === DEMAND_TAG_PAGE_SOURCE[1].value} + /> + item.value === type) : null} + options={DEMAND_TAG_PAGE_PRICING_TYPE} + onChange={(e, option) => { + dispatch( + actions.setPricingTabAction({ + type: option.value, + errors: {}, + }), + ); + }} + size="small" + disableClearable + renderInput={(params) => ( + + )} + /> + { + const { adUnitId: adUnitIdValue, ...restErrors } = errors; + dispatch(actions.setPricingTabAction({ adUnitId: target.value, errors: restErrors })); + }} + {...getRequiredInputProps({ + fieldName: 'adUnitId', + error: !!errors?.adUnitId?.length, + helperText: errors?.adUnitId && errors?.adUnitId !== 'Required' ? errors?.adUnitId : '', + })} + /> + + )} + + ); +} + +export default BusinessModel; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.jsx new file mode 100644 index 0000000..dbb7db4 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.jsx @@ -0,0 +1,223 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import isSameOrAfterPlugin from 'dayjs/plugin/isSameOrAfter'; +import { Check, Clear } from '@mui/icons-material'; + +import { + DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_LIST, + DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE, + DEMAND_TAG_PRICING_TYPE, + SUPPLY_TAG_PRICING_TYPE_EXPENSES, +} from '@/modules/marketplace/account/constants'; + +import { DateTimePicker, Grid, IconButton, MenuItem, Select, TextField } from '@/mui/components'; + +import styles from './index.module.scss'; + +dayjs.extend(isSameOrAfterPlugin); + +const getStartDateWhenIsCreatingNewTag = () => new Date().getTime(); +const getStartDateWhenIsUpdatingNewTag = (prevStartDate) => new Date(prevStartDate).getTime(); +const isPrevStartDateIsSameOrAfterToday = (prevStartDate) => dayjs(prevStartDate).isSameOrAfter(new Date().getTime()); +const isShouldDisableCalendarDate = (prevStartDate) => (dateFromCalendar) => + dayjs(prevStartDate).add(-1, 'days').isAfter(dateFromCalendar); +const isShouldDisabledSubmit = (prevStartDate, startDate) => { + if (dayjs(prevStartDate).isSame(new Date().getTime())) { + return false; + } + return dayjs(prevStartDate).isSameOrAfter(startDate); +}; + +function FormLineItem({ + className = '', + isCreatingNew = false, + prevStartDate = 0, + isZeroAllowed = false, + isEditableEndDate = false, + defaultRate = 0, + ...props +}) { + const getPrevStartDate = () => { + if (isPrevStartDateIsSameOrAfterToday(prevStartDate)) { + return new Date(prevStartDate).getTime(); + } + + return new Date().getTime(); + }; + + // eslint-disable-next-line react/prop-types + const [model$, setModel] = useState(props.defaultModel || DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_LIST[0].value); + const [rate$, setRate] = useState(defaultRate); + const [startDate$, setStartDate] = useState( + isCreatingNew ? getStartDateWhenIsCreatingNewTag() : getStartDateWhenIsUpdatingNewTag(getPrevStartDate()), + ); + const [endDate$, setEndDate] = useState(null); + const [isZeroAllowed$, setIsZeroAllowed] = useState(isZeroAllowed || false); + + useEffect(() => { + if (!isCreatingNew) { + setStartDate(getStartDateWhenIsUpdatingNewTag(getPrevStartDate())); + } + }, [prevStartDate]); + + const handleSubmit = () => { + const startDate = + !isCreatingNew && dayjs(getPrevStartDate()).isSameOrAfter(startDate$) ? null : new Date(startDate$).getTime(); + + props.onSubmit({ + rate: rate$, + startDate, + endDate: endDate$ ? new Date(endDate$).getTime() : null, + status: 'active', + ...(props.itemType === DEMAND_TAG_PRICING_TYPE.adServing ? { model: model$ } : {}), + }); + }; + + const handleSetRate = ({ target }) => { + const rate = + ((props.itemType === DEMAND_TAG_PRICING_TYPE.fee || + props.itemType === SUPPLY_TAG_PRICING_TYPE_EXPENSES || + (props.itemType === DEMAND_TAG_PRICING_TYPE.adServing && + model$ === DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE)) && + parseFloat(target.value) > 100) || + (props.itemType === DEMAND_TAG_PRICING_TYPE.adRequest && parseFloat(target.value) >= 1) + ? rate$ + : target.value; + + const digitsAfterComma = props.itemType === DEMAND_TAG_PRICING_TYPE.adRequest ? 4 : 2; + + const newRate = rate?.split('.')[1]?.length > digitsAfterComma ? rate.substring(0, rate.length - 1) : rate; + + setRate(newRate); + }; + + const handleSetStartDate = (date) => { + setStartDate(date); + if (endDate$ && dayjs(date).isAfter(endDate$)) { + setEndDate(null); + } + }; + + const handleSetEndDate = (date) => { + if (dayjs(date).isAfter(new Date().getTime())) { + setEndDate(date); + } else { + setEndDate(null); + } + }; + + return ( + + {props.itemType === DEMAND_TAG_PRICING_TYPE.adServing && ( + + + + )} + + + + + + + { + handleSetStartDate(date); + }} + shouldDisableDate={isShouldDisableCalendarDate(getPrevStartDate())} + disabled={isCreatingNew || !prevStartDate} + slotProps={{ + textField: { + size: 'small', + }, + }} + /> + + + {isEditableEndDate && ( + + { + handleSetEndDate(date); + }} + shouldDisableDate={isShouldDisableCalendarDate(getPrevStartDate())} + disablePast + disableIgnoringDatePartForTimeValidation + slotProps={{ + textField: { + size: 'small', + }, + }} + /> + + )} + + + +
    + + + +
    + +
    + props.onCancel()}> + + +
    +
    +
    + ); +} + +FormLineItem.propTypes = { + className: PropTypes.string, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + isCreatingNew: PropTypes.bool, + prevStartDate: PropTypes.number, + itemType: PropTypes.string.isRequired, + isZeroAllowed: PropTypes.bool, + isEditableEndDate: PropTypes.bool, + defaultRate: PropTypes.number, +}; + +export default FormLineItem; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.module.scss new file mode 100644 index 0000000..4dafc4e --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/FormLineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"FormLineItem_Container__Gw_hO","Container___label":"FormLineItem_Container___label__ZBtpS","Container___edit":"FormLineItem_Container___edit__kJl_9","Container___checked":"FormLineItem_Container___checked__bEIOI","Item":"FormLineItem_Item__J1g5c","Name":"FormLineItem_Name__VQ_je","Buttons":"FormLineItem_Buttons__odYbx","ButtonEdit":"FormLineItem_ButtonEdit__6twLd"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.jsx new file mode 100644 index 0000000..5671861 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.jsx @@ -0,0 +1,281 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import { Check, Clear, Delete, Edit } from '@mui/icons-material'; + +import { + DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_LIST, + DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE, + DEMAND_TAG_PRICING_TYPE, + SUPPLY_TAG_PRICING_TYPE_EXPENSES, +} from '@/modules/marketplace/account/constants'; + +import { DateTimePicker, Grid, IconButton, MenuItem, Select, TextField, Tooltip } from '@/mui/components'; + +import styles from './index.module.scss'; + +function LineItem({ + className = '', + updatedDate = null, + updatedBy = null, + status = '', + isLabelRow = false, + onSubmit = () => null, + onDelete = () => null, + rateMask = '', + isCreatingNew = false, + isDuplicating = false, + canUpdateEndDate = false, + hideDeleteBtn = false, + isZeroAllowed = false, + ...props +}) { + const statuses = { + active: 'active', + inactive: 'inactive', + }; + const [isEdit, setEditState] = useState(false); + const [isEditOnlyEndDate, setEditOnlyEndDate] = useState(false); + const [model$, setModel] = useState(props.model); + const [rate$, setRate] = useState(props.rate); + const [startDate$, setStartDate] = useState(props.startDate); + const [endDate$, setEndDate] = useState(props.endDate); + const [isZeroAllowed$, setIsZeroAllowed] = useState(isZeroAllowed || false); + const isActive = (!isLabelRow && statuses.active === status) || (!isLabelRow && isDuplicating); + const inActive = !isLabelRow && statuses.active !== status; + + // this effect needed when adding new pricing item for existing tag and update previous pricing item end date + useEffect(() => { + setEndDate(props.endDate); + }, [props.endDate]); + + const handleSetRate = ({ target }) => { + const rate = + (props.itemType === DEMAND_TAG_PRICING_TYPE.fee || + props.itemType === SUPPLY_TAG_PRICING_TYPE_EXPENSES || + (props.itemType === DEMAND_TAG_PRICING_TYPE.adServing && + model$ === DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE)) && + parseFloat(target.value) > 100 + ? rate$ + : target.value; + + setRate(rate.split('.')[1]?.length > 2 ? rate.substring(0, rate.length - 1) : rate); + }; + + return ( + + {props.model && ( + + {!isEdit && isLabelRow && props.model} + {!isEdit && + !isLabelRow && + (DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_LIST.find((item) => item.value === props.model)?.label ?? '')} + {isEdit && ( + + )} + + )} + + + {!isEdit && isLabelRow && props.rate} + {!isEdit && !isLabelRow && rateMask.replace('[NUMBER]', props.rate)} + {isEdit && ( + + )} + + + + {!isEdit && isLabelRow && startDate$} + {(!isEdit || (isEdit && isDuplicating)) && !isLabelRow && startDate$ + ? new Date(startDate$).toLocaleString() + : ''} + {isEdit && !isDuplicating && ( + { + setStartDate(date.toDate()); + }} + disabled={isCreatingNew} + slotProps={{ + textField: { + size: 'small', + }, + }} + /> + )} + + + {!isEdit && !isEditOnlyEndDate && isLabelRow && endDate$} + {!isEdit && !isEditOnlyEndDate && !isLabelRow && endDate$ ? new Date(endDate$).toLocaleString() : ''} + {props.itemType === DEMAND_TAG_PRICING_TYPE.fee && (isEdit || isEditOnlyEndDate) && ( + { + setEndDate(date.toDate()); + }} + disablePast + slotProps={{ + textField: { + size: 'small', + }, + }} + /> + )} + + + +
    {updatedBy ?? ''}
    +
    +
    +
    + + {!isLabelRow && ( + + {!isEdit && isActive && ( + <> +
    + { + setEditState(true); + }} + > + + +
    + + {(!isDuplicating || (isDuplicating && props.itemType === DEMAND_TAG_PRICING_TYPE.adServing)) && + !hideDeleteBtn && ( +
    + onDelete(props.id)} + > + + +
    + )} + + )} + {/* Edit end date for last fee */} + {!isEdit && + !isEditOnlyEndDate && + props.itemType === DEMAND_TAG_PRICING_TYPE.fee && + canUpdateEndDate && + !isActive && ( +
    + { + setEditOnlyEndDate(true); + }} + > + + +
    + )} + + {(isEdit || isEditOnlyEndDate) && ( + <> +
    + { + setEditState(false); + setEditOnlyEndDate(false); + onSubmit({ + id: props.id, + rate: rate$, + startDate: new Date(startDate$).getTime(), + endDate: new Date(endDate$).getTime(), + updatedDate, + updatedBy, + status, + ...(props.itemType === DEMAND_TAG_PRICING_TYPE.adServing ? { model: model$ } : {}), + }); + }} + > + + +
    +
    + { + setEditState(false); + setEditOnlyEndDate(false); + setStartDate(props.startDate); + setEndDate(props.endDate); + setRate(props.rate); + }} + > + + +
    + + )} +
    + )} +
    + ); +} + +LineItem.propTypes = { + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + rate: PropTypes.string.isRequired, + model: PropTypes.bool, + startDate: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + endDate: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + updatedDate: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + updatedBy: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + status: PropTypes.string, + rateMask: PropTypes.string, + + className: PropTypes.string, + isLabelRow: PropTypes.bool, + onSubmit: PropTypes.func, + onDelete: PropTypes.func, + isCreatingNew: PropTypes.bool, + itemType: PropTypes.string.isRequired, + isDuplicating: PropTypes.bool, + canUpdateEndDate: PropTypes.bool, + hideDeleteBtn: PropTypes.bool, + isZeroAllowed: PropTypes.bool, +}; + +export default LineItem; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.module.scss new file mode 100644 index 0000000..d384d51 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/components/LineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"LineItem_Container__C4D2K","Container___label":"LineItem_Container___label__chh4w","Container___edit":"LineItem_Container___edit__ZlV2z","Container___checked":"LineItem_Container___checked__6Gv_d","Item":"LineItem_Item__joC5X","ItemContent":"LineItem_ItemContent__XY2MP","Name":"LineItem_Name__WGQvV","Buttons":"LineItem_Buttons__2OFMr","ButtonEdit":"LineItem_ButtonEdit__VNsuh"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/index.jsx new file mode 100644 index 0000000..c904c4f --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/PricingTab/index.jsx @@ -0,0 +1,651 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { Add } from '@mui/icons-material'; + +import { + DEMAND_TAG_FORMAT, + DEMAND_TAG_HB_GAM_PLATFORMS, + DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_LIST, + DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE, + DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM, + DEMAND_TAG_PAGE_TYPE_VALUES, + DEMAND_TAG_PRICING_TYPE, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { definePricingTableKey } from '@/modules/marketplace/account/helpers/createDemandTagPriceRequestBody'; +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Form, FormContent, FormGroupTitle, FormRow, FormSection } from '@/modules/@common/Form'; +import BusinessModel from './components/BusinessModel/index'; +import FormLineItem from './components/FormLineItem/index'; +import LineItem from './components/LineItem'; +import { Button, Divider, Stack, TablePagination } from '@/mui/components'; + +function PricingTab(props) { + const dispatch = useDispatch(); + + const settings = useSelector(selectors.settingsSelector); + const { format, platform, type: tagType } = settings; + + const pricing = useSelector(selectors.pricingSelector); + + const { + model, + type, + fixedRpmRateTable, + adServingFeesTable, + additionalFeesTable, + adRequestFeesTable, + publisherDemand, + } = pricing; + + const pageConfig = useSelector(selectors.pageConfigSelector); + const demandAccountInfo = useSelector(selectors.demandAccountInfoSelector); + + const { isCreatingNew, isDuplicate } = pageConfig ?? {}; + const [showNewRPMForm, toggleNewRPMForm] = useState(false); + const [showNewAdServingForm, toggleNewAdServingForm] = useState(false); + const [showNewAdditionalForm, toggleNewAdditionalForm] = useState(false); + const [showNewAdRequestForm, toggleNewAdRequestForm] = useState(false); + + const defaultRate = + format === DEMAND_TAG_FORMAT.video + ? demandAccountInfo?.defaultValues?.videoAdServingFee + : demandAccountInfo?.defaultValues?.displayAdServingFee; + + const getStateMetadataByType = (typeParam) => ({ + tableKey: definePricingTableKey(typeParam), + tableState: pricing[definePricingTableKey(typeParam)], + }); + + const onChangePage = (typeParam, page) => { + const { tableKey, tableState } = getStateMetadataByType(typeParam); + + dispatch( + actions.setPricingTabAction({ + [tableKey]: { + ...tableState, + page: page - 1, + }, + }), + ); + + dispatch(actions.getDemandTagPricingAction(typeParam)); + }; + + const onChangeRowsPerPage = (typeParam, rows) => { + const { tableKey, tableState } = getStateMetadataByType(typeParam); + + dispatch( + actions.setPricingTabAction({ + [tableKey]: { + ...tableState, + rowsPerPage: rows, + page: 0, + }, + }), + ); + + dispatch(actions.getDemandTagPricingAction(typeParam)); + }; + + const onAddNewPrice = (typeParam, data, removePrevItems = false) => { + const { tableKey, tableState } = getStateMetadataByType(typeParam); + const prepareData = { + id: tableState.rows.length, + ...data, + value: parseFloat(data.rate), + }; + + if (isCreatingNew || isDuplicate) { + dispatch( + actions.setPricingTabAction({ + [tableKey]: { + ...tableState, + rows: [prepareData, ...(removePrevItems ? [] : tableState.rows)], + }, + }), + ); + } else { + // update with server + dispatch( + actions.updateDemandTagAction({ + type: typeParam, + data: prepareData, + }), + ); + } + }; + + const onEditPrice = (typeParam, data) => { + const { tableKey, tableState } = getStateMetadataByType(typeParam); + + dispatch( + actions.setPricingTabAction({ + [tableKey]: { + ...tableState, + rows: tableState.rows.map((i) => (i.id === data.id ? { ...data, value: parseFloat(data.rate) } : i)), + }, + }), + ); + }; + + const onRemovePrice = (typeParam, id) => { + const { tableKey, tableState } = getStateMetadataByType(typeParam); + + dispatch( + actions.setPricingTabAction({ + [tableKey]: { + ...tableState, + rows: tableState.rows.filter((i) => i.id !== id), + }, + }), + ); + }; + + useEffect(() => { + dispatch(actions.getDemandAccountByIdAction()); + }, []); + + useEffect(() => { + if (!props.isAdminMP && (isCreatingNew || isDuplicate)) { + const modelAdServing = + demandAccountInfo?.defaultValues?.adServingModel || DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_LIST[0].value; + + onAddNewPrice( + DEMAND_TAG_PRICING_TYPE.adServing, + { + model: modelAdServing, + rate: defaultRate, + startDate: new Date().getTime(), + endDate: null, + status: 'active', + }, + true, + ); + } + }, [defaultRate]); + + return ( +
    + + + Business Model + + + {model !== DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM[0].value && ( + <> + + + {!!fixedRpmRateTable.rows.length && !isDuplicate && ( + + )} + {!fixedRpmRateTable.rows.length && !showNewRPMForm && ( + + )} + + + )} + + {model !== DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM[0].value && ( + + + + {showNewRPMForm && ( + { + toggleNewRPMForm(false); + onAddNewPrice(DEMAND_TAG_PRICING_TYPE.price, data); + }} + onCancel={() => { + toggleNewRPMForm(false); + }} + /> + )} + {fixedRpmRateTable.rows.map((item) => ( + { + onEditPrice(DEMAND_TAG_PRICING_TYPE.price, data); + }} + onDelete={(id) => { + onRemovePrice(DEMAND_TAG_PRICING_TYPE.price, id); + }} + /> + ))} + + {!isCreatingNew && !isDuplicate && !!fixedRpmRateTable.rows.length && ( + { + onChangePage(DEMAND_TAG_PRICING_TYPE.price, page); + }} + onRowsPerPageChange={(event) => { + onChangeRowsPerPage(DEMAND_TAG_PRICING_TYPE.price, +event.target.value); + }} + /> + )} + + + )} + + {publisherDemand !== false && props.isAdminMP && ( + <> + + + {!!adServingFeesTable.rows.length && !isDuplicate && ( + + )} + {!adServingFeesTable.rows.length && !showNewAdServingForm && ( + + )} + + + + + {showNewAdServingForm && ( + { + const price = { + ...data, + rate: + data.model === DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE + ? parseNumberWithRound(data.rate / 100) + : data.rate, + }; + toggleNewAdServingForm(false); + onAddNewPrice(DEMAND_TAG_PRICING_TYPE.adServing, price); + }} + onCancel={() => { + toggleNewAdServingForm(false); + }} + isZeroAllowed + defaultRate={ + demandAccountInfo?.defaultValues?.adServingModel === + DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE + ? parseNumberWithRound(defaultRate * 100) + : defaultRate + } + defaultModel={demandAccountInfo?.defaultValues?.adServingModel} + /> + )} + {adServingFeesTable.rows.map((item) => ( + { + const price = { + ...data, + rate: + data.model === DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE + ? parseNumberWithRound(data.rate / 100) + : data.rate, + }; + onEditPrice(DEMAND_TAG_PRICING_TYPE.adServing, price); + }} + onDelete={(id) => { + onRemovePrice(DEMAND_TAG_PRICING_TYPE.adServing, id); + }} + /> + ))} + + {!isCreatingNew && !isDuplicate && !!adServingFeesTable.rows.length && ( + { + onChangePage(DEMAND_TAG_PRICING_TYPE.adServing, page); + }} + onRowsPerPageChange={(event) => { + onChangeRowsPerPage(DEMAND_TAG_PRICING_TYPE.adServing, +event.target.value); + }} + /> + )} + + + + )} + + {publisherDemand !== true && + format === DEMAND_TAG_FORMAT.display && + DEMAND_TAG_HB_GAM_PLATFORMS.includes(platform.code) && ( + <> + + + {!adRequestFeesTable.rows.length && !showNewAdRequestForm && ( + + )} + + + + + {showNewAdRequestForm && ( + { + toggleNewAdRequestForm(false); + onAddNewPrice(DEMAND_TAG_PRICING_TYPE.adRequest, data); + }} + onCancel={() => { + toggleNewAdRequestForm(false); + }} + /> + )} + {adRequestFeesTable.rows.map((item) => ( + { + onEditPrice(DEMAND_TAG_PRICING_TYPE.adRequest, data); + }} + onDelete={(id) => { + onRemovePrice(DEMAND_TAG_PRICING_TYPE.adRequest, id); + }} + /> + ))} + + {!isCreatingNew && !isDuplicate && !!adRequestFeesTable.rows.length && ( + { + onChangePage(DEMAND_TAG_PRICING_TYPE.adRequest, page); + }} + onRowsPerPageChange={(event) => { + onChangeRowsPerPage(DEMAND_TAG_PRICING_TYPE.adRequest, +event.target.value); + }} + /> + )} + + + + )} + + {props.isAdminMP && ( + <> + + + {!!additionalFeesTable.rows.length && !isDuplicate && ( + + )} + {!additionalFeesTable.rows.length && !showNewAdditionalForm && ( + + )} + + + + + {showNewAdditionalForm && ( + { + toggleNewAdditionalForm(false); + onAddNewPrice(DEMAND_TAG_PRICING_TYPE.fee, { + ...data, + rate: parseNumberWithRound(data.rate / 100), + }); + }} + onCancel={() => { + toggleNewAdditionalForm(false); + }} + /> + )} + {additionalFeesTable.rows.map((item, index) => { + const canUpdateEndDate = + additionalFeesTable.page === 0 && + index === 0 && + ((item.endDate && dayjs(item.endDate).isAfter(new Date().getTime())) || !item.endDate); + // only the last one can be edited and fee end date > current time + // cannot be edited if fee is already closed + return ( + { + onEditPrice(DEMAND_TAG_PRICING_TYPE.fee, { + ...data, + rate: parseNumberWithRound(data.rate / 100), + }); + // updating end date for last fee + if (canUpdateEndDate && !isCreatingNew && !isDuplicate) { + onAddNewPrice(DEMAND_TAG_PRICING_TYPE.fee, { + ...data, + startDate: null, // server update fee only without startDate + rate: null, // server update fee only without value + }); + } + }} + onDelete={(id) => { + onRemovePrice(DEMAND_TAG_PRICING_TYPE.fee, id); + }} + /> + ); + })} + {!isCreatingNew && !isDuplicate && !!additionalFeesTable.rows.length && ( + { + onChangePage(DEMAND_TAG_PRICING_TYPE.fee, page); + }} + onRowsPerPageChange={(event) => { + onChangeRowsPerPage(DEMAND_TAG_PRICING_TYPE.fee, +event.target.value); + }} + /> + )} + + + + )} + + +
    + ); +} + +PricingTab.propTypes = { + isAdminMP: PropTypes.bool.isRequired, +}; + +export default PricingTab; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.jsx new file mode 100644 index 0000000..e4b37f8 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.jsx @@ -0,0 +1,147 @@ +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Download, Upload } from '@mui/icons-material'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { Button, IconButton, Stack, Tooltip, Typography } from '@/mui/components'; + +import styles from './index.module.scss'; + +function BidMappingFileButton(props) { + const ref = useRef(null); + + const handleChange = ({ target }) => { + const { bidMapping, ...restErrors } = props.errors; + + const file = target.files[0]; + + if (file?.size > 102400) { + props.showNotificationAction({ + type: TYPE_ERROR, + message: 'Maximum file size is 100KB', + }); + return; + } + + const fileReader = new FileReader(); + + fileReader.onloadend = () => { + const rawContent = fileReader.result; + + let errorLine; + let errorValue; + + const content = rawContent + .split('\r\n') + .slice(1) + .filter((e) => e) + .map((item, key) => { + const row = item.split(','); + const cpm = row[0].trim().replace('$', ''); + + if (Number.isNaN(Number(cpm))) { + errorLine = key + 1; + errorValue = `$${cpm}`; + return {}; + } + + return { + cpm: Number(cpm), + code: row[1], + }; + }); + + if (errorLine) { + props.showNotificationAction({ + type: TYPE_ERROR, + message: `Invalid CPM value ${errorValue} in a line ${errorLine}`, + }); + // eslint-disable-next-line no-useless-assignment + errorLine = null; + // eslint-disable-next-line no-useless-assignment + errorValue = null; + } else { + props.setSettingsTab({ + bidMappingFileName: file.name, + bidMappingFileData: content, + errors: restErrors, + }); + } + }; + fileReader.readAsText(file); + + ref.current.value = ''; + }; + + const downloadCSV = () => { + if (props.bidMappingFileData?.length) { + let csvContent = 'data:text/csv;charset=utf-8,'; + + const values = props.bidMappingFileData + .map((i) => ({ CPM: `$${i.cpm.toFixed(2)}`, Code: i.code })) + .map((o) => Object.values(o).join(',')) + .join('\r\n'); + csvContent += `${'CPM,Code'}\r\n${values}`; + + const encodedUri = encodeURI(csvContent); + const link = document.createElement('a'); + link.setAttribute('href', encodedUri); + link.setAttribute('download', props.bidMappingFileName); + document.body.appendChild(link); + + link.click(); + } + }; + + return ( + <> + + + {props.bidMappingFileName ? ( + + + {props.bidMappingFileName} + +
    + + + + + +
    +
    + ) : ( + <> + + Upload a CSV + + + CSV. Max File Size 100kb + + + )} +
    + {props.errors?.bidMapping?.length && ( + + {props.errors?.bidMapping} + + )} + + ); +} + +BidMappingFileButton.propTypes = { + bidMappingFileName: PropTypes.string.isRequired, + bidMappingFileData: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + setSettingsTab: PropTypes.func.isRequired, + errors: PropTypes.shape({ + bidMapping: PropTypes.string, + }).isRequired, + showNotificationAction: PropTypes.func.isRequired, +}; + +export default BidMappingFileButton; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.module.scss new file mode 100644 index 0000000..06ecb73 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/BidMappingFileButton/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"File":"BidMappingFileButton_File__LfEoC","FileName":"BidMappingFileButton_FileName__s8stl"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.jsx new file mode 100644 index 0000000..ba99c25 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.jsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { Check, Clear } from '@mui/icons-material'; + +import { DEMAND_TAG_PAGE_EVENT_LIST, DEMAND_TAG_PAGE_EVENT_TYPES } from '@/modules/marketplace/account/constants'; + +import { Grid, IconButton, MenuItem, Select, TextField } from '@/mui/components'; + +import styles from './index.module.scss'; + +function FormLineItem({ className = '', ...props }) { + const [url$, setUrl] = useState(''); + const [type$, setType] = useState(DEMAND_TAG_PAGE_EVENT_TYPES[0].value); + const [event$, setEvent] = useState(DEMAND_TAG_PAGE_EVENT_LIST[0].value); + + return ( + + + + setUrl(target.value)} + variant="outlined" + size="small" + /> + + + + + + + + + +
    + { + props.onSubmit({ + url: url$, + event: event$, + type: type$, + }); + }} + > + + +
    +
    + props.onCancel()}> + + +
    +
    +
    + ); +} + +FormLineItem.propTypes = { + className: PropTypes.string, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +export default FormLineItem; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.module.scss new file mode 100644 index 0000000..dbb8642 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/FormLineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"FormLineItem_Container__XPr0R","Container___label":"FormLineItem_Container___label__kvsNw","Container___edit":"FormLineItem_Container___edit__iQmla","Container___checked":"FormLineItem_Container___checked__q_hiK","Item":"FormLineItem_Item__77L_9","Name":"FormLineItem_Name__yjRKk","Buttons":"FormLineItem_Buttons___HfHz","ButtonEdit":"FormLineItem_ButtonEdit__YCUgW"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/HeaderBiddingForm/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/HeaderBiddingForm/index.jsx new file mode 100644 index 0000000..60c0e59 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/HeaderBiddingForm/index.jsx @@ -0,0 +1,548 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { ContentCopyRounded } from '@mui/icons-material'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { + DEMAND_TAG_HB_GAM_PLATFORMS, + DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM, + DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE, + DEMAND_TAG_SUPPLY_CHAIN_VALUE, + FLOOR_PRICE, +} from '@/modules/marketplace/account/constants'; +import { HB_FORMATS } from '@/modules/marketplace/hbConnectors/constants'; + +import * as selectors from '../../../../../../redux/selectors'; +import * as actions from '../../../../../../redux/slices'; +import { getRequiredInputProps } from '@/modules/@common/Form/helpers'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { FormRow } from '@/modules/@common/Form'; +import BidMappingFileButton from '../BidMappingFileButton'; +import { + Autocomplete, + IconButton, + InputAdornment, + Link, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, +} from '@/mui/components'; + +const floorRegex = /^\d+(\.\d{0,2})?$/; +const maxFloorRegex = /^\d+$/; +const chainNode = '1.0,1!exchange1.com,1234,1,,,!exchange2.com,abcd,1,,,'; + +const useRenderFields = (platformParam) => { + const dispatch = useDispatch(); + const settings = useSelector(selectors.settingsSelector); + + const { platform, errors } = settings; + + if (!platform?.connectorId?.length) { + return null; + } + + if (!platformParam || !platformParam?.params?.length) { + return null; + } + + return platformParam.params?.map((param) => { + if (param.allowedValues?.length) { + return ( + + item.value === platform[param.name]) ?? {}} + options={param.allowedValues} + onChange={(e, option) => { + const { [param.name]: error, ...restErrors } = errors; + dispatch( + actions.setSettingsTabAction({ + platform: { + ...platform, + [param.name]: option?.value ?? null, + }, + errors: restErrors, + }), + ); + }} + size="small" + renderInput={(params) => ( + + )} + /> + + ); + } + if (DEMAND_TAG_HB_GAM_PLATFORMS.includes(platform.code)) { + return ( + + + copyToClipboard(platform[param.name])}> + + + + ), + }} + onChange={({ target }) => { + const { [param.name]: error, ...restErrors } = errors; + dispatch( + actions.setSettingsTabAction({ + platform: { + ...platform, + [param.name]: target.value.replace(/\s/g, ''), + }, + errors: restErrors, + }), + ); + }} + helperText={ + <> + {'For supported macros, please see our '} + + online help + + + } + /> + + ); + } + + if (param?.type === 'ARRAY') { + return ( + + { + const { [param.name]: error, ...restErrors } = errors; + dispatch( + actions.setSettingsTabAction({ + platform: { + ...platform, + [param.name]: target.value, + }, + errors: restErrors, + }), + ); + }} + /> + + ); + } + + return ( + + { + const { [param.name]: error, ...restErrors } = errors; + dispatch( + actions.setSettingsTabAction({ + platform: { + ...platform, + [param.name]: target.value.replace(/\s/g, ''), + }, + errors: restErrors, + }), + ); + }} + /> + + ); + }); +}; + +function HeaderBiddingForm(props) { + const dispatch = useDispatch(); + + const settings = useSelector(selectors.settingsSelector); + const { + format, + supplyChainOverride, + supplyChainValue, + supplyChainNode, + floorPrice, + maxFloor, + adjustFloor, + bidMappingFileName, + bidMappingFileData, + platform: platformProps, + platforms, + errors, + } = settings; + + const showNotification = (o) => dispatch(showNotificationAction(o)); + const setSettingsTab = (o) => dispatch(actions.setSettingsTabAction(o)); + + const platform = platforms?.find((item) => item.id === platformProps?.connectorId) ?? null; + + const handleCopyTooltip = () => { + copyToClipboard(chainNode).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Supply Chain Node was copied', + }), + ); + }); + }; + + return ( + <> + + platf.format === format || platf.format === HB_FORMATS[0].value) ?? []} + optionLabelKey="name" + optionValueKey="name" + disableClearable + // eslint-disable-next-line react/prop-types + disabled={!(props.isCreatingNew || props.isDuplicate)} + onChange={(e, value) => { + const { platform: platformError, ...restErrors } = errors; + const defaultValues = + value?.params?.reduce((acc, curr) => { + if (curr.defaultValue) { + return { + ...acc, + [curr.name]: curr.defaultValue, + }; + } + + return acc; + }, {}) ?? {}; + + dispatch( + actions.setSettingsTabAction({ + platform: { + name: value.name, + connectorId: value.id, + code: value.code, + ...defaultValues, + }, + errors: restErrors, + }), + ); + + if (!DEMAND_TAG_HB_GAM_PLATFORMS.includes(value.code)) { + dispatch( + actions.setPricingTabAction({ + model: DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM[0].value, + }), + ); + } + }} + size="small" + renderInput={(params) => ( + + )} + /> + + {useRenderFields(platform)} + + {platformProps?.code?.toUpperCase() === 'APS' && ( + + + + )} + + + dispatch( + actions.setSettingsTabAction({ + supplyChainOverride: + supplyChainOverride === DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled + ? DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.disabled + : DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled, + }), + ) + } + /> + + + {supplyChainOverride === DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled && ( + <> + + + + dispatch( + actions.setSettingsTabAction({ + supplyChainValue: DEMAND_TAG_SUPPLY_CHAIN_VALUE.blank, + }), + ) + } + > + Blank + + + dispatch( + actions.setSettingsTabAction({ + supplyChainValue: DEMAND_TAG_SUPPLY_CHAIN_VALUE.custom, + }), + ) + } + > + Custom + + + + {supplyChainValue === DEMAND_TAG_SUPPLY_CHAIN_VALUE.custom && ( + + {chainNode} + + + + + } + > + { + const { supplyChainNode: supplyChainNodeError, ...restErrors } = errors; + dispatch( + actions.setSettingsTabAction({ + supplyChainNode: target.value, + errors: restErrors, + }), + ); + }} + /> + + )} + + )} + + + + + dispatch( + actions.setSettingsTabAction({ + floorPrice: { + ...floorPrice, + override: FLOOR_PRICE.publisherDefault, + }, + }), + ) + } + > + Publisher Default Floor + + + dispatch( + actions.setSettingsTabAction({ + floorPrice: { + ...floorPrice, + override: FLOOR_PRICE.override, + }, + }), + ) + } + > + Override Floor + + + + + {floorPrice.override === FLOOR_PRICE.override && ( + <> + + { + const { viewableFloor, ...restErrors } = errors; + if (floorRegex.test(target.value) || target.value.length === 0) { + dispatch( + actions.setSettingsTabAction({ + floorPrice: { + ...floorPrice, + viewableFloor: target.value, + }, + errors: restErrors, + }), + ); + } + }} + /> + + + { + const { floor, ...restErrors } = errors; + if (floorRegex.test(target.value) || target.value.length === 0) { + dispatch( + actions.setSettingsTabAction({ + floorPrice: { + ...floorPrice, + floor: target.value, + }, + errors: restErrors, + }), + ); + } + }} + /> + + + )} + + {DEMAND_TAG_HB_GAM_PLATFORMS.includes(platformProps.code) && ( + + { + const { maxFloor: maxFloorError, ...restErrors } = errors; + if ( + target.value.length === 0 || + (target.value > 0 && target.value < 100 && maxFloorRegex.test(target.value)) + ) { + dispatch( + actions.setSettingsTabAction({ + maxFloor: target.value, + errors: restErrors, + }), + ); + } + }} + /> + + )} + + + + dispatch( + actions.setSettingsTabAction({ + adjustFloor: !adjustFloor, + }), + ) + } + /> + + + ); +} + +export default HeaderBiddingForm; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.jsx new file mode 100644 index 0000000..edc4e2a --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.jsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { Check, Clear, Delete, Edit } from '@mui/icons-material'; + +import { DEMAND_TAG_PAGE_EVENT_LIST, DEMAND_TAG_PAGE_EVENT_TYPES } from '@/modules/marketplace/account/constants'; + +import { getLabelByValue } from '@/modules/marketplace/account/helpers/demandTabs'; + +import { Grid, IconButton, MenuItem, Select, TextField, Typography } from '@/mui/components'; + +import styles from './index.module.scss'; + +function LineItem({ className = '', isLabelRow = false, onSubmit = () => null, onRemove = () => null, ...props }) { + const initType = props.type; + const initEvent = props.event; + const [isEdit, setEditState] = useState(false); + const [url$, setUrl] = useState(props.url); + const [type$, setType] = useState(initType); + const [event$, setEvent] = useState(initEvent); + + return ( + + + + {!isEdit ? ( + + {props.url} + + ) : ( + setUrl(target.value)} + variant="outlined" + size="small" + /> + )} + + + {!isEdit ? ( + getLabelByValue(DEMAND_TAG_PAGE_EVENT_TYPES, props.type) || props.type + ) : ( + + )} + + + {!isEdit ? ( + getLabelByValue(DEMAND_TAG_PAGE_EVENT_LIST, props.event) || props.event + ) : ( + + )} + + + {!isLabelRow && ( + + {!isEdit && ( + <> +
    + { + setEditState(true); + }} + > + + +
    +
    + { + onRemove({ + id: props.id, + }); + }} + > + + +
    + + )} + {isEdit && ( + <> +
    + { + setEditState(false); + onSubmit({ + id: props.id, + url: url$, + event: event$, + type: type$, + }); + }} + > + + +
    +
    + { + setEditState(false); + setUrl(props.url); + setType(initType); + setEvent(initEvent); + }} + > + + +
    + + )} +
    + )} +
    + ); +} + +LineItem.propTypes = { + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + url: PropTypes.string.isRequired, + event: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + className: PropTypes.string, + isLabelRow: PropTypes.bool, + onSubmit: PropTypes.func, + onRemove: PropTypes.func, +}; + +export default LineItem; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.module.scss new file mode 100644 index 0000000..c03e2d5 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/components/LineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"LineItem_Container__5ClWE","Container___label":"LineItem_Container___label__OJYC8","Container___edit":"LineItem_Container___edit__B52jx","Container___checked":"LineItem_Container___checked__BHrHB","Item":"LineItem_Item__9yTLy","Name":"LineItem_Name__tXbZD","Buttons":"LineItem_Buttons__YayOj","ButtonEdit":"LineItem_ButtonEdit__0iuP6"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.jsx new file mode 100644 index 0000000..8b7017e --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.jsx @@ -0,0 +1,792 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import { createFilterOptions } from '@mui/material/Autocomplete'; +import { Add, ContentCopyRounded } from '@mui/icons-material'; + +import { + DEMAND_TAG_DEFAULT_TIER_VALUES, + DEMAND_TAG_EVENT_PIXELS_SIZE, + DEMAND_TAG_FORMAT, + DEMAND_TAG_PAGE_TYPE_VALUES, + DEMAND_TAG_PRIORITY, + DEMAND_TAG_STATUS, + SOURCE_TAG_OPTIONS, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { getRequiredInputProps } from '@/modules/@common/Form/helpers'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import validation from '@/modules/marketplace/account/helpers/demandTagFormValidationRules'; +import { integerFieldBlockInvalidChar } from '@/modules/marketplace/account/helpers/validate'; +import { getNextId } from '@/modules/marketplace/common/helpers'; + +import { Form, FormContent, FormGroupTitle, FormRow, FormSection } from '@/modules/@common/Form'; +import FormLineItem from './components/FormLineItem'; +import HeaderBiddingForm from './components/HeaderBiddingForm'; +import LineItem from './components/LineItem'; +import { + Autocomplete, + Button, + DateRangePicker, + Divider, + Grid, + IconButton, + InputAdornment, + Link, + MenuItem, + Select, + Stack, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@/mui/components'; + +import styles from './index.module.scss'; + +const filter = createFilterOptions(); + +function SettingsTab(props) { + const dispatch = useDispatch(); + + const pageConfig = useSelector(selectors.pageConfigSelector); + const labelsOptions = useSelector(selectors.labelsOptionsSelector); + + const settings = useSelector(selectors.settingsSelector); + const { + name, + source, + platformId, + format, + status, + flightsDates, + uid, + advertiser, + demandAccount, + type, + url, + priority, + defaultTier, + labels, + timeout, + eventPixelsList, + videoId, + clickThroughUrl, + platforms, + errors, + } = settings; + + const { isCreatingNew, isDuplicate } = pageConfig ?? {}; + const [showNewForm, toggleNewFrom] = useState(false); + dayjs.extend(utc); + + const defineDefaultTierForSelfServeTag = ({ type: typeParam, priority: priorityParam }) => { + if (priorityParam === DEMAND_TAG_PRIORITY.openAuction) { + if (typeParam === DEMAND_TAG_PAGE_TYPE_VALUES.tag) { + dispatch( + actions.setSettingsTabAction({ + defaultTier: 3, + }), + ); + } + + if (typeParam === DEMAND_TAG_PAGE_TYPE_VALUES.hb) { + dispatch( + actions.setSettingsTabAction({ + defaultTier: 2, + }), + ); + } + + if (typeParam === DEMAND_TAG_PAGE_TYPE_VALUES.mp4) { + dispatch( + actions.setSettingsTabAction({ + defaultTier: 1, + }), + ); + } + } + }; + + useEffect(() => { + if (isCreatingNew && !props.isAdminMP) { + defineDefaultTierForSelfServeTag({ type, priority }); + } + }, [pageConfig]); + + return ( +
    + + + Basic Setting + + { + const { name: nameError, ...restErrors } = errors; + dispatch(actions.setSettingsTabAction({ name: target.value, errors: restErrors })); + }} + /> + + + {props.isAdminMP && ( + <> + + + + + {source !== SOURCE_TAG_OPTIONS[0].value && ( + + { + const { platformId: platformIdError, ...restErrors } = errors; + dispatch(actions.setSettingsTabAction({ platformId: target.value, errors: restErrors })); + }} + /> + + )} + + )} + + + + { + dispatch( + actions.setSettingsTabAction({ + format: DEMAND_TAG_FORMAT.video, + }), + ); + }} + > + Video + + { + // return type to tag because mp4 is hidden + if ( + format === DEMAND_TAG_FORMAT.sponsored || + (format === DEMAND_TAG_FORMAT.video && type === DEMAND_TAG_PAGE_TYPE_VALUES.mp4) + ) { + dispatch( + actions.setSettingsTabAction({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.tag, + }), + ); + + if (isCreatingNew && !props.isAdminMP) { + defineDefaultTierForSelfServeTag({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.tag, + priority, + }); + } + } + + dispatch( + actions.setSettingsTabAction({ + format: DEMAND_TAG_FORMAT.display, + }), + ); + }} + > + Display + + { + dispatch( + actions.setSettingsTabAction({ + format: DEMAND_TAG_FORMAT.sponsored, + }), + ); + + dispatch( + actions.setSettingsTabAction({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.mp4, + }), + ); + + if (isCreatingNew && !props.isAdminMP) { + defineDefaultTierForSelfServeTag({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.mp4, + priority, + }); + } + }} + > + Sponsored + + + + + + dispatch( + actions.setSettingsTabAction({ + status: + status === DEMAND_TAG_STATUS.enabled ? DEMAND_TAG_STATUS.disabled : DEMAND_TAG_STATUS.enabled, + }), + ) + } + /> + + + + + dispatch( + actions.setSettingsTabAction({ + flightsDates: null, + }), + ) + } + > + Indefinite + + + dispatch( + actions.setSettingsTabAction({ + flightsDates: {}, + }), + ) + } + > + Custom + + + + {flightsDates && ( + + { + const { flightsDates: error, ...restErrors } = errors; + + dispatch( + actions.setSettingsTabAction({ + flightsDates: { + startDate: dayjs.utc(date[0]).startOf('day').valueOf(), + endDate: + date[1] && + dayjs.utc(date[1]).endOf('day').valueOf() > dayjs.utc(date[0]).startOf('day').valueOf() + ? dayjs.utc(date[1]).endOf('day').valueOf() + : dayjs.utc(date[0]).endOf('day').valueOf(), + }, + errors: restErrors, + }), + ); + }} + slotProps={{ + textField: { + size: 'small', + error: errors?.flightsDates ?? false, + }, + }} + /> + + )} + {!isCreatingNew && ( + + + + )} + + + + + + + + {format !== DEMAND_TAG_FORMAT.sponsored && ( + + + { + dispatch( + actions.setSettingsTabAction({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.tag, + }), + ); + + if (isCreatingNew && !props.isAdminMP) { + defineDefaultTierForSelfServeTag({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.tag, + priority, + }); + } + }} + > + Tag + + { + dispatch( + actions.setSettingsTabAction({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.hb, + }), + ); + + if (isCreatingNew && !props.isAdminMP) { + defineDefaultTierForSelfServeTag({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.hb, + priority, + }); + } + }} + > + HB + + + {format === DEMAND_TAG_FORMAT.video && ( + { + dispatch( + actions.setSettingsTabAction({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.mp4, + }), + ); + + if (isCreatingNew && !props.isAdminMP) { + defineDefaultTierForSelfServeTag({ + type: DEMAND_TAG_PAGE_TYPE_VALUES.mp4, + priority, + }); + } + }} + > + DIRECT MP4 + + )} + + + )} + + {type === DEMAND_TAG_PAGE_TYPE_VALUES.tag && ( + + { + const { url: urlError, ...restErrors } = errors; + dispatch(actions.setSettingsTabAction({ url: target.value, errors: restErrors })); + }} + {...getRequiredInputProps({ + fieldName: 'serverUrl', + error: !!errors?.url?.length, + helperText: errors?.url?.length ? ( + errors?.url + ) : ( + + {'For supported macros, please see our '} + + macros online help + + + ), + })} + InputProps={{ + endAdornment: ( + + copyToClipboard(url)}> + + + + ), + }} + /> + + )} + + {type === DEMAND_TAG_PAGE_TYPE_VALUES.hb && platforms?.length && ( + + )} + + {type === DEMAND_TAG_PAGE_TYPE_VALUES.mp4 && ( + <> + + { + const { videoId: videoIdError, ...restErrors } = errors; + dispatch(actions.setSettingsTabAction({ videoId: target.value, errors: restErrors })); + }} + /> + + + {format !== DEMAND_TAG_FORMAT.sponsored && ( + + { + dispatch(actions.setSettingsTabAction({ clickThroughUrl: target.value })); + }} + /> + + )} + + )} + + + + dispatch( + actions.setSettingsTabAction({ + priority: DEMAND_TAG_PRIORITY.firstLook, + defaultTier: 0, + }), + ) + } + > + 1st Look + + { + dispatch( + actions.setSettingsTabAction({ + priority: DEMAND_TAG_PRIORITY.openAuction, + }), + ); + + if (isCreatingNew && !props.isAdminMP) { + defineDefaultTierForSelfServeTag({ + type, + priority: DEMAND_TAG_PRIORITY.openAuction, + }); + } + }} + > + Open Auction + + + + + {props.isAdminMP && ( + + + {DEMAND_TAG_DEFAULT_TIER_VALUES.map((tier) => ( + + dispatch( + actions.setSettingsTabAction({ + defaultTier: tier.value, + }), + ) + } + > + {tier.value} + + ))} + + + )} + + {props.isAdminMP && ( + + + dispatch( + actions.getLabelsForTableFilterAction({ + prefix: '', + }), + ) + } + filterOptions={(options, params) => { + const filtered = filter(options, params); + + if (filtered?.some((item) => item.value === params.inputValue)) { + return filtered; + } + + if (params.inputValue !== '') { + filtered.push({ + inputValue: `Create: "${params.inputValue}"`, + value: params.inputValue, + }); + } + + return filtered; + }} + getOptionLabel={(option) => { + if (typeof option === 'string') { + return option; + } + if (option?.inputValue) { + return option?.inputValue; + } + return option?.value; + }} + multiple={true} + freeSolo + onChange={(e, newValue) => { + dispatch( + actions.setSettingsTabAction({ + labels: newValue.map((item) => { + if (typeof item === 'string') { + return item; + } + + return item.value; + }), + }), + ); + }} + renderInput={(params) => ( + { + dispatch( + actions.getLabelsForTableFilterAction({ + prefix: e.target.value, + }), + ); + }} + variant="outlined" + size="small" + fullWidth + /> + )} + /> + + )} + + Advanced Setting + + + dispatch( + actions.setSettingsTabAction({ + timeout: parseInt(target.value, 10), + }), + ) + } + /> + + + + + {!!eventPixelsList.length && ( + + )} + {!eventPixelsList.length && !showNewForm && ( + + )} + + + + + {showNewForm && ( + { + toggleNewFrom(false); + dispatch( + actions.setSettingsTabAction({ + eventPixelsList: [ + { + id: getNextId(eventPixelsList), + ...data, + }, + ...eventPixelsList, + ], + }), + ); + }} + onCancel={() => { + toggleNewFrom(false); + }} + /> + )} + {eventPixelsList.map((e) => ( + + dispatch( + actions.setSettingsTabAction({ + eventPixelsList: eventPixelsList.map((i) => (i.id === data.id ? { ...data } : i)), + }), + ) + } + onRemove={(data) => + dispatch( + actions.setSettingsTabAction({ + eventPixelsList: eventPixelsList.filter((i) => i.id !== data.id), + }), + ) + } + /> + ))} + + + {'For supported macros, please see our '} + + macros online help + + + + + + + +
    + ); +} + +SettingsTab.propTypes = { + isAdminMP: PropTypes.bool.isRequired, +}; + +export default SettingsTab; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.module.scss new file mode 100644 index 0000000..4cb50dc --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/SettingsTab/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"DateRangePicker":"SettingsTab_DateRangePicker__gvJKS"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/ActionAutocomplete.module.scss b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/ActionAutocomplete.module.scss new file mode 100644 index 0000000..cea6555 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/ActionAutocomplete.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Group___indent":"ActionAutocomplete_Group___indent__BF3ST","GroupLabel":"ActionAutocomplete_GroupLabel__Y8nKq"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/index.jsx new file mode 100644 index 0000000..7b1e970 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/ActionAutocomplete/index.jsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { autocompleteClasses } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { TagSelector } from '@/modules/@common/TagSelector'; +import { List, ListItem, ListSubheader, Stack, Typography } from '@/mui/components'; + +import styles from './ActionAutocomplete.module.scss'; + +function ActionAutocomplete({ + onInputChange = () => {}, + onOpen = null, + onChange = null, + value = [], + groupBy = null, + disabled = false, + size = 'medium', + placeholder = null, + ...props +}) { + const theme = useTheme(); + const [list, setList] = useState(value || []); + const [include, setInclude] = useState(!list.length || list.some((o) => o.include)); + + useEffect(() => { + const list$ = value || []; + + setInclude(!list$.length || list$.some((o) => o.include)); + setList(list$); + }, [value]); + + const renderGroup = (params) => { + const [name, , color] = (params.group || '').split('|'); + + const optionList = ( + + {params.children} + + ); + + return params.group ? ( + + + + + {name} + + + {optionList} + + + ) : ( + optionList + ); + }; + + return ( + !list.some((item) => item.value === option.value)) ?? []} + placeholder={placeholder} + onOpen={() => { + if (onOpen) { + onOpen(''); + } + }} + onChange={(e, tags) => { + onChange(tags); + setList(tags); + }} + onInputChange={(e) => { + if (onInputChange) { + onInputChange(e.target.value); + } + }} + /> + ); +} + +ActionAutocomplete.propTypes = { + id: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + onInputChange: PropTypes.func, + onChange: PropTypes.func, + onOpen: PropTypes.func, + value: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + }), + ), + groupBy: PropTypes.func, + disabled: PropTypes.bool, + size: PropTypes.oneOf(['xSmall', 'small', 'medium', 'large']), + placeholder: PropTypes.string, +}; + +export default ActionAutocomplete; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/KeyValue/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/KeyValue/index.jsx new file mode 100644 index 0000000..40576b9 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/components/KeyValue/index.jsx @@ -0,0 +1,135 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Delete } from '@mui/icons-material'; + +import { DEMAND_TAG_KEY_VALUE_TARGETING_TYPE } from '@/modules/marketplace/account/constants'; + +import { FormGroup, FormRow, FormRowItem } from '@/modules/@common/Form'; +import StateSelect from '@/modules/@common/TagSelector/components/StateSelect/StateSelect'; +import ChipInput from '@/modules/marketplace/common/ChipInput'; +import { Autocomplete, IconButton, Stack, TextField, ToggleButton, ToggleButtonGroup } from '@/mui/components'; + +function KeyValue(props) { + const handleOnChange = (newValue) => { + props.onChange(props.id, newValue); + }; + + return ( + + + + handleOnChange({ keyName })} + onOpen={() => props.getKeyNamesOptions('')} + renderInput={(params) => ( + { + props.getKeyNamesOptions(e.target.value); + }} + /> + )} + /> + handleOnChange({ state: state ? 'INCLUDE' : 'EXCLUDE' })} + /> + + + + + + + + + + handleOnChange({ + type: DEMAND_TAG_KEY_VALUE_TARGETING_TYPE.value, + }) + } + > + Values + + + handleOnChange({ + type: DEMAND_TAG_KEY_VALUE_TARGETING_TYPE.list, + }) + } + > + Value Lists + + + {props.type === DEMAND_TAG_KEY_VALUE_TARGETING_TYPE.value && ( + { + if (labels?.length < 6) { + handleOnChange({ values: labels?.map((item) => item.replace(/\s/g, '')) ?? [] }); + } + }} + /> + )} + {props.type === DEMAND_TAG_KEY_VALUE_TARGETING_TYPE.list && ( + { + handleOnChange({ valueLists: newValue }); + }} + onOpen={() => { + props.getKeyListsOptions(''); + }} + renderInput={(params) => ( + { + props.getKeyListsOptions(e.target.value); + }} + /> + )} + /> + )} + + + + + ); +} + +KeyValue.propTypes = { + id: PropTypes.number.isRequired, + keyName: PropTypes.shape({}).isRequired, + state: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + + keyNamesOptions: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + keyListsOptions: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + + onChange: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + getKeyNamesOptions: PropTypes.func.isRequired, + getKeyListsOptions: PropTypes.func.isRequired, +}; + +export default KeyValue; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/index.jsx new file mode 100644 index 0000000..92f6f34 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/components/TargetingTab/index.jsx @@ -0,0 +1,416 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { Visibility, VisibilityOff } from '@mui/icons-material'; + +import { + DEMAND_TAG_DEFAULT_TIER_VALUES, + DEMAND_TAG_FORMAT, + DEMAND_TAG_KEY_VALUE_TARGETING_STATUS, + DEMAND_TAG_PAGE_BROWSER_LIST, + DEMAND_TAG_PAGE_DEVICE_LIST, + DEMAND_TAG_PAGE_OS_LIST, + DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE, + DEMAND_TAG_TARGETING_VIEWABILITY_TYPE, + DEMAND_TAG_VIEWABILITY_TARGETING, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { getRequiredInputProps } from '@/modules/@common/Form/helpers'; + +import { Form, FormContent, FormGroup, FormGroupTitle, FormRow, FormSection } from '@/modules/@common/Form'; +import ActionAutocomplete from './components/ActionAutocomplete'; +import KeyValue from './components/KeyValue'; +import { Button, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@/mui/components'; + +function TargetingTab(props) { + const dispatch = useDispatch(); + + const targeting = useSelector(selectors.targetingSelector); + const { + viewability, + viewabilityTargeting, + viewabilityThreshold, + playerSize, + devices, + countries, + os, + browsers, + countriesList, + kvTargetingStatus, + kvTargeting, + keyNamesOptions, + keyListsOptions, + tier, + errors, + } = targeting; + + const settings = useSelector(selectors.settingsSelector); + + const getKeyNamesOptions = (o) => dispatch(actions.getKeyNamesOptionsAction(o)); + const getKeyListsOptions = (o) => dispatch(actions.getKeyListsOptionsAction(o)); + + const handleChangeKeyValue = (id, newValue) => { + dispatch( + actions.setTargetingTabAction({ + kvTargeting: kvTargeting?.map((item, index) => { + if (id === index) { + return { + ...item, + ...newValue, + }; + } + return item; + }), + }), + ); + }; + + const handleDeleteKeyValue = (index) => { + const newKeys = [...kvTargeting]; + newKeys.splice(index, 1); + + dispatch( + actions.setTargetingTabAction({ + kvTargeting: newKeys, + kvTargetingStatus: newKeys.length + ? DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled + : DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.disabled, + }), + ); + }; + + return ( +
    + + + Details + {props.tabConfig.geography && ( + + dispatch(actions.setTargetingTabAction({ countries: data }))} + size="small" + /> + + )} + + { + // eslint-disable-next-line react/prop-types + props.tabConfig.tier && ( + + + {DEMAND_TAG_DEFAULT_TIER_VALUES.map((item) => ( + + dispatch( + actions.setTargetingTabAction({ + tier: item.value, + }), + ) + } + > + {tier.value} + + ))} + + + ) + } + + {props.tabConfig.playerSize && settings?.format !== DEMAND_TAG_FORMAT.sponsored && ( + + playerSize[key])} + > + {[ + DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.xs, + DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.s, + DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.m, + DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.l, + ].map((id) => ( + + dispatch( + actions.setTargetingTabAction({ + playerSize: { + ...playerSize, + [id]: !playerSize[id], + }, + }), + ) + } + > + {id} + + ))} + + + )} + + {props.tabConfig.viewability && settings?.format !== DEMAND_TAG_FORMAT.sponsored && ( + + viewability[key])} + > + + dispatch( + actions.setTargetingTabAction({ + viewability: { + ...viewability, + [DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView]: + !viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView], + }, + }), + ) + } + > + + + + dispatch( + actions.setTargetingTabAction({ + viewability: { + ...viewability, + [DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView]: + !viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView], + }, + }), + ) + } + > + + + + + )} + + {props.tabConfig.viewabilityTargeting && + settings?.format !== DEMAND_TAG_FORMAT.sponsored && + viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView] && + viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView] && ( + + + dispatch( + actions.setTargetingTabAction({ + viewabilityTargeting: + viewabilityTargeting === DEMAND_TAG_VIEWABILITY_TARGETING.enabled + ? DEMAND_TAG_VIEWABILITY_TARGETING.disabled + : DEMAND_TAG_VIEWABILITY_TARGETING.enabled, + }), + ) + } + /> + + )} + + {props.tabConfig.viewabilityTargeting && + settings?.format !== DEMAND_TAG_FORMAT.sponsored && + viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView] && + viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView] && + viewabilityTargeting === DEMAND_TAG_VIEWABILITY_TARGETING.enabled && ( + + { + const { viewabilityThreshold: viewabilityThresholdError, ...restErrors } = errors; + const value = + target.value.split('.')[1]?.length > 2 + ? target.value.substring(0, target.value.length - 1) + : target.value; + + if (+target.value >= 0 && +target.value <= 100) { + dispatch(actions.setTargetingTabAction({ viewabilityThreshold: value, errors: restErrors })); + } + }} + /> + + )} + {props.tabConfig.device && ( + + dispatch(actions.setTargetingTabAction({ devices: data }))} + size="small" + /> + + )} + + {props.tabConfig.os && ( + + dispatch(actions.setTargetingTabAction({ os: data }))} + size="small" + /> + + )} + + {props.tabConfig.browser && ( + + dispatch(actions.setTargetingTabAction({ browsers: data }))} + size="small" + /> + + )} + + { + // eslint-disable-next-line react/prop-types + props.tabConfig.kvTargeting && settings?.format !== DEMAND_TAG_FORMAT.sponsored && ( + <> + + + dispatch( + actions.setTargetingTabAction({ + kvTargetingStatus: + kvTargetingStatus === DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled + ? DEMAND_TAG_VIEWABILITY_TARGETING.disabled + : DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled, + kvTargeting: + kvTargetingStatus === DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled + ? [] + : [ + { + keyName: null, + state: 'INCLUDE', + type: 'VALUE', + values: [], + valueLists: [], + }, + ], + }), + ) + } + /> + + {kvTargetingStatus === DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled && !!kvTargeting?.length && ( + + {kvTargeting.map((item, index) => ( + + {!!index && ( + + + AND + + + )} + handleDeleteKeyValue(index)} + getKeyNamesOptions={getKeyNamesOptions} + getKeyListsOptions={getKeyListsOptions} + /> + + ))} + {kvTargeting?.length < 3 && ( + + + + )} + + )} + + ) + } + + +
    + ); +} + +TargetingTab.propTypes = { + tabConfig: PropTypes.shape({ + domains: PropTypes.bool, + geography: PropTypes.bool, + playerSize: PropTypes.bool, + viewability: PropTypes.bool, + viewabilityTargeting: PropTypes.bool, + device: PropTypes.bool, + os: PropTypes.bool, + browser: PropTypes.bool, + geographyTooltip: PropTypes.bool, + }).isRequired, +}; + +export default TargetingTab; diff --git a/anyclip/src/modules/marketplace/account/components/DemandSettings/index.jsx b/anyclip/src/modules/marketplace/account/components/DemandSettings/index.jsx new file mode 100644 index 0000000..ece3be2 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/DemandSettings/index.jsx @@ -0,0 +1,7 @@ +import BudgetingTab from './components/BudgetingTab'; +import FrequencyCapTab from './components/FrequencyCapTab'; +import PricingTab from './components/PricingTab'; +import SettingsTab from './components/SettingsTab'; +import TargetingTab from './components/TargetingTab'; + +export { BudgetingTab, FrequencyCapTab, PricingTab, SettingsTab, TargetingTab }; diff --git a/anyclip/src/modules/marketplace/account/components/Main/Main.module.scss b/anyclip/src/modules/marketplace/account/components/Main/Main.module.scss new file mode 100644 index 0000000..403a870 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Main/Main.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Main":"Main_Main__G1VSk","InfoContainer":"Main_InfoContainer__LfNKN","ChartContainer":"Main_ChartContainer__qspVv","TableFiltersContainer":"Main_TableFiltersContainer__ZCaCz","Search":"Main_Search__v6BRf","SearchInput":"Main_SearchInput__szAam","Filters":"Main_Filters__fiz5r","Filter":"Main_Filter__7hAAM","Status":"Main_Status__Zj5qb","Table":"Main_Table__KQoDI","DefaultSelectProp":"Main_DefaultSelectProp__lydiy","FileButton":"Main_FileButton__eWQWj","Note":"Main_Note__Wh2nk","NoteContent":"Main_NoteContent__hL2_G","Actions":"Main_Actions__d7JYj","Tier___0":"Main_Tier___0__xO2dh","Tier___1":"Main_Tier___1__8l02U","Tier___2":"Main_Tier___2__pVKUE","Tier___3":"Main_Tier___3__fIbDK","Tier___4":"Main_Tier___4__bppaT","Tier___5":"Main_Tier___5__57B8U","Tier___6":"Main_Tier___6__vqig2","Tier___7":"Main_Tier___7__Op_Sd","Tier___8":"Main_Tier___8___ihV2","Tier___9":"Main_Tier___9__rVymn"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Main/index.jsx b/anyclip/src/modules/marketplace/account/components/Main/index.jsx new file mode 100644 index 0000000..633358c --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Main/index.jsx @@ -0,0 +1,507 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Add, FilterAlt, SearchRounded, Settings } from '@mui/icons-material'; + +import { + DEMAND_ADVERTISER_PAGE, + DEMAND_TAG_PAGE, + DEMAND_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT, + DEMAND_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT, + DEMAND_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT, + SUPPLY_SITE_PAGE, + SUPPLY_TAG_PAGE, + SUPPLY_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT, + SUPPLY_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT, + SUPPLY_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT, +} from '../../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../../redux/selectors'; +import * as actions from '../../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import Table from '@/modules/marketplace/common/Table'; +import Chart from '../Chart'; +import Total from '../Total'; +import usePageConfig from '../usePageConfig'; +import { + Autocomplete, + Button, + Divider, + Grid, + IconButton, + InputAdornment, + MenuItem, + Select, + Stack, + TextField, + Tooltip, +} from '@/mui/components'; +import { CustomCsvFilled } from '@/mui/components/CustomIcon'; + +import styles from './Main.module.scss'; + +function Main(props) { + const dispatch = useDispatch(); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + // props.pageConfig + // const pageConfig = useSelector(selectors.pageConfigSelector); + const info = useSelector(selectors.infoSelector); + const total = useSelector(selectors.totalSelector); + const chartTab = useSelector(selectors.chartTabSelector); + const chartData = useSelector(selectors.chartDataSelector); + const chartHistory = useSelector(selectors.chartHistorySelector); + const timeIntervalFilter = useSelector(selectors.timeIntervalFilterSelector); + const comparisonFilter = useSelector(selectors.comparisonFilterSelector); + const isChartLoading = useSelector(selectors.isChartLoadingSelector); + const search = useSelector(selectors.searchSelector); + const filterByStatus = useSelector(selectors.filterByStatusSelector); + const filterByLabel = useSelector(selectors.filterByLabelSelector); + const labelsOptions = useSelector(selectors.labelsOptionsSelector); + const supply = useSelector(selectors.supplySelector); + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const rowsPerPage = useSelector(selectors.rowsPerPageSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const isLoading = useSelector(selectors.isLoadingSelector); + + const getChartData = (o) => dispatch(actions.getChartDataAction(o)); + const setChartTab = (o) => dispatch(actions.setChartTabAction(o)); + const setTimeIntervalFilter = (o) => dispatch(actions.setTimeIntervalFilterAction(o)); + const setComparisonFilter = (o) => dispatch(actions.setComparisonFilterAction(o)); + const downloadCSV = (o) => dispatch(actions.downloadCSVAction(o)); + const setRowsPerPage = (o) => dispatch(actions.setRowsPerPageAction(o)); + + const router = useRouter(); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const pageConfig = usePageConfig({ isAdminMP }); + + const [selected, setSelected] = useState([]); + + useEffect(() => { + if (pageConfig) { + setSelected([]); + } + }, [pageConfig?.type]); + + const createRowLink = (row) => { + if (pageConfig.nextLevelLink) { + const queries = router.asPath.split('?')[1] + ? `?${router.asPath.split('?')[1]}&${pageConfig.nextLevelQuery}=${encodeURIComponent(row.name)}` + : `?${encodeURIComponent(row.name)}`; + + return `${router.asPath.split('?')[0]}${pageConfig.nextLevelLink}/${row.id}${queries}`; + } + + if (pageConfig.getTableTagLink) { + return pageConfig.getTableTagLink(row); + } + + return null; + }; + + const handleSorting = (id) => { + if (id === 'NAME') { + const isAsc = sortBy === id && sortOrder === 'ASC'; + dispatch(actions.setSortOrderAction(isAsc ? 'DESC' : 'ASC')); + } else { + const isDesc = sortBy === id && sortOrder === 'DESC'; + dispatch(actions.setSortOrderAction(isDesc ? 'ASC' : 'DESC')); + } + dispatch(actions.setSortByAction(id)); + }; + + const onChangePage = (value) => { + dispatch(actions.setFieldAction({ page: value })); + dispatch(actions.getDataAction()); + }; + + const setRowBackgroundByTierValue = (row) => { + const { tier } = row; + const mapper = [ + styles.Tier___0, + styles.Tier___1, + styles.Tier___2, + styles.Tier___3, + styles.Tier___4, + styles.Tier___5, + styles.Tier___6, + styles.Tier___7, + styles.Tier___8, + styles.Tier___9, + ]; + return mapper[+tier] ?? mapper[0]; + }; + + const defineTotalInfoRows = () => { + // props.pageConfig + if (pageConfig?.type === DEMAND_TAG_PAGE && info?.format === 'DISPLAY') { + return isAdminMP + ? DEMAND_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT + : DEMAND_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT.filter( + (item) => !['REVENUE', 'RPM', 'PROFIT', 'ERPM'].includes(item.value), + ).map((item) => { + if (item.value === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.value === 'GROSS_RPM') { + return { ...item, label: 'Ad RPM' }; + } + return item; + }); + } + + // props.pageConfig + if (pageConfig?.type === SUPPLY_TAG_PAGE && info?.format === 'DISPLAY') { + return isAdminMP + ? SUPPLY_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT.filter( + (item) => !['PUB_REVENUE', 'PUB_AD_RPM'].includes(item.value), + ) + : SUPPLY_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT.filter( + (item) => + ![ + 'REVENUE', + 'GROSS_RPM', + 'GROSS_REVENUE', + 'EXPENSES', + 'MEDIA_COST', + 'MEDIA_MARGIN', + 'AC_AD_RPM', + 'CTR', + ].includes(item.value), + ); + } + + return pageConfig.infoRows; + }; + + const defineTableHeaders = () => { + // props.pageConfig + if (pageConfig?.type === DEMAND_TAG_PAGE && info?.format === 'DISPLAY') { + return isAdminMP + ? DEMAND_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT + : DEMAND_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT.filter( + (item) => !['REVENUE', 'RPM', 'ERPM', 'PROFIT', 'TIER_PRIORITY', 'priority'].includes(item.id), + ).map((item) => { + if (item.id === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.id === 'GROSS_RPM') { + return { ...item, label: 'Ad RPM' }; + } + return item; + }); + } + + // props.pageConfig + if (pageConfig?.type === SUPPLY_TAG_PAGE && info?.format === 'DISPLAY') { + return !isAdminMP + ? SUPPLY_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT.filter( + (item) => + ![ + 'TIER_PRIORITY', + 'priority', + 'AD_SERVING_FEES', + 'REQUESTS', + 'BIDS', + 'GROSS_REVENUE', + 'PROFIT', + 'REVENUE', + 'EPPM', + 'NET_ERPM', + 'REQ_EPPM', + 'REQ_NET_ERPM', + 'CTR', + 'ACTUAL_RPM', + 'STATUS', + ].includes(item.id), + ) + : SUPPLY_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT.filter((item) => !['PUB_REVENUE'].includes(item.id)); + } + + return pageConfig.tableHeaders; + }; + + const defineTableCells = () => { + // props.pageConfig + if (pageConfig?.type === DEMAND_TAG_PAGE && info?.format === 'DISPLAY') { + return isAdminMP + ? DEMAND_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT + : DEMAND_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT.filter( + (item) => + !['fields.REVENUE', 'fields.RPM', 'fields.ERPM', 'fields.PROFIT', 'tier', 'priority'].includes(item.id), + ); + } + + // props.pageConfig + if (pageConfig?.type === SUPPLY_TAG_PAGE && info?.format === 'DISPLAY') { + return !isAdminMP + ? SUPPLY_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT.filter( + (item) => + ![ + 'tier', + 'priority', + 'adServingFee', + 'fields.REQUESTS', + 'fields.BIDS', + 'fields.GROSS_REVENUE', + 'fields.PROFIT', + 'fields.REVENUE', + 'fields.EPPM', + 'fields.NET_ERPM', + 'fields.REQ_EPPM', + 'fields.REQ_NET_ERPM', + 'fields.CTR', + 'fields.ACTUAL_RPM', + 'status', + ].includes(item.key), + ) + : SUPPLY_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT.filter((item) => !['fields.PUB_REVENUE'].includes(item.key)); + } + + return pageConfig.tableCells; + }; + + return ( + pageConfig && ( +
    + + + +
    + +
    +
    +
    + +
    + + { + dispatch(actions.setSearchAction(event.target.value)); + }} + onFocus={props.stopRefreshInterval} + onBlur={props.resetRefreshInterval} + InputProps={{ + endAdornment: ( + + + + ), + }} + variant="outlined" + size="small" + /> + + {pageConfig.showDownloadCSVButton && ( + + + + + + )} + + {pageConfig.tableFilters?.length && ( + + + + + {pageConfig.showFilterByLabel && ( + { + dispatch(actions.setFilterByLabelAction(newValue)); + }} + renderInput={(params) => ( + { + dispatch( + actions.getLabelsForTableFilterAction({ + prefix: e.target.value, + }), + ); + }} + variant="outlined" + size="small" + fullWidth + /> + )} + /> + )} + + )} + + + {isAdminMP && !!supply?.waterfallNote?.length && ( +
    + + + {'WF Note: '} + {supply.waterfallNote} + + +
    + )} + + + {pageConfig.bulkActions?.length && ( + + )} + + {pageConfig.plusButton && ( + + )} + +
    + +
    + + + + ) + ); +} + +Main.propTypes = { + stopRefreshInterval: PropTypes.func.isRequired, + resetRefreshInterval: PropTypes.func.isRequired, +}; + +export default Main; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.jsx b/anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.jsx new file mode 100644 index 0000000..b347bdb --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.jsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { DEMAND_ADVERTISER_PAGE_CHANGE_AD_FEE_MODAL } from '../../constants'; + +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from '@/mui/components'; + +import styles from './ChangeAdFeeModal.module.scss'; + +function ChangeAdFeeModal(props) { + const [rate, setRate] = useState(); + + const handleSetRate = ({ target }) => { + const value = parseFloat(target.value) > 100 || parseFloat(target.value) < 0 ? rate : target.value; + + const newRate = value?.split('.')[1]?.length > 2 ? value.substring(0, value.length - 1) : value; + + setRate(newRate); + }; + + const handleClose = () => { + setRate(null); + props.onClose(); + }; + + const handleSave = () => { + if (props.modal?.demandIds?.length) { + props.onSave({ + tags: props.modal.demandIds.map((id) => ({ + id, + fee: [ + { + startDate: null, + endDate: null, + value: parseNumberWithRound(rate / 100), + }, + ], + })), + }); + handleClose(); + } + }; + + return ( + + Update Additional Fees + + + Set a new Additional Fees line item for all the chosen demand tags accordingly: + + + + + + Are you sure you want to continue? + + + + + + + + ); +} + +ChangeAdFeeModal.propTypes = { + modal: PropTypes.shape({ + id: PropTypes.string, + demandIds: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, +}; + +export default ChangeAdFeeModal; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.module.scss b/anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.module.scss new file mode 100644 index 0000000..a608aec --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ChangeAdFeeModal.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Content":"ChangeAdFeeModal_Content__SknZ3","Field":"ChangeAdFeeModal_Field__bj8Q_","Actions":"ChangeAdFeeModal_Actions__oj4Ph"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ChangeExpensesModal.jsx b/anyclip/src/modules/marketplace/account/components/Modals/ChangeExpensesModal.jsx new file mode 100644 index 0000000..4f88c9b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ChangeExpensesModal.jsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { SUPPLY_SITE_PAGE_CHANGE_EXPENSES_MODAL } from '../../constants'; + +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from '@/mui/components'; + +import styles from './ChangeAdFeeModal.module.scss'; + +function ChangeExpensesModal(props) { + const [rate, setRate] = useState(''); + + const handleSetRate = ({ target }) => { + const value = target.value?.length ? parseInt(target.value, 10) : target.value; + const newRate = value > 100 || value < 0 ? rate : value; + + setRate(newRate); + }; + + const handleClose = () => { + setRate(null); + props.onClose(); + }; + + const handleSave = () => { + if (props.modal?.supplyIds?.length) { + const tags = props.modal.supplyIds.map((id) => ({ + id, + expenses: { + value: parseNumberWithRound(rate / 100), + }, + })); + props.onSave({ tags }); + handleClose(); + } + }; + + return ( + + Update Expenses Terms + + + Set a new Expenses line item for all the chosen supply tags accordingly: + + + + + + Are you sure you want to continue? + + + + + + + + ); +} + +ChangeExpensesModal.propTypes = { + modal: PropTypes.shape({ + id: PropTypes.string, + supplyIds: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, +}; + +export default ChangeExpensesModal; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ChangeRevShareModal.jsx b/anyclip/src/modules/marketplace/account/components/Modals/ChangeRevShareModal.jsx new file mode 100644 index 0000000..98a2322 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ChangeRevShareModal.jsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { SUPPLY_SITE_PAGE_CHANGE_REV_SHARE_MODAL } from '../../constants'; + +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from '@/mui/components'; + +import styles from './ChangeAdFeeModal.module.scss'; + +function ChangeRevShareModal(props) { + const [rate, setRate] = useState(); + + const handleSetRate = ({ target }) => { + const value = parseInt(target.value, 10); + const newRate = value > 200 || value <= 0 ? rate : value; + + setRate(newRate); + }; + + const handleClose = () => { + setRate(null); + props.onClose(); + }; + + const handleSave = () => { + if (props.modal?.supplyIds?.length) { + const tags = props.modal.supplyIds.map((id) => ({ + id, + pricing: { + model: 'REV_SHARE', + startDate: null, + value: parseNumberWithRound(rate / 100), + }, + })); + props.onSave({ tags }); + handleClose(); + } + }; + + return ( + + Update Rev-Share Terms + + + Set a new Rev-Share line item for all the chosen supply tags accordingly: + + + + + + Are you sure you want to continue? + + + + + + + + ); +} + +ChangeRevShareModal.propTypes = { + modal: PropTypes.shape({ + id: PropTypes.string, + supplyIds: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, +}; + +export default ChangeRevShareModal; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.jsx b/anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.jsx new file mode 100644 index 0000000..3a2ae6d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.jsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { SUPPLY_TAG_PAGE_CHANGE_TIER_MODAL, TIERS } from '../../constants'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + ToggleButton, + ToggleButtonGroup, +} from '@/mui/components'; + +import styles from './ChangeTierModal.module.scss'; + +function ChangeTierModal(props) { + const [tier, setTier] = useState(); + + const handleClose = () => { + setTier(null); + props.onClose(); + }; + + const handleSave = () => { + if (props.modal?.supplyIds?.length && props.modal?.demandIds?.length) { + props.onSave({ + supplyId: props.modal.supplyIds[0], + demands: props.modal.demandIds.map((id) => ({ + demandId: id, + tier, + })), + }); + handleClose(); + } + }; + + return ( + + Change Tier + +
    + + {TIERS.map((item) => ( + setTier(item.value)}> + {item.label} + + ))} + +
    +
    + + + + +
    + ); +} + +ChangeTierModal.propTypes = { + modal: PropTypes.shape({ + id: PropTypes.string, + demandIds: PropTypes.arrayOf(PropTypes.string), + supplyIds: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, +}; + +export default ChangeTierModal; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.module.scss b/anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.module.scss new file mode 100644 index 0000000..5873b43 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ChangeTierModal.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Paper":"ChangeTierModal_Paper__MJauN","Content":"ChangeTierModal_Content__Ouovo","Tiers":"ChangeTierModal_Tiers__PuDDu","Actions":"ChangeTierModal_Actions__uFLPe"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ChangeViewabilityThreshold.jsx b/anyclip/src/modules/marketplace/account/components/Modals/ChangeViewabilityThreshold.jsx new file mode 100644 index 0000000..67ee277 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ChangeViewabilityThreshold.jsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { DEMAND_ADVERTISER_PAGE_CHANGE_VIEWABILITY_THRESHOLD_MODAL } from '../../constants'; + +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from '@/mui/components'; + +import styles from './ChangeAdFeeModal.module.scss'; + +function ChangeViewabilityThresholdModal(props) { + const [rate, setRate] = useState(); + + const handleSetRate = ({ target }) => { + const value = parseFloat(target.value) > 100 || parseFloat(target.value) <= 0 ? rate : target.value; + + const newRate = value?.split('.')[1]?.length > 2 ? value.substring(0, value.length - 1) : value; + + setRate(newRate); + }; + + const handleClose = () => { + setRate(null); + props.onClose(); + }; + + const handleSave = () => { + if (props.modal?.demandIds?.length) { + props.onSave({ + ids: props.modal.demandIds, + viewabilityThreshold: parseNumberWithRound(rate / 100), + }); + handleClose(); + } + }; + + return ( + + Update Viewability Threshold + + + Set a new Viewability Threshold for all the chosen demand tags accordingly: + + + + + + Are you sure you want to continue? + + + + + + + + ); +} + +ChangeViewabilityThresholdModal.propTypes = { + modal: PropTypes.shape({ + id: PropTypes.string, + demandIds: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, +}; + +export default ChangeViewabilityThresholdModal; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/ConfirmFrequencyCapModal.jsx b/anyclip/src/modules/marketplace/account/components/Modals/ConfirmFrequencyCapModal.jsx new file mode 100644 index 0000000..7526046 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/ConfirmFrequencyCapModal.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { DEMAND_TAG_PAGE_CONFIRM_FREQUENCY_CAP_MODAL } from '../../constants'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@/mui/components'; + +import styles from './ChangeAdFeeModal.module.scss'; + +function ConfirmFrequencyCapModal(props) { + const handleClose = () => { + props.onClose(); + }; + + const handleSave = () => { + props.onSave(); + handleClose(); + }; + + return ( + + Update Frequency Cap Settings? + + + Removing or increasing the frequency cap may reduce fill rate and negatively impact inventory yield. Consult + your AM before making changes if unsure. + + +
    + + + Are you sure you want to continue? + + + + + + +
    + ); +} + +ConfirmFrequencyCapModal.propTypes = { + modal: PropTypes.shape({ + id: PropTypes.string, + }).isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, +}; + +export default ConfirmFrequencyCapModal; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.jsx b/anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.jsx new file mode 100644 index 0000000..b8b59e1 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.jsx @@ -0,0 +1,398 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import classNames from 'clsx'; +import { FilterAlt, SearchRounded } from '@mui/icons-material'; + +import { CUSTOM_PARENT_STICKY_CLASS_NAME } from '../../../common/constants'; +import { + ADD_DEMAND_TAG_CONFIG, + ADD_SUPPLY_TAG_CONFIG, + DEMAND_TAG_FORMAT, + DEMAND_TAG_PAGE_ADD_SUPPLY_TAG_MODAL, + DEMAND_TAG_PAGE_BUSINESS_MODEL, + DISPLAY_TYPE_OPTIONS, + STATUS_FILTERS, + SUPPLY_TAG_PAGE_ADD_DEMAND_TAG_MODAL, +} from '../../constants'; + +import * as selectors from '../../redux/selectors'; + +import StatusCell from '@/modules/marketplace/common/Cells/StatusCell'; +import Table from '@/modules/marketplace/common/Table'; +import TargetingCell from '../Cells/TargetingCell'; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputAdornment, + TableContainer, + TablePagination, + TableScroll, + TextField, +} from '@/mui/components'; + +import styles from './WaterfallModal.module.scss'; + +function WaterfallModal(props) { + const [selected, setSelected] = useState([]); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const info = useSelector(selectors.infoSelector); + + const isAddDemand = props.modal.id === SUPPLY_TAG_PAGE_ADD_DEMAND_TAG_MODAL; + const isAddSupply = props.modal.id === DEMAND_TAG_PAGE_ADD_SUPPLY_TAG_MODAL; + + let config = null; + + if (isAddDemand) { + config = ADD_DEMAND_TAG_CONFIG; + } + + if (isAddSupply) { + config = ADD_SUPPLY_TAG_CONFIG; + } + + useEffect(() => { + if (isAddSupply && !props.isAdminMP) { + props.setModalInfo({ status: STATUS_FILTERS[1] }); + } + }, [isAddSupply]); + + useEffect(() => { + if (config) { + props.getDataForWaterfall({ config, page, rowsPerPage }); + props.getAccountsForWaterfall({ config }); + + if (isAddDemand) { + props.getLabelsForWaterfall(); + } + } + }, [config, props.modal.id]); + + const handleClose = () => { + props.onClose(); + setSelected([]); + setPage(0); + setRowsPerPage(25); + }; + + const handleSubmit = () => { + props.bulkCreateWaterfall({ selected }); + handleClose(); + }; + + return ( + + {config && ( + <> + + {config.title} + + +
    + + { + props.setModalInfo({ + search: e.target.value, + }); + props.getDataForWaterfall({ + config, + page: 0, + rowsPerPage, + debounce: true, + }); + }} + InputProps={{ + className: styles.Input, + endAdornment: ( + + + + ), + }} + variant="outlined" + size="small" + /> + + +
    + + + + {props.isAdminMP && ( + + options} + optionLabelKey="name" + optionValueKey="name" + onChange={(e, newValue) => { + props.setModalInfo({ account: newValue }); + props.getDataForWaterfall({ + config, + page: 0, + rowsPerPage, + }); + }} + size="small" + renderInput={(params) => ( + { + props.getAccountsForWaterfall({ + name: e.target.value, + config, + }); + }} + variant="outlined" + size="small" + /> + )} + /> + + )} + + {isAddDemand && props.isAdminMP && ( + + { + props.setModalInfo({ label: newValue }); + props.getDataForWaterfall({ + config, + page: 0, + rowsPerPage, + }); + }} + renderInput={(params) => ( + { + props.getLabelsForWaterfall({ + prefix: e.target.value, + }); + }} + variant="outlined" + size="small" + fullWidth + /> + )} + /> + + )} + + {isAddDemand && props.isAdminMP && ( + + { + props.setModalInfo({ model: newValue }); + props.getDataForWaterfall({ + config, + page: 0, + rowsPerPage, + }); + }} + size="small" + renderInput={(params) => ( + + )} + /> + + )} + + {isAddDemand && ( + + { + props.setModalInfo({ + rate: e.target.value.replace(/\D/g, ''), + }); + props.getDataForWaterfall({ + config, + page: 0, + rowsPerPage, + debounce: true, + }); + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + variant="outlined" + size="small" + /> + + )} + + + { + props.setModalInfo({ status: newValue }); + props.getDataForWaterfall({ + config, + page: 0, + rowsPerPage, + }); + }} + size="small" + renderInput={(params) => ( + + )} + /> + + + {isAddSupply && info?.format === DEMAND_TAG_FORMAT.display && ( + + { + props.setModalInfo({ displayType: newValue }); + props.getDataForWaterfall({ + config, + page: 0, + rowsPerPage, + }); + }} + size="small" + renderInput={(params) => ( + + )} + /> + + )} +
    + + +
    + DEMAND_TAG_PAGE_BUSINESS_MODEL.find((item) => item.value === cellProps.cell)?.label ?? '', + [['include', 'exclude', 'frequency']]: (cellProps) => , + status: (cellProps) => , + }} + withPagination={false} + handleCellClick={() => null} + isLoading={props.modal.isFetchingData} + /> + + + + + { + setPage(page$ - 1); + props.getDataForWaterfall({ config, page: page$ - 1, rowsPerPage }); + }} + onRowsPerPageChange={(event) => { + setRowsPerPage(+event.target.value); + props.getDataForWaterfall({ config, page, rowsPerPage: +event.target.value }); + }} + /> + + + + + + + )} + + ); +} + +WaterfallModal.propTypes = { + modal: PropTypes.shape({ + id: PropTypes.string, + data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + page: PropTypes.number.isRequired, + isFetchingData: PropTypes.bool.isRequired, + totalCount: PropTypes.number.isRequired, + search: PropTypes.string.isRequired, + account: PropTypes.shape({}), + accountsOptions: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + status: PropTypes.string, + rate: PropTypes.string, + model: PropTypes.string, + label: PropTypes.shape({}), + labelsOptions: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + displayType: PropTypes.string, + }).isRequired, + onClose: PropTypes.func.isRequired, + getDataForWaterfall: PropTypes.func.isRequired, + getAccountsForWaterfall: PropTypes.func.isRequired, + getLabelsForWaterfall: PropTypes.func.isRequired, + setModalInfo: PropTypes.func.isRequired, + bulkCreateWaterfall: PropTypes.func.isRequired, + isAdminMP: PropTypes.bool.isRequired, +}; + +export default WaterfallModal; diff --git a/anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.module.scss b/anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.module.scss new file mode 100644 index 0000000..114449a --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Modals/WaterfallModal.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Modal_controls":"WaterfallModal_Modal_controls__Scn9K","Input":"WaterfallModal_Input__cBGwk","Rate":"WaterfallModal_Rate__QKTRt","Actions":"WaterfallModal_Actions__OAdCS","Container":"WaterfallModal_Container__a5KWS","TableContainer":"WaterfallModal_TableContainer__6Dx_M","FiltersContainer":"WaterfallModal_FiltersContainer__W81Wq","Filters":"WaterfallModal_Filters__J35Gx","Filters_item":"WaterfallModal_Filters_item__cstAB","Filter___small":"WaterfallModal_Filter___small__2qwYg","Filter___fixed":"WaterfallModal_Filter___fixed__1pFSi","Filter___long":"WaterfallModal_Filter___long__XqlqG","Divider":"WaterfallModal_Divider__yDVvv","IconFilter":"WaterfallModal_IconFilter__3XyY7","DefaultSelectProp":"WaterfallModal_DefaultSelectProp__Hrl4l","FiltersAdornment":"WaterfallModal_FiltersAdornment__KrxOe"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/AutomaticOptimization/index.jsx b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/AutomaticOptimization/index.jsx new file mode 100644 index 0000000..5a4b0ec --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/AutomaticOptimization/index.jsx @@ -0,0 +1,176 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + AUTOMATIC_OPTIMIZATION, + AUTOMATIC_OPTIMIZATION_FREQUENCY, + AUTOMATIC_OPTIMIZATION_KPI_DISPLAY, + AUTOMATIC_OPTIMIZATION_KPI_VIDEO, + AUTOMATIC_OPTIMIZATION_PERIOD, + AUTOMATIC_OPTIMIZATION_TIERS, + DEMAND_TAG_FORMAT, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { getRequiredInputProps } from '@/modules/@common/Form/helpers'; +import { integerFieldBlockInvalidChar } from '@/modules/marketplace/account/helpers/validate'; + +import { Form, FormContent, FormGroup, FormRow, FormSection } from '@/modules/@common/Form'; +import { MenuItem, Select, Switch, TextField } from '@/mui/components'; + +function AutomaticOptimizationTab() { + const dispatch = useDispatch(); + + const supply = useSelector(selectors.supplySelector); + + const { format } = supply; + + const automaticOptimization = useSelector(selectors.automaticOptimizationSelector); + const { status, kpi, period, frequency, hbTiers, kpiGap, errors } = automaticOptimization; + + useEffect(() => { + dispatch( + actions.setAutomaticOptimizationTabAction({ + hbTiers: + format === DEMAND_TAG_FORMAT.video + ? AUTOMATIC_OPTIMIZATION_TIERS[1].value + : AUTOMATIC_OPTIMIZATION_TIERS[0].value, + kpiGap: format === DEMAND_TAG_FORMAT.video ? '45' : '60', + }), + ); + }, [format]); + + return ( +
    + + + + + dispatch( + actions.setAutomaticOptimizationTabAction({ + status: + status === AUTOMATIC_OPTIMIZATION.enabled + ? AUTOMATIC_OPTIMIZATION.disabled + : AUTOMATIC_OPTIMIZATION.enabled, + }), + ) + } + /> + + {status === AUTOMATIC_OPTIMIZATION.enabled && ( + + + + + + + + + + + + + + + { + const { kpiGap: kpiGapError, ...restErrors } = errors; + if (!target.value?.length || (target.value > 0 && target.value < 100)) { + dispatch( + actions.setAutomaticOptimizationTabAction({ + kpiGap: target.value?.length ? parseInt(target.value, 10).toString() : target.value, + errors: restErrors, + }), + ); + } + }} + /> + + + )} + + + + ); +} + +export default AutomaticOptimizationTab; diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/BaseSettingsForm/index.jsx b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/BaseSettingsForm/index.jsx new file mode 100644 index 0000000..34c2a28 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/BaseSettingsForm/index.jsx @@ -0,0 +1,449 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + DEMAND_TAG_FORMAT, + DISPLAY_TYPE_OPTIONS, + SOURCE_TAG_OPTIONS, + SUPPLY_TAG_PUBLISHER_DEMAND_ONLY, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import { getRequiredInputProps } from '@/modules/@common/Form/helpers'; +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { FormRow } from '@/modules/@common/Form'; +import { MenuItem, Select, Switch, TextField, ToggleButton, ToggleButtonGroup } from '@/mui/components'; + +const floorRegex = /^\d+(\.\d{0,2})?$/; + +function BaseSettings(props) { + const dispatch = useDispatch(); + + const pageConfig = useSelector(selectors.pageConfigSelector); + const info = useSelector(selectors.infoSelector); + const supply = useSelector(selectors.supplySelector); + + const { + name, + source, + platformId, + format, + displayType, + floorPrice, + waterfallNote, + publisherDemandOnly, + automaticFloorPrice, + fillRateThreshold, + minimumFloor, + maximumFloor, + errors, + } = supply; + + const { isCreatingNew, isDuplicate } = pageConfig ?? {}; + + useEffect(() => { + if (isCreatingNew) { + // set default values + if (format === DEMAND_TAG_FORMAT.video) { + dispatch( + actions.setSupplyTabAction({ + floorPrice: { + ...floorPrice, + viewableFloor: '1.25', + floor: '0.95', + firstRequestFloor: '3.75', + }, + }), + ); + } + if (format === DEMAND_TAG_FORMAT.display) { + if (displayType === DISPLAY_TYPE_OPTIONS[0].value) { + dispatch( + actions.setSupplyTabAction({ + floorPrice: { + ...floorPrice, + viewableFloor: '0.75', + floor: '0.65', + firstRequestFloor: '2.25', + }, + }), + ); + } + + if (displayType === DISPLAY_TYPE_OPTIONS[1].value) { + dispatch( + actions.setSupplyTabAction({ + floorPrice: { + ...floorPrice, + viewableFloor: '0.55', + floor: '0.45', + firstRequestFloor: '1.65', + }, + }), + ); + } + } + } + }, [isCreatingNew, format, displayType]); + + return ( + <> + + { + const { name: nameError, ...restErrors } = errors; + dispatch(actions.setSupplyTabAction({ name: target.value, errors: restErrors })); + }} + /> + + + {props.isAdminMP && ( + <> + + + + + {source !== SOURCE_TAG_OPTIONS[0].value && ( + + { + const { platformId: platformIdError, ...restErrors } = errors; + dispatch(actions.setSupplyTabAction({ platformId: target.value, errors: restErrors })); + }} + /> + + )} + + )} + + {info?.accountType !== 'VAST' && // from info by id + pageConfig?.supplyAccount?.accountType !== 'VAST' && ( // from queries for new tag + + + + dispatch( + actions.setSupplyTabAction({ + format: DEMAND_TAG_FORMAT.video, + }), + ) + } + > + Video + + + dispatch( + actions.setSupplyTabAction({ + format: DEMAND_TAG_FORMAT.display, + }), + ) + } + > + Display + + + dispatch( + actions.setSupplyTabAction({ + format: DEMAND_TAG_FORMAT.sponsored, + }), + ) + } + > + Sponsored + + + + )} + + {props.isAdminMP && ( + <> + {format === DEMAND_TAG_FORMAT.display && ( + + + {DISPLAY_TYPE_OPTIONS.map((item) => ( + + dispatch( + actions.setSupplyTabAction({ + displayType: item.value, + }), + ) + } + > + {item.label} + + ))} + + + )} + + {format !== DEMAND_TAG_FORMAT.sponsored && ( + <> + + { + const { viewableFloor, ...restErrors } = errors; + if (floorRegex.test(target.value) || target.value.length === 0) { + dispatch( + actions.setSupplyTabAction({ + floorPrice: { + ...floorPrice, + viewableFloor: target.value, + }, + errors: restErrors, + }), + ); + } + }} + /> + + + { + const { floor, ...restErrors } = errors; + if (floorRegex.test(target.value) || target.value.length === 0) { + dispatch( + actions.setSupplyTabAction({ + floorPrice: { + ...floorPrice, + floor: target.value, + }, + errors: restErrors, + }), + ); + } + }} + /> + + + { + const { firstRequestFloor, ...restErrors } = errors; + if (floorRegex.test(target.value) || target.value.length === 0) { + dispatch( + actions.setSupplyTabAction({ + floorPrice: { + ...floorPrice, + firstRequestFloor: target.value, + }, + errors: restErrors, + }), + ); + } + }} + /> + + + + + dispatch( + actions.setSupplyTabAction({ + automaticFloorPrice: !automaticFloorPrice, + }), + ) + } + /> + + + )} + + {automaticFloorPrice && ( + <> + + { + const { fillRateThreshold: fillRateThresholdError, ...restErrors } = errors; + dispatch( + actions.setSupplyTabAction({ + fillRateThreshold: target.value?.length + ? parseNumberWithRound(Math.trunc(target.value) / 100) + : '', + errors: restErrors, + }), + ); + }} + /> + + + + { + const { minimumFloor: minimumFloorError, ...restErrors } = errors; + if (floorRegex.test(target.value) || target.value.length === 0) { + dispatch( + actions.setSupplyTabAction({ + minimumFloor: target.value, + errors: restErrors, + }), + ); + } + }} + /> + + + + { + const { maximumFloor: maximumFloorError, ...restErrors } = errors; + if (floorRegex.test(target.value) || target.value.length === 0) { + dispatch( + actions.setSupplyTabAction({ + maximumFloor: target.value, + errors: restErrors, + }), + ); + } + }} + /> + + + )} + + + + dispatch( + actions.setSupplyTabAction({ + publisherDemandOnly: + publisherDemandOnly === SUPPLY_TAG_PUBLISHER_DEMAND_ONLY.enabled + ? SUPPLY_TAG_PUBLISHER_DEMAND_ONLY.disabled + : SUPPLY_TAG_PUBLISHER_DEMAND_ONLY.enabled, + }), + ) + } + /> + + + { + dispatch(actions.setSupplyTabAction({ waterfallNote: target.value })); + }} + /> + + + )} + + ); +} + +BaseSettings.propTypes = { + isAdminMP: PropTypes.bool.isRequired, +}; + +export default BaseSettings; diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/ExportTagTab/ExportTagTab.jsx b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/ExportTagTab/ExportTagTab.jsx new file mode 100644 index 0000000..af90b37 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/ExportTagTab/ExportTagTab.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { ContentCopyRounded } from '@mui/icons-material'; + +import * as selectors from '../../../../redux/selectors'; +import * as actions from '../../../../redux/slices'; +import copyToClipboard from '@/modules/@common/helpers/copy'; + +import { Form, FormContent, FormRow, FormSection } from '@/modules/@common/Form'; +import { IconButton, InputAdornment, Link, TextField } from '@/mui/components'; + +function ExportTagTab() { + const dispatch = useDispatch(); + + const supply = useSelector(selectors.supplySelector); + + const { adServerUrl } = supply; + + return ( +
    + + + + dispatch(actions.setSupplyTabAction({ adServerUrl: target.value }))} + helperText={ + <> + {'For supported macros, please see our '} + + online help + + + } + InputProps={{ + endAdornment: ( + + copyToClipboard(adServerUrl)} size="small"> + + + + ), + }} + /> + + + + + ); +} + +export default ExportTagTab; diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.jsx new file mode 100644 index 0000000..5adf373 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.jsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import isSameOrAfterPlugin from 'dayjs/plugin/isSameOrAfter'; +import { Check, Clear } from '@mui/icons-material'; + +import { SUPPLY_TAG_PAGE_PRICE_MODEL } from '@/modules/marketplace/account/constants'; + +import { DateTimePicker, Grid, IconButton, MenuItem, Select, TextField } from '@/mui/components'; + +import styles from './index.module.scss'; + +dayjs.extend(isSameOrAfterPlugin); + +const getStartDateWhenIsUpdatingNewTag = (prevStartDate) => new Date(prevStartDate).getTime(); +const getStartDateWhenIsCreatingNewTag = () => new Date().getTime(); +const isShouldDisableDate = (prevStartDate) => (dateFromCalendar) => + dayjs(prevStartDate).add(-1, 'days').isAfter(dateFromCalendar); +const isPrevStartDateIsSameOrAfterToday = (prevStartDate) => dayjs(prevStartDate).isSameOrAfter(new Date().getTime()); +const isShouldDisabledSubmit = (prevStartDate, startDate) => { + if (dayjs(prevStartDate).isSame(new Date().getTime())) { + return false; + } + return dayjs(prevStartDate).isSameOrAfter(startDate); +}; + +function FormLineItem({ className = '', isCreatingNew = false, prevStartDate = 0, ...props }) { + const getPrevStartDate = () => { + if (isPrevStartDateIsSameOrAfterToday(prevStartDate)) { + return new Date(prevStartDate).getTime(); + } + + return new Date().getTime(); + }; + + const [model$, setModel] = useState(SUPPLY_TAG_PAGE_PRICE_MODEL[2].value); + const [payment$, setPayment] = useState(80); + const [startDate$, setStartDate] = useState( + isCreatingNew ? getStartDateWhenIsCreatingNewTag() : getStartDateWhenIsUpdatingNewTag(getPrevStartDate()), + ); + + const handleSubmit = () => { + const startDate = + !isCreatingNew && dayjs(getPrevStartDate()).isSameOrAfter(startDate$) ? null : new Date(startDate$).getTime(); + + props.onSubmit({ + model: model$, + payment: payment$, + startDate, + status: 'active', + }); + }; + + const handleSetModel = ({ target }) => { + if (target.value === SUPPLY_TAG_PAGE_PRICE_MODEL[2].value) { + setPayment(1); + } + + setModel(target.value); + }; + + const handleSetPayment = ({ target }) => { + const payment = + model$ === SUPPLY_TAG_PAGE_PRICE_MODEL[2].value && !parseFloat(target.value) ? payment$ : target.value; + + setPayment(payment.split('.')[1]?.length > 2 ? payment$ : payment); + }; + + return ( + + + + + + + + + { + setStartDate(date.toDate()); + }} + shouldDisableDate={isShouldDisableDate(getPrevStartDate())} + disabled={isCreatingNew} + slotProps={{ + textField: { + size: 'small', + }, + }} + /> + + + +
    + + + +
    +
    + props.onCancel()}> + + +
    +
    +
    + ); +} + +FormLineItem.propTypes = { + className: PropTypes.string, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + isCreatingNew: PropTypes.bool, + prevStartDate: PropTypes.number, +}; + +export default FormLineItem; diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.module.scss new file mode 100644 index 0000000..d3770a5 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/FormLineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"FormLineItem_Container__YS1L6","Container___label":"FormLineItem_Container___label__db3NA","Container___edit":"FormLineItem_Container___edit__JxSDl","Container___checked":"FormLineItem_Container___checked__w0EhE","Item":"FormLineItem_Item__zOWrO","Name":"FormLineItem_Name__QTdtl","Buttons":"FormLineItem_Buttons__XlAuq","ButtonEdit":"FormLineItem_ButtonEdit__hrpAZ"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.jsx b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.jsx new file mode 100644 index 0000000..aa5c9f9 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.jsx @@ -0,0 +1,206 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import { Check, Clear, Delete, Edit } from '@mui/icons-material'; + +import { SUPPLY_TAG_PAGE_PRICE_MODEL } from '@/modules/marketplace/account/constants'; + +import { getLabelByValue } from '@/modules/marketplace/account/helpers/demandTabs'; + +import { DateTimePicker, Grid, IconButton, MenuItem, Select, TextField } from '@/mui/components'; + +import styles from './index.module.scss'; + +function LineItem({ + className = '', + isLabelRow = false, + endDate = '', + isCreatingNew = false, + paymentMask = '', + status = '', + onSubmit = () => null, + onDelete = () => null, + isDuplicating = false, + ...props +}) { + const statuses = { + active: 'active', + inactive: 'inactive', + }; + const [isEdit, setEditState] = useState(false); + const [model$, setModel] = useState(props.model); + const [payment$, setPayment] = useState(props.payment); + const [startDate$, setStartDate] = useState(props.startDate); + const isActive = (!isLabelRow && statuses.active === status) || (!isLabelRow && isDuplicating); + const inActive = !isLabelRow && statuses.active !== status; + + const handleSetPayment = ({ target }) => { + const payment = + model$ === SUPPLY_TAG_PAGE_PRICE_MODEL[2].value && !parseFloat(target.value) ? payment$ : target.value; + + setPayment(payment.split('.')[1]?.length > 2 ? payment$ : payment); + }; + + const handleSetModel = ({ target }) => { + if (target.value === SUPPLY_TAG_PAGE_PRICE_MODEL[2].value) { + setPayment(1); + } + + setModel(target.value); + }; + + return ( + + + {!isEdit ? ( + getLabelByValue(SUPPLY_TAG_PAGE_PRICE_MODEL, model$) || model$ + ) : ( + + )} + + + {!isEdit && isLabelRow && props.payment} + {!isEdit && !isLabelRow && paymentMask.replace('[NUMBER]', props.payment)} + {isEdit && ( + + )} + + + {!isEdit && isLabelRow && startDate$} + {(!isEdit || (isEdit && isDuplicating)) && !isLabelRow && startDate$ + ? new Date(startDate$).toLocaleString() + : ''} + {isEdit && !isDuplicating && ( + { + setStartDate(date.toDate()); + }} + disabled={isCreatingNew} + slotProps={{ + textField: { + size: 'small', + }, + }} + /> + )} + + + {!isEdit && isLabelRow && endDate} + {!isEdit && !isLabelRow && endDate ? new Date(endDate).toLocaleString() : ''} + + {!isLabelRow && ( + + {!isEdit && isActive && ( + <> +
    + { + setEditState(true); + }} + > + + +
    + {!isDuplicating && ( +
    + onDelete(props.id)} + > + + +
    + )} + + )} + {isEdit && ( + <> +
    + { + setEditState(false); + onSubmit({ + id: props.id, + model: model$, + payment: payment$, + startDate: new Date(startDate$).getTime(), + status, + }); + }} + > + + +
    +
    + { + setEditState(false); + setModel(props.model); + setPayment(props.payment); + setStartDate(props.startDate); + }} + > + + +
    + + )} +
    + )} +
    + ); +} + +LineItem.propTypes = { + id: PropTypes.string.isRequired, + model: PropTypes.string.isRequired, + payment: PropTypes.string.isRequired, + startDate: PropTypes.number.isRequired, + endDate: PropTypes.number, + className: PropTypes.string, + isLabelRow: PropTypes.bool, + isCreatingNew: PropTypes.bool, + paymentMask: PropTypes.string, + status: PropTypes.string, + onSubmit: PropTypes.func, + onDelete: PropTypes.func, + isDuplicating: PropTypes.bool, +}; + +export default LineItem; diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.module.scss b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.module.scss new file mode 100644 index 0000000..a9ffd94 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/components/LineItem/index.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"LineItem_Container__TGDaR","Container___label":"LineItem_Container___label__m0F9c","Container___edit":"LineItem_Container___edit__rDU_p","Container___checked":"LineItem_Container___checked__D_Kyb","Item":"LineItem_Item__yXJeU","Name":"LineItem_Name__FTj0f","Buttons":"LineItem_Buttons__yzHHA","ButtonEdit":"LineItem_ButtonEdit__4U2wV"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/index.jsx b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/index.jsx new file mode 100644 index 0000000..019e629 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/SupplyTagSettings/index.jsx @@ -0,0 +1,324 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { Add } from '@mui/icons-material'; + +import { SUPPLY_TAG_PAGE_PRICE_MODEL, SUPPLY_TAG_PRICING_TYPE } from '@/modules/marketplace/account/constants'; + +import * as selectors from '../../redux/selectors'; +import * as actions from '../../redux/slices'; +import useLocalPagination from '@/modules/marketplace/account/helpers/useLocalPagination'; +import { parseNumberWithRound } from '@/modules/marketplace/common/helpers'; + +import { Form, FormContent, FormGroupTitle, FormRow, FormSection } from '@/modules/@common/Form'; +import PricingFormLineItem from '../DemandSettings/components/PricingTab/components/FormLineItem'; +import PricingLineItem from '../DemandSettings/components/PricingTab/components/LineItem'; +import BaseSettings from './components/BaseSettingsForm'; +import FormLineItem from './components/FormLineItem'; +import LineItem from './components/LineItem'; +import { Button, Divider, Stack, TablePagination } from '@/mui/components'; + +function SupplyTagSettings(props) { + const dispatch = useDispatch(); + + const pageConfig = useSelector(selectors.pageConfigSelector); + + const supply = useSelector(selectors.supplySelector); + const { pricing, expenses } = supply; + + const { isCreatingNew, isDuplicate } = pageConfig ?? {}; + const [showNewForm, toggleNewFrom] = useState(false); + const [showNewExpensesForm, toggleNewExpensesForm] = useState(false); + const pagination = useLocalPagination(25, 1); + const paginationExpenses = useLocalPagination(25, 1); + + const onAddNewPrice = (key, data) => { + const state = supply[key]; + if (isCreatingNew || isDuplicate) { + dispatch( + actions.setSupplyTabAction({ + [key]: [ + { + id: `${state.length}`, + ...data, + }, + ...state, + ], + }), + ); + } else { + // update with server + dispatch( + actions.updateSupplyTagAction({ + data: { + ...data, + }, + key, + }), + ); + } + }; + + const onEditPrice = (key, data) => { + dispatch( + actions.setSupplyTabAction({ + [key]: supply[key]?.map((i) => (i.id === data.id ? { ...data } : i)), + }), + ); + }; + + const onDeletePrice = (key, id) => { + dispatch( + actions.setSupplyTabAction({ + [key]: supply[key]?.filter((i) => i.id !== id), + }), + ); + }; + + useEffect(() => { + if (isCreatingNew) { + // All new tags should be created with an open line of Expenses set to 0% - and available for edit + onAddNewPrice(SUPPLY_TAG_PRICING_TYPE.expenses, { + value: 0, + startDate: new Date().getTime(), + endDate: null, + status: 'active', + }); + } + }, [isCreatingNew]); + + return ( +
    + + + Basic Setting + + + {props.isAdminMP && ( + <> + + {!!pricing.length && !isDuplicate && ( + + )} + {!pricing.length && !showNewForm && ( + + )} + + + + + {showNewForm && ( + { + const payment = + data.model === SUPPLY_TAG_PAGE_PRICE_MODEL[0].value || + data.model === SUPPLY_TAG_PAGE_PRICE_MODEL[1].value + ? parseFloat(data.payment) + : parseNumberWithRound(data.payment / 100); + toggleNewFrom(false); + onAddNewPrice(SUPPLY_TAG_PRICING_TYPE.pricing, { ...data, payment }); + }} + onCancel={() => { + toggleNewFrom(false); + }} + /> + )} + {pricing.slice(pagination.startIndex, pagination.endIndex).map((tag) => ( + { + const payment = + data.model === SUPPLY_TAG_PAGE_PRICE_MODEL[0].value || + data.model === SUPPLY_TAG_PAGE_PRICE_MODEL[1].value + ? parseFloat(data.payment) + : parseNumberWithRound(data.payment / 100); + onEditPrice(SUPPLY_TAG_PRICING_TYPE.pricing, { ...data, payment }); + }} + onDelete={(id) => { + onDeletePrice(SUPPLY_TAG_PRICING_TYPE.pricing, id); + }} + /> + ))} + + {!isCreatingNew && !isDuplicate && ( + { + pagination.setCurrentPage(page); + }} + onRowsPerPageChange={(event) => { + pagination.setItemsPerPage(+event.target.value); + }} + /> + )} + + + + + + {!!expenses.length && !isDuplicate && ( + + )} + {!expenses.length && !showNewExpensesForm && ( + + )} + + + + + {showNewExpensesForm && ( + { + toggleNewExpensesForm(false); + onAddNewPrice(SUPPLY_TAG_PRICING_TYPE.expenses, { + ...data, + value: parseNumberWithRound(data.rate / 100), + }); + }} + onCancel={() => { + toggleNewExpensesForm(false); + }} + /> + )} + {expenses.slice(paginationExpenses.startIndex, paginationExpenses.endIndex).map((item) => ( + { + onEditPrice(SUPPLY_TAG_PRICING_TYPE.expenses, { + ...data, + value: parseNumberWithRound(data.rate / 100), + }); + }} + onDelete={(id) => { + onDeletePrice(SUPPLY_TAG_PRICING_TYPE.expenses, id); + }} + /> + ))} + {!isCreatingNew && !isDuplicate && !!expenses.length && ( + { + paginationExpenses.setCurrentPage(page); + }} + onRowsPerPageChange={(event) => { + paginationExpenses.setItemsPerPage(+event.target.value); + }} + /> + )} + + + + )} + + + + ); +} + +SupplyTagSettings.propTypes = { + isAdminMP: PropTypes.bool.isRequired, +}; + +export default SupplyTagSettings; diff --git a/anyclip/src/modules/marketplace/account/components/Total/Total.module.scss b/anyclip/src/modules/marketplace/account/components/Total/Total.module.scss new file mode 100644 index 0000000..2e55a5d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Total/Total.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Total":"Total_Total__MiRwa","Total_item___withMargin":"Total_Total_item___withMargin__tEKOc","Cell":"Total_Cell__rA32X","Cell___withPercent":"Total_Cell___withPercent__sHb77","Cell_title":"Total_Cell_title__Pqzmm"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/account/components/Total/index.jsx b/anyclip/src/modules/marketplace/account/components/Total/index.jsx new file mode 100644 index 0000000..15c621a --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/Total/index.jsx @@ -0,0 +1,103 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; + +import { Table, TableBody, TableCell, TableContainer, TableRow, Typography } from '@/mui/components'; + +import styles from './Total.module.scss'; + +const renderTable = (props) => ( + +
    + + {props.rows.map((row) => { + const value = props.info?.[row.value]?.value ?? null; + const valueMultiplied = row.needMultiply + ? (props.info?.[row.value]?.value ?? 0) * 100 + : props.info?.[row.value]?.value; + const valueFormatted = valueMultiplied?.toLocaleString(undefined, { + minimumFractionDigits: row.prefix?.length || row.postfix?.length ? 2 : 0, + maximumFractionDigits: 2, + }); + + const percent = props.info?.[row.value]?.change; + const roundedPercent = percent ? Math.round(percent * 100) : 0; + const percentFormatted = roundedPercent > 0 ? `+${roundedPercent}%` : `${roundedPercent}%`; + + return ( + + + + {row.label} + + + {value !== null ? ( + + + {`${row.prefix ?? ''}${valueFormatted}${row.postfix ?? ''}`} + + + ) : ( + + )} + + {!Number.isNaN(parseInt(percent, 10)) ? ( + + + {percentFormatted ?? ''} + + + ) : ( + + )} + + ); + })} + +
    + +); + +function Total({ rowsInColumn = null, ...props }) { + const chunkArray = (myArray, chunkSize) => { + const arrayLength = myArray.length; + const tempArray = []; + + for (let index = 0; index < arrayLength; index += chunkSize) { + const myChunk = myArray.slice(index, index + chunkSize); + tempArray.push(myChunk); + } + + return tempArray; + }; + + const data = rowsInColumn ? chunkArray(props.rows, rowsInColumn) : [props.rows]; + + return ( +
    + {data.map((item) => ( +
    + {renderTable({ rows: item, info: props.info })} +
    + ))} +
    + ); +} + +Total.propTypes = { + info: PropTypes.shape({}).isRequired, + rows: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + rowsInColumn: PropTypes.number, +}; + +export default Total; diff --git a/anyclip/src/modules/marketplace/account/components/index.jsx b/anyclip/src/modules/marketplace/account/components/index.jsx new file mode 100644 index 0000000..6ab66c1 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/index.jsx @@ -0,0 +1,599 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { ContentCopyRounded, InfoOutlined } from '@mui/icons-material'; + +import { + CUSTOM_PARENT_STICKY_CLASS_NAME, + FILTERS, + FILTERS_SELF_SERVE, + REFRESH_INTERVAL_TIME, +} from '../../common/constants'; +import { + BUDGETING, + DEMAND_ADVERTISER_PAGE, + DEMAND_TAG_FORMAT, + DEMAND_TAG_PAGE, + DEMAND_TAG_PRICING_TYPE, + PAGES_WITH_REFRESH_TABLES, + SUPPLY_SITE_PAGE, + SUPPLY_TAG_PAGE, + SUPPLY_TAG_PAGE_TABS_VAST, +} from '../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { useInterval } from '../../common/helpers/interval'; +import * as selectors from '../redux/selectors'; +import * as actions from '../redux/slices'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import DateSelect from '@/modules/marketplace/common/DateSelect'; +import Header from '@/modules/marketplace/common/HeaderNew'; +import AdvertiserSettings from './AdvertiserSettings'; +import AdvertiserPricingTab from './AdvertiserSettings/components/AdvertiserPricingTab'; +import Breadcrumbs from './Breadcrumbs'; +import { BudgetingTab, FrequencyCapTab, PricingTab, SettingsTab, TargetingTab } from './DemandSettings'; +import Main from './Main'; +import ChangeAdFeeModal from './Modals/ChangeAdFeeModal'; +import ChangeExpensesModal from './Modals/ChangeExpensesModal'; +import ChangeRevShareModal from './Modals/ChangeRevShareModal'; +import ChangeTierModal from './Modals/ChangeTierModal'; +import ChangeViewabilityThresholdModal from './Modals/ChangeViewabilityThreshold'; +import ConfirmFrequencyCapModal from './Modals/ConfirmFrequencyCapModal'; +import WaterfallModal from './Modals/WaterfallModal'; +import SupplyTagSettings from './SupplyTagSettings'; +import AutomaticOptimization from './SupplyTagSettings/components/AutomaticOptimization'; +import ExportTagTab from './SupplyTagSettings/components/ExportTagTab/ExportTagTab'; +import usePageConfig from './usePageConfig'; +import { + Autocomplete, + Button, + Divider, + FormControl, + Grid, + IconButton, + MenuItem, + Select, + Stack, + Tab, + TabContent, + Tabs, + TextField, + Tooltip, + Typography, +} from '@/mui/components'; +import { CustomCsvFilled } from '@/mui/components/CustomIcon'; + +import styles from './Account.module.scss'; + +export default function Account(props) { + const store = useStore(); + const dispatch = useDispatch(); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const info = useSelector(selectors.infoSelector); + const targeting = useSelector(selectors.targetingSelector); + const activeTabIndex = useSelector(selectors.activeTabIndexSelector); + const supply = useSelector(selectors.supplySelector); + const settings = useSelector(selectors.settingsSelector); + const filterByCountry = useSelector(selectors.filterByCountrySelector); + const filterByDevice = useSelector(selectors.filterByDeviceSelector); + const filterByDate = useSelector(selectors.filterByDateSelector); + const filterByPlayer = useSelector(selectors.filterByPlayerSelector); + const filterByPlayerOptions = useSelector(selectors.filterByPlayerOptionsSelector); + const modal = useSelector(selectors.modalSelector); + const isFormSubmitDisabled = useSelector(selectors.isFormSubmitDisabledSelector); + + const setFilterByDate = (o) => dispatch(actions.setFilterByDateAction(o)); + const closeModal = (o) => dispatch(actions.closeModalAction(o)); + const getDataForWaterfall = (o) => dispatch(actions.getDataForWaterfallAction(o)); + const getAccountsForWaterfall = (o) => dispatch(actions.getAccountsForWaterfallAction(o)); + const getLabelsForWaterfall = (o) => dispatch(actions.getLabelsForWaterfallAction(o)); + const setModalInfo = (o) => dispatch(actions.setModalInfoAction(o)); + const bulkCreateWaterfall = (o) => dispatch(actions.bulkCreateWaterfallAction(o)); + const updateTiers = (o) => dispatch(actions.updateTiersAction(o)); + const updateDemandTag = (o) => dispatch(actions.updateDemandTagAction(o)); + const bulkUpdateDemandTag = (o) => dispatch(actions.bulkUpdateDemandTagAction(o)); + const bulkUpdateSupplyTag = (o) => dispatch(actions.bulkUpdateSupplyTagAction(o)); + const bulkUpdateViewabilityThreshold = (o) => dispatch(actions.bulkUpdateViewabilityThresholdAction(o)); + + const router = useRouter(); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const pageConfig = usePageConfig({ isAdminMP }); + + const { resetInterval, stopInterval: stopRefreshInterval } = useInterval(() => { + dispatch(actions.refreshPageDataByIntervalAction()); + }, REFRESH_INTERVAL_TIME); + + const resetRefreshInterval = () => { + if (PAGES_WITH_REFRESH_TABLES.includes(pageConfig.type)) { + resetInterval(); + } + }; + + let { tabs } = pageConfig; + + if (pageConfig.type === SUPPLY_TAG_PAGE && info?.accountType === 'VAST' && isAdminMP) { + tabs = SUPPLY_TAG_PAGE_TABS_VAST; + } + + if (pageConfig.type === DEMAND_TAG_PAGE && settings?.format === DEMAND_TAG_FORMAT.sponsored) { + tabs = tabs.filter((item) => item.dataId !== 'demandTagFrequency'); + } + + const tabsContent = { + main:
    , + supplyTagSettings: , + demandTagSettings: , + demandTagPricing: , + demandTagTargeting: , + demandTagBudgeting: , + demandTagFrequency: , + exportSupplyTag: , + advertiserSettings: , + advertiserPricing: , + automaticOptimization: , + }; + + const handleSubmitForm = () => { + const state = store.getState(); + const upsert = () => pageConfig?.upsertAction?.(); + + const formSubmitHandlers = { + [DEMAND_ADVERTISER_PAGE]: () => { + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + const fields = selectors.advertiserSettingsValidationSchemeSelector(state).map(({ fieldName }) => fieldName); + const { validation, errorList } = actions.advertiserSettingsValidationSlice.validateFields(fields, allProps); + + if (errorList.length) { + const { tabId, fieldName } = errorList.find((e) => e.tabId === activeTabIndex) ?? errorList[0]; + const tabIndex = tabs.findIndex((t) => t.dataId === tabId); + + dispatch(actions.setActiveTabIndexAction(tabIndex)); + dispatch(actions.advertiserSettingsValidationSetScrollToFieldNameAction(fieldName)); + } else { + upsert(); + } + + dispatch(actions.advertiserSettingsValidationSetErrorByPropAction(validation)); + }, + // Future forms can go here + // Example: + // [OTHER_PAGE_VALIDATION]: () => { + // console.log('Handle OTHER PAGE HERE form'); + // upsert(); + // } + }; + + (formSubmitHandlers[pageConfig.type] ?? upsert)(); + }; + + useEffect(() => { + const handleVisibilityChange = () => { + if (PAGES_WITH_REFRESH_TABLES.includes(pageConfig.type)) { + if (document.hidden) { + stopRefreshInterval(); + } else { + resetRefreshInterval(); + } + } + }; + + window.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + useEffect(() => { + if (pageConfig) { + dispatch(actions.setDefaultConfigAction(pageConfig.defaultConfig ?? {})); + dispatch(actions.setFieldAction({ pageConfig })); + + dispatch(actions.getHubsAndDemandAccountsAction()); + + if (!pageConfig.isCreatingNew) { + dispatch(actions.getInfoAction(pageConfig.id)); + dispatch(actions.getTotalAction({ type: pageConfig.type })); + dispatch(actions.getDataAction({ type: pageConfig.type })); + dispatch(actions.getChartDataAction()); + + if (PAGES_WITH_REFRESH_TABLES.includes(pageConfig.type)) { + resetRefreshInterval(); + } + } + + if (pageConfig.isCreatingNew || pageConfig.isDuplicate || !PAGES_WITH_REFRESH_TABLES.includes(pageConfig.type)) { + stopRefreshInterval(); + } + + if (pageConfig.type === DEMAND_ADVERTISER_PAGE) { + dispatch(actions.getCountriesListAction()); + dispatch(actions.getLabelsForTableFilterAction()); + } + + if (pageConfig.type === SUPPLY_SITE_PAGE) { + dispatch(actions.getFilterByPlayerOptionsAction()); + } + + if (pageConfig.type === DEMAND_TAG_PAGE) { + dispatch(actions.getHistoryForChartAction()); + dispatch(actions.getCountriesListAction()); + dispatch(actions.getPlatformsAction()); + + if (!pageConfig.isCreatingNew) { + dispatch(actions.getDemandTagPricingAction(DEMAND_TAG_PRICING_TYPE.price)); + dispatch(actions.getDemandTagPricingAction(DEMAND_TAG_PRICING_TYPE.adServing)); + dispatch(actions.getDemandTagPricingAction(DEMAND_TAG_PRICING_TYPE.fee)); + dispatch(actions.getDemandTagPricingAction(DEMAND_TAG_PRICING_TYPE.adRequest)); + } + } + + if (pageConfig.type === SUPPLY_TAG_PAGE) { + dispatch(actions.getHistoryForChartAction()); + } + } + }, [pageConfig?.type, pageConfig?.id, pageConfig?.isCreatingNew, pageConfig?.isDuplicate]); + + // initialize demand tag form + useEffect(() => { + if (pageConfig && pageConfig.type === DEMAND_TAG_PAGE) { + dispatch(actions.initializeDemandFormDataAction()); + } + }, [info, targeting.countriesList]); + + // initialize advertiser form + useEffect(() => { + if (pageConfig && pageConfig.type === DEMAND_ADVERTISER_PAGE) { + dispatch(actions.initializeAdvertiserFormDataAction()); + } + }, [info, targeting.countriesList]); + + // initialize supply tag form + useEffect(() => { + if (pageConfig && pageConfig.type === SUPPLY_TAG_PAGE) { + dispatch(actions.initializeSupplyFormDataAction()); + } + }, [info]); + + // set query for breadcrumbs + useEffect(() => { + if (info && pageConfig.getNewLink) { + const link = pageConfig.getNewLink(info); + + if (link) { + router.replace(link); + } + } + }, [info]); + + useEffect(() => { + if (pageConfig?.breadcrumbs) { + dispatch(actions.setFieldAction({ pageConfig })); + } + }, [pageConfig?.breadcrumbs]); + + useEffect(() => { + if (activeTabIndex === 0) { + if (PAGES_WITH_REFRESH_TABLES.includes(pageConfig.type)) { + resetRefreshInterval(); + } + + if (pageConfig && pageConfig.type === DEMAND_ADVERTISER_PAGE) { + dispatch(actions.getDataAction({ type: pageConfig.type })); + } + } else { + stopRefreshInterval(); + } + }, [activeTabIndex]); + + useEffect( + () => () => { + dispatch(actions.setDefaultConfigAction({})); + }, + [], + ); + + const handleCopyID = () => { + copyToClipboard(pageConfig?.id).then(() => { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'ID was copied to clipboard', + }), + ); + }); + }; + + return ( + pageConfig && ( +
    +
    + + + + + + {pageConfig?.id === 'new' || pageConfig.isDuplicate ? ( + New + ) : ( + + {pageConfig?.id} + + + +
    + } + > +
    {pageConfig?.id}
    + + )} + {(pageConfig.isCreatingNew || pageConfig.isDuplicate) && ( + + {pageConfig.type === SUPPLY_TAG_PAGE ? supply?.name : settings?.name} + + )} + + {info && !pageConfig.isDuplicate && ( + + {`Last Updated${ + info.updatedBy ? ` by ${info.updatedBy}` : '' + }: ${dayjs(info.updated || info.created).format('DD/MM/YY hh:mm A')}`} + + )} + + {pageConfig.showHistoryCSVButton && ( + { + dispatch(actions.getHistoryForCSVAction()); + }} + size="small" + > + + + )} + + + + + dispatch(actions.setActiveTabIndexAction(value))} + > + {tabs.map((tab) => ( + + + + ) + } + iconPosition="end" + label={tab.label} + disabled={(pageConfig.isCreatingNew || pageConfig.isDuplicate) && tab.dataId === 'main'} + /> + ))} + + {activeTabIndex === 0 && ( +
    + {pageConfig?.showPlayerFilter && ( + + dispatch(actions.setFilterByPlayerAction(player))} + onOpen={() => dispatch(actions.getFilterByPlayerOptionsAction())} + disableClearable + renderInput={(params) => ( + { + dispatch(actions.getFilterByPlayerOptionsAction({ searchText: e.target.value })); + }} + /> + )} + /> + + )} + + {pageConfig?.showCountriesFilter && ( + + + + )} + + {pageConfig?.showDevicesFilter && ( + + + + )} + + +
    + )} + + {activeTabIndex !== 0 && ( + + {!pageConfig?.isCreatingNew && !pageConfig?.disableDuplicate && ( + <> + + + + )} + + + + )} +
    + {tabs.map((tabInfo, index) => ( + + {tabsContent[tabInfo.contentKey]} + + ))} + + + + + + + + + + + + + +
    + ) + ); +} diff --git a/anyclip/src/modules/marketplace/account/components/usePageConfig.jsx b/anyclip/src/modules/marketplace/account/components/usePageConfig.jsx new file mode 100644 index 0000000..3faffa8 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/components/usePageConfig.jsx @@ -0,0 +1,701 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { + // demand pages + DEMAND_ACCOUNT_PAGE_CONFIG, + DEMAND_ACCOUNT_PAGE_PATHNAME, + DEMAND_ADVERTISER_PAGE_CHANGE_AD_FEE_MODAL, + DEMAND_ADVERTISER_PAGE_CHANGE_VIEWABILITY_THRESHOLD_MODAL, + DEMAND_ADVERTISER_PAGE_CONFIG, + DEMAND_ADVERTISER_PAGE_PATHNAME, + DEMAND_TAG_PAGE_ADD_SUPPLY_TAG_MODAL, + DEMAND_TAG_PAGE_CONFIG, + DEMAND_TAG_PAGE_TYPE_VALUES, + // pages + // supply pages + SUPPLY_ACCOUNT_PAGE_CONFIG, + SUPPLY_ACCOUNT_PAGE_PATHNAME, + SUPPLY_SITE_PAGE_CHANGE_EXPENSES_MODAL, + SUPPLY_SITE_PAGE_CHANGE_REV_SHARE_MODAL, + SUPPLY_SITE_PAGE_CONFIG, + SUPPLY_SITE_PAGE_PATHNAME, + SUPPLY_TAG_PAGE_ADD_DEMAND_TAG_MODAL, + SUPPLY_TAG_PAGE_CHANGE_TIER_MODAL, + SUPPLY_TAG_PAGE_CONFIG, + TIERS, +} from '../constants'; + +import { validateTextfieldDigits } from '../helpers/validate'; +import { + bulkUpdateDemandTagAction, + bulkUpdateSupplyTagAction, + createAdvertiserAction, + createDemandTagAction, + createSupplyTagAction, + deleteWaterfallAction, + duplicateDemandTagAction, + duplicateSupplyTagAction, + initializeAdvertiserFormDataAction, + initializeDemandFormDataAction, + initializeSupplyFormDataAction, + openModalAction, + resetDemandFormAction, + resetSupplyFormAction, + showConfirmModalAction, + updateAdvertiserAction, + updateDemandTagAction, + updateSupplyTagAction, + updateWaterfallAction, + validateAdvertiserAction, + validateTagAction, +} from '../redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { + getAdvertiserLink, + getDemandTagLink, + getSiteLink, + getSupplyTagLink, +} from '@/modules/marketplace/common/helpers/supplyDemandTransitionLinks'; + +import CopyCell from '../../common/Cells/CopyCell'; +import StatusCell from '../../common/Cells/StatusCell'; +import IdCell from './Cells/IdCell'; +import NameCell from './Cells/NameCell'; +import TargetingCell from './Cells/TargetingCell'; +import TextFieldCell from './Cells/TextFieldCell'; +import TierCell from './Cells/TierCell'; + +function usePageConfig(params) { + const isAdminMP = params?.isAdminMP; + const router = useRouter(); + const [accountId, , siteAdvertiserId, , tagId] = router.query.params; + const dispatch = useDispatch(); + + const definePage = () => { + const queries = router.query; + const isSupplyPages = router.pathname.includes(SUPPLY_ACCOUNT_PAGE_PATHNAME); + const isDemandPages = router.pathname.includes(DEMAND_ACCOUNT_PAGE_PATHNAME); + + const isDuplicate = 'duplicate' in queries; + + // supply pages + if (isSupplyPages && accountId && siteAdvertiserId && tagId) { + // specific supply tag page + return { + ...SUPPLY_TAG_PAGE_CONFIG, + // redefine config for Self Serve + ...(!isAdminMP ? SUPPLY_TAG_PAGE_CONFIG.selfServeConfig : {}), + defaultConfig: { + ...SUPPLY_TAG_PAGE_CONFIG.defaultConfig, + // redefine config for Self Serve + ...(!isAdminMP ? SUPPLY_TAG_PAGE_CONFIG.selfServeConfig?.defaultConfig : {}), + activeTabIndex: tagId === 'new' || isDuplicate ? 1 : 0, + }, + isCreatingNew: tagId === 'new', + cellRenderer: { + id: (props) => ( + dispatch(showNotificationAction(notification))} /> + ), + [['name', 'dtPriority']]: (props) => , + tier: (props) => ( + { + dispatch( + updateWaterfallAction({ + tier: value, + supplyIds: [tagId], + demandIds: [id], + }), + ); + }} + {...props} + /> + ), + priority: (values) => ( + validateTextfieldDigits({ value, min: 0, max: 100 })} + onChange={({ value, id }) => { + dispatch( + updateWaterfallAction({ + priority: value, + supplyIds: [tagId], + demandIds: [id], + }), + ); + }} + disabled={values?.row?.type === DEMAND_TAG_PAGE_TYPE_VALUES.hb} + {...values} + /> + ), + [[ + 'include', + 'exclude', + 'frequency', + 'advertiserInclude', + 'advertiserExclude', + 'waterfallSkip', + 'viewabilityThreshold', + 'kvTargeting', + ]]: (props) => , + status: (props) => , + }, + id: tagId, + plusButton: { + label: 'Demand Tag', + onClick: () => { + dispatch(openModalAction({ id: SUPPLY_TAG_PAGE_ADD_DEMAND_TAG_MODAL })); + }, + }, + bulkActions: [ + ...(isAdminMP + ? [ + { + label: 'Change Tier', + onClick: ({ selected }) => { + if (selected?.length) { + dispatch( + openModalAction({ + id: SUPPLY_TAG_PAGE_CHANGE_TIER_MODAL, + demandIds: selected, + supplyIds: [tagId], + }), + ); + } + }, + }, + ] + : []), + { + label: 'Remove', + onClick: ({ selected }) => { + if (selected?.length) { + dispatch( + deleteWaterfallAction({ + demandIds: selected.filter((item) => item), + supplyIds: [tagId], + }), + ); + } + }, + }, + ], + breadcrumbs: isAdminMP + ? [ + { label: 'Supply Accounts', link: SUPPLY_ACCOUNT_PAGE_PATHNAME }, + { + label: queries?.accountName, + link: `${SUPPLY_ACCOUNT_PAGE_PATHNAME}/${accountId}?accountName=${queries?.accountName}`, + }, + { + label: queries?.siteName, + + link: `${SUPPLY_ACCOUNT_PAGE_PATHNAME}/${accountId}${SUPPLY_SITE_PAGE_PATHNAME}/${siteAdvertiserId}?accountName=${queries?.accountName}&siteName=${queries?.siteName}`, + }, + { label: queries?.tagName }, + ] + : [ + { label: 'Sites', link: SUPPLY_ACCOUNT_PAGE_PATHNAME }, + { + label: queries?.siteName, + + link: `${SUPPLY_ACCOUNT_PAGE_PATHNAME}/${accountId}${SUPPLY_SITE_PAGE_PATHNAME}/${siteAdvertiserId}?accountName=${queries?.accountName}&siteName=${queries?.siteName}`, + }, + { label: queries?.tagName }, + ], + supplyAccount: { + id: accountId, + name: queries?.accountName, + accountType: queries?.accountType, + }, + site: { + id: siteAdvertiserId, + name: queries?.siteName, + }, + upsertAction: () => { + const actions = [ + { + action: createSupplyTagAction, + isActive: tagId === 'new', + }, + { + action: updateSupplyTagAction, + isActive: tagId !== 'new' && !isDuplicate, + }, + { + action: duplicateSupplyTagAction, + isActive: isDuplicate, + }, + ]; + + const selected = actions.find((action) => action.isActive); + + if (selected) { + dispatch(validateTagAction(selected.action)); + } + }, + getNewLink: (tag, duplicate = isDuplicate) => getSupplyTagLink(tag, duplicate), + cancelAction: (tag) => { + const actions = [ + { + action: () => router.back(), + isActive: tagId === 'new', + }, + { + action: () => { + dispatch(resetSupplyFormAction()); + dispatch(initializeSupplyFormDataAction()); + }, + isActive: tagId !== 'new' && !isDuplicate, + }, + { + action: () => { + const link = getSupplyTagLink(tag); + router.push(link); + }, + isActive: isDuplicate, + }, + ]; + + const selected = actions.find((action) => action.isActive); + + if (selected) { + selected.action(); + } + }, + getTableTagLink: (tag) => getDemandTagLink(tag), + isDuplicate, + }; + } + if (isSupplyPages && accountId && siteAdvertiserId) { + // specific site page + return { + ...SUPPLY_SITE_PAGE_CONFIG, + // redefine config for Self Serve + ...(!isAdminMP ? SUPPLY_SITE_PAGE_CONFIG.selfServeConfig : {}), + defaultConfig: { + ...SUPPLY_SITE_PAGE_CONFIG.defaultConfig, + // redefine config for Self Serve + ...(!isAdminMP ? SUPPLY_SITE_PAGE_CONFIG.selfServeConfig?.defaultConfig : {}), + }, + id: siteAdvertiserId, + cellRenderer: { + id: (props) => ( + dispatch(showNotificationAction(notification))} /> + ), + name: (props) => , + status: (props) => , + copy: (props) => , + }, + bulkActions: [ + { + label: 'Activate', + onClick: ({ selected }) => { + const tags = selected.map((item) => ({ id: item, status: 'ACTIVE' })); + dispatch(bulkUpdateSupplyTagAction({ tags })); + }, + }, + { + label: 'Disable', + onClick: ({ selected }) => { + const tags = selected.map((item) => ({ id: item, status: 'DISABLED' })); + dispatch(bulkUpdateSupplyTagAction({ tags })); + }, + }, + ...(isAdminMP + ? [ + { + label: 'Update Rev-Share', + onClick: ({ selected }) => { + if (selected?.length) { + dispatch( + openModalAction({ + id: SUPPLY_SITE_PAGE_CHANGE_REV_SHARE_MODAL, + supplyIds: selected, + }), + ); + } + }, + }, + ] + : []), + ...(isAdminMP + ? [ + { + label: 'Update Expenses', + onClick: ({ selected }) => { + if (selected?.length) { + dispatch( + openModalAction({ + id: SUPPLY_SITE_PAGE_CHANGE_EXPENSES_MODAL, + supplyIds: selected, + }), + ); + } + }, + }, + ] + : []), + ], + plusButton: isAdminMP + ? { + label: 'Supply Tag', + onClick: ({ queries: additionalQueries = {} }) => { + router.push({ + pathname: `${router.pathname}/tags/new`, + query: { + ...router.query, + ...additionalQueries, + }, + }); + }, + } + : null, + getNewLink: (info) => getSiteLink(info), + breadcrumbs: isAdminMP + ? [ + { + label: 'Supply Accounts', + link: SUPPLY_ACCOUNT_PAGE_PATHNAME, + }, + { + label: queries?.accountName, + link: `${SUPPLY_ACCOUNT_PAGE_PATHNAME}/${accountId}?accountName=${queries?.accountName}`, + }, + { label: queries?.siteName }, + ] + : [ + { + label: 'Sites', + link: SUPPLY_ACCOUNT_PAGE_PATHNAME, + }, + { label: queries?.siteName }, + ], + }; + } + if (isSupplyPages && accountId) { + // specific supply account page + return { + ...SUPPLY_ACCOUNT_PAGE_CONFIG, + ...(!isAdminMP ? SUPPLY_ACCOUNT_PAGE_CONFIG.selfServeConfig : {}), + id: accountId, + cellRenderer: { + id: (props) => ( + dispatch(showNotificationAction(notification))} /> + ), + name: (props) => , + }, + breadcrumbs: [ + { label: 'Supply Accounts', link: SUPPLY_ACCOUNT_PAGE_PATHNAME }, + { label: queries?.accountName }, + ], + }; + } + + if (isDemandPages && tagId && accountId && siteAdvertiserId) { + // specific demand tag page + return { + ...DEMAND_TAG_PAGE_CONFIG, + // redefine config for Self Serve + ...(!isAdminMP ? DEMAND_TAG_PAGE_CONFIG.selfServeConfig : {}), + defaultConfig: { + ...DEMAND_TAG_PAGE_CONFIG.defaultConfig, + // redefine config for Self Serve + ...(!isAdminMP ? DEMAND_TAG_PAGE_CONFIG.selfServeConfig?.defaultConfig : {}), + activeTabIndex: tagId === 'new' || isDuplicate ? 1 : 0, + }, + isCreatingNew: tagId === 'new', + id: tagId, + demandAccount: { + id: accountId, + name: queries?.accountName, + publisherDemand: + queries?.publisherDemand === 'true' || queries?.publisherDemand === 'false' + ? JSON.parse(queries.publisherDemand) + : null, + }, + advertiser: { + id: siteAdvertiserId, + name: queries?.advertiserName, + tier: queries?.advertiserTier, + }, + cellRenderer: { + id: (props) => ( + dispatch(showNotificationAction(notification))} /> + ), + name: (props) => , + status: (props) => , + }, + plusButton: { + label: 'Supply Tag', + onClick: () => { + dispatch(openModalAction({ id: DEMAND_TAG_PAGE_ADD_SUPPLY_TAG_MODAL })); + }, + }, + bulkActions: [ + { + label: 'Remove', + onClick: ({ selected }) => { + if (selected?.length) { + dispatch( + deleteWaterfallAction({ + demandIds: [tagId], + supplyIds: selected, + }), + ); + } + }, + }, + ], + breadcrumbs: isAdminMP + ? [ + { label: 'Demand Accounts', link: DEMAND_ACCOUNT_PAGE_PATHNAME }, + { + label: queries?.accountName, + link: `${DEMAND_ACCOUNT_PAGE_PATHNAME}/${accountId}?accountName=${queries?.accountName}`, + }, + { + label: queries?.advertiserName, + + link: `${DEMAND_ACCOUNT_PAGE_PATHNAME}/${accountId}${DEMAND_ADVERTISER_PAGE_PATHNAME}/${siteAdvertiserId}?accountName=${queries?.accountName}&advertiserName=${queries?.advertiserName}`, + }, + { label: queries?.tagName }, + ] + : [ + { label: 'Advertisers', link: DEMAND_ACCOUNT_PAGE_PATHNAME }, + { + label: queries?.advertiserName, + + link: `${DEMAND_ACCOUNT_PAGE_PATHNAME}/${accountId}${DEMAND_ADVERTISER_PAGE_PATHNAME}/${siteAdvertiserId}?accountName=${queries?.accountName}&advertiserName=${queries?.advertiserName}`, + }, + { label: queries?.tagName }, + ], + upsertAction: () => { + const actions = [ + { + action: createDemandTagAction, + isActive: tagId === 'new', + }, + { + action: isAdminMP ? updateDemandTagAction : showConfirmModalAction, + isActive: tagId !== 'new' && !isDuplicate, + }, + { + action: duplicateDemandTagAction, + isActive: tagId !== 'new' && isDuplicate, + }, + ]; + + const selected = actions.find((action) => action.isActive); + + if (selected) { + dispatch(validateTagAction(selected.action)); + } + }, + cancelAction: (tag) => { + const actions = [ + { + action: () => router.back(), + isActive: tagId === 'new', + }, + { + action: () => { + dispatch(resetDemandFormAction()); + dispatch(initializeDemandFormDataAction()); + }, + isActive: tagId !== 'new' && !isDuplicate, + }, + { + action: () => { + const link = getDemandTagLink(tag); + router.push(link); + }, + isActive: isDuplicate, + }, + ]; + + const selected = actions.find((action) => action.isActive); + + if (selected) { + selected.action(); + } + }, + getTableTagLink: (tag) => getSupplyTagLink(tag), + getNewLink: (tag, duplicate = isDuplicate) => getDemandTagLink(tag, duplicate), + isDuplicate, + }; + } + if (isDemandPages && accountId && siteAdvertiserId) { + // specific demand advertiser page + return { + ...DEMAND_ADVERTISER_PAGE_CONFIG, + // redefine config for Self Serve + ...(!isAdminMP ? DEMAND_ADVERTISER_PAGE_CONFIG.selfServeConfig : {}), + defaultConfig: { + ...DEMAND_ADVERTISER_PAGE_CONFIG.defaultConfig, + // redefine config for Self Serve + ...(!isAdminMP ? DEMAND_ADVERTISER_PAGE_CONFIG.selfServeConfig?.defaultConfig : {}), + activeTabIndex: siteAdvertiserId === 'new' || isDuplicate ? 1 : 0, + }, + cellRenderer: { + id: (props) => ( + dispatch(showNotificationAction(notification))} /> + ), + name: (props) => , + [[ + 'include', + 'exclude', + 'frequency', + 'advertiserInclude', + 'advertiserExclude', + 'waterfallSkip', + 'viewabilityThreshold', + 'kvTargeting', + ]]: (props) => , + copy: (props) => , + }, + bulkActions: [ + { + label: 'Activate', + onClick: ({ selected }) => { + const tags = selected.map((item) => ({ id: item, status: 'ACTIVE' })); + dispatch(bulkUpdateDemandTagAction({ tags })); + }, + }, + { + label: 'Disable', + onClick: ({ selected }) => { + const tags = selected.map((item) => ({ id: item, status: 'DISABLED' })); + dispatch(bulkUpdateDemandTagAction({ tags })); + }, + }, + { + label: 'Update Viewability Threshold', + onClick: ({ selected }) => { + if (selected?.length) { + dispatch( + openModalAction({ + id: DEMAND_ADVERTISER_PAGE_CHANGE_VIEWABILITY_THRESHOLD_MODAL, + demandIds: selected, + }), + ); + } + }, + }, + ...(isAdminMP + ? [ + { + label: 'Update Additional Fees', + onClick: ({ selected }) => { + if (selected?.length) { + dispatch( + openModalAction({ + id: DEMAND_ADVERTISER_PAGE_CHANGE_AD_FEE_MODAL, + demandIds: selected, + }), + ); + } + }, + }, + ] + : []), + ], + plusButton: { + label: 'Demand Tag', + onClick: ({ queries: additionalQueries = {} }) => { + router.push({ + pathname: `${router.pathname}/tags/new`, + query: { + ...router.query, + ...additionalQueries, + }, + }); + }, + }, + isCreatingNew: siteAdvertiserId === 'new', + id: siteAdvertiserId, + getNewLink: (info) => getAdvertiserLink(info), + breadcrumbs: isAdminMP + ? [ + { label: 'Demand Accounts', link: DEMAND_ACCOUNT_PAGE_PATHNAME }, + { + label: queries?.accountName, + link: `${DEMAND_ACCOUNT_PAGE_PATHNAME}/${accountId}?accountName=${queries?.accountName}`, + }, + { label: queries?.advertiserName }, + ] + : [{ label: 'Advertisers', link: DEMAND_ACCOUNT_PAGE_PATHNAME }, { label: queries?.advertiserName }], + upsertAction: () => { + const actions = [ + { + action: createAdvertiserAction, + isActive: siteAdvertiserId === 'new', + }, + { + action: updateAdvertiserAction, + isActive: siteAdvertiserId !== 'new', + }, + ]; + + const selected = actions.find((action) => action.isActive); + + if (selected) { + dispatch(validateAdvertiserAction(selected.action)); + } + }, + cancelAction: () => { + const actions = [ + { + action: () => router.back(), + isActive: siteAdvertiserId === 'new', + }, + { + action: () => { + dispatch(initializeAdvertiserFormDataAction()); + }, + isActive: siteAdvertiserId !== 'new', + }, + ]; + + const selected = actions.find((action) => action.isActive); + + if (selected) { + selected.action(); + } + }, + disableDuplicate: true, + }; + } + if (isDemandPages && accountId) { + // specific demand account page + return { + ...DEMAND_ACCOUNT_PAGE_CONFIG, + id: accountId, + cellRenderer: { + id: (props) => ( + dispatch(showNotificationAction(notification))} /> + ), + name: (props) => , + }, + breadcrumbs: [ + { label: 'Demand Accounts', link: DEMAND_ACCOUNT_PAGE_PATHNAME }, + { label: queries?.accountName }, + ], + }; + } + + return null; + }; + + const [result, setResult] = useState(definePage()); + + useEffect(() => { + setResult(definePage()); + }, [router.pathname, router.query]); + + return result; +} + +export default usePageConfig; diff --git a/anyclip/src/modules/marketplace/account/constants/addTagModal.js b/anyclip/src/modules/marketplace/account/constants/addTagModal.js new file mode 100644 index 0000000..fc3fbd6 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/addTagModal.js @@ -0,0 +1,307 @@ +const ADD_DEMAND_TAG_TITLE = 'Demand Tags'; + +const ADD_DEMAND_TAG_TABLE_HEADERS = [ + { + id: 'ID', + label: 'UID', + isSortable: false, + }, + { + id: 'NAME', + label: 'Name', + isSortable: false, + }, + { + id: 'MODEL', + label: 'Business Model', + isSortable: false, + }, + { + id: 'PRICING', + label: 'Rate', + isSortable: false, + }, + { + id: 'adRequests', + label: 'Ad Requests', + isSortable: false, + align: 'right', + withGap: true, + }, + { + id: 'adImpressions', + label: 'Ad Impressions', + isSortable: false, + align: 'right', + withGap: true, + }, + { + id: 'targeting', + label: 'Targeting', + }, + { + id: 'STATUS', + label: 'Status', + isSortable: true, + }, +]; + +const ADD_DEMAND_TAG_TABLE_CELLS = [ + { + key: 'id', + }, + { + key: 'name', + }, + { + key: 'model', + }, + { + key: 'pricing', + prefix: '$', + emptyMask: 'N/A', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: ['include', 'exclude', 'frequency'], + }, + { + key: 'status', + }, +]; + +const ADD_SUPPLY_TAG_TITLE = 'Supply Tags'; + +const ADD_SUPPLY_TAG_TABLE_HEADERS = [ + { + id: 'ID', + label: 'UID', + isSortable: false, + }, + { + id: 'NAME', + label: 'Name', + isSortable: false, + }, + { + id: 'SUPPLY_REQUESTS', + label: 'Ad Requests', + isSortable: false, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Ad Impressions', + isSortable: false, + align: 'right', + withGap: true, + }, + { + id: 'STATUS', + label: 'Status', + isSortable: false, + }, +]; + +const ADD_SUPPLY_TAG_TABLE_CELLS = [ + { + key: 'id', + }, + { + key: 'name', + }, + { + key: 'fields.SUPPLY_REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'status', + }, +]; + +export const ADD_DEMAND_TAG_DATA_QUERY = ` + query DemandTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType + ) { + demandTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters + ) { + totalCount + data { + id + name + status + model + aps + pricing { + value + } + frequency { + status + value + amount + type + timeframe + } + include { + domains + geo + os + browsers + playerSizes + viewability + devices + } + exclude { + domains + geo + os + browsers + } + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + } + } + } + } +`; + +export const ADD_SUPPLY_TAG_DATA_QUERY = ` + query SupplyTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType + ) { + supplyTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters + ) { + totalCount + data { + id + name + status + fields { + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + } + } + } + } +`; + +export const ADD_DEMAND_TAG_ACCOUNTS_QUERY = ` + query DemandAccountsDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType + ) { + demandAccountsData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters + ) { + totalCount + data { + id + name + } + } + } +`; + +export const ADD_SUPPLY_TAG_ACCOUNTS_QUERY = ` + query SupplyAccountsDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType + ) { + supplyAccountsData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters + ) { + totalCount + data { + id + name + } + } + } +`; + +export const ADD_DEMAND_TAG_CONFIG = { + title: ADD_DEMAND_TAG_TITLE, + headers: ADD_DEMAND_TAG_TABLE_HEADERS, + cells: ADD_DEMAND_TAG_TABLE_CELLS, + query: ADD_DEMAND_TAG_DATA_QUERY, + accountsQuery: ADD_DEMAND_TAG_ACCOUNTS_QUERY, + fields: ['REQUESTS', 'IMPRESSIONS', 'HB_RATE'], + selfServeConfig: { + headers: ADD_DEMAND_TAG_TABLE_HEADERS.filter((item) => !['ID', 'MODEL'].includes(item.id)), + cells: ADD_DEMAND_TAG_TABLE_CELLS.filter((item) => !['id', 'model'].includes(item.key)), + }, +}; + +export const ADD_SUPPLY_TAG_CONFIG = { + title: ADD_SUPPLY_TAG_TITLE, + headers: ADD_SUPPLY_TAG_TABLE_HEADERS, + cells: ADD_SUPPLY_TAG_TABLE_CELLS, + query: ADD_SUPPLY_TAG_DATA_QUERY, + accountsQuery: ADD_SUPPLY_TAG_ACCOUNTS_QUERY, + fields: ['SUPPLY_REQUESTS', 'IMPRESSIONS'], + selfServeConfig: { + headers: ADD_SUPPLY_TAG_TABLE_HEADERS, + cells: ADD_SUPPLY_TAG_TABLE_CELLS, + }, +}; diff --git a/anyclip/src/modules/marketplace/account/constants/chart.js b/anyclip/src/modules/marketplace/account/constants/chart.js new file mode 100644 index 0000000..bd66a37 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/chart.js @@ -0,0 +1,206 @@ +import { + PRIMARY_LEFT_Y_AXIS_ID, + PRIMARY_RIGHT_Y_AXIS_ID, + SECOND_LEFT_Y_AXIS_ID, + SECOND_RIGHT_Y_AXIS_ID, + TIME_INTERVAL_OPTIONS, +} from '../../common/constants'; + +export const CHART_TIME_INTERVAL_TAB = 'CHART_TIME_INTERVAL_TAB'; +export const CHART_COMPARISON_TAB = 'CHART_COMPARISON_TAB'; + +export const CHART_TABS = [ + { + label: 'Time Interval', + value: CHART_TIME_INTERVAL_TAB, + }, + { + label: 'Comparison', + value: CHART_COMPARISON_TAB, + }, +]; + +export const COMMON_TIME_INTERVAL_PARAMS = [ + { + label: 'Request Fill', + value: 'REQUESTS_FILL', + type: 'bar', + yAxisId: PRIMARY_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-13)', + }, + { + label: 'Revenue', + value: 'REVENUE', + type: 'bar', + yAxisId: SECOND_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-14)', + }, + { + label: 'Ad Requests', + value: 'REQUESTS', + type: 'line', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-15)', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + type: 'line', + yAxisId: SECOND_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-16)', + }, +]; + +export const COMMON_SUPPLY_TIME_INTERVAL_PARAMS = [ + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + type: 'bar', + yAxisId: PRIMARY_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-13)', + }, + { + label: 'Revenue', + value: 'REVENUE', + type: 'bar', + yAxisId: SECOND_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-14)', + }, + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + type: 'line', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-15)', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + type: 'line', + yAxisId: SECOND_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-16)', + }, +]; + +export const COMMON_TIME_INTERVAL_PARAMS_FOR_SELF_SERVE = [ + { + label: 'Request Fill', + value: 'REQUESTS_FILL', + type: 'bar', + yAxisId: PRIMARY_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-13)', + }, + { + label: 'Pub Revenue', + value: 'GROSS_REVENUE', + type: 'bar', + yAxisId: SECOND_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-14)', + }, + { + label: 'Ad Requests', + value: 'REQUESTS', + type: 'line', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-15)', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + type: 'line', + yAxisId: SECOND_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-16)', + }, +]; + +export const LOG_PARAMS = [ + { + label: 'Change Logs', + value: 'LOG', + type: 'log', + yAxisId: 'log', + color: 'black', + }, +]; + +export const COMMON_TIME_INTERVAL_OPTIONS = TIME_INTERVAL_OPTIONS; + +export const COMMON_COMPARISON_OPTIONS = [ + { + label: 'Request Fill', + value: 'REQUESTS_FILL', + }, + { + label: 'Revenue', + value: 'REVENUE', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + }, + { + label: 'Ad Requests', + value: 'REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, +]; + +export const COMMON_SUPPLY_COMPARISON_OPTIONS = [ + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + }, + { + label: 'Revenue', + value: 'REVENUE', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + }, + { + label: 'Pub NET Revenue', + value: 'PUB_NET_REVENUE', + }, + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, +]; + +export const COMMON_COMPARISON_PARAMS = [ + { + label: 'Today', + value: 'today', + type: 'area', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-14)', + }, + { + label: 'Yesterday', + value: 'yesterday', + type: 'line', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-15)', + }, + { + label: '7 Days Ago', + value: 'week', + type: 'line', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-16)', + }, +]; + +export const CHART_COMMON_DEFAULT_CONFIG = { + chartTab: CHART_TIME_INTERVAL_TAB, + timeIntervalFilter: COMMON_TIME_INTERVAL_OPTIONS[0].value, + comparisonFilter: COMMON_COMPARISON_OPTIONS[1].value, +}; diff --git a/anyclip/src/modules/marketplace/account/constants/demandAccountPage.js b/anyclip/src/modules/marketplace/account/constants/demandAccountPage.js new file mode 100644 index 0000000..bc38ceb --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/demandAccountPage.js @@ -0,0 +1,504 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { + CHART_COMMON_DEFAULT_CONFIG, + COMMON_COMPARISON_OPTIONS, + COMMON_COMPARISON_PARAMS, + COMMON_TIME_INTERVAL_OPTIONS, + COMMON_TIME_INTERVAL_PARAMS, +} from './chart'; +import { DEMAND_ADVERTISER_PAGE_PATHNAME } from './demandAdvertiserPage'; +import { STATUS_FILTERS } from './table'; + +export const DEMAND_ACCOUNT_PAGE = 'DEMAND_ACCOUNT_PAGE'; + +export const DEMAND_ACCOUNT_PAGE_PATHNAME = '/demand'; + +export const DEMAND_ACCOUNT_PAGE_TABLE_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'DEMANDS', + label: 'Demand Tags', + isSortable: true, + }, + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_RPM', + label: 'Gross RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'ERPM', + label: 'eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_ERPM', + label: 'Gross eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, +]; + +export const DEMAND_ACCOUNT_PAGE_TABLE_CELLS = [ + { + key: 'name', + }, + { + key: 'demands', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, +]; + +export const DEMAND_ACCOUNT_PAGE_INFO_ROWS = [ + { + label: 'Ad Requests', + value: 'REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + { + label: 'RPM', + value: 'RPM', + prefix: '$', + }, + { + label: 'Gross RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'eRPM', + value: 'ERPM', + prefix: '$', + }, + { + label: 'Gross eRPM', + value: 'GROSS_ERPM', + prefix: '$', + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, +]; + +export const DEMAND_ACCOUNT_PAGE_TOTAL_FIELDS = [ + 'REQUESTS', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'ERPM', + 'GROSS_ERPM', + 'IMPRESSIONS_VIEWABILITY', +]; + +export const DEMAND_ACCOUNT_PAGE_TOTAL_GRAPHQL_QUERY = ` + query DemandAccountDataByIdQuery( + $fields: [String], + $filters: MarketplaceFiltersInputType, + $id: String! + ) { + demandAccountDataById( + fields: $fields, + filters: $filters, + id: $id + ) { + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + ERPM { + value + change + } + GROSS_ERPM { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + } + } + } +`; + +export const DEMAND_ACCOUNT_PAGE_DATA_FIELDS = [ + 'REQUESTS', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'ERPM', + 'GROSS_ERPM', + 'COMPLETION_RATE', + 'CTR', +]; + +export const DEMAND_ACCOUNT_PAGE_DATA_GRAPHQL_QUERY = ` + query AdvertiserDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean + ) { + advertiserData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + demands + hasApsTags + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + ERPM { + value + change + } + GROSS_ERPM { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + } + } + } + } +`; + +export const DEMAND_ACCOUNT_PAGE_HISTOGRAM_GRAPHQL_QUERY = ` + query AdvertiserHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + advertiserHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + } + } + } + } + } +`; + +export const DEMAND_ACCOUNT_PAGE_INFO_GRAPHQL_QUERY = ` + query DemandAccountByIdQuery( + $id: String + ) { + demandAccountById( + id: $id + ) { + id + name + created + updated + updatedBy + defaultValues { + videoAdServingFee + displayAdServingFee + adServingModel + } + } + } +`; + +export const DEMAND_ACCOUNT_PAGE_CONFIG = { + type: DEMAND_ACCOUNT_PAGE, + tabs: [ + { + label: 'Advertisers', + dataId: 'main', + contentKey: 'main', + }, + ], + nextLevelLink: DEMAND_ADVERTISER_PAGE_PATHNAME, + nextLevelQuery: 'advertiserName', + infoRows: DEMAND_ACCOUNT_PAGE_INFO_ROWS, + tableSelecting: false, + tableFilters: STATUS_FILTERS, + tableHeaders: DEMAND_ACCOUNT_PAGE_TABLE_HEADERS, + tableCells: DEMAND_ACCOUNT_PAGE_TABLE_CELLS, + timeIntervalOptions: COMMON_TIME_INTERVAL_OPTIONS, + timeIntervalParams: COMMON_TIME_INTERVAL_PARAMS, + comparisonOptions: [ + ...COMMON_COMPARISON_OPTIONS, + { + label: 'Opportunities Fill', + value: 'OPPORTUNITIES_FILL', + }, + ], + comparisonParams: COMMON_COMPARISON_PARAMS, + rowsPerPageOptions: [10, 25, 50, 100, 200], + defaultConfig: { + ...CHART_COMMON_DEFAULT_CONFIG, + filterByStatus: STATUS_FILTERS[1].value, + }, + totalFields: DEMAND_ACCOUNT_PAGE_TOTAL_FIELDS, + totalQuery: DEMAND_ACCOUNT_PAGE_TOTAL_GRAPHQL_QUERY, + dataFields: DEMAND_ACCOUNT_PAGE_DATA_FIELDS, + dataQuery: DEMAND_ACCOUNT_PAGE_DATA_GRAPHQL_QUERY, + histogramQuery: DEMAND_ACCOUNT_PAGE_HISTOGRAM_GRAPHQL_QUERY, + infoQuery: DEMAND_ACCOUNT_PAGE_INFO_GRAPHQL_QUERY, + showDownloadCSVButton: true, +}; diff --git a/anyclip/src/modules/marketplace/account/constants/demandAdvertiserPage.js b/anyclip/src/modules/marketplace/account/constants/demandAdvertiserPage.js new file mode 100644 index 0000000..c551d9d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/demandAdvertiserPage.js @@ -0,0 +1,847 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { + CHART_COMMON_DEFAULT_CONFIG, + COMMON_COMPARISON_OPTIONS, + COMMON_COMPARISON_PARAMS, + COMMON_TIME_INTERVAL_OPTIONS, + COMMON_TIME_INTERVAL_PARAMS, + COMMON_TIME_INTERVAL_PARAMS_FOR_SELF_SERVE, +} from './chart'; +import { DEMAND_TAG_PAGE_PATHNAME } from './demandTagPage'; +import { STATUS_FILTERS } from './table'; + +export const DEMAND_ADVERTISER_PAGE = 'DEMAND_ADVERTISER_PAGE'; + +export const DEMAND_ADVERTISER_PAGE_CHANGE_AD_FEE_MODAL = 'DEMAND_ADVERTISER_PAGE_CHANGE_AD_FEE_MODAL'; +export const DEMAND_ADVERTISER_PAGE_CHANGE_VIEWABILITY_THRESHOLD_MODAL = + 'DEMAND_ADVERTISER_PAGE_CHANGE_VIEWABILITY_THRESHOLD_MODAL'; + +export const DEMAND_ADVERTISER_PAGE_PATHNAME = '/advertisers'; + +export const DEMAND_ADVERTISER_PAGE_TABLE_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'SUPPLIES', + label: 'Supply Tags', + isSortable: true, + }, + { + id: 'PRICING', + label: 'Rate', + }, + { + id: 'targeting', + label: 'Targeting', + isSortable: false, + }, + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OPPORTUNITIES', + label: 'Opportunities', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PROFIT', + label: 'Profit', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OPPORTUNITIES_FILL', + label: 'Opp Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'ERPM', + label: 'eRPM', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'GROSS_ERPM', + label: 'Gross eRPM', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'REQ_ERPM', + label: 'Req eRPM', + isSortable: true, + }, + // only for admin + { + id: 'REQ_EPPM', + label: 'Req ePPM', + isSortable: true, + }, + // only for admin + { + id: 'REQ_NET_ERPM', + label: 'Req Pub NET eRPM', + isSortable: true, + }, + // only for self serve + { + id: 'REQ_GROSS_ERPM', + label: 'Req Gross eRPM ', + isSortable: true, + }, + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'CREATED', + label: 'Creation Date', + isSortable: true, + }, + { + id: 'COPY', + label: '', + isSortable: false, + }, +]; + +export const DEMAND_ADVERTISER_PAGE_TABLE_CELLS = [ + { + key: 'name', + }, + { + key: 'supplies', + }, + { + key: 'pricing', + prefix: '$', + emptyMask: 'N/A', + }, + { + key: [ + 'include', + 'exclude', + 'frequency', + 'advertiserInclude', + 'advertiserExclude', + 'waterfallSkip', + 'viewabilityThreshold', + 'kvTargeting', + ], + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.OPPORTUNITIES', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PROFIT', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.OPPORTUNITIES_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + // only for admin + { + key: 'fields.REQ_ERPM', + align: 'right', + prefix: '$', + }, + // only for admin + { + key: 'fields.REQ_EPPM', + align: 'right', + prefix: '$', + }, + // only for admin + { + key: 'fields.REQ_NET_ERPM', + align: 'right', + prefix: '$', + }, + // only for self serve + { + key: 'fields.REQ_GROSS_ERPM', + align: 'right', + prefix: '$', + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'created', + }, + { + key: 'copy', + align: 'left', + }, +]; + +export const DEMAND_ADVERTISER_PAGE_INFO_ROWS = [ + { + label: 'Ad Requests', + value: 'REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Ad Opportunities', + value: 'OPPORTUNITIES', + }, + { + label: 'Opportunities Fill', + value: 'OPPORTUNITIES_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + { + label: 'Profit', + value: 'PROFIT', + prefix: '$', + }, + { + label: 'RPM', + value: 'RPM', + prefix: '$', + }, + { + label: 'Gross RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'eRPM', + value: 'ERPM', + prefix: '$', + }, + { + label: 'Gross eRPM', + value: 'GROSS_ERPM', + prefix: '$', + }, + { + label: 'Ad Clicks', + value: 'CLICKS', + }, + { + label: 'CTR', + value: 'CTR', + postfix: '%', + needMultiply: true, + }, + { + label: 'Completion Rate', + value: 'COMPLETION_RATE', + postfix: '%', + needMultiply: true, + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, +]; + +export const DEMAND_ADVERTISER_PAGE_TOTAL_FIELDS = [ + 'REQUESTS', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'OPPORTUNITIES', + 'OPPORTUNITIES_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'ERPM', + 'GROSS_ERPM', + 'IMPRESSIONS_VIEWABILITY', + 'COMPLETION_RATE', + 'CTR', + 'CLICKS', + 'PROFIT', +]; + +export const DEMAND_ADVERTISER_PAGE_TOTAL_GRAPHQL_QUERY = ` + query AdvertiserDataByIdQuery( + $fields: [String], + $filters: MarketplaceFiltersInputType, + $id: String! + ) { + advertiserDataById( + fields: $fields, + filters: $filters, + id: $id, + ) { + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + OPPORTUNITIES { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + ERPM { + value + change + } + GROSS_ERPM { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + CLICKS { + value + change + } + PROFIT { + value + change + } + } + } + } +`; + +export const DEMAND_ADVERTISER_PAGE_DATA_FIELDS = [ + 'REQUESTS', + 'OPPORTUNITIES', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'OPPORTUNITIES_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'ERPM', + 'GROSS_ERPM', + 'HB_RATE', + 'COMPLETION_RATE', + 'CTR', + 'REQ_ERPM', + 'REQ_EPPM', + 'REQ_NET_ERPM', + 'REQ_GROSS_ERPM', + 'PROFIT', +]; + +export const DEMAND_ADVERTISER_PAGE_DATA_GRAPHQL_QUERY = ` + query DemandTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean, + $advertiserTargeting: Boolean + ) { + demandTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages, + advertiserTargeting: $advertiserTargeting + ) { + totalCount + data { + id + name + tier + priority + status + supplies + aps + daccountId + advertiserId + created + waterfallSkip + viewabilityThreshold + profit + pricing { + value + } + frequency { + status + value + amount + type + timeframe + } + include { + domains + geo + os + browsers + playerSizes + viewability + devices + } + exclude { + domains + geo + os + browsers + devices + } + advertiserInclude { + geo + } + advertiserExclude { + geo + } + kvTargeting { + state + type + key + values + listNames + keyName + } + fields { + REQUESTS { + value + change + } + OPPORTUNITIES { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + ERPM { + value + change + } + GROSS_ERPM { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + REQ_ERPM { + value + change + } + REQ_EPPM { + value + change + } + REQ_NET_ERPM { + value + change + } + REQ_GROSS_ERPM { + value + change + } + PROFIT { + value + change + } + } + } + } + } +`; + +export const DEMAND_ADVERTISER_PAGE_HISTOGRAM_GRAPHQL_QUERY = ` + query DemandTagHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + demandTagHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + } + } + } + } + } +`; + +export const DEMAND_ADVERTISER_PAGE_INFO_GRAPHQL_QUERY = ` + query AdvertiserByIdQuery( + $id: String + ) { + advertiserById( + id: $id + ) { + id + name + created + updated + updatedBy + daccountId + accountName + tier + publisherDemand + pbsEnabled + profitability + frequencyCapAdjustment + frequencyCapAdjustmentPerSupply + frequencyCapAdjustmentThreshold + include { + geo + } + exclude { + geo + } + revShare { + value + startDate + endDate + updatedBy + } + logo + } + } +`; + +export const DEMAND_ADVERTISER_PAGE_CONFIG_TAB_SETTINGS_ID = 'advertiserSettings'; +export const DEMAND_ADVERTISER_PAGE_CONFIG = { + type: DEMAND_ADVERTISER_PAGE, + tabs: [ + { + label: 'Demand Tags', + dataId: 'main', + contentKey: 'main', + }, + { + label: 'Settings', + dataId: DEMAND_ADVERTISER_PAGE_CONFIG_TAB_SETTINGS_ID, + contentKey: DEMAND_ADVERTISER_PAGE_CONFIG_TAB_SETTINGS_ID, + }, + { + label: 'Pricing', + dataId: 'advertiserPricing', + contentKey: 'advertiserPricing', + }, + ], + targetingTabConfig: { + name: true, + geography: true, + geographyTooltip: true, + tier: true, + }, + nextLevelLink: DEMAND_TAG_PAGE_PATHNAME, + nextLevelQuery: 'tagName', + infoRows: DEMAND_ADVERTISER_PAGE_INFO_ROWS, + rowsInColumn: 10, + tableSelecting: true, + tableHeaders: DEMAND_ADVERTISER_PAGE_TABLE_HEADERS.filter((item) => !['REQ_GROSS_ERPM'].includes(item.id)), + tableCells: DEMAND_ADVERTISER_PAGE_TABLE_CELLS.filter((item) => !['fields.REQ_GROSS_ERPM'].includes(item.key)), + tableFilters: STATUS_FILTERS, + showFilterByLabel: true, + timeIntervalOptions: COMMON_TIME_INTERVAL_OPTIONS, + timeIntervalParams: COMMON_TIME_INTERVAL_PARAMS, + comparisonOptions: [ + ...COMMON_COMPARISON_OPTIONS, + { + label: 'Opportunities Fill', + value: 'OPPORTUNITIES_FILL', + }, + ], + comparisonParams: COMMON_COMPARISON_PARAMS, + rowsPerPageOptions: [10, 25, 50, 100, 200], + // defaultConfig for Admin MP + defaultConfig: { + ...CHART_COMMON_DEFAULT_CONFIG, + filterByStatus: STATUS_FILTERS[1].value, + }, + totalFields: DEMAND_ADVERTISER_PAGE_TOTAL_FIELDS, + totalQuery: DEMAND_ADVERTISER_PAGE_TOTAL_GRAPHQL_QUERY, + dataFields: DEMAND_ADVERTISER_PAGE_DATA_FIELDS, + dataQuery: DEMAND_ADVERTISER_PAGE_DATA_GRAPHQL_QUERY, + histogramQuery: DEMAND_ADVERTISER_PAGE_HISTOGRAM_GRAPHQL_QUERY, + infoQuery: DEMAND_ADVERTISER_PAGE_INFO_GRAPHQL_QUERY, + selfServeConfig: { + showFilterByLabel: false, + infoRows: DEMAND_ADVERTISER_PAGE_INFO_ROWS.filter( + (item) => !['REVENUE', 'RPM', 'PROFIT', 'ERPM'].includes(item.value), + ).map((item) => { + if (item.value === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.value === 'GROSS_RPM') { + return { ...item, label: 'Ad RPM' }; + } + if (item.value === 'GROSS_ERPM') { + return { ...item, label: 'Opp eRPM' }; + } + return item; + }), + timeIntervalParams: COMMON_TIME_INTERVAL_PARAMS_FOR_SELF_SERVE, + comparisonOptions: [ + ...COMMON_COMPARISON_OPTIONS.filter((item) => !['REVENUE'].includes(item.value)).map((item) => { + if (item.value === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + return item; + }), + { + label: 'Opportunities Fill', + value: 'OPPORTUNITIES_FILL', + }, + ], + tableHeaders: DEMAND_ADVERTISER_PAGE_TABLE_HEADERS.filter( + (item) => !['REVENUE', 'PROFIT', 'ERPM', 'REQ_ERPM', 'REQ_EPPM', 'REQ_NET_ERPM'].includes(item.id), + ).map((item) => { + if (item.id === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.id === 'GROSS_ERPM') { + return { ...item, label: 'Ad RPM' }; + } + if (item.id === 'REQ_GROSS_ERPM') { + return { ...item, label: 'Req eRPM' }; + } + return item; + }), + tableCells: DEMAND_ADVERTISER_PAGE_TABLE_CELLS.filter( + (item) => + ![ + 'fields.REVENUE', + 'fields.PROFIT', + 'fields.ERPM', + 'fields.REQ_ERPM', + 'fields.REQ_EPPM', + 'fields.REQ_NET_ERPM', + ].includes(item.key), + ), + // defaultConfig for Self Serve MP + defaultConfig: { + comparisonFilter: COMMON_COMPARISON_OPTIONS[0].value, + }, + }, + showDownloadCSVButton: true, +}; diff --git a/anyclip/src/modules/marketplace/account/constants/demandTagPage.js b/anyclip/src/modules/marketplace/account/constants/demandTagPage.js new file mode 100644 index 0000000..d0caea7 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/demandTagPage.js @@ -0,0 +1,1380 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { + CHART_COMMON_DEFAULT_CONFIG, + COMMON_COMPARISON_OPTIONS, + COMMON_COMPARISON_PARAMS, + COMMON_TIME_INTERVAL_OPTIONS, + COMMON_TIME_INTERVAL_PARAMS, + COMMON_TIME_INTERVAL_PARAMS_FOR_SELF_SERVE, + LOG_PARAMS, +} from './chart'; +import { STATUS_FILTERS } from './table'; + +export const DEMAND_TAG_PAGE = 'DEMAND_TAG_PAGE'; + +export const DEMAND_TAG_PAGE_PATHNAME = '/tags'; + +export const DEMAND_TAG_PAGE_CONFIRM_FREQUENCY_CAP_MODAL = 'DEMAND_TAG_PAGE_CONFIRM_FREQUENCY_CAP_MODAL'; + +export const BUDGETING = 'Budgeting'; + +export const DEMAND_TAG_PAGE_TABLE_HEADERS = [ + { + id: 'ID', + label: 'Id', + isSortable: true, + }, + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'TIER_PRIORITY', + label: 'Tier', + isSortable: true, + }, + { + id: 'priority', + label: 'Priority', + isSortable: false, + }, + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'BIDS', + label: 'Bids', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OPPORTUNITIES', + label: 'Opportunities', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PROFIT', + label: 'Profit', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OPPORTUNITIES_FILL', + label: 'Opp Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'GROSS_RPM', + label: 'Gross RPM', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'ERPM', + label: 'eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_ERPM', + label: 'Gross eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'STATUS', + label: 'Status', + isSortable: false, + }, +]; + +export const DEMAND_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT = [ + { + id: 'ID', + label: 'Id', + isSortable: true, + }, + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'TIER_PRIORITY', + label: 'Tier', + isSortable: true, + }, + { + id: 'priority', + label: 'Priority', + isSortable: false, + }, + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'BIDS', + label: 'Bids', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PROFIT', + label: 'Profit', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'GROSS_RPM', + label: 'Gross RPM', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'STATUS', + label: 'Status', + isSortable: false, + }, +]; + +export const DEMAND_TAG_PAGE_TABLE_CELLS = [ + { + key: 'id', + }, + { + key: 'name', + }, + { + key: 'tier', + }, + { + key: 'priority', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.BIDS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.OPPORTUNITIES', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PROFIT', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.OPPORTUNITIES_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'status', + }, +]; + +export const DEMAND_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT = [ + { + key: 'id', + }, + { + key: 'name', + }, + { + key: 'tier', + }, + { + key: 'priority', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.BIDS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PROFIT', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'status', + }, +]; + +export const DEMAND_TAG_PAGE_INFO_ROWS = [ + { + label: 'Ad Requests', + value: 'REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Ad Opportunities', + value: 'OPPORTUNITIES', + }, + { + label: 'Opportunities Fill', + value: 'OPPORTUNITIES_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + { + label: 'Profit', + value: 'PROFIT', + prefix: '$', + }, + { + label: 'RPM', + value: 'RPM', + prefix: '$', + }, + { + label: 'Gross RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'eRPM', + value: 'ERPM', + prefix: '$', + }, + { + label: 'Gross eRPM', + value: 'GROSS_ERPM', + prefix: '$', + }, + { + label: 'Ad Clicks', + value: 'CLICKS', + }, + { + label: 'CTR', + value: 'CTR', + postfix: '%', + needMultiply: true, + }, + { + label: 'Completion Rate', + value: 'COMPLETION_RATE', + postfix: '%', + needMultiply: true, + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, +]; + +export const DEMAND_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT = [ + { + label: 'Ad Requests', + value: 'REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + { + label: 'Profit', + value: 'PROFIT', + prefix: '$', + }, + { + label: 'RPM', + value: 'RPM', + prefix: '$', + }, + { + label: 'Gross RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'Ad Clicks', + value: 'CLICKS', + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, +]; + +export const DEMAND_TAG_PAGE_ADD_SUPPLY_TAG_MODAL = 'DEMAND_TAG_PAGE_ADD_SUPPLY_TAG_MODAL'; + +export const DEMAND_TAG_PAGE_TOTAL_FIELDS = [ + 'REQUESTS', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'OPPORTUNITIES', + 'OPPORTUNITIES_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'ERPM', + 'GROSS_ERPM', + 'IMPRESSIONS_VIEWABILITY', + 'CLICKS', + 'CTR', + 'COMPLETION_RATE', + 'PROFIT', +]; + +export const DEMAND_TAG_PAGE_TOTAL_GRAPHQL_QUERY = ` + query DemandTagDataByIdQuery( + $fields: [String], + $filters: MarketplaceFiltersInputType, + $id: String! + ) { + demandTagDataById( + fields: $fields, + filters: $filters, + id: $id + ) { + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + OPPORTUNITIES { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + ERPM { + value + change + } + GROSS_ERPM { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + CLICKS { + value + change + } + CTR { + value + change + } + COMPLETION_RATE { + value + change + } + PROFIT { + value + change + } + } + } + } +`; + +export const DEMAND_TAG_PAGE_DATA_FIELDS = [ + 'REQUESTS', + 'OPPORTUNITIES', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'OPPORTUNITIES_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'ERPM', + 'GROSS_ERPM', + 'CTR', + 'COMPLETION_RATE', + 'BIDS', + 'PROFIT', +]; + +export const DEMAND_TAG_PAGE_DATA_GRAPHQL_QUERY = ` + query SupplyTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType + ) { + supplyTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters + ) { + totalCount + data { + id + name + status + tier + priority + accountId + siteId + fields { + REQUESTS { + value + change + } + OPPORTUNITIES { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + CTR { + value + change + } + COMPLETION_RATE { + value + change + } + ERPM { + value + change + } + GROSS_ERPM { + value + change + } + BIDS { + value + change + } + PROFIT { + value + change + } + } + } + } + } +`; + +export const DEMAND_TAG_PAGE_HISTOGRAM_GRAPHQL_QUERY = ` + query SupplyTagHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + supplyTagHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + } + } + } + } + } +`; + +export const DEMAND_TAG_PAGE_INFO_GRAPHQL_QUERY = ` + query DemandTagByIdQuery( + $id: String + ) { + demandTagById( + id: $id + ) { + id + name + created + updated + updatedBy + daccountId + advertiserId + status + adServer { + id + name + url + } + flightsDates{ + startDate + endDate + } + defaultTier + labels + timeout + eventPixels { + url + name + type + } + model + include { + domains + geo + os + browsers + playerSizes + viewability + devices + } + exclude { + domains + geo + os + browsers + devices + } + budgets { + budget + pacing + type + timeframe + } + frequency { + status + value + amount + type + timeframe + } + accountName + advertiserName + advertiserTier + type + platform { + connectorId + connectorName + code + params { + name + value + } + supplyChain + maxFloor + bidMapping { + fileName + data { + cpm + code + } + } + } + rateSource { + name + seatId + adUnitId + type + } + floorPrice { + floor + viewableFloor + override + } + viewabilityThreshold + adjustFloor + publisherDemand + format + priority + budgetAlarm + kvTargeting { + state + type + key + values + listNames + keyName + } + videoId + clickThroughUrl + source + platformId + } + } +`; + +export const DEMAND_TAG_PAGE_CONFIG = { + type: DEMAND_TAG_PAGE, + tabs: [ + { + label: 'Supply Tags', + dataId: 'main', + contentKey: 'main', + }, + { + label: 'Settings', + dataId: 'demandTagSettings', + contentKey: 'demandTagSettings', + }, + { + label: 'Pricing', + dataId: 'demandTagPricing', + contentKey: 'demandTagPricing', + }, + { + label: 'Targeting', + dataId: 'demandTagTargeting', + contentKey: 'demandTagTargeting', + }, + { + label: 'Frequency Cap', + dataId: 'demandTagFrequency', + contentKey: 'demandTagFrequency', + }, + { + label: BUDGETING, + dataId: 'demandTagBudgeting', + contentKey: 'demandTagBudgeting', + }, + ], + targetingTabConfig: { + geography: true, + geographyTooltip: false, + playerSize: true, + viewability: true, + viewabilityTargeting: true, + device: true, + os: true, + browser: true, + kvTargeting: true, + }, + infoRows: DEMAND_TAG_PAGE_INFO_ROWS, + rowsInColumn: 10, + tableSelecting: true, + tableHeaders: DEMAND_TAG_PAGE_TABLE_HEADERS, + tableFilters: STATUS_FILTERS, + tableCells: DEMAND_TAG_PAGE_TABLE_CELLS, + timeIntervalOptions: COMMON_TIME_INTERVAL_OPTIONS, + timeIntervalParams: COMMON_TIME_INTERVAL_PARAMS, + chartLogParams: LOG_PARAMS, + comparisonOptions: [ + ...COMMON_COMPARISON_OPTIONS, + { + label: 'Opportunities Fill', + value: 'OPPORTUNITIES_FILL', + }, + ], + comparisonParams: COMMON_COMPARISON_PARAMS, + rowsPerPageOptions: [10, 25, 50, 100, 200], + defaultConfig: { + ...CHART_COMMON_DEFAULT_CONFIG, + filterByStatus: STATUS_FILTERS[1].value, + }, + totalFields: DEMAND_TAG_PAGE_TOTAL_FIELDS, + totalQuery: DEMAND_TAG_PAGE_TOTAL_GRAPHQL_QUERY, + dataFields: DEMAND_TAG_PAGE_DATA_FIELDS, + dataQuery: DEMAND_TAG_PAGE_DATA_GRAPHQL_QUERY, + histogramQuery: DEMAND_TAG_PAGE_HISTOGRAM_GRAPHQL_QUERY, + infoQuery: DEMAND_TAG_PAGE_INFO_GRAPHQL_QUERY, + selfServeConfig: { + infoRows: DEMAND_TAG_PAGE_INFO_ROWS.filter( + (item) => !['REVENUE', 'RPM', 'PROFIT', 'ERPM'].includes(item.value), + ).map((item) => { + if (item.value === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.value === 'GROSS_RPM') { + return { ...item, label: 'Ad RPM' }; + } + if (item.value === 'GROSS_ERPM') { + return { ...item, label: 'Opp eRPM' }; + } + return item; + }), + timeIntervalParams: COMMON_TIME_INTERVAL_PARAMS_FOR_SELF_SERVE, + comparisonOptions: [ + ...COMMON_COMPARISON_OPTIONS.filter((item) => !['REVENUE'].includes(item.value)).map((item) => { + if (item.value === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + return item; + }), + { + label: 'Opportunities Fill', + value: 'OPPORTUNITIES_FILL', + }, + ], + tableHeaders: DEMAND_TAG_PAGE_TABLE_HEADERS.filter( + (item) => !['REVENUE', 'RPM', 'ERPM', 'PROFIT', 'TIER_PRIORITY', 'priority'].includes(item.id), + ).map((item) => { + if (item.id === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.id === 'GROSS_RPM') { + return { ...item, label: 'Ad RPM' }; + } + if (item.id === 'GROSS_ERPM') { + return { ...item, label: 'Opp eRPM' }; + } + return item; + }), + tableCells: DEMAND_TAG_PAGE_TABLE_CELLS.filter( + (item) => + !['fields.REVENUE', 'fields.RPM', 'fields.ERPM', 'fields.PROFIT', 'tier', 'priority'].includes(item.key), + ), + // defaultConfig for Self Serve MP + defaultConfig: { + comparisonFilter: COMMON_COMPARISON_OPTIONS[0].value, + }, + }, + isOpenNewTab: true, + showHistoryCSVButton: true, +}; + +export const DEMAND_TAG_PAGE_TYPE_VALUES = { + tag: 'TAG', + hb: 'HB', + mp4: 'DIRECT_MP4', +}; + +export const DEMAND_TAG_PAGE_AD_SERVER_TYPE_VALUES = { + none: 'none', + list: 'list', +}; + +export const DEMAND_TAG_DEFAULT_TIER_VALUES = [ + { value: 0 }, + { value: 1 }, + { value: 2 }, + { value: 3 }, + { value: 4 }, + { value: 5 }, + { value: 6 }, + { value: 7 }, + { value: 8 }, + { value: 9 }, +]; + +export const DEMAND_TAG_EVENT_PIXELS_SIZE = 5; + +export const DEMAND_TAG_PAGE_EVENT_LIST = [ + { + label: 'Ad Request', + value: 'REQUESTS', + }, + { + label: 'Ad Impression', + value: 'IMPRESSIONS', + }, + { + label: 'Ad Click', + value: 'CLICK', + }, + { + label: 'Ad Complete', + value: 'COMPLETE', + }, +]; + +export const DEMAND_TAG_PAGE_EVENT_TYPES = [ + { + label: 'Image URL', + value: 'IMAGE_URL', + }, + { + label: 'JavaScript URL', + value: 'JAVASCRIPT_URL', + }, +]; + +export const DEMAND_TAG_PAGE_BUSINESS_MODEL = [ + { + label: 'Fixed RPM', + value: 'FIXED_RPM', + }, + { + label: 'Dynamic RPM Update', + value: 'DYNAMIC_RPM', + }, +]; + +export const DEMAND_TAG_PAGE_BUSINESS_MODEL_HB_NOT_GAM = [ + { + label: 'HB Dynamic Rate', + value: 'HB_DYNAMIC_RATE', + }, + { + label: 'Fixed RPM', + value: 'FIXED_RPM', + }, +]; + +export const DEMAND_TAG_PAGE_SOURCE = [ + { + label: 'GAM', + value: 'GAM', + }, + { + label: 'SpringServe', + value: 'SPRINGSERVE', + }, +]; + +export const DEMAND_TAG_PAGE_PRICING_TYPE = [ + { + label: 'Ad Unit', + value: 'AD_UNIT', + }, + { + label: 'Report Name', + value: 'REPORT_NAME', + }, +]; + +export const DEMAND_TAG_PAGE_OS_LIST = [ + { label: 'Android', value: 'ANDROID' }, + { label: 'IOS', value: 'IOS' }, + { label: 'Win', value: 'WINDOWS' }, + { label: 'Linux', value: 'LINUX' }, + { label: 'Mac', value: 'MAC' }, + { label: 'ChromeOS', value: 'CHROME_OS' }, + { label: 'FireOS', value: 'FIRE_OS' }, + { label: 'Other', value: 'OTHER' }, +]; + +export const DEMAND_TAG_PAGE_DEVICE_LIST = [ + { label: 'Desktop', value: 'DESKTOP' }, + { label: 'Mobile and Tablet', value: 'MOBILE' }, + { label: 'Other', value: 'OTHER' }, +]; + +export const DEMAND_TAG_PAGE_BROWSER_LIST = [ + { label: 'Chrome', value: 'CHROME' }, + { label: 'Edge', value: 'EDGE' }, + { label: 'Safari', value: 'SAFARI' }, + { label: 'Firefox', value: 'FIREFOX' }, + { label: 'IE', value: 'IE' }, + { label: 'Other', value: 'OTHER' }, +]; + +export const DEMAND_TAG_PAGE_BUDGET_TYPES = { + request: 'REQUEST', + impression: 'IMPRESSION', +}; + +export const DEMAND_TAG_PAGE_BUDGET_TYPE_LIST = [ + // { + // label: 'Request', + // value: DEMAND_TAG_PAGE_BUDGET_TYPES.request, + // }, + { + label: 'Impressions', + value: DEMAND_TAG_PAGE_BUDGET_TYPES.impression, + }, +]; + +export const DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES = { + minute: 'MINUTE', + hourly: 'HOURLY', + daily: 'DAILY', + weekly: 'WEEKLY', + monthly: 'MONTHLY', + lifetime: 'LIFETIME', +}; + +export const DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_LIST = [ + { + label: 'Hourly', + value: DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES.hourly, + }, + { + label: 'Daily', + value: DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES.daily, + }, + { + label: 'Weekly', + value: DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES.weekly, + }, + { + label: 'Monthly', + value: DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES.monthly, + }, + { + label: 'Life time', + value: DEMAND_TAG_PAGE_BUDGET_TIMEFRAME_TYPES.lifetime, + }, +]; + +export const DEMAND_TAG_PAGE_BUDGET_PACING_TYPES = { + even: 'EVEN', + asap: 'ASAP', +}; +export const DEMAND_TAG_PAGE_BUDGET_PACING_LIST = [ + { + label: 'Even', + value: DEMAND_TAG_PAGE_BUDGET_PACING_TYPES.even, + }, + { + label: 'Asap', + value: DEMAND_TAG_PAGE_BUDGET_PACING_TYPES.asap, + }, +]; + +export const DEMAND_TAG_EVENT_BUDGETS_SIZE = 5; +export const DEMAND_TAG_FREQUENCY_CAP_TIMEFRAME_LIST = [ + { + label: 'Minutes', + value: 'MINUTE', + }, + { + label: 'Hour', + value: 'HOUR', + }, + { + label: 'Day', + value: 'DAY', + }, +]; + +export const DEMAND_TAG_FREQUENCY_CAP_TYPE_LIST = { + request: 'REQUEST', + impressions: 'IMPRESSION', +}; + +export const DEMAND_TAG_PRICING_TYPE = { + price: 'DEMAND_PRICE', + fee: 'DEMAND_FEE', + adServing: 'DEMAND_AD_SERVING_FEES', + adRequest: 'DEMAND_AD_REQUEST_FEE', +}; + +export const DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE = { + xs: 'XS', + s: 'S', + m: 'M', + l: 'L', +}; + +export const DEMAND_TAG_TARGETING_VIEWABILITY_TYPE = { + inView: 'IN_VIEW', + nonInView: 'NOT_IN_VIEW', +}; + +export const DEMAND_TAG_TARGETING_OS_TYPE = { + android: 'ANDROID', + ios: 'IOS', + windows: 'WINDOWS', + linux: 'LINUX', + mac: 'MAC', + chromeOs: 'CHROME_OS', + fireOS: 'FIRE_OS', + other: 'OTHER', +}; +export const DEMAND_TAG_TARGETING_DEVICE_TYPE = { + desktop: 'DESKTOP', + mobile: 'MOBILE', + tablet: 'TABLET', + connectedTv: 'CONNECTED_TV', + other: 'OTHER', +}; + +export const DEMAND_TAG_FREQUENCY_TAB_STATUS = { + active: 'ACTIVE', + disabled: 'DISABLED', +}; + +export const DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE = { + enabled: 'ENABLED', + disabled: 'DISABLED', +}; + +export const DEMAND_TAG_SUPPLY_CHAIN_VALUE = { + blank: 'BLANK', + custom: 'CUSTOM', +}; + +export const FLOOR_PRICE = { + publisherDefault: 'publisherDefault', + override: 'override', +}; + +export const DEMAND_TAG_STATUS = { + enabled: 'ACTIVE', + disabled: 'DISABLED', +}; + +export const DEMAND_TAG_VIEWABILITY_TARGETING = { + enabled: 'ACTIVE', + disabled: 'DISABLED', +}; + +export const DEMAND_TAG_FORMAT = { + video: 'VIDEO', + display: 'DISPLAY', + sponsored: 'SPONSORED', +}; + +export const DEMAND_TAG_KEY_VALUE_TARGETING_STATUS = { + enabled: 'ACTIVE', + disabled: 'DISABLED', +}; + +export const DEMAND_TAG_KEY_VALUE_TARGETING_TYPE = { + value: 'VALUE', + list: 'LIST', +}; + +export const DEMAND_TAG_PRIORITY = { + firstLook: 'FIRST_LOOK', + openAuction: 'OPEN_AUCTION', +}; + +export const DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_IMPRESSIONS = 'IMPRESSIONS_CPM'; +export const DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE = 'REV_SHARE'; + +export const DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_LIST = [ + { + label: 'Impressions CPM', + value: DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_IMPRESSIONS, + }, + { + label: 'Rev-Share', + value: DEMAND_TAG_PAGE_AD_SERVING_BUSINESS_MODEL_REV_SHARE, + }, +]; + +export const DEMAND_TAG_HB_GAM_PLATFORMS = ['gam', 'gamfixed']; diff --git a/anyclip/src/modules/marketplace/account/constants/index.js b/anyclip/src/modules/marketplace/account/constants/index.js new file mode 100644 index 0000000..0d11354 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/index.js @@ -0,0 +1,9 @@ +export * from './addTagModal'; +export * from './chart'; +export * from './demandAccountPage'; +export * from './demandAdvertiserPage'; +export * from './demandTagPage'; +export * from './supplyAccountPage'; +export * from './supplySitePage'; +export * from './supplyTagPage'; +export * from './table'; diff --git a/anyclip/src/modules/marketplace/account/constants/supplyAccountPage.js b/anyclip/src/modules/marketplace/account/constants/supplyAccountPage.js new file mode 100644 index 0000000..efaf472 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/supplyAccountPage.js @@ -0,0 +1,746 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { + CHART_COMMON_DEFAULT_CONFIG, + COMMON_COMPARISON_PARAMS, + COMMON_SUPPLY_COMPARISON_OPTIONS, + COMMON_SUPPLY_TIME_INTERVAL_PARAMS, + COMMON_TIME_INTERVAL_OPTIONS, +} from './chart'; +import { SUPPLY_SITE_PAGE_PATHNAME } from './supplySitePage'; +import { STATUS_FILTERS } from './table'; + +export const SUPPLY_ACCOUNT_PAGE = 'SUPPLY_ACCOUNT_PAGE'; + +export const SUPPLY_ACCOUNT_PAGE_PATHNAME = '/supply'; + +export const SUPPLY_ACCOUNT_PAGE_TABLE_HEADERS = [ + { + id: 'ID', + label: 'Id', + isSortable: true, + }, + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'SUPPLIES', + label: 'Supply Tags', + isSortable: true, + }, + { + id: 'PLAYER_LOADS', + label: 'Player Loads', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_REVENUE', + label: 'Pub NET Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OVERALL_FILL', + label: 'Overall Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_PLAYER_ERPM', + label: 'Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PLAYER_ERPM', + label: 'Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_PLAYER_ERPM', + label: 'Gross Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_PLAYER_ERPM', + label: 'Pub NET Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, +]; + +export const SUPPLY_ACCOUNT_PAGE_TABLE_CELLS = [ + { + key: 'id', + }, + { + key: 'name', + }, + { + key: 'supplies', + }, + { + key: 'fields.PLAYER_LOADS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.OVERALL_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.PUB_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, +]; + +export const SUPPLY_ACCOUNT_PAGE_INFO_ROWS = [ + { + label: 'Player Loads', + value: 'PLAYER_LOADS', + }, + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Overall Fill', + value: 'OVERALL_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + { + label: 'Pub NET Revenue', + value: 'PUB_NET_REVENUE', + prefix: '$', + }, + { + label: 'Expenses', + value: 'EXPENSES', + prefix: '$', + }, + { + label: 'Media Cost', + value: 'MEDIA_COST', + prefix: '$', + }, + { + label: 'Media Margin', + value: 'MEDIA_MARGIN', + prefix: '$', + }, + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + prefix: '$', + }, + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + prefix: '$', + }, + { + label: 'CPM', + value: 'CPM', + prefix: '$', + }, + { + label: 'Player eRPM', + value: 'PUB_PLAYER_ERPM', + prefix: '$', + }, + { + label: 'Player eRPM', + value: 'PLAYER_ERPM', + prefix: '$', + }, + { + label: 'Gross Player eRPM', + value: 'GROSS_PLAYER_ERPM', + prefix: '$', + }, + { + label: 'Pub NET Player eRPM', + value: 'PUB_NET_PLAYER_ERPM', + prefix: '$', + }, +]; + +export const SUPPLY_ACCOUNT_PAGE_TOTAL_FIELDS = [ + 'PLAYER_LOADS', + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'OVERALL_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'MEDIA_COST', + 'AC_AD_RPM', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'CPM', + 'PUB_PLAYER_ERPM', + 'PLAYER_ERPM', + 'GROSS_PLAYER_ERPM', + 'MEDIA_MARGIN', + 'EXPENSES', + 'PUB_NET_REVENUE', + 'PUB_NET_PLAYER_ERPM', +]; + +export const SUPPLY_ACCOUNT_PAGE_TOTAL_GRAPHQL_QUERY = ` + query SupplyAccountDataByIdQuery( + $fields: [String], + $filters: MarketplaceFiltersInputType, + $id: String! + ) { + supplyAccountDataById( + fields: $fields, + filters: $filters, + id: $id + ) { + fields { + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + OVERALL_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + MEDIA_COST { + value + change + } + AC_AD_RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + CPM { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + MEDIA_MARGIN { + value + change + } + EXPENSES { + value + change + } + PUB_NET_REVENUE { + value + change + } + PUB_NET_PLAYER_ERPM { + value + change + } + } + } + } +`; + +export const SUPPLY_ACCOUNT_PAGE_DATA_FIELDS = [ + 'PAGE_VIEWS', + 'PLAYER_LOADS', + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'OVERALL_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'PUB_PLAYER_ERPM', + 'PLAYER_ERPM', + 'GROSS_PLAYER_ERPM', + 'COMPLETION_RATE', + 'CTR', + 'PUB_NET_REVENUE', + 'PUB_NET_PLAYER_ERPM', +]; + +export const SUPPLY_ACCOUNT_PAGE_DATA_GRAPHQL_QUERY = ` + query SiteDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean + ) { + siteData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + supplies + fields { + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + OVERALL_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + PUB_NET_REVENUE { + value + change + } + PUB_NET_PLAYER_ERPM { + value + change + } + } + } + } + } +`; + +export const SUPPLY_ACCOUNT_PAGE_HISTOGRAM_GRAPHQL_QUERY = ` + query SiteHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + siteHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + PLAYER_LOADS { + value + change + } + OVERALL_FILL { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + SUPPLY_REQUESTS_VIEWABILITY { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + AC_AD_RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + PUB_NET_REVENUE { + value + change + } + PUB_NET_PLAYER_ERPM { + value + change + } + } + } + } + } + } +`; + +export const SUPPLY_ACCOUNT_PAGE_INFO_GRAPHQL_QUERY = ` + query SupplyAccountByIdQuery( + $id: String + ) { + supplyAccountById( + id: $id + ) { + id + name + created + updated + updatedBy + } + } +`; + +const comparisonOptions = [ + ...COMMON_SUPPLY_COMPARISON_OPTIONS, + { + label: 'Player Loads', + value: 'PLAYER_LOADS', + }, + { + label: 'Overall Fill ', + value: 'OVERALL_FILL', + }, + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + }, + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + }, + { + label: 'Player eRPM', + value: 'PLAYER_ERPM', + }, + { + label: 'Gross Player eRPM', + value: 'GROSS_PLAYER_ERPM', + }, + { + label: 'Pub NET Player eRPM', + value: 'PUB_NET_PLAYER_ERPM', + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + }, +]; + +export const SUPPLY_ACCOUNT_PAGE_CONFIG = { + type: SUPPLY_ACCOUNT_PAGE, + tabs: [ + { + label: 'Sites', + dataId: 'main', + contentKey: 'main', + }, + ], + nextLevelLink: SUPPLY_SITE_PAGE_PATHNAME, + nextLevelQuery: 'siteName', + infoRows: SUPPLY_ACCOUNT_PAGE_INFO_ROWS.filter((item) => !['PUB_PLAYER_ERPM', 'PUB_AD_RPM'].includes(item.value)), + rowsInColumn: 11, + tableSelecting: false, + tableFilters: STATUS_FILTERS, + tableHeaders: SUPPLY_ACCOUNT_PAGE_TABLE_HEADERS.filter( + (item) => !['PUB_PLAYER_ERPM', 'PUB_AD_RPM'].includes(item.id), + ), + tableCells: SUPPLY_ACCOUNT_PAGE_TABLE_CELLS.filter( + (item) => !['fields.PUB_PLAYER_ERPM', 'fields.PUB_AD_RPM'].includes(item.key), + ), + timeIntervalOptions: COMMON_TIME_INTERVAL_OPTIONS, + timeIntervalParams: COMMON_SUPPLY_TIME_INTERVAL_PARAMS, + comparisonOptions: comparisonOptions.filter((item) => !['PUB_AD_RPM'].includes(item.value)), + comparisonParams: COMMON_COMPARISON_PARAMS, + rowsPerPageOptions: [10, 25, 50, 100, 200], + defaultConfig: { + ...CHART_COMMON_DEFAULT_CONFIG, + sortBy: 'SUPPLY_REQUESTS', + filterByStatus: STATUS_FILTERS[1].value, + }, + totalFields: SUPPLY_ACCOUNT_PAGE_TOTAL_FIELDS, + totalQuery: SUPPLY_ACCOUNT_PAGE_TOTAL_GRAPHQL_QUERY, + dataFields: SUPPLY_ACCOUNT_PAGE_DATA_FIELDS, + dataQuery: SUPPLY_ACCOUNT_PAGE_DATA_GRAPHQL_QUERY, + histogramQuery: SUPPLY_ACCOUNT_PAGE_HISTOGRAM_GRAPHQL_QUERY, + infoQuery: SUPPLY_ACCOUNT_PAGE_INFO_GRAPHQL_QUERY, + showDownloadCSVButton: true, + selfServeConfig: { + infoRows: SUPPLY_ACCOUNT_PAGE_INFO_ROWS.filter((item) => !['PLAYER_ERPM', 'GROSS_RPM'].includes(item.value)), + tableHeaders: SUPPLY_ACCOUNT_PAGE_TABLE_HEADERS.filter((item) => !['PLAYER_ERPM', 'GROSS_RPM'].includes(item.id)), + tableCells: SUPPLY_ACCOUNT_PAGE_TABLE_CELLS.filter( + (item) => !['fields.PLAYER_ERPM', 'fields.GROSS_RPM'].includes(item.key), + ), + comparisonOptions: comparisonOptions.filter((item) => !['PUB_PLAYER_ERPM', 'GROSS_RPM'].includes(item.value)), + }, +}; diff --git a/anyclip/src/modules/marketplace/account/constants/supplySitePage.js b/anyclip/src/modules/marketplace/account/constants/supplySitePage.js new file mode 100644 index 0000000..da3a907 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/supplySitePage.js @@ -0,0 +1,962 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { + CHART_COMMON_DEFAULT_CONFIG, + COMMON_COMPARISON_PARAMS, + COMMON_SUPPLY_COMPARISON_OPTIONS, + COMMON_SUPPLY_TIME_INTERVAL_PARAMS, + COMMON_TIME_INTERVAL_OPTIONS, +} from './chart'; +import { SUPPLY_TAG_PAGE_PATHNAME } from './supplyTagPage'; +import { STATUS_FILTERS } from './table'; + +export const SUPPLY_SITE_PAGE = 'SUPPLY_SITE_PAGE'; + +export const SUPPLY_SITE_PAGE_PATHNAME = '/sites'; + +export const SUPPLY_SITE_PAGE_CHANGE_REV_SHARE_MODAL = 'SUPPLY_SITE_PAGE_CHANGE_REV_SHARE_MODAL'; +export const SUPPLY_SITE_PAGE_CHANGE_EXPENSES_MODAL = 'SUPPLY_SITE_PAGE_CHANGE_EXPENSES_MODAL'; + +export const SUPPLY_SITE_PAGE_TABLE_HEADERS = [ + { + id: 'ID', + label: 'Id', + isSortable: true, + }, + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'DEMANDS', + label: 'Demand', + isSortable: true, + }, + // only for admin + { + id: 'PRICING', + label: 'Rate', + isSortable: true, + }, + { + id: 'SUPPLY_REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for self serve + { + id: 'PUB_REVENUE', + label: 'Pub Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_REVENUE', + label: 'Pub NET Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_RPM', + label: 'Gross RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_AD_RPM', + label: 'Ad RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'MEDIA_COST', + label: 'Media Cost', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'STATUS', + label: 'Status', + }, + // only for admin + { + id: 'CREATED', + label: 'Creation Date', + isSortable: true, + }, + // only for admin + { + id: 'COPY_ACTION', + label: '', + }, +]; + +export const SUPPLY_SITE_PAGE_TABLE_CELLS = [ + { + key: 'id', + }, + { + key: 'name', + }, + { + key: 'demands', + }, + { + key: 'pricing', + prefix: '$', + }, + { + key: 'fields.SUPPLY_REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_AD_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.MEDIA_COST', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'status', + }, + { + key: 'created', + }, + { + key: 'copy', + }, +]; + +export const SUPPLY_SITE_PAGE_INFO_ROWS = [ + { + label: 'Player Loads', + value: 'PLAYER_LOADS', + }, + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + { + label: 'Overall Fill', + value: 'OVERALL_FILL', + postfix: '%', + needMultiply: true, + }, + // only for admin + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + // only for self serve + { + label: 'Pub Revenue', + value: 'PUB_REVENUE', + prefix: '$', + }, + { + label: 'Pub NET Revenue', + value: 'PUB_NET_REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Expenses', + value: 'EXPENSES', + prefix: '$', + }, + // only for admin + { + label: 'Media Cost', + value: 'MEDIA_COST', + prefix: '$', + }, + // only for admin + { + label: 'Media Margin', + value: 'MEDIA_MARGIN', + prefix: '$', + }, + // only for admin + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + prefix: '$', + }, + // only for admin + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + prefix: '$', + }, + // only for admin + { + label: 'CPM', + value: 'CPM', + prefix: '$', + }, + { + label: 'Player eRPM', + value: 'PUB_PLAYER_ERPM', + prefix: '$', + }, + { + label: 'Player eRPM', + value: 'PLAYER_ERPM', + prefix: '$', + }, + // only for admin + { + label: 'Gross Player eRPM', + value: 'GROSS_PLAYER_ERPM', + prefix: '$', + }, + { + label: ' Pub NET Player eRPM', + value: 'PUB_NET_PLAYER_ERPM', + prefix: '$', + }, + // only for admin + { + label: 'CTR', + value: 'CTR', + postfix: '%', + needMultiply: true, + }, + // only for admin + { + label: 'Completion Rate', + value: 'COMPLETION_RATE', + postfix: '%', + needMultiply: true, + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, +]; + +export const SUPPLY_SITE_PAGE_TOTAL_FIELDS = [ + 'PLAYER_LOADS', + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'OVERALL_FILL', + 'REVENUE', + 'PUB_REVENUE', + 'GROSS_REVENUE', + 'MEDIA_COST', + 'AC_AD_RPM', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'CPM', + 'PUB_PLAYER_ERPM', + 'PLAYER_ERPM', + 'GROSS_PLAYER_ERPM', + 'SUPPLY_REQUESTS_VIEWABILITY', + 'IMPRESSIONS_VIEWABILITY', + 'MEDIA_MARGIN', + 'COMPLETION_RATE', + 'CTR', + 'EXPENSES', + 'PUB_NET_REVENUE', + 'PUB_NET_PLAYER_ERPM', +]; + +export const SUPPLY_SITE_PAGE_TOTAL_GRAPHQL_QUERY = ` + query SiteDataByIdQuery( + $fields: [String], + $filters: MarketplaceFiltersInputType, + $id: String! + ) { + siteDataById( + fields: $fields, + filters: $filters, + id: $id + ) { + fields { + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + OVERALL_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + MEDIA_COST { + value + change + } + AC_AD_RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + CPM { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + SUPPLY_REQUESTS_VIEWABILITY { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + MEDIA_MARGIN { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + EXPENSES { + value + change + } + PUB_NET_REVENUE { + value + change + } + PUB_NET_PLAYER_ERPM { + value + change + } + } + } + } +`; + +export const SUPPLY_SITE_PAGE_DATA_FIELDS = [ + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'PUB_REVENUE', + 'RPM', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'MEDIA_COST', + 'COMPLETION_RATE', + 'CTR', + 'PUB_NET_REVENUE', +]; + +export const SUPPLY_SITE_PAGE_DATA_GRAPHQL_QUERY = ` + query SupplyTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean + ) { + supplyTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + demands + status + accountId + siteId + created + pricing { + value + model + } + fields { + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + MEDIA_COST { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + PUB_NET_REVENUE { + value + change + } + } + } + } + } +`; + +export const SUPPLY_SITE_PAGE_HISTOGRAM_GRAPHQL_QUERY = ` + query SupplyTagHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + supplyTagHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + PAGE_VIEWS { + value + change + } + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + OVERALL_FILL { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + MEDIA_COST { + value + change + } + RPM { + value + change + } + CPM { + value + change + } + SUPPLY_REQUESTS_VIEWABILITY { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + AC_AD_RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + PUB_NET_REVENUE { + value + change + } + PUB_NET_PLAYER_ERPM { + value + change + } + } + } + } + } + } +`; + +export const SUPPLY_SITE_PAGE_INFO_GRAPHQL_QUERY = ` + query SiteByIdQuery( + $id: String + ) { + siteById( + id: $id + ) { + id + name + created + updated + updatedBy + accountId + accountName + accountType + } + } +`; + +export const SUPPLY_SITE_PAGE_CONFIG = { + type: SUPPLY_SITE_PAGE, + tabs: [ + { + label: 'Supply Tags', + dataId: 'main', + contentKey: 'main', + }, + ], + nextLevelLink: SUPPLY_TAG_PAGE_PATHNAME, + nextLevelQuery: 'tagName', + infoRows: SUPPLY_SITE_PAGE_INFO_ROWS.filter( + (item) => !['PUB_PLAYER_ERPM', 'PUB_REVENUE', 'PUB_AD_RPM'].includes(item.value), + ), + rowsInColumn: 12, + tableSelecting: true, + tableFilters: STATUS_FILTERS, + tableHeaders: SUPPLY_SITE_PAGE_TABLE_HEADERS.filter( + (item) => !['PUB_PLAYER_ERPM', 'PUB_REVENUE', 'PUB_AD_RPM'].includes(item.id), + ), + tableCells: SUPPLY_SITE_PAGE_TABLE_CELLS.filter( + (item) => !['fields.PUB_PLAYER_ERPM', 'fields.PUB_REVENUE', 'fields.PUB_AD_RPM'].includes(item.key), + ), + timeIntervalOptions: COMMON_TIME_INTERVAL_OPTIONS, + timeIntervalParams: COMMON_SUPPLY_TIME_INTERVAL_PARAMS, + comparisonOptions: [ + ...COMMON_SUPPLY_COMPARISON_OPTIONS, + { + label: 'Player Loads', + value: 'PLAYER_LOADS', + }, + { + label: 'Overall Fill ', + value: 'OVERALL_FILL', + }, + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + }, + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + }, + { + label: 'Player eRPM', + value: 'PLAYER_ERPM', + }, + { + label: 'Gross Player eRPM', + value: 'GROSS_PLAYER_ERPM', + }, + { + label: 'Pub NET Player eRPM', + value: 'PUB_NET_PLAYER_ERPM', + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + }, + ], + comparisonParams: COMMON_COMPARISON_PARAMS, + rowsPerPageOptions: [10, 25, 50, 100, 200], + defaultConfig: { + ...CHART_COMMON_DEFAULT_CONFIG, + sortBy: 'SUPPLY_REQUESTS', + filterByStatus: STATUS_FILTERS[1].value, + }, + totalFields: SUPPLY_SITE_PAGE_TOTAL_FIELDS, + totalQuery: SUPPLY_SITE_PAGE_TOTAL_GRAPHQL_QUERY, + dataFields: SUPPLY_SITE_PAGE_DATA_FIELDS, + dataQuery: SUPPLY_SITE_PAGE_DATA_GRAPHQL_QUERY, + histogramQuery: SUPPLY_SITE_PAGE_HISTOGRAM_GRAPHQL_QUERY, + infoQuery: SUPPLY_SITE_PAGE_INFO_GRAPHQL_QUERY, + showDownloadCSVButton: true, + showPlayerFilter: true, + showCountriesFilter: true, + showDevicesFilter: true, + selfServeConfig: { + infoRows: SUPPLY_SITE_PAGE_INFO_ROWS.filter( + (item) => + ![ + 'PLAYER_ERPM', + 'REVENUE', + 'GROSS_REVENUE', + 'EXPENSES', + 'MEDIA_COST', + 'MEDIA_MARGIN', + 'AC_AD_RPM', + 'CPM', + 'GROSS_PLAYER_ERPM', + 'CTR', + 'COMPLETION_RATE', + 'GROSS_RPM', + ].includes(item.value), + ), + comparisonOptions: [ + { + label: 'Player Loads', + value: 'PLAYER_LOADS', + }, + { + label: 'Overall Fill ', + value: 'OVERALL_FILL', + }, + { + label: 'Player eRPM', + value: 'PUB_PLAYER_ERPM', + }, + { + label: 'Pub NET Player eRPM', + value: 'PUB_NET_PLAYER_ERPM', + }, + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + }, + { + label: 'Pub Revenue', + value: 'PUB_REVENUE', + }, + { + label: 'Pub NET Revenue', + value: 'PUB_NET_REVENUE', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + }, + ], + tableHeaders: SUPPLY_SITE_PAGE_TABLE_HEADERS.filter( + (item) => + ![ + 'PLAYER_ERPM', + 'PRICING', + 'REVENUE', + 'GROSS_REVENUE', + 'GROSS_RPM', + 'MEDIA_COST', + 'CTR', + 'COMPLETION_RATE', + 'CREATED', + 'COPY_ACTION', + ].includes(item.id), + ), + tableCells: SUPPLY_SITE_PAGE_TABLE_CELLS.filter( + (item) => + ![ + 'fields.PLAYER_ERPM', + 'pricing', + 'fields.REVENUE', + 'fields.GROSS_REVENUE', + 'fields.GROSS_RPM', + 'fields.MEDIA_COST', + 'fields.CTR', + 'fields.COMPLETION_RATE', + 'created', + 'copy', + ].includes(item.key), + ), + // defaultConfig for Self Serve MP + defaultConfig: { + comparisonFilter: 'PLAYER_LOADS', + }, + }, +}; diff --git a/anyclip/src/modules/marketplace/account/constants/supplyTagPage.js b/anyclip/src/modules/marketplace/account/constants/supplyTagPage.js new file mode 100644 index 0000000..05f90ae --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/supplyTagPage.js @@ -0,0 +1,1829 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { + CHART_COMMON_DEFAULT_CONFIG, + COMMON_COMPARISON_PARAMS, + COMMON_SUPPLY_COMPARISON_OPTIONS, + COMMON_SUPPLY_TIME_INTERVAL_PARAMS, + COMMON_TIME_INTERVAL_OPTIONS, + LOG_PARAMS, +} from './chart'; +import { STATUS_FILTERS } from './table'; + +export const SUPPLY_TAG_PAGE = 'SUPPLY_TAG_PAGE'; + +export const SUPPLY_TAG_PAGE_PATHNAME = '/tags'; + +export const SUPPLY_TAG_PAGE_PRICE_MODEL = [ + { + label: 'Page Load CPM', + value: 'CPM', + }, + { + label: 'Impressions CPM', + value: 'IMPRESSIONS_CPM', + }, + { + label: 'Rev-Share', + value: 'REV_SHARE', + }, +]; + +export const TIERS = [ + { + label: '0', + value: 0, + }, + { + label: '1', + value: 1, + }, + { + label: '2', + value: 2, + }, + { + label: '3', + value: 3, + }, + { + label: '4', + value: 4, + }, + { + label: '5', + value: 5, + }, + { + label: '6', + value: 6, + }, + { + label: '7', + value: 7, + }, + { + label: '8', + value: 8, + }, + { + label: '9', + value: 9, + }, +]; + +export const SUPPLY_TAG_PAGE_ADD_DEMAND_TAG_MODAL = 'SUPPLY_TAG_PAGE_ADD_DEMAND_TAG_MODAL'; +export const SUPPLY_TAG_PAGE_CHANGE_TIER_MODAL = 'SUPPLY_TAG_PAGE_CHANGE_TIER_MODAL'; + +export const SUPPLY_TAG_PAGE_TABLE_HEADERS = [ + // only for admin + { + id: 'TIER_PRIORITY', + label: 'Tier', + isSortable: true, + }, + // only for admin + { + id: 'priority', + label: 'Priority', + isSortable: false, + }, + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'PRICING', + label: 'Rate', + }, + // only for admin + { + id: 'AD_SERVING_FEES', + isSortable: true, + label: 'S.Fee', + }, + // only for admin + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OPPORTUNITIES', + label: 'Opportunities', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for self serve + { + id: 'PUB_REVENUE', + label: 'Pub Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'PROFIT', + label: 'Profit', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_REVENUE', + label: 'Pub NET Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'ERPM', + label: 'eRPM', + isSortable: true, + }, + { + id: 'PUB_ERPM', + label: 'eRPM', + isSortable: true, + }, + // only for admin + { + id: 'EPPM', + label: 'ePPM', + isSortable: true, + }, + // only for admin + { + id: 'NET_ERPM', + label: 'Pub NET eRPM', + isSortable: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OPPORTUNITIES_FILL', + label: 'Opp Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQ_ERPM', + label: 'Req eRPM', + isSortable: true, + }, + { + id: 'PUB_REQ_ERPM', + label: 'Req eRPM', + isSortable: true, + }, + // only for admin + { + id: 'REQ_EPPM', + label: 'Req ePPM', + isSortable: true, + }, + // only for admin + { + id: 'REQ_NET_ERPM', + label: 'Req Pub NET eRPM', + isSortable: true, + }, + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'targeting', + label: 'Targeting', + isSortable: false, + }, + // only for admin + { + id: 'STATUS', + label: 'Status', + isSortable: false, + }, +]; + +export const SUPPLY_TAG_PAGE_TABLE_HEADERS_FOR_DISPLAY_FORMAT = [ + // only for admin + { + id: 'TIER_PRIORITY', + label: 'Tier', + isSortable: true, + }, + // only for admin + { + id: 'priority', + label: 'Priority', + isSortable: false, + }, + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'PRICING', + label: 'Rate', + }, + // only for admin + { + id: 'AD_SERVING_FEES', + isSortable: true, + label: 'S.Fee', + }, + // only for admin + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for self serve + { + id: 'PUB_REVENUE', + label: 'Pub Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'PROFIT', + label: 'Profit', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_REVENUE', + label: 'Pub NET Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'ACTUAL_RPM', + label: 'Actual RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQ_ERPM', + label: 'Req eRPM', + isSortable: true, + }, + // only for admin + { + id: 'REQ_EPPM', + label: 'Req ePPM', + isSortable: true, + }, + // only for admin + { + id: 'REQ_NET_ERPM', + label: 'Req Pub NET eRPM', + isSortable: true, + }, + { + id: 'targeting', + label: 'Targeting', + isSortable: false, + }, + // only for admin + { + id: 'STATUS', + label: 'Status', + isSortable: false, + }, +]; + +export const SUPPLY_TAG_PAGE_TABLE_CELLS = [ + { + key: 'tier', + }, + { + key: 'priority', + }, + { + key: ['name', 'dtPriority'], + }, + { + key: 'pricing', + prefix: '$', + emptyMask: 'N/A', + }, + { + key: 'adServingFee', + prefix: '$', + emptyMask: 'N/A', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.OPPORTUNITIES', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.PUB_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PROFIT', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.ERPM', + prefix: '$', + }, + { + key: 'fields.PUB_ERPM', + prefix: '$', + }, + { + key: 'fields.EPPM', + prefix: '$', + }, + { + key: 'fields.NET_ERPM', + prefix: '$', + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.OPPORTUNITIES_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.REQ_ERPM', + prefix: '$', + }, + { + key: 'fields.PUB_REQ_ERPM', + prefix: '$', + }, + { + key: 'fields.REQ_EPPM', + prefix: '$', + }, + { + key: 'fields.REQ_NET_ERPM', + prefix: '$', + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: [ + 'include', + 'exclude', + 'frequency', + 'advertiserInclude', + 'advertiserExclude', + 'waterfallSkip', + 'viewabilityThreshold', + 'kvTargeting', + ], + }, + { + key: 'status', + }, +]; + +export const SUPPLY_TAG_PAGE_TABLE_CELLS_FOR_DISPLAY_FORMAT = [ + { + key: 'tier', + }, + { + key: 'priority', + }, + { + key: ['name', 'dtPriority'], + }, + { + key: 'pricing', + prefix: '$', + emptyMask: 'N/A', + }, + { + key: 'adServingFee', + prefix: '$', + emptyMask: 'N/A', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.PUB_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PROFIT', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.ACTUAL_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.REQ_ERPM', + prefix: '$', + }, + { + key: 'fields.REQ_EPPM', + prefix: '$', + }, + { + key: 'fields.REQ_NET_ERPM', + prefix: '$', + }, + { + key: [ + 'include', + 'exclude', + 'frequency', + 'advertiserInclude', + 'advertiserExclude', + 'waterfallSkip', + 'viewabilityThreshold', + 'kvTargeting', + ], + }, + { + key: 'status', + }, +]; + +export const SUPPLY_TAG_PAGE_INFO_ROWS = [ + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + // only for self serve + { + label: 'Pub Revenue', + value: 'PUB_REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + { + label: 'Pub NET Revenue', + value: 'PUB_NET_REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Expenses', + value: 'EXPENSES', + prefix: '$', + }, + // only for admin + { + label: 'Media Cost', + value: 'MEDIA_COST', + prefix: '$', + }, + // only for admin + { + label: 'Media Margin', + value: 'MEDIA_MARGIN', + prefix: '$', + }, + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + prefix: '$', + }, + // only for admin + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + prefix: '$', + }, + { + label: 'CTR', + value: 'CTR', + postfix: '%', + needMultiply: true, + }, + { + label: 'Completion Rate', + value: 'COMPLETION_RATE', + postfix: '%', + needMultiply: true, + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, +]; + +export const SUPPLY_TAG_PAGE_INFO_ROWS_FOR_DISPLAY_FORMAT = [ + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + // only for self serve + { + label: 'Pub Revenue', + value: 'PUB_REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Revenue', + value: 'REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + prefix: '$', + }, + { + label: 'Pub NET Revenue', + value: 'PUB_NET_REVENUE', + prefix: '$', + }, + // only for admin + { + label: 'Expenses', + value: 'EXPENSES', + prefix: '$', + }, + // only for admin + { + label: 'Media Cost', + value: 'MEDIA_COST', + prefix: '$', + }, + // only for admin + { + label: 'Media Margin', + value: 'MEDIA_MARGIN', + prefix: '$', + }, + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + prefix: '$', + }, + // only for admin + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + prefix: '$', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + prefix: '$', + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + postfix: '%', + needMultiply: true, + }, +]; + +export const SUPPLY_TAG_PAGE_TOTAL_FIELDS = [ + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'PUB_REVENUE', + 'MEDIA_COST', + 'AC_AD_RPM', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'SUPPLY_REQUESTS_VIEWABILITY', + 'IMPRESSIONS_VIEWABILITY', + 'MEDIA_MARGIN', + 'CTR', + 'COMPLETION_RATE', + 'EXPENSES', + 'PUB_NET_REVENUE', +]; + +export const SUPPLY_TAG_PAGE_TOTAL_GRAPHQL_QUERY = ` + query SupplyTagDataByIdQuery( + $fields: [String], + $filters: MarketplaceFiltersInputType, + $id: String! + ) { + supplyTagDataById( + fields: $fields, + filters: $filters, + id: $id + ) { + fields { + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + MEDIA_COST { + value + change + } + AC_AD_RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + SUPPLY_REQUESTS_VIEWABILITY { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + MEDIA_MARGIN { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + EXPENSES { + value + change + } + PUB_NET_REVENUE { + value + change + } + } + } + } +`; + +export const SUPPLY_TAG_PAGE_DATA_FIELDS = [ + 'REQUESTS', + 'OPPORTUNITIES', + 'IMPRESSIONS', + 'REVENUE', + 'PUB_REVENUE', + 'GROSS_REVENUE', + 'REQUESTS_FILL', + 'OPPORTUNITIES_FILL', + 'ERPM', + 'PUB_ERPM', + 'PUB_REQ_ERPM', + 'EPPM', + 'NET_ERPM', + 'HB_RATE', + 'ACTUAL_RPM', + 'COMPLETION_RATE', + 'CTR', + 'REQ_ERPM', + 'REQ_EPPM', + 'REQ_NET_ERPM', + 'PROFIT', + 'PUB_NET_REVENUE', +]; + +export const SUPPLY_TAG_PAGE_DATA_GRAPHQL_QUERY = ` + query DemandTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $advertiserTargeting: Boolean + ) { + demandTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + advertiserTargeting: $advertiserTargeting + ) { + totalCount + data { + id + name + tier + priority + aps + status + daccountId + advertiserId + type + waterfallSkip + viewabilityThreshold + profit + pricing { + value + } + adServingFee { + value + } + frequency { + status + value + amount + type + timeframe + } + include { + domains + geo + os + browsers + playerSizes + viewability + devices + } + exclude { + domains + geo + os + browsers + devices + } + advertiserInclude { + geo + } + advertiserExclude { + geo + } + kvTargeting { + state + type + key + values + listNames + keyName + } + dtPriority + fields { + REQUESTS { + value + change + } + OPPORTUNITIES { + value + change + } + IMPRESSIONS { + value + change + } + REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + REQUESTS_FILL { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + ERPM { + value + change + } + NET_ERPM { + value + change + } + EPPM { + value + change + } + ACTUAL_RPM { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + REQ_ERPM { + value + change + } + REQ_EPPM { + value + change + } + REQ_NET_ERPM { + value + change + } + PROFIT { + value + change + } + PUB_NET_REVENUE { + value + change + } + } + } + } + } +`; + +export const SUPPLY_TAG_PAGE_DATA_GRAPHQL_QUERY_SELF_SERVE = ` + query DemandTagDataSelfServeCompressedQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $advertiserTargeting: Boolean + ) { + demandTagDataSelfServeCompressed( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + advertiserTargeting: $advertiserTargeting + ) { + totalCount + data { + id + name + tier + priority + status + daccountId + advertiserId + type + aps + waterfallSkip + viewabilityThreshold + profit + compressed + pricing { + value + } + adServingFee { + value + } + frequency { + status + value + amount + type + timeframe + } + include { + domains + geo + os + browsers + playerSizes + viewability + devices + } + exclude { + domains + geo + os + browsers + devices + } + advertiserInclude { + geo + } + advertiserExclude { + geo + } + kvTargeting { + state + type + key + values + listNames + keyName + } + dtPriority + fields { + REQUESTS { + value + change + } + OPPORTUNITIES { + value + change + } + IMPRESSIONS { + value + change + } + REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + REQUESTS_FILL { + value + change + } + OPPORTUNITIES_FILL { + value + change + } + ERPM { + value + change + } + PUB_ERPM { + value + change + } + NET_ERPM { + value + change + } + EPPM { + value + change + } + ACTUAL_RPM { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + REQ_ERPM { + value + change + } + PUB_REQ_ERPM { + value + change + } + REQ_EPPM { + value + change + } + REQ_NET_ERPM { + value + change + } + PROFIT { + value + change + } + PUB_NET_REVENUE { + value + change + } + } + } + } + } +`; + +export const SUPPLY_TAG_PAGE_HISTOGRAM_GRAPHQL_QUERY = ` + query SupplyTagHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + supplyTagHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + REQUESTS_VIEWABILITY { + value + change + } + SUPPLY_REQUESTS_VIEWABILITY { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + AC_AD_RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + PUB_NET_REVENUE { + value + change + } + } + } + } + } + } +`; + +export const SUPPLY_TAG_PAGE_INFO_GRAPHQL_QUERY = ` + query SupplyTagByIdQuery( + $id: String + ) { + supplyTagById( + id: $id + ) { + id + name + source + platformId + created + updated + updatedBy + accountId + siteId + status + pricing { + model + value + startDate + endDate + } + expenses { + value + startDate + endDate + updatedBy + } + accountName + accountType + siteName + floorPrice { + floor + viewableFloor + firstRequestFloor + } + adServerUrl + waterfallNote + format + publisherDemand + displayType + automaticOptimization { + kpi + period + frequency + hbTiers + kpiGap + } + automaticFloorPrice { + fillRateThreshold + minimumFloor + maximumFloor + } + } + } +`; + +export const SUPPLY_TAG_PAGE_INFO_GRAPHQL_QUERY_SELF_SERVE = ` + query SupplyTagByIdQuery( + $id: String + ) { + supplyTagById( + id: $id + ) { + id + name + source + platformId + created + updated + updatedBy + accountId + siteId + status + accountName + accountType + siteName + adServerUrl + format + publisherDemand + displayType + } + } +`; + +export const SUPPLY_TAG_PAGE_TABS_VAST = [ + { + label: 'Demand', + dataId: 'main', + contentKey: 'main', + }, + { + label: 'Settings', + dataId: 'supplyTagSettings', + contentKey: 'supplyTagSettings', + }, + { + label: 'Optimization', + dataId: 'automaticOptimization', + contentKey: 'automaticOptimization', + }, + { + label: 'Export Tag', + dataId: 'exportSupplyTag', + contentKey: 'exportSupplyTag', + }, +]; + +export const SUPPLY_TAG_PAGE_CONFIG = { + type: SUPPLY_TAG_PAGE, + tabs: [ + { + label: 'Demand', + dataId: 'main', + contentKey: 'main', + }, + { + label: 'Settings', + dataId: 'supplyTagSettings', + contentKey: 'supplyTagSettings', + }, + { + label: 'Optimization', + dataId: 'automaticOptimization', + contentKey: 'automaticOptimization', + }, + ], + infoRows: SUPPLY_TAG_PAGE_INFO_ROWS.filter( + (item) => !['PUB_PLAYER_ERPM', 'PUB_REVENUE', 'PUB_AD_RPM', 'PUB_ERPM', 'PUB_REQ_ERPM'].includes(item.value), + ), + tableSelecting: true, + tableFilters: STATUS_FILTERS, + // todo: replace with flag self-serve + tableHeaders: SUPPLY_TAG_PAGE_TABLE_HEADERS.filter( + (item) => !['PUB_PLAYER_ERPM', 'PUB_REVENUE', 'PUB_AD_RPM', 'PUB_ERPM', 'PUB_REQ_ERPM'].includes(item.id), + ), + tableCells: SUPPLY_TAG_PAGE_TABLE_CELLS.filter( + (item) => + ![ + 'fields.PUB_PLAYER_ERPM', + 'fields.PUB_REVENUE', + 'fields.PUB_AD_RPM', + 'fields.PUB_ERPM', + 'fields.PUB_REQ_ERPM', + ].includes(item.key), + ), + timeIntervalOptions: COMMON_TIME_INTERVAL_OPTIONS, + timeIntervalParams: COMMON_SUPPLY_TIME_INTERVAL_PARAMS, + chartLogParams: LOG_PARAMS, + comparisonOptions: [ + ...COMMON_SUPPLY_COMPARISON_OPTIONS, + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + }, + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + }, + ], + comparisonParams: COMMON_COMPARISON_PARAMS, + setRowBackgroundBy: true, + rowsPerPageOptions: [10, 25, 50, 100, 200], + defaultConfig: { + ...CHART_COMMON_DEFAULT_CONFIG, + rowsPerPage: 50, + sortBy: 'TIER_PRIORITY', + sortOrder: 'ASC', + filterByStatus: STATUS_FILTERS[1].value, + }, + totalFields: SUPPLY_TAG_PAGE_TOTAL_FIELDS, + totalQuery: SUPPLY_TAG_PAGE_TOTAL_GRAPHQL_QUERY, + dataFields: SUPPLY_TAG_PAGE_DATA_FIELDS, + dataQuery: SUPPLY_TAG_PAGE_DATA_GRAPHQL_QUERY, + histogramQuery: SUPPLY_TAG_PAGE_HISTOGRAM_GRAPHQL_QUERY, + infoQuery: SUPPLY_TAG_PAGE_INFO_GRAPHQL_QUERY, + isOpenNewTab: true, + showHistoryCSVButton: true, + selfServeConfig: { + infoQuery: SUPPLY_TAG_PAGE_INFO_GRAPHQL_QUERY_SELF_SERVE, + tabs: [ + { + label: 'Demand', + dataId: 'main', + contentKey: 'main', + }, + { + label: 'Settings', + dataId: 'supplyTagSettings', + contentKey: 'supplyTagSettings', + }, + ], + dataQuery: SUPPLY_TAG_PAGE_DATA_GRAPHQL_QUERY_SELF_SERVE, + infoRows: SUPPLY_TAG_PAGE_INFO_ROWS.filter( + (item) => + ![ + 'PLAYER_ERPM', + 'REVENUE', + 'GROSS_REVENUE', + 'EXPENSES', + 'MEDIA_COST', + 'MEDIA_MARGIN', + 'AC_AD_RPM', + 'GROSS_RPM', + 'ERPM', + 'REQ_ERPM', + ].includes(item.value), + ), + comparisonOptions: [ + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + }, + { + label: 'Pub Revenue', + value: 'PUB_REVENUE', + }, + { + label: 'Pub NET Revenue', + value: 'PUB_NET_REVENUE', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + }, + { + label: 'Request Viewability', + value: 'SUPPLY_REQUESTS_VIEWABILITY', + }, + { + label: 'Impression Viewability', + value: 'IMPRESSIONS_VIEWABILITY', + }, + ], + tableHeaders: SUPPLY_TAG_PAGE_TABLE_HEADERS.filter( + (item) => + ![ + 'PLAYER_ERPM', + 'TIER_PRIORITY', + 'priority', + 'AD_SERVING_FEES', + 'REQUESTS', + 'GROSS_REVENUE', + 'GROSS_RPM', + 'PROFIT', + 'REVENUE', + 'EPPM', + 'NET_ERPM', + 'REQ_EPPM', + 'REQ_NET_ERPM', + 'ACTUAL_RPM', + 'STATUS', + 'ERPM', + 'REQ_ERPM', + ].includes(item.id), + ), + tableCells: SUPPLY_TAG_PAGE_TABLE_CELLS.filter( + (item) => + ![ + 'fields.PLAYER_ERPM', + 'tier', + 'priority', + 'adServingFee', + 'fields.REQUESTS', + 'fields.GROSS_REVENUE', + 'fields.GROSS_RPM', + 'fields.PROFIT', + 'fields.REVENUE', + 'fields.EPPM', + 'fields.NET_ERPM', + 'fields.REQ_EPPM', + 'fields.REQ_NET_ERPM', + 'fields.ACTUAL_RPM', + 'fields.ERPM', + 'fields.REQ_ERPM', + 'status', + ].includes(item.key), + ), + disableDuplicate: true, + // defaultConfig for Self Serve MP + defaultConfig: { + comparisonFilter: 'SUPPLY_REQUESTS', + sortBy: 'PUB_REVENUE', + sortOrder: 'DESC', + }, + }, +}; + +export const AUTOMATIC_OPTIMIZATION = { + enabled: true, + disabled: false, +}; + +export const SUPPLY_TAG_PUBLISHER_DEMAND_ONLY = { + enabled: true, + disabled: false, +}; + +export const AUTOMATIC_OPTIMIZATION_KPI_VIDEO = [ + { + label: 'Pub NET eRPM', + value: 'NET_ERPM', + }, + { + label: 'ePPM', + value: 'EPPM', + }, + { + label: 'Req Fill', + value: 'REQ_FILL', + }, + { + label: 'Opp Fill', + value: 'OPP_FILL', + }, + { + label: 'Req eRPM', + value: 'REQ_ERPM', + }, + { + label: 'Req ePPM', + value: 'REQ_EPPM', + }, + { + label: 'Req Pub NET eRPM', + value: 'REQ_NET_ERPM', + }, +]; + +export const AUTOMATIC_OPTIMIZATION_KPI_DISPLAY = [ + { + label: 'Actual RPM', + value: 'ACTUAL_RPM', + }, + { + label: 'Req Fill', + value: 'REQ_FILL', + }, + { + label: 'Req eRPM', + value: 'REQ_ERPM', + }, + { + label: 'Req ePPM', + value: 'REQ_EPPM', + }, + { + label: 'Req Pub NET eRPM', + value: 'REQ_NET_ERPM', + }, +]; + +export const AUTOMATIC_OPTIMIZATION_PERIOD = [ + { + label: 'Last 15 Minutes', + value: 'MINUTE_15', + }, + { + label: 'Last 30 Minutes', + value: 'MINUTE_30', + }, + { + label: 'Last 60 Minutes', + value: 'MINUTE_60', + }, + { + label: 'Last 120 Minutes', + value: 'MINUTE_120', + }, + { + label: 'Last 180 Minutes', + value: 'MINUTE_180', + }, + { + label: 'Today', + value: 'TODAY', + }, + { + label: 'Last 24 hours', + value: 'HOUR_24', + }, + { + label: 'Last 7 days', + value: 'DAY_7', + }, +]; + +export const AUTOMATIC_OPTIMIZATION_FREQUENCY = [ + { + label: 'Every 2 hours', + value: 'HOUR_2', + }, + { + label: 'Every 4 hours', + value: 'HOUR_4', + }, + { + label: 'Every 8 hours', + value: 'HOUR_8', + }, + { + label: 'Every 16 hours', + value: 'HOUR_16', + }, + { + label: 'Every 24 hours', + value: 'HOUR_24', + }, +]; + +export const AUTOMATIC_OPTIMIZATION_TIERS = [ + { + label: '2', + value: 'TIER_2', + }, + { + label: '3', + value: 'TIER_3', + }, + { + label: '4', + value: 'TIER_4', + }, +]; + +export const SUPPLY_TAG_PRICING_TYPE_PRICING = 'pricing'; + +export const SUPPLY_TAG_PRICING_TYPE_EXPENSES = 'expenses'; + +export const SUPPLY_TAG_PRICING_TYPE = { + pricing: SUPPLY_TAG_PRICING_TYPE_PRICING, + expenses: SUPPLY_TAG_PRICING_TYPE_EXPENSES, +}; + +export const SOURCE_TAG_OPTIONS = [ + { + label: 'Marketplace', + value: 'MARKETPLACE', + }, + { + label: 'Anyclip (TM)', + value: 'ANYCLIP', + }, + { + label: 'Google External', + value: 'GOOGLE_EXTERNAL', + }, + { + label: 'SpringServe', + value: 'SPRINGSERVE', + }, + { + label: 'SpringServe External', + value: 'SPRINGSERVE_EXTERNAL', + }, + { + label: 'XE External', + value: 'XE_EXTERNAL', + }, +]; + +export const DISPLAY_TYPE_OPTIONS = [ + { + label: 'Fallback', + value: 'FALLBACK', + }, + { + label: 'Overlay', + value: 'OVERLAY', + }, +]; diff --git a/anyclip/src/modules/marketplace/account/constants/table.js b/anyclip/src/modules/marketplace/account/constants/table.js new file mode 100644 index 0000000..cc428c3 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/constants/table.js @@ -0,0 +1,24 @@ +export const FILTERS = [ + { label: 'Today', value: 'Today' }, + { label: 'Yesterday', value: 'Yesterday' }, + { label: 'Last 15 min', value: 'Last 15 min' }, + { label: 'Last hour', value: 'last hour' }, + { label: 'Last week', value: 'last week' }, + { label: 'Month to date', value: 'Month to date' }, + { label: 'Last 30 Days', value: 'last 30 Days' }, +]; + +// todo: statuses should not be an object: rewrite to just string const +export const STATUS_ACTIVE = { + label: 'Active', + value: 'ACTIVE', +}; + +const STATUS_DISABLED = { + label: 'Disabled', + value: 'DISABLED', +}; + +export const STATUS_FILTERS = [{ label: 'All', value: 'ALL' }, { ...STATUS_ACTIVE }, { ...STATUS_DISABLED }]; + +export const PAGES_WITH_REFRESH_TABLES = ['DEMAND_TAG_PAGE', 'SUPPLY_TAG_PAGE']; diff --git a/anyclip/src/modules/marketplace/account/helpers/createDemandTagPriceRequestBody.js b/anyclip/src/modules/marketplace/account/helpers/createDemandTagPriceRequestBody.js new file mode 100644 index 0000000..f1b182e --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/createDemandTagPriceRequestBody.js @@ -0,0 +1,62 @@ +import { DEMAND_TAG_PRICING_TYPE } from '@/modules/marketplace/account/constants'; + +import * as selectors from '../redux/selectors'; + +import { createFeesListInput, createPricesListInput } from './createDemandTagRequestBody'; + +export const definePricingTableKey = (type) => { + switch (type) { + case DEMAND_TAG_PRICING_TYPE.price: + return 'fixedRpmRateTable'; + + case DEMAND_TAG_PRICING_TYPE.fee: + return 'additionalFeesTable'; + + case DEMAND_TAG_PRICING_TYPE.adServing: + return 'adServingFeesTable'; + + case DEMAND_TAG_PRICING_TYPE.adRequest: + return 'adRequestFeesTable'; + + default: + return 'fixedRpmRateTable'; + } +}; + +const defineKey = (type) => { + switch (type) { + case DEMAND_TAG_PRICING_TYPE.price: + return 'pricing'; + + case DEMAND_TAG_PRICING_TYPE.fee: + return 'fee'; + + case DEMAND_TAG_PRICING_TYPE.adServing: + return 'adServingFees'; + + case DEMAND_TAG_PRICING_TYPE.adRequest: + return 'adRequest'; + + default: + return 'pricing'; + } +}; + +const createDemandTagUpdatePriceRequestBody = (state, { type, data }) => { + const settings = selectors.settingsSelector(state); + + const key = defineKey(type); + const createInputFunction = + type === DEMAND_TAG_PRICING_TYPE.price || type === DEMAND_TAG_PRICING_TYPE.adServing + ? createPricesListInput + : createFeesListInput; + + const body = { + id: settings.uid, + [key]: createInputFunction([data]), + }; + + return body; +}; + +export default createDemandTagUpdatePriceRequestBody; diff --git a/anyclip/src/modules/marketplace/account/helpers/createDemandTagRequestBody.js b/anyclip/src/modules/marketplace/account/helpers/createDemandTagRequestBody.js new file mode 100644 index 0000000..cb110ca --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/createDemandTagRequestBody.js @@ -0,0 +1,370 @@ +import { + DEMAND_TAG_FORMAT, + DEMAND_TAG_HB_GAM_PLATFORMS, + DEMAND_TAG_KEY_VALUE_TARGETING_STATUS, + DEMAND_TAG_KEY_VALUE_TARGETING_TYPE, + DEMAND_TAG_PAGE_BUSINESS_MODEL, + DEMAND_TAG_PAGE_SOURCE, + DEMAND_TAG_PAGE_TYPE_VALUES, + DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE, + DEMAND_TAG_SUPPLY_CHAIN_VALUE, + DEMAND_TAG_TARGETING_VIEWABILITY_TYPE, + DEMAND_TAG_VIEWABILITY_TARGETING, + FLOOR_PRICE, + SOURCE_TAG_OPTIONS, +} from '@/modules/marketplace/account/constants'; + +import * as selectors from '../redux/selectors'; + +import { isFrequencyCapHasData } from './isFrequencyCapHasData'; + +export const getIncludeOrExclude = (isInclude, items) => + items.filter((item) => item.include === isInclude).map((item) => item.value); + +const getFromObjectKeysWhenTrue = (object) => Object.keys(object).filter((key) => object[key]); + +const createAdServerInput = ({ id, name, url }) => { + const res = { url }; + + if (name) { + res.name = name; + } + + if (id) { + res.id = id; + } + + return res; +}; + +export const createIncludeExcludeInput = (objectItems) => { + let res = null; + + Object.keys(objectItems).forEach((objectItem) => { + const item = objectItems[objectItem]; + if (item.value) { + res = !res ? { [item.key]: item.value } : { ...res, [item.key]: item.value }; + } + }); + + return res; +}; + +const createEventPixelsListInput = (eventPixelsList) => + eventPixelsList.map((pixel) => ({ + url: pixel.url, + name: pixel.event, + type: pixel.type, + })); + +export const createPricesListInput = (pricesList, isCreatingNew) => + pricesList.map((price) => ({ + ...(isCreatingNew ? {} : { startDate: price.startDate }), + value: price.value, + ...(price.model ? { model: price.model } : {}), + })); + +export const createFeesListInput = (pricesList, isCreatingNew) => + pricesList.map((price) => ({ + ...(isCreatingNew ? {} : { startDate: price.startDate }), + ...(price.endDate ? { endDate: price.endDate } : {}), + value: price.value, + })); + +const createBudgetsListInput = (budgetsList) => + budgetsList.map((budget) => ({ + budget: budget.budget, + pacing: budget.pacing, + type: budget.type, + timeframe: budget.timeFrame, + })); + +const createDemandTagRequestBody = (state) => { + const pageConfig = selectors.pageConfigSelector(state); + const info = selectors.infoSelector(state); + const settings = selectors.settingsSelector(state); + const pricing = selectors.pricingSelector(state); + const targeting = selectors.targetingSelector(state); + const budgeting = selectors.budgetingSelector(state); + const frequencyCap = selectors.frequencyCapSelector(state); + + const body = { + name: settings.name, + tagSource: + settings.source !== SOURCE_TAG_OPTIONS[0].value + ? { + source: settings.source, + platformId: settings.platformId, + } + : { + source: settings.source, + }, + daccountId: info ? info.daccountId : pageConfig.demandAccount.id, + advertiserId: info ? info.advertiserId : pageConfig.advertiser.id, + defaultTier: settings.defaultTier, + model: pricing.model, + pricing: + pageConfig.isCreatingNew || pageConfig.isDuplicate + ? createPricesListInput(pricing.fixedRpmRateTable.rows, pageConfig.isCreatingNew) + : [], + fee: + pageConfig.isCreatingNew || pageConfig.isDuplicate + ? createFeesListInput(pricing.additionalFeesTable.rows, pageConfig.isCreatingNew) + : [], + adServingFees: + pageConfig.isCreatingNew || pageConfig.isDuplicate + ? createPricesListInput(pricing.adServingFeesTable.rows, pageConfig.isCreatingNew) + : [], + adRequestFee: + pageConfig.isCreatingNew || pageConfig.isDuplicate + ? createPricesListInput(pricing.adRequestFeesTable.rows, pageConfig.isCreatingNew) + : [], + type: settings.type, + status: settings.status, + format: settings.format, + eventPixels: [], + labels: [], + flightsDates: settings.flightsDates || {}, + priority: settings.priority, + }; + + if (settings.uid) { + body.id = settings.uid; + } + + if (settings.labels && settings.labels.length) { + body.labels = settings.labels; + } + + if (Number.isInteger(settings.timeout)) { + body.timeout = settings.timeout; + } + + if (settings.eventPixelsList && settings.eventPixelsList.length) { + body.eventPixels = createEventPixelsListInput(settings.eventPixelsList); + } + + // include + const includes = { + geo: { + key: 'geo', + value: getIncludeOrExclude(true, targeting.countries), + }, + os: { + key: 'os', + value: getIncludeOrExclude(true, targeting.os), + }, + browsers: { + key: 'browsers', + value: getIncludeOrExclude(true, targeting.browsers), + }, + devices: { + key: 'devices', + value: getIncludeOrExclude(true, targeting.devices), + }, + playerSizes: { + key: 'playerSizes', + value: getFromObjectKeysWhenTrue(targeting.playerSize), + }, + viewability: { + key: 'viewability', + value: getFromObjectKeysWhenTrue(targeting.viewability), + }, + }; + + const includesSponsored = { + geo: { + key: 'geo', + value: getIncludeOrExclude(true, targeting.countries), + }, + os: { + key: 'os', + value: getIncludeOrExclude(true, targeting.os), + }, + browsers: { + key: 'browsers', + value: getIncludeOrExclude(true, targeting.browsers), + }, + devices: { + key: 'devices', + value: getIncludeOrExclude(true, targeting.devices), + }, + }; + + const includeObject = createIncludeExcludeInput( + settings.format === DEMAND_TAG_FORMAT.sponsored ? includesSponsored : includes, + ); + + if (includeObject) { + body.include = includeObject; + } + + // exclude + const excludes = { + geo: { + key: 'geo', + value: getIncludeOrExclude(false, targeting.countries), + }, + os: { + key: 'os', + value: getIncludeOrExclude(false, targeting.os), + }, + browsers: { + key: 'browsers', + value: getIncludeOrExclude(false, targeting.browsers), + }, + devices: { + key: 'devices', + value: getIncludeOrExclude(false, targeting.devices), + }, + }; + + const excludeObject = createIncludeExcludeInput(excludes); + + if (excludeObject) { + body.exclude = excludeObject; + } + + body.budgets = budgeting.budgetingTabList?.length ? createBudgetsListInput(budgeting.budgetingTabList) : []; + + if (isFrequencyCapHasData(frequencyCap) && settings.format !== DEMAND_TAG_FORMAT.sponsored) { + const { prevSavedData, ...frequencyCapData } = frequencyCap; + body.frequency = { ...frequencyCapData }; + } + + if (settings.type === DEMAND_TAG_PAGE_TYPE_VALUES.tag) { + body.adServer = createAdServerInput({ + name: settings.adServer.name, // optional + url: settings.url?.trim(), + }); + } + + if (settings.type === DEMAND_TAG_PAGE_TYPE_VALUES.mp4) { + body.videoId = settings.videoId; + + if (settings.format !== DEMAND_TAG_FORMAT.sponsored) { + body.clickThroughUrl = settings.clickThroughUrl; + } + } + + if (settings.type === DEMAND_TAG_PAGE_TYPE_VALUES.hb) { + const { + platform: { name, connectorId, code, ...params }, + supplyChainOverride, + supplyChainValue, + supplyChainNode, + adjustFloor, + } = settings; + + body.adjustFloor = adjustFloor; + + body.platform = { + // name: settings.platform.name, + connectorId, + connectorName: name, + code, + params: Object.keys(params).reduce((acc, cur) => { + if (params[cur]?.length) { + return [...acc, { name: cur, value: params[cur] }]; + } + return acc; + }, []), + }; + + if (settings.platform?.code?.toUpperCase() === 'APS') { + body.platform = { + ...body.platform, + bidMapping: { + fileName: settings.bidMappingFileName, + data: settings.bidMappingFileData, + }, + }; + } + + if (DEMAND_TAG_HB_GAM_PLATFORMS.includes(settings.platform?.code)) { + body.platform = { + ...body.platform, + maxFloor: +settings.maxFloor, + }; + } + + if (supplyChainOverride === DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled) { + if (supplyChainValue === DEMAND_TAG_SUPPLY_CHAIN_VALUE.blank) { + body.platform = { + ...body.platform, + supplyChain: '', + }; + } + + if (supplyChainValue === DEMAND_TAG_SUPPLY_CHAIN_VALUE.custom) { + body.platform = { + ...body.platform, + supplyChain: supplyChainNode.trim(), + }; + } + } + + if (settings.floorPrice?.override === FLOOR_PRICE.override) { + body.floorPrice = { + override: true, + floor: +(settings.floorPrice?.floor ?? 0), + viewableFloor: +(settings.floorPrice?.viewableFloor ?? 0), + }; + } else { + body.floorPrice = { + override: false, + }; + } + } + + if (pricing.model === DEMAND_TAG_PAGE_BUSINESS_MODEL[1].value) { + const { source, seatId, adUnitId, type: pricingType } = pricing; + + if (source === DEMAND_TAG_PAGE_SOURCE[0].value) { + body.rateSource = { + name: source, + seatId, + adUnitId, + type: pricingType, + }; + } else { + body.rateSource = { + name: source, + adUnitId, + type: pricingType, + }; + } + } + + if ( + targeting.viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView] && + targeting.viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView] && + targeting.viewabilityTargeting === DEMAND_TAG_VIEWABILITY_TARGETING.enabled + ) { + body.viewabilityThreshold = parseFloat(Number(targeting.viewabilityThreshold / 100).toFixed(4)); + } else { + body.disableViewabilityThreshold = true; + } + + if (settings.format !== DEMAND_TAG_FORMAT.sponsored) { + if (targeting.kvTargetingStatus === DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled) { + body.kvTargeting = targeting.kvTargeting + ?.filter((item) => item?.keyName) + .map((item) => ({ + key: item.keyName.value, + state: item.state, + type: item.type, + values: + item.type === DEMAND_TAG_KEY_VALUE_TARGETING_TYPE.list + ? item.valueLists?.map((list) => list.value) + : item.values, + })); + } else { + body.kvTargeting = []; + } + } + + return body; +}; + +export default createDemandTagRequestBody; diff --git a/anyclip/src/modules/marketplace/account/helpers/createHistoryCSV.js b/anyclip/src/modules/marketplace/account/helpers/createHistoryCSV.js new file mode 100644 index 0000000..5e24107 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/createHistoryCSV.js @@ -0,0 +1,94 @@ +import dayjs from 'dayjs'; + +import { SUPPLY_TAG_PAGE } from '@/modules/marketplace/account/constants'; + +const HEADERS = ['ID', 'Time', 'User', 'Object', 'Supply ID', 'Demand ID', 'Action', 'Old value', 'New Value']; + +const findSupplyId = (type, ids) => { + if (type === SUPPLY_TAG_PAGE) { + return ids?.length > 1 ? ids[1] : ids[0]; + } + + return ids[1] ?? 'N/A'; +}; + +const findDemandId = (type, ids) => { + if (type === SUPPLY_TAG_PAGE) { + return ids?.length === 1 ? 'N/A' : ids[0]; + } + + return ids[0]; +}; + +export const createHistoryCSV = ({ pageConfig, data, userTimezone }) => { + let csvContent = 'data:text/csv;charset=utf-8,%EF%BB%BF'; + const fileName = pageConfig.breadcrumbs[pageConfig.breadcrumbs.length - 1]?.label ?? 'file'; + + const title = HEADERS.join(','); + + const values = data?.length + ? data + .reduce((acc, cur) => { + if (cur.values?.length) { + cur.values.forEach((item) => { + const time = dayjs(cur.created).tz(userTimezone).format('DD/MM/YYYY HH:mm'); + const object = pageConfig.type === SUPPLY_TAG_PAGE ? 'Supply Tag' : 'Demand Tag'; + const supplyId = findSupplyId(pageConfig.type, cur.ids); + const demandId = findDemandId(pageConfig.type, cur.ids); + + if (item.changes?.length > 1) { + item.changes.forEach((change) => { + const result = [ + cur.id ?? '', + time, + cur.user?.email, + object, + supplyId, + demandId, + `${item.name}${item.actionWithName ? ` (${change.name})` : ''}`, + change.oldValue ?? '', + change.newValue ?? '', + ] + .map((value) => `"${value ? value.toString()?.replace(/"/g, "'") : ''}"`) + .join(','); + + acc.push(result); + }); + return; + } + const change = item.changes?.[0]; + const result = [ + cur.id, + time, + cur.user?.email, + object, + supplyId, + demandId, + `${item.name}${item.actionWithName ? ` (${change?.name ?? ''})` : ''}`, + change?.oldValue ?? '', + change?.newValue ?? '', + ] + .map((value) => `"${value ? value.toString()?.replace(/"/g, "'") : ''}"`) + .join(','); + + acc.push(result); + }); + } + return acc; + }, []) + .join('\r\n') + : ''; + + csvContent += encodeURIComponent(`${title}\r\n${values}`); + + const link = document.createElement('a'); + link.setAttribute('href', csvContent); + link.setAttribute('download', fileName.replace(/\./g, '%2E')); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + + link.click(); + document.body.removeChild(link); +}; + +export default createHistoryCSV; diff --git a/anyclip/src/modules/marketplace/account/helpers/createSupplyTagPriceRequestBody.js b/anyclip/src/modules/marketplace/account/helpers/createSupplyTagPriceRequestBody.js new file mode 100644 index 0000000..0f537dd --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/createSupplyTagPriceRequestBody.js @@ -0,0 +1,17 @@ +import * as selectors from '../redux/selectors'; + +import { createSupplyTagPricingInput } from './createSupplyTagRequestBody'; + +const createSupplyTagPriceRequestBody = (state, pricing, key = 'pricing') => { + const pageConfig = selectors.pageConfigSelector(state); + + const [price] = createSupplyTagPricingInput([pricing]); + const body = { + id: pageConfig.id, + [key]: price, + }; + + return body; +}; + +export default createSupplyTagPriceRequestBody; diff --git a/anyclip/src/modules/marketplace/account/helpers/createSupplyTagRequestBody.js b/anyclip/src/modules/marketplace/account/helpers/createSupplyTagRequestBody.js new file mode 100644 index 0000000..cffacc8 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/createSupplyTagRequestBody.js @@ -0,0 +1,131 @@ +import { AUTOMATIC_OPTIMIZATION, DEMAND_TAG_FORMAT, SOURCE_TAG_OPTIONS } from '@/modules/marketplace/account/constants'; + +import * as selectors from '../redux/selectors'; + +export const createSupplyTagPricingInput = (pricing) => + pricing.map((item) => { + let price = { + startDate: item.startDate, + endDate: item.endDate, + }; + + if (item.model) { + price = { + ...price, + model: item.model, + }; + } + + if (item.payment) { + price = { + ...price, + value: item.payment, + }; + } else { + price = { + ...price, + value: item.value, + }; + } + + return price; + }); + +const createSupplyTagRequestBody = (state, isAdminMP = true) => { + const pageConfig = selectors.pageConfigSelector(state); + const info = selectors.infoSelector(state); + const automaticOptimization = selectors.automaticOptimizationSelector(state); + const supply = selectors.supplySelector(state); + + const { id, supplyAccount, site, isCreatingNew, isDuplicate } = pageConfig; + + const { status: optimizationStatus, errors, ...restOptimization } = automaticOptimization; + + if (!isAdminMP) { + const body = { + name: supply.name, + accountId: supplyAccount.id, + siteId: site.id, + publisherDemand: supply.publisherDemandOnly, + }; + + if (id && !isCreatingNew) { + body.id = id; + } + + if (supplyAccount.accountType !== 'VAST' && info?.accountType !== 'VAST') { + body.format = supply.format; + } + + return body; + } + + const body = { + name: supply.name, + accountId: supplyAccount.id, + siteId: site.id, + waterfallNote: supply.waterfallNote, + publisherDemand: supply.publisherDemandOnly, + tagSource: + supply.source !== SOURCE_TAG_OPTIONS[0].value + ? { + source: supply.source, + platformId: supply.platformId, + } + : { + source: supply.source, + }, + }; + + if (supply.format !== DEMAND_TAG_FORMAT.sponsored) { + body.floorPrice = { + floor: +supply.floorPrice.floor, + viewableFloor: +supply.floorPrice.viewableFloor, + firstRequestFloor: +supply.floorPrice.firstRequestFloor, + }; + } + + if (isCreatingNew || isDuplicate) { + const pricing = createSupplyTagPricingInput(supply.pricing); + body.pricing = isCreatingNew ? pricing[0] : pricing; + + if (supply.expenses?.length) { + const expenses = createSupplyTagPricingInput(supply.expenses); + body.expenses = isCreatingNew ? expenses[0] : expenses; + } + } + + if (supplyAccount.accountType !== 'VAST' && info?.accountType !== 'VAST') { + body.format = supply.format; + } + + if (supply.format === DEMAND_TAG_FORMAT.display) { + body.displayType = supply.displayType; + } + + if (id && !isCreatingNew) { + body.id = id; + } + + if (supply.automaticFloorPrice) { + body.automaticFloorPrice = { + fillRateThreshold: +supply.fillRateThreshold, + minimumFloor: +supply.minimumFloor, + maximumFloor: +supply.maximumFloor, + }; + } else if (!supply.automaticFloorPrice && !isCreatingNew && !isDuplicate) { + body.automaticFloorPrice = {}; + } + + if (optimizationStatus === AUTOMATIC_OPTIMIZATION.enabled) { + body.automaticOptimization = { + ...restOptimization, + }; + } else if (optimizationStatus === AUTOMATIC_OPTIMIZATION.disabled && !isCreatingNew && !isDuplicate) { + body.automaticOptimization = {}; + } + + return body; +}; + +export default createSupplyTagRequestBody; diff --git a/anyclip/src/modules/marketplace/account/helpers/demandTabs.js b/anyclip/src/modules/marketplace/account/helpers/demandTabs.js new file mode 100644 index 0000000..43d2b42 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/demandTabs.js @@ -0,0 +1,2 @@ +export const getLabelByValue = (data, value) => data.find((i) => i.value === value)?.label; +export const getValueByLabel = (data, label) => data.find((i) => i.label === label)?.value; diff --git a/anyclip/src/modules/marketplace/account/helpers/demandTagFormValidationRules.js b/anyclip/src/modules/marketplace/account/helpers/demandTagFormValidationRules.js new file mode 100644 index 0000000..94fdd0b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/demandTagFormValidationRules.js @@ -0,0 +1,13 @@ +const validateTimeout = (value) => { + const min = 1000; + const max = 300000; + + const isValid = +value ? +value >= min && +value <= max : false; + + return { + isValid, + error: !isValid ? `Accept from ${min} to ${max}` : '', + }; +}; + +export default { validateTimeout }; diff --git a/anyclip/src/modules/marketplace/account/helpers/filters.js b/anyclip/src/modules/marketplace/account/helpers/filters.js new file mode 100644 index 0000000..356dc7d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/filters.js @@ -0,0 +1,71 @@ +import { + DEMAND_ACCOUNT_PAGE, + DEMAND_ADVERTISER_PAGE, + DEMAND_TAG_PAGE, + SUPPLY_ACCOUNT_PAGE, + SUPPLY_SITE_PAGE, + SUPPLY_TAG_PAGE, +} from '../constants'; + +export const addParamsToFilters = (params, newParams) => { + if (Array.isArray(params.filters)) { + return { + ...params, + filters: params.filters.map((filter) => ({ + ...filter, + ...newParams, + })), + }; + } + + return { + ...params, + filters: { + ...params.filters, + ...newParams, + }, + }; +}; + +export const addPageParams = (mainParams, pageConfig, isAdminMP = true, isDataTableRequest) => { + if (pageConfig.type === SUPPLY_ACCOUNT_PAGE) { + return addParamsToFilters(mainParams, { accountIds: [{ value: pageConfig.id }] }); + } + + if (pageConfig.type === SUPPLY_SITE_PAGE) { + return addParamsToFilters(mainParams, { siteId: pageConfig.id }); + } + + if (pageConfig.type === SUPPLY_TAG_PAGE) { + if (!isAdminMP && isDataTableRequest) { + return { + ...addParamsToFilters(mainParams, { ssSupplyId: pageConfig.id }), + advertiserTargeting: true, + }; + } + + return { + ...addParamsToFilters(mainParams, { supplyId: { value: pageConfig.id } }), + advertiserTargeting: true, + }; + } + + if (pageConfig.type === DEMAND_ACCOUNT_PAGE) { + return addParamsToFilters(mainParams, { daccountIds: [{ value: pageConfig.id }] }); + } + + if (pageConfig.type === DEMAND_ADVERTISER_PAGE) { + return { + ...addParamsToFilters(mainParams, { advertiserId: pageConfig.id }), + advertiserTargeting: true, + }; + } + + if (pageConfig.type === DEMAND_TAG_PAGE) { + return addParamsToFilters(mainParams, { demandId: { value: pageConfig.id } }); + } + + return mainParams; +}; + +export default addParamsToFilters; diff --git a/anyclip/src/modules/marketplace/account/helpers/getCurrentAndFuturePrice.js b/anyclip/src/modules/marketplace/account/helpers/getCurrentAndFuturePrice.js new file mode 100644 index 0000000..4d9ef2a --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/getCurrentAndFuturePrice.js @@ -0,0 +1,26 @@ +import dayjs from 'dayjs'; +import isSameOrBeforePlugin from 'dayjs/plugin/isSameOrBefore'; + +dayjs.extend(isSameOrBeforePlugin); + +const getCurrentAndFuturePrice = (prices) => { + const isFutureDate = (startDate) => dayjs(startDate).isAfter(new Date().getTime()); + const isPastDate = (startDate) => dayjs(startDate).isSameOrBefore(new Date().getTime()); + const result = []; + + for (let i = 0; i < prices.length; i += 1) { + const price = prices[i]; + + if (isFutureDate(price.startDate) && (!price.endDate || isFutureDate(price.endDate))) { + result.push(price); + } + if (isPastDate(price.startDate) && (!price.endDate || isFutureDate(price.endDate))) { + result.push(price); + break; + } + } + + return result; +}; + +export default getCurrentAndFuturePrice; diff --git a/anyclip/src/modules/marketplace/account/helpers/isFrequencyCapHasData.js b/anyclip/src/modules/marketplace/account/helpers/isFrequencyCapHasData.js new file mode 100644 index 0000000..5b4a6c4 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/isFrequencyCapHasData.js @@ -0,0 +1,4 @@ +export const isFrequencyCapHasData = (frequencyCap) => + frequencyCap.type && frequencyCap.value && frequencyCap.timeframe && frequencyCap.amount; + +export default isFrequencyCapHasData; diff --git a/anyclip/src/modules/marketplace/account/helpers/useLocalPagination.js b/anyclip/src/modules/marketplace/account/helpers/useLocalPagination.js new file mode 100644 index 0000000..76fbe61 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/useLocalPagination.js @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +const getStartIndex = (itemsPerPage, currentPage) => itemsPerPage * (currentPage - 1); +const getEndIndex = (itemsPerPage, currentPage) => itemsPerPage * currentPage; + +// usage on data rows.slice(startIndex, endIndex) +const useLocalPagination = (itemsPerPage, currentPage = 1) => { + const [currentPage$, setCurrentPage] = useState(currentPage); + const [itemsPerPage$, setItemsPerPage] = useState(itemsPerPage); + const [startIndex$, setStartIndex] = useState(getStartIndex(itemsPerPage, currentPage)); + const [endIndex$, setEndIndex] = useState(getEndIndex(itemsPerPage, currentPage)); + + useEffect(() => { + setStartIndex(getStartIndex(itemsPerPage$, currentPage$)); + setEndIndex(getEndIndex(itemsPerPage$, currentPage$)); + }, [itemsPerPage$, currentPage$]); + + return { + startIndex: startIndex$, + endIndex: endIndex$, + currentPage: currentPage$, + itemsPerPage: itemsPerPage$, + setCurrentPage, + setItemsPerPage, + }; +}; + +export default useLocalPagination; diff --git a/anyclip/src/modules/marketplace/account/helpers/validate.js b/anyclip/src/modules/marketplace/account/helpers/validate.js new file mode 100644 index 0000000..19d239b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/helpers/validate.js @@ -0,0 +1,17 @@ +export const validateTextfieldDigits = ({ value, min, max }) => { + const newValue = parseInt(value.replace(/\D/g, ''), 10); + + if (Number.isNaN(newValue)) { + return ''; + } + + if (newValue >= min && newValue <= max) { + return newValue; + } + + return null; +}; + +export default validateTextfieldDigits; + +export const integerFieldBlockInvalidChar = (e) => ['e', 'E', '+', '-', '.'].includes(e.key) && e.preventDefault(); diff --git a/anyclip/src/modules/marketplace/account/index.jsx b/anyclip/src/modules/marketplace/account/index.jsx new file mode 100644 index 0000000..eb5b72d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/index.jsx @@ -0,0 +1,3 @@ +import Account from './components'; + +export default Account; diff --git a/anyclip/src/modules/marketplace/account/redux/epics/bulkCreateWatefall.js b/anyclip/src/modules/marketplace/account/redux/epics/bulkCreateWatefall.js new file mode 100644 index 0000000..efbe90b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/bulkCreateWatefall.js @@ -0,0 +1,83 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { DEMAND_TAG_PAGE, SUPPLY_TAG_PAGE } from '../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import * as selectors from '../selectors'; +import { bulkCreateWaterfallAction, getDataAction, setModalInfoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation BulkCreateWaterfall( + $waterfall: [MarketplaceWaterfallInputType]! + ) { + bulkCreateWaterfall( + waterfall: $waterfall + ) { + data { + id + created + updated + supplyId + demandId + tier + priority + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(bulkCreateWaterfallAction.type), + filter((action) => action.payload?.selected?.length), + switchMap(({ payload: { selected } }) => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + let waterfall = []; + + if (pageConfig.type === SUPPLY_TAG_PAGE) { + waterfall = selected.map((id) => ({ supplyId: pageConfig.id, demandId: id })); + } + + if (pageConfig.type === DEMAND_TAG_PAGE) { + waterfall = selected.map((id) => ({ supplyId: id, demandId: pageConfig.id })); + } + + const stream$ = gqlRequest({ + query, + variables: { + waterfall, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Tags were added', + }), + ), + of(getDataAction()), + ]; + } + + return concat(...actions); + }), + ); + + return concat( + of(setModalInfoAction({ isFetchingData: true })), + stream$, + of(setModalInfoAction({ isFetchingData: false })), + ); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateDemandTag.js b/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateDemandTag.js new file mode 100644 index 0000000..81de31b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateDemandTag.js @@ -0,0 +1,47 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { bulkUpdateDemandTagAction, getDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation BulkUpdateDemandTag( + $tags: [MarketplaceDemandTagInputType] + ) { + bulkUpdateDemandTag( + tags: $tags + ) { + data { + id + name + status + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(bulkUpdateDemandTagAction.type), + switchMap(({ payload: { tags } }) => { + const stream$ = gqlRequest({ + query, + variables: { + tags, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(getDataAction())]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateSupplyTag.js b/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateSupplyTag.js new file mode 100644 index 0000000..6eebf4f --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateSupplyTag.js @@ -0,0 +1,46 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { bulkUpdateSupplyTagAction, getDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UpdateSupplyTag( + $tags: [MarketplaceSupplyTagInputType] + ) { + updateSupplyTag( + tags: $tags + ) { + data { + id + status + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(bulkUpdateSupplyTagAction.type), + switchMap(({ payload: { tags } }) => { + const stream$ = gqlRequest({ + query, + variables: { + tags, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(getDataAction())]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateViewabilityThreshold.js b/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateViewabilityThreshold.js new file mode 100644 index 0000000..aa6a585 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/bulkUpdateViewabilityThreshold.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { bulkUpdateViewabilityThresholdAction, getDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation BulkUpdateViewabilityThreshold( + $viewabilityThreshold: Float, + $ids: [String] + ) { + bulkUpdateViewabilityThreshold( + viewabilityThreshold: $viewabilityThreshold, + ids: $ids + ) { + data { + id + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(bulkUpdateViewabilityThresholdAction.type), + switchMap(({ payload: { ids, viewabilityThreshold } }) => { + const stream$ = gqlRequest({ + query, + variables: { + viewabilityThreshold, + ids, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(getDataAction())]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/createAdvertiser.js b/anyclip/src/modules/marketplace/account/redux/epics/createAdvertiser.js new file mode 100644 index 0000000..84023f9 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/createAdvertiser.js @@ -0,0 +1,139 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { + createFeesListInput, + createIncludeExcludeInput, + getIncludeOrExclude, +} from '../../helpers/createDemandTagRequestBody'; +import * as selectors from '../selectors'; +import { createAdvertiserAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { uploadFileAndGetDownloadUrl$ } from '@/modules/marketplace/common/helpers/uploadToS3'; + +const query = ` + mutation CreateSelfServeAdvertiser( + $name: String, + $daccountId: String, + $include: MarketplaceTargetingInputType, + $exclude: MarketplaceTargetingInputType, + $revShare: MarketplacePricingInputType, + $tier: Int, + $profitability: Boolean, + $frequencyCapAdjustment: Boolean, + $frequencyCapAdjustmentPerSupply: Boolean, + $pbsEnabled: Boolean, + $frequencyCapAdjustmentThreshold: Float, + $logo: String, + ) { + createSelfServeAdvertiser( + name: $name, + daccountId: $daccountId, + include: $include, + exclude: $exclude, + revShare: $revShare, + tier: $tier, + profitability: $profitability, + frequencyCapAdjustment: $frequencyCapAdjustment, + pbsEnabled: $pbsEnabled, + frequencyCapAdjustmentPerSupply: $frequencyCapAdjustmentPerSupply, + frequencyCapAdjustmentThreshold: $frequencyCapAdjustmentThreshold, + logo: $logo, + ) { + id + name + } + } +`; + +const createIncludeExcludeGeoObject = (countriesList, isInclude = true) => + createIncludeExcludeInput({ + geo: { + key: 'geo', + value: getIncludeOrExclude(isInclude, countriesList), + }, + }); + +export default (action$, state$) => + action$.pipe( + ofType(createAdvertiserAction.type), + switchMap(() => { + const state = state$.value; + + const advertiserSettings = selectors.advertiserSettingsSelector(state); + const userPermissions = getUserPermissionsSelector(state); + + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const { + demandAccountId, + name, + countries, + tier, + pbsEnabled, + profitability, + revShare, + frequencyCapAdjustment, + frequencyCapAdjustmentPerSupply, + frequencyCapAdjustmentThreshold, + logo, + logoFile, + } = advertiserSettings; + + const createAdvertiser$ = (logoUrl) => + gqlRequest({ + query, + variables: { + name, + pbsEnabled, + frequencyCapAdjustment, + daccountId: demandAccountId?.toString(), + logo: logoUrl ?? logo, + include: createIncludeExcludeGeoObject(countries, true), + exclude: createIncludeExcludeGeoObject(countries, false), + ...(isAdminMP && { tier, profitability }), + ...(revShare?.length && { + revShare: createFeesListInput(revShare)[0], + }), + ...(frequencyCapAdjustment && { frequencyCapAdjustmentThreshold, frequencyCapAdjustmentPerSupply }), + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + const link = `/demand/${demandAccountId}/advertisers/${data.createSelfServeAdvertiser?.id}`; + + Router.push(link); + + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Advertiser created', + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + const stream$ = !logoFile + ? createAdvertiser$(null) + : uploadFileAndGetDownloadUrl$({ file: logoFile }).pipe( + switchMap((downloadUrl) => createAdvertiser$(downloadUrl)), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/createDemandTag.js b/anyclip/src/modules/marketplace/account/redux/epics/createDemandTag.js new file mode 100644 index 0000000..4043109 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/createDemandTag.js @@ -0,0 +1,67 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createDemandTagRequestBody from '../../helpers/createDemandTagRequestBody'; +import * as selectors from '../selectors'; +import { createDemandTagAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation CreateDemandTag( + $tag: MarketplaceDemandTagInputType + ) { + createDemandTag( + tag: $tag + ) { + id + name + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createDemandTagAction.type), + switchMap(() => { + const state = state$.value; + const tag = createDemandTagRequestBody(state); + const pageConfig = selectors.pageConfigSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + tag, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + let link = `/demand/${pageConfig.demandAccount.id}/advertisers/${pageConfig.advertiser.id}/tags/${data.createDemandTag.id}`; + + link += `?accountName=${encodeURIComponent(pageConfig.demandAccount.name)}&advertiserName=${encodeURIComponent(pageConfig.advertiser.name)}&tagName=${encodeURIComponent(data.createDemandTag.name)}`; + + Router.push(link); + + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Demand tag created', + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/createSupplyTag.js b/anyclip/src/modules/marketplace/account/redux/epics/createSupplyTag.js new file mode 100644 index 0000000..aeb4799 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/createSupplyTag.js @@ -0,0 +1,67 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createSupplyTagRequestBody from '../../helpers/createSupplyTagRequestBody'; +import * as selectors from '../selectors'; +import { createSupplyTagAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation CreateSupplyTag( + $tag: MarketplaceSupplyTagInputType + ) { + createSupplyTag( + tag: $tag + ) { + id + name + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createSupplyTagAction.type), + switchMap(() => { + const state = state$.value; + const tag = createSupplyTagRequestBody(state); + const pageConfig = selectors.pageConfigSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + tag, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + let link = `/supply/${pageConfig.supplyAccount.id}/sites/${pageConfig.site.id}/tags/${data.createSupplyTag.id}`; + + link += `?accountName=${encodeURIComponent(pageConfig.supplyAccount.name)}&siteName=${encodeURIComponent(pageConfig.site.name)}&tagName=${encodeURIComponent(data.createSupplyTag.name)}`; + + Router.push(link); + + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Supply tag created', + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/deleteWaterfall.js b/anyclip/src/modules/marketplace/account/redux/epics/deleteWaterfall.js new file mode 100644 index 0000000..523d232 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/deleteWaterfall.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { deleteWaterfallAction, getDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation DeleteWaterfall( + $supplyIds: [String]!, + $demandIds: [String]! + ) { + deleteWaterfall( + supplyIds: $supplyIds, + demandIds: $demandIds + ) { + data { + id + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(deleteWaterfallAction.type), + debounce(() => timer(500)), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + ...payload, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(getDataAction())]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/downloadCSV.js b/anyclip/src/modules/marketplace/account/redux/epics/downloadCSV.js new file mode 100644 index 0000000..422c2c9 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/downloadCSV.js @@ -0,0 +1,19 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { downloadCSVAction, getDataAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(downloadCSVAction.type), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => concat(of(getDataAction({ allPages: true })))), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/duplicateDemandTag.js b/anyclip/src/modules/marketplace/account/redux/epics/duplicateDemandTag.js new file mode 100644 index 0000000..a830807 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/duplicateDemandTag.js @@ -0,0 +1,67 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createDemandTagRequestBody from '../../helpers/createDemandTagRequestBody'; +import * as selectors from '../selectors'; +import { duplicateDemandTagAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation DuplicateDemandTag( + $tag: MarketplaceDemandTagInputType + ) { + duplicateDemandTag( + tag: $tag + ) { + id + name + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(duplicateDemandTagAction.type), + switchMap(() => { + const state = state$.value; + const tag = createDemandTagRequestBody(state); + const pageConfig = selectors.pageConfigSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + tag, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + let link = `/demand/${pageConfig.demandAccount.id}/advertisers/${pageConfig.advertiser.id}/tags/${data.duplicateDemandTag.id}`; + + link += `?accountName=${encodeURIComponent(pageConfig.demandAccount.name)}&advertiserName=${encodeURIComponent(pageConfig.advertiser.name)}&tagName=${encodeURIComponent(data.duplicateDemandTag.name)}`; + + Router.push(link); + + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Demand tag created', + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/duplicateSupplyTag.js b/anyclip/src/modules/marketplace/account/redux/epics/duplicateSupplyTag.js new file mode 100644 index 0000000..0e39f67 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/duplicateSupplyTag.js @@ -0,0 +1,67 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createSupplyTagRequestBody from '../../helpers/createSupplyTagRequestBody'; +import * as selectors from '../selectors'; +import { duplicateSupplyTagAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation DuplicateSupplyTag( + $tag: MarketplaceSupplyTagDuplicateInputType + ) { + duplicateSupplyTag( + tag: $tag + ) { + id + name + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(duplicateSupplyTagAction.type), + switchMap(() => { + const state = state$.value; + const tag = createSupplyTagRequestBody(state); + const pageConfig = selectors.pageConfigSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + tag, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + let link = `/supply/${pageConfig.supplyAccount.id}/sites/${pageConfig.site.id}/tags/${data.duplicateSupplyTag.id}`; + + link += `?accountName=${encodeURIComponent(pageConfig.supplyAccount.name)}&siteName=${encodeURIComponent(pageConfig.site.name)}&tagName=${encodeURIComponent(data.duplicateSupplyTag.name)}`; + + Router.push(link); + + actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Supply tag created', + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getAccoutsForWaterfall.js b/anyclip/src/modules/marketplace/account/redux/epics/getAccoutsForWaterfall.js new file mode 100644 index 0000000..dd5dc23 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getAccoutsForWaterfall.js @@ -0,0 +1,101 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getAccountsForWaterfallAction, setModalInfoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(getAccountsForWaterfallAction.type), + debounce((action) => timer(action.payload?.name?.length ? 1000 : 0)), + switchMap(({ payload: { name, config } }) => { + const state = state$.value; + + const timezone = getUserTimezoneSelector(state); + const filterByDate = selectors.filterByDateSelector(state); + + const { change, dimension, changeDimension, ...range } = filterByDate; + let filters = {}; + + if (dimension) { + filters = { + ...filters, + dimension, + }; + } + + if (change) { + filters = { + ...filters, + changeRanges: [{ ...change, timezone }], + }; + } + + if (changeDimension) { + filters = { + ...filters, + changeDimension, + }; + } + + let mainParams = { + fields: config.fields, + size: 30, + from: 0, + filters: { + ranges: [ + { + ...range, + timezone, + }, + ], + ...filters, + }, + }; + + if (name?.length) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + query: name, + }, + }; + } + + const stream$ = gqlRequest({ + query: config.accountsQuery, + variables: { + ...mainParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + const accountsData = { + ...data.supplyAccountsData, + ...data.demandAccountsData, + }; + + if (!errors.length) { + actions = [ + of( + setModalInfoAction({ + accountsOptions: [...accountsData.data].filter((item) => !!item.name?.length), + }), + ), + ]; + + return concat(...actions); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getAdServers.js b/anyclip/src/modules/marketplace/account/redux/epics/getAdServers.js new file mode 100644 index 0000000..6739ac0 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getAdServers.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { getAdServerListsAction, setSettingsTabAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getAdServers($searchText: String, $searchIn: [String], $page: Int, $pageSize: Int, $sortOrder: String, , $sortBy: String) { + getAdServers(searchText: $searchText, searchIn: $searchIn, page: $page, pageSize: $pageSize, sortOrder: $sortOrder, sortBy: $sortBy) { + records { + id + name + url + macro + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getAdServerListsAction.type), + debounce((action) => timer(action.payload?.searchText?.length ? 1000 : 0)), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload?.searchText ?? '', + searchIn: ['name'], + page: 1, + pageSize: 30, + sortBy: 'name', + sortOrder: SORT_ASC, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + setSettingsTabAction({ + adServersList: data.getAdServers.records, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getAdvertiserSettingsTab.js b/anyclip/src/modules/marketplace/account/redux/epics/getAdvertiserSettingsTab.js new file mode 100644 index 0000000..ab51b4f --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getAdvertiserSettingsTab.js @@ -0,0 +1,89 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getAdvertiserSettingsTabAction, setAdvertiserSettingsTabAction } from '../slices'; + +const getIncludeExcludeFromArray = (options, listInclude, listExclude) => { + const includeExcludeList = [].concat(listInclude || [], listExclude || []); + if (!includeExcludeList.length) { + return []; + } + + return includeExcludeList.map((item) => { + let label = item[0].toUpperCase() + item.toLowerCase().substring(1); + if (options && options.length) { + const found = options.find((option) => option.value === item); + if (found) { + // eslint-disable-next-line prefer-destructuring + label = found.label; + } + } + return { + label, + value: item, + include: listInclude ? listInclude.includes(item) : false, + }; + }); +}; + +export default (action$, state$) => + action$.pipe( + ofType(getAdvertiserSettingsTabAction.type), + switchMap(() => { + const advertiserSettingsTab = {}; + const state = state$.value; + + const info = selectors.infoSelector(state); + const advertiserSettings = selectors.advertiserSettingsSelector(state); + + if (info) { + advertiserSettingsTab.countries = getIncludeExcludeFromArray( + advertiserSettings?.countriesList, + info.include?.geo, + info.exclude?.geo, + ); + + if (info.tier) { + advertiserSettingsTab.tier = info.tier; + } + + if (info.name) { + advertiserSettingsTab.name = info.name; + } + + if (info.daccountId) { + advertiserSettingsTab.demandAccountId = +info.daccountId; + } + + if (info.revShare?.length) { + advertiserSettingsTab.revShare = + info.revShare?.map((price, index) => ({ + id: index, + ...price, + })) ?? []; + } + if (info.frequencyCapAdjustment !== null) { + advertiserSettingsTab.frequencyCapAdjustment = info.frequencyCapAdjustment; + advertiserSettingsTab.frequencyCapAdjustmentPerSupply = info.frequencyCapAdjustmentPerSupply; + } else { + advertiserSettingsTab.frequencyCapAdjustment = false; + } + + if (info.frequencyCapAdjustment) { + advertiserSettingsTab.frequencyCapAdjustmentThreshold = info.frequencyCapAdjustmentThreshold; + advertiserSettingsTab.frequencyCapAdjustmentPerSupply = info.frequencyCapAdjustmentPerSupply; + } + + if (info.logo) { + advertiserSettingsTab.logo = info.logo; + } + + advertiserSettingsTab.pbsEnabled = info.pbsEnabled; + advertiserSettingsTab.profitability = info.profitability; + } + + return concat(of(setAdvertiserSettingsTabAction(advertiserSettingsTab))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getBudgetingTab.js b/anyclip/src/modules/marketplace/account/redux/epics/getBudgetingTab.js new file mode 100644 index 0000000..499edc7 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getBudgetingTab.js @@ -0,0 +1,29 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getBudgetingTabAction, setBudgetingTabAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(getBudgetingTabAction.type), + switchMap(() => { + const setBudgeting = {}; + const state = state$.value; + + const info = selectors.infoSelector(state); + + if (info && info.budgets) { + setBudgeting.budgetingTabList = info.budgets.map((item, index) => ({ + id: index, + budget: item.budget, + type: item.type, + timeFrame: item.timeframe, + pacing: item.pacing, + })); + } + + return concat(of(setBudgetingTabAction(setBudgeting))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getChartData.js b/anyclip/src/modules/marketplace/account/redux/epics/getChartData.js new file mode 100644 index 0000000..c343666 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getChartData.js @@ -0,0 +1,154 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { CHART_TIME_INTERVAL_TAB, DEMAND_TAG_PAGE, SUPPLY_SITE_PAGE, SUPPLY_TAG_PAGE } from '../../constants'; + +import { parseComparisonHistograms, parseHistogram } from '../../../common/helpers/histogram'; +import { addPageParams } from '../../helpers/filters'; +import * as selectors from '../selectors'; +import { + getChartDataAction, + refreshPageDataByIntervalAction, + setChartTabAction, + setComparisonFilterAction, + setFieldAction, + setFilterByCountryAction, + setFilterByDeviceAction, + setFilterByPlayerAction, + setTimeIntervalFilterAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { formatFilterSelector } from '@/modules/marketplace/dashboard/redux/selectors'; +import { setFilterFormatAction } from '@/modules/marketplace/dashboard/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType( + getChartDataAction.type, + setChartTabAction.type, + setTimeIntervalFilterAction.type, + setComparisonFilterAction.type, + refreshPageDataByIntervalAction.type, + setFilterByCountryAction.type, + setFilterByDeviceAction.type, + setFilterByPlayerAction.type, + setFilterFormatAction.type, + ), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => { + const state = state$.value; + + const chartTab = selectors.chartTabSelector(state); + const pageConfig = selectors.pageConfigSelector(state); + const timeIntervalFilter = selectors.timeIntervalFilterSelector(state); + const comparisonFilter = selectors.comparisonFilterSelector(state); + const filterByCountry = selectors.filterByCountrySelector(state); + const filterByDevice = selectors.filterByDeviceSelector(state); + const filterByPlayer = selectors.filterByPlayerSelector(state); + + const formatFilter = formatFilterSelector(state); + + const timezone = getUserTimezoneSelector(state); + + let params = { + timezone, + }; + + let filters = {}; + + if (formatFilter && !(pageConfig?.type === DEMAND_TAG_PAGE || pageConfig?.type === SUPPLY_TAG_PAGE)) { + filters = { ...formatFilter }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByCountry) { + filters = { + ...filters, + geo: filterByCountry, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByDevice) { + filters = { + ...filters, + device: filterByDevice, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByPlayer?.value) { + filters = { + ...filters, + widgetId: filterByPlayer.value, + }; + } + + if (chartTab === CHART_TIME_INTERVAL_TAB) { + const { interval, dimension, ...ranges } = timeIntervalFilter; + const mainParams = { + ...params, + fields: pageConfig.timeIntervalParams.map((option) => option.value), + interval, + filters: [{ ranges: [ranges], dimension, ...filters }], + }; + + params = addPageParams(mainParams, pageConfig); + } else { + const mainParams = { + ...params, + fields: [comparisonFilter], + interval: '1h', + filters: [ + { ranges: [{ stringFrom: 'now/d', stringTo: 'now+1d/d-59m', timezone }], dimension: 'HOUR', ...filters }, + { ranges: [{ stringFrom: 'now-1d/d', stringTo: 'now/d-59m', timezone }], dimension: 'HOUR', ...filters }, + { ranges: [{ stringFrom: 'now-7d/d', stringTo: 'now-6d/d-59m', timezone }], dimension: 'HOUR', ...filters }, + ], + }; + + params = addPageParams(mainParams, pageConfig); + } + + const stream$ = gqlRequest({ + query: pageConfig.histogramQuery, + variables: { + ...params, + }, + }).pipe( + switchMap(({ data = {}, errors }) => { + let actions = []; + + if (!errors.length) { + const chartData = { + ...data.siteHistogram, + ...data.supplyTagHistogram, + ...data.demandTagHistogram, + ...data.advertiserHistogram, + }; + let parsedChartData; + + if (chartTab === CHART_TIME_INTERVAL_TAB) { + parsedChartData = parseHistogram(chartData.data[0], timezone); + } else { + parsedChartData = parseComparisonHistograms({ + histograms: chartData.data, + timezone, + allowableFields: [comparisonFilter], + }); + } + + actions = [of(setFieldAction({ chartData: parsedChartData }))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getCountries.js b/anyclip/src/modules/marketplace/account/redux/epics/getCountries.js new file mode 100644 index 0000000..d95c617 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getCountries.js @@ -0,0 +1,54 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCountriesListAction, setAdvertiserSettingsTabAction, setTargetingTabAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query Query { + commonGeography { + id + uiKey + name + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getCountriesListAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + const countriesList = + data.commonGeography?.map((geo) => ({ + label: geo.name, + value: geo.uiKey, + })) ?? []; + + if (!errors.length) { + actions = [ + of( + setTargetingTabAction({ + countriesList, + }), + ), + of( + setAdvertiserSettingsTabAction({ + countriesList, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getData.js b/anyclip/src/modules/marketplace/account/redux/epics/getData.js new file mode 100644 index 0000000..e7cff5a --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getData.js @@ -0,0 +1,262 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { LIST_OF_META_SORTING } from '../../../common/constants'; +import { DEMAND_ADVERTISER_PAGE, DEMAND_TAG_PAGE, SUPPLY_SITE_PAGE, SUPPLY_TAG_PAGE } from '../../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import { addPageParams } from '../../helpers/filters'; +import * as selectors from '../selectors'; +import { + getDataAction, + refreshPageDataByIntervalAction, + saveToCSVAction, + setFieldAction, + setFilterByCountryAction, + setFilterByDateAction, + setFilterByDeviceAction, + setFilterByLabelAction, + setFilterByPlayerAction, + setFilterByStatusAction, + setRowsPerPageAction, + setSearchAction, + setSortByAction, + setSortOrderAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector, getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { formatFilterSelector } from '@/modules/marketplace/dashboard/redux/selectors'; +import { setFilterFormatAction } from '@/modules/marketplace/dashboard/redux/slices'; + +const actionsWithRefresh = [ + setSearchAction.type, + setFilterByDateAction.type, + setSortByAction.type, + setSortOrderAction.type, + setRowsPerPageAction.type, + setFilterByStatusAction.type, + setFilterByCountryAction.type, + setFilterByDeviceAction.type, + setFilterByPlayerAction.type, + setFilterByLabelAction.type, + setFilterFormatAction.type, +]; + +export default (action$, state$) => + action$.pipe( + ofType( + getDataAction.type, + setSearchAction.type, + setFilterByDateAction.type, + setSortByAction.type, + setSortOrderAction.type, + setRowsPerPageAction.type, + setFilterByStatusAction.type, + setFilterByCountryAction.type, + setFilterByDeviceAction.type, + setFilterByPlayerAction.type, + setFilterByLabelAction.type, + refreshPageDataByIntervalAction.type, + setFilterFormatAction.type, + ), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + debounce((action) => timer(action.type === setSearchAction.type ? 1000 : 0)), + switchMap(({ type, payload }) => { + const state = state$.value; + + const userPermissions = getUserPermissionsSelector(state$.value); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const pageConfig = selectors.pageConfigSelector(state); + const search = selectors.searchSelector(state); + const sortBy = selectors.sortBySelector(state); + const sortOrder = selectors.sortOrderSelector(state); + const filterByDate = selectors.filterByDateSelector(state); + const filterByStatus = selectors.filterByStatusSelector(state); + const page = selectors.pageSelector(state); + const rowsPerPage = selectors.rowsPerPageSelector(state); + const filterByCountry = selectors.filterByCountrySelector(state); + const filterByDevice = selectors.filterByDeviceSelector(state); + const filterByPlayer = selectors.filterByPlayerSelector(state); + const filterByLabel = selectors.filterByLabelSelector(state); + + const formatFilter = formatFilterSelector(state); + + const timezone = getUserTimezoneSelector(state); + + const { isCustomPeriod, ...filterByDateRest } = filterByDate; + const { change, dimension, changeDimension, ...range } = filterByDateRest; + let filters = {}; + + let currentPage = page; + + if (actionsWithRefresh.includes(type)) { + currentPage = 0; + } + + if (dimension) { + filters = { + ...filters, + dimension, + }; + } + + if (change) { + filters = { + ...filters, + changeRanges: [ + { + ...change, + timezone, + }, + ], + }; + } + + if (changeDimension) { + filters = { + ...filters, + changeDimension, + }; + } + + if (formatFilter && !(pageConfig?.type === DEMAND_TAG_PAGE || pageConfig?.type === SUPPLY_TAG_PAGE)) { + filters = { + ...filters, + ...formatFilter, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByCountry) { + filters = { + ...filters, + geo: filterByCountry, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByDevice) { + filters = { + ...filters, + device: filterByDevice, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByPlayer?.value) { + filters = { + ...filters, + widgetId: filterByPlayer.value, + }; + } + + if (pageConfig?.type === DEMAND_ADVERTISER_PAGE && filterByLabel) { + filters = { + ...filters, + labels: [filterByLabel.value], + }; + } + + const fields = isAdminMP ? pageConfig.dataFields : pageConfig.dataFields.filter((item) => item !== 'PROFIT'); + + let mainParams = { + fields, + size: payload?.allPages ? 1000 : rowsPerPage || 25, + from: payload?.allPages ? 0 : (currentPage || 0) * (rowsPerPage || 25), + sort: { + [LIST_OF_META_SORTING.includes(sortBy) ? 'ofMeta' : 'of']: sortBy, + order: sortOrder, + }, + filters: { + ranges: [ + { + ...range, + timezone, + }, + ], + ...filters, + }, + allPages: !!payload?.allPages, + }; + + if (search?.length) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + query: search, + }, + }; + } + + if (filterByStatus && filterByStatus !== 'ALL') { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + status: { value: filterByStatus }, + }, + }; + } + + const params = addPageParams(mainParams, pageConfig, isAdminMP, true); + + const stream$ = gqlRequest({ + query: pageConfig.dataQuery, + variables: { + ...params, + }, + }).pipe( + switchMap(({ data = {}, errors }) => { + let actions = []; + const accountData = { + ...data.siteData, + ...data.supplyTagData, + ...data.demandTagData, + ...data.demandTagDataSelfServeCompressed, + ...data.advertiserData, + }; + + if (!errors.length) { + const dataWithTime = [ + ...accountData.data.map((item) => ({ + ...item, + fields: { + ...item?.fields, + REQUESTS: { + ...item?.fields?.REQUESTS, + // todo: it has to be split and replaced + apsInfo: pageConfig.type !== DEMAND_ADVERTISER_PAGE && !!(item?.hasApsTags || item?.aps), + // mark requests cell red if profit false (advertiser, demand tag pages) + profitDecreased: item?.profit === false, + }, + }, + created: item?.created ? dayjs(+item.created).tz(timezone).format('YYYY/MM/DD h:mm A') : null, + })), + ]; + + if (payload?.allPages) { + actions = [of(saveToCSVAction(dataWithTime || []))]; + } else { + actions = [ + of(setFieldAction({ data: dataWithTime || [] })), + of(setFieldAction({ totalCount: accountData.totalCount })), + of(setFieldAction({ page: currentPage })), + ]; + } + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getDataForWaterfall.js b/anyclip/src/modules/marketplace/account/redux/epics/getDataForWaterfall.js new file mode 100644 index 0000000..fb0009b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getDataForWaterfall.js @@ -0,0 +1,276 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { DEMAND_TAG_PAGE, SOURCE_TAG_OPTIONS, SUPPLY_TAG_PAGE } from '../../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { getDataForWaterfallAction, setModalInfoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector, getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(getDataForWaterfallAction.type), + debounce((action) => timer(action.payload.debounce ? 1000 : 0)), + switchMap(({ payload: { page, rowsPerPage, config } }) => { + const timezone = getUserTimezoneSelector(state$.value); + const userPermissions = getUserPermissionsSelector(state$.value); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + const filterByDate = selectors.filterByDateSelector(state); + const info = selectors.infoSelector(state); + const userHubs = selectors.userHubsSelector(state); + const userDemandAccounts = selectors.userDemandAccountsSelector(state); + const modal = selectors.modalSelector(state); + + const { search, status, account, rate, model, label, displayType } = modal; + + const { change, dimension, changeDimension, ...range } = filterByDate; + let filters = { + source: SOURCE_TAG_OPTIONS[0].value, + }; + + if (dimension) { + filters = { + ...filters, + dimension, + }; + } + + if (change) { + filters = { + ...filters, + changeRanges: [{ ...change, timezone }], + }; + } + + if (changeDimension) { + filters = { + ...filters, + changeDimension, + }; + } + + if (info?.format) { + filters = { + ...filters, + format: info.format, + }; + } + + let mainParams = { + fields: config.fields, + size: rowsPerPage || 25, + from: (page || 0) * (rowsPerPage || 25), + filters: { + ranges: [ + { + ...range, + timezone, + }, + ], + ...filters, + }, + }; + + if (search?.length) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + query: search, + }, + }; + } + + if (status && status?.value !== 'ALL') { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + status: { value: status.value }, + }, + }; + } + + if (pageConfig.type === SUPPLY_TAG_PAGE) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + supplyId: { + exclude: true, + value: pageConfig.id, + }, + }, + }; + + if (info?.publisherDemand) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + publisherDemand: info?.publisherDemand, + }, + }; + } + + if (account) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + daccountIds: [ + { + value: account.id, + }, + ], + }, + }; + } + + if (label?.value) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + labels: [label.value], + }, + }; + } + + if (rate?.length) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + pricing: { + from: rate, + }, + }, + }; + } + + if (model?.value) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + model: { + value: model.value, + }, + }, + }; + } + + if (!isAdminMP) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + daccountIds: userDemandAccounts.map((item) => ({ value: item.id?.toString() })), + }, + }; + } + } + + if (pageConfig.type === DEMAND_TAG_PAGE) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + demandId: { + exclude: true, + value: pageConfig.id, + }, + }, + }; + + if (info?.publisherDemand === false) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + publisherDemand: info?.publisherDemand, + }, + }; + } + + if (account) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + accountIds: [ + { + value: account.id, + }, + ], + }, + }; + } + + if (!isAdminMP) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + siteIds: userHubs.map((item) => ({ value: item.id?.toString() })), + }, + }; + } + + if (displayType) { + mainParams = { + ...mainParams, + filters: { + ...mainParams.filters, + displayType: displayType.value, + }, + }; + } + } + + const stream$ = gqlRequest({ + query: config.query, + variables: { + ...mainParams, + }, + }).pipe( + switchMap((response) => { + let actions = []; + const { errors, data: waterfallData } = response; + + const tagData = { + ...waterfallData.supplyTagData, + ...waterfallData.demandTagData, + }; + + if (!errors.length) { + actions = [ + of( + setModalInfoAction({ + data: tagData.data, + totalCount: tagData.totalCount, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat( + of(setModalInfoAction({ isFetchingData: true })), + stream$, + of(setModalInfoAction({ isFetchingData: false })), + ); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getDemandAccountById.js b/anyclip/src/modules/marketplace/account/redux/epics/getDemandAccountById.js new file mode 100644 index 0000000..dcf64ad --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getDemandAccountById.js @@ -0,0 +1,42 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { DEMAND_ACCOUNT_PAGE_INFO_GRAPHQL_QUERY } from '../../constants'; + +import { getDemandAccountByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +export default (action$) => + action$.pipe( + ofType(getDemandAccountByIdAction.type), + switchMap(() => { + const demandAccountId = Router.query.params[0]; + + const stream$ = gqlRequest({ + query: DEMAND_ACCOUNT_PAGE_INFO_GRAPHQL_QUERY, + variables: { + id: demandAccountId, + }, + }).pipe( + switchMap(({ data = {}, errors }) => { + let actions = []; + + if (!errors?.length) { + actions = [ + of( + setFieldAction({ + demandAccountInfo: { ...data.demandAccountById }, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getDemandTagPricing.js b/anyclip/src/modules/marketplace/account/redux/epics/getDemandTagPricing.js new file mode 100644 index 0000000..4130f8c --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getDemandTagPricing.js @@ -0,0 +1,77 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { mergeMap, switchMap } from 'rxjs/operators'; + +import getCurrentAndFuturePrice from '../../helpers/getCurrentAndFuturePrice'; +import * as selectors from '../selectors'; +import { getDemandTagPricingAction, setPricingTabAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { definePricingTableKey } from '@/modules/marketplace/account/helpers/createDemandTagPriceRequestBody'; + +const query = ` + query getDemandTagPricing($id: String!, $from: Int, $size: Int, $type: String!) { + getDemandTagPricing(id: $id, from: $from, size: $size, type: $type) { + totalCount + data { + id + type + value + startDate + endDate + updatedBy + model + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getDemandTagPricingAction.type), + mergeMap(({ payload }) => { + const state = state$.value; + + const pricing = selectors.pricingSelector(state); + const pageConfig = selectors.pageConfigSelector(state); + + const tableKey = definePricingTableKey(payload); + const { page, rowsPerPage, type } = pricing[tableKey]; + const { isDuplicate } = pageConfig; + + const stream$ = gqlRequest({ + query, + variables: { + id: pageConfig.id, + from: page * rowsPerPage, + size: rowsPerPage, + type, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + const rows = isDuplicate + ? getCurrentAndFuturePrice(data.getDemandTagPricing.data) + : data.getDemandTagPricing.data; + + actions = [ + of( + setPricingTabAction({ + [tableKey]: { + ...pricing[tableKey], + totalCount: data.getDemandTagPricing.totalCount, + rows, + }, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getFrequencyCapTab.js b/anyclip/src/modules/marketplace/account/redux/epics/getFrequencyCapTab.js new file mode 100644 index 0000000..19896c6 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getFrequencyCapTab.js @@ -0,0 +1,41 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { DEMAND_TAG_FREQUENCY_TAB_STATUS } from '@/modules/marketplace/account/constants'; + +import * as selectors from '../selectors'; +import { getFrequencyCapTabAction, setFrequencyCapTabAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(getFrequencyCapTabAction.type), + switchMap(() => { + let setFrequencyCap = {}; + const state = state$.value; + + const info = selectors.infoSelector(state); + const pageConfig = selectors.pageConfigSelector(state); + + if ( + info && + info.frequency && + info.frequency.status && + info.frequency.type && + info.frequency.value && + info.frequency.timeframe && + info.frequency.amount + ) { + setFrequencyCap = { + ...info.frequency, + prevSavedData: null, + }; + } else if (!pageConfig?.isCreatingNew) { + setFrequencyCap = { + status: DEMAND_TAG_FREQUENCY_TAB_STATUS.disabled, + }; + } + + return concat(of(setFrequencyCapTabAction(setFrequencyCap))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getHistoryForCSV.js b/anyclip/src/modules/marketplace/account/redux/epics/getHistoryForCSV.js new file mode 100644 index 0000000..ca57cd6 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getHistoryForCSV.js @@ -0,0 +1,113 @@ +import { ofType } from 'redux-observable'; +import { concat } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { createHistoryCSV } from '../../helpers/createHistoryCSV'; +import * as selectors from '../selectors'; +import { getHistoryForCSVAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query MarketplaceHistory( + $from: Int, + $size: Int, + $ids: [String], + $actions: [String], + $types: [String], + $range: MarketplaceFiltersRangesInputType, + $graphMode: Boolean, + $excludeAuto: Boolean, + $order: String + ) { + marketplaceHistory( + from: $from, + size: $size, + ids: $ids, + actions: $actions, + types: $types, + range: $range, + graphMode: $graphMode, + excludeAuto: $excludeAuto, + order: $order + ) { + totalCount + data { + id + created + ids + type + values { + action + actionWithName + name + changes { + name + oldValue + newValue + } + } + updatedBy + user { + email + firstName + lastName + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getHistoryForCSVAction.type), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + const filterByDate = selectors.filterByDateSelector(state); + + const userTimezone = getUserTimezoneSelector(state); + + const { isCustomPeriod, ...filterByDateRest } = filterByDate; + const { change, dimension, changeDimension, ...range } = filterByDateRest; + + const stream$ = gqlRequest({ + query, + variables: { + ids: [pageConfig.id], + range, + graphMode: false, + order: 'DESC', + size: 1000, + }, + }).pipe( + switchMap(({ data = {}, errors }) => { + const actions = []; + + if (!errors.length) { + const response = { + ...data.marketplaceHistory, + }; + + createHistoryCSV({ + pageConfig, + data: response.data, + userTimezone, + }); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getHistoryForChart.js b/anyclip/src/modules/marketplace/account/redux/epics/getHistoryForChart.js new file mode 100644 index 0000000..c06a10d --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getHistoryForChart.js @@ -0,0 +1,127 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { CHART_TIME_INTERVAL_TAB } from '../../constants'; + +import * as selectors from '../selectors'; +import { getHistoryForChartAction, setChartTabAction, setFieldAction, setTimeIntervalFilterAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query MarketplaceHistory( + $from: Int, + $size: Int, + $ids: [String], + $actions: [String], + $types: [String], + $range: MarketplaceFiltersRangesInputType, + $graphMode: Boolean, + $excludeAuto: Boolean, + $order: String + ) { + marketplaceHistory( + from: $from, + size: $size, + ids: $ids, + actions: $actions, + types: $types, + range: $range, + graphMode: $graphMode, + excludeAuto: $excludeAuto, + order: $order + ) { + totalCount + data { + id + created + ids + type + values { + action + actionWithName + name + changes { + name + oldValue + newValue + } + } + updatedBy + user { + email + firstName + lastName + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getHistoryForChartAction.type, setChartTabAction.type, setTimeIntervalFilterAction.type), + filter(() => { + const state = state$.value; + + const chartTab = selectors.chartTabSelector(state); + + return chartTab === CHART_TIME_INTERVAL_TAB; + }), + switchMap(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + const timeIntervalFilter = selectors.timeIntervalFilterSelector(state); + + const { interval, dimension, ...range } = timeIntervalFilter; + + const stream$ = gqlRequest({ + query, + variables: { + ids: [pageConfig.id], + range, + graphMode: true, + order: 'DESC', + size: 1000, + excludeAuto: false, + actions: [ + 'PRICING_LINE_ITEM_ADD', + 'TIER_CHANGE', + 'PRIORITY_CHANGE', + 'AD_SERVER_URL_CHANGE', + 'TIMEOUT_CHANGE', + 'NEW_RATE_CREATION', + 'NEW_ADDITIONAL_FEES_CREATION', + 'ANY_TARGETING_CHANGE', + 'ANY_FREQUENCY_CAP_CHANGE', + 'ANY_BUDGETING_CHANGE', + 'ANY_SPECIFIC_PARAMETER_CHANGE', + 'AI_DEFAULT_FLOOR_MANAGEMENT', + 'DEFAULT_HB_FLOR_VIEWABLE_CHANGE', + 'DEFAULT_HB_FLOR_NON_VIEWABLE_CHANGE', + 'AUTOMATIC_OPTIMIZATION_STATUS_CHANGE', + 'TARGET_KPI_CHANGE', + 'DEFAULT_HB_FLOR_FIRST_REQUEST_CHANGE', + 'AUTOMATIC_FLOOR_PRICE_STATUS_CHANGE', + ], + }, + }).pipe( + switchMap(({ data = {}, errors }) => { + let actions = []; + + if (!errors?.length) { + const response = { + ...data.marketplaceHistory, + }; + + actions = [of(setFieldAction({ chartHistory: response.data }))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getInfo.js b/anyclip/src/modules/marketplace/account/redux/epics/getInfo.js new file mode 100644 index 0000000..85094e5 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getInfo.js @@ -0,0 +1,56 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getInfoAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +export default (action$, state$) => + action$.pipe( + ofType(getInfoAction.type), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(({ payload }) => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + const stream$ = gqlRequest({ + query: pageConfig.infoQuery, + variables: { + id: payload, + }, + }).pipe( + switchMap(({ data = {}, errors }) => { + let actions = []; + + if (!errors?.length) { + actions = [ + of( + setFieldAction({ + info: { + ...data.supplyAccountById, + ...data.demandAccountById, + ...data.siteById, + ...data.supplyTagById, + ...data.demandTagById, + ...data.advertiserById, + }, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getKeyListsOptions.js b/anyclip/src/modules/marketplace/account/redux/epics/getKeyListsOptions.js new file mode 100644 index 0000000..bc684e3 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getKeyListsOptions.js @@ -0,0 +1,93 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getKeyListsOptionsAction, setTargetingTabAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query keyListsSearch( + $from: Int, + $size: Int, + $query: String, + $status: String, + $sortBy: String, + $daccountIds: [String], + $sortOrder: String + ) { + keyListsSearch( + from: $from, + size: $size, + query: $query, + status: $status, + sortBy: $sortBy, + daccountIds: $daccountIds, + sortOrder: $sortOrder + ) { + totalCount + data { + id + created + updated + name + keys + count + status + updatedBy + user + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getKeyListsOptionsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + const { demandAccount } = pageConfig; + + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + ...requestParams, + query: action.payload, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + size: 30, + from: 0, + status: 'ACTIVE', + daccountIds: demandAccount?.id ? [demandAccount.id] : [], + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { data: keyLists } = data.keyListsSearch; + + actions.push( + of( + setTargetingTabAction({ + keyListsOptions: keyLists?.map((item) => ({ label: item.name, value: item.id })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getKeyNamesOptions.js b/anyclip/src/modules/marketplace/account/redux/epics/getKeyNamesOptions.js new file mode 100644 index 0000000..0772878 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getKeyNamesOptions.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getKeyNamesOptionsAction, setTargetingTabAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query availableKeysSearch( + $from: Int, + $size: Int, + $query: String, + $status: String, + $sortBy: String, + $sortOrder: String + ) { + availableKeysSearch( + from: $from, + size: $size, + query: $query, + status: $status, + sortBy: $sortBy, + sortOrder: $sortOrder + ) { + totalCount + data { + id + created + updated + name + keys + count + status + updatedBy + user + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getKeyNamesOptionsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + let requestParams = {}; + + if (action.payload?.length) { + requestParams = { + ...requestParams, + query: action.payload, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + size: 30, + from: 0, + status: 'ACTIVE', + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { data: keys } = data.availableKeysSearch; + + actions.push( + of( + setTargetingTabAction({ + keyNamesOptions: keys?.map((item) => ({ label: item.name, value: item.id })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getLabelsForTableFilter.js b/anyclip/src/modules/marketplace/account/redux/epics/getLabelsForTableFilter.js new file mode 100644 index 0000000..ebe60c2 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getLabelsForTableFilter.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getLabelsForTableFilterAction, setLabelsOptionsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query MPLabelsAutocompleteQuery( + $prefix: String, + $size: Int + ) { + mpLabelsAutocomplete( + prefix: $prefix, + size: $size + ) { + value + count + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getLabelsForTableFilterAction.type), + debounce((action) => timer(action.payload?.prefix?.length ? 1000 : 0)), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + prefix: payload?.prefix ?? '', + size: 30, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(setLabelsOptionsAction(data.mpLabelsAutocomplete.filter((item) => !!item.value?.length)))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getLabelsForWaterfall.js b/anyclip/src/modules/marketplace/account/redux/epics/getLabelsForWaterfall.js new file mode 100644 index 0000000..bd5ea96 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getLabelsForWaterfall.js @@ -0,0 +1,54 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getLabelsForWaterfallAction, setModalInfoAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query MPLabelsAutocompleteQuery( + $prefix: String, + $size: Int + ) { + mpLabelsAutocomplete( + prefix: $prefix, + size: $size + ) { + value + count + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getLabelsForWaterfallAction.type), + debounce((action) => timer(action.payload?.prefix?.length ? 1000 : 0)), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + prefix: payload?.prefix ?? '', + size: 30, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + setModalInfoAction({ + labelsOptions: data.mpLabelsAutocomplete.filter((item) => !!item.value?.length), + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getPlatforms.js b/anyclip/src/modules/marketplace/account/redux/epics/getPlatforms.js new file mode 100644 index 0000000..00ec202 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getPlatforms.js @@ -0,0 +1,67 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getPlatformsAction, setSettingsTabAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query hbConnectorsSearchAll { + hbConnectorsSearchAll { + totalCount + data { + id + created + updated + name + status + format + updatedBy + user + code + numericId + comments + params { + name + label + defaultValue + defaultValueLabel + required + type + allowedValues { + label + value + } + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlatformsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + setSettingsTabAction({ + platforms: data.hbConnectorsSearchAll?.data ?? [], + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getPlayers.js b/anyclip/src/modules/marketplace/account/redux/epics/getPlayers.js new file mode 100644 index 0000000..fe45295 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getPlayers.js @@ -0,0 +1,90 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { getFilterByPlayerOptionsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query marketplacePlayers( + $searchText: String, + $publisherId: Int + ) { + marketplacePlayers( + searchText: $searchText, + publisherId: $publisherId + ) { + id + name + alias + } + } +`; + +const querySelfServe = ` + query marketplacePlayersSelfServe( + $searchText: String, + $publisherId: Int + ) { + marketplacePlayersSelfServe( + searchText: $searchText, + publisherId: $publisherId + ) { + id + name + alias + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getFilterByPlayerOptionsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + const state = state$.value; + const pageConfig = selectors.pageConfigSelector(state); + + const userPermissions = getUserPermissionsSelector(state$.value); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + let requestParams = {}; + + if (action.payload?.searchText) { + requestParams = { + searchText: action.payload.searchText, + }; + } + + const stream$ = gqlRequest({ + query: isAdminMP ? query : querySelfServe, + variables: { + publisherId: +pageConfig.id, + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setFieldAction({ + filterByPlayerOptions: + data.marketplacePlayers?.map((item) => ({ label: item.alias, value: item.name })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getPricingTab.js b/anyclip/src/modules/marketplace/account/redux/epics/getPricingTab.js new file mode 100644 index 0000000..9b89eef --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getPricingTab.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getPricingTabAction, setPricingTabAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(getPricingTabAction.type), + switchMap(() => { + const state = state$.value; + + const info = selectors.infoSelector(state); + const pageConfig = selectors.pageConfigSelector(state); + const pricing = selectors.pricingSelector(state); + + const isDuplicate = pageConfig?.isDuplicate; + const demandAccount = pageConfig?.demandAccount; + const setPricing = {}; + + setPricing.publisherDemand = + typeof info?.publisherDemand === 'boolean' ? info?.publisherDemand : demandAccount?.publisherDemand; + // from info for existing tag, from pageConfig(queries) for new tag + + if (info) { + setPricing.model = info.model; + setPricing.source = info.rateSource?.name ?? null; + setPricing.seatId = info.rateSource?.seatId ?? ''; + setPricing.adUnitId = info.rateSource?.adUnitId ?? ''; + setPricing.type = info.rateSource?.type ?? null; + setPricing.fixedRpmRateTable = { + ...pricing.fixedRpmRateTable, + rows: pricing.fixedRpmRateTable.rows, + rowsPerPage: isDuplicate ? 100 : 5, + }; + setPricing.additionalFeesTable = { + ...pricing.additionalFeesTable, + rowsPerPage: isDuplicate ? 100 : 5, + }; + setPricing.adServingFeesTable = { + ...pricing.adServingFeesTable, + rowsPerPage: isDuplicate ? 100 : 5, + }; + setPricing.adRequestFeesTable = { + ...pricing.adRequestFeesTable, + rowsPerPage: isDuplicate ? 100 : 5, + }; + } + + return concat(of(setPricingTabAction(setPricing))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getSettingsTab.js b/anyclip/src/modules/marketplace/account/redux/epics/getSettingsTab.js new file mode 100644 index 0000000..a8c5809 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getSettingsTab.js @@ -0,0 +1,151 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + DEMAND_TAG_DEFAULT_TIER_VALUES, + DEMAND_TAG_FORMAT, + DEMAND_TAG_PRIORITY, + DEMAND_TAG_STATUS, + DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE, + DEMAND_TAG_SUPPLY_CHAIN_VALUE, + FLOOR_PRICE, +} from '../../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { getSettingsTabAction, setSettingsTabAction } from '../slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const defineTierForDuplicate = (info, pageConfig) => { + if (info?.priority === DEMAND_TAG_PRIORITY.firstLook) { + return DEMAND_TAG_DEFAULT_TIER_VALUES[0].value; + } + + return Number(info?.advertiserTier ?? pageConfig?.advertiser?.tier); // ?? info?.defaultTier; +}; + +export default (action$, state$) => + action$.pipe( + ofType(getSettingsTabAction.type), + switchMap(() => { + const state = state$.value; + + const info = selectors.infoSelector(state); + const pageConfig = selectors.pageConfigSelector(state); + const userPermissions = getUserPermissionsSelector(state); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const setSettings = {}; + setSettings.advertiser = info?.advertiserName ?? pageConfig?.advertiser?.name; + setSettings.demandAccount = info?.accountName ?? pageConfig?.demandAccount?.name; + + if (!(pageConfig?.isCreatingNew && !isAdminMP)) { + // don't set tier for creating self serve demand tag + setSettings.defaultTier = pageConfig?.isDuplicate + ? defineTierForDuplicate(info, pageConfig) + : (info?.defaultTier ?? Number(pageConfig?.advertiser?.tier ?? DEMAND_TAG_DEFAULT_TIER_VALUES[3].value)); + // from info for existing tag, from pageConfig(queries) for new tag + } + + if (pageConfig?.isCreatingNew && pageConfig.demandAccount?.publisherDemand) { + setSettings.supplyChainOverride = DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled; + } + + if (info) { + setSettings.name = pageConfig?.isDuplicate ? `Copy_of_${info.name}` : info.name; + setSettings.source = info?.source; + setSettings.platformId = info?.platformId || ''; + setSettings.status = pageConfig?.isDuplicate ? DEMAND_TAG_STATUS.enabled : info.status; + setSettings.uid = info.id; + setSettings.adServer = info.adServer; + setSettings.url = info.adServer?.url ?? ''; + setSettings.format = info.format ?? DEMAND_TAG_FORMAT.video; + setSettings.priority = info.priority; + setSettings.videoId = info.videoId ?? ''; + setSettings.clickThroughUrl = info.clickThroughUrl ?? ''; + + if (info.adServer?.name) { + setSettings.adServerType = 'list'; + } + + if (info.labels?.length) { + setSettings.labels = info.labels; + } + + setSettings.timeout = info.timeout; + + if (info.eventPixels?.length) { + setSettings.eventPixelsList = info.eventPixels.map((pixel, index) => ({ + id: index, + url: pixel.url, + event: pixel.name, + type: pixel.type, + })); + } + + setSettings.type = info.type; + + if (info.platform) { + if (info.platform.params?.length) { + const params = info.platform.params.reduce( + (acc, curr) => ({ + ...acc, + [curr.name]: curr.value, + }), + {}, + ); + setSettings.platform = { + name: info.platform.connectorName, + connectorId: info.platform.connectorId, + code: info.platform.code, + ...params, + }; + } else { + setSettings.platform = { + name: info.platform.connectorName, + connectorId: info.platform.connectorId, + code: info.platform.code, + }; + } + + if (info.platform.bidMapping?.fileName?.length) { + setSettings.bidMappingFileName = info.platform.bidMapping.fileName; + setSettings.bidMappingFileData = info.platform.bidMapping.data; + } + + if (info.platform.supplyChain !== null) { + setSettings.supplyChainOverride = DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled; + if (info.platform.supplyChain?.length > 0) { + setSettings.supplyChainValue = DEMAND_TAG_SUPPLY_CHAIN_VALUE.custom; + setSettings.supplyChainNode = info.platform.supplyChain; + } else { + setSettings.supplyChainValue = DEMAND_TAG_SUPPLY_CHAIN_VALUE.blank; + } + } + + if (info.platform.maxFloor) { + setSettings.maxFloor = info.platform.maxFloor; + } + } + + if (info.floorPrice?.override) { + setSettings.floorPrice = { + override: FLOOR_PRICE.override, + floor: info.floorPrice?.floor, + viewableFloor: info.floorPrice?.viewableFloor, + }; + } + + if (info.adjustFloor !== null && info.adjustFloor !== undefined) { + setSettings.adjustFloor = info.adjustFloor; + } + if (info.flightsDates) { + setSettings.flightsDates = info.flightsDates; + } + } + + return concat(of(setSettingsTabAction(setSettings))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getTargetingTab.js b/anyclip/src/modules/marketplace/account/redux/epics/getTargetingTab.js new file mode 100644 index 0000000..71d8742 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getTargetingTab.js @@ -0,0 +1,111 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + DEMAND_TAG_KEY_VALUE_TARGETING_STATUS, + DEMAND_TAG_KEY_VALUE_TARGETING_TYPE, + DEMAND_TAG_PAGE_DEVICE_LIST, + DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE, + DEMAND_TAG_TARGETING_VIEWABILITY_TYPE, + DEMAND_TAG_VIEWABILITY_TARGETING, +} from '../../constants'; + +import * as selectors from '../selectors'; +import { getTargetingTabAction, setTargetingTabAction } from '../slices'; + +const getIncludeExcludeFromArray = (options, listInclude, listExclude) => { + const includeExcludeList = [].concat(listInclude || [], listExclude || []); + if (!includeExcludeList.length) { + return []; + } + + return includeExcludeList.map((item) => { + let label = item[0].toUpperCase() + item.toLowerCase().substring(1); + if (options && options.length) { + const found = options.find((option) => option.value === item); + if (found) { + // eslint-disable-next-line prefer-destructuring + label = found.label; + } + } + return { + label, + value: item, + include: listInclude ? listInclude.includes(item) : false, + }; + }); +}; + +export default (action$, state$) => + action$.pipe( + ofType(getTargetingTabAction.type), + switchMap(() => { + const setTargeting = {}; + const state = state$.value; + + const info = selectors.infoSelector(state); + const targeting = selectors.targetingSelector(state); + + if (info) { + setTargeting.browsers = getIncludeExcludeFromArray(null, info.include?.browsers, info.exclude?.browsers); + + setTargeting.os = getIncludeExcludeFromArray(null, info.include?.os, info.exclude?.os); + + setTargeting.countries = getIncludeExcludeFromArray( + targeting?.countriesList, + info.include?.geo, + info.exclude?.geo, + ); + + setTargeting.devices = getIncludeExcludeFromArray( + DEMAND_TAG_PAGE_DEVICE_LIST, + info.include?.devices, + info.exclude?.devices, + ); + + setTargeting.playerSize = { + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.xs]: + info.include?.playerSizes?.includes(DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.xs) ?? false, + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.s]: + info.include?.playerSizes?.includes(DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.s) ?? false, + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.m]: + info.include?.playerSizes?.includes(DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.m) ?? false, + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.l]: + info.include?.playerSizes?.includes(DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.l) ?? false, + }; + + setTargeting.viewability = { + [DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView]: + info.include?.viewability?.includes(DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView) ?? false, + [DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView]: + info.include?.viewability?.includes(DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView) ?? false, + }; + + if (info.viewabilityThreshold) { + setTargeting.viewabilityTargeting = DEMAND_TAG_VIEWABILITY_TARGETING.enabled; + setTargeting.viewabilityThreshold = Math.round(info.viewabilityThreshold * 100 * 100) / 100; + } + + if (info.tier) { + setTargeting.tier = info.tier; + } + + if (info.kvTargeting?.length) { + setTargeting.kvTargetingStatus = DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled; + setTargeting.kvTargeting = info.kvTargeting.map((item) => ({ + keyName: { label: item.keyName, value: item.key }, + state: item.state, + type: item.type, + values: item.type === DEMAND_TAG_KEY_VALUE_TARGETING_TYPE.value ? item.values : [], + valueLists: + item.type === DEMAND_TAG_KEY_VALUE_TARGETING_TYPE.list && item.values?.length && item.listNames?.length + ? item.values.map((value, index) => ({ label: item.listNames[index], value })) + : [], + })); + } + } + + return concat(of(setTargetingTabAction(setTargeting))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getTotal.js b/anyclip/src/modules/marketplace/account/redux/epics/getTotal.js new file mode 100644 index 0000000..ad4fee1 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getTotal.js @@ -0,0 +1,148 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { DEMAND_TAG_PAGE, SUPPLY_SITE_PAGE, SUPPLY_TAG_PAGE } from '../../constants'; + +import * as selectors from '../selectors'; +import { + getTotalAction, + refreshPageDataByIntervalAction, + setFieldAction, + setFilterByCountryAction, + setFilterByDateAction, + setFilterByDeviceAction, + setFilterByPlayerAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { formatFilterSelector } from '@/modules/marketplace/dashboard/redux/selectors'; +import { setFilterFormatAction } from '@/modules/marketplace/dashboard/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType( + getTotalAction.type, + setFilterByCountryAction.type, + setFilterByDeviceAction.type, + setFilterByDateAction.type, + setFilterByPlayerAction.type, + refreshPageDataByIntervalAction.type, + setFilterFormatAction.type, + ), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + const filterByDate = selectors.filterByDateSelector(state); + const filterByCountry = selectors.filterByCountrySelector(state); + const filterByDevice = selectors.filterByDeviceSelector(state); + const filterByPlayer = selectors.filterByPlayerSelector(state); + const timezone = getUserTimezoneSelector(state); + + const formatFilter = formatFilterSelector(state); + + const { isCustomPeriod, ...filterByDateRest } = filterByDate; + const { change, dimension, changeDimension, ...range } = filterByDateRest; + let filters = {}; + + if (dimension) { + filters = { + ...filters, + dimension, + }; + } + + if (change) { + filters = { + ...filters, + changeRanges: [ + { + ...change, + timezone, + }, + ], + }; + } + + if (changeDimension) { + filters = { + ...filters, + changeDimension, + }; + } + + if (formatFilter && !(pageConfig?.type === DEMAND_TAG_PAGE || pageConfig?.type === SUPPLY_TAG_PAGE)) { + filters = { + ...filters, + ...formatFilter, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByCountry) { + filters = { + ...filters, + geo: filterByCountry, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByDevice) { + filters = { + ...filters, + device: filterByDevice, + }; + } + + if (pageConfig?.type === SUPPLY_SITE_PAGE && filterByPlayer?.value) { + filters = { + ...filters, + widgetId: filterByPlayer.value, + }; + } + + const stream$ = gqlRequest({ + query: pageConfig.totalQuery, + variables: { + id: pageConfig.id, + fields: pageConfig.totalFields, + filters: { + ranges: [{ ...range, timezone }], + ...filters, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [ + of( + setFieldAction({ + total: + { + ...data.supplyAccountDataById, + ...data.siteDataById, + ...data.supplyTagDataById, + ...data.demandAccountDataById, + ...data.advertiserDataById, + ...data.demandTagDataById, + }.fields || {}, + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/getUserHubsAndDemandAccounts.js b/anyclip/src/modules/marketplace/account/redux/epics/getUserHubsAndDemandAccounts.js new file mode 100644 index 0000000..a43f06b --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/getUserHubsAndDemandAccounts.js @@ -0,0 +1,117 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import { getHubsAndDemandAccountsAction, setHubsAndDemandAccountsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const selfServeQuery = ` + query MarketplaceHubsAndDemandAccounts { + hubsAndDemandAccounts { + records { + id + name + mpSelfService + demandAccount { + id + name + } + } + } + } +`; + +const adminQuery = ` + query SearchDemandAccountsQuery( + $from: Int, + $size: Int, + $query: String, + $status: String + ) { + searchDemandAccounts( + from: $from, + size: $size, + query: $query, + status: $status + ) { + totalCount + data { + id + name + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getHubsAndDemandAccountsAction.type), + debounce((action) => timer(action.payload?.search ? 1000 : 0)), + switchMap(({ payload }) => { + let params = {}; + const userPermissions = getUserPermissionsSelector(state$.value); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + if (isAdminMP) { + params = { + variables: { + size: 30, + from: 0, + }, + }; + + if (payload?.search?.length) { + params = { + variables: { + ...params.variables, + query: payload.search, + }, + }; + } + } + + const stream$ = gqlRequest({ + query: isAdminMP ? adminQuery : selfServeQuery, + ...params, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + if (isAdminMP) { + actions = [ + of( + setHubsAndDemandAccountsAction({ + userDemandAccounts: data.searchDemandAccounts.data, + }), + ), + ]; + } + + if (!isAdminMP) { + const demandAccounts = data.hubsAndDemandAccounts.records?.map((item) => item?.demandAccount); + + actions = [ + of( + setHubsAndDemandAccountsAction({ + userHubs: data.hubsAndDemandAccounts.records, + userDemandAccounts: Object.values( + demandAccounts.reduce((acc, obj) => ({ ...acc, [obj.id]: obj }), {}), + ), + }), + ), + ]; + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/index.js b/anyclip/src/modules/marketplace/account/redux/epics/index.js new file mode 100644 index 0000000..95a9807 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/index.js @@ -0,0 +1,101 @@ +import { combineEpics } from 'redux-observable'; + +import bulkCreateWaterfall from './bulkCreateWatefall'; +import bulkUpdateDemandTag from './bulkUpdateDemandTag'; +import bulkUpdateSupplyTag from './bulkUpdateSupplyTag'; +import bulkUpdateViewabilityThreshold from './bulkUpdateViewabilityThreshold'; +import createAdvertiser from './createAdvertiser'; +import createDemandTag from './createDemandTag'; +import createSupplyTag from './createSupplyTag'; +import deleteWaterfall from './deleteWaterfall'; +import downloadCSV from './downloadCSV'; +import duplicateDemandTag from './duplicateDemandTag'; +import duplicateSupplyTag from './duplicateSupplyTag'; +import getAccoutsForWaterfall from './getAccoutsForWaterfall'; +import getAdServers from './getAdServers'; +import getAdvertiserSettingsTab from './getAdvertiserSettingsTab'; +import getBudgetingTab from './getBudgetingTab'; +import getChartData from './getChartData'; +import getCountries from './getCountries'; +import getData from './getData'; +import getDataForWaterfall from './getDataForWaterfall'; +import getDemandAccountById from './getDemandAccountById'; +import getDemandTagPricing from './getDemandTagPricing'; +import getFrequencyCapTab from './getFrequencyCapTab'; +import getHistoryForChart from './getHistoryForChart'; +import getHistoryForCSV from './getHistoryForCSV'; +import getInfo from './getInfo'; +import getKeyListsOptions from './getKeyListsOptions'; +import getKeyNamesOptions from './getKeyNamesOptions'; +import getLabelsForTableFilter from './getLabelsForTableFilter'; +import getLabelsForWaterfall from './getLabelsForWaterfall'; +import getPlatforms from './getPlatforms'; +import getPlayers from './getPlayers'; +import getPricingTab from './getPricingTab'; +import getSettingsTab from './getSettingsTab'; +import getTargetingTab from './getTargetingTab'; +import getTotal from './getTotal'; +import getSelfServeUserHubsAndDemandAccounts from './getUserHubsAndDemandAccounts'; +import initializeAdvertiserForm from './initializeAdvertiserForm'; +import initializeDemandForm from './initializeDemandForm'; +import initializeSupplyForm from './initializeSupplyForm'; +import saveDataToCSV from './saveDataToCSV'; +import showConfirmModal from './showConfirmModal'; +import updateAdvertiser from './updateAdvertiser'; +import updateDemandTag from './updateDemandTag'; +import updateSupplyTag from './updateSupplyTag'; +import updateTiers from './updateTiers'; +import updateWaterfall from './updateWaterfall'; +import validateAdvertiser from './validateAdvertiser'; +import validateTag from './validateTag'; + +export default combineEpics( + getInfo, + getData, + getTotal, + createAdvertiser, + createSupplyTag, + getChartData, + getBudgetingTab, + getFrequencyCapTab, + getTargetingTab, + getAdvertiserSettingsTab, + getPricingTab, + getSettingsTab, + bulkUpdateSupplyTag, + updateWaterfall, + deleteWaterfall, + getAdServers, + createDemandTag, + updateDemandTag, + initializeDemandForm, + getCountries, + getDemandTagPricing, + bulkUpdateDemandTag, + initializeSupplyForm, + updateSupplyTag, + getDataForWaterfall, + getAccoutsForWaterfall, + getLabelsForTableFilter, + getLabelsForWaterfall, + bulkCreateWaterfall, + duplicateDemandTag, + duplicateSupplyTag, + getPlatforms, + validateAdvertiser, + validateTag, + downloadCSV, + saveDataToCSV, + updateTiers, + updateAdvertiser, + initializeAdvertiserForm, + getHistoryForCSV, + getHistoryForChart, + getKeyNamesOptions, + getKeyListsOptions, + getDemandAccountById, + getSelfServeUserHubsAndDemandAccounts, + bulkUpdateViewabilityThreshold, + showConfirmModal, + getPlayers, +); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/initializeAdvertiserForm.js b/anyclip/src/modules/marketplace/account/redux/epics/initializeAdvertiserForm.js new file mode 100644 index 0000000..c323a62 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/initializeAdvertiserForm.js @@ -0,0 +1,14 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getAdvertiserSettingsTabAction, initializeAdvertiserFormDataAction } from '../slices'; + +export default (action$) => + action$.pipe( + ofType(initializeAdvertiserFormDataAction.type), + switchMap(() => { + const actions = [of(getAdvertiserSettingsTabAction())]; + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/initializeDemandForm.js b/anyclip/src/modules/marketplace/account/redux/epics/initializeDemandForm.js new file mode 100644 index 0000000..bdd5953 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/initializeDemandForm.js @@ -0,0 +1,27 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + getBudgetingTabAction, + getFrequencyCapTabAction, + getPricingTabAction, + getSettingsTabAction, + getTargetingTabAction, + initializeDemandFormDataAction, +} from '../slices'; + +export default (action$) => + action$.pipe( + ofType(initializeDemandFormDataAction.type), + switchMap(() => { + const actions = [ + of(getSettingsTabAction()), + of(getPricingTabAction()), + of(getTargetingTabAction()), + of(getBudgetingTabAction()), + of(getFrequencyCapTabAction()), + ]; + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/initializeSupplyForm.js b/anyclip/src/modules/marketplace/account/redux/epics/initializeSupplyForm.js new file mode 100644 index 0000000..21dc74e --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/initializeSupplyForm.js @@ -0,0 +1,89 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { AUTOMATIC_OPTIMIZATION } from '../../constants'; + +import getCurrentAndFuturePrice from '../../helpers/getCurrentAndFuturePrice'; +import * as selectors from '../selectors'; +import { initializeSupplyFormDataAction, setAutomaticOptimizationTabAction, setSupplyTabAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(initializeSupplyFormDataAction.type), + switchMap(() => { + const setSupply = {}; + let setAutomaticOptimization = {}; + + const state = state$.value; + const info = selectors.infoSelector(state); + const pageConfig = selectors.pageConfigSelector(state); + const isDuplicate = pageConfig?.isDuplicate; + + if (info) { + setSupply.name = isDuplicate ? `Copy_of_${info.name}` : info.name; + setSupply.source = info?.source; + setSupply.platformId = info?.platformId || ''; + setSupply.publisherDemandOnly = info.publisherDemand; + setSupply.format = info.format; + setSupply.displayType = info.displayType ?? null; + + if (info.pricing) { + const pricing = info.pricing.map((price, index) => ({ + id: index, + model: price.model, + payment: price.value, + startDate: price.startDate, + endDate: price.endDate, + })); + setSupply.pricing = isDuplicate ? getCurrentAndFuturePrice(pricing) : pricing; + } + + if (info.expenses) { + const expenses = + info.expenses?.map((price, index) => ({ + id: index, + value: price.value, + startDate: price.startDate, + endDate: price.endDate, + updatedBy: price.updatedBy, + })) ?? []; + setSupply.expenses = isDuplicate ? getCurrentAndFuturePrice(expenses) : expenses; + } + + if (info.floorPrice) { + setSupply.floorPrice = info.floorPrice; + } + + if (info.adServerUrl) { + setSupply.adServerUrl = info.adServerUrl; + } + + if (info.waterfallNote?.length) { + setSupply.waterfallNote = info.waterfallNote; + } + + if (info.automaticFloorPrice) { + setSupply.automaticFloorPrice = true; + setSupply.fillRateThreshold = info.automaticFloorPrice.fillRateThreshold; + setSupply.minimumFloor = info.automaticFloorPrice.minimumFloor; + setSupply.maximumFloor = info.automaticFloorPrice.maximumFloor; + } else { + setSupply.automaticFloorPrice = false; + } + + if (info.automaticOptimization) { + setAutomaticOptimization = { + status: AUTOMATIC_OPTIMIZATION.enabled, + ...info.automaticOptimization, + }; + } else { + setAutomaticOptimization = { + status: AUTOMATIC_OPTIMIZATION.disabled, + }; + } + } + + return concat(of(setSupplyTabAction(setSupply)), of(setAutomaticOptimizationTabAction(setAutomaticOptimization))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/saveDataToCSV.js b/anyclip/src/modules/marketplace/account/redux/epics/saveDataToCSV.js new file mode 100644 index 0000000..8406499 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/saveDataToCSV.js @@ -0,0 +1,33 @@ +import { ofType } from 'redux-observable'; +import { concat } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { saveToCSVAction } from '../slices'; +import { downloadTableToCSV } from '@/modules/marketplace/common/helpers'; + +export default (action$, state$) => + action$.pipe( + ofType(saveToCSVAction.type), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(({ payload }) => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + downloadTableToCSV({ + headers: pageConfig.tableHeaders, + cells: pageConfig.tableCells, + data: payload, + fileName: pageConfig.breadcrumbs[pageConfig.breadcrumbs.length - 1].label, + }); + + return concat(); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/showConfirmModal.js b/anyclip/src/modules/marketplace/account/redux/epics/showConfirmModal.js new file mode 100644 index 0000000..0fd4595 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/showConfirmModal.js @@ -0,0 +1,44 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { DEMAND_TAG_FREQUENCY_TAB_STATUS, DEMAND_TAG_PAGE_CONFIRM_FREQUENCY_CAP_MODAL } from '../../constants'; + +import * as selectors from '../selectors'; +import { openModalAction, showConfirmModalAction, updateDemandTagAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(showConfirmModalAction.type), + switchMap(() => { + const state = state$.value; + + const frequencyCap = selectors.frequencyCapSelector(state); + const { status, value, timeframe, amount, prevSavedData } = frequencyCap; + + if ( + prevSavedData?.status === DEMAND_TAG_FREQUENCY_TAB_STATUS.active && + status === DEMAND_TAG_FREQUENCY_TAB_STATUS.disabled + ) { + return concat( + of( + openModalAction({ + id: DEMAND_TAG_PAGE_CONFIRM_FREQUENCY_CAP_MODAL, + }), + ), + ); + } + + if (prevSavedData?.timeframe === timeframe && value / amount > prevSavedData?.value / prevSavedData?.amount) { + return concat( + of( + openModalAction({ + id: DEMAND_TAG_PAGE_CONFIRM_FREQUENCY_CAP_MODAL, + }), + ), + ); + } + + return concat(of(updateDemandTagAction())); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/updateAdvertiser.js b/anyclip/src/modules/marketplace/account/redux/epics/updateAdvertiser.js new file mode 100644 index 0000000..b3a067e --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/updateAdvertiser.js @@ -0,0 +1,155 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { + createFeesListInput, + createIncludeExcludeInput, + getIncludeOrExclude, +} from '../../helpers/createDemandTagRequestBody'; +import * as selectors from '../selectors'; +import { getInfoAction, setActiveTabIndexAction, setFieldAction, updateAdvertiserAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { uploadFileAndGetDownloadUrl$ } from '@/modules/marketplace/common/helpers/uploadToS3'; + +// const query = ` +// mutation UpdateAdvertiser( +// $id: String, +// $include: MarketplaceTargetingInputType, +// $exclude: MarketplaceTargetingInputType, +// $revShare: MarketplacePricingInputType, +// $tier: Int +// ) { +// updateAdvertiser( +// id: $id, +// include: $include, +// exclude: $exclude, +// revShare: $revShare, +// tier: $tier +// ) { +// id +// name +// } +// } +// `; + +const query = ` + mutation UpdateSelfServeAdvertiser( + $id: String, + $name: String, + $include: MarketplaceTargetingInputType, + $exclude: MarketplaceTargetingInputType, + $revShare: MarketplacePricingInputType, + $tier: Int, + $profitability: Boolean, + $frequencyCapAdjustment: Boolean, + $frequencyCapAdjustmentPerSupply: Boolean, + $pbsEnabled: Boolean, + $frequencyCapAdjustmentThreshold: Float, + $logo: String, + ) { + updateSelfServeAdvertiser( + id: $id, + name: $name, + include: $include, + exclude: $exclude, + revShare: $revShare, + tier: $tier, + profitability: $profitability, + frequencyCapAdjustment: $frequencyCapAdjustment, + frequencyCapAdjustmentPerSupply: $frequencyCapAdjustmentPerSupply, + pbsEnabled: $pbsEnabled, + frequencyCapAdjustmentThreshold: $frequencyCapAdjustmentThreshold, + logo: $logo, + ) { + id + name + } + } +`; + +const createIncludeExcludeGeoObject = (countriesList, isInclude = true) => + createIncludeExcludeInput({ + geo: { + key: 'geo', + value: getIncludeOrExclude(isInclude, countriesList), + }, + }); + +export default (action$, state$) => + action$.pipe( + ofType(updateAdvertiserAction.type), + filter(() => { + const state = state$.value; + + const info = selectors.infoSelector(state); + return info?.id; + }), + switchMap(({ payload }) => { + const state = state$.value; + + const { id } = selectors.infoSelector(state); + const advertiserSettings = selectors.advertiserSettingsSelector(state); + const userPermissions = getUserPermissionsSelector(state); + + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const { + name, + countries, + tier, + pbsEnabled, + profitability, + frequencyCapAdjustment, + frequencyCapAdjustmentPerSupply, + frequencyCapAdjustmentThreshold, + logo, + logoFile, + } = advertiserSettings; + + const updateAdvertiser$ = (logoUrl) => + gqlRequest({ + query, + variables: { + id, + name, + pbsEnabled, + frequencyCapAdjustment, + logo: logoUrl ?? logo, + include: createIncludeExcludeGeoObject(countries, true), + exclude: createIncludeExcludeGeoObject(countries, false), + ...(isAdminMP && { tier, profitability }), + ...(payload?.revShare && { + revShare: createFeesListInput([payload.revShare])[0], + }), + ...(frequencyCapAdjustment && { frequencyCapAdjustmentThreshold, frequencyCapAdjustmentPerSupply }), + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + if (!errors.length) { + actions = [ + of(getInfoAction(id)), + of(showNotificationAction({ type: TYPE_SUCCESS, message: 'Advertiser updated' })), + of(setActiveTabIndexAction(0)), + ]; + } + return concat(...actions); + }), + ); + + const stream$ = !logoFile + ? updateAdvertiser$(null) + : uploadFileAndGetDownloadUrl$({ file: logoFile }).pipe( + switchMap((downloadUrl) => updateAdvertiser$(downloadUrl)), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/updateDemandTag.js b/anyclip/src/modules/marketplace/account/redux/epics/updateDemandTag.js new file mode 100644 index 0000000..211d8dd --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/updateDemandTag.js @@ -0,0 +1,86 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createDemandTagPriceRequestBody from '../../helpers/createDemandTagPriceRequestBody'; +import createDemandTagRequestBody from '../../helpers/createDemandTagRequestBody'; +import * as selectors from '../selectors'; +import { getDemandTagPricingAction, getInfoAction, setFieldAction, updateDemandTagAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateDemandTag( + $tags: [MarketplaceDemandTagInputType] + ) { + updateDemandTag( + tags: $tags + ) { + data { + id + name + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateDemandTagAction.type), + switchMap(({ payload }) => { + const state = state$.value; + const pageConfig = selectors.pageConfigSelector(state); + // const settings = selectors.settingsSelector(state); + + const isPriceUpdate = payload?.type && payload?.data; + const tag = !isPriceUpdate ? createDemandTagRequestBody(state) : createDemandTagPriceRequestBody(state, payload); + + const stream$ = gqlRequest({ + query, + variables: { + tags: [tag], + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + if (isPriceUpdate) { + actions = [ + of(getDemandTagPricingAction(payload.type)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Demand tag price added', + }), + ), + ]; + } else { + const link = `${window.location.pathname}?accountName=${pageConfig.demandAccount.name}`; + // eslint-disable-next-line max-len + // link += `&advertiserName=${encodeURIComponent(pageConfig?.advertiser.name)}&tagName=${encodeURIComponent(settings.name)}`; + + Router.push(link); + + actions = [ + of(getInfoAction(data.updateDemandTag.data[0].id)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Demand tag updated', + }), + ), + ]; + } + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/updateSupplyTag.js b/anyclip/src/modules/marketplace/account/redux/epics/updateSupplyTag.js new file mode 100644 index 0000000..b60bb7a --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/updateSupplyTag.js @@ -0,0 +1,96 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createSupplyTagPriceRequestBody from '../../helpers/createSupplyTagPriceRequestBody'; +import createSupplyTagRequestBody from '../../helpers/createSupplyTagRequestBody'; +import * as selectors from '../selectors'; +import { getInfoAction, setFieldAction, updateSupplyTagAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation UpdateSupplyTag( + $tags: [MarketplaceSupplyTagInputType] + ) { + updateSupplyTag( + tags: $tags + ) { + data { + id + name + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateSupplyTagAction.type), + switchMap(({ payload }) => { + const state = state$.value; + const userPermissions = getUserPermissionsSelector(state); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const pageConfig = selectors.pageConfigSelector(state); + + const isPriceUpdate = payload?.data; + const tag = !isPriceUpdate + ? createSupplyTagRequestBody(state, isAdminMP) + : createSupplyTagPriceRequestBody(state, payload.data, payload.key); + + const stream$ = gqlRequest({ + query, + variables: { + tags: [tag], + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + if (isPriceUpdate) { + actions = [ + of(getInfoAction(pageConfig.id)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Supply tag price added', + }), + ), + ]; + } else { + const { updateSupplyTag } = data; + const updatedTag = updateSupplyTag.data[0]; + + const link = `/supply/${pageConfig.supplyAccount.id}/sites/${pageConfig.site.id}/tags/${updatedTag.id}`; + // eslint-disable-next-line max-len + // link += `?accountName=${encodeURIComponent(pageConfig.supplyAccount.name)}&siteName=${encodeURIComponent(pageConfig.site.name)}&tagName=${encodeURIComponent(updatedTag.name)}`; + + Router.push(link); + + actions = [ + of(getInfoAction(updatedTag.id)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Supply tag updated', + }), + ), + ]; + } + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/updateTiers.js b/anyclip/src/modules/marketplace/account/redux/epics/updateTiers.js new file mode 100644 index 0000000..b32d4b0 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/updateTiers.js @@ -0,0 +1,54 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getDataAction, updateTiersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UpdateTiers( + $supplyId: String, + $demands: [MarketplaceDemandTierInputType] + ) { + updateTiers( + supplyId: $supplyId, + demands: $demands + ) { + data { + id + created + updated + supplyId + demandId + tier + priority + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(updateTiersAction.type), + debounce(() => timer(500)), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + ...payload, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(getDataAction())]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/updateWaterfall.js b/anyclip/src/modules/marketplace/account/redux/epics/updateWaterfall.js new file mode 100644 index 0000000..cc6dabd --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/updateWaterfall.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getDataAction, updateWaterfallAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation UpdateWaterfall( + $supplyIds: [String]!, + $demandIds: [String]!, + $tier: Int, + $priority: Int + ) { + updateWaterfall( + supplyIds: $supplyIds, + demandIds: $demandIds, + tier: $tier, + priority: $priority + ) { + data { + id + created + updated + supplyId + demandId + tier + priority + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(updateWaterfallAction.type), + debounce(() => timer(500)), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + ...payload, + }, + }).pipe( + switchMap(({ errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(getDataAction())]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/validateAdvertiser.js b/anyclip/src/modules/marketplace/account/redux/epics/validateAdvertiser.js new file mode 100644 index 0000000..ba29c19 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/validateAdvertiser.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { setAdvertiserSettingsTabAction, validateAdvertiserAction } from '../slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(validateAdvertiserAction.type), + switchMap((action) => { + const state = state$.value; + + const advertiserSettings = selectors.advertiserSettingsSelector(state); + + const { name, demandAccountId, frequencyCapAdjustment, frequencyCapAdjustmentThreshold } = advertiserSettings; + + const userPermissions = getUserPermissionsSelector(state); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + if (!isAdminMP) { + if (!name?.length) { + return concat( + of( + setAdvertiserSettingsTabAction({ + errors: { + name: 'Field cannot be empty', + }, + }), + ), + ); + } + + if (!demandAccountId) { + return concat( + of( + setAdvertiserSettingsTabAction({ + errors: { + demandAccountId: 'Field cannot be empty', + }, + }), + ), + ); + } + } + + if (frequencyCapAdjustment && frequencyCapAdjustmentThreshold?.length === 0) { + return concat( + of( + setAdvertiserSettingsTabAction({ + errors: { + frequencyCapAdjustmentThreshold: 'Field cannot be empty', + }, + }), + ), + ); + } + + return concat(of(action.payload())); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/epics/validateTag.js b/anyclip/src/modules/marketplace/account/redux/epics/validateTag.js new file mode 100644 index 0000000..501f8b5 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/epics/validateTag.js @@ -0,0 +1,579 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + AUTOMATIC_OPTIMIZATION, + DEMAND_TAG_FORMAT, + DEMAND_TAG_HB_GAM_PLATFORMS, + DEMAND_TAG_PAGE, + DEMAND_TAG_PAGE_BUSINESS_MODEL, + DEMAND_TAG_PAGE_SOURCE, + DEMAND_TAG_PAGE_TYPE_VALUES, + DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE, + DEMAND_TAG_SUPPLY_CHAIN_VALUE, + DEMAND_TAG_TARGETING_VIEWABILITY_TYPE, + DEMAND_TAG_VIEWABILITY_TARGETING, + FLOOR_PRICE, + SOURCE_TAG_OPTIONS, + SUPPLY_TAG_PAGE, +} from '../../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { + setActiveTabIndexAction, + setAutomaticOptimizationTabAction, + setPricingTabAction, + setSettingsTabAction, + setSupplyTabAction, + setTargetingTabAction, + validateTagAction, +} from '../slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(validateTagAction.type), + switchMap((action) => { + const state = state$.value; + const userPermissions = getUserPermissionsSelector(state); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const pageConfig = selectors.pageConfigSelector(state); + const supply = selectors.supplySelector(state); + const automaticOptimization = selectors.automaticOptimizationSelector(state); + const settings = selectors.settingsSelector(state); + const pricing = selectors.pricingSelector(state); + const targeting = selectors.targetingSelector(state); + + const { type } = pageConfig; + const { + name: supplyName, + source: supplyTagSource, + format: supplyTagFormat, + platformId: supplyPlatformId, + floorPrice: { floor: supplyFloor, viewableFloor: supplyViewableFloor, firstRequestFloor }, + automaticFloorPrice, + fillRateThreshold, + minimumFloor, + maximumFloor, + } = supply; + + const { status: optimizationStatus, kpiGap } = automaticOptimization; + + const { + name: demandName, + source: demandTagSource, + platformId: demandPlatformId, + type: tagType, + url: adServerUrl, + platform, + platforms, + supplyChainOverride, + supplyChainValue, + supplyChainNode, + bidMappingFileName, + bidMappingFileData, + flightsDates, + floorPrice: { override: demandFloorOverride, floor: demandFloor, viewableFloor: demandViewableFloor }, + maxFloor, + videoId, + } = settings; + + const { model, source, seatId, adUnitId, type: pricingType } = pricing; + + const { viewability, viewabilityTargeting, viewabilityThreshold } = targeting; + + if (type === SUPPLY_TAG_PAGE) { + if (supplyName?.length > 250) { + return concat( + of( + setSupplyTabAction({ + errors: { + name: 'The Name field can’t exceed 250 characters', + }, + }), + ), + ); + } + + if (isAdminMP) { + if (supplyTagSource !== SOURCE_TAG_OPTIONS[0].value && !supplyPlatformId?.length) { + return concat( + of( + setSupplyTabAction({ + errors: { + platformId: 'Field cannot be empty', + }, + }), + ), + ); + } + + if (supplyTagFormat !== DEMAND_TAG_FORMAT.sponsored) { + if (!supplyViewableFloor?.toString()?.length) { + return concat( + of( + setSupplyTabAction({ + errors: { + viewableFloor: 'Required', + }, + }), + ), + ); + } + + if (supplyTagFormat !== DEMAND_TAG_FORMAT.sponsored && !supplyFloor?.toString()?.length) { + return concat( + of( + setSupplyTabAction({ + errors: { + floor: 'Required', + }, + }), + ), + ); + } + + if (!firstRequestFloor?.toString()?.length) { + return concat( + of( + setSupplyTabAction({ + errors: { + firstRequestFloor: 'Field cannot be empty', + }, + }), + ), + ); + } + } + + if (automaticFloorPrice) { + if (!fillRateThreshold?.toString()?.length) { + return concat( + of( + setSupplyTabAction({ + errors: { + fillRateThreshold: 'Field cannot be empty', + }, + }), + ), + ); + } + + if (!(+fillRateThreshold >= 0 && +fillRateThreshold <= 1)) { + return concat( + of( + setSupplyTabAction({ + errors: { + fillRateThreshold: 'Fill Rate Threshold must be 0-100', + }, + }), + ), + ); + } + + if (!minimumFloor?.toString()?.length) { + return concat( + of( + setSupplyTabAction({ + errors: { + minimumFloor: 'Field cannot be empty', + }, + }), + ), + ); + } + + if (+minimumFloor < 0) { + return concat( + of( + setSupplyTabAction({ + errors: { + minimumFloor: 'Minimum Floor must be >= 0', + }, + }), + ), + ); + } + + if (!maximumFloor?.toString()?.length) { + return concat( + of( + setSupplyTabAction({ + errors: { + maximumFloor: 'Field cannot be empty', + }, + }), + ), + ); + } + + if (+maximumFloor < +minimumFloor) { + return concat( + of( + setSupplyTabAction({ + errors: { + maximumFloor: 'Maximum Floor must be >= Minimum Floor', + }, + }), + ), + ); + } + } + + if (optimizationStatus === AUTOMATIC_OPTIMIZATION.enabled && !kpiGap?.length) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setAutomaticOptimizationTabAction({ + errors: { + kpiGap: 'Required', + }, + }), + ), + ); + } + } + } + + if (type === DEMAND_TAG_PAGE) { + if (demandName?.length > 250) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + name: 'The Name field can’t exceed 250 characters', + }, + }), + ), + ); + } + + if (demandTagSource !== SOURCE_TAG_OPTIONS[0].value && !demandPlatformId?.length) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + platformId: 'Field cannot be empty', + }, + }), + ), + ); + } + + if ( + flightsDates && + (typeof flightsDates?.startDate !== 'number' || typeof flightsDates?.endDate !== 'number') + ) { + return concat( + of( + setSettingsTabAction({ + errors: { + flightsDates: 'Required', + }, + }), + ), + ); + } + + if (tagType === DEMAND_TAG_PAGE_TYPE_VALUES.tag) { + if (!/^\S*$/.test(adServerUrl.trim())) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + url: 'Invalid URL', + }, + }), + ), + ); + } + } + + if (tagType === DEMAND_TAG_PAGE_TYPE_VALUES.hb) { + if (!platform?.connectorId?.length) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + platform: 'Required', + }, + }), + ), + ); + } + + if (platform?.connectorId?.length && platforms?.length) { + const curPlatform = platforms?.find((item) => item.id === platform.connectorId); + + const errors = curPlatform.params.reduce((acc, cur) => { + if (cur.required && !platform[cur.name]) { + return { + ...acc, + [cur.name]: 'Required', + }; + } + + if (cur.type === 'ARRAY') { + let errorText = null; + try { + JSON.parse(platform[cur.name]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + errorText = 'Invalid Array'; + } + + if (errorText) { + return { + ...acc, + [cur.name]: errorText, + }; + } + } + + return acc; + }, {}); + + if (Object.keys(errors)?.length) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + ...errors, + }, + }), + ), + ); + } + } + + if (platform?.code?.toUpperCase() === 'APS' && (!bidMappingFileName?.length || !bidMappingFileData?.length)) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + bidMapping: 'Required', + }, + }), + ), + ); + } + + if (DEMAND_TAG_HB_GAM_PLATFORMS.includes(platform?.code) && !maxFloor?.toString()?.length) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + maxFloor: 'Required', + }, + }), + ), + ); + } + + if ( + supplyChainOverride === DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled && + supplyChainValue === DEMAND_TAG_SUPPLY_CHAIN_VALUE.custom && + !supplyChainNode?.length + ) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + supplyChainNode: 'Required', + }, + }), + ), + ); + } + + if ( + supplyChainOverride === DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.enabled && + supplyChainValue === DEMAND_TAG_SUPPLY_CHAIN_VALUE.custom && + supplyChainNode?.length && + supplyChainNode?.trim()?.match(/\s+/g) + ) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + supplyChainNode: 'Supply Chain value is not valid', + }, + }), + ), + ); + } + } + + if (tagType === DEMAND_TAG_PAGE_TYPE_VALUES.mp4) { + if (!videoId?.length) { + return concat( + of(setActiveTabIndexAction(1)), + of( + setSettingsTabAction({ + errors: { + videoId: 'Required', + }, + }), + ), + ); + } + } + + if (model === DEMAND_TAG_PAGE_BUSINESS_MODEL[1].value) { + if (!source?.length) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setPricingTabAction({ + errors: { + source: 'Required', + }, + }), + ), + ); + } + + if (seatId?.length > 200) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setPricingTabAction({ + errors: { + seatId: 'The Seat Id field can’t exceed 200 characters', + }, + }), + ), + ); + } + + if (!pricingType?.length) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setPricingTabAction({ + errors: { + type: 'Required', + }, + }), + ), + ); + } + + if (adUnitId?.length > 200) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setPricingTabAction({ + errors: { + adUnitId: 'The Ad Unit Id field can’t exceed 200 characters', + }, + }), + ), + ); + } + + if (source === DEMAND_TAG_PAGE_SOURCE[0].value) { + if (!seatId?.length) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setPricingTabAction({ + errors: { + seatId: 'Required', + }, + }), + ), + ); + } + + if (!adUnitId?.length) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setPricingTabAction({ + errors: { + adUnitId: 'Required', + }, + }), + ), + ); + } + } + + if (source === DEMAND_TAG_PAGE_SOURCE[1].value) { + if (!adUnitId?.length) { + return concat( + of(setActiveTabIndexAction(2)), + of( + setPricingTabAction({ + errors: { + adUnitId: 'Required', + }, + }), + ), + ); + } + } + } + + if (demandFloorOverride === FLOOR_PRICE.override) { + if (!demandViewableFloor?.toString()?.length) { + return concat( + of( + setSettingsTabAction({ + errors: { + viewableFloor: 'Required', + }, + }), + ), + ); + } + + if (!demandFloor?.toString()?.length) { + return concat( + of( + setSettingsTabAction({ + errors: { + floor: 'Required', + }, + }), + ), + ); + } + } + + if ( + viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView] && + viewability[DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView] && + viewabilityTargeting === DEMAND_TAG_VIEWABILITY_TARGETING.enabled && + !viewabilityThreshold?.toString()?.length + ) { + return concat( + of(setActiveTabIndexAction(3)), + of( + setTargetingTabAction({ + errors: { + viewabilityThreshold: 'Required', + }, + }), + ), + ); + } + } + + return concat(of(action.payload())); + }), + ); diff --git a/anyclip/src/modules/marketplace/account/redux/selectors/index.js b/anyclip/src/modules/marketplace/account/redux/selectors/index.js new file mode 100644 index 0000000..599e4fd --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/selectors/index.js @@ -0,0 +1,124 @@ +import { + DEMAND_ADVERTISER_PAGE, + DEMAND_TAG_FORMAT, + DEMAND_TAG_FREQUENCY_TAB_STATUS, + DEMAND_TAG_HB_GAM_PLATFORMS, + DEMAND_TAG_KEY_VALUE_TARGETING_STATUS, + DEMAND_TAG_PAGE, + DEMAND_TAG_PAGE_TYPE_VALUES, + SUPPLY_TAG_PAGE, +} from '../../constants'; +import { DEMAND_ADVERTISER_SETTINGS_VALIDATION_REDUX_FIELD } from '@/modules/marketplace/account/components/AdvertiserSettings/constants'; + +import validation from '../../helpers/demandTagFormValidationRules'; +import { isFrequencyCapHasData } from '../../helpers/isFrequencyCapHasData'; +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const pageConfigSelector = (state$) => state$[nameSpace].pageConfig; +export const activeTabIndexSelector = (state$) => state$[nameSpace].activeTabIndex; +export const filterByDateSelector = (state$) => state$[nameSpace].filterByDate; +export const filterByStatusSelector = (state$) => state$[nameSpace].filterByStatus; +export const filterByCountrySelector = (state$) => state$[nameSpace].filterByCountry; +export const filterByDeviceSelector = (state$) => state$[nameSpace].filterByDevice; +export const filterByPlayerSelector = (state$) => state$[nameSpace].filterByPlayer; +export const filterByPlayerOptionsSelector = (state$) => state$[nameSpace].filterByPlayerOptions; +export const filterByLabelSelector = (state$) => state$[nameSpace].filterByLabel; +export const labelsOptionsSelector = (state$) => state$[nameSpace].labelsOptions; +export const infoSelector = (state$) => state$[nameSpace].info; +export const totalSelector = (state$) => state$[nameSpace].total; +export const dataSelector = (state$) => state$[nameSpace].data; +export const totalCountSelector = (state$) => state$[nameSpace].totalCount; +export const pageSelector = (state$) => state$[nameSpace].page; +export const rowsPerPageSelector = (state$) => state$[nameSpace].rowsPerPage; +export const searchSelector = (state$) => state$[nameSpace].search; +export const sortBySelector = (state$) => state$[nameSpace].sortBy; +export const sortOrderSelector = (state$) => state$[nameSpace].sortOrder; +export const isLoadingSelector = (state$) => state$[nameSpace].isLoading; +export const chartTabSelector = (state$) => state$[nameSpace].chartTab; +export const chartDataSelector = (state$) => state$[nameSpace].chartData; +export const chartHistorySelector = (state$) => state$[nameSpace].chartHistory; +export const isChartLoadingSelector = (state$) => state$[nameSpace].isChartLoading; +export const timeIntervalFilterSelector = (state$) => state$[nameSpace].timeIntervalFilter; +export const comparisonFilterSelector = (state$) => state$[nameSpace].comparisonFilter; +export const userHubsSelector = (state$) => state$[nameSpace].userHubs; +export const userDemandAccountsSelector = (state$) => state$[nameSpace].userDemandAccounts; +export const demandAccountInfoSelector = (state$) => state$[nameSpace].demandAccountInfo; +export const modalSelector = (state$) => state$[nameSpace].modal; +export const supplySelector = (state$) => state$[nameSpace].supply; +export const automaticOptimizationSelector = (state$) => state$[nameSpace].automaticOptimization; +export const budgetingSelector = (state$) => state$[nameSpace].budgeting; +export const frequencyCapSelector = (state$) => state$[nameSpace].frequencyCap; +export const pricingSelector = (state$) => state$[nameSpace].pricing; +export const settingsSelector = (state$) => state$[nameSpace].settings; +export const targetingSelector = (state$) => state$[nameSpace].targeting; +export const advertiserSettingsSelector = (state$) => state$[nameSpace].advertiserSettings; + +export const isDemandTagFormSubmitDisabled = ($state) => { + const settings = settingsSelector($state); + const pricing = pricingSelector($state); + const frequencyCap = frequencyCapSelector($state); + const targeting = targetingSelector($state); + const isLoading = isLoadingSelector($state); + + return !( + settings.name && + Number.isInteger(settings.defaultTier) && + pricing.model && + (pricing.fixedRpmRateTable.rows.length || + (settings.type === DEMAND_TAG_PAGE_TYPE_VALUES.hb && + !DEMAND_TAG_HB_GAM_PLATFORMS.includes(settings.platform?.code))) && + (settings.type === DEMAND_TAG_PAGE_TYPE_VALUES.tag ? settings.url : true) && + validation.validateTimeout(settings.timeout).isValid && + (frequencyCap.status === DEMAND_TAG_FREQUENCY_TAB_STATUS.active ? isFrequencyCapHasData(frequencyCap) : true) && + (pricing.publisherDemand ? !!pricing.adServingFeesTable.rows.length : true) && + (targeting.kvTargetingStatus === DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.enabled + ? targeting.kvTargeting?.every((item) => !!item?.keyName && !!(item?.valueLists?.length || item?.values?.length)) + : true) && + !isLoading + ); +}; + +export const isSupplyTagFormSubmitDisabled = ($state) => { + const supply = supplySelector($state); + const pageConfig = pageConfigSelector($state); + const isLoading = isLoadingSelector($state); + + return !( + supply.name && + (pageConfig.isCreatingNew ? supply.pricing.length : true) && + (supply.format === DEMAND_TAG_FORMAT.display ? supply.displayType : true) && + !isLoading + ); +}; + +export const isFormSubmitDisabledSelector = ($state) => { + const pageConfig = pageConfigSelector($state); + + if (pageConfig && pageConfig.type === DEMAND_TAG_PAGE) { + return isDemandTagFormSubmitDisabled($state); + } + + if (pageConfig && pageConfig.type === SUPPLY_TAG_PAGE) { + return isSupplyTagFormSubmitDisabled($state); + } + + if (pageConfig && pageConfig.type === DEMAND_ADVERTISER_PAGE) { + return false; + } + + return true; +}; + +const advertiserSettingsValidationSelector = createFormSelector( + DEMAND_ADVERTISER_SETTINGS_VALIDATION_REDUX_FIELD, + nameSpace, +); + +export const advertiserSettingsValidationScrollFieldSelector = (state) => + advertiserSettingsValidationSelector.getScrollField(state); +export const advertiserSettingsValidationSchemeSelector = (state) => + advertiserSettingsValidationSelector.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/marketplace/account/redux/slices/index.js b/anyclip/src/modules/marketplace/account/redux/slices/index.js new file mode 100644 index 0000000..da0f572 --- /dev/null +++ b/anyclip/src/modules/marketplace/account/redux/slices/index.js @@ -0,0 +1,564 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { FILTERS } from '../../../common/constants'; +import { + AUTOMATIC_OPTIMIZATION, + AUTOMATIC_OPTIMIZATION_FREQUENCY, + AUTOMATIC_OPTIMIZATION_KPI_VIDEO, + AUTOMATIC_OPTIMIZATION_PERIOD, + AUTOMATIC_OPTIMIZATION_TIERS, + CHART_TABS, + DEMAND_TAG_DEFAULT_TIER_VALUES, + DEMAND_TAG_FORMAT, + DEMAND_TAG_FREQUENCY_CAP_TIMEFRAME_LIST, + DEMAND_TAG_FREQUENCY_CAP_TYPE_LIST, + DEMAND_TAG_FREQUENCY_TAB_STATUS, + DEMAND_TAG_KEY_VALUE_TARGETING_STATUS, + DEMAND_TAG_PAGE_AD_SERVER_TYPE_VALUES, + DEMAND_TAG_PAGE_BUSINESS_MODEL, + DEMAND_TAG_PAGE_DEVICE_LIST, + DEMAND_TAG_PAGE_OS_LIST, + DEMAND_TAG_PAGE_TYPE_VALUES, + DEMAND_TAG_PRICING_TYPE, + DEMAND_TAG_PRIORITY, + DEMAND_TAG_STATUS, + DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE, + DEMAND_TAG_SUPPLY_CHAIN_VALUE, + DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE, + DEMAND_TAG_TARGETING_VIEWABILITY_TYPE, + DEMAND_TAG_VIEWABILITY_TARGETING, + FLOOR_PRICE, + SOURCE_TAG_OPTIONS, + STATUS_ACTIVE, + SUPPLY_TAG_PUBLISHER_DEMAND_ONLY, +} from '../../constants'; +import { DEMAND_ADVERTISER_SETTINGS_VALIDATION_REDUX_FIELD } from '@/modules/marketplace/account/components/AdvertiserSettings/constants'; + +import createFormSlice from '@/modules/@common/Form/redux/slices'; +import { validationScheme as advertiserSettingsValidationScheme } from '@/modules/marketplace/account/components/AdvertiserSettings/helpers/validationScheme'; + +export const advertiserSettingsValidationSlice = createFormSlice( + DEMAND_ADVERTISER_SETTINGS_VALIDATION_REDUX_FIELD, + advertiserSettingsValidationScheme, +); + +const initialModalState = { + id: null, + data: [], + totalCount: 0, + page: 0, + search: '', + accountsOptions: [], + status: { + ...STATUS_ACTIVE, + }, + isFetchingData: false, + rate: '', + model: null, + label: null, + labelsOptions: [], + displayType: null, +}; + +const initialState = { + pageConfig: null, + activeTabIndex: 0, + filterByDate: FILTERS[0].value, + filterByStatus: null, + filterByCountry: null, + filterByDevice: null, + filterByLabel: null, + filterByPlayer: { label: 'All Players', value: null }, + filterByPlayerOptions: [], + labelsOptions: [], + info: null, + total: {}, + data: [], + totalCount: 0, + page: 0, + rowsPerPage: 25, + search: '', + sortBy: 'REQUESTS', + sortOrder: 'DESC', + isLoading: false, + chartTab: CHART_TABS[0].value, + chartData: [], + chartHistory: [], + isChartLoading: false, + timeIntervalFilter: null, + comparisonFilter: null, + userHubs: [], + userDemandAccounts: [], + demandAccountInfo: null, + modal: { + ...initialModalState, + }, + supply: { + name: '', + source: SOURCE_TAG_OPTIONS[0].value, + displayType: null, + platformId: '', + floorPrice: { + floor: '', + viewableFloor: '', + firstRequestFloor: '', + }, + adServerUrl: '', + waterfallNote: '', + pricing: [], + expenses: [], + format: DEMAND_TAG_FORMAT.video, // video || display + publisherDemandOnly: SUPPLY_TAG_PUBLISHER_DEMAND_ONLY.disabled, + automaticFloorPrice: false, + fillRateThreshold: '', + minimumFloor: '', + maximumFloor: '', + errors: {}, + }, + automaticOptimization: { + status: AUTOMATIC_OPTIMIZATION.enabled, + kpi: AUTOMATIC_OPTIMIZATION_KPI_VIDEO[6].value, + period: AUTOMATIC_OPTIMIZATION_PERIOD[5].value, + frequency: AUTOMATIC_OPTIMIZATION_FREQUENCY[2].value, + hbTiers: AUTOMATIC_OPTIMIZATION_TIERS[1].value, + kpiGap: '45', + errors: {}, + }, + budgeting: { + budgetingTabList: [], + isInitializeFromInfo: false, + }, + frequencyCap: { + status: DEMAND_TAG_FREQUENCY_TAB_STATUS.active, + type: DEMAND_TAG_FREQUENCY_CAP_TYPE_LIST.request, + value: 2, + timeframe: DEMAND_TAG_FREQUENCY_CAP_TIMEFRAME_LIST[0].value, + amount: 1, + prevSavedData: null, + }, + pricing: { + model: DEMAND_TAG_PAGE_BUSINESS_MODEL[0].value, + source: null, + seatId: '', + type: null, + adUnitId: '', + fixedRpmRateList: [], + additionalFeesList: [], + fixedRpmRateTable: { + rows: [], + page: 0, + totalCount: 0, + rowsPerPage: 5, // value re-initialized in getPricingTab epic + type: DEMAND_TAG_PRICING_TYPE.price, + }, + adServingFeesTable: { + rows: [], + page: 0, + totalCount: 0, + rowsPerPage: 5, // value re-initialized in getPricingTab epic + type: DEMAND_TAG_PRICING_TYPE.adServing, + }, + additionalFeesTable: { + rows: [], + page: 0, + totalCount: 0, + rowsPerPage: 5, // value re-initialized in getPricingTab epic + type: DEMAND_TAG_PRICING_TYPE.fee, + }, + adRequestFeesTable: { + rows: [], + page: 0, + totalCount: 0, + rowsPerPage: 5, // value re-initialized in getPricingTab epic + type: DEMAND_TAG_PRICING_TYPE.adRequest, + }, + publisherDemand: null, + errors: {}, + }, + settings: { + name: '', + source: SOURCE_TAG_OPTIONS[0].value, + platformId: '', + format: DEMAND_TAG_FORMAT.video, // video || display + status: DEMAND_TAG_STATUS.enabled, // enabled || disabled + flightsDates: null, + uid: '', + advertiser: '', + demandAccount: '', + type: DEMAND_TAG_PAGE_TYPE_VALUES.tag, // tag || hb + supplyChainOverride: DEMAND_TAG_SUPPLY_CHAIN_OVERRIDE.disabled, // enabled || disabled + supplyChainValue: DEMAND_TAG_SUPPLY_CHAIN_VALUE.blank, // blank || custom + supplyChainNode: '', + floorPrice: { + override: FLOOR_PRICE.publisherDefault, + floor: '', + viewableFloor: '', + }, + maxFloor: 15, + adjustFloor: true, + bidMappingFileName: '', + bidMappingFileData: [], + adServerType: DEMAND_TAG_PAGE_AD_SERVER_TYPE_VALUES.none, // none || list + adServer: {}, + adServersList: [], // options for select + url: '', + priority: DEMAND_TAG_PRIORITY.openAuction, + defaultTier: DEMAND_TAG_DEFAULT_TIER_VALUES[3].value, + labels: [], + timeout: 20000, + eventPixelsList: [], + platform: {}, + platforms: [], + videoId: '', + clickThroughUrl: '', + errors: {}, + }, + targeting: { + viewability: { + [DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.inView]: true, + [DEMAND_TAG_TARGETING_VIEWABILITY_TYPE.nonInView]: true, + }, + viewabilityTargeting: DEMAND_TAG_VIEWABILITY_TARGETING.disabled, + viewabilityThreshold: '', + playerSize: { + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.xs]: true, + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.s]: true, + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.m]: true, + [DEMAND_TAG_TARGETING_PLAYER_SIZE_TYPE.l]: true, + }, + devices: DEMAND_TAG_PAGE_DEVICE_LIST.map((device) => ({ ...device, include: true })), + countries: [], + os: DEMAND_TAG_PAGE_OS_LIST.map((device) => ({ ...device, include: true })), + browsers: [], + countriesList: [], // options for select, used also in advertiser settings tab + kvTargetingStatus: DEMAND_TAG_KEY_VALUE_TARGETING_STATUS.disabled, // enabled || disabled + kvTargeting: [], + keyNamesOptions: [], + keyListsOptions: [], + errors: {}, + }, + advertiserSettings: { + countries: [], + tier: 2, + name: '', + demandAccountId: null, + revShare: [], + pbsEnabled: false, + profitability: true, + frequencyCapAdjustmentPerSupply: true, + frequencyCapAdjustment: true, + frequencyCapAdjustmentThreshold: 0.01, + errors: {}, + logo: '', + logoFile: null, + }, + ...advertiserSettingsValidationSlice.state, +}; + +export const slice = createSlice({ + name: '@@account/ACCOUNT', + initialState, + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setDefaultConfigAction: (state, action) => { + const config = { ...initialState, ...(action.payload ?? {}) }; + Object.keys(config).forEach((key) => { + state[key] = config[key]; + }); + }, + setActiveTabIndexAction: (state, action) => { + state.activeTabIndex = action.payload; + }, + setFilterByDateAction: (state, action) => { + state.filterByDate = action.payload; + }, + setFilterByStatusAction: (state, action) => { + state.filterByStatus = action.payload; + }, + setFilterByCountryAction: (state, action) => { + state.filterByCountry = action.payload; + }, + setFilterByDeviceAction: (state, action) => { + state.filterByDevice = action.payload; + }, + setFilterByLabelAction: (state, action) => { + state.filterByLabel = action.payload; + }, + setFilterByPlayerAction: (state, action) => { + state.filterByPlayer = action.payload; + }, + setLabelsOptionsAction: (state, action) => { + state.labelsOptions = action.payload; + }, + setRowsPerPageAction: (state, action) => { + state.rowsPerPage = action.payload; + }, + setSearchAction: (state, action) => { + state.search = action.payload; + }, + setSortByAction: (state, action) => { + state.sortBy = action.payload; + }, + setSortOrderAction: (state, action) => { + state.sortOrder = action.payload; + }, + setChartTabAction: (state, action) => { + state.chartTab = action.payload; + }, + setTimeIntervalFilterAction: (state, action) => { + state.timeIntervalFilter = action.payload; + }, + setComparisonFilterAction: (state, action) => { + state.comparisonFilter = action.payload; + }, + openModalAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.modal[key] = action.payload[key]; + }); + }, + setModalInfoAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.modal[key] = action.payload[key]; + }); + }, + closeModalAction: (state) => { + Object.keys(initialModalState).forEach((key) => { + state.modal[key] = initialModalState[key]; + }); + }, + setBudgetingTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.budgeting[key] = action.payload[key]; + }); + }, + setFrequencyCapTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.frequencyCap[key] = action.payload[key]; + }); + }, + setPricingTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.pricing[key] = action.payload[key]; + }); + }, + setSettingsTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.settings[key] = action.payload[key]; + }); + }, + setTargetingTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.targeting[key] = action.payload[key]; + }); + }, + setAdvertiserSettingsTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.advertiserSettings[key] = action.payload[key]; + }); + }, + setSupplyTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.supply[key] = action.payload[key]; + }); + }, + setAutomaticOptimizationTabAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state.automaticOptimization[key] = action.payload[key]; + }); + }, + resetDemandFormAction: (state) => { + Object.keys(initialState.settings).forEach((key) => { + state.settings[key] = initialState.settings[key]; + }); + Object.keys(initialState.frequencyCap).forEach((key) => { + state.frequencyCap[key] = initialState.frequencyCap[key]; + }); + Object.keys(initialState.budgeting).forEach((key) => { + state.budgeting[key] = initialState.budgeting[key]; + }); + + const targeting = { + ...initialState.targeting, + countriesList: state.targeting.countriesList, + }; + + Object.keys(targeting).forEach((key) => { + state.targeting[key] = targeting[key]; + }); + }, + resetSupplyFormAction: (state) => { + const supply = { + ...initialState.supply, + price: state.supply.price, + }; + + Object.keys(supply).forEach((key) => { + state.supply[key] = supply[key]; + }); + }, + setHubsAndDemandAccountsAction: (state, action) => { + state.userHubs = action.payload?.userHubs ?? []; + state.userDemandAccounts = action.payload?.userDemandAccounts ?? []; + }, + + getInfoAction: (state) => state, + getDataAction: (state) => state, + getTotalAction: (state) => state, + createSupplyTagsAction: (state) => state, + getChartDataAction: (state) => state, + updateWaterfallAction: (state) => state, + updateTiersAction: (state) => state, + deleteWaterfallAction: (state) => state, + getDataForWaterfallAction: (state) => state, + getAccountsForWaterfallAction: (state) => state, + getLabelsForTableFilterAction: (state) => state, + getLabelsForWaterfallAction: (state) => state, + bulkCreateWaterfallAction: (state) => state, + refreshPageDataByIntervalAction: (state) => state, + validateTagAction: (state) => state, + downloadCSVAction: (state) => state, + saveToCSVAction: (state) => state, + getHistoryForCSVAction: (state) => state, + getHistoryForChartAction: (state) => state, + showConfirmModalAction: (state) => state, + // demand + getBudgetingTabAction: (state) => state, + getFrequencyCapTabAction: (state) => state, + getPricingTabAction: (state) => state, + getSettingsTabAction: (state) => state, + getTargetingTabAction: (state) => state, + getAdvertiserSettingsTabAction: (state) => state, + getAdServerListsAction: (state) => state, + getCountriesListAction: (state) => state, + createDemandTagAction: (state) => state, + updateDemandTagAction: (state) => state, + bulkUpdateDemandTagAction: (state) => state, + bulkUpdateViewabilityThresholdAction: (state) => state, + initializeDemandFormDataAction: (state) => state, + duplicateDemandTagAction: (state) => state, + getDemandTagPricingAction: (state) => state, + getPlatformsAction: (state) => state, + createAdvertiserAction: (state) => state, + updateAdvertiserAction: (state) => state, + initializeAdvertiserFormDataAction: (state) => state, + getKeyNamesOptionsAction: (state) => state, + getKeyListsOptionsAction: (state) => state, + getDemandAccountByIdAction: (state) => state, + validateAdvertiserAction: (state) => state, + getHubsAndDemandAccountsAction: (state) => state, + // supply + createSupplyTagAction: (state) => state, + initializeSupplyFormDataAction: (state) => state, + bulkUpdateSupplyTagAction: (state) => state, + updateSupplyTagAction: (state) => state, + duplicateSupplyTagAction: (state) => state, + getAutomaticOptimizationTabAction: (state) => state, + getFilterByPlayerOptionsAction: (state) => state, + // validations + advertiserSettingsValidationSetScrollToFieldNameAction: + advertiserSettingsValidationSlice.actions.setScrollToFieldAction, + advertiserSettingsValidationSetErrorByPropAction: + advertiserSettingsValidationSlice.actions.updateValidationSchemeAction, + advertiserSettingsValidationRemoveErrorByPropAction: + advertiserSettingsValidationSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setFieldAction, + setDefaultConfigAction, + setActiveTabIndexAction, + setFilterByDateAction, + setFilterByStatusAction, + setFilterByCountryAction, + setFilterByDeviceAction, + setFilterByLabelAction, + setFilterByPlayerAction, + setLabelsOptionsAction, + setRowsPerPageAction, + setSearchAction, + setSortByAction, + setSortOrderAction, + setChartTabAction, + setTimeIntervalFilterAction, + setComparisonFilterAction, + openModalAction, + setModalInfoAction, + closeModalAction, + setBudgetingTabAction, + setFrequencyCapTabAction, + setPricingTabAction, + setSettingsTabAction, + setTargetingTabAction, + setAdvertiserSettingsTabAction, + setSupplyTabAction, + setAutomaticOptimizationTabAction, + resetDemandFormAction, + resetSupplyFormAction, + setHubsAndDemandAccountsAction, + + getInfoAction, + getDataAction, + getTotalAction, + createSupplyTagsAction, + getChartDataAction, + updateWaterfallAction, + updateTiersAction, + deleteWaterfallAction, + getDataForWaterfallAction, + getAccountsForWaterfallAction, + getLabelsForTableFilterAction, + getLabelsForWaterfallAction, + bulkCreateWaterfallAction, + refreshPageDataByIntervalAction, + validateTagAction, + downloadCSVAction, + saveToCSVAction, + getHistoryForCSVAction, + getHistoryForChartAction, + showConfirmModalAction, + // demand + getBudgetingTabAction, + getFrequencyCapTabAction, + getPricingTabAction, + getSettingsTabAction, + getTargetingTabAction, + getAdvertiserSettingsTabAction, + getAdServerListsAction, + getCountriesListAction, + createDemandTagAction, + updateDemandTagAction, + bulkUpdateDemandTagAction, + bulkUpdateViewabilityThresholdAction, + initializeDemandFormDataAction, + duplicateDemandTagAction, + getDemandTagPricingAction, + getPlatformsAction, + createAdvertiserAction, + updateAdvertiserAction, + initializeAdvertiserFormDataAction, + getKeyNamesOptionsAction, + getKeyListsOptionsAction, + getDemandAccountByIdAction, + validateAdvertiserAction, + getHubsAndDemandAccountsAction, + // supply + createSupplyTagAction, + initializeSupplyFormDataAction, + bulkUpdateSupplyTagAction, + updateSupplyTagAction, + duplicateSupplyTagAction, + getAutomaticOptimizationTabAction, + getFilterByPlayerOptionsAction, + + // validation for advertiser demand settings page + advertiserSettingsValidationSetScrollToFieldNameAction, + advertiserSettingsValidationSetErrorByPropAction, + advertiserSettingsValidationRemoveErrorByPropAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/marketplace/accounts/components/Accounts.module.scss b/anyclip/src/modules/marketplace/accounts/components/Accounts.module.scss similarity index 100% rename from src/modules/marketplace/accounts/components/Accounts.module.scss rename to anyclip/src/modules/marketplace/accounts/components/Accounts.module.scss diff --git a/src/modules/marketplace/accounts/components/Filters/Filters.module.scss b/anyclip/src/modules/marketplace/accounts/components/Filters/Filters.module.scss similarity index 100% rename from src/modules/marketplace/accounts/components/Filters/Filters.module.scss rename to anyclip/src/modules/marketplace/accounts/components/Filters/Filters.module.scss diff --git a/src/modules/marketplace/accounts/components/Filters/index.jsx b/anyclip/src/modules/marketplace/accounts/components/Filters/index.jsx similarity index 100% rename from src/modules/marketplace/accounts/components/Filters/index.jsx rename to anyclip/src/modules/marketplace/accounts/components/Filters/index.jsx diff --git a/src/modules/marketplace/accounts/components/Modals/DisclaimerModal.jsx b/anyclip/src/modules/marketplace/accounts/components/Modals/DisclaimerModal.jsx similarity index 100% rename from src/modules/marketplace/accounts/components/Modals/DisclaimerModal.jsx rename to anyclip/src/modules/marketplace/accounts/components/Modals/DisclaimerModal.jsx diff --git a/src/modules/marketplace/accounts/components/Modals/DisclaimerModal.module.scss b/anyclip/src/modules/marketplace/accounts/components/Modals/DisclaimerModal.module.scss similarity index 100% rename from src/modules/marketplace/accounts/components/Modals/DisclaimerModal.module.scss rename to anyclip/src/modules/marketplace/accounts/components/Modals/DisclaimerModal.module.scss diff --git a/src/modules/marketplace/accounts/components/index.jsx b/anyclip/src/modules/marketplace/accounts/components/index.jsx similarity index 100% rename from src/modules/marketplace/accounts/components/index.jsx rename to anyclip/src/modules/marketplace/accounts/components/index.jsx diff --git a/src/modules/marketplace/accounts/components/usePageConfig.jsx b/anyclip/src/modules/marketplace/accounts/components/usePageConfig.jsx similarity index 100% rename from src/modules/marketplace/accounts/components/usePageConfig.jsx rename to anyclip/src/modules/marketplace/accounts/components/usePageConfig.jsx diff --git a/anyclip/src/modules/marketplace/accounts/constants/demandAccountsPage.js b/anyclip/src/modules/marketplace/accounts/constants/demandAccountsPage.js new file mode 100644 index 0000000..9c6d4ca --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/constants/demandAccountsPage.js @@ -0,0 +1,1080 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +export const DEMAND_ACCOUNTS_PAGE_PATHNAME = '/demand'; + +export const DEMAND_ACCOUNTS_PAGE_TYPE = 'DEMAND_ACCOUNTS_PAGE'; + +export const DEMAND_ACCOUNTS_PAGE_HEADER = 'Demand Accounts'; + +export const DEMAND_ACCOUNTS_PAGE_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'ADVERTISERS', + label: 'Advertisers', + isSortable: true, + }, + { + id: 'DEMANDS', + label: 'Demand Tags', + isSortable: true, + }, + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_RPM', + label: 'Gross RPM', + isSortable: true, + align: 'right', + withGap: true, + }, +]; + +export const DEMAND_ACCOUNTS_PAGE_CELLS = [ + { + key: 'name', + }, + { + key: 'advertisers', + }, + { + key: 'demands', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, +]; + +export const DEMAND_ACCOUNTS_PAGE_FIELDS = [ + 'REQUESTS', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', +]; + +export const DEMAND_ACCOUNTS_PAGE_GRAPHQL_QUERY = ` + query DemandAccountsDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean + ) { + demandAccountsData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + advertisers + demands + fields { + REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + } + } + } + } +`; + +export const DEMAND_ACCOUNTS_PAGE_ADVERTISERS_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + }, + { + id: 'DEMANDS', + label: 'Demand Tags', + isSortable: true, + }, + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PROFIT', + label: 'Profit', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_RPM', + label: 'Gross RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS_VIEWABILITY', + label: 'Impressions viewability', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'REQ_ERPM', + label: 'Req eRPM', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'REQ_EPPM', + label: 'Req ePPM', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'REQ_NET_ERPM', + label: 'Req Pub NET eRPM', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for self serve + { + id: 'REQ_GROSS_ERPM', + label: 'Req Gross eRPM ', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, +]; + +export const DEMAND_ACCOUNTS_PAGE_ADVERTISERS_CELLS = [ + { + key: 'name', + }, + { + key: 'demands', + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PROFIT', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS_VIEWABILITY', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + // only for admin + { + key: 'fields.REQ_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + // only for admin + { + key: 'fields.REQ_EPPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + // only for admin + { + key: 'fields.REQ_NET_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + // only for self serve + { + key: 'fields.REQ_GROSS_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, +]; + +export const DEMAND_ACCOUNTS_PAGE_ADVERTISERS_FIELDS = [ + 'REQUESTS', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'ERPM', + 'IMPRESSIONS_VIEWABILITY', + 'COMPLETION_RATE', + 'CTR', + 'REQ_ERPM', + 'REQ_EPPM', + 'REQ_NET_ERPM', + 'REQ_GROSS_ERPM', + 'PROFIT', +]; + +export const DEMAND_ACCOUNTS_PAGE_ADVERTISERS_GRAPHQL_QUERY = ` +query AdvertiserDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean +) { + advertiserData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + demands + daccountId + profit + hasApsTags + fields { + REQUESTS { + value + change + } + + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + REQ_ERPM { + value + change + } + REQ_EPPM { + value + change + } + REQ_NET_ERPM { + value + change + } + REQ_GROSS_ERPM { + value + change + } + PROFIT { + value + change + } + } + } + } +} +`; + +export const DEMAND_ACCOUNTS_PAGE_TAGS_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + }, + { + id: 'SUPPLIES', + label: 'Supply Tags', + isSortable: true, + }, + { + id: 'targeting', + label: 'Targeting', + isSortable: false, + }, + { + id: 'REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OPPORTUNITIES', + label: 'Opportunities', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PROFIT', + label: 'Profit', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_RPM', + label: 'Gross RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS_VIEWABILITY', + label: 'Impressions viewability', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'REQ_ERPM', + label: 'Req eRPM', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'REQ_EPPM', + label: 'Req ePPM', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'REQ_NET_ERPM', + label: 'Req Pub NET eRPM', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for self serve + { + id: 'REQ_GROSS_ERPM', + label: 'Req Gross eRPM ', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, + { + id: 'CREATED', + label: 'Creation Date', + isSortable: true, + }, +]; + +export const DEMAND_ACCOUNTS_PAGE_TAGS_CELLS = [ + { + key: 'name', + }, + { + key: 'supplies', + }, + { + key: [ + 'include', + 'exclude', + 'frequency', + 'advertiserInclude', + 'advertiserExclude', + 'waterfallSkip', + 'viewabilityThreshold', + 'kvTargeting', + ], + }, + { + key: 'fields.REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.OPPORTUNITIES', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PROFIT', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS_VIEWABILITY', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + // only for admin + { + key: 'fields.REQ_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + // only for admin + { + key: 'fields.REQ_EPPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + // only for admin + { + key: 'fields.REQ_NET_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + // only for self serve + { + key: 'fields.REQ_GROSS_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'created', + }, +]; + +export const DEMAND_ACCOUNTS_PAGE_TAGS_FIELDS = [ + 'REQUESTS', + 'OPPORTUNITIES', + 'IMPRESSIONS', + 'REQUESTS_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'ERPM', + 'IMPRESSIONS_VIEWABILITY', + 'COMPLETION_RATE', + 'CTR', + 'REQ_ERPM', + 'REQ_EPPM', + 'REQ_NET_ERPM', + 'REQ_GROSS_ERPM', + 'PROFIT', +]; + +export const DEMAND_ACCOUNTS_PAGE_TAGS_GRAPHQL_QUERY = ` +query DemandTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean +) { + demandTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + supplies + aps + advertiserId + daccountId + created + waterfallSkip + viewabilityThreshold + profit + include { + domains + geo + os + browsers + playerSizes + viewability + devices + } + exclude { + domains + geo + os + browsers + devices + } + frequency { + status + value + amount + type + timeframe + } + advertiserInclude { + geo + } + advertiserExclude { + geo + } + kvTargeting { + state + type + key + values + listNames + keyName + } + fields { + REQUESTS { + value + change + } + OPPORTUNITIES { + value + change + } + IMPRESSIONS { + value + change + } + REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + REQ_ERPM { + value + change + } + REQ_EPPM { + value + change + } + REQ_NET_ERPM { + value + change + } + REQ_GROSS_ERPM { + value + change + } + PROFIT { + value + change + } + } + } + } +} +`; + +export const DEMAND_ACCOUNTS_PAGE_CONFIG = { + type: DEMAND_ACCOUNTS_PAGE_TYPE, + pathname: DEMAND_ACCOUNTS_PAGE_PATHNAME, + header: DEMAND_ACCOUNTS_PAGE_HEADER, + rowsPerPageOptions: [10, 25, 50, 100, 200], + tabs: [ + { + label: 'Demand Accounts', + dataId: 'accounts', + tableHeaders: DEMAND_ACCOUNTS_PAGE_HEADERS, + tableCells: DEMAND_ACCOUNTS_PAGE_CELLS, + graphQuery: DEMAND_ACCOUNTS_PAGE_GRAPHQL_QUERY, + fields: DEMAND_ACCOUNTS_PAGE_FIELDS, + createRowLink: ({ pathname, id, name }) => `${pathname}/${id}?accountName=${encodeURIComponent(name)}`, + }, + { + label: 'Advertisers', + dataId: 'advertisers', + tableHeaders: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_HEADERS.filter((item) => !['REQ_GROSS_ERPM'].includes(item.id)), + tableCells: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_CELLS.filter( + (item) => !['fields.REQ_GROSS_ERPM'].includes(item.key), + ), + graphQuery: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_GRAPHQL_QUERY, + fields: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_FIELDS, + createRowLink: ({ pathname, id, name, daccountId }) => + `${pathname}/${daccountId}/advertisers/${id}?advertiserName=${encodeURIComponent(name)}`, + tabPlusButton: { + label: 'Advertiser', + onClick: (router) => { + router.push({ + pathname: `${router.pathname}/new/advertisers/new`, + }); + }, + }, + }, + { + label: 'Demand Tags', + dataId: 'tags', + tableHeaders: DEMAND_ACCOUNTS_PAGE_TAGS_HEADERS.filter((item) => !['REQ_GROSS_ERPM'].includes(item.id)), + tableCells: DEMAND_ACCOUNTS_PAGE_TAGS_CELLS.filter((item) => !['fields.REQ_GROSS_ERPM'].includes(item.key)), + graphQuery: DEMAND_ACCOUNTS_PAGE_TAGS_GRAPHQL_QUERY, + fields: DEMAND_ACCOUNTS_PAGE_TAGS_FIELDS, + createRowLink: ({ pathname, id, name, daccountId, advertiserId }) => + `${pathname}/${daccountId}/advertisers/${advertiserId}/tags/${id}?tagName=${encodeURIComponent(name)}`, + }, + ], + selfServeTabs: [ + { + label: 'Advertisers', + dataId: 'advertisers', + tableHeaders: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_HEADERS.filter( + (item) => !['REVENUE', 'RPM', 'PROFIT', 'REQ_ERPM', 'REQ_EPPM', 'REQ_NET_ERPM'].includes(item.id), + ).map((item) => { + if (item.id === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.id === 'GROSS_RPM') { + return { ...item, label: 'Ad RPM' }; + } + if (item.id === 'REQ_GROSS_ERPM') { + return { ...item, label: 'Req eRPM' }; + } + return item; + }), + tableCells: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_CELLS.filter( + (item) => + ![ + 'fields.REVENUE', + 'fields.RPM', + 'fields.PROFIT', + 'fields.REQ_ERPM', + 'fields.REQ_EPPM', + 'fields.REQ_NET_ERPM', + ].includes(item.key), + ), + graphQuery: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_GRAPHQL_QUERY, + fields: DEMAND_ACCOUNTS_PAGE_ADVERTISERS_FIELDS, + createRowLink: ({ pathname, id, name, daccountId }) => + `${pathname}/${daccountId}/advertisers/${id}?advertiserName=${encodeURIComponent(name)}`, + tabPlusButton: { + label: 'Advertiser', + onClick: (router) => { + router.push({ + pathname: `${router.pathname}/new/advertisers/new`, + }); + }, + }, + }, + { + label: 'Demand Tags', + dataId: 'tags', + tableHeaders: DEMAND_ACCOUNTS_PAGE_TAGS_HEADERS.filter( + (item) => !['REVENUE', 'RPM', 'PROFIT', 'REQ_ERPM', 'REQ_EPPM', 'REQ_NET_ERPM'].includes(item.id), + ).map((item) => { + if (item.id === 'GROSS_REVENUE') { + return { ...item, label: 'Pub Revenue' }; + } + if (item.id === 'GROSS_RPM') { + return { ...item, label: 'Ad RPM' }; + } + if (item.id === 'REQ_GROSS_ERPM') { + return { ...item, label: 'Req eRPM' }; + } + return item; + }), + tableCells: DEMAND_ACCOUNTS_PAGE_TAGS_CELLS.filter( + (item) => + ![ + 'fields.REVENUE', + 'fields.RPM', + 'fields.PROFIT', + 'fields.REQ_ERPM', + 'fields.REQ_EPPM', + 'fields.REQ_NET_ERPM', + ].includes(item.key), + ), + graphQuery: DEMAND_ACCOUNTS_PAGE_TAGS_GRAPHQL_QUERY, + fields: DEMAND_ACCOUNTS_PAGE_TAGS_FIELDS, + createRowLink: ({ pathname, id, name, daccountId, advertiserId }) => + `${pathname}/${daccountId}/advertisers/${advertiserId}/tags/${id}?tagName=${encodeURIComponent(name)}`, + }, + ], +}; diff --git a/anyclip/src/modules/marketplace/accounts/constants/index.js b/anyclip/src/modules/marketplace/accounts/constants/index.js new file mode 100644 index 0000000..dcffdd7 --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/constants/index.js @@ -0,0 +1,4 @@ +export const DISCLAIMER_MODAL = 'DISCLAIMER_MODAL'; + +export * from './demandAccountsPage'; +export * from './supplyAccountsPage'; diff --git a/anyclip/src/modules/marketplace/accounts/constants/supplyAccountsPage.js b/anyclip/src/modules/marketplace/accounts/constants/supplyAccountsPage.js new file mode 100644 index 0000000..1af8790 --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/constants/supplyAccountsPage.js @@ -0,0 +1,1115 @@ +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +export const SUPPLY_ACCOUNTS_PAGE_PATHNAME = '/supply'; + +export const SUPPLY_ACCOUNTS_PAGE_TYPE = 'SUPPLY_ACCOUNTS_PAGE'; + +export const SUPPLY_ACCOUNTS_PAGE_HEADER = 'Supply Accounts'; + +export const SUPPLY_ACCOUNTS_PAGE_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + defaultSortOrder: SORT_ASC, + }, + { + id: 'SITES', + label: 'Sites', + isSortable: true, + }, + { + id: 'PLAYER_LOADS', + label: 'Player Loads', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_REVENUE', + label: 'Pub NET Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OVERALL_FILL', + label: 'Overall Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_PLAYER_ERPM', + label: 'Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PLAYER_ERPM', + label: 'Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'GROSS_PLAYER_ERPM', + label: 'Gross Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_PLAYER_ERPM', + label: 'Pub NET Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, +]; + +export const SUPPLY_ACCOUNTS_PAGE_CELLS = [ + { + key: 'name', + }, + { + key: 'sites', + }, + { + key: 'fields.PLAYER_LOADS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.OVERALL_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.PUB_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, +]; + +export const SUPPLY_ACCOUNTS_PAGE_FIELDS = [ + 'PLAYER_LOADS', + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'OVERALL_FILL', + 'REVENUE', + 'GROSS_REVENUE', + 'PUB_PLAYER_ERPM', + 'PLAYER_ERPM', + 'GROSS_PLAYER_ERPM', + 'PUB_NET_REVENUE', + 'PUB_NET_PLAYER_ERPM', +]; + +export const SUPPLY_ACCOUNTS_PAGE_GRAPHQL_QUERY = ` + query SupplyAccountsDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean + ) { + supplyAccountsData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + sites + fields { + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + OVERALL_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + PUB_NET_REVENUE { + value + change + } + PUB_NET_PLAYER_ERPM { + value + change + } + } + } + } + } +`; + +export const SUPPLY_ACCOUNTS_PAGE_SITES_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + }, + { + id: 'SUPPLIES', + label: 'Supply Tags', + isSortable: true, + }, + { + id: 'PLAYER_LOADS', + label: 'Player Loads', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for self serve + { + id: 'PUB_REVENUE', + label: 'Pub Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_REVENUE', + label: 'Pub NET Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_RPM', + label: 'Gross RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_AD_RPM', + label: 'Ad RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'OVERALL_FILL', + label: 'Overall Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_PLAYER_ERPM', + label: 'Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PLAYER_ERPM', + label: 'Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_PLAYER_ERPM', + label: 'Gross Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_PLAYER_ERPM', + label: 'Pub NET Player eRPM', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, +]; + +export const SUPPLY_ACCOUNTS_PAGE_SITES_CELLS = [ + { + key: 'name', + }, + { + key: 'supplies', + }, + { + key: 'fields.PLAYER_LOADS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_AD_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.OVERALL_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.PUB_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_PLAYER_ERPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, +]; + +export const SUPPLY_ACCOUNTS_PAGE_SITES_FIELDS = [ + 'PLAYER_LOADS', + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'OVERALL_FILL', + 'REVENUE', + 'PUB_REVENUE', + 'GROSS_REVENUE', + 'PUB_PLAYER_ERPM', + 'PLAYER_ERPM', + 'RPM', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'GROSS_PLAYER_ERPM', + 'COMPLETION_RATE', + 'CTR', + 'PUB_NET_REVENUE', + 'PUB_NET_PLAYER_ERPM', +]; + +export const SUPPLY_ACCOUNTS_PAGE_SITES_GRAPHQL_QUERY = ` + query SiteDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean + ) { + siteData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + supplies + accountId + fields { + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + OVERALL_FILL { + value + change + } + REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + PUB_NET_REVENUE { + value + change + } + PUB_NET_PLAYER_ERPM { + value + change + } + } + } + } + } +`; + +export const SUPPLY_ACCOUNTS_PAGE_TAGS_HEADERS = [ + { + id: 'NAME', + label: 'Name', + isSortable: true, + }, + { + id: 'DEMANDS', + label: 'Demand Tags', + isSortable: true, + }, + { + id: 'SUPPLY_REQUESTS', + label: 'Requests', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS', + label: 'Impressions', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'REVENUE', + label: 'Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_REVENUE', + label: 'Gross Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for self serve + { + id: 'PUB_REVENUE', + label: 'Pub Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_NET_REVENUE', + label: 'Pub NET Revenue', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'RPM', + label: 'RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'GROSS_RPM', + label: 'Gross RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'PUB_AD_RPM', + label: 'Ad RPM', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS_FILL', + label: 'Req Fill', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'SUPPLY_REQUESTS_VIEWABILITY', + label: 'Requests Viewability', + isSortable: true, + align: 'right', + withGap: true, + }, + { + id: 'IMPRESSIONS_VIEWABILITY', + label: 'Impressions Viewability', + isSortable: true, + align: 'right', + withGap: true, + }, + // only for admin + { + id: 'CTR', + label: 'CTR', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'COMPLETION_RATE', + label: 'Completion Rate', + align: 'right', + withGap: true, + isSortable: true, + }, + // only for admin + { + id: 'CREATED', + label: 'Creation Date', + isSortable: true, + }, +]; + +export const SUPPLY_ACCOUNTS_PAGE_TAGS_CELLS = [ + { + key: 'name', + }, + { + key: 'demands', + }, + { + key: 'fields.SUPPLY_REQUESTS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS', + align: 'right', + withPercent: true, + }, + { + key: 'fields.REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_NET_REVENUE', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.GROSS_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.PUB_AD_RPM', + align: 'right', + prefix: '$', + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS_FILL', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.SUPPLY_REQUESTS_VIEWABILITY', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.IMPRESSIONS_VIEWABILITY', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.CTR', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'fields.COMPLETION_RATE', + align: 'right', + postfix: '%', + needMultiply: true, + withPercent: true, + }, + { + key: 'created', + }, +]; + +export const SUPPLY_ACCOUNTS_PAGE_TAGS_FIELDS = [ + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'REVENUE', + 'PUB_REVENUE', + 'GROSS_REVENUE', + 'RPM', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'SUPPLY_REQUESTS_VIEWABILITY', + 'IMPRESSIONS_VIEWABILITY', + 'COMPLETION_RATE', + 'CTR', + 'PUB_NET_REVENUE', +]; + +export const SUPPLY_ACCOUNTS_PAGE_TAGS_GRAPHQL_QUERY = ` +query SupplyTagDataQuery( + $fields: [String], + $sort: MarketplaceSortInputType, + $from: Int, + $size: Int, + $filters: MarketplaceFiltersInputType, + $allPages: Boolean +) { + supplyTagData( + fields: $fields, + sort: $sort, + from: $from, + size: $size, + filters: $filters, + allPages: $allPages + ) { + totalCount + data { + id + name + demands + accountId + siteId + created + fields { + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + PUB_REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + RPM { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + SUPPLY_REQUESTS_VIEWABILITY { + value + change + } + IMPRESSIONS_VIEWABILITY { + value + change + } + COMPLETION_RATE { + value + change + } + CTR { + value + change + } + PUB_NET_REVENUE { + value + change + } + } + } + } +} +`; + +export const SUPPLY_ACCOUNTS_PAGE_CONFIG = { + type: SUPPLY_ACCOUNTS_PAGE_TYPE, + pathname: SUPPLY_ACCOUNTS_PAGE_PATHNAME, + header: SUPPLY_ACCOUNTS_PAGE_HEADER, + rowsPerPageOptions: [10, 25, 50, 100, 200], + tabs: [ + { + label: 'Supply Accounts', + dataId: 'accounts', + tableHeaders: SUPPLY_ACCOUNTS_PAGE_HEADERS.filter((item) => !['PUB_PLAYER_ERPM', 'PUB_AD_RPM'].includes(item.id)), + tableCells: SUPPLY_ACCOUNTS_PAGE_CELLS.filter( + (item) => !['fields.PUB_PLAYER_ERPM', 'fields.PUB_AD_RPM'].includes(item.key), + ), + graphQuery: SUPPLY_ACCOUNTS_PAGE_GRAPHQL_QUERY, + fields: SUPPLY_ACCOUNTS_PAGE_FIELDS, + createRowLink: ({ pathname, id, name }) => `${pathname}/${id}?accountName=${encodeURIComponent(name)}`, + }, + { + label: 'Sites', + dataId: 'sites', + tableHeaders: SUPPLY_ACCOUNTS_PAGE_SITES_HEADERS.filter( + (item) => !['PUB_PLAYER_ERPM', 'PUB_REVENUE', 'PUB_AD_RPM'].includes(item.id), + ), + tableCells: SUPPLY_ACCOUNTS_PAGE_SITES_CELLS.filter( + (item) => !['fields.PUB_PLAYER_ERPM', 'fields.PUB_REVENUE', 'fields.PUB_AD_RPM'].includes(item.key), + ), + graphQuery: SUPPLY_ACCOUNTS_PAGE_SITES_GRAPHQL_QUERY, + fields: SUPPLY_ACCOUNTS_PAGE_SITES_FIELDS, + createRowLink: ({ pathname, id, name, accountId }) => + `${pathname}/${accountId}/sites/${id}?siteName=${encodeURIComponent(name)}`, + }, + { + label: 'Supply Tags', + dataId: 'tags', + tableHeaders: SUPPLY_ACCOUNTS_PAGE_TAGS_HEADERS.filter( + (item) => !['PUB_PLAYER_ERPM', 'PUB_REVENUE', 'PUB_AD_RPM'].includes(item.id), + ), + tableCells: SUPPLY_ACCOUNTS_PAGE_TAGS_CELLS.filter( + (item) => !['fields.PUB_PLAYER_ERPM', 'fields.PUB_REVENUE', 'fields.PUB_AD_RPM'].includes(item.key), + ), + graphQuery: SUPPLY_ACCOUNTS_PAGE_TAGS_GRAPHQL_QUERY, + fields: SUPPLY_ACCOUNTS_PAGE_TAGS_FIELDS, + createRowLink: ({ pathname, id, name, accountId, siteId }) => + `${pathname}/${accountId}/sites/${siteId}/tags/${id}?tagName=${encodeURIComponent(name)}`, + }, + ], + selfServeTabs: [ + { + label: 'Sites', + dataId: 'sites', + tableHeaders: SUPPLY_ACCOUNTS_PAGE_SITES_HEADERS.filter( + (item) => + ![ + 'PLAYER_ERPM', + 'REVENUE', + 'GROSS_REVENUE', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'GROSS_PLAYER_ERPM', + 'CTR', + 'COMPLETION_RATE', + ].includes(item.id), + ), + tableCells: SUPPLY_ACCOUNTS_PAGE_SITES_CELLS.filter( + (item) => + ![ + 'fields.PLAYER_ERPM', + 'fields.REVENUE', + 'fields.GROSS_REVENUE', + 'fields.GROSS_RPM', + 'fields.PUB_AD_RPM', + 'fields.GROSS_PLAYER_ERPM', + 'fields.CTR', + 'fields.COMPLETION_RATE', + ].includes(item.key), + ), + graphQuery: SUPPLY_ACCOUNTS_PAGE_SITES_GRAPHQL_QUERY, + fields: SUPPLY_ACCOUNTS_PAGE_SITES_FIELDS, + createRowLink: ({ pathname, id, name, accountId }) => + `${pathname}/${accountId}/sites/${id}?siteName=${encodeURIComponent(name)}`, + defaultConfig: { + sortBy: 'PLAYER_LOADS', + }, + }, + { + label: 'Supply Tags', + dataId: 'tags', + tableHeaders: SUPPLY_ACCOUNTS_PAGE_TAGS_HEADERS.filter( + (item) => + ![ + 'PLAYER_ERPM', + 'REVENUE', + 'GROSS_REVENUE', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'CTR', + 'COMPLETION_RATE', + 'CREATED', + ].includes(item.id), + ), + tableCells: SUPPLY_ACCOUNTS_PAGE_TAGS_CELLS.filter( + (item) => + ![ + 'fields.PLAYER_ERPM', + 'fields.REVENUE', + 'fields.GROSS_REVENUE', + 'fields.GROSS_RPM', + 'fields.PUB_AD_RPM', + 'fields.CTR', + 'fields.COMPLETION_RATE', + 'created', + ].includes(item.key), + ), + graphQuery: SUPPLY_ACCOUNTS_PAGE_TAGS_GRAPHQL_QUERY, + fields: SUPPLY_ACCOUNTS_PAGE_TAGS_FIELDS, + createRowLink: ({ pathname, id, name, accountId, siteId }) => + `${pathname}/${accountId}/sites/${siteId}/tags/${id}?tagName=${encodeURIComponent(name)}`, + }, + ], + defaultConfig: { + sortBy: 'SUPPLY_REQUESTS', + }, +}; diff --git a/anyclip/src/modules/marketplace/accounts/helpers/disclaimerModal.js b/anyclip/src/modules/marketplace/accounts/helpers/disclaimerModal.js new file mode 100644 index 0000000..91710d6 --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/helpers/disclaimerModal.js @@ -0,0 +1,10 @@ +import { getStorageItem, setBrowserStorageItem } from '@/modules/@common/storage/helpers'; + +const SELF_SERVE_DISCLAIMER_MODAL = 'SELF_SERVE_DISCLAIMER_MODAL'; + +const selfServeDisclaimerModalManager = { + setSelfServeDisclaimerModalShowInfo: () => setBrowserStorageItem(SELF_SERVE_DISCLAIMER_MODAL, true), + getSelfServeDisclaimerModalShowInfo: () => getStorageItem(SELF_SERVE_DISCLAIMER_MODAL), +}; + +export default selfServeDisclaimerModalManager; diff --git a/src/modules/marketplace/accounts/index.jsx b/anyclip/src/modules/marketplace/accounts/index.jsx similarity index 100% rename from src/modules/marketplace/accounts/index.jsx rename to anyclip/src/modules/marketplace/accounts/index.jsx diff --git a/anyclip/src/modules/marketplace/accounts/redux/epics/downloadCSV.js b/anyclip/src/modules/marketplace/accounts/redux/epics/downloadCSV.js new file mode 100644 index 0000000..76f01c4 --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/redux/epics/downloadCSV.js @@ -0,0 +1,19 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { downloadCSVAction, getAccountsAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(downloadCSVAction.type), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => concat(of(getAccountsAction({ allPages: true })))), + ); diff --git a/anyclip/src/modules/marketplace/accounts/redux/epics/getAccounts.js b/anyclip/src/modules/marketplace/accounts/redux/epics/getAccounts.js new file mode 100644 index 0000000..d123973 --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/redux/epics/getAccounts.js @@ -0,0 +1,241 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { LIST_OF_META_SORTING } from '../../../common/constants'; +import { DEMAND_ACCOUNTS_PAGE_TYPE, SUPPLY_ACCOUNTS_PAGE_TYPE } from '../../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { + getAccountsAction, + refreshPageDataByIntervalAction, + saveToCSVAction, + setActiveTabIndexAction, + setFieldAction, + setFilterByStatusAction, + setFilterDateAction, + setRowsPerPageAction, + setSearchAction, + setSortByAction, + setSortOrderAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector, getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; +import { formatFilterSelector } from '@/modules/marketplace/dashboard/redux/selectors'; +import { setFilterFormatAction } from '@/modules/marketplace/dashboard/redux/slices'; + +const actionsWithRefresh = [ + setSearchAction.type, + setFilterDateAction.type, + setSortByAction.type, + setSortOrderAction.type, + setRowsPerPageAction.type, + setActiveTabIndexAction.type, + setFilterByStatusAction.type, + setFilterFormatAction.type, +]; + +export default (action$, state$) => + action$.pipe( + ofType( + getAccountsAction.type, + setSearchAction.type, + setFilterDateAction.type, + setSortByAction.type, + setSortOrderAction.type, + setRowsPerPageAction.type, + refreshPageDataByIntervalAction.type, + setActiveTabIndexAction.type, + setFilterByStatusAction.type, + setFilterFormatAction.type, + ), + filter(() => { + const userPermissions = getUserPermissionsSelector(state$.value); + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + const userDemandAccounts = selectors.userDemandAccountsSelector(state); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + if (!isAdminMP) { + return !!userDemandAccounts?.length; + } + + return !!pageConfig; + }), + debounce((action) => timer(action.type === setSearchAction.type ? 1000 : 0)), + switchMap(({ type, payload }) => { + const state = state$.value; + const userPermissions = getUserPermissionsSelector(state$.value); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const pageConfig = selectors.pageConfigSelector(state); + const search = selectors.searchSelector(state); + const sortBy = selectors.sortBySelector(state); + const sortOrder = selectors.sortOrderSelector(state); + const filterDate = selectors.filterDateSelector(state); + const filterByStatus = selectors.filterByStatusSelector(state); + const page = selectors.pageSelector(state); + const rowsPerPage = selectors.rowsPerPageSelector(state); + const activeTabIndex = selectors.activeTabIndexSelector(state); + const userDemandAccounts = selectors.userDemandAccountsSelector(state); + const userHubs = selectors.userHubsSelector(state); + + const formatFilter = formatFilterSelector(state); + + const timezone = getUserTimezoneSelector(state$.value); + + const { isCustomPeriod, ...filterDateRest } = filterDate; + const { change, dimension, changeDimension, ...range } = filterDateRest; + let currentPage = page; + let filters = {}; + + if (actionsWithRefresh.includes(type)) { + currentPage = 0; + } + + if (search?.length) { + filters = { + ...filters, + query: search, + }; + } + + if (dimension) { + filters = { + ...filters, + dimension, + }; + } + + if (change) { + filters = { + ...filters, + changeRanges: [ + { + ...change, + timezone, + }, + ], + }; + } + + if (changeDimension) { + filters = { + ...filters, + changeDimension, + }; + } + + if (filterByStatus && filterByStatus !== 'ALL') { + filters = { + ...filters, + status: { value: filterByStatus }, + }; + } + + if (formatFilter) { + filters = { + ...filters, + ...formatFilter, + }; + } + + if (!isAdminMP && pageConfig?.type === DEMAND_ACCOUNTS_PAGE_TYPE) { + filters = { + ...filters, + daccountIds: userDemandAccounts.map((item) => ({ value: item.id?.toString() })), + }; + } + + if (!isAdminMP && pageConfig?.type === SUPPLY_ACCOUNTS_PAGE_TYPE) { + filters = { + ...filters, + siteIds: userHubs.filter((item) => item.mpSelfService).map((item) => ({ value: item.id?.toString() })), + }; + } + + const tabs = isAdminMP ? pageConfig.tabs : pageConfig.selfServeTabs; + const fields = isAdminMP + ? tabs[activeTabIndex].fields + : tabs[activeTabIndex].fields.filter((item) => item !== 'PROFIT'); + + const stream$ = gqlRequest({ + query: tabs[activeTabIndex].graphQuery, + variables: { + fields, + size: payload?.allPages ? 1000 : rowsPerPage || 25, + from: payload?.allPages ? 0 : (currentPage || 0) * (rowsPerPage || 25), + sort: { + [LIST_OF_META_SORTING.includes(sortBy) ? 'ofMeta' : 'of']: sortBy, + order: sortOrder, + }, + filters: { + ranges: [ + { + ...range, + timezone, + }, + ], + ...filters, + }, + allPages: !!payload?.allPages, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + const accountsData = { + ...data.supplyAccountsData, + ...data.demandAccountsData, + ...data.siteData, + ...data.supplyTagData, + ...data.demandTagData, + ...data.advertiserData, + }; + + if (!errors.length) { + const dataWithTime = [ + ...accountsData.data.map((item) => ({ + ...item, + fields: { + ...item?.fields, + REQUESTS: { + ...item?.fields?.REQUESTS, + // todo: it has to be split and replaced + apsInfo: !!(item?.hasApsTags || item?.aps), + // mark requests cell red if profit false (advertisers and demand tag tables) + profitDecreased: item?.profit === false, + }, + }, + created: item?.created ? dayjs(+item.created).tz(timezone).format('YYYY/MM/DD h:mm A') : null, + })), + ]; + + if (payload?.allPages) { + actions = [ + of( + saveToCSVAction({ + data: dataWithTime || [], + isAdminMP, + }), + ), + ]; + } else { + actions = [ + of(setFieldAction({ accounts: dataWithTime || [] })), + of(setFieldAction({ totalCount: accountsData.totalCount })), + of(setFieldAction({ page: currentPage })), + ]; + } + } + + return concat(...actions); + }), + ); + + return concat(of(setFieldAction({ isLoading: true })), stream$, of(setFieldAction({ isLoading: false }))); + }), + ); diff --git a/anyclip/src/modules/marketplace/accounts/redux/epics/getSelfServeUserHubsAndDemandAccounts.js b/anyclip/src/modules/marketplace/accounts/redux/epics/getSelfServeUserHubsAndDemandAccounts.js new file mode 100644 index 0000000..e283de0 --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/redux/epics/getSelfServeUserHubsAndDemandAccounts.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getAccountsAction, getHubsAndDemandAccountsAction, setHubsAndDemandAccountsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query MarketplaceHubsAndDemandAccounts { + hubsAndDemandAccounts { + records { + id + name + mpSelfService + accountId + demandAccountId + demandAccount { + id + name + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getHubsAndDemandAccountsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data: { hubsAndDemandAccounts }, errors }) => { + let actions = []; + + if (!errors.length) { + const demandAccounts = hubsAndDemandAccounts.records?.map((item) => item?.demandAccount); + + actions = [ + of( + setHubsAndDemandAccountsAction({ + userHubs: hubsAndDemandAccounts.records, + userDemandAccounts: Object.values( + demandAccounts.reduce((acc, obj) => ({ ...acc, [obj.id]: obj }), {}), + ), + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$, of(getAccountsAction())); + }), + ); diff --git a/anyclip/src/modules/marketplace/accounts/redux/epics/index.js b/anyclip/src/modules/marketplace/accounts/redux/epics/index.js new file mode 100644 index 0000000..4b1d84f --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import downloadCSV from './downloadCSV'; +import getAccounts from './getAccounts'; +import getSelfServeUserHubsAndDemandAccounts from './getSelfServeUserHubsAndDemandAccounts'; +import saveDataToCSV from './saveDataToCSV'; + +export default combineEpics(getAccounts, getSelfServeUserHubsAndDemandAccounts, downloadCSV, saveDataToCSV); diff --git a/anyclip/src/modules/marketplace/accounts/redux/epics/saveDataToCSV.js b/anyclip/src/modules/marketplace/accounts/redux/epics/saveDataToCSV.js new file mode 100644 index 0000000..bb34d7a --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/redux/epics/saveDataToCSV.js @@ -0,0 +1,36 @@ +import { ofType } from 'redux-observable'; +import { concat } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { saveToCSVAction } from '../slices'; +import { downloadTableToCSV } from '@/modules/marketplace/common/helpers'; + +export default (action$, state$) => + action$.pipe( + ofType(saveToCSVAction.type), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(({ payload }) => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + const activeTabIndex = selectors.activeTabIndexSelector(state); + + const tabs = payload?.isAdminMP ? pageConfig.tabs : pageConfig.selfServeTabs; + + downloadTableToCSV({ + headers: tabs[activeTabIndex].tableHeaders, + cells: tabs[activeTabIndex].tableCells, + data: payload?.data ?? [], + fileName: tabs[activeTabIndex].label, + }); + + return concat(); + }), + ); diff --git a/anyclip/src/modules/marketplace/accounts/redux/selectors/index.js b/anyclip/src/modules/marketplace/accounts/redux/selectors/index.js new file mode 100644 index 0000000..be0134b --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/redux/selectors/index.js @@ -0,0 +1,19 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const accountsSelector = (state) => state[nameSpace].accounts; +export const pageSelector = (state) => state[nameSpace].page; +export const totalCountSelector = (state) => state[nameSpace].totalCount; +export const rowsPerPageSelector = (state) => state[nameSpace].rowsPerPage; +export const searchSelector = (state) => state[nameSpace].search; +export const sortBySelector = (state) => state[nameSpace].sortBy; +export const sortOrderSelector = (state) => state[nameSpace].sortOrder; +export const filterDateSelector = (state) => state[nameSpace].filterDate; +export const filterByStatusSelector = (state) => state[nameSpace].filterByStatus; +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const pageConfigSelector = (state) => state[nameSpace].pageConfig; +export const activeTabIndexSelector = (state) => state[nameSpace].activeTabIndex; +export const userHubsSelector = (state) => state[nameSpace].userHubs; +export const userDemandAccountsSelector = (state) => state[nameSpace].userDemandAccounts; +export const modalSelector = (state) => state[nameSpace].modal; diff --git a/anyclip/src/modules/marketplace/accounts/redux/slices/index.js b/anyclip/src/modules/marketplace/accounts/redux/slices/index.js new file mode 100644 index 0000000..8367b41 --- /dev/null +++ b/anyclip/src/modules/marketplace/accounts/redux/slices/index.js @@ -0,0 +1,98 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { FILTERS, STATUS_FILTERS } from '../../../common/constants'; + +const initialState = { + accounts: [], + page: 0, + totalCount: 0, + rowsPerPage: 25, + search: '', + sortBy: 'REQUESTS', + sortOrder: 'DESC', + filterDate: FILTERS[0].value, + filterByStatus: STATUS_FILTERS[1].value, + isLoading: false, + pageConfig: null, + activeTabIndex: 0, + userHubs: [], + userDemandAccounts: [], + modal: null, +}; + +export const slice = createSlice({ + name: '@@accounts/ACCOUNTS', + initialState, + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setDefaultConfigAction: (state, action) => { + const config = { + ...initialState, + userHubs: state.userHubs, + userDemandAccounts: state.userDemandAccounts, + ...action.payload, + }; + + Object.keys(config).forEach((key) => { + state[key] = config[key]; + }); + }, + setHubsAndDemandAccountsAction: (state, action) => { + state.userHubs = action.payload?.userHubs ?? []; + state.userDemandAccounts = action.payload?.userDemandAccounts ?? []; + }, + setActiveTabIndexAction: (state, action) => { + state.activeTabIndex = action.payload; + }, + setSortByAction: (state, action) => { + state.sortBy = action.payload; + }, + setSortOrderAction: (state, action) => { + state.sortOrder = action.payload; + }, + setFilterDateAction: (state, action) => { + state.filterDate = action.payload; + }, + setFilterByStatusAction: (state, action) => { + state.filterByStatus = action.payload; + }, + setSearchAction: (state, action) => { + state.search = action.payload; + }, + setRowsPerPageAction: (state, action) => { + state.rowsPerPage = action.payload; + }, + + getAccountsAction: (state) => state, + getNextAccountsAction: (state) => state, + refreshPageDataByIntervalAction: (state) => state, + downloadCSVAction: (state) => state, + saveToCSVAction: (state) => state, + getHubsAndDemandAccountsAction: (state) => state, + }, +}); + +export const { + setFieldAction, + setDefaultConfigAction, + setHubsAndDemandAccountsAction, + setActiveTabIndexAction, + setSortByAction, + setSortOrderAction, + setFilterDateAction, + setFilterByStatusAction, + setSearchAction, + setRowsPerPageAction, + getAccountsAction, + getNextAccountsAction, + refreshPageDataByIntervalAction, + downloadCSVAction, + saveToCSVAction, + getHubsAndDemandAccountsAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/marketplace/common/Cells/CopyCell.jsx b/anyclip/src/modules/marketplace/common/Cells/CopyCell.jsx new file mode 100644 index 0000000..3d44e3d --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Cells/CopyCell.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useRouter } from 'next/router'; +import { CopyAllOutlined } from '@mui/icons-material'; + +import styles from './CopyCell.module.scss'; + +function CopyCell(props) { + const router = useRouter(); + const handleClick = (e) => { + e.preventDefault(); + const link = props.buildCopyLinkFunction(props.row, true); + router.push(link); + }; + + return ( +
    + +
    + ); +} + +CopyCell.propTypes = { + row: PropTypes.objectOf(PropTypes.shape({})).isRequired, + buildCopyLinkFunction: PropTypes.func.isRequired, +}; + +export default CopyCell; diff --git a/anyclip/src/modules/marketplace/common/Cells/CopyCell.module.scss b/anyclip/src/modules/marketplace/common/Cells/CopyCell.module.scss new file mode 100644 index 0000000..7d4a5e2 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Cells/CopyCell.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"CopyCell_Wrapper___nurm"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/common/Cells/StatusCell.jsx b/anyclip/src/modules/marketplace/common/Cells/StatusCell.jsx new file mode 100644 index 0000000..b7afa9b --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Cells/StatusCell.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Cancel, CheckCircle } from '@mui/icons-material'; + +function StatusCell(props) { + const active = props.cell?.toUpperCase() === 'ACTIVE'; + + return props.cell && (active ? : ); +} + +StatusCell.propTypes = { + cell: PropTypes.string.isRequired, +}; + +export default StatusCell; diff --git a/anyclip/src/modules/marketplace/common/Chart/CustomLogBar.jsx b/anyclip/src/modules/marketplace/common/Chart/CustomLogBar.jsx new file mode 100644 index 0000000..c5baae9 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Chart/CustomLogBar.jsx @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import dayjs from 'dayjs'; + +import { LOG_ACTIONS_CONFIG } from '../constants'; + +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import { Tooltip, Typography } from '@/mui/components'; + +import styles from './CustomLogBar.module.scss'; + +function CustomLogBar(props) { + const timezone = useSelector(getUserTimezoneSelector); + if (props.value && props.logValues?.length) { + const user = props.logValues[0]?.user?.firstName?.charAt(0) ?? ''; + + return ( + props.value && ( + + {props.height && ( + +
    + + {props.logValues.map((log) => { + const time = dayjs(log.created).tz(timezone); + const logUser = log.user?.email ?? 'Unknown'; + return ( +
    + + {`${time.format('DD/MM')} at ${time.format('HH:mm')}`} + + + {log.values?.map((value) => ( +
    + + {logUser || 'Unknown'} + + + {` ${LOG_ACTIONS_CONFIG[value.action] ?? value.action}`} + +
    + ))} +
    + ); + })} +
    + ) + } + onOpen={() => { + props.setShowTooltip(false); + }} + onClose={() => { + props.setShowTooltip(true); + }} + > +
    + + {user || 'U'} + +
    + + +
    + )} + + +
    + ) + ); + } + return null; +} + +CustomLogBar.propTypes = { + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + fill: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + setShowTooltip: PropTypes.func.isRequired, + logValues: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + created: PropTypes.number, + values: PropTypes.arrayOf( + PropTypes.shape({ + action: PropTypes.string, + }), + ), + user: PropTypes.shape({ + email: PropTypes.string, + firstName: PropTypes.string, + }), + }), + ).isRequired, +}; + +export default CustomLogBar; diff --git a/anyclip/src/modules/marketplace/common/Chart/CustomLogBar.module.scss b/anyclip/src/modules/marketplace/common/Chart/CustomLogBar.module.scss new file mode 100644 index 0000000..8d4d6cb --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Chart/CustomLogBar.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Label":"CustomLogBar_Label__xw_R4","Tooltip":"CustomLogBar_Tooltip__g07UY","Info":"CustomLogBar_Info__S8kY4"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/common/Chart/CustomTick.jsx b/anyclip/src/modules/marketplace/common/Chart/CustomTick.jsx new file mode 100644 index 0000000..1e07250 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Chart/CustomTick.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import styles from './CustomTick.module.scss'; + +function CustomTick(props) { + const splited = props.payload.value.split('|'); + const fullDate = splited[0]; + const time = splited[1]; + const day = splited[2]; + + return ( + + + {time ? ( + <> + + {time} + + {day && ( + + {day} + + )} + + ) : ( + + {fullDate} + + )} + + + ); +} + +CustomTick.propTypes = { + payload: PropTypes.shape({ + value: PropTypes.string.isRequired, + }).isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, +}; + +export default CustomTick; diff --git a/anyclip/src/modules/marketplace/common/Chart/CustomTick.module.scss b/anyclip/src/modules/marketplace/common/Chart/CustomTick.module.scss new file mode 100644 index 0000000..8172417 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Chart/CustomTick.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Text":"CustomTick_Text__61jqw"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/common/Chart/CustomTooltip.jsx b/anyclip/src/modules/marketplace/common/Chart/CustomTooltip.jsx new file mode 100644 index 0000000..c6d763d --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Chart/CustomTooltip.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import styles from './CustomTooltip.module.scss'; + +function CustomTooltip(props) { + const splited = props.label?.split('|'); + const fullDate = splited?.[0]; + + return props.active && props.payload?.length && props.showTooltip ? ( +
    +

    {fullDate}

    + {props.payload.map((item, index) => { + if (item.color !== 'transparent' && item.name !== 'LOG') { + const [name, value] = props.formatter(item.name, item.value); + return ( +
    + {name} + {value} +
    + ); + } + + return null; + })} +
    + ) : null; +} + +CustomTooltip.propTypes = { + active: PropTypes.bool.isRequired, + payload: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + label: PropTypes.string.isRequired, + formatter: PropTypes.func.isRequired, + showTooltip: PropTypes.bool.isRequired, +}; + +export default CustomTooltip; diff --git a/anyclip/src/modules/marketplace/common/Chart/CustomTooltip.module.scss b/anyclip/src/modules/marketplace/common/Chart/CustomTooltip.module.scss new file mode 100644 index 0000000..1e10480 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Chart/CustomTooltip.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Tooltip":"CustomTooltip_Tooltip__jyWrK","Tooltip_label":"CustomTooltip_Tooltip_label__I5BMa","Tooltip_row":"CustomTooltip_Tooltip_row__3m1g7","Tooltip_value":"CustomTooltip_Tooltip_value__0DScH","Legend":"CustomTooltip_Legend__uwMkW","Legend___active":"CustomTooltip_Legend___active__kBR1X"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/common/Chart/index.jsx b/anyclip/src/modules/marketplace/common/Chart/index.jsx new file mode 100644 index 0000000..3a8d0a3 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Chart/index.jsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { + Area, + Bar, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { PRIMARY_LEFT_Y_AXIS_ID } from '@/modules/marketplace/common/constants'; + +import CustomLogBar from './CustomLogBar'; +import CustomTick from './CustomTick'; +import CustomTooltip from './CustomTooltip'; + +import innerStyles from './CustomTooltip.module.scss'; + +const prepareParams = (params) => params.map((item) => ({ ...item, active: true })); + +const renderLegendText = (value, entry) => { + const { payload } = entry; + + return ( + + {payload?.label ?? value} + + ); +}; + +function Chart({ + params = [], + logParams = [], + isNeedToResetParams = false, + height = null, + tooltipValueFormatter = null, + ...props +}) { + const allParams = [...params, ...(logParams ?? [])]; + + const [params$, setParams$] = useState(prepareParams(allParams)); + const [showTooltip, setShowTooltip] = useState(true); + + useEffect(() => { + setParams$(prepareParams(allParams)); + }, [isNeedToResetParams]); + + const handleLegendClick = (legend) => { + const newParams = params$.map((item) => { + if (item.value === legend.dataKey) { + return { + ...item, + active: !item.active, + }; + } + + return item; + }); + + setParams$(newParams); + }; + + const tooltipFormatter = (name, value) => [ + params$.find((param) => param.value === name)?.label ?? name, + tooltipValueFormatter ? tooltipValueFormatter(name, value) : value, + ]; + + return ( + + + } tickFormatter={(tick) => tick.split('|')[1]} /> + + {props.yAxis.map((axis) => ( + + ))} + + + } /> + + + {params$.map((item, index) => { + if (item.type === 'bar') { + return ( + + ); + } + + if (item.type === 'line') { + return ( + + ); + } + + if (item.type === 'area') { + return ( + + ); + } + + if (item.type === 'log') { + return ( + } + /> + ); + } + + return null; + })} + + + ); +} + +Chart.propTypes = { + yAxis: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + params: PropTypes.arrayOf(PropTypes.shape({})), + logParams: PropTypes.arrayOf(PropTypes.shape({})), + isNeedToResetParams: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + chartData: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + height: PropTypes.number, + tooltipValueFormatter: PropTypes.func, +}; + +export default Chart; diff --git a/anyclip/src/modules/marketplace/common/ChipInput/index.jsx b/anyclip/src/modules/marketplace/common/ChipInput/index.jsx new file mode 100644 index 0000000..750c4d0 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/ChipInput/index.jsx @@ -0,0 +1,29 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; + +import { Autocomplete, TextField } from '@/mui/components'; + +function ChipInput({ placeholder = 'Enter Label', inputHint = 'For creating label press Enter', ...props }) { + const options = useMemo(() => [], []); + + return ( + } + onChange={(e, data) => props.onChange({ labels: data })} + /> + ); +} + +ChipInput.propTypes = { + labels: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, + inputHint: PropTypes.string, +}; + +export default ChipInput; diff --git a/src/modules/marketplace/common/DateSelect/CustomPeriod.jsx b/anyclip/src/modules/marketplace/common/DateSelect/CustomPeriod.jsx similarity index 100% rename from src/modules/marketplace/common/DateSelect/CustomPeriod.jsx rename to anyclip/src/modules/marketplace/common/DateSelect/CustomPeriod.jsx diff --git a/src/modules/marketplace/common/DateSelect/CustomPeriod.module.scss b/anyclip/src/modules/marketplace/common/DateSelect/CustomPeriod.module.scss similarity index 100% rename from src/modules/marketplace/common/DateSelect/CustomPeriod.module.scss rename to anyclip/src/modules/marketplace/common/DateSelect/CustomPeriod.module.scss diff --git a/src/modules/marketplace/common/DateSelect/DateSelect.module.scss b/anyclip/src/modules/marketplace/common/DateSelect/DateSelect.module.scss similarity index 100% rename from src/modules/marketplace/common/DateSelect/DateSelect.module.scss rename to anyclip/src/modules/marketplace/common/DateSelect/DateSelect.module.scss diff --git a/src/modules/marketplace/common/DateSelect/index.jsx b/anyclip/src/modules/marketplace/common/DateSelect/index.jsx similarity index 100% rename from src/modules/marketplace/common/DateSelect/index.jsx rename to anyclip/src/modules/marketplace/common/DateSelect/index.jsx diff --git a/src/modules/marketplace/common/HeaderNew/Header.module.scss b/anyclip/src/modules/marketplace/common/HeaderNew/Header.module.scss similarity index 100% rename from src/modules/marketplace/common/HeaderNew/Header.module.scss rename to anyclip/src/modules/marketplace/common/HeaderNew/Header.module.scss diff --git a/src/modules/marketplace/common/HeaderNew/index.jsx b/anyclip/src/modules/marketplace/common/HeaderNew/index.jsx similarity index 100% rename from src/modules/marketplace/common/HeaderNew/index.jsx rename to anyclip/src/modules/marketplace/common/HeaderNew/index.jsx diff --git a/anyclip/src/modules/marketplace/common/Table/Table.module.scss b/anyclip/src/modules/marketplace/common/Table/Table.module.scss new file mode 100644 index 0000000..b3f4321 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Table/Table.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Table":"Table_Table__9KY_n","Cell":"Table_Cell__k8_ep","Cell___red":"Table_Cell___red__rb_c3","VisuallyHidden":"Table_VisuallyHidden__AlciY","Percent___increased":"Table_Percent___increased__0gsWG","Percent___decreased":"Table_Percent___decreased__DVb8n","TableBody":"Table_TableBody__MHRrD","Row":"Table_Row__LAZqZ","LoaderOverlay":"Table_LoaderOverlay__zkR6a","Loader":"Table_Loader__l3_fr","CheckboxWrap":"Table_CheckboxWrap___zSEQ"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/common/Table/index.jsx b/anyclip/src/modules/marketplace/common/Table/index.jsx new file mode 100644 index 0000000..6df9816 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/Table/index.jsx @@ -0,0 +1,419 @@ +import React, { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import NextLink from 'next/link'; +import { InfoOutlined } from '@mui/icons-material'; + +import { SORT_DESC } from '@/modules/@common/constants/sort'; +import { CUSTOM_PARENT_STICKY_CLASS_NAME } from '@/modules/marketplace/common/constants'; + +import { resolveObjectPath } from '../helpers'; +import { getNumberInRange } from '@/modules/@common/helpers/number'; + +import { + Checkbox, + CircularProgress, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableScroll, + TableSortLabel, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './Table.module.scss'; + +function AccountsTable({ + sortBy = '', + sortOrder = 'ASC', + handleSorting = () => {}, + selecting = false, + selected = [], + setSelected = () => {}, + cellRenderer = null, + withPagination = true, + onChangePage = () => {}, + setRowsPerPage = () => {}, + createRowLink = () => {}, + page = 1, + rowsPerPage = 0, + totalCount = 0, + rowsPerPageOptions = [], + setRowBackgroundBy = () => null, + stopRefreshInterval = null, + resetRefreshInterval = null, + isOpenNewTab = false, + isLoading = false, + onScroll = null, + ...props +}) { + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelecteds = props.rows.map((item) => item.id); + setSelected(newSelecteds); + return; + } + setSelected([]); + }; + + const handleSelect = (id) => { + const selectedIndex = selected.indexOf(id); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, id); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + setSelected(newSelected); + }; + + const isSelected = (id) => selected.indexOf(id) !== -1; + + const tableRef = useRef(null); + const tableHeaderRef = useRef(null); + + useEffect(() => { + const step = (event) => { + if (tableHeaderRef.current && tableRef.current) { + const top = getNumberInRange( + (tableRef.current.getBoundingClientRect().y - (event.target.getBoundingClientRect?.().y ?? 0)) * -1, + 0, + tableRef.current.clientHeight - tableHeaderRef.current.clientHeight * 2, + ); + + tableHeaderRef.current.style.transform = `translateY(${top}px)`; + } + }; + + const subscribeParent = tableRef.current.closest(`.${CUSTOM_PARENT_STICKY_CLASS_NAME}`) || document; + + subscribeParent.addEventListener('scroll', step); + + return () => { + subscribeParent.removeEventListener('scroll', step); + }; + }, [tableRef.current, tableHeaderRef.current]); + + return ( + + + + + + {selecting && ( + +
    + +
    +
    + )} + {props.headers.map((header) => { + const defaultSortOrder = header.defaultSortOrder ?? SORT_DESC; + if (header.withGap) { + return ( + + + {header.isSortable && ( + { + handleSorting(header.id); + }} + hideSortIcon + > + {header.label} + {sortBy === header.id ? ( + + {sortOrder === SORT_DESC ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + )} + + {!header.isSortable && header.label} + + + + ); + } + + return ( + + {header.isSortable && ( + { + handleSorting(header.id); + }} + hideSortIcon + > + {header.label} + {sortBy === header.id ? ( + + {sortOrder === 'DESC' ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + )} + + {!header.isSortable && header.label} + + ); + })} +
    +
    + + {props.rows.map((row, index) => { + const link = createRowLink(row); + + return ( + + {selecting && ( + +
    { + e.stopPropagation(); + e.preventDefault(); + }} + aria-hidden="true" + > + { + handleSelect(row.id); + }} + inputProps={{ 'aria-labelledby': `enhanced-table-checkbox-${index}` }} + onClick={(e) => e.stopPropagation()} + disabled={row.compressed} + /> +
    +
    + )} + + {props.cells.map( + ({ + key, + align, + prefix: prefixConfig, + postfix: postfixConfig, + needMultiply: needMultiplyConfig, + withPercent, + emptyMask, + }) => { + const cell = resolveObjectPath(row, key); + let needMultiply = needMultiplyConfig; + let prefix = prefixConfig; + let postfix = postfixConfig; + + if (key === 'pricing' && cell?.model === 'REV_SHARE') { + prefix = ''; + postfix = '%'; + needMultiply = true; + } + + const cellValue = needMultiply ? (cell?.value ?? 0) * 100 : cell?.value; + + const value = cellValue?.toLocaleString(undefined, { + minimumFractionDigits: prefix?.length || postfix?.length ? 2 : 0, + maximumFractionDigits: 2, + }); + + if (cellRenderer?.[key]) { + const renderFn = cellRenderer[key]; + if (typeof renderFn === 'function') { + return ( + + {renderFn({ + cell, + id: row.id, + row, + stopRefreshInterval, + resetRefreshInterval, + })} + + ); + } + } + + if (cell === undefined || cell === null) { + if (withPercent) { + return ( + + + + + ); + } + return ( + + {emptyMask ?? ''} + + ); + } + + if (withPercent) { + const percent = cell?.change; + // todo: it has to be split and replaced, + // (apsInfo | profitDecreased) works only for REQUESTS cell (look at getData epic in account) + const apsInfo = !!cell?.apsInfo; + const profitDecreased = cell?.profitDecreased; + const roundedPercent = percent ? Math.round(percent * 100) : 0; + const percentFormatted = roundedPercent > 0 ? `+${roundedPercent}%` : `${roundedPercent}%`; + + return ( + + + + {apsInfo && ( + + + + )} + + {`${value?.charAt(0) === '-' ? '-' : ''}${ + prefix ?? '' + }${value?.replace('-', '') ?? ''}${postfix ?? ''}`} + + + + + 0, + [styles.Percent___decreased]: roundedPercent < 0, + })} + > + {!Number.isNaN(parseInt(percent, 10)) ? percentFormatted : ''} + + + + ); + } + + return ( + + {`${value?.charAt(0) === '-' ? '-' : ''}${prefix ?? ''}${ + value?.replace('-', '') ?? + cell?.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }) + }${postfix ?? ''}`} + + ); + }, + )} +
    + ); + })} + {isLoading &&
    } + +
    + {isLoading && ( +
    + +
    + )} +
    + {withPagination && ( + { + onChangePage(page$ - 1); + if (resetRefreshInterval) { + resetRefreshInterval(); + } + }} + onRowsPerPageChange={(event) => { + setRowsPerPage(+event.target.value); + if (resetRefreshInterval) { + resetRefreshInterval(); + } + }} + /> + )} +
    + ); +} + +AccountsTable.propTypes = { + headers: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + rows: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + cells: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + sortBy: PropTypes.string, + sortOrder: PropTypes.string, + handleSorting: PropTypes.func, + selecting: PropTypes.bool, + selected: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.shape({})])), + setSelected: PropTypes.func, + cellRenderer: PropTypes.shape({}), + withPagination: PropTypes.bool, + onChangePage: PropTypes.func, + setRowsPerPage: PropTypes.func, + page: PropTypes.number, + rowsPerPage: PropTypes.number, + totalCount: PropTypes.number, + rowsPerPageOptions: PropTypes.arrayOf(PropTypes.number), + createRowLink: PropTypes.func, + setRowBackgroundBy: PropTypes.func, + stopRefreshInterval: PropTypes.func, + resetRefreshInterval: PropTypes.func, + isOpenNewTab: PropTypes.bool, + isLoading: PropTypes.bool, + onScroll: PropTypes.func, +}; + +export default AccountsTable; diff --git a/anyclip/src/modules/marketplace/common/constants/index.js b/anyclip/src/modules/marketplace/common/constants/index.js new file mode 100644 index 0000000..9c5c644 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/constants/index.js @@ -0,0 +1,329 @@ +export const FILTERS = [ + { + label: 'Today', + value: { + stringFrom: 'now/d', + change: { + stringFrom: 'now-1d/d', + stringTo: 'now-1d', + }, + dimension: 'HOUR', + changeDimension: 'MINUTE', + }, + }, + { + label: 'Today (vs 7d ago)', + value: { + stringFrom: 'now/d', + stringTo: 'now/h', + change: { + stringFrom: 'now-7d/d', + stringTo: 'now-7d/h', + }, + dimension: 'HOUR', + changeDimension: 'HOUR', + }, + }, + { + label: 'Yesterday', + value: { + stringFrom: 'now-1d/d', + stringTo: 'now/d', + change: { + stringFrom: 'now-2d/d', + stringTo: 'now-1d/d', + }, + dimension: 'HOUR', + changeDimension: 'HOUR', + }, + }, + { + label: 'Last 15 min', + value: { + stringFrom: 'now-22m/m', + stringTo: 'now-7m/m', + change: { + stringFrom: 'now-37m/m', + stringTo: 'now-22m/m', + }, + dimension: 'MINUTE', + changeDimension: 'MINUTE', + }, + }, + { + label: 'Current hour', + value: { + stringFrom: 'now/h', + stringTo: 'now-7m/m', + change: { + stringFrom: 'now-1h/h', + stringTo: 'now-1h-7m/m', + }, + dimension: 'MINUTE', + changeDimension: 'MINUTE', + }, + }, + { + label: 'Last 60 mins', + value: { + stringFrom: 'now-1h-7m/m', + stringTo: 'now-7m/m', + change: { + stringFrom: 'now-2h-7m/m', + stringTo: 'now-1h-7m/m', + }, + dimension: 'MINUTE', + changeDimension: 'MINUTE', + }, + }, + { + label: 'Current week', + value: { + stringFrom: 'now/w', + change: { + stringFrom: 'now-1w/w', + stringTo: 'now-1w/h', + }, + dimension: 'HOUR', + changeDimension: 'HOUR', + }, + }, + { + label: 'Last 7 days', + value: { + stringFrom: 'now-6d/d', + change: { + stringFrom: 'now-13d/d', + stringTo: 'now-7d/h', + }, + dimension: 'HOUR', + changeDimension: 'HOUR', + }, + }, + { + label: 'Month to date', + value: { + stringFrom: 'now/M', + change: { + stringFrom: 'now-1M/M', + stringTo: 'now-1M/h', + }, + dimension: 'HOUR', + changeDimension: 'HOUR', + }, + }, + { + label: 'Last 30 Days', + value: { + stringFrom: 'now-30d/d', + change: { + stringFrom: 'now-60d/d', + stringTo: 'now-30d/h', + }, + dimension: 'HOUR', + changeDimension: 'HOUR', + }, + }, + { + label: 'Previous Month', + value: { + stringFrom: 'now-1M/M', + stringTo: 'now/M', + dimension: 'HOUR', + }, + }, +]; + +export const FILTERS_SELF_SERVE = FILTERS.filter((_, index) => index !== 1); + +export const TIME_INTERVAL_OPTIONS = [ + { + label: '1 Min', + value: { + stringFrom: 'now-127m', + stringTo: 'now-7m/m', + interval: '1m', + dimension: 'MINUTE', + }, + }, + { + label: '5 Min', + value: { + stringFrom: 'now-247m', + stringTo: 'now-7m/m', + interval: '5m', + dimension: 'MINUTE', + }, + }, + { + label: 'Hour', + value: { + stringFrom: 'now-24h', + interval: '1h', + dimension: 'HOUR', + }, + }, + { + label: 'Day', + value: { + stringFrom: 'now-7d/d', + interval: '1d', + dimension: 'HOUR', + }, + }, +]; + +export const LIST_OF_META_SORTING = [ + 'ID', + 'NAME', + 'SITES', + 'SUPPLIES', + 'ADVERTISERS', + 'DEMANDS', + 'TIER_PRIORITY', + 'PRICING', + 'CREATED', + 'AD_SERVING_FEES', +]; + +export const REFRESH_INTERVAL_TIME = 60000; + +export const TABS = [ + { + label: 'Dashboard', + link: '/marketplace-dashboard', + showFormatFilter: true, + }, + { + label: 'Supply', + link: '/supply', + showFormatFilter: true, + }, + { + label: 'Demand', + link: '/demand', + showFormatFilter: true, + }, + { + label: 'Targeting', + link: '/key-lists/keys', + showFormatFilter: false, + }, +]; + +export const TABS_SELF_SERVE = [ + { + label: 'Supply', + link: '/supply', + showFormatFilter: true, + }, + { + label: 'Demand', + link: '/demand', + showFormatFilter: true, + }, + { + label: 'Targeting', + link: '/key-lists/lists', + showFormatFilter: false, + }, +]; + +export const STATUS_FILTERS = [ + { label: 'All', value: 'ALL' }, + { + label: 'Active', + value: 'ACTIVE', + }, + { + label: 'Disabled', + value: 'DISABLED', + }, +]; + +export const FORMAT_FILTERS = [ + { + label: 'All', + id: null, + format: null, + accountType: null, + }, + { + label: 'Video', + id: 'VIDEO', + format: 'VIDEO', + accountType: 'PUBLISHER', + }, + { + label: 'Display', + id: 'DISPLAY', + format: 'DISPLAY', + accountType: 'PUBLISHER', + }, + { + label: 'Sponsored', + id: 'SPONSORED', + format: 'SPONSORED', + accountType: 'PUBLISHER', + }, +]; + +export const FORMAT_FILTERS_SELF_SERVE = [ + { + label: 'All', + id: null, + format: null, + accountType: null, + }, + { + label: 'Video', + id: 'VIDEO', + format: 'VIDEO', + accountType: 'PUBLISHER', + }, + { + label: 'Display', + id: 'DISPLAY', + format: 'DISPLAY', + accountType: 'PUBLISHER', + }, + { + label: 'Sponsored', + id: 'SPONSORED', + format: 'SPONSORED', + accountType: 'PUBLISHER', + }, +]; + +export const LOG_ACTIONS_CONFIG = { + PRICING_LINE_ITEM_ADD: 'has created a pricing line item', + TIER_CHANGE: 'has changed tier', + PRIORITY_CHANGE: 'has changed priority', + AD_SERVER_URL_CHANGE: 'has changed Ad server URL', + TIMEOUT_CHANGE: 'has changed Timeout (ms)', + NEW_RATE_CREATION: 'has created a new rate', + NEW_ADDITIONAL_FEES_CREATION: 'has created a new additional fees', + ANY_TARGETING_CHANGE: 'has changed targeting', + ANY_FREQUENCY_CAP_CHANGE: 'has changed Frequency Cap', + ANY_BUDGETING_CHANGE: 'has changed budget', + ANY_SPECIFIC_PARAMETER_CHANGE: 'has changed (Placement ID, Site ID, etc.)', + AI_DEFAULT_FLOOR_MANAGEMENT: 'has changed AI Default Floor Management', + DATA_LOOK_BACK_PERIOD_CHANGE: 'has changed Data Look-Back Period', + UPDATE_FREQUENCY_CHANGE: 'has changed Update Frequency', + MAXIMUM_NUMBER_OF_HB_TIERS_CHANGE: 'has changed Maximum Number of HB Tiers', + MAX_SAME_TIER_KPI_GAP_CHANGE: 'has changed Max Same Tier KPI Gap (%)', + DEFAULT_HB_FLOR_VIEWABLE_CHANGE: 'has changed Default HB Floor - Viewable', + DEFAULT_HB_FLOR_NON_VIEWABLE_CHANGE: 'has changed Default HB Floor - Non-Viewable', + AUTOMATIC_OPTIMIZATION_STATUS_CHANGE: 'has changed the status of Automatic Optimization', + TARGET_KPI_CHANGE: 'has changed the Target KPI of Automatic Optimization', + DEFAULT_HB_FLOR_FIRST_REQUEST_CHANGE: 'has changed First Request HB Floor', + AUTOMATIC_FLOOR_PRICE_STATUS_CHANGE: 'has changed the status of Automatic Floor Adjustment', +}; + +export const CUSTOM_PARENT_STICKY_CLASS_NAME = 'sticky-parent-scroll-container'; + +export const PRIMARY_LEFT_Y_AXIS_ID = '2'; +export const SECOND_LEFT_Y_AXIS_ID = '3'; + +export const PRIMARY_RIGHT_Y_AXIS_ID = '0'; +export const SECOND_RIGHT_Y_AXIS_ID = '1'; diff --git a/anyclip/src/modules/marketplace/common/helpers/histogram.js b/anyclip/src/modules/marketplace/common/helpers/histogram.js new file mode 100644 index 0000000..2387bf0 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/helpers/histogram.js @@ -0,0 +1,102 @@ +import dayjs from 'dayjs'; + +export const parseHistogram = (histogram, timezone, customKey, allowableFields) => + histogram?.buckets?.map((bucket) => { + const keyWithPercent = [ + 'REQUESTS_FILL', + 'SUPPLY_REQUESTS_FILL', + 'OVERALL_FILL', + 'OPPORTUNITIES_FILL', + 'MEDIA_MARGIN', + 'REQUESTS_VIEWABILITY', + 'SUPPLY_REQUESTS_VIEWABILITY', + 'IMPRESSIONS_VIEWABILITY', + 'MEDIA_MARGIN_PERCENT', + ]; + + const fields = Object.keys(bucket.fields).reduce((acc, key) => { + if (allowableFields?.length && !allowableFields.includes(key)) { + return acc; + } + + if (bucket.fields[key]) { + return { + ...acc, + [customKey || key]: keyWithPercent.includes(key) + ? Math.round(bucket.fields[key].value * 10000) / 100 + : bucket.fields[key].value, + }; + } + + return acc; + }, {}); + + // Remove the data of the next hours for today comparison graphs + if (customKey === 'today' && bucket.time > dayjs().valueOf()) { + return { + name: dayjs(bucket.time).tz(timezone).format('DD/MM/YYYY HH:mm|HH:mm|DD/MM'), + time: bucket.time, + ...fields, + today: null, + }; + } + + return { + name: dayjs(bucket.time).tz(timezone).format('DD/MM/YYYY HH:mm|HH:mm|DD/MM'), + ...fields, + time: bucket.time, + }; + }) ?? []; + +export const parseComparisonHistograms = ({ histograms, timezone, allowableFields }) => { + const config = { + 0: 'today', + 1: 'yesterday', + 2: 'week', + }; + + if (!histograms?.length) { + return []; + } + + return histograms + .map((histogram, index) => parseHistogram(histogram, timezone, config[index], allowableFields)) + .reduce((a, b) => + a.map((item, i) => { + const { name, ...rest } = b[i]; + return { + ...item, + ...rest, + }; + }), + ); +}; + +export const tooltipValueFormatter = (name, value) => { + const withPercent = [ + 'REQUESTS_FILL', + 'OVERALL_FILL', + 'OPPORTUNITIES_FILL', + 'SUPPLY_REQUESTS_FILL', + 'MEDIA_MARGIN_PERCENT', + ]; + const withDollar = ['REVENUE', 'GROSS_REVENUE', 'MEDIA_COST', 'RPM', 'CPM', 'ERPM', 'PLAYER_ERPM', 'PUB_PLAYER_ERPM']; + + if (withPercent.includes(name)) { + return `${value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}%`; + } + if (withDollar.includes(name)) { + return `$${value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + } + + return value.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); +}; diff --git a/anyclip/src/modules/marketplace/common/helpers/index.js b/anyclip/src/modules/marketplace/common/helpers/index.js new file mode 100644 index 0000000..f852689 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/helpers/index.js @@ -0,0 +1,105 @@ +export const resolveObjectPath = (obj, path) => { + if (obj && path) { + if (Array.isArray(path)) { + return path.reduce( + (acc, key) => ({ + ...acc, + [key]: key.split('.').reduce((prev, curr) => prev?.[curr] ?? null, obj), + }), + {}, + ); + } + return path.split('.').reduce((prev, curr) => prev?.[curr] ?? null, obj); + } + + return null; +}; + +export const parseNumberWithRound = (number, fractionDigits = 10) => parseFloat(number.toFixed(fractionDigits)); + +export const getNextId = (rows) => { + if (!rows?.length) return 1; + const max = Math.max(...rows.map((row) => row.id)); + return max + 1; +}; + +export const downloadTableToCSV = ({ headers, cells, data, fileName }) => { + if (headers?.length && cells?.length && data?.length) { + let csvContent = 'data:text/csv;charset=utf-8,%EF%BB%BF'; + + const title = headers + .map(({ label, withGap }) => { + if (withGap) { + return `${label},`; + } + return label; + }) + .join(','); + + const values = data + .map((row) => { + const textRow = cells + .map( + ({ key, prefix: prefixConfig, postfix: postfixConfig, needMultiply: needMultiplyConfig, withPercent }) => { + const cell = resolveObjectPath(row, key); + let needMultiply = needMultiplyConfig; + let prefix = prefixConfig; + let postfix = postfixConfig; + + if (key === 'pricing' && cell?.model === 'REV_SHARE') { + prefix = ''; + postfix = '%'; + needMultiply = true; + } + + if (cell === undefined || cell === null) { + if (withPercent) { + return ','; + } + return ''; + } + + const cellValue = needMultiply ? (cell?.value ?? 0) * 100 : cell?.value; + + const value = cellValue?.toLocaleString(undefined, { + minimumFractionDigits: prefix?.length || postfix?.length ? 2 : 0, + maximumFractionDigits: 2, + }); + + if (withPercent) { + const percent = cell?.change; + const roundedPercent = percent ? Math.round(percent * 100) : 0; + const percentFormatted = roundedPercent > 0 ? `+${roundedPercent}%` : `${roundedPercent}%`; + + return `${prefix ?? ''}${value.replace(/,/g, '') ?? ''}${postfix ?? ''},${percentFormatted}`; + } + + return `${prefix ?? ''}${ + value ?? + cell?.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }) + }${postfix ?? ''}`.replace(/,/g, ''); + }, + ) + .join(','); + + return textRow; + }) + .join('\r\n'); + + csvContent += encodeURIComponent(`${title}\r\n${values}`); + + const link = document.createElement('a'); + link.setAttribute('href', csvContent); + link.setAttribute('download', fileName.replace(/\./g, '%2E')); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + + link.click(); + document.body.removeChild(link); + } +}; + +export default resolveObjectPath; diff --git a/anyclip/src/modules/marketplace/common/helpers/interval.jsx b/anyclip/src/modules/marketplace/common/helpers/interval.jsx new file mode 100644 index 0000000..bb2e463 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/helpers/interval.jsx @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useInterval = (callback, delay) => { + const savedCallback = useRef(callback); + const intervalIdRef = useRef(undefined); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + const tick = () => { + savedCallback.current(); + }; + + if (delay !== null) { + intervalIdRef.current = setInterval(tick, delay); + } + + return () => { + clearInterval(intervalIdRef.current); + }; + }, [delay]); + + useEffect(() => { + const id = intervalIdRef.current; + return () => { + clearInterval(id); + }; + }, []); + + const resetInterval = useCallback(() => { + clearInterval(intervalIdRef.current); + intervalIdRef.current = setInterval(savedCallback.current, delay); + }, [delay]); + + const stopInterval = useCallback(() => { + clearInterval(intervalIdRef.current); + }, [delay]); + + return { resetInterval, stopInterval }; +}; + +export default useInterval; diff --git a/src/modules/marketplace/common/helpers/supplyDemandTransitionLinks.js b/anyclip/src/modules/marketplace/common/helpers/supplyDemandTransitionLinks.js similarity index 100% rename from src/modules/marketplace/common/helpers/supplyDemandTransitionLinks.js rename to anyclip/src/modules/marketplace/common/helpers/supplyDemandTransitionLinks.js diff --git a/anyclip/src/modules/marketplace/common/helpers/uploadToS3.ts b/anyclip/src/modules/marketplace/common/helpers/uploadToS3.ts new file mode 100644 index 0000000..870f359 --- /dev/null +++ b/anyclip/src/modules/marketplace/common/helpers/uploadToS3.ts @@ -0,0 +1,38 @@ +import { of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +import { gqlRequest, uploadS3 } from '@/modules/@common/request'; + +const queryGetLinksGQL = ` + query GetS3Links($filename: String!, $contentOwnerId: Float!, $fromLiveEvent: Boolean) { + S3UploadLink(filename: $filename, contentOwnerId: $contentOwnerId, fromLiveEvent: $fromLiveEvent) { + uploadUrl + downloadUrl + } + } +`; + +// need for create place in bucket +// not for validation +const TMP_BUCKET_KEY = 911111111119; + +export const uploadFileAndGetDownloadUrl$ = ({ file }: { file: File; contentOwnerId: number }) => { + const filename = file.name.replace(/\s/g, '_'); + const contentOwnerId = TMP_BUCKET_KEY; + + return gqlRequest({ + query: queryGetLinksGQL, + variables: { filename, contentOwnerId, fromLiveEvent: true }, + }).pipe( + switchMap(({ data, errors }) => { + if (errors?.length || !data?.S3UploadLink) return of(null); + const { uploadUrl, downloadUrl } = data.S3UploadLink; + + return uploadS3(uploadUrl, file).pipe( + map((res) => (!res?.errors?.length ? downloadUrl : null)), + catchError(() => of(null)), + ); + }), + catchError(() => of(null)), + ); +}; diff --git a/anyclip/src/modules/marketplace/dashboard/components/Dashboard.module.scss b/anyclip/src/modules/marketplace/dashboard/components/Dashboard.module.scss new file mode 100644 index 0000000..7e2005c --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/components/Dashboard.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Dashboard":"Dashboard_Dashboard__waDTl","Header":"Dashboard_Header__uELxf","Filters":"Dashboard_Filters__Vt8kg","Filter":"Dashboard_Filter__mj34i","Totals":"Dashboard_Totals__g0GiQ","Chart":"Dashboard_Chart__MCfdX","ChartHeading":"Dashboard_ChartHeading__YZ1Lb","ChartFilter":"Dashboard_ChartFilter__Z_WsG","ChartFilterTitle":"Dashboard_ChartFilterTitle__Q_RtM","SmallChart":"Dashboard_SmallChart__oojxz"}; \ No newline at end of file diff --git a/src/modules/marketplace/dashboard/components/SearchNew.jsx b/anyclip/src/modules/marketplace/dashboard/components/SearchNew.jsx similarity index 100% rename from src/modules/marketplace/dashboard/components/SearchNew.jsx rename to anyclip/src/modules/marketplace/dashboard/components/SearchNew.jsx diff --git a/src/modules/marketplace/dashboard/components/SearchNew.module.scss b/anyclip/src/modules/marketplace/dashboard/components/SearchNew.module.scss similarity index 100% rename from src/modules/marketplace/dashboard/components/SearchNew.module.scss rename to anyclip/src/modules/marketplace/dashboard/components/SearchNew.module.scss diff --git a/anyclip/src/modules/marketplace/dashboard/components/Total.jsx b/anyclip/src/modules/marketplace/dashboard/components/Total.jsx new file mode 100644 index 0000000..97d3de1 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/components/Total.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { AdjustOutlined, ArrowCircleDown, ArrowCircleUp, InfoOutlined } from '@mui/icons-material'; + +import { Grid, Tooltip, Typography } from '@/mui/components'; + +import styles from './Total.module.scss'; + +function Total({ prefix = '', postfix = '', needMultiply = false, change = null, tooltip = null, ...props }) { + const value = props.value ?? null; + const valueMultiplied = needMultiply ? value * 100 : value; + const valueFormatted = valueMultiplied?.toLocaleString(undefined, { + minimumFractionDigits: prefix?.length || postfix?.length ? 2 : 0, + maximumFractionDigits: 2, + }); + + const roundedPercent = change ? Math.round(change * 100) : 0; + const percentFormatted = roundedPercent > 0 ? `+${roundedPercent}%` : `${roundedPercent}%`; + let IconPercent = AdjustOutlined; + + if (roundedPercent > 0) { + IconPercent = ArrowCircleUp; + } + + if (roundedPercent < 0) { + IconPercent = ArrowCircleDown; + } + + return ( + +
    + + {props.header} + + + {tooltip?.length && ( + +
    + +
    +
    + )} +
    + + + {value !== null ? `${prefix ?? ''}${valueFormatted ?? ''}${postfix ?? ''}` : 'N/A'} + + + {!Number.isNaN(parseInt(change, 10)) && ( + + {percentFormatted} + + + )} +
    + ); +} + +Total.propTypes = { + header: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + change: PropTypes.number, + prefix: PropTypes.string, + postfix: PropTypes.string, + needMultiply: PropTypes.bool, + tooltip: PropTypes.string, +}; + +export default Total; diff --git a/anyclip/src/modules/marketplace/dashboard/components/Total.module.scss b/anyclip/src/modules/marketplace/dashboard/components/Total.module.scss new file mode 100644 index 0000000..0115a28 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/components/Total.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Total":"Total_Total__EPsZA","Header":"Total_Header__qfnPK","Value":"Total_Value__mBFY_","PercentIcon":"Total_PercentIcon__Rtcqc","InfoTooltip":"Total_InfoTooltip__gQzjU"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/dashboard/components/index.jsx b/anyclip/src/modules/marketplace/dashboard/components/index.jsx new file mode 100644 index 0000000..8210fc0 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/components/index.jsx @@ -0,0 +1,350 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import { useTheme } from '@mui/material/styles'; +import { AccessTime, CompareArrows } from '@mui/icons-material'; + +import { + FILTERS, + PRIMARY_LEFT_Y_AXIS_ID, + PRIMARY_RIGHT_Y_AXIS_ID, + SECOND_LEFT_Y_AXIS_ID, + TIME_INTERVAL_OPTIONS, +} from '../../common/constants'; +import { + DEVICE_FILTERS, + FINANCIALS_PARAMS, + GEO_FILTERS, + PERFORMANCE_COMPARISON_FILTERS, + PERFORMANCE_COMPARISON_PARAMS, + PERFORMANCE_PARAMS, + TOTALS, +} from '../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import { tooltipValueFormatter } from '../../common/helpers/histogram'; +import * as selectors from '../redux/selectors'; +import * as actions from '../redux/slices'; +import { abbreviateNumber } from '@/modules/@common/helpers/number'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import DateSelect from '@/modules/marketplace/common/DateSelect'; +import Chart from '../../common/Chart'; +import Header from '../../common/HeaderNew'; +import Total from './Total'; +import { FormControl, Grid, MenuItem, Select, Typography } from '@/mui/components'; + +import styles from './Dashboard.module.scss'; + +function Dashboard() { + const theme = useTheme(); + + const dispatch = useDispatch(); + + const filterDate = useSelector(selectors.filterDateSelector); + const geo = useSelector(selectors.geoSelector); + const device = useSelector(selectors.deviceSelector); + const total = useSelector(selectors.totalSelector); + const comparisonFilter = useSelector(selectors.comparisonFilterSelector); + const comparisonHistogram = useSelector(selectors.comparisonHistogramSelector); + const performanceFilter = useSelector(selectors.performanceFilterSelector); + const performanceHistogram = useSelector(selectors.performanceHistogramSelector); + const financialsFilter = useSelector(selectors.financialsFilterSelector); + const financialsHistogram = useSelector(selectors.financialsHistogramSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + const setFilterDate = (o) => dispatch(actions.setFilterDateAction(o)); + + useEffect(() => { + dispatch(actions.setPageConfigAction('MARKETPLACE_DASHBOARD')); + // this is necessary in order not to make unnecessary requests when filter format was changed on other pages + + dispatch(actions.getTotalAction()); + dispatch(actions.getComparisonHistogramAction()); + dispatch(actions.getPerformanceHistogramAction()); + dispatch(actions.getFinancialsHistogramAction()); + + return () => { + dispatch(actions.setPageConfigAction(null)); + }; + }, []); + + return ( +
    +
    + + + + + + + + + + + + {TOTALS.map((item) => ( + + ))} + + + + + + + + + + + + Performance Comparison + + + + + + + Comparison + + + + + + + + {!!comparisonHistogram?.length && ( + { + if ( + ['REQUESTS_FILL', 'OVERALL_FILL', 'OPPORTUNITIES_FILL', 'SUPPLY_REQUESTS_FILL'].includes( + comparisonFilter, + ) + ) { + return `${abbreviateNumber(tick)}%`; + } + if ( + ['REVENUE', 'MEDIA_COST', 'RPM', 'CPM', 'ERPM', 'PLAYER_ERPM', 'PUB_PLAYER_ERPM'].includes( + comparisonFilter, + ) + ) { + return `$${abbreviateNumber(tick)}`; + } + + return abbreviateNumber(tick); + }, + }, + ]} + height={210} + tooltipValueFormatter={(name, value) => tooltipValueFormatter(comparisonFilter, value)} + /> + )} + + + + + Performance + +
    + + + + Time Interval + + + + +
    +
    + + {!!performanceHistogram?.length && ( + abbreviateNumber(tick), + }, + { + key: 1, + id: SECOND_LEFT_Y_AXIS_ID, + orientation: 'left', + stroke: theme.palette['-graph'][14], + tickFormatter: (tick) => abbreviateNumber(tick), + }, + { + key: 2, + id: PRIMARY_RIGHT_Y_AXIS_ID, + orientation: 'right', + stroke: theme.palette['-graph'][15], + tickFormatter: (tick) => `${tick}%`, + }, + ]} + height={210} + tooltipValueFormatter={(name, value) => tooltipValueFormatter(name, value)} + /> + )} +
    + + + + + Financials + +
    + + + + Time Interval + + + + +
    +
    + {!!financialsHistogram?.length && ( + abbreviateNumber(tick), + }, + { + key: 1, + id: PRIMARY_RIGHT_Y_AXIS_ID, + orientation: 'right', + tickFormatter: (tick) => `${tick}%`, + }, + ]} + height={210} + tooltipValueFormatter={(name, value) => tooltipValueFormatter(name, value)} + /> + )} +
    +
    +
    +
    +
    + ); +} + +export default Dashboard; diff --git a/anyclip/src/modules/marketplace/dashboard/constants/index.js b/anyclip/src/modules/marketplace/dashboard/constants/index.js new file mode 100644 index 0000000..c91cd02 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/constants/index.js @@ -0,0 +1,227 @@ +import { + PRIMARY_LEFT_Y_AXIS_ID, + PRIMARY_RIGHT_Y_AXIS_ID, + SECOND_LEFT_Y_AXIS_ID, +} from '@/modules/marketplace/common/constants'; + +export const TOTALS = [ + { + header: 'Player Loads', + key: 'PLAYER_LOADS', + }, + { + header: 'Ad Requests', + key: 'SUPPLY_REQUESTS', + }, + { + header: 'Ad Impressions', + key: 'IMPRESSIONS', + }, + { + header: 'Request Fill', + key: 'SUPPLY_REQUESTS_FILL', + postfix: '%', + needMultiply: true, + }, + { + header: 'Revenue', + key: 'REVENUE', + prefix: '$', + tooltip: 'Revenue + Expenses', + }, + { + header: 'Gross Revenue', + key: 'GROSS_REVENUE', + prefix: '$', + }, + { + header: 'Media Margin', + key: 'MEDIA_MARGIN', + prefix: '$', + tooltip: 'Revenue - Cost', + }, + { + header: 'Media Margin %', + key: 'MEDIA_MARGIN_PERCENT', + postfix: '%', + needMultiply: true, + tooltip: 'Media Margin / Revenue', + }, + { + header: 'Gross Ad RPM', + key: 'GROSS_RPM', + prefix: '$', + tooltip: 'Gross Revenue/Ad impressions', + }, + { + header: 'Ad RPM', + key: 'PUB_AD_RPM', + prefix: '$', + tooltip: 'Revenue/Ad impressions', + }, + { + header: 'Gross Player eRPM', + key: 'GROSS_PLAYER_ERPM', + prefix: '$', + tooltip: 'Gross Revenue/Player Loads', + }, + { + header: 'AC Ad RPM', + key: 'AC_AD_RPM', + prefix: '$', + tooltip: 'Revenue generated from AC demand/Ad impressions generated from AC demand', + }, +]; + +export const PERFORMANCE_COMPARISON_PARAMS = [ + { + label: 'Today', + value: 'today', + type: 'area', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-14)', + }, + { + label: 'Yesterday', + value: 'yesterday', + type: 'line', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-15)', + }, + { + label: '7 Days Ago', + value: 'week', + type: 'line', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-16)', + }, +]; + +export const PERFORMANCE_COMPARISON_FILTERS = [ + { + label: 'Player Loads', + value: 'PLAYER_LOADS', + }, + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + }, + { + label: 'Revenue', + value: 'REVENUE', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + }, + { + label: 'Overall Fill', + value: 'OVERALL_FILL', + }, + { + label: 'AC Ad RPM', + value: 'AC_AD_RPM', + }, + { + label: 'Gross Ad RPM', + value: 'GROSS_RPM', + }, + { + label: 'Ad RPM', + value: 'PUB_AD_RPM', + }, + { + label: 'Gross Player eRPM', + value: 'GROSS_PLAYER_ERPM', + }, + { + label: 'Player eRPM', + value: 'PUB_PLAYER_ERPM', + }, + { + label: 'Player eRPM', + value: 'PLAYER_ERPM', + }, +]; + +export const PERFORMANCE_PARAMS = [ + { + label: 'Request Fill', + value: 'SUPPLY_REQUESTS_FILL', + type: 'line', + yAxisId: PRIMARY_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-15)', + }, + { + label: 'Ad Requests', + value: 'SUPPLY_REQUESTS', + type: 'bar', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-13)', + }, + { + label: 'Ad Impressions', + value: 'IMPRESSIONS', + type: 'bar', + yAxisId: SECOND_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-14)', + }, +]; + +export const FINANCIALS_PARAMS = [ + { + label: 'Media Margin %', + value: 'MEDIA_MARGIN_PERCENT', + type: 'line', + yAxisId: PRIMARY_RIGHT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-15)', + }, + { + label: 'Revenue', + value: 'REVENUE', + type: 'bar', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-13)', + }, + { + label: 'Gross Revenue', + value: 'GROSS_REVENUE', + type: 'bar', + yAxisId: PRIMARY_LEFT_Y_AXIS_ID, + color: 'var(--theme-palette--graph-14)', + }, +]; + +export const GEO_FILTERS = [ + { + label: 'All Countries', + value: null, + }, + { + label: 'US Only', + value: 'US', + }, + { + label: 'Non-US', + value: 'NON_US', + }, +]; + +export const DEVICE_FILTERS = [ + { + label: 'All Devices', + value: null, + }, + { + label: 'Desktop', + value: 'DESKTOP', + }, + { + label: 'Mobile', + value: 'MOBILE', + }, +]; diff --git a/anyclip/src/modules/marketplace/dashboard/index.jsx b/anyclip/src/modules/marketplace/dashboard/index.jsx new file mode 100644 index 0000000..d4c9578 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/index.jsx @@ -0,0 +1,3 @@ +import Dashboard from './components'; + +export default Dashboard; diff --git a/anyclip/src/modules/marketplace/dashboard/redux/epics/getComparisonHistogram.js b/anyclip/src/modules/marketplace/dashboard/redux/epics/getComparisonHistogram.js new file mode 100644 index 0000000..5838c18 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/epics/getComparisonHistogram.js @@ -0,0 +1,175 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { parseComparisonHistograms } from '../../../common/helpers/histogram'; +import * as selectors from '../selectors'; +import { + getComparisonHistogramAction, + setComparisonFilterAction, + setComparisonHistogramAction, + setDeviceAction, + setFilterFormatAction, + setGeoAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query SupplyAccountHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + supplyAccountHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + PAGE_VIEWS { + value + change + } + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + OVERALL_FILL { + value + change + } + GROSS_RPM { + value + change + } + PUB_PLAYER_ERPM { + value + change + } + PLAYER_ERPM { + value + change + } + AC_AD_RPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + } + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType( + getComparisonHistogramAction.type, + setComparisonFilterAction.type, + setFilterFormatAction.type, + setDeviceAction.type, + setGeoAction.type, + ), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => { + const state = state$.value; + + const comparisonFilter = selectors.comparisonFilterSelector(state); + const formatFilter = selectors.formatFilterSelector(state); + const device = selectors.deviceSelector(state); + const geo = selectors.geoSelector(state); + + const timezone = getUserTimezoneSelector(state$.value); + + let filters = {}; + + if (formatFilter) { + filters = { ...formatFilter }; + } + + if (device) { + filters = { + ...filters, + device, + }; + } + + if (geo) { + filters = { + ...filters, + geo, + }; + } + + const params = { + timezone, + fields: [comparisonFilter], + interval: '1h', + filters: [ + { ranges: [{ stringFrom: 'now/d', stringTo: 'now+1d/d-59m', timezone }], dimension: 'HOUR', ...filters }, + { ranges: [{ stringFrom: 'now-1d/d', stringTo: 'now/d-59m', timezone }], dimension: 'HOUR', ...filters }, + { ranges: [{ stringFrom: 'now-7d/d', stringTo: 'now-6d/d-59m', timezone }], dimension: 'HOUR', ...filters }, + ], + }; + + const stream$ = gqlRequest({ + query, + variables: { + ...params, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + const { supplyAccountHistogram } = data; + + if (!errors.length) { + const histograms = parseComparisonHistograms({ + histograms: supplyAccountHistogram.data, + timezone, + allowableFields: [comparisonFilter], + }); + + actions = [of(setComparisonHistogramAction(histograms))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/dashboard/redux/epics/getFinancialsHistogram.js b/anyclip/src/modules/marketplace/dashboard/redux/epics/getFinancialsHistogram.js new file mode 100644 index 0000000..1499446 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/epics/getFinancialsHistogram.js @@ -0,0 +1,129 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { parseHistogram } from '../../../common/helpers/histogram'; +import * as selectors from '../selectors'; +import { + getFinancialsHistogramAction, + setDeviceAction, + setFilterFormatAction, + setFinancialsFilterAction, + setFinancialsHistogramAction, + setGeoAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query SupplyAccountHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + supplyAccountHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + REVENUE { + value + change + } + GROSS_REVENUE { + value + change + } + MEDIA_MARGIN_PERCENT { + value + change + } + } + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType( + getFinancialsHistogramAction.type, + setFinancialsFilterAction.type, + setFilterFormatAction.type, + setDeviceAction.type, + setGeoAction.type, + ), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => { + const state = state$.value; + + const financialsFilter = selectors.financialsFilterSelector(state); + const formatFilter = selectors.formatFilterSelector(state); + const device = selectors.deviceSelector(state); + const geo = selectors.geoSelector(state); + + const timezone = getUserTimezoneSelector(state$.value); + + const { interval, dimension, ...ranges } = financialsFilter; + + let filters = {}; + + if (formatFilter) { + filters = { ...formatFilter }; + } + + if (device) { + filters = { + ...filters, + device, + }; + } + + if (geo) { + filters = { + ...filters, + geo, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + timezone, + fields: ['REVENUE', 'GROSS_REVENUE', 'MEDIA_MARGIN_PERCENT'], + interval, + filters: [{ ranges: [ranges], dimension, ...filters }], + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + const { supplyAccountHistogram } = data; + + if (!errors.length) { + const histogram = parseHistogram(supplyAccountHistogram.data[0], timezone); + + actions = [of(setFinancialsHistogramAction(histogram))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/dashboard/redux/epics/getPerformanceHistogram.js b/anyclip/src/modules/marketplace/dashboard/redux/epics/getPerformanceHistogram.js new file mode 100644 index 0000000..1c9644b --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/epics/getPerformanceHistogram.js @@ -0,0 +1,129 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { parseHistogram } from '../../../common/helpers/histogram'; +import * as selectors from '../selectors'; +import { + getPerformanceHistogramAction, + setDeviceAction, + setFilterFormatAction, + setGeoAction, + setPerformanceFilterAction, + setPerformanceHistogramAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query SupplyAccountHistogramQuery( + $fields: [String], + $interval: String, + $timezone: String, + $filters: [MarketplaceFiltersInputType] + ) { + supplyAccountHistogram( + fields: $fields, + interval: $interval, + timezone: $timezone, + filters: $filters + ) { + totalCount + data { + name + buckets { + time + fields { + SUPPLY_REQUESTS_FILL { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + } + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType( + getPerformanceHistogramAction.type, + setPerformanceFilterAction.type, + setFilterFormatAction.type, + setDeviceAction.type, + setGeoAction.type, + ), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => { + const state = state$.value; + + const performanceFilter = selectors.performanceFilterSelector(state); + const formatFilter = selectors.formatFilterSelector(state); + const device = selectors.deviceSelector(state); + const geo = selectors.geoSelector(state); + + const timezone = getUserTimezoneSelector(state$.value); + + const { interval, dimension, ...ranges } = performanceFilter; + + let filters = {}; + + if (formatFilter) { + filters = { ...formatFilter }; + } + + if (device) { + filters = { + ...filters, + device, + }; + } + + if (geo) { + filters = { + ...filters, + geo, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + timezone, + fields: ['SUPPLY_REQUESTS_FILL', 'SUPPLY_REQUESTS', 'IMPRESSIONS'], + interval, + filters: [{ ranges: [ranges], dimension, ...filters }], + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + const { supplyAccountHistogram } = data; + + if (!errors.length) { + const histogram = parseHistogram(supplyAccountHistogram.data[0], timezone); + + actions = [of(setPerformanceHistogramAction(histogram))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/dashboard/redux/epics/getSelfServeUserHubsAndDemandAccounts.js b/anyclip/src/modules/marketplace/dashboard/redux/epics/getSelfServeUserHubsAndDemandAccounts.js new file mode 100644 index 0000000..b67ba57 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/epics/getSelfServeUserHubsAndDemandAccounts.js @@ -0,0 +1,55 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getHubsAndDemandAccountsAction, setHubsAndDemandAccountsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query MarketplaceHubsAndDemandAccounts { + hubsAndDemandAccounts { + records { + id + name + mpSelfService + demandAccount { + id + name + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getHubsAndDemandAccountsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data: { hubsAndDemandAccounts }, errors }) => { + let actions = []; + + if (!errors.length) { + const demandAccounts = hubsAndDemandAccounts.records?.map((item) => item?.demandAccount); + + actions = [ + of( + setHubsAndDemandAccountsAction({ + userHubs: hubsAndDemandAccounts.records, + userDemandAccounts: Object.values( + demandAccounts.reduce((acc, obj) => ({ ...acc, [obj.id]: obj }), {}), + ), + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/dashboard/redux/epics/getTotal.js b/anyclip/src/modules/marketplace/dashboard/redux/epics/getTotal.js new file mode 100644 index 0000000..974544f --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/epics/getTotal.js @@ -0,0 +1,187 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { + getTotalAction, + setDeviceAction, + setFilterDateAction, + setFilterFormatAction, + setGeoAction, + setTotalAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query SupplyAccountTotalQuery( + $fields: [String], + $filters: MarketplaceFiltersInputType + ) { + supplyAccountTotal( + fields: $fields, + filters: $filters + ) { + fields { + PLAYER_LOADS { + value + change + } + SUPPLY_REQUESTS { + value + change + } + IMPRESSIONS { + value + change + } + SUPPLY_REQUESTS_FILL { + value + change + } + REVENUE { + value + change + } + GROSS_RPM { + value + change + } + PUB_AD_RPM { + value + change + } + GROSS_PLAYER_ERPM { + value + change + } + GROSS_REVENUE { + value + change + } + MEDIA_MARGIN { + value + change + } + MEDIA_MARGIN_PERCENT { + value + change + } + AC_AD_RPM { + value + change + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType( + getTotalAction.type, + setFilterDateAction.type, + setFilterFormatAction.type, + setDeviceAction.type, + setGeoAction.type, + ), + filter(() => { + const state = state$.value; + + const pageConfig = selectors.pageConfigSelector(state); + + return !!pageConfig; + }), + switchMap(() => { + const state = state$.value; + + const filterDate = selectors.filterDateSelector(state); + const formatFilter = selectors.formatFilterSelector(state); + const device = selectors.deviceSelector(state); + const geo = selectors.geoSelector(state); + + const timezone = getUserTimezoneSelector(state$.value); + const { isCustomPeriod, ...filterDateRest } = filterDate; + const { change, dimension, changeDimension, ...range } = filterDateRest; + let filters = {}; + + if (dimension) { + filters = { + ...filters, + dimension, + }; + } + + if (change) { + filters = { + ...filters, + changeRanges: [{ ...change, timezone }], + }; + } + + if (changeDimension) { + filters = { + ...filters, + changeDimension, + }; + } + + if (formatFilter) { + filters = { + ...filters, + ...formatFilter, + }; + } + + if (device) { + filters = { + ...filters, + device, + }; + } + + if (geo) { + filters = { + ...filters, + geo, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + fields: [ + 'PLAYER_LOADS', + 'SUPPLY_REQUESTS', + 'IMPRESSIONS', + 'SUPPLY_REQUESTS_FILL', + 'REVENUE', + 'GROSS_RPM', + 'PUB_AD_RPM', + 'GROSS_PLAYER_ERPM', + 'GROSS_REVENUE', + 'MEDIA_MARGIN', + 'MEDIA_MARGIN_PERCENT', + 'AC_AD_RPM', + ], + filters: { + ranges: [{ ...range, timezone }], + ...filters, + }, + }, + }).pipe( + switchMap(({ data, errors }) => { + let actions = []; + + if (!errors.length) { + actions = [of(setTotalAction(data.supplyAccountTotal.fields || {}))]; + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/dashboard/redux/epics/index.js b/anyclip/src/modules/marketplace/dashboard/redux/epics/index.js new file mode 100644 index 0000000..746e942 --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/epics/index.js @@ -0,0 +1,17 @@ +import { combineEpics } from 'redux-observable'; + +import getComparisonHistogram from './getComparisonHistogram'; +import getFinancialsHistogram from './getFinancialsHistogram'; +import getPerformanceHistogram from './getPerformanceHistogram'; +import getSelfServeUserHubsAndDemandAccounts from './getSelfServeUserHubsAndDemandAccounts'; +import getTotal from './getTotal'; +import search from './search'; + +export default combineEpics( + getComparisonHistogram, + getFinancialsHistogram, + getPerformanceHistogram, + getSelfServeUserHubsAndDemandAccounts, + getTotal, + search, +); diff --git a/anyclip/src/modules/marketplace/dashboard/redux/epics/search.js b/anyclip/src/modules/marketplace/dashboard/redux/epics/search.js new file mode 100644 index 0000000..88ca11f --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/epics/search.js @@ -0,0 +1,98 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { setIsSearchLoadingAction, setSearchAction, setSearchResultAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query supplyDemandTagSearch( + $query: String, + $size: Int, + $searchSupply: Boolean, + $searchDemand: Boolean, + $siteIds: [String], + $daccountIds: [MarketplaceValueInputType] + ) { + supplyDemandTagSearch( + query: $query, + size: $size, + searchSupply: $searchSupply, + searchDemand: $searchDemand, + siteIds: $siteIds, + daccountIds: $daccountIds + ) { + data { + id + daccountId + advertiserId + accountId + siteId + name + groupName + status + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(setSearchAction.type), + debounceTime(100), + filter(() => !!getToken()), + switchMap((action) => { + const userPermissions = getUserPermissionsSelector(state$.value); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const { search } = action.payload; + + const state = state$.value; + + const userDemandAccounts = selectors.userDemandAccountsSelector(state); + const userHubs = selectors.userHubsSelector(state); + + let filters = {}; + + if (search.length <= 1) { + return of(setSearchResultAction([])); + } + + if (!isAdminMP) { + filters = { + ...filters, + siteIds: userHubs.filter((item) => item.mpSelfService).map((item) => item.id?.toString()), + daccountIds: userDemandAccounts.map((item) => ({ value: item.id?.toString() })), + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + query: search, + size: 50, + searchSupply: true, + searchDemand: true, + ...filters, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setSearchResultAction(data.supplyDemandTagSearch?.data ?? []))); + } + + return concat(...actions, of(setIsSearchLoadingAction(false))); + }), + ); + + return concat(of(setIsSearchLoadingAction(true)), stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/dashboard/redux/selectors/index.js b/anyclip/src/modules/marketplace/dashboard/redux/selectors/index.js new file mode 100644 index 0000000..4a1c9de --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/selectors/index.js @@ -0,0 +1,21 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const filterDateSelector = (state$) => state$[nameSpace].filterDate; +export const formatFilterIdSelector = (state$) => state$[nameSpace].formatFilterId; +export const formatFilterSelector = (state$) => state$[nameSpace].formatFilter; +export const geoSelector = (state$) => state$[nameSpace].geo; +export const deviceSelector = (state$) => state$[nameSpace].device; +export const totalSelector = (state$) => state$[nameSpace].total; +export const comparisonFilterSelector = (state$) => state$[nameSpace].comparisonFilter; +export const comparisonHistogramSelector = (state$) => state$[nameSpace].comparisonHistogram; +export const performanceFilterSelector = (state$) => state$[nameSpace].performanceFilter; +export const performanceHistogramSelector = (state$) => state$[nameSpace].performanceHistogram; +export const financialsFilterSelector = (state$) => state$[nameSpace].financialsFilter; +export const financialsHistogramSelector = (state$) => state$[nameSpace].financialsHistogram; +export const searchResultSelector = (state$) => state$[nameSpace].searchResult; +export const isSearchLoadingSelector = (state$) => state$[nameSpace].isSearchLoading; +export const userHubsSelector = (state$) => state$[nameSpace].userHubs; +export const userDemandAccountsSelector = (state$) => state$[nameSpace].userDemandAccounts; +export const pageConfigSelector = (state$) => state$[nameSpace].pageConfig; diff --git a/anyclip/src/modules/marketplace/dashboard/redux/slices/index.js b/anyclip/src/modules/marketplace/dashboard/redux/slices/index.js new file mode 100644 index 0000000..74cb04c --- /dev/null +++ b/anyclip/src/modules/marketplace/dashboard/redux/slices/index.js @@ -0,0 +1,113 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { FILTERS, TIME_INTERVAL_OPTIONS } from '../../../common/constants'; +import { PERFORMANCE_COMPARISON_FILTERS } from '../../constants'; + +const initialState = { + filterDate: FILTERS[0].value, + formatFilterId: null, + formatFilter: null, + device: null, + geo: null, + total: {}, + comparisonFilter: PERFORMANCE_COMPARISON_FILTERS[3].value, + comparisonHistogram: [], + performanceFilter: TIME_INTERVAL_OPTIONS[2].value, + performanceHistogram: [], + financialsFilter: TIME_INTERVAL_OPTIONS[2].value, + financialsHistogram: [], + searchResult: [], + isSearchLoading: false, + userHubs: [], + userDemandAccounts: [], + pageConfig: null, +}; + +export const slice = createSlice({ + name: '@@marketplaceDashboard/MARKETPLACEDASHBOARD', + initialState, + + reducers: { + setFilterDateAction: (state, action) => { + state.filterDate = action.payload; + }, + setFilterFormatAction: (state, action) => { + Object.keys(action.payload ?? {}).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + setGeoAction: (state, action) => { + state.geo = action.payload; + }, + setDeviceAction: (state, action) => { + state.device = action.payload; + }, + setTotalAction: (state, action) => { + state.total = action.payload; + }, + setComparisonFilterAction: (state, action) => { + state.comparisonFilter = action.payload; + }, + setComparisonHistogramAction: (state, action) => { + state.comparisonHistogram = action.payload; + }, + setPerformanceFilterAction: (state, action) => { + state.performanceFilter = action.payload; + }, + setPerformanceHistogramAction: (state, action) => { + state.performanceHistogram = action.payload; + }, + setFinancialsFilterAction: (state, action) => { + state.financialsFilter = action.payload; + }, + setFinancialsHistogramAction: (state, action) => { + state.financialsHistogram = action.payload; + }, + setIsSearchLoadingAction: (state, action) => { + state.isSearchLoading = action.payload; + }, + setSearchResultAction: (state, action) => { + state.searchResult = action.payload; + }, + setPageConfigAction: (state, action) => { + state.pageConfig = action.payload; + }, + setHubsAndDemandAccountsAction: (state, action) => { + state.userHubs = action.payload?.userHubs ?? []; + state.userDemandAccounts = action.payload?.userDemandAccounts ?? []; + }, + + getTotalAction: (state) => state, + getComparisonHistogramAction: (state) => state, + getPerformanceHistogramAction: (state) => state, + getFinancialsHistogramAction: (state) => state, + setSearchAction: (state) => state, + getHubsAndDemandAccountsAction: (state) => state, + }, +}); + +export const { + setDeviceAction, + setFilterDateAction, + setFilterFormatAction, + setGeoAction, + setTotalAction, + setComparisonFilterAction, + setComparisonHistogramAction, + setPerformanceFilterAction, + setPerformanceHistogramAction, + setFinancialsFilterAction, + setFinancialsHistogramAction, + setIsSearchLoadingAction, + setSearchResultAction, + setPageConfigAction, + setHubsAndDemandAccountsAction, + getTotalAction, + getComparisonHistogramAction, + getPerformanceHistogramAction, + getFinancialsHistogramAction, + setSearchAction, + getHubsAndDemandAccountsAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/HBConnectorForm.module.scss b/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/HBConnectorForm.module.scss new file mode 100644 index 0000000..51956b2 --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/HBConnectorForm.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"HBConnectorForm_Wrapper__oaPch","Title":"HBConnectorForm_Title__Diuyo","Controls":"HBConnectorForm_Controls__y1MqN"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/index.jsx b/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/index.jsx new file mode 100644 index 0000000..9300cc4 --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectorForm/index.jsx @@ -0,0 +1,331 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { AD_FORMATS, HB_CONNECTOR_STATUSES } from '@/modules/marketplace/hbConnectors/constants'; + +import * as selectors from '../../redux/selectors'; +import * as actions from '../../redux/slices'; +import { getRequiredInputProps } from '@/modules/@common/Form/helpers'; + +import { Form, FormContent, FormRow, FormSection } from '@/modules/@common/Form'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Link, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './HBConnectorForm.module.scss'; + +function HBConnectorForm() { + const dispatch = useDispatch(); + + const name = useSelector(selectors.nameSelector); + const code = useSelector(selectors.codeSelector); + const parameters = useSelector(selectors.parametersSelector); + const comments = useSelector(selectors.commentsSelector); + const status = useSelector(selectors.statusSelector); + const format = useSelector(selectors.formatSelector); + + const router = useRouter(); + const [errors, setErrors] = useState({ + name: null, + code: null, + parameters: null, + }); + const [openDialog, setOpenDialog] = useState(false); + + const [id] = router.query.params; + const isCreateForm = id === 'new'; + + useEffect(() => { + if (!isCreateForm) { + dispatch(actions.getConnectorByIdAction({ id })); + } + }, []); + + const validateFields = (fields = {}) => { + let newErrors = { ...errors }; + Object.entries(fields).forEach(([field, value]) => { + if (field === 'name') { + if (!value?.length) { + newErrors = { + ...newErrors, + name: 'Required', + }; + } else { + newErrors = { + ...newErrors, + name: null, + }; + } + } + + if (field === 'code') { + if (!value?.length) { + newErrors = { + ...newErrors, + code: 'Required', + }; + } else if (!/^\S+$/.test(value)) { + newErrors = { + ...newErrors, + code: 'Code should be without spaces', + }; + } else { + newErrors = { + ...newErrors, + code: null, + }; + } + } + + if (field === 'parameters') { + try { + const params = JSON.parse(value); + + if (isCreateForm && Object.keys(params).length === 0) { + newErrors = { + ...newErrors, + parameters: 'Required', + }; + } else { + newErrors = { + ...newErrors, + parameters: null, + }; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + newErrors = { + ...newErrors, + parameters: 'The Parameters JavaScript is invalid', + }; + } + } + }); + setErrors(newErrors); + }; + + const handleSubmit = () => { + validateFields({ + name, + code, + parameters, + }); + + if (!(errors.name || errors.code || errors.parameters) && name?.length && code?.length) { + if (isCreateForm) { + dispatch(actions.createConnectorAction()); + } else { + setOpenDialog(true); + } + } + }; + + return ( +
    + + + {isCreateForm ? 'New HB Connector' : `${name} > Settings`} + + + + + + + + +
    + + + + { + validateFields({ name: value }); + dispatch(actions.setFieldAction({ name: value })); + }} + /> + + + { + validateFields({ code: value }); + dispatch(actions.setFieldAction({ code: value })); + }} + disabled={!isCreateForm} + /> + + + + dispatch( + actions.setFieldAction({ + status: + status === HB_CONNECTOR_STATUSES.enabled + ? HB_CONNECTOR_STATUSES.disabled + : HB_CONNECTOR_STATUSES.enabled, + }), + ) + } + /> + + + + + + + + Click here + + {' to see parameters of existing connectors'} + + ), + })} + onChange={({ target: { value } }) => { + validateFields({ parameters: value }); + dispatch(actions.setFieldAction({ parameters: value })); + }} + /> + + + { + dispatch(actions.setFieldAction({ comments: value })); + }} + /> + + + +
    + + { + setOpenDialog(false); + }} + > + { + setOpenDialog(false); + }} + > + Important Notification + + + Any change to an existing HB connector must be pre approved and supported by the Player dev team + + + + + +
    + ); +} + +export default HBConnectorForm; diff --git a/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectors.module.scss b/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectors.module.scss new file mode 100644 index 0000000..4424236 --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/components/HBConnectors.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"HBConnectors_Wrapper__TxqRe","Title":"HBConnectors_Title__JH3u8","Controls":"HBConnectors_Controls__QkQoi","TableWrapper":"HBConnectors_TableWrapper__XaTBt","Page":"HBConnectors_Page__ONM9X","Header":"HBConnectors_Header__lUUFE","Filters":"HBConnectors_Filters__qSA5m","Table":"HBConnectors_Table__xs_sn","Row":"HBConnectors_Row__xflib","Status":"HBConnectors_Status__6GWUh","Entries":"HBConnectors_Entries__Z3ZJL","Search":"HBConnectors_Search__PlgLn","Suggester":"HBConnectors_Suggester__GBFCK","CodeWrapper":"HBConnectors_CodeWrapper__FOYSU","Code":"HBConnectors_Code__xCGSQ"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/hbConnectors/components/index.jsx b/anyclip/src/modules/marketplace/hbConnectors/components/index.jsx new file mode 100644 index 0000000..48e64cd --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/components/index.jsx @@ -0,0 +1,277 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { AddRounded, DataObjectRounded, FilterAltRounded } from '@mui/icons-material'; + +import { HB_CONNECTOR_STATUSES, HB_FORMATS } from '../constants'; + +import * as selectors from '../redux/selectors'; +import * as actions from '../redux/slices'; +import { capitalizeFirstLetter } from '@/modules/@common/helpers/string'; +import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Divider, + MenuItem, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableScroll, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './HBConnectors.module.scss'; + +function HBConnectors() { + const dispatch = useDispatch(); + + const userTimezone = useSelector(getUserTimezoneSelector); + + const connectors = useSelector(selectors.connectorsSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const page = useSelector(selectors.pageSelector); + const rowsPerPage = useSelector(selectors.rowsPerPageSelector); + const search = useSelector(selectors.searchSelector); + const filterStatus = useSelector(selectors.filterStatusSelector); + const filterFormat = useSelector(selectors.filterFormatSelector); + + const router = useRouter(); + const [openDialog, setOpenDialog] = useState(false); + const [dialogName, setDialogName] = useState(null); + const [dialogContent, setDialogContent] = useState(null); + + useEffect(() => { + dispatch(actions.getConnectorsAction()); + }, []); + + const handleClickOpenDialog = (name, params) => { + import('prismjs').then((Prism$) => { + setDialogContent({ + __html: Prism$.default.highlight( + JSON.stringify({ params }, null, 2) || '', + Prism$.default.languages.javascript, + 'javascript', + ), + }); + }); + + setDialogName(`${name} Parameters`); + setOpenDialog(true); + }; + + const handleCloseDialog = () => { + setOpenDialog(false); + setDialogName(null); + }; + + // todo: migrate to + return ( +
    + + + HB Connectors + + + + +
    + { + dispatch(actions.setFieldAction({ search: value, page: 0 })); + dispatch(actions.getConnectorsAction()); + }} + /> +
    + + + + +
    + +
    +
    + +
    +
    + + + +
    +
    + + + + + + Id + Name + Code + Parameters + Ad Format + Comments + Updated By + Last Update + + + + {connectors.map( + ({ id, numericId, name, code, params, format, comments, user, updatedBy, created, updated }) => ( + { + dispatch(actions.resetFormAction()); + dispatch( + actions.setFieldAction({ + name, + code, + params, + format, + comments, + }), + ); + router.push(`/hb-connectors/${id}`); + }} + > + + {numericId} + + {name} + {code} + +
    +
    + { + dispatch(actions.setFieldAction({ page: newPage - 1 })); + dispatch(actions.getConnectorsAction()); + }} + onRowsPerPageChange={(event) => { + dispatch(actions.setFieldAction({ rowsPerPage: +event.target.value })); + dispatch(actions.getConnectorsAction()); + }} + /> +
    +
    + + {dialogName} + + +
    +              
    +            
    +
    +
    +
    +
    + ); +} + +export default HBConnectors; diff --git a/anyclip/src/modules/marketplace/hbConnectors/constants/index.js b/anyclip/src/modules/marketplace/hbConnectors/constants/index.js new file mode 100644 index 0000000..3d700fa --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/constants/index.js @@ -0,0 +1,48 @@ +export const STATUS_FILTER = [ + { label: 'Show All statuses', value: 'ALL' }, + { label: 'Active', value: 'ACTIVE' }, + { label: 'Inactive', value: 'INACTIVE' }, +]; + +export const ENTRIES_FILTER = [ + { label: '10 entries', value: 10 }, + { label: '25 entries', value: 25 }, + { label: '50 entries', value: 50 }, + { label: '100 entries', value: 100 }, +]; + +export const HB_CONNECTOR_STATUSES = { + enabled: 'ACTIVE', + allStatuses: 'ALL STATUSES', + disabled: 'DISABLED', +}; + +export const HB_FORMATS = [ + { + label: 'ALL', + value: 'BOTH', + }, + { + label: 'Video', + value: 'VIDEO', + }, + { + label: 'Display', + value: 'DISPLAY', + }, +]; + +export const AD_FORMATS = [ + { + label: 'Both', + value: 'BOTH', + }, + { + label: 'Video', + value: 'VIDEO', + }, + { + label: 'Display', + value: 'DISPLAY', + }, +]; diff --git a/anyclip/src/modules/marketplace/hbConnectors/index.jsx b/anyclip/src/modules/marketplace/hbConnectors/index.jsx new file mode 100644 index 0000000..0ee9f41 --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/index.jsx @@ -0,0 +1,3 @@ +import HBConnectors from './components'; + +export default HBConnectors; diff --git a/anyclip/src/modules/marketplace/hbConnectors/redux/epics/createConnector.js b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/createConnector.js new file mode 100644 index 0000000..3b0fd6c --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/createConnector.js @@ -0,0 +1,100 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { createConnectorAction, getConnectorsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation createHBConnector( + $name: String, + $code: String, + $status: String, + $format: String, + $comments: String, + $params: [MarketplaceHBParamInputType] + ) { + createHBConnector( + name: $name, + code: $code, + status: $status, + format: $format, + comments: $comments, + params: $params + ) { + id + created + updated + name + status + updatedBy + code + format + numericId + comments + params { + name + label + defaultValue + defaultValueLabel + required + type + allowedValues { + label + value + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createConnectorAction.type), + switchMap(() => { + const state = state$.value; + + const name = selectors.nameSelector(state); + const code = selectors.codeSelector(state); + const status = selectors.statusSelector(state); + const format = selectors.formatSelector(state); + const parameters = selectors.parametersSelector(state); + const comments = selectors.commentsSelector(state); + + let params = []; + + const parsedParams = JSON.parse(parameters); + + if (parsedParams?.params && Array.isArray(parsedParams?.params)) { + params = [...params, ...parsedParams.params]; + } else { + params = [parsedParams]; + } + + const stream$ = gqlRequest({ + query, + variables: { + name, + code, + status, + format, + params, + comments, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(getConnectorsAction())); + Router.push('/hb-connectors'); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectorById.js b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectorById.js new file mode 100644 index 0000000..108f00d --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectorById.js @@ -0,0 +1,78 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getConnectorByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query hbConnectorById( + $id: String + ) { + hbConnectorById( + id: $id + ) { + id + created + updated + name + status + format + updatedBy + user + code + numericId + comments + params { + name + label + defaultValue + defaultValueLabel + required + type + allowedValues { + label + value + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getConnectorByIdAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { name, code, params, comments, status, format } = data.hbConnectorById; + + actions.push( + of( + setFieldAction({ + name, + code, + params, + comments, + status, + format, + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectors.js b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectors.js new file mode 100644 index 0000000..42b5b81 --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/getConnectors.js @@ -0,0 +1,118 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { HB_CONNECTOR_STATUSES, HB_FORMATS } from '../../constants'; + +import * as selectors from '../selectors'; +import { getConnectorsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query hbConnectorsSearch( + $from: Int, + $size: Int, + $query: String, + $status: String + $format: String + ) { + hbConnectorsSearch( + from: $from, + size: $size, + query: $query, + status: $status + format: $format + ) { + totalCount + data { + id + created + updated + name + status + format + updatedBy + user + code + numericId + comments + params { + name + label + defaultValue + defaultValueLabel + required + type + allowedValues { + label + value + } + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getConnectorsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap(() => { + const state = state$.value; + + const search = selectors.searchSelector(state); + const page = selectors.pageSelector(state); + const rowsPerPage = selectors.rowsPerPageSelector(state); + const filterStatus = selectors.filterStatusSelector(state); + const filterFormat = selectors.filterFormatSelector(state); + + let requestParams = {}; + + if (search?.length) { + requestParams = { + ...requestParams, + query: search, + }; + } + if (filterStatus !== HB_CONNECTOR_STATUSES.allStatuses) { + requestParams = { + ...requestParams, + status: filterStatus, + }; + } + if (filterFormat !== HB_FORMATS[0].value) { + requestParams = { + ...requestParams, + format: filterFormat, + }; + } + const stream$ = gqlRequest({ + query, + variables: { + size: rowsPerPage || 10, + from: (page || 0) * (rowsPerPage || 10), + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { data: connectors, totalCount } = data.hbConnectorsSearch; + + actions.push( + of( + setFieldAction({ + totalCount, + connectors, + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/hbConnectors/redux/epics/index.js b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/index.js new file mode 100644 index 0000000..5cede82 --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import createConnector from './createConnector'; +import getConnectorById from './getConnectorById'; +import getConnectors from './getConnectors'; +import updateConnector from './updateConnector'; + +export default combineEpics(getConnectors, getConnectorById, createConnector, updateConnector); diff --git a/anyclip/src/modules/marketplace/hbConnectors/redux/epics/updateConnector.js b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/updateConnector.js new file mode 100644 index 0000000..50bf0fd --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/redux/epics/updateConnector.js @@ -0,0 +1,130 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { getConnectorsAction, updateConnectorAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation updateHBConnector( + $id: String, + $name: String, + $code: String, + $status: String, + $format: String, + $comments: String, + $params: [MarketplaceHBParamInputType] + ) { + updateHBConnector( + id: $id, + name: $name, + code: $code, + status: $status, + format: $format, + comments: $comments, + params: $params + ) { + id + created + updated + name + updatedBy + status + format + code + numericId + comments + params { + name + label + defaultValue + defaultValueLabel + required + type + allowedValues { + label + value + } + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateConnectorAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const state = state$.value; + + const name = selectors.nameSelector(state); + const code = selectors.codeSelector(state); + const status = selectors.statusSelector(state); + const format = selectors.formatSelector(state); + const parameters = selectors.parametersSelector(state); + const comments = selectors.commentsSelector(state); + + let requestParams = { + comments, + }; + + if (code?.length) { + requestParams = { + ...requestParams, + code, + }; + } + + const parsedParams = JSON.parse(parameters); + + if (Object.keys(parsedParams).length !== 0) { + if (parsedParams?.params && Array.isArray(parsedParams?.params)) { + requestParams = { + ...requestParams, + params: [...parsedParams.params], + }; + } else { + requestParams = { + ...requestParams, + params: [parsedParams], + }; + } + } + + if (format?.length) { + requestParams = { + ...requestParams, + format, + }; + } + if (status?.length) { + requestParams = { + ...requestParams, + status, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + name, + ...requestParams, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(getConnectorsAction())); + Router.push('/hb-connectors'); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/hbConnectors/redux/selectors/index.js b/anyclip/src/modules/marketplace/hbConnectors/redux/selectors/index.js new file mode 100644 index 0000000..817e86c --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/redux/selectors/index.js @@ -0,0 +1,19 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const connectorsSelector = (state$) => state$[nameSpace].connectors; +export const totalCountSelector = (state$) => state$[nameSpace].totalCount; +export const pageSelector = (state$) => state$[nameSpace].page; +export const rowsPerPageSelector = (state$) => state$[nameSpace].rowsPerPage; +export const searchSelector = (state$) => state$[nameSpace].search; +export const filterStatusSelector = (state$) => state$[nameSpace].filterStatus; +export const filterFormatSelector = (state$) => state$[nameSpace].filterFormat; + +export const nameSelector = (state$) => state$[nameSpace].name; +export const codeSelector = (state$) => state$[nameSpace].code; +export const paramsSelector = (state$) => state$[nameSpace].params; +export const parametersSelector = (state$) => state$[nameSpace].parameters; +export const commentsSelector = (state$) => state$[nameSpace].comments; +export const statusSelector = (state$) => state$[nameSpace].status; +export const formatSelector = (state$) => state$[nameSpace].format; diff --git a/anyclip/src/modules/marketplace/hbConnectors/redux/slices/index.js b/anyclip/src/modules/marketplace/hbConnectors/redux/slices/index.js new file mode 100644 index 0000000..b2e0da6 --- /dev/null +++ b/anyclip/src/modules/marketplace/hbConnectors/redux/slices/index.js @@ -0,0 +1,60 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { HB_CONNECTOR_STATUSES, HB_FORMATS } from '../../constants'; + +const initialConnectorFormState = { + name: '', + code: '', + params: [], + parameters: '{}', + comments: '', + status: HB_CONNECTOR_STATUSES.enabled, + format: HB_FORMATS[0].value, +}; + +const initialState = { + connectors: [], + totalCount: 0, + page: 0, + rowsPerPage: 10, + search: '', + + filterStatus: HB_CONNECTOR_STATUSES.enabled, + filterFormat: HB_FORMATS[0].value, + + ...initialConnectorFormState, +}; + +export const slice = createSlice({ + name: '@@hbConnectors/HB_CONNECTORS', + initialState, + + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + resetFormAction: (state) => { + Object.keys(initialConnectorFormState).forEach((key) => { + state[key] = initialConnectorFormState[key]; + }); + }, + + getConnectorsAction: (state) => state, + getConnectorByIdAction: (state) => state, + createConnectorAction: (state) => state, + updateConnectorAction: (state) => state, + }, +}); + +export const { + setFieldAction, + resetFormAction, + getConnectorsAction, + getConnectorByIdAction, + createConnectorAction, + updateConnectorAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/marketplace/keyLists/components/Filters/Filters.module.scss b/anyclip/src/modules/marketplace/keyLists/components/Filters/Filters.module.scss new file mode 100644 index 0000000..826880e --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/Filters/Filters.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SearchFieldWrapper":"Filters_SearchFieldWrapper__rfCHi","Filters":"Filters_Filters__Yepfg","IconSpecial":"Filters_IconSpecial__2yqlC","DefaultSelectProp":"Filters_DefaultSelectProp__AIawz","PlusButton":"Filters_PlusButton__EBBeH"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/keyLists/components/Filters/index.jsx b/anyclip/src/modules/marketplace/keyLists/components/Filters/index.jsx new file mode 100644 index 0000000..8646dc3 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/Filters/index.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Add, FilterAlt, SearchRounded, Settings } from '@mui/icons-material'; + +import { BULK_ACTIONS, STATUS_FILTERS } from '../../constants'; + +import { Button, FormControl, IconButton, InputAdornment, MenuItem, Select, Stack, TextField } from '@/mui/components'; + +import styles from './Filters.module.scss'; + +function Filters(props) { + return ( + + +
    + { + props.setField({ + search: event.target.value, + page: 0, + }); + props.fetchData(event.target.value); + }} + placeholder="Search" + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + size="small" + /> +
    +
    + null} readOnly> + + +
    + + + +
    + + + + + + + +
    + ); +} + +Filters.propTypes = { + search: PropTypes.string.isRequired, + filterStatus: PropTypes.string.isRequired, + setField: PropTypes.func.isRequired, + disabledBulk: PropTypes.bool.isRequired, + addButtonText: PropTypes.string.isRequired, + + handleBulkUpdate: PropTypes.func.isRequired, + onCreateNew: PropTypes.func.isRequired, + fetchData: PropTypes.func.isRequired, +}; + +export default Filters; diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/KeyListForm.module.scss b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/KeyListForm.module.scss new file mode 100644 index 0000000..9c4fbd8 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/KeyListForm.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"KeyListForm_Wrapper__CSEIn","Title":"KeyListForm_Title__esYBL","Controls":"KeyListForm_Controls__nKEj7","EmptyKeys":"KeyListForm_EmptyKeys__a93r9"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/FormLineItem.jsx b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/FormLineItem.jsx new file mode 100644 index 0000000..e42a5dc --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/FormLineItem.jsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { Check, Clear } from '@mui/icons-material'; + +import { Checkbox, Grid, IconButton, InputAdornment, TextField, Typography } from '@/mui/components'; + +import styles from './LineItem.module.scss'; + +function FormLineItem({ className = '', ...props }) { + const [name$, setName] = useState(''); + + return ( + + + + + + + {props.id} + + + setName(target.value)} + variant="outlined" + size="small" + InputProps={{ + endAdornment: ( + + { + props.onSubmit({ + id: props.id, + name: name$, + }); + }} + > + + + props.onCancel()}> + + + + ), + }} + /> + + + + ); +} + +FormLineItem.propTypes = { + className: PropTypes.string, + id: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +export default FormLineItem; diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/LineItem.module.scss b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/LineItem.module.scss new file mode 100644 index 0000000..12e9ebc --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/LineItem.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Container":"LineItem_Container__xpvLe","Container___label":"LineItem_Container___label__vJmLJ","Container___edit":"LineItem_Container___edit__DcxZV","Container___checked":"LineItem_Container___checked__Uf_qa","Item":"LineItem_Item__VWjaf","Name":"LineItem_Name__1bX9y","Buttons":"LineItem_Buttons__zVEbI","ButtonEdit":"LineItem_ButtonEdit__ZcnlC"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/index.jsx b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/index.jsx new file mode 100644 index 0000000..64a5b2b --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/LineItem/index.jsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { Check, Clear, Edit } from '@mui/icons-material'; + +import { Checkbox, Grid, IconButton, InputAdornment, TextField, Typography } from '@/mui/components'; + +import styles from './LineItem.module.scss'; + +// todo: fix layout Replace Grid to Stack +function LineItem({ + className = '', + isLabelRow = false, + checked = false, + hasItems = false, + onSelect = () => null, + onSubmit = () => null, + onRemove = () => null, + ...props +}) { + const [isEdit, setEditState] = useState(false); + const [name$, setName] = useState(props.name); + + return ( + + + + onSelect({ checked: target.checked, id: props.id })} + /> + + + {props.id} + + + {!isEdit ? ( + + {props.name} + + ) : ( + setName(target.value)} + variant="outlined" + size="small" + InputProps={{ + endAdornment: ( + + { + setEditState(false); + onSubmit({ + id: props.id, + name: name$, + }); + }} + > + + + { + setEditState(false); + setName(props.name); + }} + > + + + + ), + }} + /> + )} + + + {!isLabelRow && ( + + {!isEdit && ( + <> +
    + { + setEditState(true); + }} + > + + +
    +
    + { + onRemove({ + id: props.id, + }); + }} + > + + +
    + + )} +
    + )} +
    + ); +} + +LineItem.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + className: PropTypes.string, + isLabelRow: PropTypes.bool, + checked: PropTypes.bool, + hasItems: PropTypes.bool, + onSelect: PropTypes.func, + onSubmit: PropTypes.func, + onRemove: PropTypes.func, +}; + +export default LineItem; diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/index.jsx b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/index.jsx new file mode 100644 index 0000000..e6835b4 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyListForm/index.jsx @@ -0,0 +1,456 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Add, Delete, SearchRounded, UploadOutlined } from '@mui/icons-material'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../../redux/selectors'; +import * as actions from '../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { getNextId } from '@/modules/marketplace/common/helpers'; + +import { Form, FormContent, FormGroupTitle, FormRow, FormRowItem, FormSection } from '@/modules/@common/Form'; +import ChipInput from '../../../common/ChipInput'; +import LineItem from './LineItem'; +import FormLineItem from './LineItem/FormLineItem'; +import { + Autocomplete, + Button, + IconButton, + InputAdornment, + Stack, + TablePagination, + TextField, + Tooltip, + Typography, +} from '@/mui/components'; +import { CustomCsvFilled } from '@/mui/components/CustomIcon'; + +import styles from './KeyListForm.module.scss'; + +function KeyListForm() { + const store = useStore(); + const dispatch = useDispatch(); + + const userPermissions = useSelector(getUserPermissionsSelector); + const scheme = useSelector(selectors.schemeSelector); + + const name = useSelector(selectors.nameSelector); + const demandAccount = useSelector(selectors.demandAccountSelector); + const demandAccountOptions = useSelector(selectors.demandAccountOptionsSelector); + const searchKeys = useSelector(selectors.searchKeysSelector); + const keys = useSelector(selectors.keysSelector); + const keysPage = useSelector(selectors.keysPageSelector); + const keysRowsPerPage = useSelector(selectors.keysRowsPerPageSelector); + const bulkKeys = useSelector(selectors.bulkKeysSelector); + const userDemandAccounts = useSelector(selectors.userDemandAccountsSelector); + + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const router = useRouter(); + const fileInputRef = useRef(null); + const [totalKeys, setTotalKeys] = useState(false); + const [showNewForm, toggleNewFrom] = useState(false); + + const { id } = router.query; + const isCreateForm = id === 'new'; + + const isDuplicate = 'duplicate' in router.query; + + const clearForm = () => { + dispatch(actions.setInitialFormAction()); + }; + + useEffect(() => { + clearForm(); + if (!isCreateForm) { + dispatch(actions.getKeyListByIdAction({ id, isDuplicate })); + } + + if (isAdminMP) { + dispatch(actions.getDemandAccountsAction()); + } + }, []); + + const keysData = useMemo(() => { + let computedKeys = [...keys]; + + if (searchKeys?.length) { + computedKeys = computedKeys.filter((key) => key.name.toLowerCase().includes(searchKeys.toLowerCase())); + } + + setTotalKeys(computedKeys.length); + + return computedKeys.slice(keysPage * keysRowsPerPage, keysPage * keysRowsPerPage + keysRowsPerPage); + }, [keys, searchKeys, keysPage, keysRowsPerPage]); + + const isKeysSelected = () => keys.some((o) => o.checked); + + const handleCsvParse = ({ target }) => { + const file = target.files[0]; + const fileReader = new FileReader(); + + fileReader.onloadend = () => { + const rawContent = fileReader.result; + const content = rawContent.split('\n'); + + dispatch( + actions.setFieldAction({ + bulkKeys: content.filter(Boolean).map((item) => item.replace(/\r/g, '').trim()), + }), + ); + }; + fileReader.readAsText(file); + + fileInputRef.current.value = ''; + }; + + const handleRemoveKeys = () => { + dispatch( + actions.setFieldAction({ + keys: keys.filter((o) => !o.checked), + }), + ); + }; + + const handleBulkAddKeys = () => { + const newBulkKeys = bulkKeys + ?.map((item, index) => ({ + id: getNextId(keys) + index, + name: item, + })) + .reverse(); + dispatch( + actions.setFieldAction({ + keys: [...newBulkKeys, ...keys], + }), + ); + dispatch(actions.setFieldAction({ bulkKeys: [] })); + }; + + const handleBulkRemoveKeys = () => { + dispatch( + actions.setFieldAction({ + keys: keys.filter((key) => !bulkKeys?.includes(key.name)), + }), + ); + dispatch(actions.setFieldAction({ bulkKeys: [] })); + }; + + const handleSubmit = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = actions.validateFields( + selectors.schemeSelector(state).map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList[0]; + + dispatch(actions.setScrollToFieldNameAction(errorField.fieldName)); + } else if (isCreateForm || isDuplicate) { + dispatch(actions.createKeyListAction()); + } else { + dispatch(actions.updateKeyListAction({ id })); + } + + dispatch(actions.setErrorByPropAction(validation)); + }; + + const headerText = isCreateForm ? 'Adding New Value List' : `${name} Value List`; + + return ( +
    + + + {headerText?.length > 50 ? ( + + {`${headerText.substring(0, 50)}... Value List`} + + ) : ( + headerText + )} + + + +
    + + + + + + +
    + + + Basic Setting + + { + dispatch( + actions.setFieldAction({ + name: target.value, + }), + ); + }} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(actions.removeErrorByPropAction(['name']))} + /> + + + ({ value: item?.id?.toString(), label: item?.name })) + } + value={demandAccount} + onChange={(event, newValue) => { + dispatch(actions.setFieldAction({ demandAccount: newValue })); + }} + disabled={!(isCreateForm || isDuplicate)} + renderInput={(params) => ( + { + if (isAdminMP) { + dispatch(actions.getDemandAccountsAction(e.target.value)); + } + }} + {...getInputPropsByName(scheme, ['demandAccount'])} + onFocus={() => dispatch(actions.removeErrorByPropAction(['demandAccount']))} + /> + )} + /> + + + + + + null}> + + + + ), + }} + variant="outlined" + size="small" + onChange={({ target }) => { + dispatch( + actions.setFieldAction({ + searchKeys: target.value, + }), + ); + }} + /> + + + {isKeysSelected() && ( + + )} + + + + + + o.checked)} + onSelect={(data) => { + dispatch( + actions.setFieldAction({ + keys: keys.map((o) => + keysData?.find((key) => key.id === o.id) ? { ...o, checked: data.checked } : o, + ), + }), + ); + }} + /> + + {showNewForm && ( + { + toggleNewFrom(false); + dispatch( + actions.setFieldAction({ + keys: [{ ...data }, ...keys], + }), + ); + }} + onCancel={() => { + toggleNewFrom(false); + }} + /> + )} + + {keysData.map((key) => ( + { + dispatch( + actions.setFieldAction({ + keys: keys.map((o) => (o.id === data.id ? { ...data } : o)), + }), + ); + }} + onRemove={(data) => { + dispatch( + actions.setFieldAction({ + keys: keys.filter((o) => o.id !== data.id), + }), + ); + }} + onSelect={(data) => { + dispatch( + actions.setFieldAction({ + keys: keys.map((o) => (o.id === data.id ? { ...o, checked: data.checked } : o)), + }), + ); + }} + /> + ))} + + {totalKeys > 5 && ( + { + dispatch(actions.setFieldAction({ keysPage: page - 1 })); + }} + onRowsPerPageChange={(event) => { + dispatch(actions.setFieldAction({ keysRowsPerPage: +event.target.value })); + }} + /> + )} + + {!keys.length && !showNewForm && ( + + No values to show. Start adding them by clicking button below, or use Bulk Actions. + + )} + + + Bulk Actions + + + + + dispatch(actions.setFieldAction({ bulkKeys: labels }))} + /> + + + + + + + +
    +
    + ); +} + +export default KeyListForm; diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyLists.module.scss b/anyclip/src/modules/marketplace/keyLists/components/KeyLists.module.scss new file mode 100644 index 0000000..89105b4 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyLists.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"KeyLists_Wrapper__0BvGg","Filters":"KeyLists_Filters__PVFUB","Body":"KeyLists_Body__7OY72","Table":"KeyLists_Table__ZttH6","Row":"KeyLists_Row__WGtQ9","Controls":"KeyLists_Controls__ymV18","Tabs":"KeyLists_Tabs__eVD3M","VisibilityHidden":"KeyLists_VisibilityHidden__BFwR0"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/KeyListForm.module.scss b/anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/KeyListForm.module.scss new file mode 100644 index 0000000..31ee26b --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/KeyListForm.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"KeyListForm_Wrapper__4UAs_","Title":"KeyListForm_Title__0o0rE","Controls":"KeyListForm_Controls__4DkRK"}; \ No newline at end of file diff --git a/anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/index.jsx b/anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/index.jsx new file mode 100644 index 0000000..633a51b --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/KeyValueForm/index.jsx @@ -0,0 +1,147 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import * as selectors from '../../redux/selectors'; +import * as actions from '../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { Form, FormContent, FormGroupTitle, FormRow, FormSection } from '@/modules/@common/Form'; +import { Button, Stack, TextField, Tooltip, Typography } from '@/mui/components'; + +import styles from './KeyListForm.module.scss'; + +function KeyValueForm() { + const store = useStore(); + const dispatch = useDispatch(); + + const scheme = useSelector(selectors.schemeValueFormSelector); + + const keyName = useSelector(selectors.keyNameSelector); + const keyValue = useSelector(selectors.keyValueSelector); + + const router = useRouter(); + + const { id } = router.query; + const isCreateForm = id === 'new'; + + const clearForm = () => { + dispatch(actions.setInitialFormAction()); + }; + + useEffect(() => { + clearForm(); + + if (!isCreateForm) { + dispatch(actions.getAvailableKeyByIdAction({ id })); + } + }, []); + + const handleSubmit = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = actions.validateValueFormFields( + selectors.schemeValueFormSelector(state).map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList[0]; + + dispatch(actions.setScrollToFieldNameValueFormAction(errorField.fieldName)); + } else if (isCreateForm) { + dispatch(actions.createAvailableKeyAction()); + } else { + dispatch(actions.updateAvailableKeyAction({ id })); + } + + dispatch(actions.setErrorByPropValueFormAction(validation)); + }; + + const headerText = isCreateForm ? 'Adding New Key' : `${keyName} Key`; + + return ( +
    + + + {headerText?.length > 50 ? ( + + {`${headerText.substring(0, 50)}... Key`} + + ) : ( + headerText + )} + + + +
    + + + + + +
    + + + Basic Setting + + { + dispatch( + actions.setFieldAction({ + keyName: target.value, + }), + ); + }} + {...getInputPropsByName(scheme, ['keyName'])} + onFocus={() => dispatch(actions.removeErrorByPropValueFormAction(['keyName']))} + /> + + + { + dispatch( + actions.setFieldAction({ + keyValue: target.value, + }), + ); + }} + {...getInputPropsByName(scheme, ['keyValue'])} + onFocus={() => dispatch(actions.removeErrorByPropValueFormAction(['keyValue']))} + /> + + + +
    +
    + ); +} + +export default KeyValueForm; diff --git a/anyclip/src/modules/marketplace/keyLists/components/index.jsx b/anyclip/src/modules/marketplace/keyLists/components/index.jsx new file mode 100644 index 0000000..72e9e1f --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/components/index.jsx @@ -0,0 +1,391 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { Cancel, CheckCircle, ContentCopyRounded } from '@mui/icons-material'; + +import { PAGE_CONFIG, TABS, TABS_SELF_SERVE } from '../constants'; +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; +import { CUSTOM_PARENT_STICKY_CLASS_NAME } from '@/modules/marketplace/common/constants'; + +import * as selectors from '../redux/selectors'; +import * as actions from '../redux/slices'; +import { getNumberInRange } from '@/modules/@common/helpers/number'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector, getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors'; + +import { TableCellActions } from '@/modules/@common/Table'; +import Header from '../../common/HeaderNew'; +import Filters from './Filters'; +import { + Box, + Checkbox, + IconButton, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableScroll, + TableSortLabel, + Tabs, + Tooltip, +} from '@/mui/components'; + +import styles from './KeyLists.module.scss'; + +const STATUS_ICON = { + ACTIVE: ( + null}> + + + ), + DISABLED: ( + null}> + + + ), + DELETED: ( + null}> + + + ), +}; + +function KeyLists() { + const dispatch = useDispatch(); + + const userTimezone = useSelector(getUserTimezoneSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + + const tab = useSelector(selectors.tabSelector); + const search = useSelector(selectors.searchSelector); + const status = useSelector(selectors.statusSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const page = useSelector(selectors.pageSelector); + const rowsPerPage = useSelector(selectors.rowsPerPageSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + const keyLists = useSelector(selectors.keyListsSelector); + + const setField = (o) => dispatch(actions.setFieldAction(o)); + + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const router = useRouter(); + const [pageConfig, setPageConfig] = useState(PAGE_CONFIG.keys); + const [selected, setSelected] = useState([]); + + const isListsPage = router.pathname.includes('/lists'); + + const fetchData = () => { + if (router.pathname.includes('/keys')) { + dispatch(actions.searchAvailableKeysAction()); + } + + if (isListsPage) { + dispatch(actions.searchKeyListsAction()); + } + }; + + const bulkUpdate = (value) => { + if (router.pathname.includes('/keys')) { + dispatch( + actions.bulkUpdateAvailableKeysAction({ + ids: selected, + status: value, + }), + ); + } + + if (isListsPage) { + dispatch( + actions.bulkUpdateKeyListsAction({ + ids: selected, + status: value, + }), + ); + } + }; + + useEffect(() => { + if (isAdminMP) { + fetchData(); + } else { + dispatch(actions.getHubsAndDemandAccountsAction()); + } + + if (router.pathname.includes('/keys')) { + dispatch(actions.setFieldAction({ tab: 0 })); + } + + if (isListsPage) { + dispatch(actions.setFieldAction({ tab: isAdminMP ? 1 : 0 })); + } + }, []); + + useEffect(() => { + if (router.pathname.includes('/keys')) { + setPageConfig(PAGE_CONFIG.keys); + } + + if (isListsPage) { + setPageConfig(PAGE_CONFIG.lists); + } + }, [router.pathname]); + + const handleSelectAllClick = (event) => { + if (event.target.checked) { + const newSelected = keyLists.map((item) => item.id); + setSelected(newSelected); + return; + } + setSelected([]); + }; + + const handleSelect = (id) => { + const selectedIndex = selected.indexOf(id); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, id); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + setSelected(newSelected); + }; + + const createNewDomain = () => router.push(`${pageConfig.link}/new`); + + const handleRowClick = (id) => { + router.push(`${pageConfig.link}/${id}`); + }; + + const handleSorting = (id) => { + const isAsc = sortBy === id && sortOrder === 'ASC'; + + dispatch( + actions.setFieldAction({ + sortOrder: isAsc ? 'DESC' : 'ASC', + sortBy: id, + page: 0, + }), + ); + + fetchData(); + }; + + const handleBulkUpdate = (newStatus) => { + bulkUpdate(newStatus); + setSelected([]); + }; + + const tabs = isAdminMP ? TABS : TABS_SELF_SERVE; + + const tableRef = useRef(null); + const tableHeaderRef = useRef(null); + + useEffect(() => { + const step = (event) => { + if (tableHeaderRef.current && tableRef.current) { + const top = getNumberInRange( + (tableRef.current.getBoundingClientRect().y - (event.target.getBoundingClientRect?.().y ?? 0)) * -1, + 0, + tableRef.current.clientHeight - tableHeaderRef.current.clientHeight * 2, + ); + + tableHeaderRef.current.style.transform = `translateY(${top}px)`; + } + }; + + const subscribeParent = tableRef.current.closest(`.${CUSTOM_PARENT_STICKY_CLASS_NAME}`) || document; + + subscribeParent.addEventListener('scroll', step); + + return () => { + subscribeParent.removeEventListener('scroll', step); + }; + }, [tableRef.current, tableHeaderRef.current]); + + return ( +
    +
    + + + {tabs.map((item, index) => ( + { + dispatch(actions.setFieldAction({ tab: index })); + router.push(item.link); + dispatch(actions.setInitialListsStateAction()); + }} + disabled={!isAdminMP} + /> + ))} + + + + + +
    + + + + + + + + + + {pageConfig.tableHeaders?.map((header, index) => ( + + {header.isSortable && ( + { + handleSorting(header.id); + }} + > + {header.label} + {sortBy === header.id ? ( + + {sortOrder === SORT_DESC ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + )} + {!header.isSortable && header.label} + + ))} + + + + {keyLists.map( + ( + { id, name, key, count, user, updatedBy, created, updated, status: statusKeyList, daccountName }, + index, + ) => ( + { + handleRowClick(id); + }} + > + + { + handleSelect(id); + }} + inputProps={{ 'aria-labelledby': `enhanced-table-checkbox-${index}` }} + onClick={(e) => e.stopPropagation()} + /> + + + {name?.length > 50 ? ( + + {`${name.substring(0, 50)}...`} + + ) : ( + name + )} + + + {key?.length > 50 ? ( + + {`${key.substring(0, 50)}...`} + + ) : ( + key || count + )} + + {user || updatedBy} + + {dayjs(updated || created) + .tz(userTimezone) + .format('DD-MMM-YY HH:mm A')} + + + {isListsPage && {daccountName || ''}} + + {STATUS_ICON[statusKeyList]} + + {isListsPage && ( + + { + e.stopPropagation(); + router.push(`${pageConfig.link}/${id}?duplicate`); + }} + > + + + + )} + + ), + )} + +
    +
    + { + dispatch(actions.setFieldAction({ page: newPage - 1 })); + fetchData(); + }} + onRowsPerPageChange={(event) => { + dispatch( + actions.setFieldAction({ + rowsPerPage: +event.target.value, + page: 0, + }), + ); + fetchData(); + }} + /> +
    +
    +
    + ); +} + +export default KeyLists; diff --git a/anyclip/src/modules/marketplace/keyLists/constants/index.js b/anyclip/src/modules/marketplace/keyLists/constants/index.js new file mode 100644 index 0000000..7d00d78 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/constants/index.js @@ -0,0 +1,130 @@ +export const TABLE_HEADERS_KEYS = [ + { + id: 'name', + label: 'Name', + isSortable: true, + }, + { + id: 'key', + label: 'Key', + }, + { + id: 'updatedBy', + label: 'Updated By', + isSortable: true, + }, + { + id: 'updated', + label: 'Updated Date', + isSortable: true, + }, + { + id: 'status', + label: 'Status', + }, +]; + +export const TABLE_HEADERS_LISTS = [ + { + id: 'name', + label: 'Name', + isSortable: true, + }, + { + id: 'keys', + label: 'Values', + }, + { + id: 'updatedBy', + label: 'Updated By', + isSortable: true, + }, + { + id: 'updated', + label: 'Updated Date', + isSortable: true, + }, + { + id: 'daccountName', + label: 'Demand Partner', + isSortable: true, + }, + { + id: 'status', + label: 'Status', + }, + { + id: 'actions', + label: '', + autoWidth: true, + padding: 'none', + }, +]; + +export const PAGE_CONFIG = { + keys: { + link: '/key-lists/keys', + addButtonText: 'Key', + tableHeaders: TABLE_HEADERS_KEYS, + }, + lists: { + link: '/key-lists/lists', + addButtonText: 'Value List', + tableHeaders: TABLE_HEADERS_LISTS, + }, +}; + +export const TABS = [ + { + label: 'Keys', + value: 'VALUES', + link: PAGE_CONFIG.keys.link, + }, + { + label: 'Value Lists', + value: 'LISTS', + link: PAGE_CONFIG.lists.link, + }, +]; + +export const TABS_SELF_SERVE = [ + { + label: 'Value Lists', + value: 'LISTS', + link: PAGE_CONFIG.lists.link, + }, +]; + +export const STATUS_FILTERS = [ + { + label: 'All', + value: 'ALL', + }, + { + label: 'Active', + value: 'ACTIVE', + icon: 'active', + }, + { + label: 'Disabled', + value: 'DISABLED', + icon: 'disabled', + }, +]; + +export const BULK_ACTIONS = [ + { + label: 'Activate', + value: 'ACTIVE', + }, + { + label: 'Disable', + value: 'DISABLED', + }, +]; + +export const KEY_LIST_FORM = 'KEY_LIST_FORM'; +export const FORM_REDUX_FIELD_NAME = 'commonForm'; + +export const KEY_VALUE_FORM = 'KEY_VALUE_FORM'; +export const VALUE_FORM_REDUX_FIELD_NAME = 'VALUE_FORM_REDUX_FIELD_NAME'; diff --git a/anyclip/src/modules/marketplace/keyLists/helpers/validationScheme.js b/anyclip/src/modules/marketplace/keyLists/helpers/validationScheme.js new file mode 100644 index 0000000..5765665 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/helpers/validationScheme.js @@ -0,0 +1,28 @@ +import { KEY_LIST_FORM } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: KEY_LIST_FORM, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'demandAccount', + tabId: KEY_LIST_FORM, + validation: (value) => { + if (!value?.value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/marketplace/keyLists/helpers/validationSchemeValue.js b/anyclip/src/modules/marketplace/keyLists/helpers/validationSchemeValue.js new file mode 100644 index 0000000..c33b2aa --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/helpers/validationSchemeValue.js @@ -0,0 +1,30 @@ +import { KEY_VALUE_FORM } from '../constants'; + +export const validationValueScheme = [ + { + fieldName: 'keyName', + tabId: KEY_VALUE_FORM, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'keyValue', + tabId: KEY_VALUE_FORM, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/marketplace/keyLists/index.jsx b/anyclip/src/modules/marketplace/keyLists/index.jsx new file mode 100644 index 0000000..e7f21b7 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/index.jsx @@ -0,0 +1,3 @@ +import KeyLists from './components'; + +export default KeyLists; diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateAvailableKeys.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateAvailableKeys.js new file mode 100644 index 0000000..2759952 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateAvailableKeys.js @@ -0,0 +1,43 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { bulkUpdateAvailableKeysAction, searchAvailableKeysAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation bulkUpdateAvailableKeys( + $ids: [String], + $status: String + ) { + bulkUpdateAvailableKeys( + ids: $ids, + status: $status + ) + } +`; + +export default (action$) => + action$.pipe( + ofType(bulkUpdateAvailableKeysAction.type), + filter(({ payload }) => payload.ids && payload.status), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + ...payload, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(searchAvailableKeysAction())); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateKeyLists.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateKeyLists.js new file mode 100644 index 0000000..e068ec2 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/bulkUpdateKeyLists.js @@ -0,0 +1,43 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { bulkUpdateKeyListsAction, searchKeyListsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation bulkUpdateKeyLists( + $ids: [String], + $status: String + ) { + bulkUpdateKeyLists( + ids: $ids, + status: $status + ) + } +`; + +export default (action$) => + action$.pipe( + ofType(bulkUpdateKeyListsAction.type), + filter(({ payload }) => payload.ids && payload.status), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + ...payload, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(searchKeyListsAction())); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/createAvailableKey.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/createAvailableKey.js new file mode 100644 index 0000000..9ca9b6a --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/createAvailableKey.js @@ -0,0 +1,59 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { createAvailableKeyAction, searchAvailableKeysAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation createAvailableKey( + $name: String, + $key: String + ) { + createAvailableKey( + name: $name, + key: $key + ) { + id + created + updated + name + key + status + updatedBy + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createAvailableKeyAction.type), + switchMap(() => { + const state = state$.value; + + const name = selectors.keyNameSelector(state); + const keyValue = selectors.keyValueSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + name, + key: keyValue, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(searchAvailableKeysAction())); + Router.push('/key-lists/keys'); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/createKeyList.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/createKeyList.js new file mode 100644 index 0000000..6994811 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/createKeyList.js @@ -0,0 +1,66 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { createKeyListAction, searchKeyListsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation createKeyList( + $name: String, + $status: String, + $daccountId: String, + $keys: [String] + ) { + createKeyList( + name: $name, + status: $status, + daccountId: $daccountId, + keys: $keys + ) { + id + created + updated + name + keys + count + status + updatedBy + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createKeyListAction.type), + switchMap(() => { + const state = state$.value; + + const name = selectors.nameSelector(state); + const keys = selectors.keysSelector(state); + const demandAccount = selectors.demandAccountSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + name, + daccountId: demandAccount.value, + keys: keys?.map((item) => item.name) ?? [], + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(searchKeyListsAction())); + Router.push('/key-lists/lists'); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/getAvailableKeyById.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/getAvailableKeyById.js new file mode 100644 index 0000000..d1445f1 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/getAvailableKeyById.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getAvailableKeyByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query availableKeyById( + $id: String + ) { + availableKeyById( + id: $id + ) { + id + created + updated + name + key + status + updatedBy + user + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getAvailableKeyByIdAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { name, key } = data.availableKeyById; + + actions.push( + of( + setFieldAction({ + keyName: name, + keyValue: key, + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/getDemandAccounts.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/getDemandAccounts.js new file mode 100644 index 0000000..8d672c4 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/getDemandAccounts.js @@ -0,0 +1,74 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getDemandAccountsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query SearchDemandAccountsQuery( + $from: Int, + $size: Int, + $query: String, + $status: String + ) { + searchDemandAccounts( + from: $from, + size: $size, + query: $query, + status: $status + ) { + totalCount + data { + id + name + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getDemandAccountsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap((action) => { + let params = {}; + + if (action.payload?.length) { + params = { + ...params, + query: action.payload, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + size: 30, + from: 0, + ...params, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + setFieldAction({ + demandAccountOptions: + data.searchDemandAccounts?.data?.map((item) => ({ + label: item.name, + value: item.id, + })) ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/getKeyListById.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/getKeyListById.js new file mode 100644 index 0000000..e31c4ba --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/getKeyListById.js @@ -0,0 +1,63 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getKeyListByIdAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query keyListById( + $id: String + ) { + keyListById( + id: $id + ) { + id + created + updated + name + keys + count + status + updatedBy + user + daccountId + daccountName + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getKeyListByIdAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { name, keys, daccountName, daccountId } = data.keyListById; + + actions.push( + of( + setFieldAction({ + name: payload.isDuplicate ? `Copy_of_${name}` : name, + keys: keys?.map((item, index) => ({ id: keys.length - index, name: item })) ?? [], + demandAccount: { label: daccountName, value: daccountId }, + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/getSelfServeUserHubsAndDemandAccounts.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/getSelfServeUserHubsAndDemandAccounts.js new file mode 100644 index 0000000..b5b877f --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/getSelfServeUserHubsAndDemandAccounts.js @@ -0,0 +1,55 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getHubsAndDemandAccountsAction, searchKeyListsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query MarketplaceHubsAndDemandAccounts { + hubsAndDemandAccounts { + records { + id + name + mpSelfService + demandAccount { + id + name + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getHubsAndDemandAccountsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap(({ data: { hubsAndDemandAccounts }, errors }) => { + let actions = []; + + if (!errors.length) { + const demandAccounts = hubsAndDemandAccounts.records?.map((item) => item?.demandAccount); + + actions = [ + of( + setFieldAction({ + userHubs: hubsAndDemandAccounts.records, + userDemandAccounts: Object.values( + demandAccounts.reduce((acc, obj) => ({ ...acc, [obj.id]: obj }), {}), + ), + }), + ), + ]; + } + + return concat(...actions); + }), + ); + + return concat(stream$, of(searchKeyListsAction())); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/index.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/index.js new file mode 100644 index 0000000..d4faa15 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/index.js @@ -0,0 +1,29 @@ +import { combineEpics } from 'redux-observable'; + +import bulkUpdateAvailableKeys from './bulkUpdateAvailableKeys'; +import bulkUpdateKeyLists from './bulkUpdateKeyLists'; +import createAvailableKey from './createAvailableKey'; +import createKeyList from './createKeyList'; +import getAvailableKeyById from './getAvailableKeyById'; +import getDemandAccounts from './getDemandAccounts'; +import keyListById from './getKeyListById'; +import getSelfServeUserHubsAndDemandAccounts from './getSelfServeUserHubsAndDemandAccounts'; +import searchAvailableKeys from './searchAvailableKeys'; +import searchKeyLists from './searchKeyLists'; +import updateAvailableKey from './updateAvailableKey'; +import updateKeyList from './updateKeyList'; + +export default combineEpics( + searchKeyLists, + keyListById, + createKeyList, + updateKeyList, + bulkUpdateKeyLists, + searchAvailableKeys, + getAvailableKeyById, + getDemandAccounts, + createAvailableKey, + updateAvailableKey, + bulkUpdateAvailableKeys, + getSelfServeUserHubsAndDemandAccounts, +); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/searchAvailableKeys.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/searchAvailableKeys.js new file mode 100644 index 0000000..71853c6 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/searchAvailableKeys.js @@ -0,0 +1,109 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { searchAvailableKeysAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query availableKeysSearch( + $from: Int, + $size: Int, + $query: String, + $status: String, + $sortBy: String, + $sortOrder: String + ) { + availableKeysSearch( + from: $from, + size: $size, + query: $query, + status: $status, + sortBy: $sortBy, + sortOrder: $sortOrder + ) { + totalCount + data { + id + created + updated + name + key + count + status + updatedBy + user + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(searchAvailableKeysAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap(() => { + const state = state$.value; + + const search = selectors.searchSelector(state); + const page = selectors.pageSelector(state); + const rowsPerPage = selectors.rowsPerPageSelector(state); + const status = selectors.statusSelector(state); + const sortBy = selectors.sortBySelector(state); + const sortOrder = selectors.sortOrderSelector(state); + + let requestParams = {}; + + if (search?.length) { + requestParams = { + ...requestParams, + query: search, + }; + } + + if (status !== 'ALL') { + requestParams = { + ...requestParams, + status, + }; + } + + if (sortBy) { + requestParams = { + ...requestParams, + sortBy, + sortOrder, + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + size: rowsPerPage || 10, + from: (page || 0) * (rowsPerPage || 10), + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { data: keyLists, totalCount } = data.availableKeysSearch; + + actions.push( + of( + setFieldAction({ + totalCount, + keyLists, + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/searchKeyLists.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/searchKeyLists.js new file mode 100644 index 0000000..93297ca --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/searchKeyLists.js @@ -0,0 +1,127 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { PCN_GET_MARKETPLACE_DASHBOARD } from '@/modules/@common/acl/constants'; + +import * as selectors from '../selectors'; +import { searchKeyListsAction, setFieldAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query keyListsSearch( + $from: Int, + $size: Int, + $query: String, + $status: String, + $sortBy: String, + $sortOrder: String, + $daccountIds: [String] + ) { + keyListsSearch( + from: $from, + size: $size, + query: $query, + status: $status, + sortBy: $sortBy, + sortOrder: $sortOrder, + daccountIds: $daccountIds + ) { + totalCount + data { + id + created + updated + name + keys + count + status + updatedBy + user + daccountName + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(searchKeyListsAction.type), + debounce((action) => timer(action.payload ? 1000 : 0)), + switchMap(() => { + const userPermissions = getUserPermissionsSelector(state$.value); + const isAdminMP = hasPermission(PCN_GET_MARKETPLACE_DASHBOARD, userPermissions); + + const state = state$.value; + + const search = selectors.searchSelector(state); + const page = selectors.pageSelector(state); + const rowsPerPage = selectors.rowsPerPageSelector(state); + const status = selectors.statusSelector(state); + const sortBy = selectors.sortBySelector(state); + const sortOrder = selectors.sortOrderSelector(state); + const userDemandAccounts = selectors.userDemandAccountsSelector(state); + + let requestParams = {}; + + if (search?.length) { + requestParams = { + ...requestParams, + query: search, + }; + } + + if (status !== 'ALL') { + requestParams = { + ...requestParams, + status, + }; + } + + if (sortBy) { + requestParams = { + ...requestParams, + sortBy, + sortOrder, + }; + } + + if (!isAdminMP) { + requestParams = { + ...requestParams, + daccountIds: userDemandAccounts.map((item) => item.id?.toString()), + }; + } + + const stream$ = gqlRequest({ + query, + variables: { + size: rowsPerPage || 10, + from: (page || 0) * (rowsPerPage || 10), + ...requestParams, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const { data: keyLists, totalCount } = data.keyListsSearch; + + actions.push( + of( + setFieldAction({ + totalCount, + keyLists, + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/updateAvailableKey.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/updateAvailableKey.js new file mode 100644 index 0000000..f0d6581 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/updateAvailableKey.js @@ -0,0 +1,63 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { searchAvailableKeysAction, updateAvailableKeyAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation updateAvailableKey( + $id: String, + $name: String, + $key: String + ) { + updateAvailableKey( + id: $id, + name: $name, + key: $key + ) { + id + created + updated + name + key + status + updatedBy + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateAvailableKeyAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const state = state$.value; + + const name = selectors.keyNameSelector(state); + const keyValue = selectors.keyValueSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + name, + key: keyValue, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(searchAvailableKeysAction())); + Router.push('/key-lists/keys'); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/epics/updateKeyList.js b/anyclip/src/modules/marketplace/keyLists/redux/epics/updateKeyList.js new file mode 100644 index 0000000..b55c4c2 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/epics/updateKeyList.js @@ -0,0 +1,66 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import * as selectors from '../selectors'; +import { searchKeyListsAction, updateKeyListAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation updateKeyList( + $id: String, + $name: String, + $status: String, + $keys: [String] + ) { + updateKeyList( + id: $id, + name: $name, + status: $status, + keys: $keys + ) { + id + created + updated + name + keys + count + status + updatedBy + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateKeyListAction.type), + filter(({ payload }) => payload.id), + switchMap(({ payload }) => { + const state = state$.value; + + const name = selectors.nameSelector(state); + const keys = selectors.keysSelector(state); + + const stream$ = gqlRequest({ + query, + variables: { + id: payload.id, + name, + keys: keys?.map((item) => item.name) ?? [], + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(searchKeyListsAction())); + Router.push('/key-lists/lists'); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/selectors/index.js b/anyclip/src/modules/marketplace/keyLists/redux/selectors/index.js new file mode 100644 index 0000000..47e678a --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/selectors/index.js @@ -0,0 +1,37 @@ +import { FORM_REDUX_FIELD_NAME, VALUE_FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const searchSelector = (state$) => state$[nameSpace].search; +export const statusSelector = (state$) => state$[nameSpace].status; +export const totalCountSelector = (state$) => state$[nameSpace].totalCount; +export const pageSelector = (state$) => state$[nameSpace].page; +export const rowsPerPageSelector = (state$) => state$[nameSpace].rowsPerPage; +export const sortBySelector = (state$) => state$[nameSpace].sortBy; +export const sortOrderSelector = (state$) => state$[nameSpace].sortOrder; +export const keyListsSelector = (state$) => state$[nameSpace].keyLists; +export const tabSelector = (state$) => state$[nameSpace].tab; +export const userHubsSelector = (state$) => state$[nameSpace].userHubs; +export const userDemandAccountsSelector = (state$) => state$[nameSpace].userDemandAccounts; +export const nameSelector = (state$) => state$[nameSpace].name; +export const demandAccountSelector = (state$) => state$[nameSpace].demandAccount; +export const demandAccountOptionsSelector = (state$) => state$[nameSpace].demandAccountOptions; +export const keyNameSelector = (state$) => state$[nameSpace].keyName; +export const keyValueSelector = (state$) => state$[nameSpace].keyValue; +export const searchKeysSelector = (state$) => state$[nameSpace].searchKeys; +export const keysSelector = (state$) => state$[nameSpace].keys; +export const keysPageSelector = (state$) => state$[nameSpace].keysPage; +export const keysRowsPerPageSelector = (state$) => state$[nameSpace].keysRowsPerPage; +export const bulkKeysSelector = (state$) => state$[nameSpace].bulkKeys; + +const formSelectors = createFormSelector(FORM_REDUX_FIELD_NAME, nameSpace); +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; + +const formValueSelectors = createFormSelector(VALUE_FORM_REDUX_FIELD_NAME, nameSpace); +export const scrollFieldValueFormSelector = (state) => formValueSelectors.getScrollField(state); +export const schemeValueFormSelector = (state) => formValueSelectors.schemeSelector(state); diff --git a/anyclip/src/modules/marketplace/keyLists/redux/slices/index.js b/anyclip/src/modules/marketplace/keyLists/redux/slices/index.js new file mode 100644 index 0000000..8132a00 --- /dev/null +++ b/anyclip/src/modules/marketplace/keyLists/redux/slices/index.js @@ -0,0 +1,124 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { FORM_REDUX_FIELD_NAME, STATUS_FILTERS, VALUE_FORM_REDUX_FIELD_NAME } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import { validationValueScheme } from '../../helpers/validationSchemeValue'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(FORM_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const formValueSlice = createFormSlice(VALUE_FORM_REDUX_FIELD_NAME, validationValueScheme); + +export const { validateFields: validateValueFormFields } = formValueSlice; + +const initialListsState = { + search: '', + status: STATUS_FILTERS[1].value, + totalCount: 0, + page: 0, + rowsPerPage: 10, + sortBy: 'updated', + sortOrder: 'DESC', + keyLists: [], +}; + +const initialFormState = { + name: '', + demandAccount: null, + demandAccountOptions: [], + searchKeys: '', + keys: [], + keysPage: 0, + keysRowsPerPage: 5, + bulkKeys: [], + // for key value form + keyName: '', + keyValue: '', + + ...formSlice.state, + ...formValueSlice.state, +}; + +const initialState = { + tab: 0, + userHubs: [], + userDemandAccounts: [], + // list + ...initialListsState, + // form + ...initialFormState, +}; + +export const slice = createSlice({ + name: '@@marketplaceKeyLists/MARKETPLACE_KEYLISTS', + initialState, + + reducers: { + setFieldAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialListsStateAction: (state) => { + Object.keys(initialListsState).forEach((key) => { + state[key] = initialListsState[key]; + }); + }, + setInitialFormAction: (state) => ({ + ...state, + ...initialFormState, + }), + + searchKeyListsAction: (state) => state, + bulkUpdateKeyListsAction: (state) => state, + getKeyListByIdAction: (state) => state, + createKeyListAction: (state) => state, + updateKeyListAction: (state) => state, + searchAvailableKeysAction: (state) => state, + bulkUpdateAvailableKeysAction: (state) => state, + getAvailableKeyByIdAction: (state) => state, + createAvailableKeyAction: (state) => state, + updateAvailableKeyAction: (state) => state, + getDemandAccountsAction: (state) => state, + getHubsAndDemandAccountsAction: (state) => state, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + + setScrollToFieldNameValueFormAction: formValueSlice.actions.setScrollToFieldAction, + setErrorByPropValueFormAction: formValueSlice.actions.updateValidationSchemeAction, + removeErrorByPropValueFormAction: formValueSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setFieldAction, + setInitialListsStateAction, + searchKeyListsAction, + bulkUpdateKeyListsAction, + getKeyListByIdAction, + createKeyListAction, + updateKeyListAction, + searchAvailableKeysAction, + bulkUpdateAvailableKeysAction, + getAvailableKeyByIdAction, + createAvailableKeyAction, + updateAvailableKeyAction, + getDemandAccountsAction, + getHubsAndDemandAccountsAction, + setInitialFormAction, + + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, + + setScrollToFieldNameValueFormAction, + setErrorByPropValueFormAction, + removeErrorByPropValueFormAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/notifications/components/Notifications.jsx b/anyclip/src/modules/notifications/components/Notifications.jsx new file mode 100644 index 0000000..f620a6f --- /dev/null +++ b/anyclip/src/modules/notifications/components/Notifications.jsx @@ -0,0 +1,88 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { isLoadingSelector } from '../redux/selectors'; +import { getItemAction, saveItemAction } from '../redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from '@/modules/users/Editor/components/Editor.module.scss'; + +const TAB_ID = 'GeneralTab'; + +const tabs = [ + { + title: 'Main', + id: TAB_ID, + content: GeneralTab, + }, +].filter(Boolean); + +export default function Notifications() { + const dispatch = useDispatch(); + + const isLoading = useSelector(isLoadingSelector); + + const handleSave = () => dispatch(saveItemAction()); + + useEffect(() => { + dispatch(getItemAction()); + }, []); + + return ( +
    + + + AnyClip Notifications + + + + {tabs.length > 1 && ( + + {tabs.map((tab) => ( + + ))} + + )} + + + + + {!isLoading && ( +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    + )} +
    + ); +} diff --git a/anyclip/src/modules/notifications/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/notifications/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..fefdf4f --- /dev/null +++ b/anyclip/src/modules/notifications/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; + +import * as selectors from '../../../redux/selectors'; +import { setAction } from '../../../redux/slices'; + +import { FormRow, FormRowItem, useFormSettings } from '@/modules/@common/Form'; +import { Switch, TextField } from '@/mui/components'; + +const MESSAGE_MAX_LENGTH = 200; + +function GeneralTab() { + const dispatch = useDispatch(); + const { size } = useFormSettings(); + + const isLoading = useSelector(selectors.isLoadingSelector); + const overallFailure = useSelector(selectors.overallFailureSelector); + const popup = useSelector(selectors.popupSelector); + const message = useSelector(selectors.messageSelector); + const updatedAt = useSelector(selectors.updatedAtSelector); + const updatedBy = useSelector(selectors.updatedBySelector); + + return ( + <> + + dispatch(setAction({ overallFailure: !overallFailure }))} + name="terms" + /> + + + dispatch(setAction({ popup: !popup }))} + name="terms" + /> + + + dispatch(setAction({ message: target.value }))} + /> + + + {dayjs(updatedAt).format('MMMM Do YYYY, h:mm:ss a')} + + + {updatedBy} + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/notifications/redux/epics/getItem.js b/anyclip/src/modules/notifications/redux/epics/getItem.js new file mode 100644 index 0000000..1bc5b47 --- /dev/null +++ b/anyclip/src/modules/notifications/redux/epics/getItem.js @@ -0,0 +1,76 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ITEM } from '../../../../graphql/services/notifications/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const gqlQuery = ` + query ${GET_ITEM} { + ${GET_ITEM} { + overallFailure + popup + message + updatedAt + updatedBy + } + } +`; + +const getResponse = ({ data }) => { + const notification = data[GET_ITEM]; + return { + ...notification, + overallFailure: !!notification.overallFailure, + popup: !!notification.popup, + }; +}; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap(() => { + const stream$ = gqlRequest( + { + query: gqlQuery, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open notification for edit", + }), + ), + ); + } else { + const data = getResponse(response); + + actions.push( + of( + setAction({ + ...data, + isLoading: false, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(of(setAction({ isLoading: true })), stream$); + }), + ); diff --git a/anyclip/src/modules/notifications/redux/epics/index.js b/anyclip/src/modules/notifications/redux/epics/index.js new file mode 100644 index 0000000..fe1f9be --- /dev/null +++ b/anyclip/src/modules/notifications/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getItem from './getItem'; +import saveItem from './saveItem'; + +export default combineEpics(getItem, saveItem); diff --git a/anyclip/src/modules/notifications/redux/epics/saveItem.js b/anyclip/src/modules/notifications/redux/epics/saveItem.js new file mode 100644 index 0000000..909bd84 --- /dev/null +++ b/anyclip/src/modules/notifications/redux/epics/saveItem.js @@ -0,0 +1,79 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SAVE_ITEM } from '../../../../graphql/services/notifications/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_ITEM } from '../../../../graphql/services/notifications/types/payload/item'; + +import * as selectors from '../selectors'; +import { saveItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const gqlQuery = ` + mutation ${SAVE_ITEM} ($payload: ${PAYLOAD_ITEM}) { + ${SAVE_ITEM}(payload: $payload) { + overallFailure + popup + message + updatedAt + updatedBy + } + } +`; + +const getResponse = ({ data }) => { + const response = data[SAVE_ITEM]; + + return { + ...response, + overallFailure: !!response.overallFailure, + popup: !!response.popup, + }; +}; + +export default (action$, state$) => + action$.pipe( + ofType(saveItemAction.type), + switchMap(() => { + const overallFailure = selectors.overallFailureSelector(state$.value); + const popup = selectors.popupSelector(state$.value); + const message = selectors.messageSelector(state$.value); + + const stream$ = gqlRequest({ + query: gqlQuery, + variables: { + payload: { + overallFailure: overallFailure ? 1 : 0, + popup: popup ? 1 : 0, + message, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const actions = [ + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Saved', + }), + ), + of( + setAction({ + ...getResponse(response), + }), + ), + ]; + return concat(...actions); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/notifications/redux/selectors/index.js b/anyclip/src/modules/notifications/redux/selectors/index.js new file mode 100644 index 0000000..79cc9a9 --- /dev/null +++ b/anyclip/src/modules/notifications/redux/selectors/index.js @@ -0,0 +1,11 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const overallFailureSelector = (state) => state[nameSpace].overallFailure; +export const popupSelector = (state) => state[nameSpace].popup; +export const messageSelector = (state) => state[nameSpace].message; +export const updatedAtSelector = (state) => state[nameSpace].updatedAt; +export const updatedBySelector = (state) => state[nameSpace].updatedBy; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; diff --git a/anyclip/src/modules/notifications/redux/slices/index.js b/anyclip/src/modules/notifications/redux/slices/index.js new file mode 100644 index 0000000..99105cb --- /dev/null +++ b/anyclip/src/modules/notifications/redux/slices/index.js @@ -0,0 +1,27 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + overallFailure: false, + popup: false, + message: '', + updatedAt: null, + updatedBy: null, + + isLoading: true, +}; + +export const slice = createSlice({ + name: '@@NOTIFICATIONS', + initialState, + reducers: { + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getItemAction: (state) => state, + saveItemAction: (state) => state, + }, +}); + +export const { setAction, getItemAction, saveItemAction } = slice.actions; diff --git a/anyclip/src/modules/onlineHelp/Editor/components/Editor.jsx b/anyclip/src/modules/onlineHelp/Editor/components/Editor.jsx new file mode 100644 index 0000000..41481b3 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/components/Editor.jsx @@ -0,0 +1,163 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_GENERAL } from '../constants'; +import { INTERNAL } from '@/modules/@common/user/constants/rolesType'; + +import { getCanReadOnly } from '../helpers/getRestrictions'; +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const name = useSelector(selectors.nameSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + + const id = parseInt(router.query.id, 10); + + const canReadOnly = getCanReadOnly(id, userPermissions); + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId, fieldName }) => { + if (!tabs.some((tab) => tab.id === tabId)) { + return false; + } + + if (fieldName === 'account' || fieldName === 'publisherIds' || fieldName === 'publisherId') { + return allProps.role && allProps.role.type !== INTERNAL; + } + + if (fieldName === 'email') { + return !id; + } + + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( +
    + + + {id ? `${name} > Settings` : 'New Online Help Configuration'} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + + {!canReadOnly && ( + + )} + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/onlineHelp/Editor/components/Editor.module.scss b/anyclip/src/modules/onlineHelp/Editor/components/Editor.module.scss new file mode 100644 index 0000000..c3de144 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__QnByF","Title":"Editor_Title__wH9AN","Controls":"Editor_Controls__JOdDR","Tabs":"Editor_Tabs__gpVed"}; \ No newline at end of file diff --git a/anyclip/src/modules/onlineHelp/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/onlineHelp/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..c792b95 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { getCanReadOnly } from '../../../helpers/getRestrictions'; +import * as selectors from '../../../redux/selectors'; +import { removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { TextField } from '@/mui/components'; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + // selectors + // const id = useSelector(selectors.idSelector); + const name = useSelector(selectors.nameSelector); + const suffix = useSelector(selectors.suffixSelector); + const helpPageUrl = useSelector(selectors.helpPageUrlSelector); + const scheme = useSelector(selectors.schemeSelector); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const id = parseInt(router.query.id, 10); + const canReadOnly = getCanReadOnly(id, userPermissions); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + handleSetState({ suffix: e.target.value })} + {...getInputPropsByName(scheme, ['suffix'])} + onFocus={() => dispatch(removeErrorByPropAction(['suffix']))} + /> + + + handleSetState({ helpPageUrl: e.target.value })} + {...getInputPropsByName(scheme, ['helpPageUrl'])} + onFocus={() => dispatch(removeErrorByPropAction(['helpPageUrl']))} + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/onlineHelp/Editor/constants/index.js b/anyclip/src/modules/onlineHelp/Editor/constants/index.js new file mode 100644 index 0000000..78fb9e4 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/constants/index.js @@ -0,0 +1,3 @@ +export const TAB_GENERAL = 'general'; + +export const REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/onlineHelp/Editor/helpers/getRestrictions.js b/anyclip/src/modules/onlineHelp/Editor/helpers/getRestrictions.js new file mode 100644 index 0000000..c10ccad --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/helpers/getRestrictions.js @@ -0,0 +1,9 @@ +import { PCN_GET_ONLINE_HELP } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +export const getCanReadOnly = (id, userPermissions) => + !(id + ? // todo: fix permissions + hasPermission(PCN_GET_ONLINE_HELP, userPermissions) + : hasPermission(PCN_GET_ONLINE_HELP, userPermissions)); diff --git a/anyclip/src/modules/onlineHelp/Editor/helpers/validationScheme.js b/anyclip/src/modules/onlineHelp/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..448601a --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/helpers/validationScheme.js @@ -0,0 +1,49 @@ +import { TAB_GENERAL } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length > 45) { + return 'Field must be less than or equal to 45 Symbols'; + } + + return ''; + }, + }, + { + fieldName: 'suffix', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length > 256) { + return 'Field must be less than or equal to 256 Symbols'; + } + + return ''; + }, + }, + { + fieldName: 'helpPageUrl', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length > 256) { + return 'Field must be less than or equal to 256 Symbols'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/onlineHelp/Editor/redux/epics/createItem.js b/anyclip/src/modules/onlineHelp/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..9ec3591 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/redux/epics/createItem.js @@ -0,0 +1,58 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_CONFIGURATION } from '@/graphql/services/onlineHelp/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/onlineHelp/types/payload/item'; + +import * as selectors from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_CONFIGURATION}($payload: ${PAYLOAD_NAME}) { + ${CREATE_CONFIGURATION}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const name = selectors.nameSelector(state$.value); + const suffix = selectors.suffixSelector(state$.value); + const helpPageUrl = selectors.helpPageUrlSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + name, + suffix, + helpPageUrl, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/online-help'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Configuration created successfully', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/onlineHelp/Editor/redux/epics/getItem.js b/anyclip/src/modules/onlineHelp/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..d7c18e5 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/redux/epics/getItem.js @@ -0,0 +1,77 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_CONFIGURATION } from '@/graphql/services/onlineHelp/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/onlineHelp/types/payload/configuration'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_CONFIGURATION}($payload: ${PAYLOAD_NAME}) { + ${GET_CONFIGURATION}(payload: $payload) { + id + name + suffix + helpPageUrl + } + } +`; + +const getResponse = ({ data }) => data[GET_CONFIGURATION]; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: action.payload.id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open configuration for edit", + }), + ), + ); + + Router.push('/online-help'); + } else { + const data = getResponse(response); + + actions.push( + of( + setAction({ + ...data, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/onlineHelp/Editor/redux/epics/index.js b/anyclip/src/modules/onlineHelp/Editor/redux/epics/index.js new file mode 100644 index 0000000..90cc26b --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/redux/epics/index.js @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getItem from './getItem'; +import updateItem from './updateItem'; + +export default combineEpics(getItem, createItem, updateItem); diff --git a/anyclip/src/modules/onlineHelp/Editor/redux/epics/updateItem.js b/anyclip/src/modules/onlineHelp/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..9ca298a --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/redux/epics/updateItem.js @@ -0,0 +1,59 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_CONFIGURATION } from '@/graphql/services/onlineHelp/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/onlineHelp/types/payload/item'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_CONFIGURATION}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_CONFIGURATION}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap(({ payload }) => { + const name = selectors.nameSelector(state$.value); + const suffix = selectors.suffixSelector(state$.value); + const helpPageUrl = selectors.helpPageUrlSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: payload, + name, + suffix, + helpPageUrl, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/online-help'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Configuration updated successfully', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/onlineHelp/Editor/redux/selectors/index.js b/anyclip/src/modules/onlineHelp/Editor/redux/selectors/index.js new file mode 100644 index 0000000..e739796 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/redux/selectors/index.js @@ -0,0 +1,22 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; + +export const idSelector = (state) => state[nameSpace].id; +export const nameSelector = (state) => state[nameSpace].name; +export const suffixSelector = (state) => state[nameSpace].suffix; +export const helpPageUrlSelector = (state) => state[nameSpace].helpPageUrl; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/onlineHelp/Editor/redux/slices/index.js b/anyclip/src/modules/onlineHelp/Editor/redux/slices/index.js new file mode 100644 index 0000000..ec8b46f --- /dev/null +++ b/anyclip/src/modules/onlineHelp/Editor/redux/slices/index.js @@ -0,0 +1,62 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + name: '', + suffix: '', + helpPageUrl: '', + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@ONLINE_HELP/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + createItemAction, + updateItemAction, + + setActiveTabIdAction, + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/onlineHelp/List/components/Empty/Empty.jsx b/anyclip/src/modules/onlineHelp/List/components/Empty/Empty.jsx new file mode 100644 index 0000000..14741ee --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/components/Empty/Empty.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { AddRounded } from '@mui/icons-material'; + +import { Button, Grid, Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +function Empty() { + const router = useRouter(); + return ( + + + empty-logo + + Click below to create your first user + + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/onlineHelp/List/components/Empty/Empty.module.scss b/anyclip/src/modules/onlineHelp/List/components/Empty/Empty.module.scss new file mode 100644 index 0000000..23011eb --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__wYfmO","EmptyContent":"Empty_EmptyContent__cS24I"}; \ No newline at end of file diff --git a/anyclip/src/modules/onlineHelp/List/components/List.jsx b/anyclip/src/modules/onlineHelp/List/components/List.jsx new file mode 100644 index 0000000..39aa1b0 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/components/List.jsx @@ -0,0 +1,264 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useRouter } from 'next/router'; +import { AddRounded, DeleteRounded, SearchRounded } from '@mui/icons-material'; + +import { SEARCH_TEXT_MAX_LENGTH } from '../constants'; +import { PCN_POST_ONLINE_HELP } from '@/modules/@common/acl/constants'; +import { ACCOUNT } from '@/modules/@common/user/constants/rolesType'; + +import { getConfigHeaders } from '../helpers'; +import * as computedState from '../helpers/computedState'; +import * as selectors from '../redux/selectors'; +import { deleteItemAction, getDataAction, setAction, setTableAction } from '../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector, getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable, { TableCellActions } from '@/modules/@common/Table'; +import Empty from './Empty/Empty'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './List.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); +function List() { + const router = useRouter(); + + const dispatch = useDispatch(); + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + + const search = useSelector(selectors.searchSelector); + + const selected = useSelector(selectors.selectedSelector); + const shouldShowEmpty = useSelector(computedState.shouldShowEmpty); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const hasAccount = useSelector(getUserRoleTypeSelector) === ACCOUNT; + const canCreate = hasPermission(PCN_POST_ONLINE_HELP, userPermissions); + + const [configDelete, setConfirmDelete] = useState(null); + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + const handleSelectDeselectAllRows = (checked) => { + dispatch( + setTableAction({ + selected: checked ? data.map((r) => r.id) : [], + }), + ); + }; + + const handleSelectDeselectRow = (rowId) => { + const selectedIndex = selected.indexOf(rowId); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, rowId); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + dispatch( + setTableAction({ + selected: newSelected, + }), + ); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + <> + +
    + handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
    +
    + } + renderActions={ + <> + {canCreate && ( + + + + )} + + } + > + {shouldShowEmpty ? ( + + ) : ( + { + const isItemSelected = selectedRows.includes(row.id); + + return ( + router.push(`/online-help/${row.id}`)} + > + +
    {row.id}
    +
    + +
    {row.name}
    +
    + +
    {row.suffix}
    +
    + +
    {row.helpPageUrl}
    +
    + +
    {row.updatedBy}
    +
    + +
    {dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
    +
    + + + { + event.stopPropagation(); + setConfirmDelete(row.id); + }} + > + + + + +
    + ); + }} + data={data || []} + selected={selected} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onSelectDeselectAllRows={handleSelectDeselectAllRows} + onSelectDeselectRow={handleSelectDeselectRow} + onFilter={handleFilter} + /> + )} + + {!!configDelete && ( + setConfirmDelete(null)}> + setConfirmDelete(null)}>Delete + {`Are you sure you want to delete configuration: ${configDelete}?`} + + + + + + )} + + ); +} + +export default List; diff --git a/anyclip/src/modules/onlineHelp/List/components/List.module.scss b/anyclip/src/modules/onlineHelp/List/components/List.module.scss new file mode 100644 index 0000000..1186d9c --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SearchField":"List_SearchField__epC9z","Row":"List_Row__ULlNM","Name":"List_Name__u1Awu","NoWrap":"List_NoWrap__NQl9e"}; \ No newline at end of file diff --git a/anyclip/src/modules/onlineHelp/List/constants/index.js b/anyclip/src/modules/onlineHelp/List/constants/index.js new file mode 100644 index 0000000..094a13f --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/constants/index.js @@ -0,0 +1,8 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/onlineHelp/List/helpers/computedState.js b/anyclip/src/modules/onlineHelp/List/helpers/computedState.js new file mode 100644 index 0000000..ba68e8d --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/helpers/computedState.js @@ -0,0 +1,12 @@ +import * as selectors from '../redux/selectors'; + +export const shouldShowEmpty = (state) => { + const data = selectors.dataSelector(state); + const page = selectors.pageSelector(state); + const search = selectors.searchSelector(state); + const isLoading = selectors.isLoadingSelector(state); + + return !isLoading && Array.isArray(data) && !data.length && page === 1 && !search; +}; + +export default {}; diff --git a/anyclip/src/modules/onlineHelp/List/helpers/index.js b/anyclip/src/modules/onlineHelp/List/helpers/index.js new file mode 100644 index 0000000..40dc26a --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/helpers/index.js @@ -0,0 +1,53 @@ +export const getConfigHeaders = () => [ + { + id: 'id', + label: 'Id', + sortable: true, + width: 100, + }, + { + id: 'name', + label: 'Name', + sortable: true, + width: 100, + }, + { + id: 'suffix', + label: 'Suffix', + sortable: true, + width: 100, + }, + { + id: 'helpPageUrl', + label: 'Help Page Url', + sortable: true, + width: 100, + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: 100, + }, + { + id: 'updatedAt', + label: 'Updated At', + sortable: true, + width: 100, + }, + { + id: 'actions', + label: '', + autoWidth: true, + padding: 'none', + }, +]; + +// "id": 1, +// "name": "root", +// "suffix": "*", +// "helpPageUrl": "/", +// "createdAt": "2021-03-07T15:28:34.000Z", +// "createdBy": "migration@anyclip.com", +// "updatedAt": "2021-03-07T15:28:34.000Z", +// "updatedBy": "migration@anyclip.com" diff --git a/anyclip/src/modules/onlineHelp/List/redux/epics/deleteItem.js b/anyclip/src/modules/onlineHelp/List/redux/epics/deleteItem.js new file mode 100644 index 0000000..6ff96a8 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/redux/epics/deleteItem.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { DELETE_CONFIGURATION } from '@/graphql/services/onlineHelp/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/onlineHelp/types/payload/item'; + +import { deleteItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${DELETE_CONFIGURATION}($payload: ${PAYLOAD_NAME}) { + ${DELETE_CONFIGURATION}(payload: $payload) { + id + } +}`; + +export default (action$) => + action$.pipe( + ofType(deleteItemAction.type), + switchMap(({ payload: id }) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Configuration deleted successfully', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/onlineHelp/List/redux/epics/getData.js b/anyclip/src/modules/onlineHelp/List/redux/epics/getData.js new file mode 100644 index 0000000..b306304 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/redux/epics/getData.js @@ -0,0 +1,53 @@ +import { GET_CONFIGURATIONS } from '@/graphql/services/onlineHelp/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/onlineHelp/types/payload/configurations'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_CONFIGURATIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_CONFIGURATIONS}(payload: $payload) { + records { + id + name + suffix + helpPageUrl + createdAt + createdBy + updatedAt + updatedBy + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const data$ = data[GET_CONFIGURATIONS]; + + return { + records: data$.records, + recordsTotal: data$.recordsTotal, + allRecordsCount: data$.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/onlineHelp/List/redux/epics/index.js b/anyclip/src/modules/onlineHelp/List/redux/epics/index.js new file mode 100644 index 0000000..2271f88 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import deleteItem from './deleteItem'; +import getData from './getData'; + +export default combineEpics(getData, deleteItem); diff --git a/anyclip/src/modules/onlineHelp/List/redux/selectors/index.js b/anyclip/src/modules/onlineHelp/List/redux/selectors/index.js new file mode 100644 index 0000000..6164565 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/redux/selectors/index.js @@ -0,0 +1,20 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const searchSelector = (state) => state[nameSpace].search; diff --git a/anyclip/src/modules/onlineHelp/List/redux/slices/index.js b/anyclip/src/modules/onlineHelp/List/redux/slices/index.js new file mode 100644 index 0000000..29a0f87 --- /dev/null +++ b/anyclip/src/modules/onlineHelp/List/redux/slices/index.js @@ -0,0 +1,41 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', +}; + +export const slice = createSlice({ + name: '@@ONLINE_HELP/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + deleteItemAction: (state, action) => { + state[TABLE_REDUX_FIELD_NAME].data = state[TABLE_REDUX_FIELD_NAME].data.filter(({ id }) => id !== action.payload); + }, + }, +}); + +export const { getDataAction, setTableAction, setAction, deleteItemAction } = slice.actions; diff --git a/anyclip/src/modules/permissions/components/Permissions.jsx b/anyclip/src/modules/permissions/components/Permissions.jsx new file mode 100644 index 0000000..6445204 --- /dev/null +++ b/anyclip/src/modules/permissions/components/Permissions.jsx @@ -0,0 +1,68 @@ +import React, { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Check, Clear } from '@mui/icons-material'; + +import * as selectors from '../redux/selectors'; +import { getDataAction } from '../redux/slices'; + +import CommonList from '@/modules/@common/List'; +import CommonTable from '@/modules/@common/Table'; +import { CircularProgress, Stack, TableCell, TableRow, Typography } from '@/mui/components'; + +import styles from './Permissions.module.scss'; + +function Permissions() { + const dispatch = useDispatch(); + const data = useSelector(selectors.dataSelector); + + const headers = useMemo(() => data?.roles.map((role) => ({ id: role, label: role })) || [], [data?.roles]); + + // Fetch data on mount + useEffect(() => { + dispatch(getDataAction()); + }, [dispatch]); + + return ( + +
    + {!data?.roles ? ( + + + + ) : ( + ( + + + + {row.name} + + + {headers.map((header) => ( + + {row.roles.includes(header.id) ? ( + + ) : ( + + )} + + ))} + + )} + /> + )} +
    +
    + ); +} + +export default Permissions; diff --git a/anyclip/src/modules/permissions/components/Permissions.module.scss b/anyclip/src/modules/permissions/components/Permissions.module.scss new file mode 100644 index 0000000..e7b9a3a --- /dev/null +++ b/anyclip/src/modules/permissions/components/Permissions.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Root":"Permissions_Root__xBm0t","CellHeader":"Permissions_CellHeader__Ca0xH","Cell":"Permissions_Cell__SQmtc","LoaderRoot":"Permissions_LoaderRoot__yNIAc","CommonListRoot":"Permissions_CommonListRoot__9s6SQ","CommonListContent":"Permissions_CommonListContent__xLMKE"}; \ No newline at end of file diff --git a/anyclip/src/modules/permissions/constants/index.js b/anyclip/src/modules/permissions/constants/index.js new file mode 100644 index 0000000..9f168e9 --- /dev/null +++ b/anyclip/src/modules/permissions/constants/index.js @@ -0,0 +1,2 @@ +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; +export const ROWS_PER_PAGE_DEFAULT = 1e4; diff --git a/anyclip/src/modules/permissions/redux/epics/getData.js b/anyclip/src/modules/permissions/redux/epics/getData.js new file mode 100644 index 0000000..484adab --- /dev/null +++ b/anyclip/src/modules/permissions/redux/epics/getData.js @@ -0,0 +1,38 @@ +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +import { GET_DATA } from '@/graphql/services/permissions/constatnts'; + +const gqlQuery = ` + query ${GET_DATA} { + ${GET_DATA} { + roles + permissions { + name + roles + } + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: () => { + const variables = {}; + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const res = data[GET_DATA]; + + return { + records: res, + recordsTotal: res.permissions.length, + allRecordsCount: res.permissions.length, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/permissions/redux/epics/index.js b/anyclip/src/modules/permissions/redux/epics/index.js new file mode 100644 index 0000000..1775aad --- /dev/null +++ b/anyclip/src/modules/permissions/redux/epics/index.js @@ -0,0 +1,5 @@ +import { combineEpics } from 'redux-observable'; + +import getData from './getData'; + +export default combineEpics(getData); diff --git a/anyclip/src/modules/permissions/redux/selectors/index.js b/anyclip/src/modules/permissions/redux/selectors/index.js new file mode 100644 index 0000000..77e8d20 --- /dev/null +++ b/anyclip/src/modules/permissions/redux/selectors/index.js @@ -0,0 +1,18 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; + +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); diff --git a/anyclip/src/modules/permissions/redux/slices/index.js b/anyclip/src/modules/permissions/redux/slices/index.js new file mode 100644 index 0000000..d6ccc5b --- /dev/null +++ b/anyclip/src/modules/permissions/redux/slices/index.js @@ -0,0 +1,30 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: '', + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, +}; + +export const slice = createSlice({ + name: '@@PERMISSIONS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + }, +}); + +export const { getDataAction, setTableAction } = slice.actions; diff --git a/anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.jsx b/anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.jsx new file mode 100644 index 0000000..a443237 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { ChevronLeftRounded, ChevronRightRounded } from '@mui/icons-material'; + +import { IconButton } from '@/mui/components'; + +import styles from './Arrow.module.scss'; + +function Arrow({ isNext = false, ...props }) { + return ( + + {isNext ? : } + + ); +} + +Arrow.propTypes = { + onClick: PropTypes.func.isRequired, + isNext: PropTypes.bool, + top: PropTypes.number.isRequired, +}; + +export default Arrow; diff --git a/anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.module.scss b/anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.module.scss new file mode 100644 index 0000000..1357de3 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/component/Arrow/Arrow.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Button":"Arrow_Button__wGcMP","Button___prev":"Arrow_Button___prev__n_dSH","Button___next":"Arrow_Button___next__gCzKO"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.jsx b/anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.jsx new file mode 100644 index 0000000..3ff45a8 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.jsx @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; + +import { TYPE_B, videoPropTypes } from '../constants'; +import { SPACING } from '@/mui/constants'; + +import { getNumberInRange, isInRange } from '@/modules/@common/helpers/number'; + +import Arrow from './Arrow/Arrow'; +import Slide from './Slide/Slide'; + +import styles from './Carousel.module.scss'; + +function CarouselWidget({ slidesToShow = 3, slidesToScroll = 3, slideDimensions = TYPE_B, ...props }) { + const [indexPosition, setIndexPosition] = useState(0); + const [translateShift, setTranslateShift] = useState(0); + const [start, setStart] = useState({ x: 0, y: 0, canceled: false }); + const [draggableShiftPosition, setDraggableShiftPosition] = useState(0); + const [prevButton, setPrevState] = useState(false); + const [nextButton, setNextState] = useState(false); + const [animated, setAnimatedState] = useState(true); + + const padding = SPACING * 3; + const shiftSize = slideDimensions.width + padding; + const carouselWidth = props.playlist.length * shiftSize - padding; + const indexStart = Math.max(0, Math.floor(Math.abs(translateShift) / shiftSize)); + const visibleIndexStart = Math.max(0, indexStart - slidesToShow); + const visibleIndexEnd = indexStart + slidesToShow + slidesToShow; + + const changeSlide = (delta) => { + const width = props.playlist.length - slidesToShow; + + const nextIndex = getNumberInRange(indexPosition + delta, 0, width); + + setAnimatedState(true); + setIndexPosition(nextIndex); + }; + + useEffect(() => { + let showPrev = false; + let showNext = false; + + if (carouselWidth > 0) { + showPrev = !!indexPosition; + showNext = indexPosition * shiftSize < carouselWidth - slidesToShow * shiftSize; + } + + setPrevState(showPrev); + setNextState(showNext); + setTranslateShift( + getNumberInRange(indexPosition * shiftSize, 0, Math.max(0, carouselWidth - slideDimensions.width)), + ); + }); + + useEffect(() => { + setIndexPosition(0); + }, [props.playlist.map(({ title }) => title).join('|')]); + + return ( +
    + setStart({ + x: event.touches[0].screenX, + y: event.touches[0].screenY, + canceled: false, + }) + } + onTouchMove={(event) => { + if (!start.canceled) { + setDraggableShiftPosition(event.touches[0].screenX - start.x); + setAnimatedState(false); + } + }} + onTouchEnd={(event) => { + if (!start.canceled) { + const delta = start.x - event.changedTouches[0].screenX; + + if (Math.abs(delta) >= Math.floor(window.devicePixelRatio || 0) * 25 && Math.abs(delta) < shiftSize) { + changeSlide(Math.sign(delta)); + } else if (Math.abs(delta) >= shiftSize) { + changeSlide(Math.round(delta / shiftSize)); + } + } + + setAnimatedState(true); + setDraggableShiftPosition(0); + }} + onTouchCancel={() => { + setAnimatedState(true); + setDraggableShiftPosition(0); + }} + style={{ + minWidth: shiftSize * slidesToShow - padding, + maxWidth: shiftSize * slidesToShow - padding, + }} + className={styles.Wrapper} + > +
    +
    +
    + {props.playlist.map((video, index) => { + if (!isInRange(index, visibleIndexStart, visibleIndexEnd)) { + return null; + } + + return ( +
    + +
    + ); + })} +
    +
    + {prevButton && ( + changeSlide(-1 * Math.min(slidesToScroll, slidesToShow))} + top={slideDimensions.height / 2} + /> + )} + {nextButton && ( + changeSlide(Math.min(slidesToScroll, slidesToShow))} + top={slideDimensions.height / 2} + /> + )} +
    +
    + ); +} + +CarouselWidget.displayName = 'CarouselWidget'; + +CarouselWidget.propTypes = { + playlist: PropTypes.arrayOf(videoPropTypes).isRequired, + slidesToShow: PropTypes.number, + slidesToScroll: PropTypes.number, + slideDimensions: PropTypes.shape({ + width: PropTypes.number, + height: PropTypes.number, + }), +}; + +export default CarouselWidget; diff --git a/anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.module.scss b/anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.module.scss new file mode 100644 index 0000000..fc8bf2e --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/component/Carousel.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Carousel_Wrapper__h8Oy1","Slider":"Carousel_Slider__1iEJo","List":"Carousel_List__xMRj8","Track":"Carousel_Track__jzMXh","Track___animated":"Carousel_Track___animated__lKytX","Slide":"Carousel_Slide__nN9UE"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.jsx b/anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.jsx new file mode 100644 index 0000000..35accc8 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Image from 'next/image'; + +import { videoPropTypes } from '../../constants'; + +import { getFileUrlByDimensions } from '../../helpers'; + +import { Stack, Typography } from '@/mui/components'; + +import styles from './Slide.module.scss'; + +function Slide(props) { + return ( + + Video Thumbnail + + {props.video.title} + + + ); +} + +Slide.propTypes = { + video: videoPropTypes.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + isSmallScreen: PropTypes.bool.isRequired, +}; + +export default Slide; diff --git a/anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.module.scss b/anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.module.scss new file mode 100644 index 0000000..f451e4f --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/component/Slide/Slide.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Image":"Slide_Image__PTcgT","Title":"Slide_Title__ueeKa"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/Carousel/constants/index.js b/anyclip/src/modules/players/Editor/components/Carousel/constants/index.js new file mode 100644 index 0000000..7095311 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/constants/index.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; + +export const videoPropTypes = PropTypes.shape({ + mediaid: PropTypes.string.isRequired, + images: PropTypes.arrayOf( + PropTypes.shape({ + file: PropTypes.string.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + }), + ).isRequired, + title: PropTypes.string.isRequired, +}); + +export const TYPE_A = { + width: 416, + height: 234, +}; + +export const TYPE_B = { + width: 288, + height: 162, +}; + +export const TYPE_C = { + width: 184, + height: 104, +}; + +export const TYPE_D = { + width: 175, + height: 101, +}; + +export const TYPE_E = { + width: 136, + height: 77, +}; + +export const TYPE_F = { + width: 162, + height: 162, +}; + +export const TYPE_G = { + width: 104, + height: 104, +}; + +export const TYPE_H = { + width: 44, + height: 44, +}; + +export const TYPE_I = { + width: 252, + height: 142, +}; + +export default {}; diff --git a/anyclip/src/modules/players/Editor/components/Carousel/helpers/index.js b/anyclip/src/modules/players/Editor/components/Carousel/helpers/index.js new file mode 100644 index 0000000..da0cd0b --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Carousel/helpers/index.js @@ -0,0 +1,21 @@ +export const getFileUrlByDimensions = (files, width = Infinity, height = Infinity) => { + if (!files?.length) { + return ''; + } + + const sortedBySize = files.slice().sort((a, b) => a.height - b.height); + + let matchedFile = sortedBySize[sortedBySize.length - 1]; + + if (width !== Infinity && height !== Infinity) { + const file = sortedBySize.find((item) => item.height >= height && item.width >= width); + + if (file) { + matchedFile = file; + } + } + + return matchedFile.file; +}; + +export default {}; diff --git a/anyclip/src/modules/players/Editor/components/Editor.jsx b/anyclip/src/modules/players/Editor/components/Editor.jsx new file mode 100644 index 0000000..29d06db --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Editor.jsx @@ -0,0 +1,406 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { + PCN_POST_PLAYER_NEW, + PCN_PUT_PLAYER_NEW, + SELF_SERV_PLAYER_ACCESS_MONETIZATION, +} from '@/modules/@common/acl/constants'; +import { + TYPE_INTELLIGENT, + TYPE_INTELLIGENT_AMP, + TYPE_OUTSTREAM, + TYPE_STORIES, + TYPE_STORIES_AMP, + TYPE_VERTICAL, +} from '@/modules/@common/constants/playerTypes'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { + CONTENT_FILTERS_TAB, + CUSTOM_PLACEHOLDER_TAB, + DISPLAY_ADS_TAB, + FLOATING_TAB, + GENERAL_TAB, + INACTIVATED_PLAYER, + LOOK_AND_FEEL_TAB, + PLAYBACK_TAB, + UNSUPPORTED_PLAYER_TYPE, + VIDEO_ADS_TAB, +} from '@/modules/players/Editor/constants'; +import { PLAYER_TYPES } from '@/modules/players/Players/constants'; + +import * as playerSelectors from '../redux/selectors'; +import { isNumber } from '@/modules/@common/helpers/number'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserAccountSelector, getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { isAmpTypes, isOutstreamType } from '@/modules/players/Editor/helpers'; +import { + createPlayerAction, + getPlayerDataAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updatePlayerAction, + validateFields, +} from '@/modules/players/Editor/redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import SaveAndCreateTagsConfirmDialog from '@/modules/players/Editor/components/SaveAndCreateTagsConfirmDialog/SaveAndCreateTagsConfirmDialog'; +import PlayerPreviewWrapper from '@/modules/players/Editor/components/Tabs/PlayerPreviewWrapper'; +import ContentFiltersTab from './Tabs/ContentFiltersTab/ContentFiltersTab'; +import CustomPlaceholderTab from './Tabs/CustomPlaceholderTab/CustomPlaceholderTab'; +import DisplayAdsTab from './Tabs/DisplayAdsTab/DisplayAdsTab'; +import FloatingTab from './Tabs/FloatingTab/FloatingTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import LookAndFeelTab from './Tabs/LookAndFeelTab/LookAndFeelTab'; +import PlaybackTab from './Tabs/PlaybackTab/PlaybackTab'; +import VideoAdsTab from './Tabs/VideoAdsTab/VideoAdsTab'; +import { Button, Stack, Tab, TabContent, Tabs, Tooltip, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const [open, setOpen] = useState(false); + const [saveAndCreateTagsDialogOpen, setSaveAndCreateTagsDialogOpen] = useState(false); + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const publisher = useSelector(playerSelectors.publisherSelector); + const playerType = useSelector(playerSelectors.playerTypeSelector); + const isLoading = useSelector(playerSelectors.isLoadingSelector); + const abTest = useSelector(playerSelectors.abTestSelector); + const status = useSelector(playerSelectors.statusSelector); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const activeTabId = useSelector(playerSelectors.activeTabIdSelector); + const alias = useSelector(playerSelectors.aliasSelector); + const availablePlayerTypes = useSelector(playerSelectors.availableTemplatePlayersSelector); + + const userAccount = useSelector(getUserAccountSelector); + + const canCreatePlayer = hasPermission(PCN_POST_PLAYER_NEW, userPermissions); + const canUpdatePlayer = hasPermission(PCN_PUT_PLAYER_NEW, userPermissions); + const accessMonetization = hasPermission(SELF_SERV_PLAYER_ACCESS_MONETIZATION, userPermissions); + + const paramTypeId = parseInt(router.query.params[0], 10) || null; + const id = parseInt(router.query.params[1], 10) || null; + const paramPlayerType = PLAYER_TYPES.find((type) => type.id === paramTypeId && type.selfServe); + const copyFromId = parseInt(router.query.copy, 10) || null; + const canReadOnly = (id && !canUpdatePlayer) || (!id && !canCreatePlayer); + + const shouldShowSaveAndCreateTagsBtn = + [TYPE_INTELLIGENT, TYPE_INTELLIGENT_AMP, TYPE_OUTSTREAM, TYPE_STORIES, TYPE_STORIES_AMP, TYPE_VERTICAL].includes( + paramTypeId, + ) && + !canReadOnly && + !id && + isNumber(userAccount.expenses) && + isNumber(userAccount.publisherRevShare); + + const tabs = [ + { + label: 'General', + Content: GeneralTab, + id: GENERAL_TAB, + show: true, + }, + { + label: 'Look & Feel', + Content: LookAndFeelTab, + id: LOOK_AND_FEEL_TAB, + show: paramTypeId !== TYPE_OUTSTREAM, + }, + { + label: 'Floating', + Content: FloatingTab, + id: FLOATING_TAB, + show: true, + }, + { + label: 'Content Filters', + Content: ContentFiltersTab, + id: CONTENT_FILTERS_TAB, + show: paramTypeId !== TYPE_OUTSTREAM, + }, + { + label: 'Playback', + Content: PlaybackTab, + id: PLAYBACK_TAB, + show: paramTypeId !== TYPE_OUTSTREAM, + }, + { + label: 'Video Ads', + Content: VideoAdsTab, + id: VIDEO_ADS_TAB, + show: !!publisher?.monetization && accessMonetization, + }, + { + label: 'Display Overlay Ads', + Content: DisplayAdsTab, + id: DISPLAY_ADS_TAB, + show: paramTypeId !== TYPE_OUTSTREAM && !!publisher?.monetization && accessMonetization, + }, + { + label: 'Custom Placeholder', + Content: CustomPlaceholderTab, + id: CUSTOM_PLACEHOLDER_TAB, + show: true, + }, + ].filter(({ show }) => show); + + const validate = () => { + const state = store.getState(); + const allProps = playerSelectors.fullAccessToStoreFieldsForValidation(state); + const isAmp = isAmpTypes(allProps.playerType?.id); + const isOutstream = isOutstreamType(allProps.playerType?.id); + + const { validation, errorList } = validateFields( + playerSelectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .filter(({ fieldName }) => { + // omit for floating tab those fields + if (['floatingDesktopDelay', 'floatingMobileDelay'].includes(fieldName)) { + return !isAmp && !isOutstream; + } + if (fieldName === 'selectors') { + return allProps.customPlaceholderEnable; + } + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + const errorFields = errorList.map((field) => { + if (['playerWidth', 'playerWidthMobile'].includes(field.fieldName)) { + return { + ...field, + tabId: isOutstreamType(playerType?.id) ? GENERAL_TAB : LOOK_AND_FEEL_TAB, + }; + } + return field; + }); + + if (errorFields.length) { + const errorField = errorFields.find((error) => error.tabId === activeTabId) ?? errorFields[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } + + dispatch(setErrorByPropAction(validation)); + + return { errorFields }; + }; + + const handleSave = () => { + const { errorFields } = validate(); + + if (!errorFields.length) { + if (id) { + dispatch(updatePlayerAction()); + } else { + dispatch(createPlayerAction({ copyFromId, shouldCreateTags: false })); + } + } + }; + + const handleSaveAndCreateTags = () => { + const { errorFields } = validate(); + + if (!errorFields.length) { + setSaveAndCreateTagsDialogOpen(true); + } + }; + + useEffect(() => { + if ( + !paramPlayerType || + (playerType && playerType.id !== paramTypeId) || + (!id && + !copyFromId && + availablePlayerTypes?.length && + !availablePlayerTypes.some((type) => type.id === paramTypeId)) + ) { + dispatch( + showNotificationAction({ + key: UNSUPPORTED_PLAYER_TYPE, + type: TYPE_ERROR, + message: UNSUPPORTED_PLAYER_TYPE, + }), + ); + router.replace('/player'); + } + }, [paramTypeId, playerType, availablePlayerTypes]); + + useEffect(() => { + if (id && status < 1) { + dispatch( + showNotificationAction({ + key: INACTIVATED_PLAYER, + type: TYPE_ERROR, + message: INACTIVATED_PLAYER, + }), + ); + router.replace('/player'); + } + }, [id, status]); + + useEffect(() => { + if (!id && !copyFromId && paramPlayerType) { + dispatch( + setAction({ + playerType: paramPlayerType, + type: paramPlayerType.id, + }), + ); + } + dispatch(getPlayerDataAction({ id: copyFromId || id, copy: !!copyFromId })); + }, [id, paramTypeId, copyFromId]); + + useEffect(() => () => dispatch(setInitialAction()), []); + + const shouldHavePreview = PLAYER_TYPES.find((type) => type.id === paramTypeId && type.preview); + + const playerPreview = useMemo( + () => + publisher && shouldHavePreview ? ( + + + + ) : null, + [publisher, shouldHavePreview], + ); + + const getTitle = () => { + let action = 'New'; + let suffix = 'Player'; + + if (copyFromId) { + action = 'Copy'; + } else if (id) { + action = `${alias}`; + suffix = '> Settings'; + } + + return `${action} ${playerType?.name || ''} ${suffix}`; + }; + + return ( +
    + + + {getTitle()} + + + + {tabs.length > 1 && ( + { + dispatch( + setAction({ + activeTabId: value, + }), + ); + }} + > + {tabs.map((tab) => ( + + ))} + + )} + + + {!canReadOnly && ( + + setOpen(true)} // Manually control open state + onMouseLeave={() => setOpen(false)} + > + + + + )} + {shouldShowSaveAndCreateTagsBtn && ( + + )} + + +
    + {tabs.map((tab) => { + const { Content } = tab; + + return ( + + + + ); + })} +
    + + {saveAndCreateTagsDialogOpen && ( + setSaveAndCreateTagsDialogOpen(false)} + onConfirm={() => { + dispatch(createPlayerAction({ copyFromId, shouldCreateTags: true })); + setSaveAndCreateTagsDialogOpen(false); + }} + /> + )} +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/players/Editor/components/Editor.module.scss b/anyclip/src/modules/players/Editor/components/Editor.module.scss new file mode 100644 index 0000000..6253e8d --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__I1HTg","Title":"Editor_Title__b6AFC","Controls":"Editor_Controls__zeRZK","Tabs":"Editor_Tabs__5TGfr"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.jsx b/anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.jsx new file mode 100644 index 0000000..96f949e --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.jsx @@ -0,0 +1,152 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +import { + DesktopWindowsRounded, + PhoneIphoneRounded, + VisibilityOffRounded, + VisibilityRounded, +} from '@mui/icons-material'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { INTERVAL_MAX, INTERVAL_MIN } from '@/modules/players/Editor/constants'; + +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { setAction } from '@/modules/players/Editor/redux/slices'; + +import { DataGridPro, Stack } from '@/mui/components'; + +import styles from './IntervalsList.module.scss'; + +function IntervalsList({ + showDesktopIntervals = true, + + ...props +}) { + const dispatch = useDispatch(); + const handleSetState = (state) => dispatch(setAction(state)); + + const handleRowUpdate = (row) => { + if (row.interval < INTERVAL_MIN || row.interval > INTERVAL_MAX) { + dispatch( + showNotificationAction({ + key: props.intervalsHeaderName + Date.now(), + type: TYPE_ERROR, + message: `${props.intervalsHeaderName}: accept only numbers between ${INTERVAL_MIN} to ${INTERVAL_MAX}`, + }), + ); + return undefined; + } + handleSetState({ [row.stateName]: Math.floor(row.interval) }); + // eslint-disable-next-line no-param-reassign + row.interval = Math.floor(row.interval); + return row; + }; + const intervals = [ + showDesktopIntervals && { + id: 0, + device: 'desktop', + viewability: 'inView', + interval: props.desktopInViewInterval ?? INTERVAL_MIN, + stateName: props.desktopInViewStateName, + }, + showDesktopIntervals && { + id: 1, + device: 'desktop', + viewability: 'notInView', + interval: props.desktopNotInViewInterval ?? INTERVAL_MIN, + stateName: props.desktopNotInViewStateName, + }, + { + id: 2, + device: 'mobile', + viewability: 'inView', + interval: props.mobileInViewInterval ?? INTERVAL_MIN, + stateName: props.mobileInViewStateName, + }, + { + id: 3, + device: 'mobile', + viewability: 'notInView', + interval: props.mobileNotInViewInterval ?? INTERVAL_MIN, + stateName: props.mobileNotInViewStateName, + }, + ].filter(Boolean); + + return ( + { + const Icon = params.value === 'desktop' ? DesktopWindowsRounded : PhoneIphoneRounded; + return ( + + + + ); + }, + }, + { + field: 'viewability', + headerName: 'Viewability', + headerAlign: 'center', + align: 'center', + sortable: false, + filterable: false, + width: 200, + disableColumnMenu: true, + renderCellValue: (params) => { + const Icon = params.value === 'inView' ? VisibilityRounded : VisibilityOffRounded; + + return ( + + + + ); + }, + }, + { + field: 'interval', + headerName: props.intervalsHeaderName ?? 'Intervals (ms)', + headerAlign: 'center', + align: 'center', + sortable: false, + filterable: false, + cellType: 'number', + width: 230, + disableColumnMenu: true, + editable: true, + }, + ]} + rows={intervals} + /> + ); +} + +IntervalsList.propTypes = { + showDesktopIntervals: PropTypes.bool, + intervalsHeaderName: PropTypes.string.isRequired, + desktopInViewInterval: PropTypes.number.isRequired, + desktopNotInViewInterval: PropTypes.number.isRequired, + mobileInViewInterval: PropTypes.number.isRequired, + mobileNotInViewInterval: PropTypes.number.isRequired, + desktopInViewStateName: PropTypes.string.isRequired, + desktopNotInViewStateName: PropTypes.string.isRequired, + mobileInViewStateName: PropTypes.string.isRequired, + mobileNotInViewStateName: PropTypes.string.isRequired, +}; + +export default IntervalsList; diff --git a/anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.module.scss b/anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.module.scss new file mode 100644 index 0000000..0fcbf3b --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/IntervalsList/IntervalsList.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"IconWrapper":"IntervalsList_IconWrapper__2eMk7"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/SaveAndCreateTagsConfirmDialog/SaveAndCreateTagsConfirmDialog.tsx b/anyclip/src/modules/players/Editor/components/SaveAndCreateTagsConfirmDialog/SaveAndCreateTagsConfirmDialog.tsx new file mode 100644 index 0000000..1397143 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/SaveAndCreateTagsConfirmDialog/SaveAndCreateTagsConfirmDialog.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from '@/mui/components'; + +type PropTypes = { + onClose: () => null; + onConfirm: () => void; + open: boolean; +}; + +function SaveAndCreateTagsConfirmDialog({ onClose, onConfirm, open = false }: PropTypes) { + return ( + + Save Player and Create Supply Tags + + +

    + New supply tags will be created automatically without any demand connected to them. They will be available + in the Marketplace interface after saving. +

    +

    To begin monetizing, please connect demand tags through the Marketplace or contact your AM.

    +
    +
    + + + + +
    + ); +} + +export default SaveAndCreateTagsConfirmDialog; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/ContentFiltersTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/ContentFiltersTab.jsx new file mode 100644 index 0000000..ccf3eab --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/ContentFiltersTab.jsx @@ -0,0 +1,332 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { VIDEO_ACCESS_TARGETING } from '@/modules/@common/acl/constants'; +import { TYPE_INTELLIGENT, TYPE_INTELLIGENT_AMP, TYPE_VERTICAL } from '@/modules/@common/constants/playerTypes'; +import { REDUX_ERROR_PROP_NAME } from '@/modules/@common/Form/constants'; +import { TYPE_WARNING } from '@/modules/@common/notify/constants'; +import { + ALL_FEEDS_ID, + ALL_FEEDS_NAME, + ALL_LANGUAGES_NAME, + ALL_LANGUAGES_VALUE, +} from '@/modules/players/Editor/constants'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { isStoriesTypes } from '@/modules/players/Editor/helpers'; +import * as playerSelectors from '@/modules/players/Editor/redux/selectors'; +import { + getPlayerFeedsOptionsAction, + removeErrorByPropAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + validateSingleField, +} from '@/modules/players/Editor/redux/slices'; + +import { FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import TagsFilter from './TagsFilter/TagsFilter'; +import { Autocomplete, Divider, List, ListItem, Switch, TextField } from '@/mui/components'; + +function ContentFiltersTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const userPermissions = useSelector(getUserPermissionsSelector); + + const id = useSelector(playerSelectors.idSelector); + + const playerType = useSelector(playerSelectors.playerTypeSelector); + + const publisher = useSelector(playerSelectors.publisherSelector); + + const useDefaultContentOwner = useSelector(playerSelectors.useDefaultContentOwnerSelector); + + const playerContentOwnerFeeds = useSelector(playerSelectors.playerContentOwnerFeedsSelector); + const playerContentOwnerFeedsOptions = useSelector(playerSelectors.playerContentOwnerFeedsOptionsSelector); + const allFeeds = useSelector(playerSelectors.allFeedsSelector); + + const playerFeedLanguages = useSelector(playerSelectors.playerFeedLanguagesSelector); + const feedLanguagesOptions = useSelector(playerSelectors.feedLanguagesOptionsSelector); + + const playerBrandSafeties = useSelector(playerSelectors.playerBrandSafetiesSelector); + const brandSafetiesOptions = useSelector(playerSelectors.playerBrandSafetiesOptionsSelector); + const enableVideoTargeting = useSelector(playerSelectors.enableVideoTargetingSelector); + const videoFormatByDevice = useSelector(playerSelectors.videoFormatByDeviceSelector); + + const scheme = useSelector(playerSelectors.schemeSelector); + + const [sourcesDropDownOpen, setSourcesDropDownOpen] = useState(false); + + const showOnlySources = isStoriesTypes(playerType?.id); + + const isEnableVideoTargetingActive = hasPermission(VIDEO_ACCESS_TARGETING, userPermissions); + const shouldShowEnableVideoTargetingControl = [TYPE_INTELLIGENT, TYPE_INTELLIGENT_AMP, TYPE_VERTICAL].includes( + playerType?.id, + ); + + const autoSelectVideoLayooutByDevice = [TYPE_INTELLIGENT, TYPE_VERTICAL].includes(playerType?.id); + + const handleSetState = (state) => dispatch(setAction(state)); + + const isActiveSyndicatedContent = () => + publisher && (publisher.includePublicContent || publisher.publisherContentOwners?.length); + + const handleChangeFeeds = (selected) => { + if (!allFeeds && selected.some((f) => f.id === ALL_FEEDS_ID)) { + handleSetState({ + allFeeds: 1, // true + playerContentOwnerFeeds: [{ name: ALL_FEEDS_NAME, id: ALL_FEEDS_ID }], + playerContentOwnerFeedsOptions: playerContentOwnerFeeds.filter((f) => f.id !== ALL_FEEDS_ID), + }); + } else { + const filteredSelected = selected.filter((f) => f.id !== ALL_FEEDS_ID); + handleSetState({ + playerContentOwnerFeeds: filteredSelected, + allFeeds: 0, + }); + } + }; + + const isAllLanguagesSelected = playerFeedLanguages.length === feedLanguagesOptions.length; + const feedLanguagesMultiSelectValue = isAllLanguagesSelected + ? [{ name: ALL_LANGUAGES_NAME, value: ALL_LANGUAGES_VALUE }] + : playerFeedLanguages; + const feedLanguagesMultiSelectOptions = [ + ...(!isAllLanguagesSelected ? [{ name: ALL_LANGUAGES_NAME, value: ALL_LANGUAGES_VALUE }] : []), + ...feedLanguagesOptions, + ]; + + const handleChangeLanguages = (selected) => { + if (!isAllLanguagesSelected && selected.some((l) => l.value === ALL_LANGUAGES_VALUE)) { + handleSetState({ playerFeedLanguages: feedLanguagesOptions }); + } else { + const filteredSelected = selected.filter((l) => l.value !== ALL_LANGUAGES_VALUE); + handleSetState({ playerFeedLanguages: filteredSelected }); + } + }; + + // set default enabledVideoTargetingFlag state + // when user create player and has access to video target in account + useEffect(() => { + if (!id && isEnableVideoTargetingActive && shouldShowEnableVideoTargetingControl) { + dispatch(setAction({ enableVideoTargeting: true })); + } + }, [id, isEnableVideoTargetingActive, shouldShowEnableVideoTargetingControl]); + + return ( + + {!showOnlySources && ( + + { + handleSetState({ + useDefaultContentOwner: target.checked ? 1 : 0, + }); + if (target.checked) { + dispatch(removeErrorByPropAction(['playerContentOwnerFeeds'])); + } + }} + /> + + )} + + { + const validation = validateSingleField('publisher', publisher); + + if (validation[REDUX_ERROR_PROP_NAME]) { + dispatch(setErrorByPropAction([validation])); + dispatch(setActiveTabIdAction(validation.tabId)); + dispatch(setScrollToFieldNameAction(validation.fieldName)); + dispatch( + notifyAction({ + type: TYPE_WARNING, + message: 'Please select a Hub from the list', + }), + ); + } else { + setSourcesDropDownOpen(true); + dispatch( + getPlayerFeedsOptionsAction({ + search: '', + publisherId: publisher?.id, + includePublicContent: publisher?.includePublicContent, + }), + ); + } + }} + onChange={(_, selected) => handleChangeFeeds(selected)} + onBlur={() => { + setSourcesDropDownOpen(false); + }} + onInputChange={(_, searchText) => + dispatch( + getPlayerFeedsOptionsAction({ + search: searchText, + publisherId: publisher?.id, + includePublicContent: publisher?.includePublicContent, + }), + ) + } + optionLabelKey="name" + optionValueKey="id" + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['playerContentOwnerFeeds']))} + /> + )} + /> + + {!showOnlySources && ( + <> + + handleSetState({ playerBrandSafeties: selected })} + renderInput={(params) => ( + + )} + /> + + + + handleChangeLanguages(selected)} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['playerFeedLanguages']))} + /> + )} + /> + + + {shouldShowEnableVideoTargetingControl && ( + + { + handleSetState({ + enableVideoTargeting: target.checked, + }); + }} + /> + + )} + {autoSelectVideoLayooutByDevice && ( + + { + handleSetState({ + videoFormatByDevice: target.checked, + }); + }} + /> + + )} + + Use this tool to filter in/out videos based on the system and custom tags. Relationship between tags in + the table: + + + Within the same Tag Type: + + + If you have multiple tags set to 'Include', the relationship is 'OR'. In + other words, at least one tag needs to exist for the video to be eligible to play. + + + If you have multiple tags set to 'Exclude', the relationship is 'AND'. This + means that if at least one tag exists, the video would not be eligible to play. + + + If you have both 'Include' and 'Exclude' tags in the same category, all + 'Exclude' tags must be respected, and at least one 'Include' tag needs to + exist for the video to be eligible to play. + + + + + Between different Tag Types: + + + The relationship is always 'AND'. This means that only videos that meet the above + criteria in each of the types would be eligible to play. + + + + + + } + /> + + + + )} + + ); +} + +export default ContentFiltersTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.jsx b/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.jsx new file mode 100644 index 0000000..35764bc --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.jsx @@ -0,0 +1,220 @@ +import React, { useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; + +import { + NEGATIVE_BOOST_FILTERS_VALUE_DEFAULT, + POSITIVE_BOOST_FILTERS_VALUE_DEFAULT, +} from '@/modules/players/Editor/constants'; + +import * as playerSelectors from '../../../../redux/selectors'; +import { getIAB } from '@/modules/@common/iab/helpers'; +import { getPlayerBoostListOptionsAction, setAction } from '@/modules/players/Editor/redux/slices'; +import { autoSuggestHighlight } from '@/mui/helpers'; +import { createFlatTreeMap } from '@/mui/helpers/treeView'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { TagIabSelector, TagSelector } from '@/modules/@common/TagSelector'; + +import styles from './TagsFilter.module.scss'; + +const IAB_CATEGORY = 'IAB'; + +function TagsFilter() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const boostsTypesOptions = useSelector(playerSelectors.boostsTypesOptionsSelector); + const boostsListOptions = useSelector(playerSelectors.boostsListOptionsSelector); + const boosts = useSelector(playerSelectors.boostsSelector); + + const iabFlatTree = useMemo(() => createFlatTreeMap(getIAB(), { label: 'name', children: 'categories' }), []); + const values = useMemo(() => { + // build initial values { : [] } + const types = boostsTypesOptions.reduce((acc, boostType) => { + acc[boostType.value] = []; + return acc; + }, {}); + + boosts.forEach((boost) => { + if (!types[boost.type]) { + types[boost.type] = []; + } + + if (boost.type === IAB_CATEGORY) { + let tag = null; + + iabFlatTree.forEach((node) => { + if (node.label === boost.value) { + tag = node; + } + }); + + types[boost.type].push({ + initialNode: { ...tag }, + label: tag.label, + value: tag.id, + include: boost.score > 0, + }); + } else { + types[boost.type].push({ + initialNode: { ...boost }, + label: boost.value, + value: boost.value, + include: boost.score > 0, + }); + } + }); + + return types; + }, [boosts, boostsTypesOptions]); + + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + {boostsTypesOptions.map((tagGroup) => { + const clearAllByType = () => { + handleSetState({ + boosts: boosts.filter((tag$) => tag$.type !== tagGroup.value), + }); + }; + + return ( + + <> + {tagGroup.value === IAB_CATEGORY ? ( + { + if (reason === 'clear') { + clearAllByType(); + } else { + const alreadyAdded = []; + const newBoosts = boosts.filter((boost) => { + if (boost.type !== IAB_CATEGORY) { + return true; + } + // order exclude/include fix + return tags.some((tag) => { + const exist = tag.label === boost.value && tag.include === boost.score > 0; + + if (exist) { + alreadyAdded.push(boost.value); + } + + return exist; + }); + }); + + tags.forEach((tag) => { + if (!alreadyAdded.includes(tag.label)) { + newBoosts.push({ + type: tagGroup.value, + value: tag.label, + score: tag.include + ? POSITIVE_BOOST_FILTERS_VALUE_DEFAULT + : NEGATIVE_BOOST_FILTERS_VALUE_DEFAULT, + taxonomyId: undefined, + }); + } + }); + + handleSetState({ + boosts: newBoosts, + }); + } + }} + /> + ) : ( + ({ + initialNode: { ...option }, + label: option.value, + value: [option.label, option.value].filter(Boolean).join(':'), + }))} + getTagLabel={(tag) => + tagGroup.grouped && tag.initialNode.label + ? `${tag.initialNode.label}:${tag.initialNode.value}` + : tag.initialNode.label + } + renderOption={(optionProps, option, { inputValue }) => ( +
  • + {autoSuggestHighlight(inputValue, option.label).map((part) => { + const Tag = part.highlight ? 'b' : React.Fragment; + + return {part.text}; + })} +
  • + )} + value={values[tagGroup.value] || []} + placeholder="Select tag" + groupBy={tagGroup.grouped ? (option) => option.initialNode.label : undefined} + onOpen={() => { + dispatch( + getPlayerBoostListOptionsAction({ + type: tagGroup.value, + }), + ); + }} + onInputChange={(_, searchText) => + dispatch( + getPlayerBoostListOptionsAction({ + searchText, + type: tagGroup.value, + }), + ) + } + onChange={(event, tags, reason) => { + if (reason === 'selectOption') { + handleSetState({ + boosts: [ + ...boosts, + ...tags + .filter( + (boost) => !boosts.some((b) => b.type === tagGroup.value && b.value === boost.value), + ) + .map((tag) => ({ + type: tagGroup.value, + value: tag.value, + score: tag.include + ? POSITIVE_BOOST_FILTERS_VALUE_DEFAULT + : NEGATIVE_BOOST_FILTERS_VALUE_DEFAULT, + taxonomyId: tag.initialNode.uid, + })), + ], + }); + } else if (reason === 'removeOption') { + const newBoosts = []; + + boosts.forEach((boost) => { + if (boost.type !== tagGroup.value) { + newBoosts.push(boost); + } else if (tags.some((tag) => tag.value === boost.value)) { + newBoosts.push(boost); + } + }); + handleSetState({ + boosts: newBoosts, + }); + } else if (reason === 'clear') { + clearAllByType(); + } + }} + /> + )} + +
    + ); + })} + + ); +} + +export default TagsFilter; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.module.scss b/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.module.scss new file mode 100644 index 0000000..11714b1 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/ContentFiltersTab/TagsFilter/TagsFilter.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"OptionRoot":"TagsFilter_OptionRoot__UaES_"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/CustomPlaceholderTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/CustomPlaceholderTab.jsx new file mode 100644 index 0000000..a47fc99 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/CustomPlaceholderTab.jsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Close } from '@mui/icons-material'; + +import * as playerSelectors from '../../../redux/selectors'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { + removeErrorByPropAction, + setAction, + setSelectorAction, + setSelectorsAction, +} from '@/modules/players/Editor/redux/slices'; + +import { FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import PlaceholderList from './components/PlaceholderList/PlaceholderList'; +import { Button, IconButton, InputAdornment, Switch, TextField } from '@/mui/components'; + +function CustomPlaceholderTab() { + const dispatch = useDispatch(); + const { size } = useFormSettings(); + const customPlaceholderEnable = useSelector(playerSelectors.customPlaceholderEnableSelector); + const scheme = useSelector(playerSelectors.schemeSelector); + const selectors = useSelector(playerSelectors.selectorsSelector); + + // local state + const [placeholderSelector, setPlaceholderSelector] = useState(''); + + const handleSetState = (state) => dispatch(setAction(state)); // + const [error, setError] = useState(''); + + const applySelector = () => { + const hasDuplicate = selectors?.some((item) => item.selector === placeholderSelector); + if (!hasDuplicate) { + dispatch( + setSelectorAction({ + id: `${Math.random().toString(36)}`, + selector: placeholderSelector, + }), + ); + setPlaceholderSelector(''); + } + setError(hasDuplicate ? 'Selector already exists' : ''); + }; + const selectorValidation = getInputPropsByName(scheme, ['placeholderSelector']); + return ( + + + + handleSetState({ + customPlaceholderEnable: target.checked, + }) + } + /> + + {customPlaceholderEnable && ( + <> + + + { + setPlaceholderSelector(''); + setError(''); + }} + > + + + + ) : null, + }} + onChange={({ target: { value } }) => { + setPlaceholderSelector(value); + setError(''); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && customPlaceholderEnable && placeholderSelector.trim()) { + applySelector(); + } + }} + {...selectorValidation} + error={selectorValidation.error || !!error} + helperText={selectorValidation.helperText || error} + onFocus={() => { + setError(''); + dispatch(removeErrorByPropAction(['placeholderSelector'])); + }} + /> + + + {selectors && ( + + dispatch(removeErrorByPropAction(keyArray))} + onSetField={(items) => { + dispatch(setSelectorsAction(items)); + }} + /> + + )} + + )} + + ); +} + +export default CustomPlaceholderTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.jsx b/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.jsx new file mode 100644 index 0000000..1bf68a6 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.jsx @@ -0,0 +1,110 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { DeleteRounded, DragIndicatorRounded } from '@mui/icons-material'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import SortableItem from '@/modules/@common/dnd/SortableItem/SortableItem'; +import { FormGroup, FormRow, useFormSettings } from '@/modules/@common/Form'; +import { IconButton, Stack, TextField } from '@/mui/components'; + +import styles from './PlaceholderList.module.scss'; + +function PlaceholderList({ ...props }) { + const { size } = useFormSettings(); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // fields handlers + const handleDragEnd = ({ active, over }) => { + const getIndex = (neededIndex) => props.fields.findIndex((o) => o.id === neededIndex); + const oldIndex = getIndex(active.id); + const newIndex = getIndex(over.id); + const dynamicFields = arrayMove(props.fields, oldIndex, newIndex); + + props.onSetField(dynamicFields); + }; + + const handleRemove = (fieldId) => { + const dynamicFields = props.fields.filter((field) => field.id !== fieldId); + return props.onSetField(dynamicFields); + }; + + const handleFieldSetValue = (fieldId, keyOfValue, value) => { + const dynamicFields = props.fields.map((field) => ({ + ...field, + [keyOfValue]: field.id === fieldId ? value : field[keyOfValue], + })); + + return props.onSetField(dynamicFields); + }; + + return ( + + + field.id)} strategy={verticalListSortingStrategy}> + {props.fields?.map((field) => ( + + {(sortableItemProps) => ( + + + <> + + + + handleFieldSetValue(field.id, 'selector', target.value)} + // eslint-disable-next-line react/prop-types + {...getInputPropsByName(props.scheme, ['selectors', field.id, 'selector'])} + // eslint-disable-next-line react/prop-types + onFocus={() => props.removeErrorByPropAction(['selectors', field.id, 'selector'])} + /> + handleRemove(field.id)}> + + + + + + )} + + ))} + + + + ); +} + +PlaceholderList.propTypes = { + fields: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + onSetField: PropTypes.func.isRequired, +}; + +export default PlaceholderList; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.module.scss b/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.module.scss new file mode 100644 index 0000000..36d9d3f --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/CustomPlaceholderTab/components/PlaceholderList/PlaceholderList.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Field":"PlaceholderList_Field__a4bqH","Field___dragging":"PlaceholderList_Field___dragging__HTZJ1"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/Tabs/DisplayAdsTab/DisplayAdsTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/DisplayAdsTab/DisplayAdsTab.jsx new file mode 100644 index 0000000..451dd6f --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/DisplayAdsTab/DisplayAdsTab.jsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import * as playerSelectors from '../../../redux/selectors'; +import { setAction } from '../../../redux/slices'; + +import { FormGroupTitle, FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import IntervalsList from '../../IntervalsList/IntervalsList'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Switch, Typography } from '@/mui/components'; + +function DisplayAdsTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const displayMonetizationDesktop = useSelector(playerSelectors.displayMonetizationDesktopSelector); + const displayMonetizationMobile = useSelector(playerSelectors.displayMonetizationMobileSelector); + + const displayClipNotInViewPlayAdsDesktop = useSelector(playerSelectors.displayClipNotInViewPlayAdsDesktopSelector); + const displayClipNotInViewPlayAdsMobile = useSelector(playerSelectors.displayClipNotInViewPlayAdsMobileSelector); + + const displayInviewAdInterval = useSelector(playerSelectors.displayInviewAdIntervalSelector); + const displayNotInviewAdInterval = useSelector(playerSelectors.displayNotInviewAdIntervalSelector); + const displayInviewAdIntervalMobile = useSelector(playerSelectors.displayInviewAdIntervalMobileSelector); + const displayNotInviewAdIntervalMobile = useSelector(playerSelectors.displayNotInviewAdIntervalMobileSelector); + + const handleSetState = (state) => dispatch(setAction(state)); + const [confirmationDialog, setConfirmationDialog] = useState(null); + + const handleConfirm = () => { + handleSetState({ + displayClipNotInViewPlayAdsDesktop: 0, + displayClipNotInViewPlayAdsMobile: 0, + }); + setConfirmationDialog(null); + }; + + const handleCancel = () => { + setConfirmationDialog(null); + }; + + return ( + <> + + + + handleSetState({ + displayMonetizationDesktop: target.checked ? 1 : 0, + }) + } + /> + + + + handleSetState({ + displayMonetizationMobile: target.checked ? 1 : 0, + }) + } + /> + + + { + if (!target.checked) { + if (displayClipNotInViewPlayAdsDesktop && displayClipNotInViewPlayAdsMobile) { + setConfirmationDialog({ + title: 'Disable non-viewable Display Ads', + primary: + 'Disabling non-viewable video ads will significantly reduce the revenue generated by this player by limiting ad delivery when the player is not in view.', + secondary: 'Please consult your dedicated Account Manager before proceeding.', + important: true, + }); + } + } else { + handleSetState({ + displayClipNotInViewPlayAdsDesktop: target.checked ? 1 : 0, + displayClipNotInViewPlayAdsMobile: target.checked ? 1 : 0, + }); + } + }} + /> + + Display Refresh Intervals + + + + + {confirmationDialog && ( + + setConfirmationDialog(null)}>{confirmationDialog.title} + + + + {confirmationDialog.primary} + + + {confirmationDialog.secondary} + + + + + + + + + )} + + ); +} + +export default DisplayAdsTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/FloatingTab/FloatingTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/FloatingTab/FloatingTab.jsx new file mode 100644 index 0000000..501d526 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/FloatingTab/FloatingTab.jsx @@ -0,0 +1,408 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Monitor, PhoneIphone } from '@mui/icons-material'; + +import { + DELAY_CLOSE_ICON_ENABLE_DEFAULT, + FLOATING_DISABLED, + FLOATING_MODE_DISABLED, + FLOATING_MODE_DISABLED_LABEL, + FLOATING_STANDARD, +} from '@/modules/players/Editor/constants'; + +import * as playerSelectors from '../../../redux/selectors'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { + castToInt, + getDefaultFloatingMobilePosition, + getDependentFieldsDefaultValuesObj, + isAmpTypes, + isIntelligentAmpType, + isIntelligentType, + isOutstreamType, + isStoriesTypes, + isVerticalType, +} from '@/modules/players/Editor/helpers'; +import { removeErrorByPropAction, setAction } from '@/modules/players/Editor/redux/slices'; + +import { FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import { MenuItem, Select, Stack, TextField, Tooltip } from '@/mui/components'; + +function FloatingTab({ playerPreview = null }) { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const floatingDesktopEnabled = useSelector(playerSelectors.floatingDesktopEnabledSelector); + const floatingDesktopEnabledOptions = useSelector(playerSelectors.floatingDesktopEnabledOptionsSelector); + const floatingDesktopMode = useSelector(playerSelectors.floatingDesktopModeSelector); + const floatingDesktopModeOptions = useSelector(playerSelectors.floatingDesktopModeOptionsSelector); + const floatingDesktopPosition = useSelector(playerSelectors.floatingDesktopPositionSelector); + const floatingDesktopPositionOptions = useSelector(playerSelectors.floatingDesktopPositionOptionsSelector); + const floatingDesktopWidth = useSelector(playerSelectors.floatingDesktopWidthSelector); + const floatingDesktopWidthVerticalDefault = useSelector(playerSelectors.floatingDesktopWidthVerticalSelector); + const floatingDesktopDelay = useSelector(playerSelectors.floatingDesktopDelaySelector); + + const floatingMobileEnabled = useSelector(playerSelectors.floatingMobileEnabledSelector); + const floatingMobileEnabledOptions = useSelector(playerSelectors.floatingMobileEnabledOptionsSelector); + const floatingMobileMode = useSelector(playerSelectors.floatingMobileModeSelector); + const floatingMobileModeOptions = useSelector(playerSelectors.floatingMobileModeOptionsSelector); + const floatingMobilePosition = useSelector(playerSelectors.floatingMobilePositionSelector); + const floatingMobilePositionOptions = useSelector(playerSelectors.floatingMobilePositionOptionsSelector); + const floatingMobileWidth = useSelector(playerSelectors.floatingMobileWidthSelector); + const floatingMobileWidthVerticalDefault = useSelector(playerSelectors.floatingMobileWidthVerticalSelector); + const floatingMobileDelay = useSelector(playerSelectors.floatingMobileDelaySelector); + const playerType = useSelector(playerSelectors.playerTypeSelector); + const scheme = useSelector(playerSelectors.schemeSelector); + + const [floatingMobileEnabledOptionsState, setFloatingMobileEnabledOptionsState] = + useState(floatingMobileEnabledOptions); + + const [floatingMobileAmpModeOptionsState, setFloatingMobileAmpModeOptionsState] = useState([ + { + value: FLOATING_DISABLED, + label: FLOATING_MODE_DISABLED_LABEL, + dependentFieldsDefaults: { + delayCloseIconEnabledMobile: DELAY_CLOSE_ICON_ENABLE_DEFAULT, + }, + }, + ]); + + const [floatingDesktopEnabledPrevious, setFloatingDesktopEnabledPrevious] = useState(1); + const [floatingMobileEnabledPrevious, setFloatingMobileEnabledPrevious] = useState(1); + + const id = parseInt(router.query.params[1], 10) || null; + const copyFromId = parseInt(router.query.copy, 10) || null; + const playerTypeId = playerType?.id; + const isIntelligent = isIntelligentType(playerTypeId); + const isStories = isStoriesTypes(playerTypeId); + const isIntelligentAmp = isIntelligentAmpType(playerTypeId); + const isVertical = isVerticalType(playerTypeId); + const isAmp = isAmpTypes(playerTypeId); + const isOutstream = isOutstreamType(playerTypeId); + + useEffect(() => { + if (isOutstream && !id) { + dispatch( + setAction({ + floatingMobileMode: floatingMobileModeOptions.find((mode) => mode.forOutstream)?.value, + floatingDesktopMode: floatingDesktopModeOptions.find((mode) => mode.forOutstream)?.value, + }), + ); + } + + if (!floatingMobileEnabledOptions.length) return; + + let filteredOptions = []; + if (isAmp) { + filteredOptions = floatingMobileEnabledOptions.filter((o) => o.forAmp); + } else if (isOutstream) { + filteredOptions = floatingMobileEnabledOptions.filter((o) => o.forOutstream); + } + + if (isAmp) { + if (!id && !copyFromId) { + dispatch(setAction({ floatingMobileEnabled: FLOATING_DISABLED })); + } + setFloatingMobileAmpModeOptionsState([...floatingMobileAmpModeOptionsState, ...filteredOptions]); + } else { + setFloatingMobileEnabledOptionsState(filteredOptions); + } + }, [floatingMobileEnabledOptions, playerTypeId, id, copyFromId]); + + useEffect(() => { + if (!id && isVertical && !copyFromId) { + dispatch( + setAction({ + floatingDesktopWidth: floatingDesktopWidthVerticalDefault, + floatingMobileWidth: floatingMobileWidthVerticalDefault, + }), + ); + } + }, [playerTypeId, floatingDesktopWidthVerticalDefault, floatingMobileWidthVerticalDefault]); + + const showDesktopSettings = !isAmp; + + const showStyle = !isStories && !isVertical && !isIntelligentAmp; + + const showPosition = !isAmp; + const showWidth = !isAmp; + const showDelay = !isAmp && !isOutstream; + + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + + {showDesktopSettings && ( + + + + + + )} + + + + + + + + {showDesktopSettings && ( + + )} + {isAmp ? ( + + ) : ( + + )} + + {showStyle && ( + + {showDesktopSettings && ( + + )} + + + )} + {showPosition && ( + + {showDesktopSettings && ( + + )} + + + )} + {showWidth && ( + + {showDesktopSettings && ( + + handleSetState({ + floatingDesktopWidth: target.value, + }) + } + onBlur={({ target }) => + handleSetState({ + floatingDesktopWidth: castToInt(target.value), + }) + } + {...getInputPropsByName(scheme, ['floatingDesktopWidth'])} + onFocus={() => dispatch(removeErrorByPropAction(['floatingDesktopWidth']))} + /> + )} + + handleSetState({ + floatingMobileWidth: target.value, + }) + } + onBlur={({ target }) => + handleSetState({ + floatingMobileWidth: castToInt(target.value), + }) + } + {...getInputPropsByName(scheme, ['floatingMobileWidth'])} + onFocus={() => dispatch(removeErrorByPropAction(['floatingMobileWidth']))} + /> + + )} + {showDelay && ( + + {showDesktopSettings && ( + handleSetState({ floatingDesktopDelay: target.value })} + onBlur={({ target }) => handleSetState({ floatingDesktopDelay: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['floatingDesktopDelay'])} + onFocus={() => dispatch(removeErrorByPropAction(['floatingDesktopDelay']))} + /> + )} + handleSetState({ floatingMobileDelay: target.value })} + onBlur={({ target }) => handleSetState({ floatingMobileDelay: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['floatingMobileDelay'])} + onFocus={() => dispatch(removeErrorByPropAction(['floatingMobileDelay']))} + /> + + )} + + {playerPreview} + + ); +} + +FloatingTab.propTypes = { + playerPreview: PropTypes.node, +}; + +export default FloatingTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..8216fad --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,262 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { REDUX_ERROR_PROP_NAME } from '@/modules/@common/Form/constants'; +import { TYPE_WARNING } from '@/modules/@common/notify/constants'; +import { NAME_MAX_LENGTH, PLAYER_WIDTH_UNITS_PERCENT } from '@/modules/players/Editor/constants'; + +import * as playerSelectors from '../../../redux/selectors'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { castToInt, isOutstreamType } from '@/modules/players/Editor/helpers'; +import { + getPublishersOptionsAction, + removeErrorByPropAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + validateSingleField, +} from '@/modules/players/Editor/redux/slices'; + +import { FormRow, FormRowItem, FormSection, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Divider, MenuItem, Select, TextField, Typography } from '@/mui/components'; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const playerType = useSelector(playerSelectors.typeSelector); + const publisher = useSelector(playerSelectors.publisherSelector); + const publishersOptions = useSelector(playerSelectors.publishersOptionsSelector); + + const publisherDomain = useSelector(playerSelectors.publisherDomainSelector); + const publisherDomainsOptions = useSelector(playerSelectors.publisherDomainsOptionsSelector); + + const comments = useSelector(playerSelectors.commentsSelector); + const alias = useSelector(playerSelectors.aliasSelector); + + const playerWidthUnitOptions = useSelector(playerSelectors.playerWidthUnitOptionsSelector); + const playerWidthUnit = useSelector(playerSelectors.playerWidthUnitSelector); + const playerWidth = useSelector(playerSelectors.playerWidthSelector); + + const playerAspectRatio = useSelector(playerSelectors.playerAspectRatioSelector); + const playerAspectRatioOptions = useSelector(playerSelectors.playerAspectRatioOptionsSelector); + + const scheme = useSelector(playerSelectors.schemeSelector); + + const [domainDropDownOpen, setDomainDropDownOpen] = useState(false); + + const id = parseInt(router.query.params[1], 10) || null; + const copyFromId = parseInt(router.query.copy, 10) || null; + + return ( + + + dispatch(getPublishersOptionsAction({ playerType }))} + onChange={(_, selectedPublisher) => { + dispatch( + setAction({ + publisher: selectedPublisher, + publisherId: selectedPublisher?.id, + publisherDomainsOptions: selectedPublisher?.publisherDomains ?? [], + ...(!selectedPublisher && { + publisherDomain: null, + publisherDomainId: 0, + playerContentOwnerFeeds: [], + useDefaultContentOwner: 0, + editorialPlaylist: null, + playerEditorialPlaylistsOptions: [], + }), + }), + ); + if (!selectedPublisher) { + dispatch(removeErrorByPropAction(['playerContentOwnerFeeds'])); + dispatch(removeErrorByPropAction(['playerFeedLanguages'])); + } + }} + onInputChange={(_, searchText) => + !id && + dispatch( + getPublishersOptionsAction({ + searchText, + playerType, + }), + ) + } + optionLabelKey="name" + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['publisher']))} + /> + )} + /> + + + dispatch(setAction({ alias: target.value }))} + {...getInputPropsByName(scheme, ['alias'])} + onFocus={() => dispatch(removeErrorByPropAction(['alias']))} + /> + + + { + setDomainDropDownOpen(false); + }} + onOpen={() => { + const validation = validateSingleField('publisher', publisher); + + if (validation[REDUX_ERROR_PROP_NAME]) { + dispatch(setErrorByPropAction([validation])); + dispatch(setActiveTabIdAction(validation.tabId)); + dispatch(setScrollToFieldNameAction(validation.fieldName)); + dispatch( + notifyAction({ + type: TYPE_WARNING, + message: 'Please select a Hub from the list', + }), + ); + } else { + setDomainDropDownOpen(true); + } + }} + onChange={(_, selectedPublisherDomain) => { + dispatch( + setAction({ + publisherDomain: selectedPublisherDomain, + publisherDomainId: selectedPublisherDomain?.id, + }), + ); + setDomainDropDownOpen(false); + }} + optionLabelKey="domain" + renderInput={(params) => ( + { + if (publisher) { + dispatch(removeErrorByPropAction(['publisherDomain'])); + } + }} + /> + )} + /> + + + + dispatch( + setAction({ + comments: target.value, + }), + ) + } + /> + + + {isOutstreamType(playerType) && ( + <> + + + + + Width + + + + dispatch( + setAction({ + playerWidth: target.value, + }), + ) + } + onBlur={({ target }) => + dispatch( + setAction({ + playerWidth: castToInt(target.value), + }), + ) + } + {...getInputPropsByName(scheme, ['playerWidth'])} + onFocus={() => dispatch(removeErrorByPropAction(['playerWidth']))} + /> + + + + + + + )} + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/LookAndFeelTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/LookAndFeelTab.jsx new file mode 100644 index 0000000..3af5dbd --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/LookAndFeelTab.jsx @@ -0,0 +1,729 @@ +import React, { useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Monitor, PhoneIphone } from '@mui/icons-material'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { + ASPECT_RATIO, + PLAYER_LOGO_INVALID_FILE_TYPE, + PLAYER_LOGO_MAX_FILE_SIZE, + PLAYER_WIDTH_UNITS_PERCENT, + VERTICAL_FULLSCREEN_TITLE_TIMELINE_OPTIONS, + VERTICAL_FULLSCREEN_TITLE_TIMELINE_VISIBILITY_ALWAYS, +} from '@/modules/players/Editor/constants'; + +import * as playerSelectors from '../../../redux/selectors'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { isValidUrl } from '@/modules/@common/helpers/string'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { + getCreateControlsOptions, + getCreateSocialFeaturesOptions, + multiSelectHandler, + setStateHandler, +} from '@/modules/players/Editor/components/Tabs/LookAndFeelTab/helpers/handlers'; +import { + castToInt, + isAmpTypes, + isIntelligentAmpType, + isIntelligentType, + isStoriesTypes, + isVerticalType, +} from '@/modules/players/Editor/helpers'; +import { + removeErrorByPropAction, + setAction, + setObjectValuesAction, + setOppositeAction, +} from '@/modules/players/Editor/redux/slices'; + +import { + FormGroup, + FormGroupTitle, + FormImageUploader, + FormRow, + FormRowItem, + FormSection, + useFormSettings, +} from '@/modules/@common/Form'; +import { + Autocomplete, + List, + ListItem, + MenuItem, + Select, + Switch, + TextField, + TextFieldColorPicker, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@/mui/components'; + +function LookAndFeelTab({ playerPreview = null }) { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const playerType = useSelector(playerSelectors.playerTypeSelector); + const playerLreLogo = useSelector(playerSelectors.playerLreLogoSelector); + + const playerWidthUnitOptions = useSelector(playerSelectors.playerWidthUnitOptionsSelector); + const playerWidthUnit = useSelector(playerSelectors.playerWidthUnitSelector); + const playerWidth = useSelector(playerSelectors.playerWidthSelector); + const playerWidthMobileUnit = useSelector(playerSelectors.playerWidthMobileUnitSelector); + const playerWidthMobile = useSelector(playerSelectors.playerWidthMobileSelector); + + const playerAspectRatio = useSelector(playerSelectors.playerAspectRatioSelector); + const playerAspectRatioOptions = useSelector(playerSelectors.playerAspectRatioOptionsSelector); + const playerDynamicAspectRatioOptions = useSelector(playerSelectors.playerDynamicAspectRatioOptionsSelector); + + const brandingLeft = useSelector(playerSelectors.brandingLeftSelector); + + const carouselEnabled = useSelector(playerSelectors.carouselEnabledSelector); + const carouselEnabledMobile = useSelector(playerSelectors.carouselEnabledMobileSelector); + + const carouselOrientation = useSelector(playerSelectors.carouselOrientationSelector); + const carouselOrientationOptions = useSelector(playerSelectors.carouselOrientationOptionsSelector); + + const brandingRight = useSelector(playerSelectors.brandingRightSelector); + const brandingRightDefault = useSelector(playerSelectors.brandingRightDefaultSelector); + + const closedCaptioning = useSelector(playerSelectors.closedCaptioningSelector); + const closedCaptioningOptions = useSelector(playerSelectors.closedCaptioningOptionsSelector); + + const verticalFullscreenTitleTimelineVisibility = useSelector( + playerSelectors.verticalFullscreenTitleTimelineVisibilitySelector, + ); + + const swipeAnimation = useSelector(playerSelectors.swipeAnimationSelector); + + const swipeAnimationMaxVisits = useSelector(playerSelectors.swipeAnimationMaxVisitsSelector); + const swipeAnimationDuration = useSelector(playerSelectors.swipeAnimationDurationSelector); + + const videoResolutionControl = useSelector(playerSelectors.videoResolutionControlSelector); + const playbackSpeedControl = useSelector(playerSelectors.playbackSpeedControlSelector); + const fullScreen = useSelector(playerSelectors.fullScreenSelector); + const chapteringMenuNavigation = useSelector(playerSelectors.chapteringMenuNavigationSelector); + const theaterMode = useSelector(playerSelectors.theaterModeSelector); + const ffRewind = useSelector(playerSelectors.ffRewindSelector); + + const socialViewButton = useSelector(playerSelectors.socialViewButtonSelector); + const socialLikeButton = useSelector(playerSelectors.socialLikeButtonSelector); + + const xRayCampaignsEnabled = useSelector(playerSelectors.xRayCampaignsEnabledSelector); + + const leftBrandingColor = useSelector(playerSelectors.leftBrandingColorSelector); + const leftBrandingSize = useSelector(playerSelectors.leftBrandingSizeSelector); + const leftBrandingSizeOptions = useSelector(playerSelectors.leftBrandingSizeOptionsSelector); + const progressBarColor = useSelector(playerSelectors.progressBarColorSelector); + const timestampTooltipColor = useSelector(playerSelectors.timestampTooltipColorSelector); + const volumeBarColor = useSelector(playerSelectors.volumeBarColorSelector); + const settingsMenuHighlightColor = useSelector(playerSelectors.settingsMenuHighlightColorSelector); + + const scheme = useSelector(playerSelectors.schemeSelector); + + const carouselValues = useMemo(() => { + const state = { carouselEnabled, carouselEnabledMobile }; + return Object.entries(state) + .filter(([, value]) => value !== null && value) + .map(([key]) => key); + }, [carouselEnabled, carouselEnabledMobile]); + + const id = parseInt(router.query.params[1], 10) || null; + const copyFromId = parseInt(router.query.copy, 10) || null; + const playerTypeId = playerType?.id; + const isAmp = isAmpTypes(playerTypeId); + const isStories = isStoriesTypes(playerTypeId); + const isIntelligentAmp = isIntelligentAmpType(playerTypeId); + const isVertical = isVerticalType(playerTypeId); + const isIntelligen = isIntelligentType(playerTypeId); + + useEffect(() => { + if (!id && !copyFromId && isVertical) { + dispatch( + setAction({ + playerAspectRatio: playerAspectRatioOptions.find((ratio) => ratio.isDefaultForVertical)?.value, + carouselEnabled: 0, + carouselEnabledMobile: 0, + }), + ); + } + }, [playerTypeId, playerAspectRatioOptions]); + + useEffect(() => { + if (!id && !copyFromId && isStories) { + dispatch( + setAction({ + brandingLeft: '', + brandingRight: '', + }), + ); + } + }, [playerTypeId, brandingRightDefault]); + + const showSocialFeatures = !isStories; + const showOnlyFullScreenControl = isStories; + const showBranding = !isStories; + const showClosedCaptions = !isStories; + const showPlayerControls = !isStories; + const showColorAndSizeCustomization = !isStories; + const showCarousel = !isStories && !isIntelligentAmp && !isVertical; + const showXRayCampaigns = !isStories && !isIntelligentAmp; + const showTheaterMode = !isIntelligentAmp && !isVertical; + const showPlayerMobileSize = isVertical || isIntelligen; + + const disabledPlayerWithUnit = isAmp; + const disabledAspectRatio = isVertical; + + const disabledCarousel = !playerAspectRatioOptions.find( + (option) => option.value === playerAspectRatio && option.withCarousel, + ); + + const handleSetState = (fieldName, value, additionalData) => + setStateHandler(fieldName, value, dispatch, additionalData); + + const handleMultiSelect = (selectedArr, options) => multiSelectHandler(selectedArr, options, dispatch); + const handleChangeColor = (fieldName, { rgb, hex }) => { + const color$ = rgb.a === 1 ? hex : `rgba(${rgb.r},${rgb.g},${rgb.b},${rgb.a})`; + handleSetState(fieldName, color$); + }; + + const controlsOptions = getCreateControlsOptions({ + ...(!showOnlyFullScreenControl && { videoResolutionControl }), + ...(!showOnlyFullScreenControl && { playbackSpeedControl }), + fullScreen, + ...(!showOnlyFullScreenControl && { chapteringMenuNavigation }), + ...(!showOnlyFullScreenControl && { ffRewind }), + ...(showTheaterMode && !showOnlyFullScreenControl && { theaterMode }), + }); + + const socialFeaturesOptions = getCreateSocialFeaturesOptions({ + socialViewButton, + socialLikeButton, + }); + + return ( + <> + + + { + const allowedFormats = ['image/jpeg', 'image/png']; + + if (!allowedFormats.includes(file.type)) { + dispatch( + showNotificationAction({ + key: PLAYER_LOGO_INVALID_FILE_TYPE, + type: TYPE_ERROR, + message: PLAYER_LOGO_INVALID_FILE_TYPE, + }), + ); + return false; + } + + const maxFileSize = 500 * 1024; // 500KB + if (file.size > maxFileSize) { + dispatch( + showNotificationAction({ + key: PLAYER_LOGO_MAX_FILE_SIZE, + type: TYPE_ERROR, + message: PLAYER_LOGO_MAX_FILE_SIZE, + }), + ); + + return false; + } + + return true; + }} + onLoad={(event, file, fileResult) => { + dispatch( + setObjectValuesAction({ + playerLreLogo: { + file: fileResult, + enabled: 1, + }, + }), + ); + }} + onError={(event, file, error) => { + dispatch( + showNotificationAction({ + key: error, + type: TYPE_ERROR, + message: error, + }), + ); + }} + onRemove={() => { + const linkShouldBeRemoved = playerLreLogo?.link && !isValidUrl(playerLreLogo?.link); + dispatch(removeErrorByPropAction(['playerLreLogo.link'])); + dispatch( + setObjectValuesAction({ + playerLreLogo: { + enabled: 0, + file: '', + ...(linkShouldBeRemoved && { link: '' }), + }, + }), + ); + }} + /> + + + + handleSetState('link', target.value?.trim(), { + playerLreLogo, + objectName: 'playerLreLogo', + }) + } + {...getInputPropsByName(scheme, ['playerLreLogo.link'], playerLreLogo?.file ? 'Required' : '')} + onFocus={() => dispatch(removeErrorByPropAction(['playerLreLogo.link']))} + /> + + + + + Width + + + + dispatch( + setAction({ + playerWidth: target.value, + }), + ) + } + onBlur={({ target }) => + dispatch( + setAction({ + playerWidth: castToInt(target.value), + }), + ) + } + {...getInputPropsByName(scheme, ['playerWidth'])} + onFocus={() => dispatch(removeErrorByPropAction(['playerWidth']))} + /> + + + {showPlayerMobileSize && ( + <> + + + + Width + + + + dispatch( + setAction({ + playerWidthMobile: target.value, + }), + ) + } + onBlur={({ target }) => + dispatch( + setAction({ + playerWidthMobile: castToInt(target.value), + }), + ) + } + {...getInputPropsByName(scheme, ['playerWidthMobile'])} + onFocus={() => dispatch(removeErrorByPropAction(['playerWidthMobile']))} + /> + + + + + + + )} + {!showPlayerMobileSize && ( + + + + )} + {showBranding && ( + + handleSetState('brandingLeft', target.value)} + /> + + )} + {showCarousel && ( + <> + + { + dispatch(setOppositeAction({ [newValue]: newValue })); + }} + value={carouselValues} + > + + + + + + + + + + + + + )} + {showBranding && ( + + handleSetState('brandingRight', target.checked ? brandingRightDefault : '')} + /> + + )} + {showClosedCaptions && ( + + + + )} + {showPlayerControls && ( + + Please note the following limitations for two features: + + + Chaptering is accessible exclusively for videos in which the feature has been activated. + + + Theater Mode is exclusively compatible with desktop devices. + + + + } + > + control.value)} + options={controlsOptions} + onChange={(_, selected) => handleMultiSelect(selected, controlsOptions)} + renderInput={(params) => } + /> + + )} + {showSocialFeatures && ( + + feature.value)} + options={socialFeaturesOptions} + onChange={(_, selected) => handleMultiSelect(selected, socialFeaturesOptions)} + renderInput={(params) => } + /> + + )} + {isVertical && ( + + + + )} + + {isVertical && ( + <> + + { + const isChecked = target.checked; + + handleSetState('swipeAnimation', target.checked); + + if (isChecked) { + if (!swipeAnimationMaxVisits) { + handleSetState('swipeAnimationMaxVisits', 3); + } + + if (!swipeAnimationDuration) { + handleSetState('swipeAnimationDuration', 5); + } + } else { + dispatch(removeErrorByPropAction(['swipeAnimationMaxVisits'])); + dispatch(removeErrorByPropAction(['swipeAnimationDuration'])); + } + }} + /> + + + {!!swipeAnimation && ( + + + handleSetState('swipeAnimationMaxVisits', target.value)} + // onChange={({ target }) => handleSetState({ + // 'swipeAnimationMaxVisits', target.value + // })} + onBlur={({ target }) => handleSetState('swipeAnimationMaxVisits', castToInt(target.value))} + {...getInputPropsByName(scheme, ['swipeAnimationMaxVisits'])} + onFocus={() => dispatch(removeErrorByPropAction(['swipeAnimationMaxVisits']))} + /> + + + + handleSetState('swipeAnimationDuration', target.value)} + onBlur={({ target }) => handleSetState('swipeAnimationDuration', castToInt(target.value))} + {...getInputPropsByName(scheme, ['swipeAnimationDuration'])} + onFocus={() => dispatch(removeErrorByPropAction(['swipeAnimationDuration']))} + /> + + + )} + + )} + + {showXRayCampaigns && ( + + handleSetState('xRayCampaignsEnabled', target.checked ? 1 : 0)} + /> + + )} + {showColorAndSizeCustomization && ( + <> + Color & Size Customization + + + + + handleChangeColor('leftBrandingColor', color)} + /> + + + handleChangeColor('progressBarColor', color)} + /> + + + handleChangeColor('timestampTooltipColor', color)} + /> + + + handleChangeColor('volumeBarColor', color)} + /> + + + handleChangeColor('settingsMenuHighlightColor', color)} + /> + + + )} + + {playerPreview} + + ); +} + +LookAndFeelTab.propTypes = { + playerPreview: PropTypes.node, +}; + +export default LookAndFeelTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/helpers/handlers.js b/anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/helpers/handlers.js new file mode 100644 index 0000000..06591ee --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/LookAndFeelTab/helpers/handlers.js @@ -0,0 +1,133 @@ +import { setAction, setObjectValuesAction } from '@/modules/players/Editor/redux/slices'; + +export const setStateHandler = (fieldName, value, dispatch, additionalData = {}) => { + let objectToUpdate = {}; + const { + objectName = '', + playerAspectRatioOptions = [], + playerDynamicAspectRatioOptions = [], + carouselOrientationOptions = [], + } = additionalData; + + if (fieldName === 'playerWidthUnit') { + objectToUpdate = { + ...objectToUpdate, + playerWidth: additionalData.playerWidthUnitOptions.find((option) => option.value === value)?.defaultWidth, + }; + } + if (fieldName === 'playerWidthMobileUnit') { + objectToUpdate = { + ...objectToUpdate, + playerWidthMobile: additionalData.playerWidthUnitOptions.find((option) => option.value === value)?.defaultWidth, + }; + } + if (fieldName === 'playerAspectRatio') { + const withoutCarousel = + !playerAspectRatioOptions.find((option) => option.value === value && option.withCarousel) || + !playerDynamicAspectRatioOptions.find((option) => option.value === value && option.withCarousel); + const isDefaultAspectRatio = !!playerAspectRatioOptions.find( + (option) => option.value === value && option.isDefault, + ); + + if (withoutCarousel) { + objectToUpdate = { + ...objectToUpdate, + carouselEnabled: 0, + carouselEnabledMobile: 0, + }; + } + if (isDefaultAspectRatio) { + const defaultCarouselOrientation = carouselOrientationOptions.find((option) => option.isDefault); + objectToUpdate = { + ...objectToUpdate, + carouselEnabled: 1, + carouselOrientation: defaultCarouselOrientation?.value, + }; + } + } + + if (objectName) { + dispatch(setObjectValuesAction({ [objectName]: { [fieldName]: value } })); + } else { + dispatch( + setAction({ + ...objectToUpdate, + [fieldName]: value, + }), + ); + } +}; + +export const getCreateControlsOptions = ({ + videoResolutionControl, + playbackSpeedControl, + fullScreen, + chapteringMenuNavigation, + theaterMode, + ffRewind, +}) => + [ + { + label: 'Resolution Control', + value: videoResolutionControl, + valueName: 'videoResolutionControl', + }, + { + label: 'Playback Speed', + value: playbackSpeedControl, + valueName: 'playbackSpeedControl', + }, + { + label: 'Full Screen', + value: fullScreen, + valueName: 'fullScreen', + }, + { + label: 'Chaptering', + value: chapteringMenuNavigation, + valueName: 'chapteringMenuNavigation', + dependentValueName: 'chapteringTimeline', + }, + { + label: 'Theater Mode', + value: theaterMode, + valueName: 'theaterMode', + }, + { + label: '10 Seconds Rewind', + value: ffRewind, + valueName: 'ffRewind', + }, + ].filter((option) => option.value !== null && option.value !== undefined); + +export const multiSelectHandler = (selectedArr, options, dispatch) => { + const newValues = selectedArr.reduce((acc, { valueName, dependentValueName }) => { + if (dependentValueName) { + acc[dependentValueName] = 1; + } + acc[valueName] = 1; + return acc; + }, {}); + + const valuesToDisable = options?.filter(({ valueName }) => !newValues[valueName]); + valuesToDisable.forEach(({ valueName, dependentValueName }) => { + if (dependentValueName) { + newValues[dependentValueName] = 0; + } + newValues[valueName] = 0; + }); + dispatch(setAction(newValues)); +}; + +export const getCreateSocialFeaturesOptions = ({ socialViewButton, socialLikeButton }) => [ + { + label: 'Views', + value: socialViewButton, + valueName: 'socialViewButton', + }, + { + label: 'Likes', + value: socialLikeButton, + valueName: 'socialLikeButton', + }, +]; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/PlaybackTab/PlaybackTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/PlaybackTab/PlaybackTab.jsx new file mode 100644 index 0000000..7f5bd85 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/PlaybackTab/PlaybackTab.jsx @@ -0,0 +1,400 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Monitor, PhoneIphone } from '@mui/icons-material'; + +import { TYPE_E } from '../../Carousel/constants'; +import { REDUX_ERROR_PROP_NAME } from '@/modules/@common/Form/constants'; +import { TYPE_WARNING } from '@/modules/@common/notify/constants'; +import { + DISABLED_FLOW_TERM_OPTION, + DISABLED_FLOW_TERM_OPTION_ID, + EDITORIAL_TYPE_DEFAULT, + EDITORIAL_TYPE_ONLY, + PLAYBACK_MODE_CLICK_TO_PLAY, + PLAYBACK_MODE_SCROLL_TO_PLAY, + PLAYBACK_TAB, +} from '@/modules/players/Editor/constants'; + +import * as playerSelectors from '../../../redux/selectors'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { castToInt, isAmpTypes, isStoriesTypes, isVerticalType } from '@/modules/players/Editor/helpers'; +import { + getPlayerEditorialPlaylistsOptionsAction, + removeErrorByPropAction, + setAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + validateSingleField, +} from '@/modules/players/Editor/redux/slices'; + +import { FormGroup, FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import Carousel from '../../Carousel/component/Carousel'; +import { Autocomplete, List, ListItem, MenuItem, Select, Stack, Switch, TextField, Tooltip } from '@/mui/components'; + +function PlaybackTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(playerSelectors.activeTabIdSelector); + const autoplayOptions = useSelector(playerSelectors.autoplayOptionsSelector); + const autoplayDesktop = useSelector(playerSelectors.autoplayDesktopSelector); + const autoplayMobile = useSelector(playerSelectors.autoplayMobileSelector); + + const startWithSoundOptions = useSelector(playerSelectors.startWithSoundOptionsSelector); + const startWithSound = useSelector(playerSelectors.startWithSoundSelector); + const startWithSoundMobile = useSelector(playerSelectors.startWithSoundMobileSelector); + + const playlistLimit = useSelector(playerSelectors.playlistLimitSelector); + + const flowTermId = useSelector(playerSelectors.flowTermIdSelector); + const flowTermsOptions = useSelector(playerSelectors.flowTermsOptionsSelector); + + const playInLoop = useSelector(playerSelectors.playInLoopSelector); + const playInLoopOptions = useSelector(playerSelectors.playInLoopOptionsSelector); + + const clipAutomaticSkipOptions = useSelector(playerSelectors.clipAutomaticSkipOptionsSelector); + const clipAutomaticSkipEnabled = useSelector(playerSelectors.clipAutomaticSkipEnabledSelector); + const clipAutomaticSkipTimeToPresent = useSelector(playerSelectors.clipAutomaticSkipTimeToPresentSelector); + const clipAutomaticSkipTimeToSkip = useSelector(playerSelectors.clipAutomaticSkipTimeToSkipSelector); + + const shufflePlaylist = useSelector(playerSelectors.shufflePlaylistSelector); + + const publisher = useSelector(playerSelectors.publisherSelector); + const playerEditorialPlaylist = useSelector(playerSelectors.playerEditorialPlaylistSelector); + const playerEditorialPlaylistsOptions = useSelector(playerSelectors.playerEditorialPlaylistsOptionsSelector); + + const playerType = useSelector(playerSelectors.playerTypeSelector); + const scheme = useSelector(playerSelectors.schemeSelector); + + const flowTermsOptionsState = useMemo(() => [...flowTermsOptions, DISABLED_FLOW_TERM_OPTION], [flowTermsOptions]); + + const [fallbackPlaylistDropDownOpen, setFallbackPlaylistDropDownOpen] = useState(false); + + const id = parseInt(router.query.params[1], 10) || null; + const copyFromId = parseInt(router.query.copy, 10) || null; + const playerTypeId = playerType?.id; + const isStories = isStoriesTypes(playerTypeId); + const isVertical = isVerticalType(playerTypeId); + const isAmp = isAmpTypes(playerTypeId); + + useEffect(() => { + if ((isStories || isVertical) && !id && !copyFromId) { + dispatch(setAction({ playInLoop: 0, loopPlaylist: 1, adsOnLoopOff: 1 })); // Enabled + } + }, [playInLoopOptions]); + + const showDesktopSettings = !isAmp; + const showStartWithSound = !isStories; + const showPlaylistType = !isStories; + const showFallbackPlaylist = !isStories; + const showShuffleVideo = !isStories; + const disabledRepeatPlaylist = isVertical; + + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + + + {showDesktopSettings && ( + + + + + + )} + + + + + + + + + {showDesktopSettings && ( + + )} + + + {showStartWithSound && ( + + {showDesktopSettings && ( + + )} + + + )} + + handleSetState({ playlistLimit: target.value })} + onBlur={({ target }) => handleSetState({ playlistLimit: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['playlistLimit'])} + onFocus={() => dispatch(removeErrorByPropAction(['playlistLimit']))} + /> + + {showPlaylistType && ( + + + + )} + + + + {showShuffleVideo && ( + + + handleSetState({ + shufflePlaylist: target.checked ? 1 : 0, + }) + } + /> + + )} + + {"Enable Automatic Skip to show ‘Next' and ‘Stay' buttons after a set ‘Wait Time’. "} + {'Buttons appear for a specified duration (’Appearance Time’) before auto-skipping to the next clip.\n'} + {"For the best ad-related user experience, enable 'True Pre-Roll' "} + {"with a minimum Timeout of 8000 ms in the 'Video ads' tab."} +
    + } + > + { + handleSetState({ + clipAutomaticSkipEnabled: target.checked ? 1 : 0, + ...(!target.checked && { + clipAutomaticSkipTimeToPresent: clipAutomaticSkipOptions.clipAutomaticSkipTimeToPresent.defaultValue, + clipAutomaticSkipTimeToSkip: clipAutomaticSkipOptions.clipAutomaticSkipTimeToSkip.defaultValue, + }), + }); + if (!target.checked) { + dispatch(removeErrorByPropAction(['clipAutomaticSkipTimeToPresent'])); + dispatch(removeErrorByPropAction(['clipAutomaticSkipTimeToSkip'])); + } + }} + /> + + {!!clipAutomaticSkipEnabled && ( + + + handleSetState({ clipAutomaticSkipTimeToPresent: target.value })} + onBlur={({ target }) => handleSetState({ clipAutomaticSkipTimeToPresent: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['clipAutomaticSkipTimeToPresent'])} + onFocus={() => dispatch(removeErrorByPropAction(['clipAutomaticSkipTimeToPresent']))} + /> + + + handleSetState({ clipAutomaticSkipTimeToSkip: target.value })} + onBlur={({ target }) => handleSetState({ clipAutomaticSkipTimeToSkip: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['clipAutomaticSkipTimeToSkip'])} + onFocus={() => dispatch(removeErrorByPropAction(['clipAutomaticSkipTimeToSkip']))} + /> + + + )} + {showFallbackPlaylist && ( + <> + + The Playlist should be pre-created in the “Studio” section. The playlist will be used in any of the + following cases: + + + “Fallback Playlist” was chosen as the Playlist method. + + + There aren’t enough relevant/recent Videos to fill the chosen carousel length. + + + + } + > + { + const validation = validateSingleField('publisher', publisher); + + if (validation[REDUX_ERROR_PROP_NAME]) { + dispatch(setErrorByPropAction([validation])); + dispatch(setActiveTabIdAction(validation.tabId)); + dispatch(setScrollToFieldNameAction(validation.fieldName)); + dispatch( + notifyAction({ + type: TYPE_WARNING, + message: 'Please select a Hub from the list', + }), + ); + } else { + setFallbackPlaylistDropDownOpen(true); + dispatch( + getPlayerEditorialPlaylistsOptionsAction({ + publisherId: publisher?.id, + searchText: '', + }), + ); + } + }} + onBlur={() => { + setFallbackPlaylistDropDownOpen(false); + }} + onChange={(_, selected) => { + handleSetState({ + editorialPlaylist: selected, + playerEditorialPlaylistId: selected?.id ?? null, + }); + setFallbackPlaylistDropDownOpen(false); + }} + onInputChange={(_, searchText) => { + if (activeTabId === PLAYBACK_TAB) { + dispatch( + getPlayerEditorialPlaylistsOptionsAction({ + publisherId: publisher?.id, + searchText, + }), + ); + } + }} + optionLabelKey="title" + optionValueKey="id" + renderInput={(params) => } + /> + + {!!playerEditorialPlaylist?.clips?.length && ( + + + + )} + + )} + + ); +} + +export default PlaybackTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.jsx b/anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.jsx new file mode 100644 index 0000000..990eb8d --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.jsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Monitor, PhoneIphone } from '@mui/icons-material'; + +import { + AD_SERVER_ENABLED_VALUES, + FLOATING_AMP_STICKY_BAR_MODE, + FLOATING_MODE_ONLY_FOR_CONFIG, + FLOATING_TAB, +} from '@/modules/players/Editor/constants'; + +import * as playerSelectors from '../../redux/selectors'; +import { + getEditorialPlaylistClipInformation, + getPlayerWidgetName, + getPlayerWidth, + getPublisherName, +} from '@/modules/players/Editor/helpers/playerPreview'; + +import PlayerIframeView from '@/modules/players/PlayerIframeView/PlayerIframeView'; +import { Stack, ToggleButton, ToggleButtonGroup } from '@/mui/components'; + +import styles from './PlayerPreviewWrapper.module.scss'; + +function PlayerPreviewWrapper() { + const id = useSelector(playerSelectors.idSelector); + const name = useSelector(playerSelectors.nameSelector); + const playerLreLogo = useSelector(playerSelectors.playerLreLogoSelector); + const publisher = useSelector(playerSelectors.publisherSelector); + const playlistLimit = useSelector(playerSelectors.playlistLimitSelector); + const playInLoop = useSelector(playerSelectors.playInLoopSelector); + const startWithSound = useSelector(playerSelectors.startWithSoundSelector); + const publisherDomainId = useSelector(playerSelectors.publisherDomainIdSelector); + const allFeeds = useSelector(playerSelectors.allFeedsSelector); + const playerEditorialPlaylist = useSelector(playerSelectors.playerEditorialPlaylistSelector); + const autoplayDesktop = useSelector(playerSelectors.autoplayDesktopSelector); + const autoplayMobile = useSelector(playerSelectors.autoplayMobileSelector); + const displayMonetizationDesktop = useSelector(playerSelectors.displayMonetizationDesktopSelector); + const displayMonetizationMobile = useSelector(playerSelectors.displayMonetizationMobileSelector); + const displayClipNotInViewPlayAdsDesktop = useSelector(playerSelectors.displayClipNotInViewPlayAdsDesktopSelector); + const displayClipNotInViewPlayAdsMobile = useSelector(playerSelectors.displayClipNotInViewPlayAdsMobileSelector); + const adIndicator = useSelector(playerSelectors.adIndicatorSelector); + const adIndicatorText = useSelector(playerSelectors.adIndicatorTextSelector); + const loopPlaylist = useSelector(playerSelectors.loopPlaylistSelector); + const adsOnLoopOff = useSelector(playerSelectors.adsOnLoopOffSelector); + const clipNotInViewPlayAdsDesktop = useSelector(playerSelectors.clipNotInViewPlayAdsDesktopSelector); + const inviewAdInterval = useSelector(playerSelectors.inviewAdIntervalSelector); + const notInviewAdInterval = useSelector(playerSelectors.notInviewAdIntervalSelector); + const maxAdsPerClipChecked = useSelector(playerSelectors.maxAdsPerClipCheckedSelector); + const maxAdsPerClip = useSelector(playerSelectors.maxAdsPerClipSelector); + const firstAdRequestDelay = useSelector(playerSelectors.firstAdRequestDelaySelector); + const clipNotInViewPlayAdsMobile = useSelector(playerSelectors.clipNotInViewPlayAdsMobileSelector); + const leftBrandingColor = useSelector(playerSelectors.leftBrandingColorSelector); + const leftBrandingSize = useSelector(playerSelectors.leftBrandingSizeSelector); + const progressBarColor = useSelector(playerSelectors.progressBarColorSelector); + const timestampTooltipColor = useSelector(playerSelectors.timestampTooltipColorSelector); + const volumeBarColor = useSelector(playerSelectors.volumeBarColorSelector); + const settingsMenuHighlightColor = useSelector(playerSelectors.settingsMenuHighlightColorSelector); + + const floatingDesktopMode = useSelector(playerSelectors.floatingDesktopModeSelector); + const floatingDesktopEnabled = useSelector(playerSelectors.floatingDesktopEnabledSelector); + + const floatingDesktopPosition = useSelector(playerSelectors.floatingDesktopPositionSelector); + const floatingDesktopWidth = useSelector(playerSelectors.floatingDesktopWidthSelector); + const floatingDesktopDelay = useSelector(playerSelectors.floatingDesktopDelaySelector); + + const floatingMobileEnabled = useSelector(playerSelectors.floatingMobileEnabledSelector); + const floatingMobileMode = useSelector(playerSelectors.floatingMobileModeSelector); + const floatingMobilePosition = useSelector(playerSelectors.floatingMobilePositionSelector); + const floatingMobileWidth = useSelector(playerSelectors.floatingMobileWidthSelector); + const floatingMobileDelay = useSelector(playerSelectors.floatingMobileDelaySelector); + + const adManagerDesktop = useSelector(playerSelectors.adManagerDesktopSelector); + const adManagerMobile = useSelector(playerSelectors.adManagerMobileSelector); + + const flowTermId = useSelector(playerSelectors.flowTermIdSelector); + const flowTermsOptions = useSelector(playerSelectors.flowTermsOptionsSelector); + const shufflePlaylist = useSelector(playerSelectors.shufflePlaylistSelector); + const playerType = useSelector(playerSelectors.playerTypeSelector); + const playerWidthUnit = useSelector(playerSelectors.playerWidthUnitSelector); + const playerWidth = useSelector(playerSelectors.playerWidthSelector); + const playerWidthMobileUnit = useSelector(playerSelectors.playerWidthMobileUnitSelector); + const playerWidthMobile = useSelector(playerSelectors.playerWidthMobileSelector); + const playerAspectRatio = useSelector(playerSelectors.playerAspectRatioSelector); + const brandingLeft = useSelector(playerSelectors.brandingLeftSelector); + const brandingRight = useSelector(playerSelectors.brandingRightSelector); + const carouselEnabled = useSelector(playerSelectors.carouselEnabledSelector); + const carouselEnabledMobile = useSelector(playerSelectors.carouselEnabledMobileSelector); + const carouselOrientation = useSelector(playerSelectors.carouselOrientationSelector); + const socialViewButton = useSelector(playerSelectors.socialViewButtonSelector); + const socialLikeButton = useSelector(playerSelectors.socialLikeButtonSelector); + + const xRayCampaignsEnabled = useSelector(playerSelectors.xRayCampaignsEnabledSelector); + const titleAndTimeline = useSelector(playerSelectors.verticalFullscreenTitleTimelineVisibilitySelector); + const swipeAnimation = useSelector(playerSelectors.swipeAnimationSelector); + const swipeAnimationMaxVisits = useSelector(playerSelectors.swipeAnimationMaxVisitsSelector); + const swipeAnimationDuration = useSelector(playerSelectors.swipeAnimationDurationSelector); + const closedCaptioning = useSelector(playerSelectors.closedCaptioningSelector); + const videoResolutionControl = useSelector(playerSelectors.videoResolutionControlSelector); + const playbackSpeedControl = useSelector(playerSelectors.playbackSpeedControlSelector); + const fullScreen = useSelector(playerSelectors.fullScreenSelector); + const chapteringMenuNavigation = useSelector(playerSelectors.chapteringMenuNavigationSelector); + const theaterMode = useSelector(playerSelectors.theaterModeSelector); + const ffRewind = useSelector(playerSelectors.ffRewindSelector); + const activeTabId = useSelector(playerSelectors.activeTabIdSelector); + const verticalFullscreenTitleTimelineVisibility = useSelector( + playerSelectors.verticalFullscreenTitleTimelineVisibilitySelector, + ); + + const [isMobileDevice, toggleDeviceState] = useState(0); + const isDynamicAspectRatio = playerAspectRatio === 'dynamic'; + return ( + + { + if (newValue !== null) { + toggleDeviceState(newValue); + } + }} + > + + + + + + + + + flow.id === flowTermId)?.term} + shuffleFallback={!!shufflePlaylist} + titleAndTimeline={titleAndTimeline} + closedCaptions={closedCaptioning} + resolutionPane={!!videoResolutionControl} + speedPane={!!playbackSpeedControl} + fullScreenButton={!!fullScreen} + chapteringMenuListEnabled={!!chapteringMenuNavigation} + theaterModeButton={!!theaterMode} + ffRewind={!!ffRewind} + socialIconsEnabled={!!socialViewButton || !!socialLikeButton} + socialViewButton={!!socialViewButton} + socialLikeButton={!!socialLikeButton} + leftBrandingColor={leftBrandingColor} + leftBrandingSize={leftBrandingSize} + progressBarColor={progressBarColor} + timestampTooltipColor={timestampTooltipColor} + volumeBarColor={volumeBarColor} + settingsMenuHighlightColor={settingsMenuHighlightColor} + verticalFullscreenTitleTimelineVisibility={verticalFullscreenTitleTimelineVisibility?.toLowerCase()} + swipeAnimation={!!swipeAnimation} + swipeAnimationMaxVisits={swipeAnimationMaxVisits} + swipeAnimationDuration={swipeAnimationDuration} + /> + + ); +} + +export default PlayerPreviewWrapper; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.module.scss b/anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.module.scss new file mode 100644 index 0000000..67350b8 --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/PlayerPreviewWrapper.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"PlayerPreviewWrapper_Wrapper__UTuCs"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.jsx b/anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.jsx new file mode 100644 index 0000000..77f70fc --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.jsx @@ -0,0 +1,478 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; + +import { VIDEO_MONETIZATION_DISABLED, VIDEO_MONETIZATION_ENABLED } from '@/modules/players/Editor/constants'; + +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { + castToInt, + isAmpTypes, + isOutstreamType, + isStoriesTypes, + isVerticalType, +} from '@/modules/players/Editor/helpers'; +import * as playerSelectors from '@/modules/players/Editor/redux/selectors'; +import { removeErrorByPropAction, setAction } from '@/modules/players/Editor/redux/slices'; + +import { FormGroup, FormGroupTitle, FormRow, FormRowItem, FormSection, useFormSettings } from '@/modules/@common/Form'; +import IntervalsList from '../../IntervalsList/IntervalsList'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './VideoAdsTab.module.scss'; + +const clipOptions = Array.from({ length: 10 }, (_, i) => { + const value = i + 1; + return { + value, + label: `${value} Clip${value > 1 ? 's' : ''}`, + }; +}); + +function VideoAdsTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const playerType = useSelector(playerSelectors.playerTypeSelector); + + const adManagerDesktop = useSelector(playerSelectors.adManagerDesktopSelector); + const fallbackDisplayMonetizationDesktop = useSelector(playerSelectors.fallbackDisplayMonetizationDesktopSelector); + + const adManagerMobile = useSelector(playerSelectors.adManagerMobileSelector); + const fallbackDisplayMonetizationMobile = useSelector(playerSelectors.fallbackDisplayMonetizationMobileSelector); + + const clipNotInViewPlayAdsDesktop = useSelector(playerSelectors.clipNotInViewPlayAdsDesktopSelector); + const clipNotInViewPlayAdsMobile = useSelector(playerSelectors.clipNotInViewPlayAdsMobileSelector); + + const maxAdsPerClipChecked = useSelector(playerSelectors.maxAdsPerClipCheckedSelector); + + const maxAdsPerClip = useSelector(playerSelectors.maxAdsPerClipSelector); + const maxAdsPerClipOptions = useSelector(playerSelectors.maxAdsPerClipOptionsSelector); + + const firstAdRequestDelay = useSelector(playerSelectors.firstAdRequestDelaySelector); + + const preRollOptions = useSelector(playerSelectors.preRollOptionsSelector); + const preRoll = useSelector(playerSelectors.preRollSelector); + const preRollWaitMs = useSelector(playerSelectors.preRollWaitMsSelector); + + const prerollOnly = useSelector(playerSelectors.prerollOnlySelector); + const prerollOnlyClipsCount = useSelector(playerSelectors.prerollOnlyClipsCountSelector); + + const adIndicator = useSelector(playerSelectors.adIndicatorSelector); + const adIndicatorText = useSelector(playerSelectors.adIndicatorTextSelector); + + const skipAdButton = useSelector(playerSelectors.skipAdButtonSelector); + const skipAdAppearAfter = useSelector(playerSelectors.skipAdAppearAfterSelector); + const skipAdAppearAfterDefault = useSelector(playerSelectors.skipAdAppearAfterDefaultSelector); + + const inviewAdInterval = useSelector(playerSelectors.inviewAdIntervalSelector); + const notInviewAdInterval = useSelector(playerSelectors.notInviewAdIntervalSelector); + const inviewAdIntervalMobile = useSelector(playerSelectors.inviewAdIntervalMobileSelector); + const notInviewAdIntervalMobile = useSelector(playerSelectors.notInviewAdIntervalMobileSelector); + + const scheme = useSelector(playerSelectors.schemeSelector); + + const showDesktopMonetization = !isAmpTypes(playerType?.id); + const showMaxAdsPerVideo = !isStoriesTypes(playerType?.id) && !isOutstreamType(playerType?.id); + const showPreRoll = !isOutstreamType(playerType?.id); + const showClipNotInViewPlayAds = !isOutstreamType(playerType?.id); + + const shouldShowPrerollOnly = isStoriesTypes(playerType?.id) || isVerticalType(playerType?.id); + + const handleSetState = (state) => dispatch(setAction(state)); + const [confirmationDialog, setConfirmationDialog] = useState(null); + const [pendingFirstAdDelay, setPendingFirstAdDelay] = useState(null); + + const handleConfirm = () => { + if (confirmationDialog?.type === 'FIRST_AD_DELAY') { + handleSetState({ firstAdRequestDelay: pendingFirstAdDelay }); + setPendingFirstAdDelay(null); + } + + if (confirmationDialog?.type === 'DISABLE_NOT_INVIEW_ADS') { + handleSetState({ + clipNotInViewPlayAdsDesktop: 0, + clipNotInViewPlayAdsMobile: 0, + }); + } + + setConfirmationDialog(null); + }; + + const handleCancel = () => { + setConfirmationDialog(null); + }; + + return ( + <> + + {showDesktopMonetization && ( + + + + handleSetState({ + adManagerDesktop: target.checked ? VIDEO_MONETIZATION_ENABLED : VIDEO_MONETIZATION_DISABLED, + fallbackDisplayMonetizationDesktop: target.checked || fallbackDisplayMonetizationDesktop, + }) + } + /> + + {adManagerDesktop === VIDEO_MONETIZATION_ENABLED && ( + + + + handleSetState({ + fallbackDisplayMonetizationDesktop: target.checked, + }) + } + /> + + + )} + + )} + + + + handleSetState({ + adManagerMobile: target.checked ? VIDEO_MONETIZATION_ENABLED : VIDEO_MONETIZATION_DISABLED, + fallbackDisplayMonetizationMobile: target.checked || fallbackDisplayMonetizationMobile, + }) + } + /> + + {adManagerMobile === VIDEO_MONETIZATION_ENABLED && ( + + + + handleSetState({ + fallbackDisplayMonetizationMobile: target.checked, + }) + } + /> + + + )} + + {showClipNotInViewPlayAds && ( + + { + if (!target.checked) { + if (clipNotInViewPlayAdsDesktop && clipNotInViewPlayAdsMobile) { + setConfirmationDialog({ + title: 'Disable non-viewable Video Ads', + primary: + 'Disabling non-viewable video ads will significantly reduce the revenue generated by this player by limiting ad delivery when the player is not in view.', + secondary: 'Please consult your dedicated Account Manager before proceeding.', + important: true, + type: 'DISABLE_NOT_INVIEW_ADS', + }); + } + } else { + handleSetState({ + clipNotInViewPlayAdsDesktop: target.checked ? 1 : 0, + clipNotInViewPlayAdsMobile: target.checked ? 1 : 0, + }); + } + }} + /> + + )} + {showMaxAdsPerVideo && ( + + + handleSetState({ + maxAdsPerClipChecked: target.checked ? 1 : 0, + }) + } + /> + + + )} + {shouldShowPrerollOnly && ( + + { + handleSetState({ + prerollOnly: target.checked, + }); + }} + /> + + + )} + {showPreRoll && ( + + { + handleSetState({ + preRoll: target.checked ? 1 : 0, + ...(!target.checked && { + preRollWaitMs: preRollOptions.preRollWaitMs.defaultValue, + }), + }); + if (!target.checked) { + dispatch(removeErrorByPropAction(['preRollWaitMs'])); + } + }} + /> + handleSetState({ preRollWaitMs: target.value })} + onBlur={({ target }) => handleSetState({ preRollWaitMs: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['preRollWaitMs'], preRoll ? 'Required' : '')} + onFocus={() => dispatch(removeErrorByPropAction(['preRollWaitMs']))} + /> + + )} + + + This setting enables you to introduce a delay, in milliseconds, for the first ad request when playing + the initial video. Use this feature if you prefer to include only midroll ads in the first video. + Examples: + + + + 0 milliseconds: Immediate ad request with the start of the video. + + + 5000 milliseconds: 5-second delay before the first ad request. + + + + } + > + { + if (!firstAdRequestDelay && target.value && target.value !== '0') { + setPendingFirstAdDelay(target.value); + setConfirmationDialog({ + title: 'Apply First Ad Request Delay', + primary: + 'Applying a first ad request delay may impact monetization, as ad requests will be delayed until the player becomes viewable. This can heavily reduce monetization for players that load below the fold.', + secondary: 'For more information, please consult your dedicated Account Manager.', + important: false, + type: 'FIRST_AD_DELAY', + }); + } else { + handleSetState({ firstAdRequestDelay: target.value }); + } + }} + onBlur={({ target }) => handleSetState({ firstAdRequestDelay: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['firstAdRequestDelay'])} + onFocus={() => dispatch(removeErrorByPropAction(['firstAdRequestDelay']))} + /> + + + + handleSetState({ + adIndicator: target.checked ? 1 : 0, + }) + } + /> + + {!!adIndicator && ( + + + + handleSetState({ + adIndicatorText: target.value, + }) + } + /> + + + )} + + { + handleSetState({ + skipAdButton: target.checked ? 1 : 0, + ...(!target.checked && { + skipAdAppearAfter: skipAdAppearAfterDefault, + }), + }); + if (!target.checked) { + dispatch(removeErrorByPropAction(['skipAdAppearAfter'])); + } + }} + /> + + {!!skipAdButton && ( + + + handleSetState({ skipAdAppearAfter: target.value })} + onBlur={({ target }) => handleSetState({ skipAdAppearAfter: castToInt(target.value) })} + {...getInputPropsByName(scheme, ['skipAdAppearAfter'])} + onFocus={() => dispatch(removeErrorByPropAction(['skipAdAppearAfter']))} + /> + + + )} + Mid-Roll Settings + +
    + +
    +
    +
    + {confirmationDialog && ( + + setConfirmationDialog(null)}>{confirmationDialog.title} + + + + {confirmationDialog.primary} + + + {confirmationDialog.secondary} + + + + + + + + + )} + + ); +} + +export default VideoAdsTab; diff --git a/anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.module.scss b/anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.module.scss new file mode 100644 index 0000000..e4ae7ee --- /dev/null +++ b/anyclip/src/modules/players/Editor/components/Tabs/VideoAdsTab/VideoAdsTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"IntervalsList":"VideoAdsTab_IntervalsList__2vENy","IntervalsList___withDesktop":"VideoAdsTab_IntervalsList___withDesktop___8wgy","InRowFormRowRoot":"VideoAdsTab_InRowFormRowRoot__RGBVx"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Editor/constants/index.js b/anyclip/src/modules/players/Editor/constants/index.js new file mode 100644 index 0000000..352576c --- /dev/null +++ b/anyclip/src/modules/players/Editor/constants/index.js @@ -0,0 +1,175 @@ +import { + TYPE_INTELLIGENT, + TYPE_INTELLIGENT_AMP, + TYPE_LIVE, + TYPE_MOBILE_SDK, + TYPE_OUTSTREAM, + TYPE_STORIES, + TYPE_STORIES_AMP, + TYPE_VERTICAL, + TYPE_WATCH, + TYPE_WATCH_INLINE, +} from '@/modules/@common/constants/playerTypes'; + +export const GENERAL_TAB = 'generalTab'; +export const LOOK_AND_FEEL_TAB = 'lookAndFeel'; +export const FLOATING_TAB = 'floating'; +export const CONTENT_FILTERS_TAB = 'contentFilters'; +export const PLAYBACK_TAB = 'playback'; +export const VIDEO_ADS_TAB = 'videoAds'; +export const DISPLAY_ADS_TAB = 'displayAds'; +export const CUSTOM_PLACEHOLDER_TAB = 'customPlaceholder'; + +export const POSITIVE_BOOST_FILTERS_VALUE_DEFAULT = 10 ** 3; +export const NEGATIVE_BOOST_FILTERS_VALUE_DEFAULT = -(10 ** 6); + +export const PLAYER_TYPES = [ + { + id: TYPE_INTELLIGENT, + templateName: 'IntelligentPlayerTemplate', + }, + { + id: TYPE_STORIES, + templateName: 'StoriesPlayerTemplate', + }, + { + id: TYPE_WATCH, + templateName: null, + }, + { + id: TYPE_INTELLIGENT_AMP, + templateName: 'IntelligentAMPPlayerTemplate', + }, + { + id: TYPE_STORIES_AMP, + templateName: 'StoriesAMPPlayerTemplate', + }, + { + id: TYPE_LIVE, + templateName: null, + }, + { + id: TYPE_MOBILE_SDK, + templateName: null, + }, + { + id: TYPE_OUTSTREAM, + templateName: 'OutstreamPlayerTemplate', + }, + { + id: TYPE_WATCH_INLINE, + templateName: null, + }, + { + id: TYPE_VERTICAL, + templateName: 'VerticalPlayerTemplate', + }, +]; + +export const PLAYER_LOGO_MAX_FILE_SIZE = 'The file size exceeds the maximum allowed limit'; +export const PLAYER_LOGO_DIMENSIONS = 'The image dimensions exceed the maximum allowed size'; +export const PLAYER_LOGO_INVALID_FILE_TYPE = 'The file type is not supported'; + +export const UNSUPPORTED_PLAYER_TYPE = 'Unsupported player type'; + +export const INACTIVATED_PLAYER = 'The player was removed'; + +export const FLOATING_DISABLED = 0; +export const FLOATING_STANDARD = 1; +export const FLOATING_AMP_STICKY_AD_BAR = 5; + +export const WIDTH_MIN = 80; +export const WIDTH_MAX = 7680; // value from MAX_INT_DB +export const FLOATING_WIDTH_MIN = 80; +export const FLOATING_WIDTH_MAX = 7680; // value from MAX_INT_DB; + +export const FLOATING_MODE_DISABLED = 'DISABLED'; + +export const FLOATING_MODE_DISABLED_LABEL = 'Disabled'; + +export const FLOATING_MODE_ONLY_FOR_CONFIG = 'only'; + +export const DELAY_CLOSE_ICON_ENABLE_DEFAULT = 1; + +export const FLOATING_PAGE_WIDTH_MODE = 4; +export const FLOATING_PAGE_WIDTH_WITH_PILLARBOXING = 6; + +export const FLOATING_AMP_STICKY_BAR_MODE = 5; + +export const PLAYER_WIDTH_UNITS_PERCENT = '%'; +export const PLAYER_WIDTH_UNITS_PIXEL = 'PX'; + +export const PLAYBACK_MODE_SCROLL_TO_PLAY = 2; +export const PLAYBACK_MODE_CLICK_TO_PLAY = 3; + +export const VIDEO_MONETIZATION_ENABLED = 1; +export const VIDEO_MONETIZATION_DISABLED = 6; + +export const CAROUSEL_ORIENTATION_HORIZONTAL = 'horizontal'; +export const CAROUSEL_ORIENTATION_VERTICAL = 'vertical'; +export const CAROUSEL_ORIENTATION_VERTICAL_TITLES = 'vertical-titles'; + +export const DISABLED_FLOW_TERM_OPTION_ID = 0; +export const DISABLED_FLOW_TERM_OPTION = { + id: DISABLED_FLOW_TERM_OPTION_ID, // API WAIT FOR NULL + term: 'Disable', + termUi: 'Disable', + termUiSelfServe: 'Disable', + ApiValue: null, +}; + +export const EDITORIAL_TYPE_ONLY = 'EDITORIAL_ONLY'; +export const EDITORIAL_TYPE_DEFAULT = 'EDITORIAL_AND_LUMINOUS'; + +export const PLAYER_PREVIEW_WIDTH_DESKTOP = 800; +export const PLAYER_PREVIEW_WIDTH_MOBILE = 500; + +export const SKIP_AD_APPEARS_AFTER_MS_MIN = 1; +export const SKIP_AD_APPEARS_AFTER_MS_MAX = 60000; + +export const ALL_FEEDS_ID = -1; +export const ALL_FEEDS_NAME = 'All'; + +export const ALL_LANGUAGES_VALUE = -1; +export const ALL_LANGUAGES_NAME = 'All'; + +export const PLAYLIST_LIMIT_MIN = 1; +export const PLAYLIST_LIMIT_MAX = 200; + +export const INTERVAL_MIN = 0; +export const INTERVAL_MAX = 999999; + +export const NAME_MIN_LENGTH = 2; +export const NAME_MAX_LENGTH = 45; + +export const AD_SERVER_ENABLED_VALUES = [1, 4, 5]; + +export const PLAYER_REDUX_FIELD_NAME = 'commonForm'; + +export const DELAY_MIN_VALUE = 0; +export const DELAY_MAX_VALUE = 999; + +export const VERTICAL_FULLSCREEN_TITLE_TIMELINE_VISIBILITY_ALWAYS = 'ALWAYS'; +export const VERTICAL_FULLSCREEN_TITLE_TIMELINE_VISIBILITY_ON_TAP = 'ON-TAP'; + +export const SWIPE_ANIMATION_MAX_VISITS = 3; +export const SWIPE_ANIMATION_DURATION = 5; + +export const VERTICAL_FULLSCREEN_TITLE_TIMELINE_OPTIONS = [ + { + label: 'Always Visible', + value: VERTICAL_FULLSCREEN_TITLE_TIMELINE_VISIBILITY_ALWAYS, + }, + { + label: 'Visible on Tap', + value: VERTICAL_FULLSCREEN_TITLE_TIMELINE_VISIBILITY_ON_TAP, + }, +]; +export const ASPECT_RATIO = { + RATIO_16_9: '16_9', + RATIO_4_3: '4_3', + RATIO_1_1: '1_1', + RATIO_9_16: '9_16', + DYNAMIC: 'dynamic', +}; +export const HUB_ADMIN_ROLE = 'hub_admin'; diff --git a/anyclip/src/modules/players/Editor/helpers/createRequestBody.js b/anyclip/src/modules/players/Editor/helpers/createRequestBody.js new file mode 100644 index 0000000..6f5ac3d --- /dev/null +++ b/anyclip/src/modules/players/Editor/helpers/createRequestBody.js @@ -0,0 +1,371 @@ +import { + TYPE_INTELLIGENT, + TYPE_INTELLIGENT_AMP, + TYPE_OUTSTREAM, + TYPE_STORIES, + TYPE_STORIES_AMP, + TYPE_VERTICAL, +} from '@/modules/@common/constants/playerTypes'; +import { + ALL_FEEDS_ID, + DISABLED_FLOW_TERM_OPTION, + FLOATING_AMP_STICKY_AD_BAR, + FLOATING_DISABLED, + VIDEO_MONETIZATION_ENABLED, +} from '@/modules/players/Editor/constants'; + +import * as selectors from '../redux/selectors'; +import { isAmpTypes } from '@/modules/players/Editor/helpers'; + +const commonPlayerFields = [ + 'type', + 'publisherId', + 'alias', + 'publisherDomainId', + 'comments', + 'playerWidthUnit', + 'playerWidth', + 'playerWidthMobileUnit', + 'playerWidthMobile', + 'playerAspectRatio', + 'floatingMobileEnabled', + 'delayCloseIconEnabledMobile', + 'inviewAdIntervalMobile', + 'notInviewAdIntervalMobile', + 'adIndicator', + 'adIndicatorText', + 'skipAdButton', + 'skipAdAppearAfter', + 'firstAdRequestDelay', + 'adManagerMobile', + 'fallbackDisplayMonetizationMobile', + 'enableVideoTargeting', + 'videoFormatByDevice', + 'customPlaceholderEnable', + 'selectors', +]; + +const playerTypesFields = { + [TYPE_INTELLIGENT]: [ + 'adManagerDesktop', + 'fallbackDisplayMonetizationDesktop', + 'adsOnLoopOff', + 'allFeeds', + 'autoplayDesktop', + 'autoplayMobile', + 'boosts', + 'brandingLeft', + 'brandingRight', + 'carouselEnabled', + 'carouselEnabledMobile', + 'carouselOrientation', + 'chapteringMenuNavigation', + 'chapteringTimeline', + 'clipAutomaticSkipEnabled', + 'clipAutomaticSkipTimeToPresent', + 'clipAutomaticSkipTimeToSkip', + 'clipNotInViewPlayAdsMobile', + 'clipNotInViewPlayAdsDesktop', + 'closedCaptioning', + 'displayClipNotInViewPlayAdsDesktop', + 'displayClipNotInViewPlayAdsMobile', + 'displayInviewAdInterval', + 'displayInviewAdIntervalMobile', + 'displayMonetizationDesktop', + 'displayMonetizationMobile', + 'displayNotInviewAdInterval', + 'displayNotInviewAdIntervalMobile', + 'editorialEnabled', + 'editorialType', + 'ffRewind', + 'floatingDesktopDelay', + 'floatingDesktopEnabled', + 'floatingDesktopMode', + 'floatingDesktopPosition', + 'floatingDesktopWidth', + 'floatingMobileDelay', + 'floatingMobileMode', + 'floatingMobilePosition', + 'floatingMobileWidth', + 'flowTermId', + 'fullScreen', + 'inviewAdInterval', + 'leftBrandingColor', + 'leftBrandingSize', + 'loopPlaylist', + 'maxAdsPerClip', + 'maxAdsPerClipChecked', + 'notInviewAdInterval', + 'playbackSpeedControl', + 'playerBrandSafeties', + 'playerContentOwnerFeeds', + 'playerEditorialPlaylistId', + 'playerFeedLanguages', + 'playerLreLogo', + 'playlistLimit', + 'preRoll', + 'preRollWaitMs', + 'progressBarColor', + 'settingsMenuHighlightColor', + 'shufflePlaylist', + 'socialLikeButton', + 'socialViewButton', + 'startWithSound', + 'startWithSoundMobile', + 'theaterMode', + 'timestampTooltipColor', + 'useDefaultContentOwner', + 'videoResolutionControl', + 'volumeBarColor', + 'xRayCampaignsEnabled', + ], + [TYPE_INTELLIGENT_AMP]: [ + 'adsOnLoopOff', + 'allFeeds', + 'autoplayMobile', + 'boosts', + 'brandingLeft', + 'brandingRight', + 'chapteringMenuNavigation', + 'chapteringTimeline', + 'clipNotInViewPlayAdsMobile', + 'clipAutomaticSkipEnabled', + 'clipAutomaticSkipTimeToPresent', + 'clipAutomaticSkipTimeToSkip', + 'closedCaptioning', + 'displayClipNotInViewPlayAdsDesktop', + 'displayClipNotInViewPlayAdsMobile', + 'displayInviewAdInterval', + 'displayInviewAdIntervalMobile', + 'displayMonetizationDesktop', + 'displayMonetizationMobile', + 'displayNotInviewAdInterval', + 'displayNotInviewAdIntervalMobile', + 'editorialEnabled', + 'editorialType', + 'ffRewind', + 'flowTermId', + 'fullScreen', + 'leftBrandingColor', + 'leftBrandingSize', + 'loopPlaylist', + 'maxAdsPerClip', + 'maxAdsPerClipChecked', + 'playbackSpeedControl', + 'playerBrandSafeties', + 'playerContentOwnerFeeds', + 'playerEditorialPlaylistId', + 'playerFeedLanguages', + 'playerLreLogo', + 'playlistLimit', + 'preRoll', + 'preRollWaitMs', + 'progressBarColor', + 'settingsMenuHighlightColor', + 'shufflePlaylist', + 'socialLikeButton', + 'socialViewButton', + 'startWithSoundMobile', + 'timestampTooltipColor', + 'useDefaultContentOwner', + 'videoResolutionControl', + 'volumeBarColor', + ], + [TYPE_STORIES]: [ + 'adManagerDesktop', + 'fallbackDisplayMonetizationDesktop', + 'adsOnLoopOff', + 'allFeeds', + 'autoplayDesktop', + 'autoplayMobile', + 'brandingLeft', + 'brandingRight', + 'clipAutomaticSkipEnabled', + 'clipAutomaticSkipTimeToPresent', + 'clipAutomaticSkipTimeToSkip', + 'clipNotInViewPlayAdsMobile', + 'clipNotInViewPlayAdsDesktop', + 'displayClipNotInViewPlayAdsDesktop', + 'displayClipNotInViewPlayAdsMobile', + 'displayInviewAdInterval', + 'displayInviewAdIntervalMobile', + 'displayMonetizationDesktop', + 'displayMonetizationMobile', + 'displayNotInviewAdInterval', + 'displayNotInviewAdIntervalMobile', + 'floatingDesktopDelay', + 'floatingDesktopEnabled', + 'floatingDesktopMode', + 'floatingDesktopPosition', + 'floatingDesktopWidth', + 'floatingMobileDelay', + 'floatingMobileMode', + 'floatingMobilePosition', + 'floatingMobileWidth', + 'inviewAdInterval', + 'loopPlaylist', + 'notInviewAdInterval', + 'playerContentOwnerFeeds', + 'playerLreLogo', + 'playlistLimit', + 'preRoll', + 'preRollWaitMs', + 'prerollOnly', + 'prerollOnlyClipsCount', + ], + [TYPE_STORIES_AMP]: [ + 'adsOnLoopOff', + 'allFeeds', + 'autoplayMobile', + 'brandingLeft', + 'brandingRight', + 'clipNotInViewPlayAdsMobile', + 'clipAutomaticSkipEnabled', + 'clipAutomaticSkipTimeToPresent', + 'clipAutomaticSkipTimeToSkip', + 'displayClipNotInViewPlayAdsDesktop', + 'displayClipNotInViewPlayAdsMobile', + 'displayInviewAdInterval', + 'displayInviewAdIntervalMobile', + 'displayMonetizationDesktop', + 'displayMonetizationMobile', + 'displayNotInviewAdInterval', + 'displayNotInviewAdIntervalMobile', + 'loopPlaylist', + 'playerContentOwnerFeeds', + 'playerLreLogo', + 'playlistLimit', + 'preRoll', + 'preRollWaitMs', + 'prerollOnly', + 'prerollOnlyClipsCount', + ], + [TYPE_VERTICAL]: [ + 'adManagerDesktop', + 'fallbackDisplayMonetizationDesktop', + 'adsOnLoopOff', + 'allFeeds', + 'autoplayDesktop', + 'autoplayMobile', + 'boosts', + 'brandingLeft', + 'brandingRight', + 'carouselEnabled', + 'carouselEnabledMobile', + 'chapteringMenuNavigation', + 'chapteringTimeline', + 'clipAutomaticSkipEnabled', + 'clipAutomaticSkipTimeToPresent', + 'clipAutomaticSkipTimeToSkip', + 'clipNotInViewPlayAdsMobile', + 'clipNotInViewPlayAdsDesktop', + 'closedCaptioning', + 'displayClipNotInViewPlayAdsDesktop', + 'displayClipNotInViewPlayAdsMobile', + 'displayInviewAdInterval', + 'displayInviewAdIntervalMobile', + 'displayMonetizationDesktop', + 'displayMonetizationMobile', + 'displayNotInviewAdInterval', + 'displayNotInviewAdIntervalMobile', + 'editorialEnabled', + 'editorialType', + 'ffRewind', + 'floatingDesktopDelay', + 'floatingDesktopEnabled', + 'floatingDesktopMode', + 'floatingDesktopPosition', + 'floatingDesktopWidth', + 'floatingMobileDelay', + 'floatingMobileMode', + 'floatingMobilePosition', + 'floatingMobileWidth', + 'flowTermId', + 'fullScreen', + 'inviewAdInterval', + 'leftBrandingColor', + 'leftBrandingSize', + 'loopPlaylist', + 'maxAdsPerClip', + 'maxAdsPerClipChecked', + 'notInviewAdInterval', + 'playbackSpeedControl', + 'playerBrandSafeties', + 'playerContentOwnerFeeds', + 'playerEditorialPlaylistId', + 'playerFeedLanguages', + 'playerLreLogo', + 'playlistLimit', + 'preRoll', + 'preRollWaitMs', + 'progressBarColor', + 'settingsMenuHighlightColor', + 'shufflePlaylist', + 'socialLikeButton', + 'socialViewButton', + 'startWithSound', + 'startWithSoundMobile', + 'timestampTooltipColor', + 'useDefaultContentOwner', + 'videoResolutionControl', + 'volumeBarColor', + 'xRayCampaignsEnabled', + 'verticalFullscreenTitleTimelineVisibility', + 'swipeAnimation', + 'swipeAnimationMaxVisits', + 'swipeAnimationDuration', + 'prerollOnly', + 'prerollOnlyClipsCount', + ], + [TYPE_OUTSTREAM]: [ + 'floatingDesktopEnabled', + 'floatingDesktopMode', + 'floatingDesktopPosition', + 'floatingDesktopWidth', + 'floatingMobileMode', + 'floatingMobilePosition', + 'floatingMobileWidth', + 'adManagerDesktop', + 'fallbackDisplayMonetizationDesktop', + 'inviewAdInterval', + 'notInviewAdInterval', + ], +}; + +const getValue = (data, field) => { + const fieldValue = data[field]; + + switch (field) { + case 'verticalFullscreenTitleTimelineVisibility': + return fieldValue || 'ALWAYS'; + case 'flowTermId': + return fieldValue === 0 ? DISABLED_FLOW_TERM_OPTION.ApiValue : fieldValue; + case 'playerContentOwnerFeeds': + return Array.isArray(fieldValue) ? fieldValue.filter((f) => f.id !== ALL_FEEDS_ID) : fieldValue; + case 'fallbackDisplayMonetizationDesktop': + return data.adManagerDesktop === VIDEO_MONETIZATION_ENABLED ? fieldValue : false; + case 'fallbackDisplayMonetizationMobile': + return data.adManagerMobile === VIDEO_MONETIZATION_ENABLED ? fieldValue : false; + case 'floatingMobileEnabled': + if (isAmpTypes(data.type) && ![FLOATING_DISABLED, FLOATING_AMP_STICKY_AD_BAR].includes(fieldValue)) { + return FLOATING_DISABLED; + } + return fieldValue; + case 'selectors': + return data.customPlaceholderEnable ? fieldValue.map((s) => s.selector) : []; + default: + return fieldValue; + } +}; + +const createPlayerRequestBody = (playerType, state) => { + const playerFields = [...commonPlayerFields, ...playerTypesFields[playerType]]; + const playerData = selectors.playerStateSelector(state); + const body = {}; + playerFields.forEach((field) => { + body[field] = getValue(playerData, field); + }); + return body; +}; + +export default createPlayerRequestBody; diff --git a/anyclip/src/modules/players/Editor/helpers/index.js b/anyclip/src/modules/players/Editor/helpers/index.js new file mode 100644 index 0000000..db111ee --- /dev/null +++ b/anyclip/src/modules/players/Editor/helpers/index.js @@ -0,0 +1,33 @@ +import { + TYPE_INTELLIGENT, + TYPE_INTELLIGENT_AMP, + TYPE_OUTSTREAM, + TYPE_STORIES, + TYPE_STORIES_AMP, + TYPE_VERTICAL, +} from '@/modules/@common/constants/playerTypes'; +import { FLOATING_PAGE_WIDTH_MODE, FLOATING_PAGE_WIDTH_WITH_PILLARBOXING } from '@/modules/players/Editor/constants'; + +export const isIntelligentType = (id) => TYPE_INTELLIGENT === id; +export const isIntelligentAmpType = (id) => TYPE_INTELLIGENT_AMP === id; +export const isVerticalType = (id) => TYPE_VERTICAL === id; +export const isStoriesTypes = (id) => [TYPE_STORIES, TYPE_STORIES_AMP].includes(id); +export const isAmpTypes = (id) => [TYPE_INTELLIGENT_AMP, TYPE_STORIES_AMP].includes(id); +export const isOutstreamType = (id) => TYPE_OUTSTREAM === id; + +export const getDefaultFloatingMobilePosition = (style, options) => { + const filterField = [FLOATING_PAGE_WIDTH_MODE, FLOATING_PAGE_WIDTH_WITH_PILLARBOXING].includes(style) + ? 'isDefaultForPageWidth' + : 'isDefault'; + return options.find((option) => option[filterField])?.value; +}; + +export const getDependentFieldsDefaultValuesObj = (value, options) => { + const option = options.find((option$) => option$.value === value); + if (option.dependentFieldsDefaults) { + return option.dependentFieldsDefaults; + } + return {}; +}; + +export const castToInt = (value) => Math.floor(+value.replace(/^0+/, '')) || 0; diff --git a/anyclip/src/modules/players/Editor/helpers/playerPreview.js b/anyclip/src/modules/players/Editor/helpers/playerPreview.js new file mode 100644 index 0000000..e44412f --- /dev/null +++ b/anyclip/src/modules/players/Editor/helpers/playerPreview.js @@ -0,0 +1,44 @@ +import { PLAYER_TYPES, PLAYER_WIDTH_UNITS_PERCENT } from '@/modules/players/Editor/constants'; + +export const getPlayerWidth = (playerWidth, playerWidthUnit) => { + const val = !Number.isNaN(parseInt(playerWidth, 10)) ? playerWidth : undefined; + + return val !== undefined && playerWidthUnit === PLAYER_WIDTH_UNITS_PERCENT + ? `${playerWidth}${PLAYER_WIDTH_UNITS_PERCENT}` + : val; +}; + +export const getEditorialPlaylistClipInformation = (playlist) => { + if (!playlist) { + return []; + } + return playlist.clips; +}; + +export const getPlayerWidgetName = (publisher, playerType, playerName) => { + if (playerName) { + return playerName; + } + if (!publisher || !playerType) { + return undefined; + } + + const playerTemplate = Object.values(PLAYER_TYPES).find((type) => type.id === playerType.id); + if (!playerTemplate) { + return undefined; + } + return publisher[playerTemplate.templateName]?.slug; +}; + +export const getPublisherName = (id, publisher, playerType) => { + if (!publisher) { + return undefined; + } + + if (id) { + return publisher.slug; + } + const playerTemplate = Object.values(PLAYER_TYPES).find((type) => type.id === playerType.id); + const publisherTemplate = publisher[playerTemplate.templateName]; + return publisherTemplate?.publisher?.slug; +}; diff --git a/anyclip/src/modules/players/Editor/helpers/validationScheme.js b/anyclip/src/modules/players/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..8f0c1ad --- /dev/null +++ b/anyclip/src/modules/players/Editor/helpers/validationScheme.js @@ -0,0 +1,329 @@ +import { MAX_INT_DB } from '@/modules/@common/constants/db'; +import { + CONTENT_FILTERS_TAB, + CUSTOM_PLACEHOLDER_TAB, + DELAY_MAX_VALUE, + DELAY_MIN_VALUE, + FLOATING_TAB, + FLOATING_WIDTH_MAX, + FLOATING_WIDTH_MIN, + GENERAL_TAB, + LOOK_AND_FEEL_TAB, + NAME_MAX_LENGTH, + NAME_MIN_LENGTH, + PLAYBACK_TAB, + PLAYLIST_LIMIT_MAX, + PLAYLIST_LIMIT_MIN, + SKIP_AD_APPEARS_AFTER_MS_MAX, + SKIP_AD_APPEARS_AFTER_MS_MIN, + VIDEO_ADS_TAB, + WIDTH_MAX, + WIDTH_MIN, +} from '@/modules/players/Editor/constants'; + +import { isInRange } from '@/modules/@common/helpers/number'; +import { isValidUrl } from '@/modules/@common/helpers/string'; +import { isVerticalType } from '@/modules/players/Editor/helpers/index'; + +export const validationScheme = [ + { + fieldName: 'publisher', + tabId: GENERAL_TAB, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + return ''; + }, + }, + { + fieldName: 'alias', + tabId: GENERAL_TAB, + validation: (title) => { + const value = title?.trim(); + + if (!value) { + return 'Field cannot be empty'; + } + if (value.length < NAME_MIN_LENGTH) { + return `Field cannot be less then ${NAME_MIN_LENGTH} symbols`; + } + if (value.length > NAME_MAX_LENGTH) { + return `Field cannot be greater then ${NAME_MAX_LENGTH} symbols`; + } + + return ''; + }, + }, + { + fieldName: 'publisherDomain', + tabId: GENERAL_TAB, + validation: (domain) => { + if (!domain) { + return 'Field cannot be empty'; + } + return ''; + }, + }, + { + fieldName: 'playerLreLogo.link', + tabId: LOOK_AND_FEEL_TAB, + validation: (url, state) => { + const hasLogo = state.playerLreLogo?.enabled; + const value = url?.trim(); + + if (hasLogo && !value) { + return 'Field cannot be empty'; + } + if (value && !isValidUrl(value)) { + return 'Wrong link format'; + } + return ''; + }, + }, + { + fieldName: 'swipeAnimationMaxVisits', + tabId: LOOK_AND_FEEL_TAB, + validation: (value, state) => { + if (isVerticalType(state.playerType?.id)) { + const min = 1; + const max = 9999; + if (!isInRange(value, min, max)) { + return `${min}-${max}`; + } + } + return ''; + }, + }, + { + fieldName: 'swipeAnimationDuration', + tabId: LOOK_AND_FEEL_TAB, + validation: (value, state) => { + if (isVerticalType(state.playerType?.id)) { + const min = 1; + const max = 99; + if (!isInRange(value, min, max)) { + return `${min}-${max}`; + } + } + return ''; + }, + }, + + { + fieldName: 'playerWidth', + tabId: GENERAL_TAB, + validation: (width) => { + if (width < WIDTH_MIN) { + return `Cannot be less than ${WIDTH_MIN}px`; + } + + if (width > WIDTH_MAX) { + return `Cannot be greater than ${WIDTH_MAX}px`; + } + + return ''; + }, + }, + { + fieldName: 'playerWidthMobile', + tabId: GENERAL_TAB, + validation: (width) => { + if (width < WIDTH_MIN) { + return `Cannot be less than ${WIDTH_MIN}px`; + } + + if (width > WIDTH_MAX) { + return `Cannot be greater than ${WIDTH_MAX}px`; + } + + return ''; + }, + }, + { + fieldName: 'floatingDesktopWidth', + tabId: FLOATING_TAB, + validation: (width) => { + if (width < FLOATING_WIDTH_MIN) { + return `Cannot be less than ${FLOATING_WIDTH_MIN}px`; + } + + if (width > FLOATING_WIDTH_MAX) { + return `Cannot be greater than ${FLOATING_WIDTH_MAX}px`; + } + + return ''; + }, + }, + { + fieldName: 'floatingMobileWidth', + tabId: FLOATING_TAB, + validation: (width) => { + if (width < FLOATING_WIDTH_MIN) { + return `Cannot be less than ${FLOATING_WIDTH_MIN}px`; + } + + if (width > FLOATING_WIDTH_MAX) { + return `Cannot be greater than ${FLOATING_WIDTH_MAX}px`; + } + + return ''; + }, + }, + { + fieldName: 'floatingDesktopDelay', + tabId: FLOATING_TAB, + validation: (value) => { + if (!value && typeof value !== 'number') { + return 'Field cannot be empty'; + } + if (value < DELAY_MIN_VALUE) { + return `Cannot be less than ${DELAY_MIN_VALUE}`; + } + if (value > DELAY_MAX_VALUE) { + return `Cannot be greater than ${DELAY_MAX_VALUE}`; + } + + return ''; + }, + }, + { + fieldName: 'floatingMobileDelay', + tabId: FLOATING_TAB, + validation: (value) => { + if (!value && typeof value !== 'number') { + return 'Field cannot be empty'; + } + if (value < DELAY_MIN_VALUE) { + return `Cannot be less than ${DELAY_MIN_VALUE}`; + } + if (value > DELAY_MAX_VALUE) { + return `Cannot be greater than ${DELAY_MAX_VALUE}`; + } + + return ''; + }, + }, + { + fieldName: 'playerContentOwnerFeeds', + tabId: CONTENT_FILTERS_TAB, + validation: (sources, state) => { + if (!state.useDefaultContentOwner && (!sources || !sources.length)) { + return 'Field cannot be empty'; + } + return ''; + }, + }, + { + fieldName: 'playerFeedLanguages', + tabId: CONTENT_FILTERS_TAB, + validation: (languages) => { + if (!languages || !languages.length) { + return 'Field cannot be empty'; + } + return ''; + }, + }, + { + fieldName: 'playlistLimit', + tabId: PLAYBACK_TAB, + validation: (width) => { + if (!isInRange(width, PLAYLIST_LIMIT_MIN, PLAYLIST_LIMIT_MAX)) { + return `${PLAYLIST_LIMIT_MIN}-${PLAYLIST_LIMIT_MAX}`; + } + return ''; + }, + }, + { + fieldName: 'clipAutomaticSkipTimeToPresent', + tabId: PLAYBACK_TAB, + validation: (width, state) => { + const min = state.clipAutomaticSkipOptions.clipAutomaticSkipTimeToPresent.minValue; + const max = state.clipAutomaticSkipOptions.clipAutomaticSkipTimeToPresent.maxValue; + if (!isInRange(width, min, max)) { + return `${min}-${max}`; + } + return ''; + }, + }, + { + fieldName: 'clipAutomaticSkipTimeToSkip', + tabId: PLAYBACK_TAB, + validation: (width, state) => { + const min = state.clipAutomaticSkipOptions.clipAutomaticSkipTimeToSkip.minValue; + const max = state.clipAutomaticSkipOptions.clipAutomaticSkipTimeToSkip.maxValue; + if (!isInRange(width, min, max)) { + return `${min}-${max}`; + } + return ''; + }, + }, + { + fieldName: 'preRollWaitMs', + tabId: VIDEO_ADS_TAB, + validation: (width, state) => { + const min = state.preRollOptions.preRollWaitMs.minValue; + const max = state.preRollOptions.preRollWaitMs.maxValue; + if (!isInRange(width, min, max)) { + return `${min}-${max}`; + } + return ''; + }, + }, + { + fieldName: 'firstAdRequestDelay', + tabId: VIDEO_ADS_TAB, + validation: (width) => { + const min = 0; + const max = MAX_INT_DB; + if (!isInRange(width, min, max)) { + return `${min}-${max}`; + } + return ''; + }, + }, + { + fieldName: 'skipAdAppearAfter', + tabId: VIDEO_ADS_TAB, + validation: (width) => { + const min = SKIP_AD_APPEARS_AFTER_MS_MIN; + const max = SKIP_AD_APPEARS_AFTER_MS_MAX; + if (!isInRange(width, min, max)) { + return `${min}-${max}`; + } + return ''; + }, + }, + { + fieldName: 'placeholderSelector', + tabId: CUSTOM_PLACEHOLDER_TAB, + validation: (value, allProps) => { + if (allProps.customPlaceholderEnable && !allProps.selectors.length) { + return 'Add at least one selector'; + } + return ''; + }, + }, + // dynamic fields + { + fieldName: 'selectors', + dynamic: true, + tabId: CUSTOM_PLACEHOLDER_TAB, + + validation: (items) => + items.reduce((acc, field) => { + const trimedValue = field.selector.trim(); + let error = ''; + if (!trimedValue) { + error = 'Field cannot be empty'; + } else if (items.filter((item) => item.id !== field.id).some((item) => item.selector.trim() === trimedValue)) { + error = 'Field has duplicate'; + } + acc[field.id] = { + selector: error, + }; + + return acc; + }, {}), + }, +]; diff --git a/anyclip/src/modules/players/Editor/redux/epics/createPlayer.js b/anyclip/src/modules/players/Editor/redux/epics/createPlayer.js new file mode 100644 index 0000000..0dcf6a2 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/createPlayer.js @@ -0,0 +1,65 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createRequestBody from '../../helpers/createRequestBody'; +import { createPlayerAction, setPlayerIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { typeSelector } from '@/modules/players/Editor/redux/selectors'; + +const query = ` + mutation CreatePlayer( + $player: ModulePlayerCreateInputType, + $copyFromId: Int + $shouldCreateTags: Boolean + ) { + createPlayer( + player: $player + copyFromId: $copyFromId + shouldCreateTags: $shouldCreateTags + ) { + id + alias + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createPlayerAction.type), + switchMap((action) => { + const { copyFromId, shouldCreateTags } = action.payload; + const actions = []; + const playerType = typeSelector(state$.value); + const player = createRequestBody(playerType, state$.value); + const stream$ = gqlRequest({ + query, + variables: { + player, + copyFromId: copyFromId || undefined, + shouldCreateTags, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/player'); + + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Player created', + }), + ), + ); + } + return concat(...actions); + }), + ); + return concat(of(setPlayerIsLoadingAction(true)), stream$, of(setPlayerIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/players/Editor/redux/epics/getPlayerBoostList.js b/anyclip/src/modules/players/Editor/redux/epics/getPlayerBoostList.js new file mode 100644 index 0000000..530d659 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/getPlayerBoostList.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { getPlayerBoostListOptionsAction, setAction, setPlayerIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPlayerBoostList( + $type: String + $searchText: String + ) { + getPlayerBoostList( + type: $type + searchText: $searchText + ) { + records{ + value + label + uid + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlayerBoostListOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actions = []; + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload.searchText ?? '', + type: action.payload.type, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + actions.push( + of( + setAction({ + boostsListOptions: response.data.getPlayerBoostList?.records ?? [], + }), + ), + ); + } + return concat(...actions); + }), + ); + return concat(of(setPlayerIsLoadingAction(true)), stream$, of(setPlayerIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/players/Editor/redux/epics/getPlayerData.js b/anyclip/src/modules/players/Editor/redux/epics/getPlayerData.js new file mode 100644 index 0000000..a55fa23 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/getPlayerData.js @@ -0,0 +1,736 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { getPlayerDataAction, setDefaultsAction, setPlayerIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const playerQuery = ` + query getPlayerById( + $id: Int! + ) { + getPlayerUiProps { + autoplayOptions { + id + term + termUi + isDefault + } + autoplayDesktop + autoplayMobile + flowTermsOptions { + id + term + termUi + termUiSelfServe + } + flowTermId + editorialEnabled + editorialType + playerFeedLanguages { + lang + name + } + feedLanguagesOptions { + lang + name + } + availableTemplatePlayers { + id + name + order + } + brandSafetiesOptions { + termId + term + termUi + } + playerWidthUnitOptions { + value + label + defaultWidth + isDefault + } + playerWidthUnit + playerWidthMobileUnit + playerAspectRatioOptions { + value + label + isDefault + withCarousel + isDefaultForVertical + } + playerDynamicAspectRatioOptions { + value + label + isDefault + withCarousel + isDefaultForVertical + } + playerAspectRatio + brandingLeft + brandingRight + brandingRightDefault + floatingDesktopModeOptions { + value + label + isDefault + forOutstream + } + floatingDesktopMode + floatingDesktopPositionOptions { + value + label + isDefault + } + floatingDesktopPosition + floatingMobileModeOptions { + value + label + isDefault + forOutstream + } + floatingMobileMode + floatingMobilePositionOptions { + value + label + isDefault + isDefaultForPageWidth + } + floatingMobilePosition + boostsTypesOptions { + value + label + isDefault + grouped + } + carouselEnabled + carouselEnabledMobile + carouselOrientationOptions { + value + label + isDefault + } + carouselOrientation + closedCaptioningOptions { + value + label + isDefault + } + closedCaptioning + videoResolutionControl + playbackSpeedControl + fullScreen + chapteringMenuNavigation + chapteringTimeline + theaterMode + ffRewind + socialViewButton + socialLikeButton + xRayCampaignsEnabled + leftBrandingColor + leftBrandingSize + leftBrandingSizeOptions + progressBarColor + timestampTooltipColor + volumeBarColor + settingsMenuHighlightColor + floatingDesktopEnabledOptions { + value + label + isDefault + } + floatingDesktopEnabled + floatingDesktopWidth + floatingDesktopWidthVertical + floatingDesktopDelay + floatingMobileEnabledOptions { + value + label + isDefault + forAmp + forOutstream + width + dependentFieldsDefaults { + delayCloseIconEnabledMobile + } + } + floatingMobileEnabled + delayCloseIconEnabledMobile + floatingMobileWidth + floatingMobileWidthVertical + floatingMobileDelay + useDefaultContentOwner + allFeeds + playerContentOwnerFeeds{ + id + name + } + startWithSoundOptions{ + value + label + } + startWithSound + startWithSoundMobile + playlistLimit + playInLoopOptions { + value + label + isDefault + } + loopPlaylist + adsOnLoopOff + verticalFullscreenTitleTimelineVisibility + swipeAnimation + swipeAnimationMaxVisits + swipeAnimationDuration + playInLoop + shufflePlaylist + clipAutomaticSkipOptions { + enabled { + defaultValue + } + clipAutomaticSkipTimeToPresent { + defaultValue + minValue + maxValue + } + clipAutomaticSkipTimeToSkip { + defaultValue + minValue + maxValue + } + } + clipAutomaticSkipEnabled + clipAutomaticSkipTimeToPresent + clipAutomaticSkipTimeToSkip + adManagerDesktop + adManagerMobile + clipNotInViewPlayAdsDesktop + clipNotInViewPlayAdsMobile + maxAdsPerClipChecked + maxAdsPerClip + maxAdsPerClipOptions + firstAdRequestDelay + preRollOptions { + enabled { + defaultValue + } + preRollWaitMs { + defaultValue + minValue + maxValue + } + } + preRoll + preRollWaitMs + adIndicator + adIndicatorText + skipAdButton + skipAdAppearAfter + skipAdAppearAfterDefault + inviewAdInterval + notInviewAdInterval + inviewAdIntervalMobile + notInviewAdIntervalMobile + displayMonetizationDesktop + displayMonetizationMobile + displayClipNotInViewPlayAdsDesktop + displayClipNotInViewPlayAdsMobile + displayInviewAdInterval + displayNotInviewAdInterval + displayInviewAdIntervalMobile + displayNotInviewAdIntervalMobile + abTest + customPlaceholderEnable + selectors { + selector + } + } + getPlayerById( + id: $id + ) { + id + type + name + status + playerType { + id + name + order + } + alias + publisherDomainId + comments + publisherId + publisher { + id + name + includePublicContent + monetization + slug + publisherDomains { + id + domain + } + publisherContentOwners { + id + contentOwnerId + } + } + playerLreLogo { + file + link + enabled + } + playerWidth + playerWidthUnit + playerWidthMobile + playerWidthMobileUnit + playerAspectRatio + brandingLeft + carouselEnabled + carouselEnabledMobile + carouselOrientation + brandingRight + closedCaptioning + videoResolutionControl + playbackSpeedControl + fullScreen + chapteringMenuNavigation + chapteringTimeline + theaterMode + ffRewind + socialViewButton + socialLikeButton + xRayCampaignsEnabled + leftBrandingColor + leftBrandingSize + progressBarColor + timestampTooltipColor + volumeBarColor + settingsMenuHighlightColor + floatingDesktopEnabled + floatingDesktopMode + floatingDesktopPosition + floatingDesktopWidth + floatingDesktopDelay + floatingMobileEnabled + delayCloseIconEnabledMobile + floatingMobileMode + floatingMobilePosition + floatingMobileWidth + floatingMobileDelay + useDefaultContentOwner + playerFeedLanguages { + id + lang + name + } + playerBrandSafeties { + id + termId + term + termUi + } + boosts { + id + type + value + score + } + allFeeds + playerContentOwnerFeeds { + id + name + description + contentOwner { + id + name + isPublic + } + playerContentOwnerFeedId + accountId + } + autoplayDesktop + autoplayMobile + startWithSound + startWithSoundMobile + playlistLimit + flowTermId + editorialEnabled + editorialType + loopPlaylist + adsOnLoopOff + verticalFullscreenTitleTimelineVisibility + swipeAnimation + swipeAnimationMaxVisits + swipeAnimationDuration + playInLoop + shufflePlaylist + clipAutomaticSkipEnabled + clipAutomaticSkipTimeToPresent + clipAutomaticSkipTimeToSkip + playerEditorialPlaylistId + editorialPlaylist { + id + title + clips { + title + mediaid + image + file + hlsFile{ + file + } + files { + width + height + file + } + images { + width + height + file + } + } + } + adManagerDesktop + fallbackDisplayMonetizationDesktop + adManagerMobile + fallbackDisplayMonetizationMobile + clipNotInViewPlayAdsDesktop + clipNotInViewPlayAdsMobile + maxAdsPerClipChecked + maxAdsPerClip + firstAdRequestDelay + preRoll + preRollWaitMs + prerollOnly + prerollOnlyClipsCount + adIndicator + adIndicatorText + skipAdButton + skipAdAppearAfter + inviewAdInterval + notInviewAdInterval + inviewAdIntervalMobile + notInviewAdIntervalMobile + displayMonetizationDesktop + displayMonetizationMobile + displayClipNotInViewPlayAdsDesktop + displayClipNotInViewPlayAdsMobile + displayInviewAdInterval + displayNotInviewAdInterval + displayInviewAdIntervalMobile + displayNotInviewAdIntervalMobile + abTest + enableVideoTargeting + videoFormatByDevice + customPlaceholderEnable + selectors { + selector + id + } + } + } +`; + +const defaultPropsQuery = ` + query getPlayerUiProps{ + getPlayerUiProps { + autoplayOptions { + id + term + termUi + isDefault + } + autoplayDesktop + autoplayMobile + flowTermsOptions { + id + term + termUi + termUiSelfServe + } + flowTermId + editorialEnabled + editorialType + feedLanguagesOptions { + lang + name + } + playerFeedLanguages { + lang + name + } + availableTemplatePlayers { + id + name + order + } + brandSafetiesOptions { + termId + term + termUi + } + playerWidthUnitOptions { + value + label + defaultWidth + isDefault + } + playerWidth + playerWidthUnit + playerWidthMobile + playerWidthMobileUnit + playerAspectRatioOptions { + value + label + isDefault + withCarousel + isDefaultForVertical + } + playerDynamicAspectRatioOptions { + value + label + isDefault + withCarousel + isDefaultForVertical + } + playerAspectRatio + brandingLeft + brandingRight + brandingRightDefault + floatingDesktopModeOptions { + value + label + isDefault + forOutstream + } + floatingDesktopMode + floatingDesktopPositionOptions { + value + label + isDefault + } + floatingDesktopPosition + floatingMobileModeOptions { + value + label + isDefault + forOutstream + } + floatingMobileMode + floatingMobilePositionOptions { + value + label + isDefault + isDefaultForPageWidth + } + floatingMobilePosition + boostsTypesOptions { + value + label + isDefault + grouped + } + carouselEnabled + carouselEnabledMobile + carouselOrientationOptions { + value + label + isDefault + } + carouselOrientation + closedCaptioningOptions { + value + label + isDefault + } + closedCaptioning + videoResolutionControl + playbackSpeedControl + fullScreen + chapteringMenuNavigation + chapteringTimeline + theaterMode + ffRewind + socialViewButton + socialLikeButton + xRayCampaignsEnabled + leftBrandingColor + leftBrandingSize + leftBrandingSizeOptions + progressBarColor + timestampTooltipColor + volumeBarColor + settingsMenuHighlightColor + floatingDesktopEnabledOptions { + value + label + isDefault + } + floatingDesktopEnabled + floatingDesktopWidth + floatingDesktopWidthVertical + floatingDesktopDelay + floatingMobileEnabledOptions { + value + label + isDefault + forAmp + forOutstream + width + dependentFieldsDefaults { + delayCloseIconEnabledMobile + } + } + floatingMobileEnabled + delayCloseIconEnabledMobile + floatingMobileWidth + floatingMobileWidthVertical + floatingMobileDelay + useDefaultContentOwner + allFeeds + playerContentOwnerFeeds{ + id + name + } + startWithSoundOptions{ + value + label + } + startWithSound + startWithSoundMobile + playlistLimit + playInLoopOptions { + value + label + isDefault + } + loopPlaylist + adsOnLoopOff + verticalFullscreenTitleTimelineVisibility + swipeAnimation + swipeAnimationMaxVisits + swipeAnimationDuration + playInLoop + shufflePlaylist + clipAutomaticSkipOptions { + enabled { + defaultValue + } + clipAutomaticSkipTimeToPresent { + defaultValue + minValue + maxValue + } + clipAutomaticSkipTimeToSkip { + defaultValue + minValue + maxValue + } + } + clipAutomaticSkipEnabled + clipAutomaticSkipTimeToPresent + clipAutomaticSkipTimeToSkip + adManagerDesktop + adManagerMobile + clipNotInViewPlayAdsDesktop + clipNotInViewPlayAdsMobile + maxAdsPerClipChecked + maxAdsPerClip + maxAdsPerClipOptions + firstAdRequestDelay + preRollOptions { + enabled { + defaultValue + } + preRollWaitMs { + defaultValue + minValue + maxValue + } + } + preRoll + preRollWaitMs + adIndicator + adIndicatorText + skipAdButton + skipAdAppearAfter + skipAdAppearAfterDefault + inviewAdInterval + notInviewAdInterval + inviewAdIntervalMobile + notInviewAdIntervalMobile + displayMonetizationDesktop + displayMonetizationMobile + displayClipNotInViewPlayAdsDesktop + displayClipNotInViewPlayAdsMobile + displayInviewAdInterval + displayNotInviewAdInterval + displayInviewAdIntervalMobile + displayNotInviewAdIntervalMobile + abTest + customPlaceholderEnable + selectors { + selector + id + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlayerDataAction.type), + switchMap((action) => { + const variables = {}; + if (action.payload.id) { + variables.id = action.payload.id; + } + const stream$ = gqlRequest({ + query: action.payload.id ? playerQuery : defaultPropsQuery, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + if (!response.errors.length) { + const { getPlayerUiProps, getPlayerById } = response.data; + + if (getPlayerById) { + if (action.payload.copy) { + getPlayerById.alias = `Copy of ${getPlayerById.alias}`; + getPlayerById.abTest = false; + } + getPlayerById.publisherDomainsOptions = getPlayerById.publisher.publisherDomains; + getPlayerById.publisherDomain = getPlayerById.publisher.publisherDomains.find( + (domain) => domain.id === getPlayerById.publisherDomainId, + ); + } + const state = { + ...getPlayerUiProps, + ...(action.payload.id && getPlayerById ? getPlayerById : {}), + }; + + actions.push(of(setDefaultsAction(state))); + } else { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: action.payload.id ? "Can't update Player" : "Can't create Player", + }), + ), + ); + } + return concat(...actions); + }), + ); + return concat(of(setPlayerIsLoadingAction(true)), stream$, of(setPlayerIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/players/Editor/redux/epics/getPlayerEditorialPlaylists.js b/anyclip/src/modules/players/Editor/redux/epics/getPlayerEditorialPlaylists.js new file mode 100644 index 0000000..6bba1de --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/getPlayerEditorialPlaylists.js @@ -0,0 +1,81 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { ROWS_PER_PAGE_DEFAULT_FILTER } from '@/modules/players/Players/constants'; + +import { getPlayerEditorialPlaylistsOptionsAction, setAction, setPlayerIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPlayerEditorialPlaylists( + $publisherId: Int + $searchText: String + $pageSize: Int + ) { + getPlayerEditorialPlaylists( + publisherId: $publisherId + searchText: $searchText + pageSize: $pageSize + ) { + records{ + id + title + clips{ + title + image + mediaid + file + files { + width + height + file + } + images { + width + height + file + } + hlsFile { + file + } + } + } + recordsTotal + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlayerEditorialPlaylistsOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actions = []; + const stream$ = gqlRequest({ + query, + variables: { + publisherId: action.payload.publisherId, + searchText: action.payload.searchText ?? '', + pageSize: ROWS_PER_PAGE_DEFAULT_FILTER, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + actions.push( + of( + setAction({ + playerEditorialPlaylistsOptions: response.data.getPlayerEditorialPlaylists?.records, + }), + ), + ); + } + return concat(...actions); + }), + ); + return concat(of(setPlayerIsLoadingAction(true)), stream$, of(setPlayerIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/players/Editor/redux/epics/getPlayerFeeds.js b/anyclip/src/modules/players/Editor/redux/epics/getPlayerFeeds.js new file mode 100644 index 0000000..3368bb8 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/getPlayerFeeds.js @@ -0,0 +1,69 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { ROWS_PER_PAGE_DEFAULT_FILTER } from '@/modules/players/Players/constants'; + +import { getPlayerFeedsOptionsAction, setAction, setPlayerIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPlayerFeedsOptions( + $pageSize: Int + $searchText: String + $publisherId: Int + $includePublicContent: Int + ) { + getPlayerFeedsOptions( + pageSize: $pageSize + searchText: $searchText + publisherId: $publisherId + includePublicContent: $includePublicContent + ) { + records { + id + name + description + accountId + contentOwner { + id + name + isPublic + } + } + recordsTotal + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPlayerFeedsOptionsAction.type), + debounce(({ payload }) => timer(payload.search?.length > 1 ? 1000 : 0)), + switchMap(({ payload }) => { + const actions = []; + const stream$ = gqlRequest({ + query, + variables: { + searchText: payload.search ?? '', + pageSize: ROWS_PER_PAGE_DEFAULT_FILTER, + publisherId: payload.publisherId, + includePublicContent: payload.includePublicContent, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + actions.push( + of( + setAction({ + playerContentOwnerFeedsOptions: response.data.getPlayerFeedsOptions.records, + }), + ), + ); + } + return concat(...actions); + }), + ); + return concat(of(setPlayerIsLoadingAction(true)), stream$, of(setPlayerIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/players/Editor/redux/epics/getPlayerPublishers.js b/anyclip/src/modules/players/Editor/redux/epics/getPlayerPublishers.js new file mode 100644 index 0000000..a46d983 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/getPlayerPublishers.js @@ -0,0 +1,113 @@ +import { ofType } from 'redux-observable'; +import { concat, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { ROWS_PER_PAGE_DEFAULT_FILTER } from '@/modules/players/Players/constants'; + +import { getPublishersOptionsAction, setAction, setPlayerIsLoadingAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPlayerPublishersOptions( + $pageSize: Int + $searchText: String + $playerType: Int + ) { + getPlayerPublishersOptions( + pageSize: $pageSize + searchText: $searchText + playerType: $playerType + ) { + records { + id + name + slug + IntelligentPlayerTemplate{ + id + slug + publisher { + id + slug + } + } + StoriesPlayerTemplate{ + id + slug + publisher { + id + slug + } + } + IntelligentAMPPlayerTemplate{ + id + slug + publisher { + id + slug + } + } + StoriesAMPPlayerTemplate{ + id + slug + publisher { + id + slug + } + } + VerticalPlayerTemplate{ + id + slug + publisher { + id + slug + } + } + includePublicContent + monetization + publisherDomains { + id + domain + } + publisherContentOwners { + id + contentOwnerId + } + } + recordsTotal + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getPublishersOptionsAction.type), + debounce((action) => { + const search = action.payload.searchText; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actions = []; + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload.searchText ?? '', + pageSize: ROWS_PER_PAGE_DEFAULT_FILTER, + playerType: action.payload.playerType, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + actions.push( + of( + setAction({ + publishersOptions: response.data.getPlayerPublishersOptions.records, + }), + ), + ); + } + return concat(...actions); + }), + ); + return concat(of(setPlayerIsLoadingAction(true)), stream$, of(setPlayerIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/players/Editor/redux/epics/index.js b/anyclip/src/modules/players/Editor/redux/epics/index.js new file mode 100644 index 0000000..12761e9 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/index.js @@ -0,0 +1,19 @@ +import { combineEpics } from 'redux-observable'; + +import createPlayer from './createPlayer'; +import getPlayerBoostList from './getPlayerBoostList'; +import getPlayerData from './getPlayerData'; +import getPlayerEditorialPlaylists from './getPlayerEditorialPlaylists'; +import getPlayerFeeds from './getPlayerFeeds'; +import getPublishersOptions from './getPlayerPublishers'; +import updatePlayer from './updatePlayer'; + +export default combineEpics( + getPlayerData, + createPlayer, + updatePlayer, + getPublishersOptions, + getPlayerFeeds, + getPlayerBoostList, + getPlayerEditorialPlaylists, +); diff --git a/anyclip/src/modules/players/Editor/redux/epics/updatePlayer.js b/anyclip/src/modules/players/Editor/redux/epics/updatePlayer.js new file mode 100644 index 0000000..7356bbf --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/epics/updatePlayer.js @@ -0,0 +1,61 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import createRequestBody from '../../helpers/createRequestBody'; +import { setPlayerIsLoadingAction, updatePlayerAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { idSelector, typeSelector } from '@/modules/players/Editor/redux/selectors'; + +const query = ` + mutation UpdatePlayer( + $id: Int! + $player: ModulePlayerCreateInputType + ) { + updatePlayer( + id: $id + player: $player + ) { + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updatePlayerAction.type), + switchMap(() => { + const actions = []; + const id = idSelector(state$.value); + const type = typeSelector(state$.value); + const player = createRequestBody(type, state$.value); + const stream$ = gqlRequest({ + query, + variables: { + id, + player, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/player'); + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Player updated', + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(of(setPlayerIsLoadingAction(true)), stream$, of(setPlayerIsLoadingAction(false))); + }), + ); diff --git a/anyclip/src/modules/players/Editor/redux/selectors/index.js b/anyclip/src/modules/players/Editor/redux/selectors/index.js new file mode 100644 index 0000000..22bcfe0 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/selectors/index.js @@ -0,0 +1,227 @@ +import { PLAYER_REDUX_FIELD_NAME } from '@/modules/players/Editor/constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +export const idSelector = (state) => state[nameSpace].id; + +export const nameSelector = (state) => state[nameSpace].name; + +export const statusSelector = (state) => state[nameSpace].status; + +export const isLoadingSelector = (state) => state[nameSpace].isLoading; + +export const abTestSelector = (state) => state[nameSpace].abTest; + +export const typeSelector = (state) => state[nameSpace].type; + +export const availableTemplatePlayersSelector = (state) => state[nameSpace].availableTemplatePlayers; + +/// GENERAL TAB /// +export const playerTypeSelector = (state) => state[nameSpace].playerType; +export const publisherSelector = (state) => state[nameSpace].publisher; +export const publishersOptionsSelector = (state) => state[nameSpace].publishersOptions; +export const publisherDomainSelector = (state) => state[nameSpace].publisherDomain; +export const publisherDomainIdSelector = (state) => state[nameSpace].publisherDomainId; +export const publisherDomainsOptionsSelector = (state) => state[nameSpace].publisherDomainsOptions; +export const commentsSelector = (state) => state[nameSpace].comments; +export const aliasSelector = (state) => state[nameSpace].alias; + +/// LOOK AND FEEL TAB /// + +export const playerLreLogoSelector = (state) => state[nameSpace].playerLreLogo; + +export const playerWidthUnitOptionsSelector = (state) => state[nameSpace].playerWidthUnitOptions; +export const playerWidthUnitSelector = (state) => state[nameSpace].playerWidthUnit; +export const playerWidthSelector = (state) => state[nameSpace].playerWidth; + +export const playerWidthMobileUnitSelector = (state) => state[nameSpace].playerWidthMobileUnit; +export const playerWidthMobileSelector = (state) => state[nameSpace].playerWidthMobile; + +export const playerAspectRatioOptionsSelector = (state) => state[nameSpace].playerAspectRatioOptions; +export const playerDynamicAspectRatioOptionsSelector = (state) => state[nameSpace].playerDynamicAspectRatioOptions; + +export const playerAspectRatioSelector = (state) => state[nameSpace].playerAspectRatio; + +export const brandingLeftSelector = (state) => state[nameSpace].brandingLeft; + +export const carouselEnabledSelector = (state) => state[nameSpace].carouselEnabled; +export const carouselEnabledMobileSelector = (state) => state[nameSpace].carouselEnabledMobile; + +export const carouselOrientationSelector = (state) => state[nameSpace].carouselOrientation; +export const carouselOrientationOptionsSelector = (state) => state[nameSpace].carouselOrientationOptions; + +export const brandingRightSelector = (state) => state[nameSpace].brandingRight; +export const brandingRightDefaultSelector = (state) => state[nameSpace].brandingRightDefault; + +export const closedCaptioningSelector = (state) => state[nameSpace].closedCaptioning; +export const verticalFullscreenTitleTimelineVisibilitySelector = (state) => + state[nameSpace].verticalFullscreenTitleTimelineVisibility; + +export const swipeAnimationSelector = (state) => state[nameSpace].swipeAnimation; +export const swipeAnimationMaxVisitsSelector = (state) => state[nameSpace].swipeAnimationMaxVisits; +export const swipeAnimationDurationSelector = (state) => state[nameSpace].swipeAnimationDuration; + +export const closedCaptioningOptionsSelector = (state) => state[nameSpace].closedCaptioningOptions; + +export const videoResolutionControlSelector = (state) => state[nameSpace].videoResolutionControl; +export const playbackSpeedControlSelector = (state) => state[nameSpace].playbackSpeedControl; +export const fullScreenSelector = (state) => state[nameSpace].fullScreen; +export const chapteringMenuNavigationSelector = (state) => state[nameSpace].chapteringMenuNavigation; +export const theaterModeSelector = (state) => state[nameSpace].theaterMode; +export const ffRewindSelector = (state) => state[nameSpace].ffRewind; + +export const socialViewButtonSelector = (state) => state[nameSpace].socialViewButton; +export const socialLikeButtonSelector = (state) => state[nameSpace].socialLikeButton; + +export const xRayCampaignsEnabledSelector = (state) => state[nameSpace].xRayCampaignsEnabled; + +export const leftBrandingColorSelector = (state) => state[nameSpace].leftBrandingColor; +export const leftBrandingSizeSelector = (state) => state[nameSpace].leftBrandingSize; +export const leftBrandingSizeOptionsSelector = (state) => state[nameSpace].leftBrandingSizeOptions; +export const progressBarColorSelector = (state) => state[nameSpace].progressBarColor; +export const timestampTooltipColorSelector = (state) => state[nameSpace].timestampTooltipColor; +export const volumeBarColorSelector = (state) => state[nameSpace].volumeBarColor; +export const settingsMenuHighlightColorSelector = (state) => state[nameSpace].settingsMenuHighlightColor; + +/// FLOATING TAB /// + +export const floatingDesktopEnabledSelector = (state) => state[nameSpace].floatingDesktopEnabled; +export const floatingDesktopEnabledOptionsSelector = (state) => state[nameSpace].floatingDesktopEnabledOptions; +export const floatingDesktopModeSelector = (state) => state[nameSpace].floatingDesktopMode; +export const floatingDesktopModeOptionsSelector = (state) => state[nameSpace].floatingDesktopModeOptions; +export const floatingDesktopPositionSelector = (state) => state[nameSpace].floatingDesktopPosition; +export const floatingDesktopPositionOptionsSelector = (state) => state[nameSpace].floatingDesktopPositionOptions; +export const floatingDesktopWidthSelector = (state) => state[nameSpace].floatingDesktopWidth; +export const floatingDesktopWidthVerticalSelector = (state) => state[nameSpace].floatingDesktopWidthVertical; +export const floatingDesktopDelaySelector = (state) => state[nameSpace].floatingDesktopDelay; + +export const floatingMobileEnabledSelector = (state) => state[nameSpace].floatingMobileEnabled; +export const floatingMobileEnabledOptionsSelector = (state) => state[nameSpace].floatingMobileEnabledOptions; +export const floatingMobileModeSelector = (state) => state[nameSpace].floatingMobileMode; +export const floatingMobileModeOptionsSelector = (state) => state[nameSpace].floatingMobileModeOptions; +export const floatingMobilePositionSelector = (state) => state[nameSpace].floatingMobilePosition; +export const floatingMobilePositionOptionsSelector = (state) => state[nameSpace].floatingMobilePositionOptions; +export const floatingMobileWidthSelector = (state) => state[nameSpace].floatingMobileWidth; +export const floatingMobileWidthVerticalSelector = (state) => state[nameSpace].floatingMobileWidthVertical; +export const floatingMobileDelaySelector = (state) => state[nameSpace].floatingMobileDelay; + +/// CONTENT FILTERS TAB /// + +export const useDefaultContentOwnerSelector = (state) => state[nameSpace].useDefaultContentOwner; + +export const playerContentOwnerFeedsSelector = (state) => state[nameSpace].playerContentOwnerFeeds; +export const playerContentOwnerFeedsOptionsSelector = (state) => state[nameSpace].playerContentOwnerFeedsOptions; +export const allFeedsSelector = (state) => state[nameSpace].allFeeds; + +export const playerFeedLanguagesSelector = (state) => state[nameSpace].playerFeedLanguages; +export const feedLanguagesOptionsSelector = (state) => state[nameSpace].feedLanguagesOptions; + +export const playerBrandSafetiesSelector = (state) => state[nameSpace].playerBrandSafeties; +export const playerBrandSafetiesOptionsSelector = (state) => state[nameSpace].brandSafetiesOptions; + +export const boostsSelector = (state) => state[nameSpace].boosts; +export const boostsTypesOptionsSelector = (state) => state[nameSpace].boostsTypesOptions; + +export const boostsListOptionsSelector = (state) => state[nameSpace].boostsListOptions; + +export const enableVideoTargetingSelector = (state) => state[nameSpace].enableVideoTargeting; +export const videoFormatByDeviceSelector = (state) => state[nameSpace].videoFormatByDevice; + +/// PLAYBACK TAB /// + +export const autoplayOptionsSelector = (state) => state[nameSpace].autoplayOptions; +export const autoplayDesktopSelector = (state) => state[nameSpace].autoplayDesktop; +export const autoplayMobileSelector = (state) => state[nameSpace].autoplayMobile; + +export const startWithSoundOptionsSelector = (state) => state[nameSpace].startWithSoundOptions; +export const startWithSoundSelector = (state) => state[nameSpace].startWithSound; +export const startWithSoundMobileSelector = (state) => state[nameSpace].startWithSoundMobile; + +export const playlistLimitSelector = (state) => state[nameSpace].playlistLimit; + +export const flowTermIdSelector = (state) => state[nameSpace].flowTermId; +export const flowTermsOptionsSelector = (state) => state[nameSpace].flowTermsOptions; + +export const playInLoopSelector = (state) => state[nameSpace].playInLoop; +export const playInLoopOptionsSelector = (state) => state[nameSpace].playInLoopOptions; + +export const loopPlaylistSelector = (state) => state[nameSpace].loopPlaylist; +export const adsOnLoopOffSelector = (state) => state[nameSpace].adsOnLoopOff; + +export const clipAutomaticSkipOptionsSelector = (state) => state[nameSpace].clipAutomaticSkipOptions; +export const clipAutomaticSkipEnabledSelector = (state) => state[nameSpace].clipAutomaticSkipEnabled; +export const clipAutomaticSkipTimeToPresentSelector = (state) => state[nameSpace].clipAutomaticSkipTimeToPresent; +export const clipAutomaticSkipTimeToSkipSelector = (state) => state[nameSpace].clipAutomaticSkipTimeToSkip; + +export const shufflePlaylistSelector = (state) => state[nameSpace].shufflePlaylist; + +export const playerEditorialPlaylistSelector = (state) => state[nameSpace].editorialPlaylist; +export const playerEditorialPlaylistsOptionsSelector = (state) => state[nameSpace].playerEditorialPlaylistsOptions; + +/// VIDEO ADS TAB /// + +export const adManagerDesktopSelector = (state) => state[nameSpace].adManagerDesktop; +export const fallbackDisplayMonetizationDesktopSelector = (state) => + state[nameSpace].fallbackDisplayMonetizationDesktop; +export const adManagerMobileSelector = (state) => state[nameSpace].adManagerMobile; +export const fallbackDisplayMonetizationMobileSelector = (state) => state[nameSpace].fallbackDisplayMonetizationMobile; + +export const clipNotInViewPlayAdsDesktopSelector = (state) => state[nameSpace].clipNotInViewPlayAdsDesktop; +export const clipNotInViewPlayAdsMobileSelector = (state) => state[nameSpace].clipNotInViewPlayAdsMobile; + +export const maxAdsPerClipCheckedSelector = (state) => state[nameSpace].maxAdsPerClipChecked; + +export const maxAdsPerClipSelector = (state) => state[nameSpace].maxAdsPerClip; +export const maxAdsPerClipOptionsSelector = (state) => state[nameSpace].maxAdsPerClipOptions; + +export const firstAdRequestDelaySelector = (state) => state[nameSpace].firstAdRequestDelay; + +export const preRollOptionsSelector = (state) => state[nameSpace].preRollOptions; +export const preRollSelector = (state) => state[nameSpace].preRoll; +export const preRollWaitMsSelector = (state) => state[nameSpace].preRollWaitMs; + +export const prerollOnlySelector = (state) => state[nameSpace].prerollOnly; +export const prerollOnlyClipsCountSelector = (state) => state[nameSpace].prerollOnlyClipsCount; + +export const adIndicatorSelector = (state) => state[nameSpace].adIndicator; +export const adIndicatorTextSelector = (state) => state[nameSpace].adIndicatorText; + +export const skipAdButtonSelector = (state) => state[nameSpace].skipAdButton; +export const skipAdAppearAfterSelector = (state) => state[nameSpace].skipAdAppearAfter; +export const skipAdAppearAfterDefaultSelector = (state) => state[nameSpace].skipAdAppearAfterDefault; + +export const inviewAdIntervalSelector = (state) => state[nameSpace].inviewAdInterval; +export const notInviewAdIntervalSelector = (state) => state[nameSpace].notInviewAdInterval; +export const inviewAdIntervalMobileSelector = (state) => state[nameSpace].inviewAdIntervalMobile; +export const notInviewAdIntervalMobileSelector = (state) => state[nameSpace].notInviewAdIntervalMobile; + +/// DISPLAY ADS TAB /// + +export const displayMonetizationDesktopSelector = (state) => state[nameSpace].displayMonetizationDesktop; +export const displayMonetizationMobileSelector = (state) => state[nameSpace].displayMonetizationMobile; + +export const displayClipNotInViewPlayAdsDesktopSelector = (state) => + state[nameSpace].displayClipNotInViewPlayAdsDesktop; +export const displayClipNotInViewPlayAdsMobileSelector = (state) => state[nameSpace].displayClipNotInViewPlayAdsMobile; + +export const displayInviewAdIntervalSelector = (state) => state[nameSpace].displayInviewAdInterval; +export const displayNotInviewAdIntervalSelector = (state) => state[nameSpace].displayNotInviewAdInterval; +export const displayInviewAdIntervalMobileSelector = (state) => state[nameSpace].displayInviewAdIntervalMobile; +export const displayNotInviewAdIntervalMobileSelector = (state) => state[nameSpace].displayNotInviewAdIntervalMobile; + +export const playerStateSelector = (state) => state[nameSpace]; + +const formSelectors = createFormSelector(PLAYER_REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; + +/// CUSTOM PLACEHOLDER TAB /// +export const customPlaceholderEnableSelector = (state) => state[nameSpace].customPlaceholderEnable; +export const selectorsSelector = (state) => state[nameSpace].selectors; diff --git a/anyclip/src/modules/players/Editor/redux/slices/index.js b/anyclip/src/modules/players/Editor/redux/slices/index.js new file mode 100644 index 0000000..582a2a2 --- /dev/null +++ b/anyclip/src/modules/players/Editor/redux/slices/index.js @@ -0,0 +1,270 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + GENERAL_TAB, + PLAYER_REDUX_FIELD_NAME, + SWIPE_ANIMATION_DURATION, + SWIPE_ANIMATION_MAX_VISITS, + VERTICAL_FULLSCREEN_TITLE_TIMELINE_VISIBILITY_ALWAYS, +} from '@/modules/players/Editor/constants'; + +import createFormSlice from '@/modules/@common/Form/redux/slices'; +import { validationScheme } from '@/modules/players/Editor/helpers/validationScheme'; + +const formSlice = createFormSlice(PLAYER_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +//TODO: Player defaults values always received from backend with getPlayerUiProps +// src/modules/players/Editor/redux/epics/getPlayerData.js + +const initialState = { + id: 0, + type: 0, + playerType: null, + name: '', + status: 1, + activeTabId: GENERAL_TAB, + availableTemplatePlayers: [], + /// GENERAL TAB /// + publisherId: 0, + publishersOptions: null, + publisher: null, + alias: '', + publisherDomainId: 0, + publisherDomainsOptions: [], + publisherDomain: null, + comments: '', + /// LOOK AND FEEL TAB /// + playerLreLogo: null, + playerWidth: 0, + playerWidthUnit: '', + playerWidthMobile: 0, + playerWidthMobileUnit: '', + playerWidthUnitOptions: [], + playerAspectRatio: '', + playerAspectRatioOptions: [], + playerDynamicAspectRatioOptions: [], + brandingLeft: '', + brandingRight: '', + brandingRightDefault: '', + carouselEnabled: 0, + carouselEnabledMobile: 0, + carouselOrientation: '', + carouselOrientationOptions: [], + closedCaptioning: '', + closedCaptioningOptions: [], + videoResolutionControl: 0, + playbackSpeedControl: 0, + fullScreen: 0, + chapteringMenuNavigation: 0, + chapteringTimeline: 0, + theaterMode: 0, + ffRewind: 0, + socialViewButton: 0, + socialLikeButton: 0, + xRayCampaignsEnabled: 0, + leftBrandingColor: '', + leftBrandingSize: 0, + leftBrandingSizeOptions: [], + verticalFullscreenTitleTimelineVisibility: VERTICAL_FULLSCREEN_TITLE_TIMELINE_VISIBILITY_ALWAYS, + swipeAnimation: true, + swipeAnimationMaxVisits: SWIPE_ANIMATION_MAX_VISITS, + swipeAnimationDuration: SWIPE_ANIMATION_DURATION, + progressBarColor: '', + timestampTooltipColor: '', + volumeBarColor: '', + settingsMenuHighlightColor: '', + /// FLOATING TAB /// + floatingDesktopEnabled: 0, + floatingDesktopEnabledOptions: [], + floatingDesktopMode: '', + floatingDesktopModeOptions: [], + floatingDesktopPosition: '', + floatingDesktopPositionOptions: [], + floatingDesktopWidth: 0, + floatingDesktopWidthVertical: 0, + floatingDesktopDelay: 0, + floatingMobileEnabled: 0, + delayCloseIconEnabledMobile: 0, + floatingMobileEnabledOptions: [], + floatingMobileMode: '', + floatingMobileModeOptions: [], + floatingMobilePosition: '', + floatingMobilePositionOptions: [], + floatingMobileWidth: 0, + floatingMobileWidthVertical: 0, + floatingMobileDelay: 0, + /// CONTENT FILTERS TAB /// + useDefaultContentOwner: 0, + allFeeds: 0, + playerContentOwnerFeeds: [], + playerContentOwnerFeedsOptions: [], + playerBrandSafeties: [], + brandSafetiesOptions: [], + playerFeedLanguages: [], + feedLanguagesOptions: [], + boosts: [], + boostsTypesOptions: [], + boostsListOptions: [], + /// PLAYBACK TAB /// + autoplayDesktop: 0, + autoplayMobile: 0, + autoplayOptions: [], + startWithSound: 0, + startWithSoundMobile: 0, + startWithSoundOptions: [], + playlistLimit: 0, + flowTermId: 0, + editorialEnabled: 0, + editorialType: '', + flowTermsOptions: [], + playInLoop: 0, // value only for UI + playInLoopOptions: [], + loopPlaylist: 0, + adsOnLoopOff: 0, + shufflePlaylist: 0, + clipAutomaticSkipOptions: null, + clipAutomaticSkipEnabled: 0, + clipAutomaticSkipTimeToPresent: 0, + clipAutomaticSkipTimeToSkip: 0, + playerEditorialPlaylistId: 0, + editorialPlaylist: null, + playerEditorialPlaylistsOptions: null, + /// VIDEO ADS TAB /// + adManagerDesktop: 0, + fallbackDisplayMonetizationDesktop: true, + adManagerMobile: 0, + fallbackDisplayMonetizationMobile: true, + clipNotInViewPlayAdsDesktop: 0, + clipNotInViewPlayAdsMobile: 0, + maxAdsPerClipChecked: 0, + maxAdsPerClip: 0, + maxAdsPerClipOptions: [], + firstAdRequestDelay: 0, + preRollOptions: null, + preRoll: 0, + preRollWaitMs: 0, + prerollOnly: false, + prerollOnlyClipsCount: 1, + adIndicator: 0, + adIndicatorText: '', + skipAdButton: 0, + skipAdAppearAfter: 1, + skipAdAppearAfterDefault: 1, + inviewAdInterval: 0, + notInviewAdInterval: 0, + inviewAdIntervalMobile: 0, + notInviewAdIntervalMobile: 0, + /// DISPLAY ADS TAB /// + displayMonetizationDesktop: 0, + displayMonetizationMobile: 0, + displayClipNotInViewPlayAdsDesktop: 0, + displayClipNotInViewPlayAdsMobile: 0, + displayInviewAdInterval: 0, + displayNotInviewAdInterval: 0, + displayInviewAdIntervalMobile: 0, + displayNotInviewAdIntervalMobile: 0, + abTest: false, + enableVideoTargeting: false, + videoFormatByDevice: false, + isLoading: true, + + /// CUSTOM PLACEHOLDER TAB /// + customPlaceholderEnable: false, + selectors: [], + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@PLAYER/EDITOR', + initialState, + reducers: { + setDefaultsAction: (state, action) => ({ ...state, ...action.payload }), + + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setObjectValuesAction: (state, action) => { + const keys = Object.keys(action.payload); + keys.forEach((key) => { + if (key) { + state[key] = { ...state[key], ...action.payload[key] }; + } + }); + }, + setOppositeAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = state[key] ? 0 : 1; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getPlayerDataAction: (state) => state, + getPublishersOptionsAction: (state) => state, + getPlayerFeedsOptionsAction: (state) => state, + getPlayerBoostListOptionsAction: (state) => state, + getPlayerEditorialPlaylistsOptionsAction: (state) => state, + createPlayerAction: (state) => state, + updatePlayerAction: (state) => state, + setPlayerIsLoadingAction: (state, action) => { + state.isLoading = action.payload; + }, + setSelectorsAction: (state, action) => { + state.selectors = action.payload; + }, + setSelectorAction: (state, action) => { + state.selectors.push(action.payload); + }, + setSelectorValuesAction: (state, action) => { + const index = state.selectors.findIndex((item) => item.id === action.payload.id); + if (index !== -1) { + state.selectors[index].selector = action.payload.selector; + } + }, + deleteSelectorAction: (state, action) => { + const index = state.selectors.findIndex((item) => item.id === action.payload.id); + if (index !== -1) { + state.selectors.slice(index, 1); + } + }, + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setDefaultsAction, + setAction, + setObjectValuesAction, + setInitialAction, + setOppositeAction, + getPlayerDataAction, + getAccountOptionsAction, + getPublishersOptionsAction, + getPlayerFeedsOptionsAction, + getPlayerBoostListOptionsAction, + getPlayerEditorialPlaylistsOptionsAction, + setPlayerIsLoadingAction, + createPlayerAction, + updatePlayerAction, + setSelectorAction, + setSelectorValuesAction, + deleteSelectorAction, + setSelectorsAction, + + setActiveTabIdAction, + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.jsx b/anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.jsx new file mode 100644 index 0000000..046a9d4 --- /dev/null +++ b/anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.jsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'clsx'; +import { stringify } from 'qs'; + +import { + DESKTOP_USER_AGENT, + EXTERNAL_PLAYER_CONFIG_KEY, + MOBILE_USER_AGENT, +} from '@/modules/players/PlayerIframeView/constants'; + +import { Paper } from '@/mui/components'; + +import styles from './PlayerIframeView.module.scss'; + +const PLAYER_PREVIEW_URL = '/player/_preview'; + +function PlayerIframeView(props) { + const key = useMemo(() => Date.now(), [JSON.stringify(props)]); + + return ( +
    + + { + Object.defineProperty( + event.target.contentWindow.navigator, + 'userAgent', + props.mobile ? { get: () => MOBILE_USER_AGENT } : { get: () => DESKTOP_USER_AGENT }, + ); + event.target.contentWindow[EXTERNAL_PLAYER_CONFIG_KEY] = props; + + if (event.target.contentWindow.location.pathname !== PLAYER_PREVIEW_URL) { + window.location.reload(); + } + }} + /> + +
    + ); +} + +PlayerIframeView.propTypes = { + publisherName: PropTypes.string.isRequired, + widgetName: PropTypes.string.isRequired, + mobile: PropTypes.bool.isRequired, +}; + +export default PlayerIframeView; diff --git a/anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.module.scss b/anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.module.scss new file mode 100644 index 0000000..b973c28 --- /dev/null +++ b/anyclip/src/modules/players/PlayerIframeView/PlayerIframeView.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"PlayerIframeView_Wrapper__G3zmH","Wrapper___mobile":"PlayerIframeView_Wrapper___mobile__rmO69","IframeWrapper":"PlayerIframeView_IframeWrapper__VFw1s","IframeWrapper___mobile":"PlayerIframeView_IframeWrapper___mobile__mPRh9","Iframe":"PlayerIframeView_Iframe__ZPjZh"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.jsx b/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.jsx new file mode 100644 index 0000000..3c3df3d --- /dev/null +++ b/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.jsx @@ -0,0 +1,251 @@ +import React, { useEffect, useState } from 'react'; +import className from 'clsx'; +import { useRouter } from 'next/router'; + +import { + EXTERNAL_PLAYER_CONFIG_KEY, + MAP_CLOSED_CAPTIONS, + MAP_MOBILE_FLOATING_THEME, +} from '@/modules/players/PlayerIframeView/constants'; + +import { + getPlayerConfigCdnEndpoint, + getPlayerEndpoint, + getPlaylistApiEndpoint, +} from '@/modules/@common/PlayerWidget/helpers'; + +import DesktopWrapper from './components/DesktopWrapper/DesktopWrapper'; +import MobileWrapper from './components/MobileWrapper/MobileWrapper'; +import { Skeleton } from '@/mui/components'; + +import styles from './PlayerPreview.module.scss'; + +const playerContainerId = 'player-container'; + +function PlayerPreview() { + const [config, setConfig] = useState({}); + const router = useRouter(); + + const options = router.query; + + const ITEM_HEIGHT = options.mobile ? 44 : 32; + + const Wrapper = options.mobile === 'true' ? MobileWrapper : DesktopWrapper; + const shouldSetPlayerAdjustWidth = + (options.mobile === 'true' && + config?.playerWidth_mobile === '100%' && + (config?.aspectratio_mobile === '9:16' || config?.aspectratio === '9:16')) || + (options.mobile === 'false' && config?.playerWidth === '100%' && config?.aspectratio === '9:16'); + + const addConfigConstraints = () => { + const { ac_lre_conf: conf } = window; + const externalConf = window[EXTERNAL_PLAYER_CONFIG_KEY]; + + return { + ...conf, + lre_playerLogo: { + file: externalConf.logo, + link: '', + enabled: !!externalConf.logo, + }, + playerWidth: /%/.test(externalConf.playerWidth) + ? externalConf.playerWidth + : parseInt(externalConf.playerWidth, 10), + playerWidth_mobile: /%/.test(externalConf.playerWidthMobile) + ? externalConf.playerWidthMobile + : parseInt(externalConf.playerWidthMobile, 10), + playlistLimit: externalConf.playlistLimit, + loopPlaylist: externalConf.loopPlaylist, + startWithSound: externalConf.startWithSound, + publisherDomainId: externalConf.publisherDomainId, + selfServe: externalConf.selfServe, + allFeeds: externalConf.allFeeds, + verticalFullscreenTitleTimelineVisibility: externalConf.verticalFullscreenTitleTimelineVisibility, + swipeAnimation: externalConf.swipeAnimation, + swipeAnimationMaxVisits: parseInt(externalConf.swipeAnimationMaxVisits, 10), + swipeAnimationDuration: parseInt(externalConf.swipeAnimationDuration, 10), + fallbackPlaylist: externalConf.fallbackPlaylist, + 'auto-play-desktop': externalConf.autoplayDesktop, + 'auto-play-mobile': externalConf.autoplayMobile, + displayAds: { + ...conf.displayAds, + enabled: externalConf.displayAdsEnabled, + playAdsNotInView: externalConf.displayAdsPlayAdsNotInView, + maxAdsPerClip: -1, + }, + displayAdsForMobile: { + ...conf.displayAdsForMobile, + enabled: externalConf.displayAdsForMobileEnabled, + playAdsNotInView: externalConf.displayAdsForMobilePlayAdsNotInView, + maxAdsPerClip: -1, + }, + lre_imaAds: { + ...conf.lre_imaAds, + adIndicator: externalConf.adIndicator, + adIndicatorText: externalConf.adIndicatorText, + playAdsOnLoopEnd: externalConf.playAdsOnLoopEnd, + playAdsNotInView: externalConf.clipNotInViewPlayAdsDesktop, + inviewAdInterval: externalConf.inviewAdInterval, + notInviewAdInterval: externalConf.notInviewAdInterval, + maxAdsPerClip: externalConf.maxAdsPerClip, + adRequestDelay: externalConf.firstAdRequestDelay, + }, + lre_imaAdsForMobile: { + ...conf.lre_imaAdsForMobile, + adIndicator: externalConf.adIndicator, + adIndicatorText: externalConf.adIndicatorText, + playAdsOnLoopEnd: externalConf.playAdsOnLoopEnd, + playAdsNotInView: externalConf.clipNotInViewPlayAdsMobile, + maxAdsPerClip: externalConf.maxAdsPerClip, + adRequestDelay: externalConf.firstAdRequestDelay, + }, + aspectratio: externalConf.aspectratio, + aspectratio_mobile: externalConf.aspectratioMobile, + branding: { + ...conf.branding, + enabled: !!externalConf.brandingLeftText || !!externalConf.brandingRigthText, + leftText: externalConf.brandingLeftText || '', // Player Title + rightText: externalConf.brandingRigthText || '', // Powered by AnyClip + }, + // Player Controls + advSettingsControl: { + ...conf.advSettingsControl, + resolutionPane: !!externalConf.resolutionPane, // Resolution Control + speedPane: !!externalConf.speedPane, // Playback Speed + }, + fullScreenButton: !!externalConf.fullScreenButton, // Full Screen + chaptering: { + ...conf.chaptering, + menuListEnabled: !!externalConf.chapteringMenuListEnabled, // Chaptering + }, + theaterModeButton: externalConf.theaterModeButton, // Theater Mode + ffRewindDesktop: { + ...conf.ffRewindDesktop, + enabled: !!externalConf.ffRewind, // 10 Seconds Rewind + }, + ffRewindMobile: { + ...conf.ffRewindMobile, + enabled: !!externalConf.ffRewind, // 10 Seconds Rewind + }, + // Color & Size Customization + customUI: { + ...conf.customUI, + leftBrandingColor: externalConf.leftBrandingColor, + leftBrandingSize: `${externalConf.leftBrandingSize}px`, + progressBarColor: externalConf.progressBarColor, + timestampTooltipColor: externalConf.timestampTooltipColor, + volumeBarColor: externalConf.volumeBarColor, + settingsMenuHighlightColor: externalConf.settingsMenuHighlightColor, + }, + // Floating + floatingDesktop: { + ...conf.floatingDesktop, + closeButtonDelay: -1, // Don't show close button + floatingDelay: externalConf.floatingDesktopDelay, + floatingEnabled: externalConf.floatingDesktopEnabled, + floatingMode: externalConf.floatingDesktopMode, + floatingPosition: externalConf.floatingDesktopPosition, + floatingReopenWait: 0, + floatingWidth: externalConf.floatingDesktopWidth, + }, + floatingMobile: { + ...conf.floatingMobile, + closeButtonDelay: -1, // Don't show close button + floatingDelay: externalConf.floatingMobileDelay, + floatingEnabled: externalConf.floatingMobileEnabled, + floatingMargin: 0, + floatingMode: externalConf.floatingMobileMode, + floatingPosition: externalConf.floatingMobilePosition, + floatingReopenWait: 0, + floatingWidth: externalConf.floatingMobileWidth, + floatingTheme: + MAP_MOBILE_FLOATING_THEME[ + typeof externalConf.floatingMobileEnabled === 'number' + ? externalConf.floatingMobileEnabled + : externalConf.floatingMobileEnabledNumber + ], + }, + ampStickyBarMode: externalConf.ampStickyBarMode, + lre_carouselEnabled: externalConf.carouselEnabled, + lre_carouselEnabledMobile: externalConf.carouselEnabledMobile, + carousel: { + ...conf.carousel, + type: externalConf.carouselOrientation, + }, + xRayCampaignsEnabled: externalConf.xRayCampaignsEnabled, + adServerDesktop: { + ...conf.adServerDesktop, + enabled: externalConf.adServerDesktopEnabled, + }, + adServerMobile: { + ...conf.adServerMobile, + enabled: externalConf.adServerMobileEnabled, + }, + lre_playlistType: externalConf.playlistType, + shuffleFallback: externalConf.shuffleFallback, + closedCaptions: { + ...conf.closedCaptions, + ...(externalConf.closedCaptions ? { state: MAP_CLOSED_CAPTIONS[externalConf.closedCaptions] } : {}), + }, + socialIconsDesktop: { + ...conf.socialIconsDesktop, + enabled: !!externalConf.socialLikeButton || !!externalConf.socialViewButton, + likeButton: !!externalConf.socialLikeButton, + viewButton: !!externalConf.socialViewButton, + }, + socialIconsMobile: { + ...conf.socialIconsMobile, + enabled: !!externalConf.socialLikeButton || !!externalConf.socialViewButton, + likeButton: !!externalConf.socialLikeButton, + viewButton: !!externalConf.socialViewButton, + }, + lre_playlistApiEndpoint: getPlaylistApiEndpoint(), + }; + }; + + useEffect(() => { + const playerContainer = document.getElementById(playerContainerId); + + if (window[EXTERNAL_PLAYER_CONFIG_KEY]) { + const script$ = document.createElement('script'); + script$.src = getPlayerConfigCdnEndpoint(options.publisherName, options.widgetName); + + script$.addEventListener('load', () => { + const configOverride = addConfigConstraints(); + window.ac_lre_conf_override = configOverride; + setConfig(configOverride); + + const script = document.createElement('script'); + + script.setAttribute('pubname', options.publisherName); + script.setAttribute('widgetname', options.widgetName); + script.setAttribute('data-ads', 'false'); + + script.src = getPlayerEndpoint(); + + playerContainer.appendChild(script); + }); + + playerContainer.appendChild(script$); + } + + return () => { + playerContainer.innerHTML = ''; + }; + }, []); + + return ( + + +
    + + + + + ); +} + +export default PlayerPreview; diff --git a/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.module.scss b/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.module.scss new file mode 100644 index 0000000..a01f015 --- /dev/null +++ b/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/PlayerPreview.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"PlayerPreview_Wrapper__G4rm_","Wrapper___playerAdjustWidth":"PlayerPreview_Wrapper___playerAdjustWidth__UEotp"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/components/DesktopWrapper/DesktopWrapper.jsx b/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/components/DesktopWrapper/DesktopWrapper.jsx new file mode 100644 index 0000000..887b456 --- /dev/null +++ b/anyclip/src/modules/players/PlayerIframeView/components/PlayerPreview/components/DesktopWrapper/DesktopWrapper.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, Skeleton, Stack } from '@/mui/components'; + +import styles from './DesktopWrapper.module.scss'; + +function DesktopWrapper(props) { + return ( + + + + setAnchorEl(null)}> + {typesOptions?.map((typeOption) => ( + { + dispatch(setAction({ newPlayerType: typeOption })); + router.push(`/player/${typeOption.id}/new`); + }} + > + {typeOption.name} + + ))} + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/players/Players/components/Empty/Empty.module.scss b/anyclip/src/modules/players/Players/components/Empty/Empty.module.scss new file mode 100644 index 0000000..4c3c40b --- /dev/null +++ b/anyclip/src/modules/players/Players/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__Q88Pu","EmptyContent":"Empty_EmptyContent__10Wxp"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Players/components/Players.jsx b/anyclip/src/modules/players/Players/components/Players.jsx new file mode 100644 index 0000000..2c93568 --- /dev/null +++ b/anyclip/src/modules/players/Players/components/Players.jsx @@ -0,0 +1,419 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; +import { + AddRounded, + CodeRounded, + ContentCopyRounded, + DeleteRounded, + FilterAltRounded, + SearchRounded, +} from '@mui/icons-material'; + +import { + ALL_TYPE, + ASPECT_RATIOS, + EMBED_CODE_TYPES, + PLAYER_TYPES, + SEARCH_TEXT_MAX_LENGTH, + TABLE_HEADER, +} from '../constants'; +import { PCN_DELETE_PLAYER_NEW, PCN_POST_PLAYER_NEW } from '@/modules/@common/acl/constants'; +import { ASPECT_RATIO_9_16_VALUE } from '@/modules/@common/constants/aspectRatios'; +import { TYPE_VERTICAL } from '@/modules/@common/constants/playerTypes'; + +import * as playerSelectors from '../redux/selectors'; +import { + deletePlayerAction, + getDataAction, + getPublishersOptionsAction, + getUiPropsAction, + setAction, + setAspectRatio, + setEmbedCodeAction, + setEmbedCodeByTypeAction, + setTableAction, +} from '../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import EmbedCodePopup from '@/modules/@common/EmbedCodePopup'; +import CommonList from '@/modules/@common/List'; +import CommonTable, { TableCellActions } from '@/modules/@common/Table'; +import Empty from './Empty/Empty'; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + Select, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, + Typography, +} from '@/mui/components'; + +import styles from './Players.module.scss'; + +function Players() { + const router = useRouter(); + const dispatch = useDispatch(); + + const data = useSelector(playerSelectors.dataSelector); + + const page = useSelector(playerSelectors.pageSelector); + const pageSize = useSelector(playerSelectors.pageSizeSelector); + const totalCount = useSelector(playerSelectors.totalCountSelector); + const sortBy = useSelector(playerSelectors.sortBySelector); + const sortOrder = useSelector(playerSelectors.sortOrderSelector); + const search = useSelector(playerSelectors.searchSelector); + + const allRecordsCount = useSelector(playerSelectors.allRecordsCountSelector); + + const type = useSelector(playerSelectors.typeSelector); + const typesOptions = useSelector(playerSelectors.typesOptionsSelector); + const publisher = useSelector(playerSelectors.publisherSelector); + const publishersOptions = useSelector(playerSelectors.publishersOptionsSelector); + const embedCode = useSelector(playerSelectors.embedCodeSelector); + const aspectRatio = useSelector(playerSelectors.aspectRatioSelector); + useSelector(playerSelectors.aspectRatioSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + + const shouldShowEmpty = Array.isArray(data) && !allRecordsCount; + + const [showEmbedPopup, setShowEmbedPopup] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [selectedPlayerId, setSelectedPlayerId] = useState(null); + const [selectedEmbedCodeType, setSelectedEmbedCodeType] = useState(EMBED_CODE_TYPES[0]); + const [anchorEl, setAnchorEl] = useState(null); + const [isDisabledEmbedCodeAspectRatio, setIsDisabledEmbedCodeAspectRatio] = useState(false); + const [showEmbedCodeType, setShowEmbedCodeType] = useState(false); + + useEffect(() => { + dispatch(getDataAction()); + dispatch(getUiPropsAction()); + }, []); + + const canCreatePlayer = hasPermission(PCN_POST_PLAYER_NEW, userPermissions); + const canDeletePlayer = hasPermission(PCN_DELETE_PLAYER_NEW, userPermissions); + const open = Boolean(anchorEl); + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + selected: [], + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + const renderRowActions = (row) => { + const actions = [ + { + title: 'Embed', + Icon: CodeRounded, + onClick: () => { + const verticalAspectRatioObj = ASPECT_RATIOS.find((aRatio) => aRatio.value === ASPECT_RATIO_9_16_VALUE); + const playerType = PLAYER_TYPES.find(({ id }) => id === row.type); + setIsDisabledEmbedCodeAspectRatio(playerType?.embedCodeAspectRatioDisabled); + setShowEmbedCodeType(playerType?.embedCodeTypes); + setSelectedPlayerId(row.id); + dispatch( + setAspectRatio({ + aspectRatioObj: row.type === TYPE_VERTICAL ? verticalAspectRatioObj : ASPECT_RATIOS[0], + id: row.id, + }), + ); + dispatch(setEmbedCodeAction({ id: row.id })); + setShowEmbedPopup(true); + }, + }, + { + title: 'Duplicate', + Icon: ContentCopyRounded, + onClick: () => router.push(`/player/${row.type}/new?copy=${row.id}`), + }, + ]; + + if (canDeletePlayer) { + actions.push({ + title: 'Delete', + Icon: DeleteRounded, + onClick: () => { + setSelectedPlayerId(row.id); + setShowDeleteDialog(true); + }, + }); + } + return ( + <> + {actions.map(({ title, Icon, onClick }) => ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + + ))} + + ); + }; + + const renderTableCell = (id, row) => { + let component; + switch (id) { + case 'type': + component = PLAYER_TYPES.find(($type) => $type.id === row[id])?.name; + break; + case 'publisher': + component = row[id]?.name; + break; + case 'updatedAt': + component = dayjs(row[id]).format('MMM D, YYYY hh:mm A'); + break; + case 'rowActions': + component = renderRowActions(row); + break; + case 'alias': + case 'comments': + component = ( + + {row[id]} + + ); + break; + default: + component = row[id]; + break; + } + return id === 'rowActions' ? ( + {component} + ) : ( + +
    + {component} +
    +
    + ); + }; + + return ( + + handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> + + + + + + + handleFilter({ publisher: selectedHPublisher, page: 1 })} + onOpen={() => dispatch(getPublishersOptionsAction(''))} + onInputChange={(_, searchText) => dispatch(getPublishersOptionsAction(searchText))} + renderInput={(params) => ( + + )} + /> +
    + } + renderActions={ + + {canCreatePlayer && ( + + <> + + setAnchorEl(null)}> + {typesOptions.map((typeOption) => ( + { + dispatch(setAction({ newPlayerType: typeOption })); + router.push(`/player/${typeOption.id}/new`); + }} + > + {typeOption.name} + + ))} + + + + )} + + } + > + {shouldShowEmpty ? ( + + ) : ( + ( + { + router.push(`/player/${row.type}/${row.id}`); + }} + > + {TABLE_HEADER.map(({ id }) => renderTableCell(id, row))} + + )} + data={data || []} + selected={[]} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onSelectDeselectAllRows={() => {}} + onSelectDeselectRow={() => {}} + onFilter={handleFilter} + /> + )} + {showEmbedPopup && ( + { + setShowEmbedPopup(false); + setSelectedEmbedCodeType(EMBED_CODE_TYPES[0]); + }} + title="Player Embed Code" + showAspectRatio={!isDisabledEmbedCodeAspectRatio} + showEmbedCodeTypes={showEmbedCodeType} + isAspectRatioDisabled={isDisabledEmbedCodeAspectRatio} + selectedAspectRatio={aspectRatio} + handleAspectRatioChange={(aspectRatioObj) => { + dispatch(setAspectRatio({ aspectRatioObj, id: selectedPlayerId })); + }} + selectedEmbedCodeType={selectedEmbedCodeType} + handleEmbedCodeTypeChange={(embedCodeTypeObj) => { + setSelectedEmbedCodeType(embedCodeTypeObj); + dispatch(setEmbedCodeByTypeAction({ embedCodeTypeObj, id: selectedPlayerId })); + }} + /> + )} + {showDeleteDialog && ( + setShowDeleteDialog(false)} + > + Delete Action + + + Are you sure you want to delete the player? + + + + + + + + + )} + + ); +} + +export default Players; diff --git a/anyclip/src/modules/players/Players/components/Players.module.scss b/anyclip/src/modules/players/Players/components/Players.module.scss new file mode 100644 index 0000000..f3e03a8 --- /dev/null +++ b/anyclip/src/modules/players/Players/components/Players.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"ActionsSelect":"Players_ActionsSelect__bLp9D","SearchField":"Players_SearchField__hvYCl","HubSelect":"Players_HubSelect__yxpiO","TypeSelect":"Players_TypeSelect__Vpt88","Row":"Players_Row__Y28kx","Cell":"Players_Cell__ZoLnC","Cell___nowrap":"Players_Cell___nowrap__qNhqK"}; \ No newline at end of file diff --git a/anyclip/src/modules/players/Players/constants/index.js b/anyclip/src/modules/players/Players/constants/index.js new file mode 100644 index 0000000..cdf2322 --- /dev/null +++ b/anyclip/src/modules/players/Players/constants/index.js @@ -0,0 +1,209 @@ +import { + ASPECT_RATIO_1_1_LABEL, + ASPECT_RATIO_1_1_VALUE, + ASPECT_RATIO_3_4_LABEL, + ASPECT_RATIO_3_4_VALUE, + ASPECT_RATIO_4_3_LABEL, + ASPECT_RATIO_4_3_VALUE, + ASPECT_RATIO_9_16_LABEL, + ASPECT_RATIO_9_16_VALUE, + ASPECT_RATIO_16_9_LABEL, + ASPECT_RATIO_16_9_VALUE, +} from '@/modules/@common/constants/aspectRatios'; +import { + EMBED_CODE_TYPE_DFP_GAM_LABEL, + EMBED_CODE_TYPE_DFP_GAM_VALUE, + EMBED_CODE_TYPE_DIRECT_LABEL, + EMBED_CODE_TYPE_DIRECT_VALUE, +} from '@/modules/@common/constants/embedTypes'; +import { + TYPE_INTELLIGENT, + TYPE_INTELLIGENT_AMP, + TYPE_INTELLIGENT_AMP_NAME, + TYPE_INTELLIGENT_NAME, + TYPE_LIVE, + TYPE_LIVE_NAME, + TYPE_MOBILE_SDK, + TYPE_MOBILE_SDK_NAME, + TYPE_OUTSTREAM, + TYPE_OUTSTREAM_NAME, + TYPE_STORIES, + TYPE_STORIES_AMP, + TYPE_STORIES_AMP_NAME, + TYPE_STORIES_NAME, + TYPE_VERTICAL, + TYPE_VERTICAL_NAME, + TYPE_WATCH, + TYPE_WATCH_INLINE, + TYPE_WATCH_INLINE_NAME, + TYPE_WATCH_NAME, +} from '@/modules/@common/constants/playerTypes'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const PLAYER_TYPES = [ + { + id: TYPE_INTELLIGENT, + name: TYPE_INTELLIGENT_NAME, + selfServe: true, + preview: true, + embedCodeAspectRatio: true, + embedCodeAspectRatioDisabled: false, + }, + { + id: TYPE_STORIES, + name: TYPE_STORIES_NAME, + selfServe: true, + preview: true, + embedCodeAspectRatio: true, + embedCodeAspectRatioDisabled: false, + }, + { + id: TYPE_WATCH, + name: TYPE_WATCH_NAME, + }, + { + id: TYPE_INTELLIGENT_AMP, + name: TYPE_INTELLIGENT_AMP_NAME, + selfServe: true, + embedCodeAspectRatio: false, + embedCodeAspectRatioDisabled: true, + }, + { + id: TYPE_STORIES_AMP, + name: TYPE_STORIES_AMP_NAME, + selfServe: true, + embedCodeAspectRatio: false, + embedCodeAspectRatioDisabled: true, + }, + { + id: TYPE_LIVE, + name: TYPE_LIVE_NAME, + }, + { + id: TYPE_MOBILE_SDK, + name: TYPE_MOBILE_SDK_NAME, + }, + { + id: TYPE_OUTSTREAM, + name: TYPE_OUTSTREAM_NAME, + selfServe: true, + preview: false, + embedCodeAspectRatio: false, + embedCodeAspectRatioDisabled: true, + embedCodeTypes: true, + }, + { + id: TYPE_WATCH_INLINE, + name: TYPE_WATCH_INLINE_NAME, + }, + { + id: TYPE_VERTICAL, + name: TYPE_VERTICAL_NAME, + selfServe: true, + preview: true, + embedCodeAspectRatio: true, + embedCodeAspectRatioDisabled: true, + }, +]; + +// Status Select +export const ALL_TYPE = { + id: 0, + name: 'All Types', +}; + +// Table header + +export const TABLE_HEADER = [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '68', + }, + { + id: 'alias', + label: 'Name', + sortable: true, + width: '200', + }, + { + id: 'comments', + label: 'Description', + sortable: true, + width: '200', + }, + { + id: 'type', + label: 'Type ', + sortable: true, + width: '100', + }, + { + id: 'publisher', + label: 'Hub', + sortable: false, + width: '160', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: '220', + }, + { + id: 'updatedAt', + label: 'Updated Date', + sortable: true, + width: '160', + isDefaultSortBy: true, + defaultSortOrder: SORT_DESC, + }, + { + id: 'rowActions', + label: '', + sortable: false, + autoWidth: true, + padding: 'none', + }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; +export const ROWS_PER_PAGE_DEFAULT_FILTER = 30; +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; + +export const EMBED_CODE_TYPES = [ + { + label: EMBED_CODE_TYPE_DIRECT_LABEL, + value: EMBED_CODE_TYPE_DIRECT_VALUE, + }, + { + label: EMBED_CODE_TYPE_DFP_GAM_LABEL, + value: EMBED_CODE_TYPE_DFP_GAM_VALUE, + }, +]; + +export const ASPECT_RATIOS = [ + { + label: ASPECT_RATIO_16_9_LABEL, + value: ASPECT_RATIO_16_9_VALUE, + }, + { + label: ASPECT_RATIO_9_16_LABEL, + value: ASPECT_RATIO_9_16_VALUE, + }, + { + label: ASPECT_RATIO_4_3_LABEL, + value: ASPECT_RATIO_4_3_VALUE, + }, + { + label: ASPECT_RATIO_3_4_LABEL, + value: ASPECT_RATIO_3_4_VALUE, + }, + { + label: ASPECT_RATIO_1_1_LABEL, + value: ASPECT_RATIO_1_1_VALUE, + }, +]; diff --git a/anyclip/src/modules/players/Players/redux/epics/deletePlayer.js b/anyclip/src/modules/players/Players/redux/epics/deletePlayer.js new file mode 100644 index 0000000..84454ca --- /dev/null +++ b/anyclip/src/modules/players/Players/redux/epics/deletePlayer.js @@ -0,0 +1,48 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { deletePlayerAction, getDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation DeletePlayer($id: Int!) { + deletePlayer(id: $id){ + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(deletePlayerAction.type), + switchMap(({ payload: { id } }) => { + const stream$ = gqlRequest({ + query, + variables: { + id, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + if (!errors.length) { + actions.push(of(getDataAction())); + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Player was deleted successfully', + }), + ), + ); + } + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/players/Players/redux/epics/getPlayerPublishers.js b/anyclip/src/modules/players/Players/redux/epics/getPlayerPublishers.js new file mode 100644 index 0000000..f93d353 --- /dev/null +++ b/anyclip/src/modules/players/Players/redux/epics/getPlayerPublishers.js @@ -0,0 +1,62 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { ROWS_PER_PAGE_DEFAULT_FILTER } from '@/modules/players/Players/constants'; + +import { getPublishersOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPlayerPublishersOptions( + $pageSize: Int + $searchText: String + ) { + getPlayerPublishersOptions( + pageSize: $pageSize + searchText: $searchText + ) { + records { + id + name + } + recordsTotal + } + } +`; + +const getResponse = ({ records }) => + records.map(({ id, name }) => ({ + value: id, + label: name, + })); + +export default (action$) => + action$.pipe( + ofType(getPublishersOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + searchText: action.payload ?? '', + pageSize: ROWS_PER_PAGE_DEFAULT_FILTER, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + publishersOptions: getResponse(response.data.getPlayerPublishersOptions), + }), + ); + } + return EMPTY; + }), + ); + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/players/Players/redux/epics/getPlayerUiProps.js b/anyclip/src/modules/players/Players/redux/epics/getPlayerUiProps.js new file mode 100644 index 0000000..44dea83 --- /dev/null +++ b/anyclip/src/modules/players/Players/redux/epics/getPlayerUiProps.js @@ -0,0 +1,53 @@ +import { ofType } from 'redux-observable'; +import { EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getUiPropsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getPlayerUiProps( + $forTable: Boolean + ) { + getPlayerUiProps( + forTable: $forTable + ) { + availableTemplatePlayers { + id + name + order + } + embedSources { + deOutstream + dfpOutstream + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getUiPropsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: { + forTable: true, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const { availableTemplatePlayers: playerTypes, embedSources } = response.data.getPlayerUiProps; + return of( + setAction({ + typesOptions: playerTypes, + embedSources, + }), + ); + } + return EMPTY; + }), + ); + return stream$; + }), + ); diff --git a/anyclip/src/modules/players/Players/redux/epics/getPlayersData.js b/anyclip/src/modules/players/Players/redux/epics/getPlayersData.js new file mode 100644 index 0000000..5112b11 --- /dev/null +++ b/anyclip/src/modules/players/Players/redux/epics/getPlayersData.js @@ -0,0 +1,70 @@ +import * as playersSelectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query getPlayers( + $sortBy: String + $sortOrder: String + $page: Int + $pageSize: Int + $searchText: String + $publisherId: Int + $type: Int + ) { + getPlayers( + sortBy: $sortBy + sortOrder: $sortOrder + page: $page + pageSize: $pageSize + searchText: $searchText + publisherId: $publisherId + type: $type + ) { + allRecordsCount + records { + id + alias + comments + publisher { + id + name + } + type + updatedBy + updatedAt + embedCode + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const type = playersSelectors.typeSelector(state); + const publisher = playersSelectors.publisherSelector(state); + + const variables = { + page: playersSelectors.pageSelector(state), + pageSize: playersSelectors.pageSizeSelector(state), + sortBy: playersSelectors.sortBySelector(state), + sortOrder: playersSelectors.sortOrderSelector(state), + searchText: playersSelectors.searchSelector(state), + }; + + if (type?.id) { + variables.type = type.id; + } + + if (publisher?.value) { + variables.publisherId = publisher.value; + } + + return variables; + }, + processResponse: ({ data: { getPlayers } }) => getPlayers, + setTableAction, +}); diff --git a/anyclip/src/modules/players/Players/redux/epics/index.js b/anyclip/src/modules/players/Players/redux/epics/index.js new file mode 100644 index 0000000..060f755 --- /dev/null +++ b/anyclip/src/modules/players/Players/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import deletePlayer from './deletePlayer'; +import getPublishers from './getPlayerPublishers'; +import getPlayerData from './getPlayersData'; +import getUiProps from './getPlayerUiProps'; + +export default combineEpics(getPlayerData, getUiProps, getPublishers, deletePlayer); diff --git a/anyclip/src/modules/players/Players/redux/selectors/index.js b/anyclip/src/modules/players/Players/redux/selectors/index.js new file mode 100644 index 0000000..600cfb5 --- /dev/null +++ b/anyclip/src/modules/players/Players/redux/selectors/index.js @@ -0,0 +1,32 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; + +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + isLoadingSelector, + allRecordsCountSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, slice.name); + +/// FILTERS /// +export const searchSelector = (state) => state[nameSpace].search; +export const typeSelector = (state) => state[nameSpace].type; +export const typesOptionsSelector = (state) => state[nameSpace].typesOptions; +export const publisherSelector = (state) => state[nameSpace].publisher; +export const publishersOptionsSelector = (state) => state[nameSpace].publishersOptions; + +/// ROW ACTIONS /// +export const embedCodeSelector = (state) => state[nameSpace].embedCode; +export const aspectRatioSelector = (state) => state[nameSpace].aspectRatio; + +/// ADDITION /// +export const newPlayerTypeSelector = (state) => state[nameSpace].newPlayerType; diff --git a/anyclip/src/modules/players/Players/redux/slices/index.js b/anyclip/src/modules/players/Players/redux/slices/index.js new file mode 100644 index 0000000..da86d87 --- /dev/null +++ b/anyclip/src/modules/players/Players/redux/slices/index.js @@ -0,0 +1,117 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + ALL_TYPE, + ASPECT_RATIOS, + PLAYER_TYPES, + ROWS_PER_PAGE_DEFAULT, + TABLE_HEADER, + TABLE_REDUX_FIELD_NAME, +} from '../../constants'; +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_HEADER.find((column) => column.isDefaultSortBy)?.id || 'id', + sortOrder: TABLE_HEADER.find((column) => column.isDefaultSortBy)?.defaultSortOrder || SORT_ASC, +}); + +const addDataARToEmbedCode = (embedCodeText, dataARValue) => { + const existingDataAR = embedCodeText.match(/data-ar\s*=\s*['"]([^'"]+)['"]/); + let embedCode; + + if (existingDataAR) { + embedCode = embedCodeText.replace(existingDataAR[0], `data-ar="${dataARValue}"`); + } else { + embedCode = embedCodeText.replace(/(src\s*=\s*['"]([^'"]+)['"])/i, `$1 data-ar="${dataARValue}"`); + } + + return embedCode; +}; + +const handleChangeEmbedCodeSrc = (embedCodeText, src) => { + if (src) { + return embedCodeText.replace(/src="([^"]*)"/, `src="${src}"`); + } + return embedCodeText; +}; + +const initialState = { + /// TABLE /// + ...tableSlice.state, + + /// FILTERS /// + search: '', + publisher: null, + publishersOptions: [], + type: ALL_TYPE, + typesOptions: [], + + /// ROW ACTIONS /// + embedCode: '', + aspectRatio: ASPECT_RATIOS[0], + embedSources: null, + + /// ADDITION /// + newPlayerType: {}, + allRecordsCount: 0, +}; + +export const slice = createSlice({ + name: '@@PLAYER/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getUiPropsAction: (state) => state, + getPublishersOptionsAction: (state) => state, + setEmbedCodeAction: (state, { payload }) => { + const player = state[TABLE_REDUX_FIELD_NAME].data.find((row) => row.id === payload.id); + const hasEmbedCodeAspectRatio = + PLAYER_TYPES.find((p) => p.id === player.type)?.embedCodeAspectRatio && player.embedCode; + if (hasEmbedCodeAspectRatio) { + state.embedCode = addDataARToEmbedCode(player.embedCode, state.aspectRatio.label); + } else { + state.embedCode = player.embedCode || ''; + } + }, + setEmbedCodeByTypeAction: (state, { payload }) => { + const player = state[TABLE_REDUX_FIELD_NAME].data.find((row) => row.id === payload.id); + const hasEmbedCodeTypes = PLAYER_TYPES.find((p) => p.id === player.type)?.embedCodeTypes && player.embedCode; + + state.embedCode = hasEmbedCodeTypes + ? handleChangeEmbedCodeSrc(player.embedCode, state.embedSources[payload.embedCodeTypeObj.value]) + : player.embedCode || ''; + }, + setAspectRatio: (state, { payload }) => { + state.aspectRatio = payload.aspectRatioObj; + const player = state[TABLE_REDUX_FIELD_NAME].data.find((row) => row.id === payload.id); + const hasEmbedCodeAspectRatio = PLAYER_TYPES.find((p) => p.id === player.type)?.embedCodeAspectRatio; + if (hasEmbedCodeAspectRatio) { + state.embedCode = addDataARToEmbedCode(state.embedCode, payload.aspectRatioObj.label); + } + }, + + deletePlayerAction: (state) => state, + }, +}); + +export const { + getDataAction, + setTableAction, + setAction, + getPublishersOptionsAction, + getUiPropsAction, + setEmbedCodeAction, + setAspectRatio, + deletePlayerAction, + setEmbedCodeByTypeAction, +} = slice.actions; diff --git a/anyclip/src/modules/publishing/Destination/components/SignButton/SignButton.jsx b/anyclip/src/modules/publishing/Destination/components/SignButton/SignButton.jsx new file mode 100644 index 0000000..de80d4c --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/SignButton/SignButton.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; + +import { TYPE_FACEBOOK, TYPE_YOUTUBE } from '../../constants'; + +import * as selectors from '../../redux/selectors'; +import { removeErrorByPropAction } from '../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { Button, FormHelperText, Stack } from '@/mui/components'; + +const openAuthPopup = (authProvider, clientId) => { + const AUTH_BASE_URL = process.env.APP_ENV_BASE_URL; + + const WIDTH = 562; + const HEIGHT = 670; + + const y = window.top.outerHeight / 2 + window.top.screenY - HEIGHT / 2; + const x = window.top.outerWidth / 2 + window.top.screenX - WIDTH / 2; + window.open( + `${AUTH_BASE_URL}/auth/${authProvider}?cid=${clientId}`, + 'Anyclip Authentication', + `scrollbars=no, resizable=no, copyhistory=no, popup, width=${WIDTH}, height=${HEIGHT}, top=${y}, left=${x}`, + ); +}; + +export function SignButton(props) { + const dispatch = useDispatch(); + const type = useSelector(selectors.typeSelector); + const accessToken = useSelector(selectors.accessTokenSelector); + const userName = useSelector(selectors.userNameSelector); + const scheme = useSelector(selectors.schemeSelector); + + let title = ''; + let signOnClick = () => null; + + if (type === TYPE_YOUTUBE) { + title = 'Youtube'; + signOnClick = () => { + openAuthPopup('google', process.env.APP_G_CLIENT_ID); + }; + } else if (type === TYPE_FACEBOOK) { + title = 'Facebook'; + signOnClick = () => { + openAuthPopup('facebook', process.env.APP_FB_CLIENT_ID); + }; + } + + const inputSchemeData = getInputPropsByName(scheme, ['platform']); + + return ( + + + {inputSchemeData.error && {inputSchemeData.helperText}} + + ); +} + +SignButton.propTypes = { + size: PropTypes.oneOf(['xSmall', 'small', 'medium', 'large']), + disabled: PropTypes.bool, +}; diff --git a/anyclip/src/modules/publishing/Destination/components/auth/CognitoAuth.jsx b/anyclip/src/modules/publishing/Destination/components/auth/CognitoAuth.jsx new file mode 100644 index 0000000..798ecc3 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/auth/CognitoAuth.jsx @@ -0,0 +1,48 @@ +/* eslint-disable camelcase */ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +function CognitoAuth() { + const router = useRouter(); + const { authUri, redirectUri, state, code, error, error_description } = router.query; + + useEffect(() => { + if (authUri) { + const { origin, pathname, searchParams } = new URL(decodeURIComponent(authUri)); + + const authSearchParams = Object.fromEntries(searchParams); + + router.replace({ + pathname: `${origin}${pathname}`, + query: { + ...authSearchParams, + state: redirectUri, + redirect_uri: `${window.location.origin}/auth/cognito`, + }, + }); + } + + if (code) { + router.replace({ + pathname: decodeURIComponent(state), + query: { + code, + }, + }); + } + + if (error) { + router.replace({ + pathname: decodeURIComponent(state), + query: { + error: error_description, + }, + }); + } + }, []); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + +export default CognitoAuth; diff --git a/anyclip/src/modules/publishing/Destination/components/auth/FacebookAuth.jsx b/anyclip/src/modules/publishing/Destination/components/auth/FacebookAuth.jsx new file mode 100644 index 0000000..d609985 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/auth/FacebookAuth.jsx @@ -0,0 +1,44 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +const SCOPES = [ + 'email', + 'pages_show_list', + 'pages_read_engagement', + 'pages_manage_posts', + 'pages_manage_metadata', + 'read_insights', + 'business_management', +].join(','); + +function FacebookAuth() { + const router = useRouter(); + const { cid } = router.query; + + useEffect(() => { + const code = new URLSearchParams(window.location.hash?.split('#')[1]).get('access_token'); + + if (cid) { + router.replace({ + pathname: 'https://www.facebook.com/v22.0/dialog/oauth', + query: { + client_id: cid, + display: 'popup', + redirect_uri: `${window.location.origin}/auth/facebook`, + response_type: 'token', + scope: SCOPES, + }, + }); + } + + if (code) { + window.opener.postMessage({ type: 'ac_auth_fb', code }, '*'); + window.close(); + } + }, []); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + +export default FacebookAuth; diff --git a/anyclip/src/modules/publishing/Destination/components/auth/GoogleAuth.jsx b/anyclip/src/modules/publishing/Destination/components/auth/GoogleAuth.jsx new file mode 100644 index 0000000..02cb161 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/auth/GoogleAuth.jsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +const PREFIX = 'https://www.googleapis.com/auth'; +const SCOPES = [ + `${PREFIX}/youtube.force-ssl`, + `${PREFIX}/youtube.upload`, + `${PREFIX}/youtube ${PREFIX}/yt-analytics-monetary.readonly`, +].join(' '); + +function GoogleAuth() { + const router = useRouter(); + const { cid, code } = router.query; + + useEffect(() => { + if (cid) { + router.replace({ + pathname: 'https://accounts.google.com/o/oauth2/v2/auth', + query: { + client_id: cid, + redirect_uri: `${window.location.origin}/auth/google`, + response_type: 'code', + scope: SCOPES, + access_type: 'offline', + include_granted_scopes: true, + prompt: 'consent', + }, + }); + } + + if (code) { + window.opener.postMessage({ type: 'ac_auth_g', code }, '*'); + window.close(); + } + }, []); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + +export default GoogleAuth; diff --git a/anyclip/src/modules/publishing/Destination/components/auth/MicrosoftAuth.jsx b/anyclip/src/modules/publishing/Destination/components/auth/MicrosoftAuth.jsx new file mode 100644 index 0000000..804890b --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/auth/MicrosoftAuth.jsx @@ -0,0 +1,69 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { PublicClientApplication } from '@azure/msal-browser'; + +const SCOPES = { + teams: ['User.Read', 'UserNotification.ReadWrite.CreatedByApp'], + sharepoint: ['User.Read'], +}; + +function TeamsAuth() { + const router = useRouter(); + const { authService, clientId, redirectUri, state } = router.query; + + const msalConfig = { + auth: { + clientId, + authority: 'https://login.microsoftonline.com/organizations', + validateAuthority: false, + navigateToLoginRequestUrl: false, + redirectUri: `${window.location.origin}/auth/${authService}`, + }, + cache: { + cacheLocation: 'sessionStorage', + storeAuthStateInCookie: false, + }, + system: { + allowRedirectInIframe: true, + navigateFrameWait: 60000, + windowHashTimeout: 60000, + iframeHashTimeout: 60000, + loadFrameTimeout: 60000, + }, + }; + + const msalInstance = new PublicClientApplication(msalConfig); + + useEffect(() => { + if (clientId) { + msalInstance.loginRedirect({ + scopes: SCOPES[authService], + state: new URLSearchParams({ + state, + redirectUri, + }).toString, + }); + } else { + const paramsFromMSHash = new URLSearchParams(window.location.hash?.split('#')[1]); + + const { + code, + state: stateFromMSHash, + client_info: clientInfo, + session_state: sessionState, + } = Object.fromEntries(paramsFromMSHash); + + const stateParams = new URLSearchParams(decodeURIComponent(stateFromMSHash)); + + router.replace({ + pathname: stateParams.get('redirectUri'), + hash: `code=${code}&client_info=${clientInfo}&session_state=${sessionState}&state=${stateParams.get('state')}`, + }); + } + }, []); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + +export default TeamsAuth; diff --git a/anyclip/src/modules/publishing/Destination/components/auth/ZoomAuth.jsx b/anyclip/src/modules/publishing/Destination/components/auth/ZoomAuth.jsx new file mode 100644 index 0000000..1ccddee --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/auth/ZoomAuth.jsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +function ZoomAuth() { + const router = useRouter(); + const { clientId, state, redirectUri, code } = router.query; + + useEffect(() => { + if (clientId) { + router.replace({ + pathname: 'https://zoom.us/oauth/authorize', + query: { + state: new URLSearchParams({ + state, + redirectUri, + }).toString, + response_type: 'code', + client_id: clientId, + redirect_uri: `${window.location.origin}/auth/zoom`, + }, + }); + } + + if (code) { + const stateParams = new URLSearchParams(decodeURIComponent(state)); + + router.replace({ + pathname: stateParams.get('redirectUri'), + query: { + code, + state: stateParams.get('state'), + }, + }); + } + }, []); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} + +export default ZoomAuth; diff --git a/anyclip/src/modules/publishing/Destination/components/index.jsx b/anyclip/src/modules/publishing/Destination/components/index.jsx new file mode 100644 index 0000000..a39c554 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/index.jsx @@ -0,0 +1,437 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import NextLink from 'next/link'; +import { useRouter } from 'next/router'; + +import { + PUBLISH_DESTINATION_TYPES, + PUBLISH_SETTINGS, + SETTINGS_STATUS_DISCONNECTED, + STATUS_SETTINGS, + TAB_GENERAL, + TYPE_FACEBOOK, + TYPE_YOUTUBE, +} from '../constants'; +import { PCN_POST_DESTINATIONS, PCN_PUT_DESTINATIONS } from '@/modules/@common/acl/constants'; + +import { publishersSelector } from '../../DestinationList/redux/selectors'; +import { getPublishersAction } from '../../DestinationList/redux/slices'; +import * as selectors from '../redux/selectors'; +import { + clearFormAction, + createDestinationAction, + exchangeFacebookAuthCodeAction, + exchangeGoogleAuthCodeAction, + getDestinationByIdAction, + getViewabilityConfigAction, + removeErrorByPropAction, + setActiveTabIdAction, + setErrorByPropAction, + setScrollToFieldNameAction, + updateDestinationAction, + updateDestinationFormAction, + validateFields, +} from '../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import { Form, FormContent, FormRow, FormSection } from '@/modules/@common/Form'; +import { SignButton } from './SignButton/SignButton'; +import { + Autocomplete, + Button, + Checkbox, + FormControl, + FormControlLabel, + FormHelperText, + GridList, + InputLabel, + MenuItem, + Select, + Stack, + TabContent, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './styles.module.scss'; + +const DISPLAY_NAME_MAX_LENGTH = 100; + +function Destination() { + const size = 'small'; + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const name = useSelector(selectors.nameSelector); + const visibility = useSelector(selectors.visibilitySelector); + const status = useSelector(selectors.statusSelector); + const accessToken = useSelector(selectors.accessTokenSelector); + const channels = useSelector(selectors.channelsSelector); + const defaultChannel = useSelector(selectors.defaultChannelSelector); + const playlists = useSelector(selectors.playlistsSelector); + const viewabilityConfig = useSelector(selectors.viewabilityConfigSelector); + const isLoading = useSelector(selectors.isLoadingSelector); + const publisherId = useSelector(selectors.publisherIdSelector); + const publisher = useSelector(selectors.publisherSelector); + const type = useSelector(selectors.typeSelector); + const activeTabId = useSelector(selectors.activeTabIdSelector); + const scheme = useSelector(selectors.schemeSelector); + const publishers = useSelector(publishersSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + + const publishSettings = { + title: useSelector(selectors.titleSelector), + language: useSelector(selectors.languageSelector), + category: useSelector(selectors.categorySelector), + manualTags: useSelector(selectors.manualTagsSelector), + thumbnail: useSelector(selectors.thumbnailSelector), + autoTags: useSelector(selectors.autoTagsSelector), + description: useSelector(selectors.descriptionSelector), + closedCaptions: useSelector(selectors.closedCaptionsSelector), + }; + + const destinationId = +router.query.id; + const destinationPlatform = router.query.platform.toUpperCase(); + + let viewabilities; // [] + + const isDeniedByPermission = destinationId + ? !hasPermission(PCN_PUT_DESTINATIONS, userPermissions) + : !hasPermission(PCN_POST_DESTINATIONS, userPermissions); + + if (destinationId) { + const config = viewabilityConfig.find((conf) => conf.mediaPlatform === type); + + const transitions = config?.transitions.find((transition) => transition.name === visibility)?.transitions ?? []; + + viewabilities = + config?.viewabilities + ?.filter((viewabilitiy) => transitions.includes(viewabilitiy.name) || viewabilitiy.name === visibility) + ?.map((viewabilitiy) => ({ + label: viewabilitiy.name.toLowerCase(), + value: viewabilitiy.name, + })) ?? []; + } else { + viewabilities = + viewabilityConfig + .find((config) => config.mediaPlatform === type) + ?.viewabilities.filter((viewabilitiy) => !viewabilitiy.onlyForExisting) + ?.map((viewabilitiy) => ({ + label: viewabilitiy.name.toLowerCase(), + value: viewabilitiy.name, + })) ?? []; + } + + const handleOnChange = (fieldName, fieldValue) => { + if (fieldName === 'defaultChannel') { + const channel = channels.find((ch) => ch.id === fieldValue); + + return dispatch( + updateDestinationFormAction({ + [fieldName]: fieldValue, + defaultChannelName: channel?.title ?? '', + pageAccessToken: channel?.token ?? '', + }), + ); + } + + if (fieldName === 'defaultPlaylist') { + const defaultPlaylistName = playlists.find((playlist) => playlist.id === fieldValue)?.title; + + return dispatch( + updateDestinationFormAction({ + [fieldName]: fieldValue, + defaultPlaylistName, + }), + ); + } + + return dispatch( + updateDestinationFormAction({ + [fieldName]: fieldValue, + }), + ); + }; + + const handleAuthResponse = (responseEvent) => { + if (responseEvent?.data?.type === 'ac_auth_fb') { + dispatch(exchangeFacebookAuthCodeAction(responseEvent?.data)); + } + if (responseEvent?.data?.type === 'ac_auth_g') { + dispatch(exchangeGoogleAuthCodeAction(responseEvent?.data)); + } + }; + + let channelTitle = ''; + + if (destinationPlatform === TYPE_YOUTUBE) { + channelTitle = 'Default Channel'; + } else if (destinationPlatform === TYPE_FACEBOOK) { + channelTitle = 'Default Page'; + } + + useEffect(() => { + dispatch(getViewabilityConfigAction(destinationId)); + + if (destinationPlatform) { + dispatch( + updateDestinationFormAction({ + type: destinationPlatform, + }), + ); + } + + if (destinationId) { + dispatch( + getDestinationByIdAction({ + id: destinationId, + }), + ); + } + + window.addEventListener('message', handleAuthResponse); + + return () => window.removeEventListener('message', handleAuthResponse); + }, [destinationPlatform]); + + useEffect(() => { + if (status === SETTINGS_STATUS_DISCONNECTED) { + dispatch( + updateDestinationFormAction({ + accessToken: '', + }), + ); + } + }, [status]); + + useEffect(() => { + if (channels?.length) { + dispatch( + updateDestinationFormAction({ + defaultChannel: channels[0].id, + defaultChannelName: channels[0].title, + pageAccessToken: channels[0].token || '', + }), + ); + } + + if (playlists?.length) { + dispatch( + updateDestinationFormAction({ + defaultPlaylist: playlists[0].id, + defaultPlaylistName: playlists[0].title, + }), + ); + } + }, [channels, playlists]); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ fieldName }) => { + if (fieldName === 'platform') { + return !allProps.accessToken; + } + + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (destinationId) { + dispatch(updateDestinationAction(destinationId)); + } else { + dispatch(createDestinationAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const defaultChannelScheme = getInputPropsByName(scheme, ['defaultChannel']); + + const destinationType = PUBLISH_DESTINATION_TYPES.find((dest) => dest.value === destinationPlatform); + + return ( +
    + + + {destinationId ? `${destinationType?.label} ${name} > Settings` : 'New Publish Destination'} + + + + + + {!isDeniedByPermission && ( + + )} + + +
    + + + + + + + handleOnChange('name', e.target.value)} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + +pub.id === +publisherId) || publisher} + size={size} + onOpen={() => dispatch(getPublishersAction())} + onChange={(e, value) => handleOnChange('publisherId', value?.id)} + options={publishers} + optionLabelKey="name" + optionValueKey="id" + disabled={isDeniedByPermission} + renderInput={(params$) => ( + dispatch(getPublishersAction(e.target.value))} + onFocus={() => dispatch(removeErrorByPropAction(['publisherId']))} + /> + )} + /> + + + + + + + + + + {defaultChannelScheme.label && defaultChannelScheme.required && ( + + Required + + )} + + {defaultChannelScheme.helperText && ( + {defaultChannelScheme.helperText} + )} + + + + + {PUBLISH_SETTINGS.map((setting) => ( + { + handleOnChange(setting.propertyName, !publishSettings[setting.propertyName]); + }} + /> + } + label={setting.label} + /> + ))} + + + + +
    +
    + ); +} + +export default Destination; diff --git a/anyclip/src/modules/publishing/Destination/components/styles.module.scss b/anyclip/src/modules/publishing/Destination/components/styles.module.scss new file mode 100644 index 0000000..f0f54fd --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/components/styles.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"styles_Wrapper__Ur7Ww","Title":"styles_Title___olN8","Controls":"styles_Controls__RHh_i","Loader":"styles_Loader__jZchO","Tabs":"styles_Tabs__oos4Q","Container":"styles_Container__FoJHR","Container___loading":"styles_Container___loading__FE26d"}; \ No newline at end of file diff --git a/anyclip/src/modules/publishing/Destination/constants/index.js b/anyclip/src/modules/publishing/Destination/constants/index.js new file mode 100644 index 0000000..ff36a00 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/constants/index.js @@ -0,0 +1,79 @@ +export const TAB_GENERAL = 'general'; +export const DESTINATION_REDUX_FIELD_NAME = 'commonForm'; + +export const TYPE_YOUTUBE = 'YOUTUBE'; +export const TYPE_FACEBOOK = 'FACEBOOK'; + +export const PUBLISH_DESTINATION_TYPES = [ + { + label: 'YouTube', + value: TYPE_YOUTUBE, + }, + { + label: 'Facebook', + value: TYPE_FACEBOOK, + }, +]; + +export const PUBLISH_SETTINGS = [ + { + label: 'Title', + value: 'TITLE', + required: true, + propertyName: 'title', + }, + { + label: 'Language', + value: 'LANGUAGE', + propertyName: 'language', + }, + { + label: 'Category', + value: 'CATEGORY', + propertyName: 'category', + }, + { + label: 'Manual Tags', + value: 'MANUAL_TAGS', + propertyName: 'manualTags', + }, + { + label: 'Thumbnail', + value: 'THUMBNAIL', + propertyName: 'thumbnail', + }, + { + label: 'Automatic Tags', + value: 'AUTO_TAGS', + propertyName: 'autoTags', + }, + { + label: 'Description', + value: 'DESCRIPTION', + propertyName: 'description', + }, + { + label: 'Closed Captions', + value: 'CC', + propertyName: 'closedCaptions', + }, +]; + +export const SETTINGS_STATUS_ENABLED = 'ENABLED'; +export const SETTINGS_STATUS_DISABLED = 'DISABLED'; +export const SETTINGS_STATUS_DISCONNECTED = 'DISCONNECTED'; + +export const STATUS_SETTINGS = [ + { + label: 'Enabled', + value: SETTINGS_STATUS_ENABLED, + }, + { + label: 'Disabled', + value: SETTINGS_STATUS_DISABLED, + }, + { + label: 'Disconnected', + value: SETTINGS_STATUS_DISCONNECTED, + }, +]; diff --git a/anyclip/src/modules/publishing/Destination/helpers/computedState.js b/anyclip/src/modules/publishing/Destination/helpers/computedState.js new file mode 100644 index 0000000..90a4cd9 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/helpers/computedState.js @@ -0,0 +1,27 @@ +import * as selectors from '../redux/selectors'; + +export const getDestinationParams = (state) => ({ + type: selectors.typeSelector(state), + name: selectors.nameSelector(state), + visibility: selectors.visibilitySelector(state), + defaultChannel: selectors.defaultChannelSelector(state), + defaultChannelName: selectors.defaultChannelNameSelector(state), + defaultPlaylist: selectors.defaultPlaylistSelector(state), + defaultPlaylistName: selectors.defaultPlaylistNameSelector(state), + title: selectors.titleSelector(state), + description: selectors.descriptionSelector(state), + thumbnail: selectors.thumbnailSelector(state), + category: selectors.categorySelector(state), + manualTags: selectors.manualTagsSelector(state), + autoTags: selectors.autoTagsSelector(state), + closedCaptions: selectors.closedCaptionsSelector(state), + language: selectors.languageSelector(state), + status: selectors.statusSelector(state), + accessToken: selectors.accessTokenSelector(state), + refreshToken: selectors.refreshTokenSelector(state), + expiryDate: selectors.expiryDateSelector(state), + publisherId: +selectors.publisherIdSelector(state), + pageAccessToken: selectors.pageAccessTokenSelector(state), +}); + +export default {}; diff --git a/anyclip/src/modules/publishing/Destination/helpers/validationScheme.js b/anyclip/src/modules/publishing/Destination/helpers/validationScheme.js new file mode 100644 index 0000000..3c40875 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/helpers/validationScheme.js @@ -0,0 +1,67 @@ +import { TAB_GENERAL, TYPE_FACEBOOK, TYPE_YOUTUBE } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'platform', + tabId: TAB_GENERAL, + validation: (value, allProps) => { + if (!allProps.accessToken) { + return 'You must be autorized'; + } + + return ''; + }, + }, + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length > 250) { + return 'The Name field can’t exceed 250 characters'; + } + + return ''; + }, + }, + { + fieldName: 'publisherId', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'defaultChannel', + tabId: TAB_GENERAL, + validation: (value, allProps) => { + if (!allProps.channels?.length) { + let name = ''; + let channelName = ''; + + if (allProps.type === TYPE_YOUTUBE) { + name = 'Youtube'; + channelName = 'channel'; + } else if (allProps.type === TYPE_FACEBOOK) { + name = 'Facebook'; + channelName = 'page'; + } + + return `The ${name} account must have a ${channelName}`; + } + + if (!value) { + return 'Please choose channel'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/publishing/Destination/index.js b/anyclip/src/modules/publishing/Destination/index.js new file mode 100644 index 0000000..1b80085 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/index.js @@ -0,0 +1,3 @@ +import Destination from './components'; + +export default Destination; diff --git a/anyclip/src/modules/publishing/Destination/redux/epics/createDestination.js b/anyclip/src/modules/publishing/Destination/redux/epics/createDestination.js new file mode 100644 index 0000000..d6b55ba --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/epics/createDestination.js @@ -0,0 +1,105 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { getDestinationParams } from '../../helpers/computedState'; +import { clearFormAction, createDestinationAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation createPublishingDestination( + $type: String!, + $status: String!, + $publisherId: Int!, + $name: String!, + $visibility: String!, + $defaultChannel: String, + $defaultChannelName: String, + $defaultPlaylist: String, + $defaultPlaylistName: String, + $accessToken: String!, + $pageAccessToken: String, + $refreshToken: String, + $expiryDate: String, + $title: Boolean, + $description: Boolean, + $thumbnail: Boolean, + $category: Boolean, + $manualTags: Boolean, + $autoTags: Boolean, + $closedCaptions: Boolean, + $language: Boolean, + ) { + createPublishingDestination( + type: $type, + status: $status, + publisherId: $publisherId, + name: $name, + visibility: $visibility, + defaultChannel: $defaultChannel, + defaultChannelName: $defaultChannelName, + defaultPlaylist: $defaultPlaylist, + defaultPlaylistName: $defaultPlaylistName, + accessToken: $accessToken, + pageAccessToken: $pageAccessToken, + refreshToken: $refreshToken, + expiryDate: $expiryDate, + title: $title, + description: $description, + thumbnail: $thumbnail, + category: $category, + manualTags: $manualTags, + autoTags: $autoTags, + closedCaptions: $closedCaptions, + language: $language, + ) { + uid + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createDestinationAction.type), + switchMap(() => { + const params = { + ...getDestinationParams(state$.value), + }; + + if (!params.title) { + params.title = true; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...params, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Destination created', + }), + ), + ); + Router.push('/publishing'); + actions.push(of(clearFormAction())); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/publishing/Destination/redux/epics/exchangeFBAuthCode.js b/anyclip/src/modules/publishing/Destination/redux/epics/exchangeFBAuthCode.js new file mode 100644 index 0000000..1f0c015 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/epics/exchangeFBAuthCode.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SETTINGS_STATUS_ENABLED } from '../../constants'; + +import { exchangeFacebookAuthCodeAction, updateDestinationFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query exchangeFacebookAuthCode($code: String!) { + exchangeFacebookAuthCode(code: $code) { + expiryDate + accessToken + userName + pages { + id + title + token + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(exchangeFacebookAuthCodeAction.type), + switchMap((action) => { + const { code } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + code, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateDestinationFormAction({ + ...data.exchangeFacebookAuthCode, + status: SETTINGS_STATUS_ENABLED, + channels: data.exchangeFacebookAuthCode.pages, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/publishing/Destination/redux/epics/exchangeGAuthCode.js b/anyclip/src/modules/publishing/Destination/redux/epics/exchangeGAuthCode.js new file mode 100644 index 0000000..5df2cdb --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/epics/exchangeGAuthCode.js @@ -0,0 +1,61 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SETTINGS_STATUS_ENABLED } from '../../constants'; + +import { exchangeGoogleAuthCodeAction, updateDestinationFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query exchangeGoogleAuthCode($code: String!) { + exchangeGoogleAuthCode(code: $code) { + expiryDate + accessToken + refreshToken + userName + channels { + id + title + } + playlists { + id + title + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(exchangeGoogleAuthCodeAction.type), + switchMap((action) => { + const { code } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + code, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + updateDestinationFormAction({ + status: SETTINGS_STATUS_ENABLED, + ...data.exchangeGoogleAuthCode, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/publishing/Destination/redux/epics/getDestination.js b/anyclip/src/modules/publishing/Destination/redux/epics/getDestination.js new file mode 100644 index 0000000..f7b0c1f --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/epics/getDestination.js @@ -0,0 +1,78 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getDestinationByIdAction, setDestinationDataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getDestinationById($id: Int!) { + getDestinationById(id: $id) { + id + type + name + publisherId + visibility + defaultChannel + defaultPlaylist + defaultChannelName + defaultPlaylistName + title + description + thumbnail + category + manualTags + autoTags + closedCaptions + language + status + videos + accessToken + pageAccessToken + refreshToken + expiryDate + createdAt + updatedAt + userName + publisher { + name + } + channels { + id + title + } + playlists { + id + title + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getDestinationByIdAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...action.payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (errors.length) { + Router.push('/publishing'); + } else { + actions.push(of(setDestinationDataAction(data.getDestinationById))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/publishing/Destination/redux/epics/getPublishViewabilityConfig.js b/anyclip/src/modules/publishing/Destination/redux/epics/getPublishViewabilityConfig.js new file mode 100644 index 0000000..34ef58e --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/epics/getPublishViewabilityConfig.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { typeSelector } from '../selectors'; +import { getViewabilityConfigAction, setViewabilityConfigAction, updateDestinationFormAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query getPublishViewabilityConfig { + getPublishViewabilityConfig { + mediaPlatform + viewabilities { + name + onlyForExisting + } + transitions { + name + transitions + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getViewabilityConfigAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ query: queryGQL }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const destinationId = action.payload; + const type = typeSelector(state$.value); + + actions.push(of(setViewabilityConfigAction(data.getPublishViewabilityConfig))); + + if (!destinationId && type) { + actions.push( + of( + updateDestinationFormAction({ + visibility: data.getPublishViewabilityConfig.find((config) => config.mediaPlatform === type) + .viewabilities[0].name, + }), + ), + ); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/publishing/Destination/redux/epics/index.js b/anyclip/src/modules/publishing/Destination/redux/epics/index.js new file mode 100644 index 0000000..b0c1244 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/epics/index.js @@ -0,0 +1,17 @@ +import { combineEpics } from 'redux-observable'; + +import createDestination from './createDestination'; +import exchangeFBAuthCode from './exchangeFBAuthCode'; +import exchangeGAuthCode from './exchangeGAuthCode'; +import getDestination from './getDestination'; +import getPublishViewabilityConfig from './getPublishViewabilityConfig'; +import updateDestination from './updateDestination'; + +export default combineEpics( + exchangeGAuthCode, + exchangeFBAuthCode, + createDestination, + getDestination, + updateDestination, + getPublishViewabilityConfig, +); diff --git a/anyclip/src/modules/publishing/Destination/redux/epics/updateDestination.js b/anyclip/src/modules/publishing/Destination/redux/epics/updateDestination.js new file mode 100644 index 0000000..0f77d00 --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/epics/updateDestination.js @@ -0,0 +1,95 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getDestinationParams } from '../../helpers/computedState'; +import { clearFormAction, updateDestinationAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + mutation updateDestination( + $id: Int!, + $type: String!, + $name: String!, + $publisherId: Int!, + $visibility: String!, + $defaultChannel: String, + $defaultChannelName: String, + $defaultPlaylist: String, + $defaultPlaylistName: String, + $title: Boolean, + $description: Boolean, + $thumbnail: Boolean, + $category: Boolean, + $manualTags: Boolean, + $autoTags: Boolean, + $closedCaptions: Boolean, + $language: Boolean, + $status: String!, + $accessToken: String!, + $refreshToken: String, + $expiryDate: String! + ) { + updateDestination( + id: $id, + name: $name, + type: $type, + publisherId: $publisherId, + visibility: $visibility, + defaultChannel: $defaultChannel, + defaultChannelName: $defaultChannelName, + defaultPlaylist: $defaultPlaylist, + defaultPlaylistName: $defaultPlaylistName, + title: $title, + description: $description, + thumbnail: $thumbnail, + category: $category, + manualTags: $manualTags, + autoTags: $autoTags, + closedCaptions: $closedCaptions, + language: $language, + status: $status, + accessToken: $accessToken, + refreshToken: $refreshToken, + expiryDate: $expiryDate + ){ + id + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(updateDestinationAction.type), + switchMap(({ payload }) => { + const params = { + ...getDestinationParams(state$.value), + }; + + if (!params.title) { + params.title = true; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + ...params, + id: payload, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + Router.push('/publishing'); + actions.push(of(clearFormAction())); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/publishing/Destination/redux/selectors/index.js b/anyclip/src/modules/publishing/Destination/redux/selectors/index.js new file mode 100644 index 0000000..436539b --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/selectors/index.js @@ -0,0 +1,42 @@ +import { DESTINATION_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const typeSelector = (state) => state[nameSpace].type; +export const idSelector = (state) => state[nameSpace].id; +export const publisherIdSelector = (state) => state[nameSpace].publisherId; +export const publisherSelector = (state) => state[nameSpace].publisher; +export const nameSelector = (state) => state[nameSpace].name; +export const statusSelector = (state) => state[nameSpace].status; +export const visibilitySelector = (state) => state[nameSpace].visibility; +export const viewabilityConfigSelector = (state) => state[nameSpace].viewabilityConfig; +export const channelsSelector = (state) => state[nameSpace].channels; +export const defaultChannelSelector = (state) => state[nameSpace].defaultChannel; +export const playlistsSelector = (state) => state[nameSpace].playlists; +export const defaultPlaylistSelector = (state) => state[nameSpace].defaultPlaylist; +export const defaultChannelNameSelector = (state) => state[nameSpace].defaultChannelName; +export const defaultPlaylistNameSelector = (state) => state[nameSpace].defaultPlaylistName; +export const userNameSelector = (state) => state[nameSpace].userName; +export const pageAccessTokenSelector = (state) => state[nameSpace].pageAccessToken; +export const titleSelector = (state) => state[nameSpace].title; +export const categorySelector = (state) => state[nameSpace].category; +export const thumbnailSelector = (state) => state[nameSpace].thumbnail; +export const descriptionSelector = (state) => state[nameSpace].description; +export const languageSelector = (state) => state[nameSpace].language; +export const manualTagsSelector = (state) => state[nameSpace].manualTags; +export const autoTagsSelector = (state) => state[nameSpace].autoTags; +export const closedCaptionsSelector = (state) => state[nameSpace].closedCaptions; +export const expiryDateSelector = (state) => state[nameSpace].expiryDate; +export const accessTokenSelector = (state) => state[nameSpace].accessToken; +export const refreshTokenSelector = (state) => state[nameSpace].refreshToken; +export const isLoadingSelector = (state) => state[nameSpace].isLoading; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +const formSelectors = createFormSelector(DESTINATION_REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/publishing/Destination/redux/slices/index.js b/anyclip/src/modules/publishing/Destination/redux/slices/index.js new file mode 100644 index 0000000..1ef89bd --- /dev/null +++ b/anyclip/src/modules/publishing/Destination/redux/slices/index.js @@ -0,0 +1,114 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { DESTINATION_REDUX_FIELD_NAME, SETTINGS_STATUS_ENABLED, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(DESTINATION_REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + type: '', + id: 0, + publisherId: 0, + publisher: null, + name: '', + status: SETTINGS_STATUS_ENABLED, + visibility: null, + viewabilityConfig: [], + channels: [], + defaultChannel: '', + playlists: [], + defaultPlaylist: '', + defaultChannelName: '', + defaultPlaylistName: '', + userName: '', + pageAccessToken: '', + + title: true, + category: true, + thumbnail: true, + description: true, + language: false, + manualTags: true, + autoTags: false, + closedCaptions: false, + + expiryDate: '', + accessToken: '', + refreshToken: '', + isLoading: false, + + activeTabId: TAB_GENERAL, + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@DESTINATION/EDIT', + initialState, + + reducers: { + exchangeGoogleAuthCodeAction: (state) => state, + exchangeFacebookAuthCodeAction: (state) => state, + createDestinationAction: (state) => state, + getDestinationByIdAction: (state) => state, + updateDestinationAction: (state) => state, + getViewabilityConfigAction: (state) => state, + setDestinationDataAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + + state.publisher = { + id: action.payload.publisherId, + name: action.payload.publisher.name, + }; + }, + clearFormAction: () => ({ + ...initialState, + }), + updateDestinationFormAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + setViewabilityConfigAction: (state, action) => { + state.viewabilityConfig = [...action.payload]; + }, + setErrorAction: (state, action) => { + state.errors = { + ...state.errors, + ...action.payload, + }; + }, + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + clearFormAction, + createDestinationAction, + exchangeFacebookAuthCodeAction, + exchangeGoogleAuthCodeAction, + getDestinationByIdAction, + getViewabilityConfigAction, + setDestinationDataAction, + setViewabilityConfigAction, + updateDestinationAction, + updateDestinationFormAction, + + setActiveTabIdAction, + setScrollToFieldNameAction, + setErrorByPropAction, + removeErrorByPropAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/FilterSuggester.tsx b/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/FilterSuggester.tsx new file mode 100644 index 0000000..89b7c41 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/FilterSuggester.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { PublisherType } from '../../types'; + +import { Autocomplete, TextField } from '@/mui/components'; + +import styles from './styles.module.scss'; + +type FilterSuggesterProps = { + type: string; + label: string; + options: PublisherType[]; + value: string | null; + onSearch: (value: string) => void; + onChange: (type: string, id: string) => void; +}; + +function FilterSuggester({ options = [], type, label, value, onSearch, onChange }: FilterSuggesterProps) { + const filterValue = options.find((option) => option.id === value); + + const handleSearch = (event: React.SyntheticEvent) => { + const target = event?.target as HTMLInputElement; + onSearch(target?.value || filterValue?.name || ''); + }; + + const handleChange = (event: React.SyntheticEvent, option: unknown) => { + const option$ = option as PublisherType; + onChange(type, option$?.id); + onSearch(option$?.name || ''); + }; + + return ( + } + /> + ); +} + +export default FilterSuggester; diff --git a/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/index.tsx b/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/index.tsx new file mode 100644 index 0000000..1b626ba --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/index.tsx @@ -0,0 +1,216 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import NextLink from 'next/link'; +import { AddRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; + +import { + PUBLISH_DESTINATION_TYPES, + SETTINGS_STATUS_DISABLED, + SETTINGS_STATUS_DISCONNECTED, + SETTINGS_STATUS_ENABLED, +} from '../../../Destination/constants'; +import { FILTER_TYPE_PLATFORM, FILTER_TYPE_STATUS } from '../../constants'; +import { PCN_POST_DESTINATIONS } from '@/modules/@common/acl/constants'; + +import { FilterOptionType, FilterType } from '../../types'; + +import * as selectors from '../../redux/selectors'; +import { getDestinationsAction, getPublishersAction, setFilterParamAction } from '../../redux/slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +import FilterSuggester from './FilterSuggester'; +import { + Autocomplete, + Button, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + Stack, + TextField, + Typography, +} from '@/mui/components'; + +import styles from './styles.module.scss'; + +function DestinationListHeader() { + const dispatch = useDispatch(); + + const publishers = useSelector(selectors.publishersSelector); + const search = useSelector(selectors.searchSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const status = useSelector(selectors.statusSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + const platform = useSelector(selectors.platformSelector); + const publisherId = useSelector(selectors.publisherIdSelector); + const userPermissions = useSelector(getUserPermissionsSelector); + const [anchorEl, setAnchorEl] = useState<(EventTarget & HTMLButtonElement) | null>(null); + + const handleAddButtonClick = (event: React.MouseEvent) => setAnchorEl(event.currentTarget); + + const handleCloseAddButton = () => setAnchorEl(null); + + const open = Boolean(anchorEl); + + const query = { + search, + page, + pageSize, + status, + sortBy, + sortOrder, + platform, + publisherId, + }; + + const handleGetDestination = (params = {}) => dispatch(getDestinationsAction({ ...query, ...params })); + + const handleFilterChange = (filterType: string, filterValue: string | null) => { + dispatch( + setFilterParamAction({ + [filterType]: filterValue, + }), + ); + + handleGetDestination({ + [filterType]: filterValue, + page: 1, + }); + }; + + const handleSearchChange = (event: React.ChangeEvent) => { + handleGetDestination({ + search: event.target.value, + }); + }; + + const handleSearchClick = () => { + handleGetDestination(); + }; + + const filters: FilterType[] = [ + { + type: FILTER_TYPE_PLATFORM, + placeholder: 'Platform', + options: [ + { + label: 'YouTube', + value: 'YOUTUBE', + }, + { + label: 'Facebook', + value: 'FACEBOOK', + }, + ], + value: query.platform as string, + }, + { + type: FILTER_TYPE_STATUS, + placeholder: 'Status', + options: [ + { + label: 'Enabled', + value: SETTINGS_STATUS_ENABLED, + }, + { + label: 'Disabled', + value: SETTINGS_STATUS_DISABLED, + }, + { + label: 'Disconnected', + value: SETTINGS_STATUS_DISCONNECTED, + }, + ], + value: query.status as string, + }, + ]; + + const suggesters = [ + { + type: 'publisherId', + label: 'Hub', + options: publishers, + onSearch: (searchText = '') => dispatch(getPublishersAction(searchText)), + value: query.publisherId, + }, + ]; + + return ( + <> + + + Publish Destinations + + + +
    + + + + + + ), + }} + /> +
    + + + + + {filters.map((filter) => ( + s.value === filter.value) ?? null} + options={filter.options} + size="small" + onChange={(e, selected$) => { + const selected = selected$ as FilterOptionType | null; + const value$ = (selected?.value ?? null) as FilterOptionType['value'] | null; + return handleFilterChange(filter.type, value$); + }} + renderInput={(params) => } + /> + ))} + {suggesters.map((filter) => ( +
    + +
    + ))} + + {hasPermission(PCN_POST_DESTINATIONS, userPermissions) && ( +
    + + + {PUBLISH_DESTINATION_TYPES.map((dest) => ( + + {dest.label} + + ))} + +
    + )} +
    + + ); +} + +export default DestinationListHeader; diff --git a/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/styles.module.scss b/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/styles.module.scss new file mode 100644 index 0000000..150b6bd --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/components/DestinationsHeader/styles.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Text":"styles_Text__pNm2S","WrapperTitle":"styles_WrapperTitle__iimSc","Search":"styles_Search__aGCf6","Filter":"styles_Filter__e7_ZY","FilterIcon":"styles_FilterIcon__2R_wN","Suggester":"styles_Suggester__1LGxw","AddButtonWrapper":"styles_AddButtonWrapper__FKsYQ","StatusSelect":"styles_StatusSelect___fIfG"}; \ No newline at end of file diff --git a/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/Header.tsx b/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/Header.tsx new file mode 100644 index 0000000..c865746 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/Header.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; + +import { TableCell, TableHead, TableRow, TableSortLabel } from '@/mui/components'; + +import styles from './styles.module.scss'; + +type EnhancedTableHeadProps = { + sortBy: string; + sortOrder: typeof SORT_ASC | typeof SORT_DESC; + onRequestSort: (event: React.MouseEvent, property: string) => void; +}; + +export default function EnhancedTableHead({ sortBy, sortOrder, onRequestSort }: EnhancedTableHeadProps) { + const headCells = [ + { + id: 'id', + label: 'Id', + sortable: true, + }, + { + id: 'platform', + label: 'Platform', + sortable: false, + }, + { + id: 'name', + label: 'Publish Destination Name', + sortable: true, + }, + { + id: 'publisher', + label: 'Hub', + sortable: false, + }, + { + id: 'status', + label: 'Status', + sortable: false, + }, + { + id: 'visibility', + label: 'Visibility', + sortable: false, + }, + ]; + + const createSortHandler = (property: string) => (event: React.MouseEvent) => { + onRequestSort(event, property); + }; + + return ( + + + {headCells.map((headCell) => ( + + {headCell.sortable && ( + + {headCell.label} + {sortBy === headCell.id ? ( + + {sortOrder === SORT_DESC ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + )} + {!headCell.sortable && headCell.label} + + ))} + + + ); +} diff --git a/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/index.tsx b/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/index.tsx new file mode 100644 index 0000000..4b427a8 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/index.tsx @@ -0,0 +1,186 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Facebook, YouTube } from '@mui/icons-material'; + +import { PLATFORM_FACEBOOK, PLATFORM_YOUTUBE } from '../../constants'; +import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort'; + +import { DestinationType } from '../../types'; + +import * as selectors from '../../redux/selectors'; +import { clearFilterParamsAction, getDestinationsAction, setDestinationsAction } from '../../redux/slices'; +import { capitalizeFirstLetter } from '@/modules/publishing/DestinationList/helpers'; + +import Header from './Header'; +import { Table, TableBody, TableCell, TableContainer, TablePagination, TableRow, TableScroll } from '@/mui/components'; + +import styles from './styles.module.scss'; + +const platformIconStyle = { + [PLATFORM_YOUTUBE]: { + color: 'platform.youtube', + icon: YouTube, + }, + [PLATFORM_FACEBOOK]: { + color: 'platform.facebook', + icon: Facebook, + }, +}; + +function DestinationList() { + const router = useRouter(); + const dispatch = useDispatch(); + + const destinations = useSelector(selectors.destinationsSelector); + const totalDestinations = useSelector(selectors.totalDestinationsSelector); + const search = useSelector(selectors.searchSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const status = useSelector(selectors.statusSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + const platform = useSelector(selectors.platformSelector); + const publisherId = useSelector(selectors.publisherIdSelector); + + const query = { + search, + page, + pageSize, + status, + sortBy, + sortOrder, + platform, + publisherId, + }; + const handleGetDestination = (params = {}) => dispatch(getDestinationsAction({ ...query, ...params })); + + useEffect(() => { + handleGetDestination({ page: 1 }); + + return () => { + dispatch(setDestinationsAction([])); + dispatch(clearFilterParamsAction()); + }; + }, []); + + const handleRequestSort = (event: React.MouseEvent, property: string) => { + const isAsc = query.sortBy === property && query.sortOrder === 'ASC'; + handleGetDestination({ + page: 1, + sortBy: property, + sortOrder: isAsc ? 'DESC' : 'ASC', + }); + }; + + const handleChangePage = (event: React.MouseEvent | null, newPage: number) => { + handleGetDestination({ + page: newPage, + }); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + handleGetDestination({ + page: 1, + pageSize: +event.target.value, + }); + }; + + return ( +
    + + + +
    + + {destinations.map((row: DestinationType, index) => { + const labelId = `enhanced-table-checkbox-${index}`; + const Icon = platformIconStyle[row.type].icon; + return ( + router.push(`/publishing/${row.type.toLowerCase()}/${row.id}`)} + className={styles.Row} + role="checkbox" + tabIndex={-1} + key={row.id} + > + + {row.id} + + + + + + {row.name} + + + {row.publisher?.name} + + + {row.status ? capitalizeFirstLetter(row.status) : row.status} + + + {row.visibility ? capitalizeFirstLetter(row.visibility) : row.visibility} + + + ); + })} + +
    +
    + {!!destinations.length && ( + + )} +
    +
    + ); +} + +export default DestinationList; diff --git a/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/styles.module.scss b/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/styles.module.scss new file mode 100644 index 0000000..12277bf --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/components/DestinationsTable/styles.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"styles_Wrapper__Q6zs1","Table":"styles_Table__RFJTF","Row":"styles_Row__fgxyq","Cell":"styles_Cell__h63zv","Icon":"styles_Icon__lOgDQ","VisuallyHidden":"styles_VisuallyHidden__DblxE"}; \ No newline at end of file diff --git a/anyclip/src/modules/publishing/DestinationList/components/index.tsx b/anyclip/src/modules/publishing/DestinationList/components/index.tsx new file mode 100644 index 0000000..e0c19b6 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/components/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import DestinationsHeader from './DestinationsHeader'; +import DestinationsTable from './DestinationsTable'; + +function DestinationsComponent() { + // todo: migrate to + return ( + <> + + + + ); +} + +export default DestinationsComponent; diff --git a/src/modules/publishing/DestinationList/constants/index.ts b/anyclip/src/modules/publishing/DestinationList/constants/index.ts similarity index 100% rename from src/modules/publishing/DestinationList/constants/index.ts rename to anyclip/src/modules/publishing/DestinationList/constants/index.ts diff --git a/anyclip/src/modules/publishing/DestinationList/helpers/index.ts b/anyclip/src/modules/publishing/DestinationList/helpers/index.ts new file mode 100644 index 0000000..d4cdda0 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/helpers/index.ts @@ -0,0 +1 @@ +export const capitalizeFirstLetter = (str: string): string => str[0].toUpperCase() + str.slice(1).toLowerCase(); diff --git a/anyclip/src/modules/publishing/DestinationList/index.ts b/anyclip/src/modules/publishing/DestinationList/index.ts new file mode 100644 index 0000000..d26e499 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/index.ts @@ -0,0 +1,3 @@ +import DestinationList from './components'; + +export default DestinationList; diff --git a/anyclip/src/modules/publishing/DestinationList/redux/epics/addPublishEntries.ts b/anyclip/src/modules/publishing/DestinationList/redux/epics/addPublishEntries.ts new file mode 100644 index 0000000..108890b --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/epics/addPublishEntries.ts @@ -0,0 +1,95 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { DestinationType } from '../../types'; + +import { addPublishEntriesAction, getPublishEntriesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import type { RootState } from '@/modules/@common/store/store'; + +const queryGQL = ` + mutation createPublishEntry($destinations: [PublishEntryInputType]) { + createPublishEntry(destinations: $destinations) { + data { + id + status + } + } + } +`; + +const epic: Epic = (action$) => + action$.pipe( + filter( + (action): action is ReturnType => action.type === addPublishEntriesAction.type, + ), + switchMap((action) => { + const { destinations, videoId } = action.payload; + + const paramsToSend = destinations + .filter((dest: DestinationType) => dest.checked && !dest.published && dest.targetId && dest.targetName) + .map((dest: DestinationType) => ({ + videoId, + platform: dest.type, + accountId: dest.id, + target: { + id: dest.targetId, + name: dest.targetName, + type: dest.targetType.toUpperCase(), + }, + viewability: dest.visibility, + settings: dest.settings, + postText: dest.postText, + })); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + destinations: [...paramsToSend], + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of(getPublishEntriesAction(videoId)), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Video published', + }), + ), + ); + } + + const corruptedDestinations = destinations.filter( + (dest: DestinationType) => dest.checked && !dest.published && (!dest.targetId || !dest.targetName), + ); + + if (corruptedDestinations.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: `There are corrupted destinations (${corruptedDestinations.length})`, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/publishing/DestinationList/redux/epics/deletePublishEntries.ts b/anyclip/src/modules/publishing/DestinationList/redux/epics/deletePublishEntries.ts new file mode 100644 index 0000000..d15e51a --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/epics/deletePublishEntries.ts @@ -0,0 +1,73 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PublishEntryType } from '../../types'; + +import { deletePublishEntriesAction, getPublishEntriesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import type { RootState } from '@/modules/@common/store/store'; + +const queryGQL = ` + mutation deletePublishEntry( + $videoId: String!, + $destinations: [String], + ) { + deletePublishEntry( + videoId: $videoId, + destinations: $destinations, + ) { + data { + id + } + } + } +`; + +const epic: Epic = (action$) => + action$.pipe( + filter( + (action): action is ReturnType => + action.type === deletePublishEntriesAction.type, + ), + switchMap((action) => { + const { entries, videoId } = action.payload; + + const paramsToSend = entries.map((entry: PublishEntryType) => entry.id); + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + destinations: [...paramsToSend], + videoId, + }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: `${entries.length > 1 ? 'Entries' : 'Entry'} removed`, + }), + ), + of(getPublishEntriesAction(videoId)), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinations.ts b/anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinations.ts new file mode 100644 index 0000000..9e303a4 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinations.ts @@ -0,0 +1,95 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { getDestinationsAction, setDestinationsAction, setTotalDestinationsAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +const queryGQL = ` + query destinationsSearch( + $search: String, + $platform: String, + $publisherIds: [Int], + $page: Int, + $pageSize: Int, + $sortBy: String, + $sortOrder: String, + $status: String, + $name: String + ) { + destinationsSearch( + search: $search, + platform: $platform, + publisherIds: $publisherIds, + page: $page, + pageSize: $pageSize, + sortBy: $sortBy, + sortOrder: $sortOrder, + status: $status, + name: $name, + ) { + recordsTotal + page + pageSize + records { + id + type + name + visibility + defaultChannel + defaultChannelName + defaultPlaylist + defaultPlaylistName + closedCaptions + title + description + thumbnail + category + manualTags + autoTags + language + status + videos + updatedAt + publisher { + name, + id + } + } + } + } +`; + +const epic: Epic = (action$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getDestinationsAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap((action) => { + const variables = { ...action.payload }; + + if (action.payload.publisherId) { + variables.publisherIds = [action.payload.publisherId]; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables, + }).pipe( + switchMap(({ data }) => { + const recordsTotal = data?.destinationsSearch?.recordsTotal || 0; + const records = data?.destinationsSearch?.records || []; + + return concat(of(setTotalDestinationsAction(recordsTotal)), of(setDestinationsAction(records))); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinationsPublishers.ts b/anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinationsPublishers.ts new file mode 100644 index 0000000..cd67f20 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/epics/getDestinationsPublishers.ts @@ -0,0 +1,63 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, filter, switchMap } from 'rxjs/operators'; + +import { getPublishersAction, setPublishersAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getToken } from '@/modules/@common/token/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +const gqlQuery = ` + query GetPublishDestinationPublishers( + $watchEnabledOnly: Boolean!, + $removeDisabled: Boolean!, + $formEnabledOnly: Boolean, + $searchText: String, + $limit: Int + ) { + getPublishDestinationPublishers( + watchEnabledOnly: $watchEnabledOnly, + removeDisabled: $removeDisabled, + formEnabledOnly: $formEnabledOnly + searchText: $searchText + limit: $limit + ) { + id + name + } + } +`; + +const epic: Epic = (action$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getPublishersAction.type), + debounceTime(500), + filter(() => !!getToken()), + switchMap((action) => { + const stream$ = gqlRequest({ + query: gqlQuery, + variables: { + watchEnabledOnly: false, + removeDisabled: true, + limit: 20, + searchText: action.payload || '', + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(setPublishersAction(data.getPublishDestinationPublishers))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/publishing/DestinationList/redux/epics/getPublishEntries.ts b/anyclip/src/modules/publishing/DestinationList/redux/epics/getPublishEntries.ts new file mode 100644 index 0000000..6301373 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/epics/getPublishEntries.ts @@ -0,0 +1,74 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { delay, filter, mapTo, switchMap } from 'rxjs/operators'; + +import { PublishEntryType } from '../../types'; + +import { getPublishEntriesAction, setPublishEntriesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +import type { RootState } from '@/modules/@common/store/store'; + +const queryGQL = ` + query searchPublishEntries($videoIds: [String]) { + searchPublishEntries(videoIds: $videoIds) { + totalCount + data { + id + name + videoId + accountId + platform + publishedVideoId + status + viewability + postText + target { + id + type + name + } + settings { + sync + type + } + } + } + } +`; + +const epic: Epic = (action$) => + action$.pipe( + filter( + (action): action is ReturnType => action.type === getPublishEntriesAction.type, + ), + switchMap((action) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoIds: [action.payload], + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length) { + const publishEntries: PublishEntryType[] = data.searchPublishEntries?.data; + + actions.push(of(setPublishEntriesAction(publishEntries))); + + if (publishEntries?.find((entry) => entry.status === 'PROCESSING')) { + actions.push(of(null).pipe(mapTo(getPublishEntriesAction(action.payload)), delay(4000))); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/anyclip/src/modules/publishing/DestinationList/redux/epics/index.ts b/anyclip/src/modules/publishing/DestinationList/redux/epics/index.ts new file mode 100644 index 0000000..4abd0e7 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/epics/index.ts @@ -0,0 +1,17 @@ +import { combineEpics } from 'redux-observable'; + +import addPublishEntries from './addPublishEntries'; +import deletePublishEntries from './deletePublishEntries'; +import getDestinations from './getDestinations'; +import getDestinationsPublishers from './getDestinationsPublishers'; +import getPublishEntries from './getPublishEntries'; +import updatePublishEntry from './updatePublishEntry'; + +export default combineEpics( + getDestinations, + addPublishEntries, + deletePublishEntries, + getPublishEntries, + getDestinationsPublishers, + updatePublishEntry, +); diff --git a/anyclip/src/modules/publishing/DestinationList/redux/epics/updatePublishEntry.ts b/anyclip/src/modules/publishing/DestinationList/redux/epics/updatePublishEntry.ts new file mode 100644 index 0000000..1c8fd6d --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/epics/updatePublishEntry.ts @@ -0,0 +1,52 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { getPublishEntriesAction, updatePublishEntryAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +import type { RootState } from '@/modules/@common/store/store'; + +const queryGQL = ` + mutation updatePublishEntry( + $id: String!, + $videoId: String!, + $settings: [PublishEntrySettingsInputType], + $viewability: String, + $postText: String!, + ) { + updatePublishEntry( + id: $id, + videoId: $videoId, + settings: $settings, + viewability: $viewability, + postText: $postText, + ) { + id + } + } +`; + +const epic: Epic = (action$) => + action$.pipe( + filter( + (action): action is ReturnType => action.type === updatePublishEntryAction.type, + ), + switchMap((action) => { + const { videoId, postText, ...info } = action.payload; + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + videoId, + postText: postText || '', + ...info, + }, + }).pipe(switchMap(() => concat(of(getPublishEntriesAction(videoId))))); + + return concat(stream$); + }), + ); + +export default epic; diff --git a/src/modules/publishing/DestinationList/redux/selectors/index.ts b/anyclip/src/modules/publishing/DestinationList/redux/selectors/index.ts similarity index 100% rename from src/modules/publishing/DestinationList/redux/selectors/index.ts rename to anyclip/src/modules/publishing/DestinationList/redux/selectors/index.ts diff --git a/anyclip/src/modules/publishing/DestinationList/redux/slices/index.ts b/anyclip/src/modules/publishing/DestinationList/redux/slices/index.ts new file mode 100644 index 0000000..29c1463 --- /dev/null +++ b/anyclip/src/modules/publishing/DestinationList/redux/slices/index.ts @@ -0,0 +1,99 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +import { DestinationListStateType, DestinationType, PublishEntryType } from '../../types'; + +const initialState: DestinationListStateType = { + destinations: [], + totalDestinations: 0, + + publishers: [], + + search: '', + page: 1, + pageSize: 15, + sortBy: 'id', + sortOrder: 'DESC', + platform: null, + status: null, + publisherId: null, + + publishEntries: [], +}; + +export const slice = createSlice({ + name: '@@DESTINATION/LIST', + initialState, + + reducers: { + getPublishersAction: (state, action) => ({ + ...state, + ...action.payload, + }), + addPublishEntriesAction: (state, action: PayloadAction<{ destinations: DestinationType[]; videoId: string }>) => { + if (action.payload) { + return state; + } + return state; + }, + getPublishEntriesAction: (state, action: PayloadAction) => { + if (action.payload) { + return state; + } + return state; + }, + deletePublishEntriesAction: (state, action: PayloadAction<{ entries: PublishEntryType[]; videoId: string }>) => { + if (action.payload) { + return state; + } + return state; + }, + updatePublishEntryAction: (state, action: PayloadAction<{ videoId: string; postText?: string }>) => { + if (action.payload) { + return state; + } + return state; + }, + getDestinationsAction: (state, action) => ({ + ...state, + ...action.payload, + }), + setDestinationsAction: (state, action) => { + state.destinations = action.payload; + }, + setTotalDestinationsAction: (state, action) => { + state.totalDestinations = action.payload; + }, + setPublishersAction: (state, action) => { + state.publishers = action.payload; + }, + setPublishEntriesAction: (state, action) => { + state.publishEntries = action.payload; + }, + setFilterParamAction: (state, action) => ({ + ...state, + ...action.payload, + }), + clearFilterParamsAction: () => ({ + ...initialState, + }), + }, +}); + +export const { + addPublishEntriesAction, + clearFilterParamsAction, + deletePublishEntriesAction, + getDestinationsAction, + getPublishEntriesAction, + getPublishersAction, + setDestinationsAction, + setFilterParamAction, + setPublishEntriesAction, + setPublishersAction, + setTotalDestinationsAction, + updatePublishEntryAction, +} = slice.actions; + +export const nameSpace = slice.name; + +export default slice.reducer; diff --git a/anyclip/src/modules/rolesPermissions/Editor/components/Editor.jsx b/anyclip/src/modules/rolesPermissions/Editor/components/Editor.jsx new file mode 100644 index 0000000..9c76bda --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/components/Editor.jsx @@ -0,0 +1,159 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_DETAILS, TAB_PERMISSIONS } from '../constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import DetailsTab from './Tabs/DetailsTab/DetailsTab'; +import PermissionsTab from './Tabs/PermissionsTab/PermissionsTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const displayName = useSelector(selectors.displayNameSelector); + + const id = parseInt(router.query.id, 10); + const isDuplicate = router.asPath.split('/').filter(Boolean).reverse()[0] === 'duplicate'; + + const tabs = [ + { + title: 'Role Details', + id: TAB_DETAILS, + content: DetailsTab, + }, + { + title: 'Permissions', + id: TAB_PERMISSIONS, + content: PermissionsTab, + }, + ].filter(Boolean); + + const getTitle = () => { + if (id && !isDuplicate) { + return `${displayName} > Settings`; + } + + if (isDuplicate) { + return 'Duplicate Role'; + } + + return 'New Role'; + }; + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } + + if (!errorList.length) { + const action = id && !isDuplicate ? updateItemAction(id) : createItemAction(id); + dispatch(action); + } + + dispatch(setErrorByPropAction(validation)); + }; + + useEffect(() => { + dispatch(getItemAction({ id, isDuplicate })); + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + return ( +
    + + + {getTitle()} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + className={styles.Tabs} + > + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/rolesPermissions/Editor/components/Editor.module.scss b/anyclip/src/modules/rolesPermissions/Editor/components/Editor.module.scss new file mode 100644 index 0000000..432dd60 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__P_4a4","Title":"Editor_Title__1sFjt","Controls":"Editor_Controls__LTKly","Tabs":"Editor_Tabs__felE5"}; \ No newline at end of file diff --git a/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/DetailsTab/DetailsTab.jsx b/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/DetailsTab/DetailsTab.jsx new file mode 100644 index 0000000..a5a794f --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/DetailsTab/DetailsTab.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TYPE_ACCOUNT, TYPE_ALL, TYPE_OPTIONS } from '../../../../List/constants'; +import { DEFAULT_PAGE_OPTIONS } from '../../../constants'; + +import * as selectors from '../../../redux/selectors'; +import { getAccountOptionsAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, MenuItem, Select, Stack, Switch, TextField } from '@/mui/components'; + +const NAME_MAX_LENGTH = 100; + +function DetailsTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const displayName = useSelector(selectors.displayNameSelector); + const type = useSelector(selectors.typeSelector); + const account = useSelector(selectors.accountSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + const defaultPage = useSelector(selectors.defaultPageSelector); + const visibleInSelfServe = useSelector(selectors.visibleInSelfServeSelector); + const readOnlyInSelfServe = useSelector(selectors.readOnlyInSelfServeSelector); + + const scheme = useSelector(selectors.schemeSelector); + + const id = parseInt(router.query.id, 10); + const isDuplicate = router.asPath.split('/').filter(Boolean).reverse()[0] === 'duplicate'; + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + handleSetState({ displayName: e.target.value })} + {...getInputPropsByName(scheme, ['displayName'])} + onFocus={() => dispatch(removeErrorByPropAction(['displayName']))} + /> + + + + + + + + + { + handleSetState({ + account: selected, + }); + }} + onOpen={() => { + dispatch(getAccountOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getAccountOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['account']))} + label="" + /> + )} + /> + + + + + + + + + + handleSetState({ + visibleInSelfServe: e.target.checked, + readOnlyInSelfServe: true, + }) + } + /> + + + + + handleSetState({ + readOnlyInSelfServe: e.target.checked, + }) + } + /> + + + ); +} + +export default DetailsTab; diff --git a/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.jsx b/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.jsx new file mode 100644 index 0000000..1f2ff84 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.jsx @@ -0,0 +1,270 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Edit } from '@mui/icons-material'; + +import * as selectors from '../../../redux/selectors'; +import { setAction, updatePermissionsMetadataAction, updateRoleModuleMetadataAction } from '../../../redux/slices'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import UpdatePermissionDialog from '../../UpdatePermissionDialog/UpdatePermissionDialog'; +import UpdateRoleModuleDialog from '../../UpdateRoleModuleDialog/UpdateRoleModuleDialog'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Checkbox, + FormControlLabel, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Typography, +} from '@/mui/components'; + +import styles from './PermissionsTab.module.scss'; + +const TYPE_PERMISSION_GET = 'GET'; +const TYPE_PERMISSION_POST = 'POST'; +const TYPE_PERMISSION_PUT = 'PUT'; +const TYPE_PERMISSION_DELETE = 'DELETE'; + +function PermissionsTab() { + const dispatch = useDispatch(); + const { size } = useFormSettings(); + const [expandedPermission, setExpandedPermission] = useState(null); + const [updateRoleModuleMetadataDialog, setUpdateRoleModuleMetadataDialog] = useState(null); + const [updatePermissionMetadataDialog, setUpdatePermissionMetadataDialog] = useState(null); + + const permissionsModules = useSelector(selectors.permissionsModulesSelector); + const permissions = useSelector(selectors.permissionsSelector); + + const handleSetExpandedPermission = (permissionId) => + setExpandedPermission((prev) => (prev === permissionId ? null : permissionId)); + + const handleSetPermission = (permissionName) => { + dispatch( + setAction({ + permissions: { + ...permissions, + [permissionName]: !permissions[permissionName], + }, + }), + ); + }; + + const handleUpdateRoleMetadataModule = (data) => { + dispatch(updateRoleModuleMetadataAction(data)); + }; + + const handleUpdatePermissionMetadata = (data) => { + dispatch(updatePermissionsMetadataAction(data)); + }; + + return ( + <> + {permissionsModules.map((permission) => { + const crudPermissions = permission.permissions.reduce((acc, per) => { + if ( + [TYPE_PERMISSION_GET, TYPE_PERMISSION_POST, TYPE_PERMISSION_DELETE, TYPE_PERMISSION_PUT].includes(per.type) + ) { + acc[per.type] = per; + } + return acc; + }, {}); + + const innerPermissions = permission.permissions.filter( + (per) => + ![TYPE_PERMISSION_GET, TYPE_PERMISSION_POST, TYPE_PERMISSION_DELETE, TYPE_PERMISSION_PUT].includes( + per.type, + ), + ); + return ( + + handleSetExpandedPermission(permission.id)} + > + + + + + {permission.displayName} + + { + e.stopPropagation(); + setUpdateRoleModuleMetadataDialog({ + id: permission.id, + displayName: permission.displayName, + description: permission.description, + crudPermissions: [ + crudPermissions[TYPE_PERMISSION_GET], + crudPermissions[TYPE_PERMISSION_POST], + crudPermissions[TYPE_PERMISSION_PUT], + crudPermissions[TYPE_PERMISSION_DELETE], + ], + }); + }} + > + + + + + + {crudPermissions[TYPE_PERMISSION_GET]?.name && ( + { + handleSetPermission(crudPermissions[TYPE_PERMISSION_GET]?.name); + }} + /> + } + onClick={(e) => { + e.stopPropagation(); + }} + /> + )} + + + + {crudPermissions[TYPE_PERMISSION_POST]?.name && ( + { + handleSetPermission(crudPermissions[TYPE_PERMISSION_POST]?.name); + }} + /> + } + onClick={(e) => { + e.stopPropagation(); + }} + /> + )} + + + {crudPermissions[TYPE_PERMISSION_PUT]?.name && ( + { + handleSetPermission(crudPermissions[TYPE_PERMISSION_PUT]?.name); + }} + /> + } + onClick={(e) => { + e.stopPropagation(); + }} + /> + )} + + + {crudPermissions[TYPE_PERMISSION_DELETE]?.name && ( + { + handleSetPermission(crudPermissions[TYPE_PERMISSION_DELETE]?.name); + }} + /> + } + onClick={(e) => { + e.stopPropagation(); + }} + /> + )} + + + + + + + + + {innerPermissions.map((innerPermission) => ( + + + + setUpdatePermissionMetadataDialog({ + id: innerPermission.id, + displayName: innerPermission.displayName, + description: innerPermission.description, + name: innerPermission.name, + moduleId: permission.id, + }) + } + > + {innerPermission.displayName} + + + + + {innerPermission.description} + + + + + {innerPermission.name} + + + + handleSetPermission(innerPermission.name)} + /> + + + ))} + +
    +
    +
    +
    +
    + ); + })} + + {updateRoleModuleMetadataDialog && ( + setUpdateRoleModuleMetadataDialog(null)} + onApply={handleUpdateRoleMetadataModule} + /> + )} + + {updatePermissionMetadataDialog && ( + setUpdatePermissionMetadataDialog(null)} + onApply={handleUpdatePermissionMetadata} + /> + )} + + ); +} + +export default PermissionsTab; diff --git a/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.module.scss b/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.module.scss new file mode 100644 index 0000000..1ec9e85 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/components/Tabs/PermissionsTab/PermissionsTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"TableContent":"PermissionsTab_TableContent__n61Tk","Pointer":"PermissionsTab_Pointer__ePIID","PermissionContainer":"PermissionsTab_PermissionContainer__WfZb_"}; \ No newline at end of file diff --git a/anyclip/src/modules/rolesPermissions/Editor/components/UpdatePermissionDialog/UpdatePermissionDialog.jsx b/anyclip/src/modules/rolesPermissions/Editor/components/UpdatePermissionDialog/UpdatePermissionDialog.jsx new file mode 100644 index 0000000..3cbd1af --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/components/UpdatePermissionDialog/UpdatePermissionDialog.jsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Form, FormContent, FormRow, FormSection } from '@/modules/@common/Form'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@/mui/components'; + +const NAME_MAX_LENGTH = 100; +const DESCRIPTION_MAX_LENGTH = 250; + +function UpdatePermissionDialog(props) { + const [fields, setFiled] = useState({ + id: props.data.id, + displayName: props.data.displayName, + description: props.data.description, + moduleId: props.data.moduleId, + }); + + const handleClose = () => props.onClose(); + const handleSetState = (valueObject) => { + setFiled((prev) => ({ ...prev, ...valueObject })); + }; + const handleApply = () => { + props.onApply(fields); + handleClose(); + }; + + return ( + + + Update Permission: {props.data.name} + + +
    + + + + handleSetState({ displayName: e.target.value })} + /> + + + handleSetState({ description: e.target.value })} + /> + + + +
    +
    + + + + +
    + ); +} + +UpdatePermissionDialog.propTypes = { + data: PropTypes.shape({ + id: PropTypes.number, + moduleId: PropTypes.number, + displayName: PropTypes.string, + description: PropTypes.string, + name: PropTypes.string, + }).isRequired, + onClose: PropTypes.func.isRequired, + onApply: PropTypes.func.isRequired, +}; + +export default UpdatePermissionDialog; diff --git a/anyclip/src/modules/rolesPermissions/Editor/components/UpdateRoleModuleDialog/UpdateRoleModuleDialog.jsx b/anyclip/src/modules/rolesPermissions/Editor/components/UpdateRoleModuleDialog/UpdateRoleModuleDialog.jsx new file mode 100644 index 0000000..452615c --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/components/UpdateRoleModuleDialog/UpdateRoleModuleDialog.jsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Form, FormContent, FormGroupTitle, FormRow, FormSection } from '@/modules/@common/Form'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@/mui/components'; + +const NAME_MAX_LENGTH = 100; +const DESCRIPTION_MAX_LENGTH = 250; + +function UpdateRoleModuleDialog(props) { + const [getPermission, postPermission, putPermission, deletePermission] = props.data.crudPermissions; + + const [fields, setFiled] = useState({ + id: props.data.id, + displayName: props.data.displayName, + description: props.data.description, + getDescription: getPermission?.description, + postDescription: postPermission?.description, + putDescription: putPermission?.description, + deleteDescription: deletePermission?.description, + crudPermissions: props.data.crudPermissions, + }); + + const handleClose = () => props.onClose(); + const handleSetState = (valueObject) => { + setFiled((prev) => ({ ...prev, ...valueObject })); + }; + const handleApply = () => { + props.onApply(fields); + handleClose(); + }; + + return ( + + Update Module + +
    + + + + handleSetState({ displayName: e.target.value })} + /> + + + handleSetState({ description: e.target.value })} + /> + + + Permission Description + + {props.data.crudPermissions.map((crud) => { + if (!crud) { + return null; + } + + const fieldStateKey = `${crud.type.toLowerCase()}Description`; + return ( + + {crud.name} ({crud.type}) + + } + > + handleSetState({ [fieldStateKey]: e.target.value })} + /> + + ); + })} + + +
    +
    + + + + +
    + ); +} + +UpdateRoleModuleDialog.propTypes = { + data: PropTypes.shape({ + id: PropTypes.number, + displayName: PropTypes.string, + description: PropTypes.string, + crudPermissions: PropTypes.arrayOf(PropTypes.shape({})), + }).isRequired, + onClose: PropTypes.func.isRequired, + onApply: PropTypes.func.isRequired, +}; + +export default UpdateRoleModuleDialog; diff --git a/anyclip/src/modules/rolesPermissions/Editor/constants/index.js b/anyclip/src/modules/rolesPermissions/Editor/constants/index.js new file mode 100644 index 0000000..5eaf335 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/constants/index.js @@ -0,0 +1,20 @@ +export const TAB_DETAILS = 'details'; +export const TAB_PERMISSIONS = 'permissions'; + +export const REDUX_FIELD_NAME = 'commonForm'; + +export const DEFAULT_PAGE_VIDEO_MANAGER = 'VIDEO_MANAGER'; +export const DEFAULT_PAGE_ANALYTICS = 'ANALYTICS'; +export const DEFAULT_PAGE_MARKETPLACE = 'MARKETPLACE'; +export const DEFAULT_PAGE_WATCH = 'WATCH'; +export const DEFAULT_PAGE_LIVE = 'LIVE'; +export const DEFAULT_PAGE_PLAYER = 'PLAYER'; + +export const DEFAULT_PAGE_OPTIONS = [ + { label: 'Manage Videos', value: DEFAULT_PAGE_VIDEO_MANAGER }, + { label: 'Analytics', value: DEFAULT_PAGE_ANALYTICS }, + { label: 'Marketplace', value: DEFAULT_PAGE_MARKETPLACE }, + { label: 'Watch', value: DEFAULT_PAGE_WATCH }, + { label: 'Live', value: DEFAULT_PAGE_LIVE }, + { label: 'Player', value: DEFAULT_PAGE_PLAYER }, +]; diff --git a/anyclip/src/modules/rolesPermissions/Editor/helpers/validationScheme.js b/anyclip/src/modules/rolesPermissions/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..4e640f6 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/helpers/validationScheme.js @@ -0,0 +1,19 @@ +import { TAB_DETAILS } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'displayName', + tabId: TAB_DETAILS, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/epics/createItem.js b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..67639ca --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/createItem.js @@ -0,0 +1,68 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_ROLE_ITEM } from '@/graphql/services/rolesPermissions/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/rolesPermissions/types/payload/roleItem'; + +import * as selectors from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_ROLE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${CREATE_ROLE_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const displayName = selectors.displayNameSelector(state$.value); + const accountId = selectors.accountSelector(state$.value)?.id; + const defaultPage = selectors.defaultPageSelector(state$.value); + const type = selectors.typeSelector(state$.value); + const readOnlyInSelfServe = selectors.readOnlyInSelfServeSelector(state$.value); + const visibleInSelfServe = selectors.visibleInSelfServeSelector(state$.value); + const permissionsObject = selectors.permissionsSelector(state$.value); + + const permissions = Object.keys(permissionsObject).filter((permission) => permissionsObject[permission]); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + displayName, + defaultPage, + readOnlyInSelfServe, + visibleInSelfServe, + permissions, + type, + ...(accountId && { accountId }), + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/roles-permissions'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/epics/getAccounts.js b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/getAccounts.js new file mode 100644 index 0000000..8e1dd4c --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/getAccounts.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_ROLE_ACCOUNTS } from '@/graphql/services/rolesPermissions/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/rolesPermissions/types/payload/account'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_ROLE_ACCOUNTS}($payload: ${PAYLOAD_NAME}) { + ${GET_ROLE_ACCOUNTS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_ROLE_ACCOUNTS].records; + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/epics/getItem.js b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..46741dc --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/getItem.js @@ -0,0 +1,119 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_ROLE_ITEM } from '@/graphql/services/rolesPermissions/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/rolesPermissions/types/payload/roleItem'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_ROLE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${GET_ROLE_ITEM}(payload: $payload) { + displayName + type + account { + id + name + } + defaultPage + visibleInSelfServe + readOnlyInSelfServe + permissions { + id + name + } + permissionsModules { + id + displayName + description + permissions { + id + name + displayName + description + disabled + hidden + type + } + } + } + } +`; + +const getResponse = ({ data }) => { + const { publisher, ...item } = data[GET_ROLE_ITEM]; + + return { + ...item, + hub: publisher, + }; +}; + +function clearNullableValues(data) { + return Object.fromEntries(Object.entries(data).filter((entry) => entry[1] !== null)); +} + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const { id, isDuplicate } = action.payload; + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open for edit", + }), + ), + ); + + Router.push('/roles-permissions'); + } else { + const data = clearNullableValues(getResponse(response)); + + const payload = { + ...data, + displayName: isDuplicate ? `Copy of ${data.displayName}` : data.displayName, + permissions: {}, + }; + + if (data.permissions) { + payload.permissions = data.permissions.reduce((acc, permission) => { + acc[permission.name] = true; + return acc; + }, {}); + } + + actions.push(of(setAction(payload))); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/epics/index.js b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/index.js new file mode 100644 index 0000000..35fe332 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/index.js @@ -0,0 +1,17 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getAccount from './getAccounts'; +import getItem from './getItem'; +import updateItem from './updateItem'; +import updatePermissionMetadata from './updatePermissionMetadata'; +import updateRoleModuleMetadata from './updateRoleModuleMetadata'; + +export default combineEpics( + getItem, + getAccount, + updateItem, + createItem, + updateRoleModuleMetadata, + updatePermissionMetadata, +); diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateItem.js b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..57d6ad5 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateItem.js @@ -0,0 +1,67 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_ROLE_ITEM } from '@/graphql/services/rolesPermissions/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/rolesPermissions/types/payload/roleItem'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_ROLE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_ROLE_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const displayName = selectors.displayNameSelector(state$.value); + const accountId = selectors.accountSelector(state$.value)?.id; + const defaultPage = selectors.defaultPageSelector(state$.value); + const readOnlyInSelfServe = selectors.readOnlyInSelfServeSelector(state$.value); + const visibleInSelfServe = selectors.visibleInSelfServeSelector(state$.value); + const permissionsObject = selectors.permissionsSelector(state$.value); + + const permissions = Object.keys(permissionsObject).filter((permission) => permissionsObject[permission]); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: action.payload, + displayName, + defaultPage, + readOnlyInSelfServe, + visibleInSelfServe, + permissions, + ...(accountId && { accountId }), + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/roles-permissions'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Updated', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updatePermissionMetadata.js b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updatePermissionMetadata.js new file mode 100644 index 0000000..6a1ae5a --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updatePermissionMetadata.js @@ -0,0 +1,70 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_PERMISSION_METADATA } from '@/graphql/services/rolesPermissions/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/rolesPermissions/types/payload/permissionMetadata'; + +import * as selectors from '../selectors'; +import { setAction, updatePermissionsMetadataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_PERMISSION_METADATA}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_PERMISSION_METADATA}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updatePermissionsMetadataAction.type), + switchMap((action) => { + const permissionsModules = selectors.permissionsModulesSelector(state$.value); + const { id, displayName, description, moduleId } = action.payload; + + const payloadForApi = { + id, + displayName, + description, + }; + + const payloadForUiState = permissionsModules.map((module) => + module.id === moduleId + ? { + ...module, + permissions: module.permissions.map((perm) => + perm.id === payloadForApi.id ? { ...perm, ...payloadForApi } : perm, + ), + } + : module, + ); + + const stream$ = gqlRequest({ + query, + variables: { + payload: payloadForApi, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Permission saved successfully', + }), + ), + of(setAction({ permissionsModules: payloadForUiState })), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateRoleModuleMetadata.js b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateRoleModuleMetadata.js new file mode 100644 index 0000000..7b46872 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/epics/updateRoleModuleMetadata.js @@ -0,0 +1,78 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_ROLE_MODULE_METADATA } from '@/graphql/services/rolesPermissions/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/rolesPermissions/types/payload/roleModuleMetadata'; + +import * as selectors from '../selectors'; +import { setAction, updateRoleModuleMetadataAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_ROLE_MODULE_METADATA}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_ROLE_MODULE_METADATA}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateRoleModuleMetadataAction.type), + switchMap((action) => { + const permissionsModules = selectors.permissionsModulesSelector(state$.value); + const { id, displayName, description, crudPermissions } = action.payload; + + const payloadForApi = { + id, + displayName, + description, + permissions: crudPermissions.filter(Boolean).map(({ id: id$, displayName: displayName$, type }) => ({ + id: id$, + displayName: displayName$, + description: action.payload[`${type.toLowerCase()}Description`], + })), + }; + + const payloadForUiState = permissionsModules.map((module) => + module.id === id + ? { + ...module, + displayName, + description, + permissions: module.permissions.map((perm) => { + const updatedPerm = payloadForApi.permissions.find((perm$) => perm$.id === perm.id); + return updatedPerm ? { ...perm, ...updatedPerm } : perm; + }), + } + : module, + ); + + const stream$ = gqlRequest({ + query, + variables: { + payload: payloadForApi, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Module saved successfully', + }), + ), + of(setAction({ permissionsModules: payloadForUiState })), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/selectors/index.js b/anyclip/src/modules/rolesPermissions/Editor/redux/selectors/index.js new file mode 100644 index 0000000..caf3090 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/selectors/index.js @@ -0,0 +1,24 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const displayNameSelector = (state) => state[nameSpace].displayName; +export const typeSelector = (state) => state[nameSpace].type; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; +export const defaultPageSelector = (state) => state[nameSpace].defaultPage; +export const visibleInSelfServeSelector = (state) => state[nameSpace].visibleInSelfServe; +export const readOnlyInSelfServeSelector = (state) => state[nameSpace].readOnlyInSelfServe; +export const permissionsSelector = (state) => state[nameSpace].permissions; +export const permissionsModulesSelector = (state) => state[nameSpace].permissionsModules; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +// forms +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/rolesPermissions/Editor/redux/slices/index.js b/anyclip/src/modules/rolesPermissions/Editor/redux/slices/index.js new file mode 100644 index 0000000..80d38bf --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/Editor/redux/slices/index.js @@ -0,0 +1,75 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { TYPE_INTERNAL } from '../../../List/constants'; +import { DEFAULT_PAGE_VIDEO_MANAGER, REDUX_FIELD_NAME, TAB_DETAILS } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + displayName: '', + type: TYPE_INTERNAL, + account: null, + accountOptions: null, + defaultPage: DEFAULT_PAGE_VIDEO_MANAGER, + visibleInSelfServe: false, + readOnlyInSelfServe: true, + permissions: {}, // applied permissions + permissionsModules: [], // permissions list for build UI + + activeTabId: TAB_DETAILS, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@ROLES_PERMISSIONS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getAccountOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + updateRoleModuleMetadataAction: (state) => state, + updatePermissionsMetadataAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getAccountOptionsAction, + createItemAction, + updateItemAction, + updateRoleModuleMetadataAction, + updatePermissionsMetadataAction, + + setActiveTabIdAction, + + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.jsx b/anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.jsx new file mode 100644 index 0000000..5e8c7e1 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { AddRounded } from '@mui/icons-material'; + +import { Button, Grid, Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +function Empty() { + const router = useRouter(); + return ( + + + empty-logo + + Click below to create New Roles + + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.module.scss b/anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.module.scss new file mode 100644 index 0000000..439c414 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper___ctlw","EmptyContent":"Empty_EmptyContent__evWxt"}; \ No newline at end of file diff --git a/anyclip/src/modules/rolesPermissions/List/components/List.jsx b/anyclip/src/modules/rolesPermissions/List/components/List.jsx new file mode 100644 index 0000000..49ce938 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/components/List.jsx @@ -0,0 +1,213 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useRouter } from 'next/router'; +import { AddRounded, ContentCopyRounded, FilterAltRounded, SearchRounded } from '@mui/icons-material'; + +import { SEARCH_TEXT_MAX_LENGTH, TYPE_ALL, TYPE_OPTIONS } from '../constants'; + +import { getConfigHeaders } from '../helpers'; +import * as computedState from '../helpers/computedState'; +import * as selectors from '../redux/selectors'; +import { getAccountOptionsAction, getDataAction, setAction, setTableAction } from '../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable, { TableCellActions } from '@/modules/@common/Table'; +import Empty from './Empty/Empty'; +import { + Autocomplete, + Button, + Divider, + IconButton, + InputAdornment, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './List.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +const getAccountTypeName = (accountType) => TYPE_OPTIONS.find((o) => o.value === accountType)?.label; + +function List() { + const router = useRouter(); + const dispatch = useDispatch(); + + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + + const search = useSelector(selectors.searchSelector); + const type = useSelector(selectors.typeSelector); + const account = useSelector(selectors.accountSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + + const shouldShowEmpty = useSelector(computedState.shouldShowEmpty); + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + +
    + handleFilter({ search: target.value, page: 1 })} + inputProps={{ + autoComplete: 'off', + maxLength: SEARCH_TEXT_MAX_LENGTH, + }} + InputProps={{ + endAdornment: ( + + null}> + + + + ), + }} + variant="outlined" + disabled={shouldShowEmpty} + /> +
    + + + + + s.value === type) ?? TYPE_ALL} + options={TYPE_OPTIONS} + size="small" + onChange={(e, selected$) => handleFilter({ type: selected$?.value ?? TYPE_ALL, page: 1 })} + renderInput={(params) => } + /> + dispatch(getAccountOptionsAction(''))} + onChange={(e, selectedAccount) => handleFilter({ account: selectedAccount, page: 1 })} + onInputChange={(e, searchText) => dispatch(getAccountOptionsAction(searchText))} + renderInput={(params) => } + /> + + } + renderActions={ + + + + } + > + {shouldShowEmpty ? ( + + ) : ( + ( + router.push(`/roles-permissions/${row.id}`)} + > + +
    {row.id}
    +
    + +
    {row.displayName}
    +
    + +
    {getAccountTypeName(row.type)}
    +
    + +
    {row.accountName}
    +
    + +
    {row.updatedBy}
    +
    + +
    {dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
    +
    + + + { + e.stopPropagation(); + router.push(`/roles-permissions/${row.id}/duplicate`); + }} + > + + + + +
    + )} + data={data || []} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onFilter={handleFilter} + /> + )} +
    + ); +} + +export default List; diff --git a/anyclip/src/modules/rolesPermissions/List/components/List.module.scss b/anyclip/src/modules/rolesPermissions/List/components/List.module.scss new file mode 100644 index 0000000..5e1351d --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SearchField":"List_SearchField__EXcKk","StatusSelect":"List_StatusSelect__7huli","AdvertiserSelect":"List_AdvertiserSelect__aBa8e","HubSelect":"List_HubSelect__RpUfo","Row":"List_Row__vCk5c","NoWrap":"List_NoWrap__np0AC","Image":"List_Image__eEgkI"}; \ No newline at end of file diff --git a/anyclip/src/modules/rolesPermissions/List/constants/index.js b/anyclip/src/modules/rolesPermissions/List/constants/index.js new file mode 100644 index 0000000..27cfa8c --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/constants/index.js @@ -0,0 +1,20 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const TYPE_ALL = null; +export const TYPE_INTERNAL = 'INTERNAL'; +export const TYPE_EXTERNAL = 'EXTERNAL'; +export const TYPE_ACCOUNT = 'ACCOUNT'; +export const TYPE_API = 'API'; +export const TYPE_OPTIONS = [ + { label: 'Internal', value: TYPE_INTERNAL }, + { label: 'External', value: TYPE_EXTERNAL }, + { label: 'Account', value: TYPE_ACCOUNT }, + { label: 'API', value: TYPE_API }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'displayName'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/rolesPermissions/List/helpers/computedState.js b/anyclip/src/modules/rolesPermissions/List/helpers/computedState.js new file mode 100644 index 0000000..8e57eec --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/helpers/computedState.js @@ -0,0 +1,15 @@ +import { TYPE_ALL } from '../constants'; + +import * as selectors from '../redux/selectors'; + +export const shouldShowEmpty = (state) => { + const data = selectors.dataSelector(state); + const page = selectors.pageSelector(state); + const search = selectors.searchSelector(state); + const status = selectors.typeSelector(state); + const isLoading = selectors.isLoadingSelector(state); + + return !isLoading && Array.isArray(data) && !data.length && page === 1 && !search && status === TYPE_ALL; +}; + +export default {}; diff --git a/anyclip/src/modules/rolesPermissions/List/helpers/index.js b/anyclip/src/modules/rolesPermissions/List/helpers/index.js new file mode 100644 index 0000000..2e92e81 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/helpers/index.js @@ -0,0 +1,42 @@ +export const getConfigHeaders = () => [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '100', + }, + { + id: 'displayName', + label: 'Name', + sortable: true, + width: '255', + }, + { + id: 'type', + label: 'Type', + sortable: true, + width: '200', + }, + { + id: 'accountName', + label: 'Account Name', + width: '255', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: '100', + }, + { + id: 'updatedAt', + label: 'Updated Date', + sortable: true, + width: '100', + }, + { + id: 'action', + label: '', + width: '100', + }, +]; diff --git a/anyclip/src/modules/rolesPermissions/List/redux/epics/getAccounts.js b/anyclip/src/modules/rolesPermissions/List/redux/epics/getAccounts.js new file mode 100644 index 0000000..8e1dd4c --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/redux/epics/getAccounts.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_ROLE_ACCOUNTS } from '@/graphql/services/rolesPermissions/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/rolesPermissions/types/payload/account'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_ROLE_ACCOUNTS}($payload: ${PAYLOAD_NAME}) { + ${GET_ROLE_ACCOUNTS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_ROLE_ACCOUNTS].records; + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/rolesPermissions/List/redux/epics/getData.js b/anyclip/src/modules/rolesPermissions/List/redux/epics/getData.js new file mode 100644 index 0000000..810509e --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/redux/epics/getData.js @@ -0,0 +1,63 @@ +import { TYPE_ALL } from '../../constants'; +import { GET_ROLE_LIST } from '@/graphql/services/rolesPermissions/constants'; + +import { PAYLOAD_GET_ROLE_LIST } from '@/graphql/services/rolesPermissions/types/payload/roleList'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_ROLE_LIST}($payload: ${PAYLOAD_GET_ROLE_LIST}) { + ${GET_ROLE_LIST}(payload: $payload) { + records { + id + displayName + type + accountName + updatedBy + updatedAt + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const type = selectors.typeSelector(state); + const accountId = selectors.accountSelector(state)?.id; + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (type !== TYPE_ALL) { + variables.type = type; + } + + if (accountId) { + variables.accountId = accountId; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const res = data[GET_ROLE_LIST]; + + return { + records: res.records, + recordsTotal: res.recordsTotal, + allRecordsCount: res.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/rolesPermissions/List/redux/epics/index.js b/anyclip/src/modules/rolesPermissions/List/redux/epics/index.js new file mode 100644 index 0000000..ae14d03 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getAccounts from './getAccounts'; +import getData from './getData'; + +export default combineEpics(getData, getAccounts); diff --git a/anyclip/src/modules/rolesPermissions/List/redux/selectors/index.js b/anyclip/src/modules/rolesPermissions/List/redux/selectors/index.js new file mode 100644 index 0000000..4a43845 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/redux/selectors/index.js @@ -0,0 +1,25 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const typeSelector = (state) => state[nameSpace].type; +export const searchSelector = (state) => state[nameSpace].search; +export const accountSelector = (state) => state[nameSpace].account; + +// autocomplete options +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; diff --git a/anyclip/src/modules/rolesPermissions/List/redux/slices/index.js b/anyclip/src/modules/rolesPermissions/List/redux/slices/index.js new file mode 100644 index 0000000..4643c29 --- /dev/null +++ b/anyclip/src/modules/rolesPermissions/List/redux/slices/index.js @@ -0,0 +1,43 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY, TYPE_ALL } from '../../constants'; +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_ASC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + type: TYPE_ALL, + + account: null, + accountOptions: null, +}; + +export const slice = createSlice({ + name: '@@ROLES_PERMISSIONS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getAccountOptionsAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, getAccountOptionsAction, archiveAction } = slice.actions; diff --git a/anyclip/src/modules/sso/Editor/components/Editor.jsx b/anyclip/src/modules/sso/Editor/components/Editor.jsx new file mode 100644 index 0000000..8b15dad --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Editor.jsx @@ -0,0 +1,137 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { PROVIDER_CREATE_URL_MAPPER } from '../../List/constants'; +import { TAB_GENERAL } from '../constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const displayName = useSelector(selectors.displayNameSelector); + const settingsCannotCreateProfile = useSelector(selectors.settingsCannotCreateProfileSelector); + + const routerId = router.query.id; + const id = parseInt(routerId, 10); + + useEffect(() => { + const payload = id ? { id } : { provider: PROVIDER_CREATE_URL_MAPPER[routerId] }; + + dispatch(getItemAction(payload)); + + return () => { + dispatch(setInitialAction()); + }; + }, [routerId]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( +
    + + + {id ? `${displayName} > Settings` : 'New SSO'} + + + + {tabs.length > 1 && ( + + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/sso/Editor/components/Editor.module.scss b/anyclip/src/modules/sso/Editor/components/Editor.module.scss new file mode 100644 index 0000000..f1a348d --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__EZ5j8","Title":"Editor_Title__7BiYy","Controls":"Editor_Controls__iR7ZQ","Tabs":"Editor_Tabs__SPNT_"}; \ No newline at end of file diff --git a/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..f9002c8 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,316 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { AddOutlined, ContentCopyRounded, DeleteRounded, InfoOutlined } from '@mui/icons-material'; + +import { PROVIDER_VALUE_GOOGLE } from '../../../../List/constants'; +import { ALL_HUB_OPTION, ALL_OPTION_ID } from '../../../constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import * as selectors from '../../../redux/selectors'; +import { getHubOptionsAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import { addAllHubsOption } from '@/modules/sso/Editor/helpers/addAllHubsOption'; + +import { FormGroupTitle, FormRow, useFormSettings } from '@/modules/@common/Form'; +import AttributeTable from './components/AttributeTable'; +import { + Alert, + Autocomplete, + Button, + IconButton, + InputAdornment, + Stack, + Switch, + TextField, + Tooltip, +} from '@/mui/components'; + +import styles from './GeneralTab.module.scss'; + +const DISPLAY_NAME_MAX_LENGTH = 100; +const URL_NAME_MAX_LENGTH = 250; +const DOMAIN_NAME_MAX_LENGTH = 255; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + // selectors + const displayName = useSelector(selectors.displayNameSelector); + const allDomains = useSelector(selectors.allDomainsSelector); + const onlyExisting = useSelector(selectors.onlyExistingSelector); + const domains = useSelector(selectors.domainsSelector); + const metadataURL = useSelector(selectors.metadataURLSelector); + const settingsSingleSignOnUrl = useSelector(selectors.settingsSingleSignOnUrlSelector); + const settingsAudienceUri = useSelector(selectors.settingsAudienceUriSelector); + const settingsAllDomainsDisabled = useSelector(selectors.settingsAllDomainsDisabledSelector); + const ssoDefaultHubs = useSelector(selectors.ssoDefaultHubsSelector); + const hubOptions = useSelector(selectors.hubOptionsSelector); + const provider = useSelector(selectors.providerSelector); + const settingsCannotCreateProfile = useSelector(selectors.settingsCannotCreateProfileSelector); + + const scheme = useSelector(selectors.schemeSelector); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + const handleAddDomainFiled = () => { + dispatch(setAction({ domains: [...domains, ''] })); + }; + const handleRemoveDomainField = (index) => { + const newDomains = [...domains.slice(0, index), ...domains.slice(index + 1)]; + dispatch(setAction({ domains: newDomains })); + }; + const handleOnChangeDomainField = (index, value) => { + const newDomains = domains.map((domainValue, domainIndex) => (domainIndex === index ? value : domainValue)); + dispatch(setAction({ domains: newDomains })); + }; + const handleCopyToClipboard = async (value) => { + const res = await copyToClipboard(value); + if (res) { + dispatch( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Copied', + }), + ); + } + }; + + return ( + <> + {settingsCannotCreateProfile && ( + + + This option is disabled since other profile is set with "Allow users to use any domain when they use + Single Sign-On" + + + )} + Settings + + handleSetState({ displayName: e.target.value })} + {...getInputPropsByName(scheme, ['displayName'])} + onFocus={() => dispatch(removeErrorByPropAction(['displayName']))} + /> + + + + handleSetState({ + allDomains: target.checked, + domains: !domains.length ? [''] : domains, + onlyExisting: target.checked || onlyExisting, + }) + } + /> + + + + + handleSetState({ + onlyExisting: target.checked, + }) + } + /> + + + {!allDomains && ( + + {domains.map((_, index) => ( + + + handleOnChangeDomainField(index, e.target.value)} + {...getInputPropsByName(scheme, ['domains', index])} + onFocus={() => dispatch(removeErrorByPropAction(['domains', index]))} + /> + {domains.length > 1 && ( +
    + handleRemoveDomainField(index)}> + + +
    + )} +
    +
    + ))} + + + +
    + )} + + {provider !== PROVIDER_VALUE_GOOGLE && ( + + handleSetState({ metadataURL: e.target.value })} + {...getInputPropsByName(scheme, ['metadataURL'])} + onFocus={() => dispatch(removeErrorByPropAction(['metadataURL']))} + /> + + )} + + {provider !== PROVIDER_VALUE_GOOGLE && ( + <> + + + The following properties are required for {provider} settings + + + + + + + + handleCopyToClipboard(settingsSingleSignOnUrl)}> + + + + ), + }} + /> + + + + handleCopyToClipboard(settingsAudienceUri)}> + + + + ), + }} + /> + + + )} + + {!onlyExisting && ( + <> + + + Assign New Users To Hubs + + + + + + + + + { + const hasAllOption = ssoDefaultHubs.some((option) => option.id === ALL_OPTION_ID); + return hasAllOption ? [] : options; + }} + onChange={(e, selected) => { + const hasAllOption = selected.some((option) => option.id === ALL_OPTION_ID); + handleSetState({ + allHubs: hasAllOption, + ssoDefaultHubs: hasAllOption ? [ALL_HUB_OPTION] : selected, + }); + }} + onOpen={() => { + dispatch(getHubOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getHubOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['ssoDefaultHubs']))} + /> + )} + /> + + + + {provider !== PROVIDER_VALUE_GOOGLE && ( + <> + Create custom rules for assigning users to Hubs + + + + + )} + + )} + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss new file mode 100644 index 0000000..7e7eaeb --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"DomainFieldDelete":"GeneralTab_DomainFieldDelete__w6ksV","DomainFieldRow":"GeneralTab_DomainFieldRow__jQ_0H"}; \ No newline at end of file diff --git a/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.jsx b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.jsx new file mode 100644 index 0000000..147f1ed --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.jsx @@ -0,0 +1,213 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { AddRounded, ExpandMoreRounded } from '@mui/icons-material'; + +import * as selectors from '@/modules/sso/Editor/redux/selectors'; +import { setAction } from '@/modules/sso/Editor/redux/slices'; + +import useLocalPagination from '@/modules/@common/Table/hooks/useLocalPagination'; +import AttributesForm from './AttributesForm'; +import { + Button, + Checkbox, + Menu, + MenuItem, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, +} from '@/mui/components'; + +import styles from './AttributeTable.module.scss'; + +function AttributeTable() { + const dispatch = useDispatch(); + const [showActions, setShowActions] = useState(null); + const [selected, setSelected] = useState([]); + const [attributeForm, setAttributeForm] = useState(null); + + const ssoCustomAttributes = useSelector(selectors.ssoCustomAttributesSelector); + + const pagination = useLocalPagination(25, 1); + + const handleSelectDeselectRow = (rowId) => { + const selectedIndex = selected.indexOf(rowId); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, rowId); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + setSelected(newSelected); + }; + + const handleSelectDeselectAllRows = (checked) => { + setSelected(checked ? ssoCustomAttributes.map((r) => r.id) : []); + }; + + const handleBulkActivate = () => { + const payload = ssoCustomAttributes.map((attribute) => ({ + ...attribute, + status: selected.includes(attribute.id) || attribute.status, + })); + + dispatch( + setAction({ + ssoCustomAttributes: payload, + }), + ); + + setShowActions(''); + }; + + const handleBulkDeactivate = () => { + const payload = ssoCustomAttributes.map((attribute) => ({ + ...attribute, + status: selected.includes(attribute.id) ? false : attribute.status, + })); + + dispatch( + setAction({ + ssoCustomAttributes: payload, + }), + ); + + setShowActions(''); + }; + + const handleBulkDelete = () => { + const payload = ssoCustomAttributes.filter((attribute) => !selected.includes(attribute.id)); + + dispatch( + setAction({ + ssoCustomAttributes: payload, + }), + ); + + setShowActions(''); + }; + + return ( + + + + + {showActions && ( + setShowActions('')}> + Activate + Deactivate + Delete + + )} + + + + + + + + + { + handleSelectDeselectAllRows(checked); + }} + /> + + Name + Status + + + {ssoCustomAttributes.slice(pagination.startIndex, pagination.endIndex).map((attribute) => { + const isItemSelected = selected.includes(attribute.id); + return ( + setAttributeForm(attribute.id)} + > + + { + e.stopPropagation(); + handleSelectDeselectRow(attribute.id); + }} + /> + + +
    {attribute.name}
    +
    + +
    {attribute.status ? 'Active' : 'Disabled'}
    +
    +
    + ); + })} +
    +
    + { + pagination.setCurrentPage(page); + }} + onRowsPerPageChange={(event) => { + pagination.setItemsPerPage(+event.target.value); + pagination.setCurrentPage(1); + }} + /> +
    + {attributeForm && setAttributeForm(null)} />} +
    + ); +} + +export default AttributeTable; diff --git a/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.module.scss b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.module.scss new file mode 100644 index 0000000..178d967 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributeTable.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Controls":"AttributeTable_Controls__KHlYj"}; \ No newline at end of file diff --git a/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.jsx b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.jsx new file mode 100644 index 0000000..a74a188 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.jsx @@ -0,0 +1,292 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { AddOutlined, DeleteRounded } from '@mui/icons-material'; + +import { ALL_HUB_OPTION, ALL_OPTION_ID } from '../../../../constants'; + +import * as selectors from '../../../../redux/selectors'; +import { + attributeAllHubsSelector, + attributeHubsSelector, + attributeNameSelector, + attributesKeyValuesSelector, + fullAccessToStoreFieldsForValidation, + hubOptionsSelector, + schemeAttributeFormSelector, + ssoCustomAttributesSelector, +} from '../../../../redux/selectors'; +import { + getHubOptionsAction, + removeErrorByPropAction, + setAction, + setAttributesFormToInitialAction, + setErrorByPropAttributeFormAction, + setScrollToFieldNameAttributeFormAction, + validateAttributeFields, +} from '../../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { addAllHubsOption } from '@/modules/sso/Editor/helpers/addAllHubsOption'; + +import { Form, FormContent, FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + TextField, +} from '@/mui/components'; + +import styles from './AttributesForm.module.scss'; + +const NAME_MAX_LENGTH = 250; + +function generateUniqueId() { + const timestamp = Date.now(); + const randomPart = Math.random().toString(16).substring(2, 10); + return `${timestamp}-${randomPart}`; +} + +function AttributesForm(props) { + const dispatch = useDispatch(); + const store = useStore(); + const { size } = useFormSettings(); + + const ssoCustomAttributes = useSelector(ssoCustomAttributesSelector); + + const attributeName = useSelector(attributeNameSelector); + const attributesKeyValues = useSelector(attributesKeyValuesSelector); + const attributeHubs = useSelector(attributeHubsSelector); + const attributeAllHubs = useSelector(attributeAllHubsSelector); + const hubOptions = useSelector(hubOptionsSelector); + + const scheme = useSelector(selectors.schemeAttributeFormSelector); + + const handleSetState = (state) => dispatch(setAction(state)); + + const handleAddAttributeFiled = () => { + handleSetState({ attributesKeyValues: [...attributesKeyValues, ['', '']] }); + }; + const handleRemoveAttributeField = (index) => { + const newAttributesKeyValues = [...attributesKeyValues.slice(0, index), ...attributesKeyValues.slice(index + 1)]; + handleSetState({ attributesKeyValues: newAttributesKeyValues }); + }; + const handleOnChangeAttributeKeyField = (index, value) => { + const newAttributesKeyValues = attributesKeyValues.map((attribute, attributeIndex) => + attributeIndex === index ? [value, attribute[1]] : attribute, + ); + handleSetState({ attributesKeyValues: newAttributesKeyValues }); + }; + const handleOnChangeAttributeValueField = (index, value) => { + const newAttributesKeyValues = attributesKeyValues.map((attribute, attributeIndex) => + attributeIndex === index ? [attribute[0], value] : attribute, + ); + handleSetState({ attributesKeyValues: newAttributesKeyValues }); + }; + + const handleAdd = () => { + const { attributeFormId, handleClose } = props; + const exists = ssoCustomAttributes.some((attr) => attr.id === attributeFormId); + + let updatedAttributes; + + if (exists) { + updatedAttributes = ssoCustomAttributes.map((attr) => + attr.id === attributeFormId + ? { + ...attr, + name: attributeName, + ssoCustomAttributesKeys: attributesKeyValues, + ssoCustomAttributesHubs: attributeHubs, + allHubs: attributeAllHubs, + } + : attr, + ); + } else { + const newAttribute = { + id: generateUniqueId(), + status: true, + name: attributeName, + ssoCustomAttributesKeys: attributesKeyValues, + ssoCustomAttributesHubs: attributeHubs, + allHubs: attributeAllHubs, + }; + + updatedAttributes = [...ssoCustomAttributes, newAttribute]; + } + + dispatch(setAction({ ssoCustomAttributes: updatedAttributes })); + handleClose(); + }; + const handleAddStartProcess = () => { + const state = store.getState(); + const allProps = fullAccessToStoreFieldsForValidation(state); + const { validation, errorList } = validateAttributeFields( + schemeAttributeFormSelector(state).map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList[0]; + dispatch(setScrollToFieldNameAttributeFormAction(errorField.fieldName)); + } else { + handleAdd(); + } + + dispatch(setErrorByPropAttributeFormAction(validation)); + }; + + useEffect(() => { + if (props.attributeFormId !== 'new') { + const attribute = ssoCustomAttributes.find((attributeObject) => attributeObject.id === props.attributeFormId); + dispatch( + setAction({ + attributeName: attribute.name, + attributesKeyValues: attribute.ssoCustomAttributesKeys, + attributeHubs: attribute.allHubs ? [ALL_HUB_OPTION] : attribute.ssoCustomAttributesHubs, + attributeAllHubs: attribute.allHubs, + }), + ); + } + }, [props.attributeFormId]); + + useEffect( + () => () => { + dispatch(setAttributesFormToInitialAction()); + }, + [], + ); + + return ( + + Assign Users to Hubs + +
    + + + + handleSetState({ attributeName: e.target.value })} + {...getInputPropsByName(scheme, ['attributeName'])} + onFocus={() => dispatch(removeErrorByPropAction(['attributeName']))} + /> + + + + {attributesKeyValues.map((_, index) => ( + + + handleOnChangeAttributeKeyField(index, e.target.value)} + {...getInputPropsByName(scheme, ['attributesKeyValues', index, 'key'])} + onFocus={() => dispatch(removeErrorByPropAction(['attributesKeyValues', index, 'key']))} + /> + handleOnChangeAttributeValueField(index, e.target.value)} + {...getInputPropsByName(scheme, ['attributesKeyValues', index, 'value'])} + onFocus={() => dispatch(removeErrorByPropAction(['attributesKeyValues', index, 'value']))} + /> + {attributesKeyValues.length > 1 && ( +
    + handleRemoveAttributeField(index)}> + + +
    + )} +
    +
    + ))} + + + +
    + + + + { + const hasAllOption = attributeHubs.some((option) => option.id === ALL_OPTION_ID); + return hasAllOption ? [] : options; + }} + onChange={(e, selected) => { + const hasAllOption = selected.some((option) => option.id === ALL_OPTION_ID); + handleSetState({ + attributeAllHubs: hasAllOption, + attributeHubs: hasAllOption ? [ALL_HUB_OPTION] : selected, + }); + }} + onOpen={() => { + dispatch(getHubOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getHubOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['attributeHubs']))} + /> + )} + /> + + +
    +
    +
    +
    + + + + +
    + ); +} + +AttributesForm.propTypes = { + attributeFormId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + handleClose: PropTypes.func.isRequired, +}; + +export default AttributesForm; diff --git a/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.module.scss b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.module.scss new file mode 100644 index 0000000..43d782e --- /dev/null +++ b/anyclip/src/modules/sso/Editor/components/Tabs/GeneralTab/components/AttributesForm.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"AttributesDelete":"AttributesForm_AttributesDelete__gcJwB","AttributesFieldRow":"AttributesForm_AttributesFieldRow__VAxFV"}; \ No newline at end of file diff --git a/anyclip/src/modules/sso/Editor/constants/index.js b/anyclip/src/modules/sso/Editor/constants/index.js new file mode 100644 index 0000000..2977add --- /dev/null +++ b/anyclip/src/modules/sso/Editor/constants/index.js @@ -0,0 +1,6 @@ +export const TAB_GENERAL = 'general'; +export const REDUX_FIELD_NAME = 'commonForm'; +export const REDUX_ATTRIBUTE_FORM_FIELD_NAME = 'attributeForm'; + +export const ALL_OPTION_ID = 'all'; +export const ALL_HUB_OPTION = { id: ALL_OPTION_ID, name: 'All Hubs' }; diff --git a/anyclip/src/modules/sso/Editor/helpers/addAllHubsOption.js b/anyclip/src/modules/sso/Editor/helpers/addAllHubsOption.js new file mode 100644 index 0000000..4e32a38 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/helpers/addAllHubsOption.js @@ -0,0 +1,3 @@ +import { ALL_HUB_OPTION } from '@/modules/sso/Editor/constants'; + +export const addAllHubsOption = (options) => (options ? [ALL_HUB_OPTION].concat(options) : null); diff --git a/anyclip/src/modules/sso/Editor/helpers/normalizeCustomAttributes.js b/anyclip/src/modules/sso/Editor/helpers/normalizeCustomAttributes.js new file mode 100644 index 0000000..7facb43 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/helpers/normalizeCustomAttributes.js @@ -0,0 +1,14 @@ +function normalizeCustomAttributes(ssoAttributes) { + return ssoAttributes.map((attribute) => { + const { id, ...restAttribute } = attribute; + const filtered = typeof attribute.id === 'number' ? { ...attribute } : restAttribute; + + if (filtered.allHubs) { + filtered.ssoCustomAttributesHubs = []; + } + + return filtered; + }); +} + +export default normalizeCustomAttributes; diff --git a/anyclip/src/modules/sso/Editor/helpers/validationScheme.js b/anyclip/src/modules/sso/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..f780a0e --- /dev/null +++ b/anyclip/src/modules/sso/Editor/helpers/validationScheme.js @@ -0,0 +1,122 @@ +import { TAB_GENERAL } from '../constants'; +import { PROVIDER_VALUE_GOOGLE } from '@/modules/sso/List/constants'; + +// moved from original sso domain validation +const domainRegExp = /^(?!:\/\/)([a-zA-Z0-9-]{1,63}\.){1,4}[a-zA-Z]{2,11}$/; +const urlRegExp = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/; + +export const validationScheme = [ + { + fieldName: 'displayName', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'domains', + tabId: TAB_GENERAL, + dynamic: true, + validation: (domains, fields) => { + if (fields.allDomains) { + return ''; + } + return domains.reduce((acc, value, index) => { + const trimmedValue = value?.trim(); + let errorMessage = ''; + + if (!trimmedValue) { + errorMessage = 'Field cannot be empty'; + } else if (!domainRegExp.test(trimmedValue)) { + errorMessage = 'Invalid domain'; + } + + acc[index] = errorMessage; + + return acc; + }, {}); + }, + }, + { + fieldName: 'metadataURL', + tabId: TAB_GENERAL, + validation: (value, fields) => { + if (fields.provider === PROVIDER_VALUE_GOOGLE) { + return ''; + } + if (!value) { + return 'Field cannot be empty'; + } + + if (!urlRegExp.test(value)) { + return 'Field must be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'ssoDefaultHubs', + tabId: TAB_GENERAL, + validation: (value, fields) => { + if (fields.onlyExisting) { + return ''; + } + if (!value.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; + +export const validationAttributesScheme = [ + { + fieldName: 'attributeName', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'attributesKeyValues', + tabId: TAB_GENERAL, + dynamic: true, + validation: (attributesKeyValues) => + attributesKeyValues.reduce((acc, [key, value], index) => { + acc[index] = { + key: !urlRegExp.test(key) ? 'Invalid key pattern' : '', + value: !value ? 'Field cannot be empty' : '', + }; + return acc; + }, {}), + }, + { + fieldName: 'attributeHubs', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/sso/Editor/redux/epics/createItem.js b/anyclip/src/modules/sso/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..70a4b72 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/redux/epics/createItem.js @@ -0,0 +1,77 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PROVIDER_VALUE_GOOGLE } from '../../../List/constants'; +import { CREATE_SSO_ITEM } from '@/graphql/services/sso/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_UPSERT_SSO_ITEM } from '@/graphql/services/sso/types/payload/ssoUpsertItem'; + +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import normalizeCustomAttributes from '@/modules/sso/Editor/helpers/normalizeCustomAttributes'; +import * as selectors from '@/modules/sso/Editor/redux/selectors'; + +const query = `mutation ${CREATE_SSO_ITEM}($payload: ${PAYLOAD_UPSERT_SSO_ITEM}) { + ${CREATE_SSO_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const displayName = selectors.displayNameSelector(state$.value); + const allDomains = selectors.allDomainsSelector(state$.value); + const allHubs = selectors.allHubsSelectors(state$.value); + const domains = selectors.domainsSelector(state$.value); + const onlyExisting = selectors.onlyExistingSelector(state$.value); + const provider = selectors.providerSelector(state$.value); + const ssoDefaultHubs = selectors.ssoDefaultHubsSelector(state$.value); + const ssoCustomAttributes = selectors.ssoCustomAttributesSelector(state$.value); + const metadataURL = selectors.metadataURLSelector(state$.value); + + const payload = { + displayName, + allDomains, + allHubs, + domains: allDomains ? [] : domains, + onlyExisting, + provider, + ssoDefaultHubs: allHubs ? [] : ssoDefaultHubs, + ssoCustomAttributes: normalizeCustomAttributes(ssoCustomAttributes), + }; + + if (provider !== PROVIDER_VALUE_GOOGLE) { + payload.metadataURL = metadataURL; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/sso'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/sso/Editor/redux/epics/getHubs.js b/anyclip/src/modules/sso/Editor/redux/epics/getHubs.js new file mode 100644 index 0000000..268f83d --- /dev/null +++ b/anyclip/src/modules/sso/Editor/redux/epics/getHubs.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_SSO_HUB_OPTIONS } from '@/graphql/services/sso/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/sso/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_SSO_HUB_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_SSO_HUB_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_SSO_HUB_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + console.log(getResponse(response), 'getResponse(response)'); + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/sso/Editor/redux/epics/getItem.js b/anyclip/src/modules/sso/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..43f71e7 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/redux/epics/getItem.js @@ -0,0 +1,123 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ALL_HUB_OPTION } from '../../constants'; +import { GET_SSO_ITEM } from '@/graphql/services/sso/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_GET_SSO_ITEM } from '@/graphql/services/sso/types/payload/ssoGetItem'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_SSO_ITEM}($payload: ${PAYLOAD_GET_SSO_ITEM}) { + ${GET_SSO_ITEM}(payload: $payload) { + displayName + allDomains + allHubs + onlyExisting + domains + metadataURL + provider + ssoCustomAttributes { + id + allHubs + name + status + ssoCustomAttributesHubs { + id + name + } + ssoCustomAttributesKeys + } + ssoDefaultHubs { + id + name + } + settings { + allDomainsDisabled + cannotCreateProfile + singleSignOnUrl + audienceUri + } + } + } +`; + +const getResponse = ({ data }) => { + const item = data[GET_SSO_ITEM]; + + return item; +}; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const payload = {}; + + if (action.payload.provider) { + payload.provider = action.payload.provider; + } + + if (action.payload.id) { + payload.id = action.payload.id; + } + + const stream$ = gqlRequest( + { + query, + variables: { + payload, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open for edit", + }), + ), + ); + + Router.push('/sso'); + } else { + const res = getResponse(response); + const data = action.payload.id ? res : { settings: res.settings, provider: action.payload.provider }; + + if (res.settings.allDomainsDisabled) { + data.allDomains = false; + } + + if (res.allHubs) { + data.ssoDefaultHubs = [ALL_HUB_OPTION]; + } + + actions.push( + of( + setAction({ + ...data, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/sso/Editor/redux/epics/index.js b/anyclip/src/modules/sso/Editor/redux/epics/index.js new file mode 100644 index 0000000..0f9fd66 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getHubs from './getHubs'; +import getItem from './getItem'; +import updateItem from './updateItem'; + +export default combineEpics(getItem, getHubs, createItem, updateItem); diff --git a/anyclip/src/modules/sso/Editor/redux/epics/updateItem.js b/anyclip/src/modules/sso/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..feba3c3 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/redux/epics/updateItem.js @@ -0,0 +1,78 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_SSO_ITEM } from '@/graphql/services/sso/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { PROVIDER_VALUE_GOOGLE } from '@/modules/sso/List/constants'; + +import { PAYLOAD_UPSERT_SSO_ITEM } from '@/graphql/services/sso/types/payload/ssoUpsertItem'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; +import normalizeCustomAttributes from '@/modules/sso/Editor/helpers/normalizeCustomAttributes'; + +const query = `mutation ${UPDATE_SSO_ITEM}($payload: ${PAYLOAD_UPSERT_SSO_ITEM}) { + ${UPDATE_SSO_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const displayName = selectors.displayNameSelector(state$.value); + const allDomains = selectors.allDomainsSelector(state$.value); + const allHubs = selectors.allHubsSelectors(state$.value); + const domains = selectors.domainsSelector(state$.value); + const onlyExisting = selectors.onlyExistingSelector(state$.value); + const provider = selectors.providerSelector(state$.value); + const ssoDefaultHubs = selectors.ssoDefaultHubsSelector(state$.value); + const ssoCustomAttributes = selectors.ssoCustomAttributesSelector(state$.value); + const metadataURL = selectors.metadataURLSelector(state$.value); + + const payload = { + id: action.payload, + displayName, + allDomains, + allHubs, + domains: allDomains ? [] : domains, + onlyExisting, + provider, + ssoDefaultHubs: allHubs ? [] : ssoDefaultHubs, + ssoCustomAttributes: normalizeCustomAttributes(ssoCustomAttributes), + }; + + if (provider !== PROVIDER_VALUE_GOOGLE) { + payload.metadataURL = metadataURL; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/sso'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Updated', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/sso/Editor/redux/selectors/index.js b/anyclip/src/modules/sso/Editor/redux/selectors/index.js new file mode 100644 index 0000000..e6536aa --- /dev/null +++ b/anyclip/src/modules/sso/Editor/redux/selectors/index.js @@ -0,0 +1,39 @@ +import { REDUX_ATTRIBUTE_FORM_FIELD_NAME, REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); +const formAttributeSelectors = createFormSelector(REDUX_ATTRIBUTE_FORM_FIELD_NAME, nameSpace); + +export const idSelector = (state) => state[nameSpace].id; +export const providerSelector = (state) => state[nameSpace].provider; +export const displayNameSelector = (state) => state[nameSpace].displayName; +export const allDomainsSelector = (state) => state[nameSpace].allDomains; +export const onlyExistingSelector = (state) => state[nameSpace].onlyExisting; +export const domainsSelector = (state) => state[nameSpace].domains; +export const metadataURLSelector = (state) => state[nameSpace].metadataURL; +export const settingsAllDomainsDisabledSelector = (state) => state[nameSpace].settings.allDomainsDisabled; +export const settingsCannotCreateProfileSelector = (state) => state[nameSpace].settings.cannotCreateProfile; +export const settingsSingleSignOnUrlSelector = (state) => state[nameSpace].settings.singleSignOnUrl; +export const settingsAudienceUriSelector = (state) => state[nameSpace].settings.audienceUri; +export const ssoDefaultHubsSelector = (state) => state[nameSpace].ssoDefaultHubs; +export const allHubsSelectors = (state) => state[nameSpace].allHubs; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; +export const ssoCustomAttributesSelector = (state) => state[nameSpace].ssoCustomAttributes; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +// attributes +export const attributeNameSelector = (state) => state[nameSpace].attributeName; +export const attributesKeyValuesSelector = (state) => state[nameSpace].attributesKeyValues; +export const attributeHubsSelector = (state) => state[nameSpace].attributeHubs; +export const attributeAllHubsSelector = (state) => state[nameSpace].attributeAllHubs; + +// forms +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; + +export const scrollFieldAttributeFormSelector = (state) => formAttributeSelectors.getScrollField(state); +export const schemeAttributeFormSelector = (state) => formAttributeSelectors.schemeSelector(state); diff --git a/anyclip/src/modules/sso/Editor/redux/slices/index.js b/anyclip/src/modules/sso/Editor/redux/slices/index.js new file mode 100644 index 0000000..362dc03 --- /dev/null +++ b/anyclip/src/modules/sso/Editor/redux/slices/index.js @@ -0,0 +1,96 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { PROVIDER_VALUE_GOOGLE } from '../../../List/constants'; +import { REDUX_ATTRIBUTE_FORM_FIELD_NAME, REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationAttributesScheme, validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); +const formAttributeSlice = createFormSlice(REDUX_ATTRIBUTE_FORM_FIELD_NAME, validationAttributesScheme); + +export const { validateFields, validateSingleField } = formSlice; +export const { validateFields: validateAttributeFields } = formAttributeSlice; + +const initialState = { + id: null, + displayName: '', + allDomains: false, + onlyExisting: true, + domains: [''], + metadataURL: '', + ssoDefaultHubs: [], + hubOptions: null, + allHubs: false, + ssoCustomAttributes: [], + provider: PROVIDER_VALUE_GOOGLE, + settings: { + allDomainsDisabled: false, + cannotCreateProfile: false, + singleSignOnUrl: '', + audienceUri: '', + }, + + // attributes form + attributeName: '', + attributesKeyValues: [['', '']], + attributeHubs: [], + attributeAllHubs: false, + + activeTabId: TAB_GENERAL, + + ...formSlice.state, + ...formAttributeSlice.state, +}; + +export const slice = createSlice({ + name: '@@SSO/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + setAttributesFormToInitialAction: (state) => { + state.attributeName = ''; + state.attributesKeyValues = [['', '']]; + state.attributeHubs = []; + }, + getItemAction: (state) => state, + getHubOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + + setScrollToFieldNameAttributeFormAction: formAttributeSlice.actions.setScrollToFieldAction, + setErrorByPropAttributeFormAction: formAttributeSlice.actions.updateValidationSchemeAction, + removeErrorByPropAttributeFormAction: formAttributeSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getHubOptionsAction, + createItemAction, + updateItemAction, + setAttributesFormToInitialAction, + + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, + + setScrollToFieldNameAttributeFormAction, + setErrorByPropAttributeFormAction, + removeErrorByPropAttributeFormAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/sso/List/components/Empty/Empty.jsx b/anyclip/src/modules/sso/List/components/Empty/Empty.jsx new file mode 100644 index 0000000..eea7eb2 --- /dev/null +++ b/anyclip/src/modules/sso/List/components/Empty/Empty.jsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { AddRounded } from '@mui/icons-material'; + +import { + PROVIDER_CREATE_URL_AZURE, + PROVIDER_CREATE_URL_GOOGLE, + PROVIDER_CREATE_URL_OKTA, + PROVIDER_VALUE_AZURE, + PROVIDER_VALUE_GOOGLE, + PROVIDER_VALUE_OKTA, + PROVIDERS, +} from '@/modules/sso/List/constants'; + +import { Button, Grid, Menu, MenuItem, Stack, Typography } from '@/mui/components'; + +import EmptyLogo from '@/assets/img/empty.svg'; + +import styles from './Empty.module.scss'; + +const getProviderLabel = (value) => PROVIDERS.find((provider) => provider.value === value)?.label; + +function Empty() { + const [showCreateSsoProviderList, setShowCreateSsoProviderList] = useState(null); + const router = useRouter(); + + const handleNewProvide = (provider) => { + router.push(`/sso/${provider}`); + }; + + return ( + + + empty-logo + + Click below to create New SSO + + <> + + setShowCreateSsoProviderList(null)} + > + handleNewProvide(PROVIDER_CREATE_URL_GOOGLE)}> + {getProviderLabel(PROVIDER_VALUE_GOOGLE)} + + handleNewProvide(PROVIDER_CREATE_URL_AZURE)}> + {getProviderLabel(PROVIDER_VALUE_AZURE)} + + handleNewProvide(PROVIDER_CREATE_URL_OKTA)}> + {getProviderLabel(PROVIDER_VALUE_OKTA)} + + + + + + ); +} + +export default Empty; diff --git a/anyclip/src/modules/sso/List/components/Empty/Empty.module.scss b/anyclip/src/modules/sso/List/components/Empty/Empty.module.scss new file mode 100644 index 0000000..355f394 --- /dev/null +++ b/anyclip/src/modules/sso/List/components/Empty/Empty.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"EmptyWrapper":"Empty_EmptyWrapper__HVUvz","EmptyContent":"Empty_EmptyContent__K8yR9"}; \ No newline at end of file diff --git a/anyclip/src/modules/sso/List/components/List.jsx b/anyclip/src/modules/sso/List/components/List.jsx new file mode 100644 index 0000000..70e42ab --- /dev/null +++ b/anyclip/src/modules/sso/List/components/List.jsx @@ -0,0 +1,245 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useRouter } from 'next/router'; +import { AddRounded, ExpandMoreRounded } from '@mui/icons-material'; + +import { + PROVIDER_CREATE_URL_AZURE, + PROVIDER_CREATE_URL_GOOGLE, + PROVIDER_CREATE_URL_OKTA, + PROVIDER_VALUE_AZURE, + PROVIDER_VALUE_GOOGLE, + PROVIDER_VALUE_OKTA, + PROVIDERS, + STATUS_ACTIVE_VALUE, + STATUS_DISABLED_VALUE, +} from '../constants'; + +import { getConfigHeaders } from '../helpers'; +import * as computedState from '../helpers/computedState'; +import * as selectors from '../redux/selectors'; +import { getDataAction, setAction, setTableAction, statusAction } from '../redux/slices'; +import { omitUndefinedProps } from '@/mui/helpers'; + +import CommonList from '@/modules/@common/List'; +import CommonTable from '@/modules/@common/Table'; +import Empty from './Empty/Empty'; +import { Button, Checkbox, Menu, MenuItem, Stack, TableCell, TableRow, Tooltip } from '@/mui/components'; + +import styles from './List.module.scss'; + +dayjs.extend(utcPlugin); +dayjs.extend(timezonePlugin); + +const getProviderLabel = (value) => PROVIDERS.find((provider) => provider.value === value)?.label; + +function List() { + const router = useRouter(); + const dispatch = useDispatch(); + + const [showCreateSsoProviderList, setShowCreateSsoProviderList] = useState(null); + const [showActions, setShowActions] = useState(null); + + const data = useSelector(selectors.dataSelector); + const page = useSelector(selectors.pageSelector); + const pageSize = useSelector(selectors.pageSizeSelector); + const totalCount = useSelector(selectors.totalCountSelector); + const sortBy = useSelector(selectors.sortBySelector); + const sortOrder = useSelector(selectors.sortOrderSelector); + const selected = useSelector(selectors.selectedSelector); + + const shouldShowEmpty = useSelector(computedState.shouldShowEmpty); + + const handleFilter = (filter) => { + const { sortBy: sortBy$, sortOrder: sortOrder$, page: page$, pageSize: pageSize$, ...mainState } = filter; + + dispatch( + setTableAction( + omitUndefinedProps({ + sortBy: sortBy$, + sortOrder: sortOrder$, + page: page$, + pageSize: pageSize$, + }), + ), + ); + + dispatch( + setAction({ + ...mainState, + }), + ); + dispatch(getDataAction()); + }; + + const handleNewProvide = (provider) => { + router.push(`/sso/${provider}`); + }; + + const handleSelectDeselectAllRows = (checked) => { + dispatch( + setTableAction({ + selected: checked ? data.map((r) => r.id) : [], + }), + ); + }; + + const handleSelectDeselectRow = (rowId) => { + const selectedIndex = selected.indexOf(rowId); + let newSelected = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, rowId); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1)); + } + + dispatch( + setTableAction({ + selected: newSelected, + }), + ); + }; + + const handleChangeStatus = (status) => { + dispatch(statusAction({ ids: selected, status })); + dispatch(setTableAction({ selected: [] })); + setShowActions(''); + }; + + useEffect(() => { + dispatch(getDataAction()); + }, []); + + return ( + + + + {showActions && ( + setShowActions('')}> + handleChangeStatus(STATUS_ACTIVE_VALUE)}>Activate + handleChangeStatus(STATUS_DISABLED_VALUE)}>Deactivate + + )} + + + } + renderActions={ + + <> + + setShowCreateSsoProviderList(null)} + > + handleNewProvide(PROVIDER_CREATE_URL_GOOGLE)}> + {getProviderLabel(PROVIDER_VALUE_GOOGLE)} + + handleNewProvide(PROVIDER_CREATE_URL_AZURE)}> + {getProviderLabel(PROVIDER_VALUE_AZURE)} + + handleNewProvide(PROVIDER_CREATE_URL_OKTA)}> + {getProviderLabel(PROVIDER_VALUE_OKTA)} + + + + + } + > + {shouldShowEmpty ? ( + + ) : ( + { + const isItemSelected = selectedRows.includes(row.id); + return ( + router.push(`/sso/${row.id}`)} + role="checkbox" + aria-checked={isItemSelected} + selected={isItemSelected} + > + + { + e.stopPropagation(); + onSelectDeselectRow(row.id); + }} + /> + + +
    {row.id}
    +
    + +
    {row.displayName}
    +
    + +
    {getProviderLabel(row.provider)}
    +
    + +
    {row.onlyExisting ? 'Only existing users' : 'All users'}
    +
    + +
    {row.status > 0 ? 'Active' : 'Disabled'}
    +
    + +
    {row.updatedBy}
    +
    + +
    {dayjs(row.updatedAt).format('MMM D, YYYY hh:mm A')}
    +
    +
    + ); + }} + data={data || []} + sortBy={sortBy} + sortOrder={sortOrder} + totalCount={totalCount} + page={page} + rowsPerPage={pageSize} + onFilter={handleFilter} + selected={selected} + onSelectDeselectAllRows={handleSelectDeselectAllRows} + onSelectDeselectRow={handleSelectDeselectRow} + /> + )} +
    + ); +} + +export default List; diff --git a/anyclip/src/modules/sso/List/components/List.module.scss b/anyclip/src/modules/sso/List/components/List.module.scss new file mode 100644 index 0000000..5f23e5d --- /dev/null +++ b/anyclip/src/modules/sso/List/components/List.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"SearchField":"List_SearchField__rdGg7","StatusSelect":"List_StatusSelect__qGV71","AdvertiserSelect":"List_AdvertiserSelect__THOaI","HubSelect":"List_HubSelect__PfIHb","Row":"List_Row__8lFfI","NoWrap":"List_NoWrap__i4vih"}; \ No newline at end of file diff --git a/anyclip/src/modules/sso/List/constants/index.js b/anyclip/src/modules/sso/List/constants/index.js new file mode 100644 index 0000000..3dbb72c --- /dev/null +++ b/anyclip/src/modules/sso/List/constants/index.js @@ -0,0 +1,25 @@ +// table +export const ROWS_PER_PAGE_DEFAULT = 15; +export const TABLE_SORT_BY = 'updatedAt'; +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; +// +export const PROVIDER_VALUE_GOOGLE = 'Google'; +export const PROVIDER_VALUE_AZURE = 'AZURE'; +export const PROVIDER_VALUE_OKTA = 'OKTA'; +export const PROVIDERS = [ + { label: 'Google', value: PROVIDER_VALUE_GOOGLE }, + { label: 'Azure AD', value: PROVIDER_VALUE_AZURE, isSAML: true }, + { label: 'Okta', value: PROVIDER_VALUE_OKTA, isSAML: true }, +]; +// +export const PROVIDER_CREATE_URL_GOOGLE = 'google'; +export const PROVIDER_CREATE_URL_AZURE = 'azure'; +export const PROVIDER_CREATE_URL_OKTA = 'okta'; +export const PROVIDER_CREATE_URL_MAPPER = { + [PROVIDER_CREATE_URL_GOOGLE]: PROVIDER_VALUE_GOOGLE, + [PROVIDER_CREATE_URL_AZURE]: PROVIDER_VALUE_AZURE, + [PROVIDER_CREATE_URL_OKTA]: PROVIDER_VALUE_OKTA, +}; + +export const STATUS_ACTIVE_VALUE = 1; +export const STATUS_DISABLED_VALUE = 0; diff --git a/anyclip/src/modules/sso/List/helpers/computedState.js b/anyclip/src/modules/sso/List/helpers/computedState.js new file mode 100644 index 0000000..de91a0f --- /dev/null +++ b/anyclip/src/modules/sso/List/helpers/computedState.js @@ -0,0 +1,11 @@ +import * as selectors from '../redux/selectors'; + +export const shouldShowEmpty = (state) => { + const data = selectors.dataSelector(state); + const page = selectors.pageSelector(state); + const isLoading = selectors.isLoadingSelector(state); + + return !isLoading && Array.isArray(data) && !data.length && page === 1; +}; + +export default {}; diff --git a/anyclip/src/modules/sso/List/helpers/index.js b/anyclip/src/modules/sso/List/helpers/index.js new file mode 100644 index 0000000..8d3460b --- /dev/null +++ b/anyclip/src/modules/sso/List/helpers/index.js @@ -0,0 +1,42 @@ +export const getConfigHeaders = () => [ + { + id: 'id', + label: 'Id', + sortable: true, + width: '100', + }, + { + id: 'displayName', + label: 'Display Name', + sortable: true, + width: '255', + }, + { + id: 'provider', + label: 'Provider', + sortable: true, + width: '255', + }, + { + id: 'onlyExisting', + label: 'Policy', + width: '100', + }, + { + id: 'status', + label: 'Status', + width: '100', + }, + { + id: 'updatedBy', + label: 'Updated By', + sortable: true, + width: '100', + }, + { + id: 'updatedAt', + label: 'Updated Date', + sortable: true, + width: '100', + }, +]; diff --git a/anyclip/src/modules/sso/List/redux/epics/getData.js b/anyclip/src/modules/sso/List/redux/epics/getData.js new file mode 100644 index 0000000..45ad54e --- /dev/null +++ b/anyclip/src/modules/sso/List/redux/epics/getData.js @@ -0,0 +1,51 @@ +import { GET_SSO_ITEMS } from '@/graphql/services/sso/constants'; + +import { PAYLOAD_GET_SSO_ITEMS } from '@/graphql/services/sso/types/payload/ssoList'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_SSO_ITEMS}($payload: ${PAYLOAD_GET_SSO_ITEMS}) { + ${GET_SSO_ITEMS}(payload: $payload) { + records { + id + displayName + provider + onlyExisting + status + updatedBy + updatedAt + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + }; + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const res = data[GET_SSO_ITEMS]; + + return { + records: res.records, + recordsTotal: res.recordsTotal, + allRecordsCount: res.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/sso/List/redux/epics/index.js b/anyclip/src/modules/sso/List/redux/epics/index.js new file mode 100644 index 0000000..54d7e59 --- /dev/null +++ b/anyclip/src/modules/sso/List/redux/epics/index.js @@ -0,0 +1,6 @@ +import { combineEpics } from 'redux-observable'; + +import getData from './getData'; +import status from './status'; + +export default combineEpics(getData, status); diff --git a/anyclip/src/modules/sso/List/redux/epics/status.js b/anyclip/src/modules/sso/List/redux/epics/status.js new file mode 100644 index 0000000..b24a238 --- /dev/null +++ b/anyclip/src/modules/sso/List/redux/epics/status.js @@ -0,0 +1,52 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CHANGE_SSO_STATUS } from '@/graphql/services/sso/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/sso/types/payload/status'; + +import { getDataAction, statusAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${CHANGE_SSO_STATUS} ($payload: ${PAYLOAD_NAME}) { + ${CHANGE_SSO_STATUS}(payload: $payload) { + status + } + } +`; + +export default (action$) => + action$.pipe( + ofType(statusAction.type), + switchMap((action) => { + const { ids, status } = action.payload; + const stream$ = gqlRequest({ + query, + variables: { + payload: { ids, status }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'The Profile Status has been updated', + }), + ), + of(getDataAction()), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/sso/List/redux/selectors/index.js b/anyclip/src/modules/sso/List/redux/selectors/index.js new file mode 100644 index 0000000..62c616a --- /dev/null +++ b/anyclip/src/modules/sso/List/redux/selectors/index.js @@ -0,0 +1,17 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); diff --git a/anyclip/src/modules/sso/List/redux/slices/index.js b/anyclip/src/modules/sso/List/redux/slices/index.js new file mode 100644 index 0000000..a81156a --- /dev/null +++ b/anyclip/src/modules/sso/List/redux/slices/index.js @@ -0,0 +1,36 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, +}; + +export const slice = createSlice({ + name: '@@SSO/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + statusAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, statusAction } = slice.actions; diff --git a/src/modules/uploaderNew/components/MultiUploadStatusDialog/MultiUploadStatusDialog.module.scss b/anyclip/src/modules/uploaderNew/components/MultiUploadStatusDialog/MultiUploadStatusDialog.module.scss similarity index 100% rename from src/modules/uploaderNew/components/MultiUploadStatusDialog/MultiUploadStatusDialog.module.scss rename to anyclip/src/modules/uploaderNew/components/MultiUploadStatusDialog/MultiUploadStatusDialog.module.scss diff --git a/src/modules/uploaderNew/components/MultiUploadStatusDialog/helpers/index.js b/anyclip/src/modules/uploaderNew/components/MultiUploadStatusDialog/helpers/index.js similarity index 100% rename from src/modules/uploaderNew/components/MultiUploadStatusDialog/helpers/index.js rename to anyclip/src/modules/uploaderNew/components/MultiUploadStatusDialog/helpers/index.js diff --git a/src/modules/uploaderNew/components/MultiUploadStatusDialog/index.jsx b/anyclip/src/modules/uploaderNew/components/MultiUploadStatusDialog/index.jsx similarity index 100% rename from src/modules/uploaderNew/components/MultiUploadStatusDialog/index.jsx rename to anyclip/src/modules/uploaderNew/components/MultiUploadStatusDialog/index.jsx diff --git a/src/modules/uploaderNew/components/useShowCancelUploadDialog.js b/anyclip/src/modules/uploaderNew/components/useShowCancelUploadDialog.js similarity index 100% rename from src/modules/uploaderNew/components/useShowCancelUploadDialog.js rename to anyclip/src/modules/uploaderNew/components/useShowCancelUploadDialog.js diff --git a/anyclip/src/modules/uploaderNew/constants/index.js b/anyclip/src/modules/uploaderNew/constants/index.js new file mode 100644 index 0000000..3432dc8 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/constants/index.js @@ -0,0 +1,52 @@ +export const UPLOAD_STATUS_WAIT = 'wait'; +export const UPLOAD_STATUS_UPLOADING = 'uploading'; +export const UPLOAD_STATUS_COMPLETE = 'complete'; +export const UPLOAD_STATUS_CANCELED = 'canceled'; +export const UPLOAD_STATUS_FAILED = 'failed'; + +export const MAX_STATUS_TITLE_LENGTH = 25; + +export const CANCEL_ALL_UPLOAD_TEXT_TITLE = 'Cancel All Uploads?'; +export const CANCEL_ALL_UPLOAD_TEXT_BODY = + 'Your uploads are not complete. Would you like to cancel all ongoing uploads?'; +export const CANCEL_ALL_UPLOAD_TEXT_CANCEL_BUTTON = 'Cancel Uploads'; +export const CANCEL_ALL_UPLOAD_TEXT_CONFIRM_BUTTON = 'Continue Uploads'; + +export const CANCEL_ITEM_UPLOAD_TEXT_TITLE = 'Cancel upload?'; +export const CANCEL_ITEM_UPLOAD_TEXT_BODY = `Your uploads are not complete. Would you like to cancel {name}?`; +export const CANCEL_ITEM_UPLOAD_TEXT_CANCEL_BUTTON = 'Cancel Upload'; +export const CANCEL_ITEM_UPLOAD_TEXT_CONFIRM_BUTTON = 'Continue Upload'; + +export const CANCEL_UPLOAD_PROGRESS_UPLOAD = 'FROM_PROGRESS_UPLOAD'; +export const CANCEL_UPLOAD_LOGIN_AS = 'FROM_LOGIN_AS'; +export const CANCEL_UPLOAD_LOGOUT = 'FROM_LOGOUT'; + +export const SUPPORT_AUDIO_EXT = ['.mp3', '.wav', '.aac', '.wma', '.flac', '.ogg']; + +export const SUPPORT_UPLOAD_EXT = [ + '.mov', + '.qt', + '.mp4', + '.m4v', + '.mpg', + '.mpeg', + '.mpe', + '.mpg', + '.mpeg', + '.avi', + '.mts', + '.m2ts', + '.ts', + '.m3u', + '.m3u8', + '.webm', + '.wmv', +]; + +export const SUPPORT_UPLOAD_EXT_FOR_UPLOAD_VIDEO_DIALOG = [...SUPPORT_UPLOAD_EXT, ...SUPPORT_AUDIO_EXT]; + +export const UPLOAD_MODES_URL = 'UPLOAD_MODE_URL'; +export const UPLOAD_MODES_CSV = 'UPLOAD_MODE_CSV'; +export const UPLOAD_MODES_FILE = 'UPLOAD_MODE_FILE'; + +export const UPLOAD_FLOW_CREATE_VIDEO_VERSION = 'UPLOAD_FLOW_CREATE_VIDEO_VERSION'; diff --git a/anyclip/src/modules/uploaderNew/helpers/audio.js b/anyclip/src/modules/uploaderNew/helpers/audio.js new file mode 100644 index 0000000..7951566 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/helpers/audio.js @@ -0,0 +1,18 @@ +import { SUPPORT_AUDIO_EXT } from '@/modules/uploaderNew/constants'; + +import DEFAULT_THUMBNAIL_PATH_FOR_AUDIO_FORMAT from '@/assets/img/upload/default-img-audio-thumbnail.jpg'; + +export const checkFileIsAudio = (filePath) => { + const ext = filePath.split('.').pop(); + + return SUPPORT_AUDIO_EXT.includes(`.${ext}`); +}; + +export const getDefaultAudioThumbnailAsFile = async () => { + const response = await fetch(DEFAULT_THUMBNAIL_PATH_FOR_AUDIO_FORMAT.src); + const blob = await response.blob(); + + return new File([blob], DEFAULT_THUMBNAIL_PATH_FOR_AUDIO_FORMAT.src, { + type: blob.type, + }); +}; diff --git a/anyclip/src/modules/uploaderNew/helpers/getContentOwnersList.js b/anyclip/src/modules/uploaderNew/helpers/getContentOwnersList.js new file mode 100644 index 0000000..16d0a77 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/helpers/getContentOwnersList.js @@ -0,0 +1,11 @@ +export const getContentOwnersList = (userContentOwners) => + userContentOwners + .filter((owner) => owner.status === 1 && owner.publisherOwnsContent) + .map((owner) => ({ + label: owner.name, + value: owner.contentOwnerId, + status: owner.status, + isPublic: owner.isPublic, + })); + +export default {}; diff --git a/anyclip/src/modules/uploaderNew/helpers/persist.js b/anyclip/src/modules/uploaderNew/helpers/persist.js new file mode 100644 index 0000000..fe040bb --- /dev/null +++ b/anyclip/src/modules/uploaderNew/helpers/persist.js @@ -0,0 +1,25 @@ +import { getUserPreferences, setUserPreferences } from '@/modules/@common/user/helpers'; +import { slice } from '@/modules/uploaderNew/redux/slices'; + +const nameSpace = slice.name; + +const STORAGE = { + feedName: 'selectedFeedName', + contentOwner: 'selectedContentOwner', + hubs: 'selectedHubs', + accessLevel: 'selectedAccessLevel', +}; + +const getPreferences = () => getUserPreferences()?.[nameSpace]; +const setPreferences = (param, value) => { + const preferences = getPreferences(); + setUserPreferences(nameSpace, { ...preferences, [param]: value }); +}; +const buildPersist = (storageName) => ({ + set: (value) => setPreferences(storageName, value), + get: () => getPreferences(storageName)?.[storageName], +}); + +export const persistFeedName = buildPersist(STORAGE.feedName); +export const persistHubs = buildPersist(STORAGE.hubs); +export const persistAccessLevel = buildPersist(STORAGE.accessLevel); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/clearUploadQueue.js b/anyclip/src/modules/uploaderNew/redux/epics/clearUploadQueue.js new file mode 100644 index 0000000..7b6c4f5 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/clearUploadQueue.js @@ -0,0 +1,18 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { processedUploadQueueIdSelector } from '../selectors'; +import { clearAllAction, clearUploadQueueAction, uploadVideoCancelledAction } from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(clearUploadQueueAction.type), + switchMap(() => { + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + + const actions = [of(uploadVideoCancelledAction({ processedUploadQueueId })), of(clearAllAction())]; + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/createVideo.js b/anyclip/src/modules/uploaderNew/redux/epics/createVideo.js new file mode 100644 index 0000000..7f04ebe --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/createVideo.js @@ -0,0 +1,264 @@ +import dayjs from 'dayjs'; +import utcPlugin from 'dayjs/plugin/utc'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { AUDIO_SOURCE_TYPE, EDITORIAL_SOURCE_TYPE } from '@/modules/@common/constants/file'; +import { mapApiError } from '@/modules/@common/constants/mapApiError'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { ACCESS_LEVEL_ENUM, SEND_HUBS_NOTIFICATION } from '@/modules/editorial/shareAndAccess/constants'; +import { UPLOAD_STATUS_COMPLETE, UPLOAD_STATUS_FAILED } from '@/modules/uploaderNew/constants'; + +import { checkFileIsAudio } from '../../helpers/audio'; +import { getCurrentUploadStateFromQueue, processedUploadQueueIdSelector } from '../selectors'; +import { + createVideoAction, + errorAction, + loadingAction, + loadingProgressAction, + setProcessUploadStatusAction, + startUploadFromQueueAction, +} from '../slices'; +import { clearAction as clearCcFilesModuleBeforeUnmount } from '@/modules/@common/ccFiles/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +dayjs.extend(utcPlugin); + +const getNotEmptyValue = (obj) => + Object.fromEntries( + Object.entries(obj).filter( + ([, value]) => + (typeof value === 'string' && value.trim() !== '') || typeof value === 'number' || typeof value === 'boolean', + ), + ); + +const queryGQL = ` + mutation videoCreateMutation($video: VideoInputType!) { + videoCreate(video: $video) { + uid + } + } +`; + +const getErrorMessage = (errors, refId, uploadVideo, videoUrl) => { + const { + statusCode: status, + response: { error: message }, + } = errors[0]; + + if (status === 400 && uploadVideo && Object.keys(uploadVideo).length) { + return 'Invalid file extension'; + } + + if (status === 400 && uploadVideo && videoUrl) { + return 'The video URL is invalid or cannot be accessed. Please verify that you’re using a publicly available, valid URL.'; + } + + if (status === 409) { + return `Video with the GUID ${refId} already exists. Try another GUID.`; + } + + const apiError = mapApiError(); + + return apiError([message]); +}; + +export default (action$, state$) => + action$.pipe( + ofType(createVideoAction.type), + switchMap(() => { + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + const { + title, + refId, + description, + owner, + feed, + uploadVideo, + date, + language, + isNotify, + labels, + keywords, + iab, + evergreen, + notes, + videoUrl, + uploadCsv, + accessLevel, + sites, + ccFiles, + + landingPageLink, + primaryText, + buttonLabel, + secondaryText, + timeToAppear, + advertiserId, + advertiserName, + advertiserLogo, + sponsored, + + sendHubsNotification, + } = getCurrentUploadStateFromQueue(state$.value); + + const isAudio = checkFileIsAudio(uploadVideo.downloadUrl || videoUrl); + + const videoData = { + type: 'SHORT_FORM', + name: title, + plot: description, + videoUrl: uploadVideo.downloadUrl || videoUrl, + contentOwner: owner.value, + lang: [language.value], + evergreen, + landingPageLink, + sponsored: getNotEmptyValue({ + primaryText, + buttonLabel, + secondaryText, + timeToAppear: parseInt(timeToAppear, 10), + advertiserId, + advertiserName, + advertiserLogo, + sponsored, + }), + access: { + level: accessLevel, + }, + features: [], + }; + + if (videoUrl) { + videoData.publisherLink = videoUrl; + } + + if (accessLevel === ACCESS_LEVEL_ENUM.site) { + videoData.access.sites = sites.map((site) => site.value.toString()); + + if (sendHubsNotification) { + videoData.features.push(SEND_HUBS_NOTIFICATION); + } + } + + if (uploadVideo?.ccFiles?.length) { + videoData.ccFiles = uploadVideo.ccFiles.map((uploadedCcFile, index) => { + const { fileObject, ...ccFile } = ccFiles[index]; + return { + ...ccFile, + file: uploadedCcFile.downloadUrl, + }; + }); + } + + videoData.videoCreationDate = dayjs(date || new Date()) + .utc() + .valueOf(); + + if (uploadVideo.thumbnailDownloadUrl) { + videoData.thumbnailUrl = uploadVideo.thumbnailDownloadUrl; + } + + if (feed) { + videoData.feedDescription = feed.feedDescription; + videoData.feedSource = feed.feedSource; + videoData.feedId = feed.value; + } + + if (refId) { + videoData.refId = refId; + } + + if (labels.length) { + videoData.label = labels; + } + + if (keywords.length) { + videoData.keywords = keywords; + } + + if (iab.length) { + videoData.iab = iab.map((o) => ({ id: o.id })); + } + + if (notes.length) { + videoData.notes = notes; + } + + if (feed?.value) { + videoData.features.push('ENABLE_VIDEO', 'TOP_PRIORITY'); + } else { + videoData.features.push( + 'GENERATE_HLS', + 'TAGGING', + 'CLASSIFICATION', + 'SPEECH_RECOGNITION', + 'GENERATE_VIDEO_RESOLUTIONS', + 'ENABLE_VIDEO', + 'ENCODE', + 'TOP_PRIORITY', + ); + } + + if (isNotify) { + videoData.features.push('SEND_NOTIFICATION'); + } + + videoData.origin = !isAudio ? EDITORIAL_SOURCE_TYPE : AUDIO_SOURCE_TYPE; + + const stream$ = gqlRequest( + { + query: queryGQL, + variables: { video: videoData }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: getErrorMessage(errors, refId, uploadVideo, videoUrl, uploadCsv), + }), + ), + of( + setProcessUploadStatusAction({ + status: UPLOAD_STATUS_FAILED, + processedUploadQueueId, + }), + ), + of(startUploadFromQueueAction()), + ); + } else { + actions.push( + of( + loadingProgressAction({ + percentage: 1, + processedUploadQueueId, + }), + ), + of(clearCcFilesModuleBeforeUnmount()), + of( + setProcessUploadStatusAction({ + status: UPLOAD_STATUS_COMPLETE, + processedUploadQueueId, + }), + ), + of(startUploadFromQueueAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$, of(loadingAction(false)), of(errorAction({}))); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/createVideoCsv.js b/anyclip/src/modules/uploaderNew/redux/epics/createVideoCsv.js new file mode 100644 index 0000000..5f9d68c --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/createVideoCsv.js @@ -0,0 +1,112 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ACCESS_LEVEL_ENUM } from '@/modules/editorial/shareAndAccess/constants'; +import { UPLOAD_STATUS_COMPLETE, UPLOAD_STATUS_FAILED } from '@/modules/uploaderNew/constants'; + +import { getContentOwnersList } from '../../helpers/getContentOwnersList'; +import { getCurrentUploadStateFromQueue, processedUploadQueueIdSelector } from '../selectors'; +import { + createVideoFromCsvAction, + errorAction, + isOpenAction, + loadingAction, + loadingProgressAction, + ownerAction, + setProcessUploadStatusAction, + startUploadFromQueueAction, +} from '../slices'; +import { clearAction as clearCcFilesModuleBeforeUnmount } from '@/modules/@common/ccFiles/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserContentOwnersSelector } from '@/modules/@common/user/redux/selectors'; + +const queryGQL = ` + mutation videoCsvCreateMutation($video: VideoCsvInputType!) { + videoCsvCreate(video: $video) { + uid + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(createVideoFromCsvAction.type), + switchMap(() => { + const userContentOwners = getUserContentOwnersSelector(state$.value); + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + const state = getCurrentUploadStateFromQueue(state$.value); + const { feed, uploadCsv, language, evergreen, notes, accessLevel, sites } = state; + + const videoData = { + videoUrl: uploadCsv.downloadUrl, + lang: [language.value], + evergreen, + access: { + level: accessLevel, + }, + }; + + if (accessLevel === ACCESS_LEVEL_ENUM.site) { + videoData.access.sites = sites.map((site) => site.value.toString()); + } + + if (state.ccFiles.length) { + videoData.ccFiles = state.ccFiles; + } + + if (feed) { + videoData.feedId = feed.value; + } + + if (notes.length) { + videoData.notes = notes; + } + + const stream$ = gqlRequest({ + query: queryGQL, + variables: { video: videoData }, + }).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + const contentOwners = getContentOwnersList(userContentOwners); + + actions.push( + of( + loadingProgressAction({ + percentage: 1, + processedUploadQueueId, + }), + ), + of(ownerAction(contentOwners[0])), + of(isOpenAction(false)), + of(clearCcFilesModuleBeforeUnmount()), + of( + setProcessUploadStatusAction({ + status: UPLOAD_STATUS_COMPLETE, + processedUploadQueueId, + }), + ), + of(startUploadFromQueueAction()), + ); + } else { + actions.push( + of( + setProcessUploadStatusAction({ + status: UPLOAD_STATUS_FAILED, + processedUploadQueueId, + }), + ), + of(startUploadFromQueueAction()), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$, of(loadingAction(false)), of(errorAction({}))); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/createVideoVersion.js b/anyclip/src/modules/uploaderNew/redux/epics/createVideoVersion.js new file mode 100644 index 0000000..bae9043 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/createVideoVersion.js @@ -0,0 +1,175 @@ +import dayjs from 'dayjs'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; +import { UPLOAD_STATUS_COMPLETE, UPLOAD_STATUS_FAILED } from '@/modules/uploaderNew/constants'; + +import { getCurrentUploadStateFromQueue, processedUploadQueueIdSelector } from '../selectors'; +import { + createVideoVersionAction, + errorAction, + loadingAction, + loadingProgressAction, + setProcessUploadStatusAction, + startUploadFromQueueAction, +} from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { videosSelector } from '@/modules/editorial/editorialSearchResults/redux/selectors'; +import { + videosAction, + videosChangeProcessingStatusMonitoringAction, +} from '@/modules/editorial/editorialSearchResults/redux/slices'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; +import { selectedVideoAction } from '@/modules/editorial/editorialVideoDetails/redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + mutation CreateNewVideoVersion( + $videoId: String! + $videoUrl: String! + $videoCreationDate: Float! + $features: [String] + $videoVersionId: String + $videoVersionNotes: String + $sites: [String] + ) { + createNewVideoVersion( + videoId: $videoId + videoUrl: $videoUrl + videoCreationDate: $videoCreationDate + features: $features + videoVersionId: $videoVersionId + videoVersionNotes: $videoVersionNotes + sites: $sites + ) + } +`; + +const FEATURES_SEND_NOTIFICATION = 'SEND_NOTIFICATION'; +const FEATURES_SEND_HUBS_NOTIFICATION = 'SEND_HUBS_NOTIFICATION'; + +const getErrorMessage = (errors) => { + const { message } = errors[0]; + + return message; +}; + +export default (action$, state$) => + action$.pipe( + ofType(createVideoVersionAction.type), + switchMap(() => { + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + const { + videoId, + uploadVideo, + videoUrl, + date, + isNotify, + isNotifyToAccessUsers, + videoVersionId, + videoVersionNotes, + videoAccess, + } = getCurrentUploadStateFromQueue(state$.value); + + const videos = videosSelector(state$.value); + const selectedVideo = selectedVideoSelector(state$.value); + + const variables = { + videoId, + videoUrl: uploadVideo.downloadUrl || videoUrl, + videoCreationDate: dayjs(date || new Date()) + .utc() + .valueOf(), + features: [], + }; + + if (isNotify) { + variables.features.push(FEATURES_SEND_NOTIFICATION); + } + + if (isNotifyToAccessUsers) { + variables.features.push(FEATURES_SEND_HUBS_NOTIFICATION); + } + + if (videoVersionId) { + variables.videoVersionId = videoVersionId; + } + + if (videoVersionNotes) { + variables.videoVersionNotes = videoVersionNotes; + } + + if (videoAccess?.sites) { + variables.sites = videoAccess.sites.map((site) => site.id); + } + + const stream$ = gqlRequest( + { + query: queryGQL, + variables, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: getErrorMessage(errors), + }), + ), + of( + setProcessUploadStatusAction({ + status: UPLOAD_STATUS_FAILED, + processedUploadQueueId, + }), + ), + of(startUploadFromQueueAction()), + ); + } else { + const updateVideoList = videos.map((video) => { + if (video.uid === videoId) { + return { + ...video, + status: 'PROCESSING', + }; + } + return video; + }); + actions.push( + of( + loadingProgressAction({ + percentage: 1, + processedUploadQueueId, + }), + ), + of( + setProcessUploadStatusAction({ + status: UPLOAD_STATUS_COMPLETE, + processedUploadQueueId, + }), + ), + of(startUploadFromQueueAction()), + of(videosAction(updateVideoList)), + of(videosChangeProcessingStatusMonitoringAction()), + ); + + if (selectedVideo?.uid === videoId) { + actions.push(of(selectedVideoAction({ ...selectedVideo, status: 'PROCESSING' }))); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$, of(loadingAction(false)), of(errorAction({}))); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/getAdvertisers.ts b/anyclip/src/modules/uploaderNew/redux/epics/getAdvertisers.ts new file mode 100644 index 0000000..f678745 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/getAdvertisers.ts @@ -0,0 +1,86 @@ +import type { Action } from 'redux'; +import type { Epic } from 'redux-observable'; +import { EMPTY, of, timer } from 'rxjs'; +import { debounce, filter, switchMap } from 'rxjs/operators'; + +import { getAdvertiserOptions, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import type { GraphQLResponse } from '@/modules/@common/store/helpers'; + +import type { RootState } from '@/modules/@common/store/store'; + +export type RequestPayloadType = { + searchText?: string; + pageSize: number; + siteIds?: number[]; +}; + +type ResponseType = { + data: { + id: string; + name: string; + }[]; +}; + +type ActionPayload = { + searchText: string; +}; + +const queryName = 'videoUploadGetAdvertiserOptions' as const; + +const getResponse = (data: ResponseType) => data.data; + +const query = ` + query VideoUploadGetAdvertiserOptions( + $pageSize: Int, + $searchText: String, + ) { + ${queryName}( + pageSize: $pageSize, + searchText: $searchText, + ) { + data { + id + name + logo + } + } + } +`; + +const getSupplyTagOptionsEpic: Epic = (action$) => + action$.pipe( + filter((action): action is ReturnType => action.type === getAdvertiserOptions.type), + debounce((action) => { + if (!action.payload) { + return timer(0); + } + const { searchText = '' } = action.payload; + return timer(searchText.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const actionPayload = action.payload! as ActionPayload; + const payload: RequestPayloadType = { + searchText: actionPayload.searchText || '', + pageSize: 500, + }; + + return gqlRequest({ + query, + variables: payload, + }).pipe( + switchMap((response: GraphQLResponse) => { + if (!response.errors.length) { + return of( + setAction({ + advertiserOptions: getResponse(response.data[queryName]), + }), + ); + } + return EMPTY; + }), + ); + }), + ); + +export default getSupplyTagOptionsEpic; diff --git a/anyclip/src/modules/uploaderNew/redux/epics/getFeedSources.js b/anyclip/src/modules/uploaderNew/redux/epics/getFeedSources.js new file mode 100644 index 0000000..18a9c52 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/getFeedSources.js @@ -0,0 +1,76 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { debounceTime, switchMap } from 'rxjs/operators'; + +import { persistFeedName } from '../../helpers/persist'; +import { feedAction, feedListAction, getFeedSourcesAction, ownerAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const queryGQL = ` + query GetVideoFeedSourcesByAccount( + $searchText: String, + ) { + getVideoFeedSourcesByAccount( + searchText: $searchText, + ) { + records { + id + name + description + schedule_status + status + accessLevel + contentOwner { + id + name + } + } + } + } +`; + +export default (action$) => + action$.pipe( + ofType(getFeedSourcesAction.type), + debounceTime(+process.env.APP_CLEAR_TIMEOUT), + switchMap(({ payload = '' }) => { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + searchText: payload, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions = []; + + if (!errors.length && data.getVideoFeedSourcesByAccount) { + const feedSources = data.getVideoFeedSourcesByAccount.records.map((feed) => ({ + label: feed.description.length ? feed.description : `Invalid data(${feed.name})`, + value: feed.id, + feedDescription: feed.description, + feedSource: feed.name, + scheduleStatus: feed.schedule_status, + accessLevel: feed.accessLevel, + contentOwner: { + label: feed.contentOwner.name, + value: feed.contentOwner.id, + }, + })); + const feedName = persistFeedName.get(); + const isFeedNameFromStorageValid = feedSources.find((item) => item?.value === feedName?.value); + const selectedFeed = isFeedNameFromStorageValid ? feedName : feedSources[0]; + + actions.push(of(feedListAction(feedSources)), of(feedAction(selectedFeed))); + + if (selectedFeed?.contentOwner.value) { + actions.push(of(ownerAction(selectedFeed.contentOwner))); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/handleCancelUploadFromConfirmDialog.js b/anyclip/src/modules/uploaderNew/redux/epics/handleCancelUploadFromConfirmDialog.js new file mode 100644 index 0000000..09b1176 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/handleCancelUploadFromConfirmDialog.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + CANCEL_UPLOAD_LOGIN_AS, + CANCEL_UPLOAD_LOGOUT, + CANCEL_UPLOAD_PROGRESS_UPLOAD, + UPLOAD_STATUS_CANCELED, +} from '@/modules/uploaderNew/constants'; + +import { uploadConfirmDialogSelector } from '../selectors'; +import { + clearUploadQueueAction, + handleCancelUploadFromConfirmDialogAction, + hideCancelUploadConfirmDialogAction, + loadingProgressAction, + setProcessUploadStatusAction, + startUploadFromQueueAction, + uploadVideoCancelledAction, +} from '../slices'; +import { cancelImpersonationAction, logOutEventAction } from '@/modules/@common/token/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(handleCancelUploadFromConfirmDialogAction.type), + switchMap(() => { + const uploadConfirmDialog = uploadConfirmDialogSelector(state$.value); + const actions = []; + + // from upload status + if (uploadConfirmDialog.context === CANCEL_UPLOAD_PROGRESS_UPLOAD) { + if (uploadConfirmDialog?.processedUploadQueueId) { + const { processedUploadQueueId } = uploadConfirmDialog; + actions.push( + of(uploadVideoCancelledAction({ processedUploadQueueId })), + of(setProcessUploadStatusAction({ processedUploadQueueId, status: UPLOAD_STATUS_CANCELED })), + of(loadingProgressAction({ percentage: 0, processedUploadQueueId })), + of(startUploadFromQueueAction()), + ); + } else { + actions.push(of(clearUploadQueueAction())); + } + } + + // from login as + if (uploadConfirmDialog.context === CANCEL_UPLOAD_LOGIN_AS) { + actions.push(of(clearUploadQueueAction()), of(cancelImpersonationAction())); + } + + // from logout + if (uploadConfirmDialog.context === CANCEL_UPLOAD_LOGOUT) { + actions.push(of(clearUploadQueueAction()), of(logOutEventAction())); + } + + return concat(...actions, of(hideCancelUploadConfirmDialogAction())); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/index.js b/anyclip/src/modules/uploaderNew/redux/epics/index.js new file mode 100644 index 0000000..b3a0a3b --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/index.js @@ -0,0 +1,49 @@ +import { combineEpics } from 'redux-observable'; + +import clearUploadQueue from './clearUploadQueue'; +import createVideo from './createVideo'; +import createVideoCsv from './createVideoCsv'; +import createVideoVersion from './createVideoVersion'; +import getAdvertisers from './getAdvertisers'; +import getFeedSources from './getFeedSources'; +import handleCancelUploadFromConfirmDialog from './handleCancelUploadFromConfirmDialog'; +import siteSuggester from './siteSuggester'; +import startUploadFromQueue from './startUploadFromQueue'; +import chooseUploadBranch from './uploadFlow/chooseUploadBranch'; +import startUploadProcess from './uploadFlow/startUploadProcess'; +import uploadCcFilesBranch from './uploadFlow/uploadCcFilesBranch'; +import uploadCsvBranch from './uploadFlow/uploadCsvBranch'; +import uploadThumbnailBranch from './uploadFlow/uploadThumbnailBranch'; +import uploadVideoBranch from './uploadFlow/uploadVideoBranch'; +import chooseVideoVersionUploadBranch from './videoVersionUploadFlow/chooseUploadBranch'; +import startVideoVersionUploadProcess from './videoVersionUploadFlow/startUploadProcess'; +import uploadVideoVersionBranch from './videoVersionUploadFlow/uploadVideoBranch'; + +export default combineEpics( + createVideo, + createVideoCsv, + siteSuggester, + getFeedSources, + createVideoVersion, + + // upload flow + startUploadProcess, + chooseUploadBranch, + uploadVideoBranch, + uploadCsvBranch, + uploadThumbnailBranch, + uploadCcFilesBranch, + + // upload video version flow + startVideoVersionUploadProcess, + chooseVideoVersionUploadBranch, + uploadVideoVersionBranch, + + // queue + startUploadFromQueue, + clearUploadQueue, + handleCancelUploadFromConfirmDialog, + + // promotion advertiser options + getAdvertisers, +); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/siteSuggester.js b/anyclip/src/modules/uploaderNew/redux/epics/siteSuggester.js new file mode 100644 index 0000000..15da822 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/siteSuggester.js @@ -0,0 +1,62 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getSitesListAction, setSitesListAction, sitesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { persistHubs } from '@/modules/uploaderNew/helpers/persist'; + +const query = ` + query getVideoUserHubs( + $pageSize: Int + $searchText: String + ) { + getVideoUserHubs( + pageSize: $pageSize, + searchText: $searchText, + ) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data: { getVideoUserHubs } }) => + getVideoUserHubs.records.map((record) => ({ value: record.id, label: record.name })); + +export default (action$) => + action$.pipe( + ofType(getSitesListAction.type), + switchMap(() => { + const variables = { + pageSize: 10000, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const savedHubs = persistHubs.get() || []; + const hubs = getResponse(response); + const savedInHubs = savedHubs.filter((sh) => hubs?.find((h) => h.value === sh.value)); + + if (savedInHubs.length) { + actions.push(of(sitesAction(savedInHubs))); + } + + actions.push(of(setSitesListAction(hubs))); + } + + return concat(...actions); + }), + ); + + return concat(of(setSitesListAction(null)), stream$); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/startUploadFromQueue.js b/anyclip/src/modules/uploaderNew/redux/epics/startUploadFromQueue.js new file mode 100644 index 0000000..30792ed --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/startUploadFromQueue.js @@ -0,0 +1,61 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + UPLOAD_FLOW_CREATE_VIDEO_VERSION, + UPLOAD_STATUS_UPLOADING, + UPLOAD_STATUS_WAIT, +} from '@/modules/uploaderNew/constants'; + +import { queueSelector } from '../selectors'; +import { + chooseUploadBranchAction, + chooseVideoVersionUploadBranchAction, + setProcessUploadQueueIdAction, + setProcessUploadStatusAction, + startUploadFromQueueAction, +} from '../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(startUploadFromQueueAction.type), + switchMap((action) => { + const queue = queueSelector(state$.value); + const queueIds = Object.keys(queue); + + const waitForUpload = queueIds.filter((id) => { + const qItem = queue[id]; + return qItem.processedUploadStatus === UPLOAD_STATUS_WAIT; + }); + + const activeProcessedUploadQueueId = waitForUpload[0]; + + const hasUploadingInProgress = queueIds.some((id) => { + const qItem = queue[id]; + return qItem.processedUploadStatus === UPLOAD_STATUS_UPLOADING; + }); + + if (!hasUploadingInProgress && activeProcessedUploadQueueId) { + const actions = [ + of(setProcessUploadQueueIdAction(activeProcessedUploadQueueId)), + of( + setProcessUploadStatusAction({ + status: UPLOAD_STATUS_UPLOADING, + processedUploadQueueId: activeProcessedUploadQueueId, + }), + ), + ]; + + if (action.payload !== UPLOAD_FLOW_CREATE_VIDEO_VERSION) { + actions.push(of(chooseUploadBranchAction())); + } else { + actions.push(of(chooseVideoVersionUploadBranchAction())); + } + + return concat(...actions); + } + + return EMPTY; + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/chooseUploadBranch.js b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/chooseUploadBranch.js new file mode 100644 index 0000000..74535e3 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/chooseUploadBranch.js @@ -0,0 +1,40 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { PCN_POST_VIDEO } from '@/modules/@common/acl/constants'; + +import { getCurrentUploadStateFromQueue } from '../../selectors'; +import { chooseUploadBranchAction, createVideoAction, uploadCsvAction, uploadVideoAction } from '../../slices'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; + +export default (action$, state$) => + action$.pipe( + ofType(chooseUploadBranchAction.type), + filter(() => hasPermission(PCN_POST_VIDEO, getUserPermissionsSelector(state$.value))), + switchMap(() => { + const { + csvFile, + video, + thumbnail, + ccFiles, + videoUrl, + + uploadVideo, + uploadCsv, + } = getCurrentUploadStateFromQueue(state$.value); + + const actions = []; + + if (csvFile) { + actions.push(of(uploadCsvAction(uploadCsv))); + } else if (video || csvFile || thumbnail || ccFiles) { + actions.push(of(uploadVideoAction(uploadVideo))); + } else if (videoUrl) { + actions.push(of(createVideoAction())); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/startUploadProcess.js b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/startUploadProcess.js new file mode 100644 index 0000000..7cba50a --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/startUploadProcess.js @@ -0,0 +1,123 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; + +import { PCN_POST_VIDEO } from '@/modules/@common/acl/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import * as selectors from '../../selectors'; +import { + addToUploadQueueAction, + clearAction, + setS3LinksAction, + startUploadAction, + startUploadFromQueueAction, +} from '../../slices'; +import { clearAction as clearCcFilesModuleBeforeUnmount } from '@/modules/@common/ccFiles/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hasPermission } from '@/modules/@common/user/helpers'; +import { getUserPermissionsSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const queryGQL = ` + query Query($name: String, $filename: String, $thumbnail: String, $ccFiles: [String], $contentOwnerId: Float!, $refId: String) { + S3UploadLink(name: $name, filename: $filename, thumbnail: $thumbnail, ccFiles: $ccFiles, contentOwnerId: $contentOwnerId, refId: $refId) { + uploadUrl + downloadUrl + thumbnailUploadUrl + thumbnailDownloadUrl + ccFiles { + uploadUrl + downloadUrl + isUploaded + } + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(startUploadAction.type), + filter(() => hasPermission(PCN_POST_VIDEO, getUserPermissionsSelector(state$.value))), + switchMap(() => { + const videoUrl = selectors.videoUrlSelector(state$.value); + const title = selectors.titleSelector(state$.value); + const video = selectors.videoSelector(state$.value); + const csvFile = selectors.csvFileSelector(state$.value); + const thumbnail = selectors.thumbnailSelector(state$.value); + const owner = selectors.ownerSelector(state$.value); + const ccFiles = selectors.ccFilesSelector(state$.value); + const refId = selectors.refIdSelector(state$.value); + const queue = selectors.queueSelector(state$.value); + + const filename = (video && video.name) || (csvFile && csvFile.name); + const thumbnailName = thumbnail && thumbnail.name; + const contentOwnerId = owner && owner.value; + const ccFilesData = ccFiles.map((ccFile) => ccFile?.fileObject?.name); + + const actions = []; + + // check title is uniq + const itemsInQueue = Object.values(queue); + const hasTitleInQueue = itemsInQueue.some((q) => q.title === title && !q.csvFile); + + if (hasTitleInQueue) { + return concat( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: 'The video title already exists. Please enter a new one', + }), + ), + ); + } + + if (filename || thumbnail || ccFilesData.length) { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + contentOwnerId, + thumbnail: thumbnailName, + ccFiles: ccFilesData, + filename, + name: title, + refId, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions$ = []; + + if (!errors.length && data.S3UploadLink) { + const s3uploadParams = { ...data.S3UploadLink }; + + actions$.push( + of( + setS3LinksAction({ + key: !csvFile ? 'uploadVideo' : 'uploadCsv', + s3uploadParams, + }), + ), + of(addToUploadQueueAction()), + of(clearAction()), + of(clearCcFilesModuleBeforeUnmount()), + of(startUploadFromQueueAction()), + ); + } + + return concat(...actions$); + }), + ); + + actions.push(stream$); + } else if (videoUrl) { + actions.push( + of(addToUploadQueueAction()), + of(clearAction()), + of(clearCcFilesModuleBeforeUnmount()), + of(startUploadFromQueueAction()), + ); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCcFilesBranch.js b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCcFilesBranch.js new file mode 100644 index 0000000..37cf6f0 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCcFilesBranch.js @@ -0,0 +1,129 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, merge, of, Subject } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, concatMap, map, switchMap } from 'rxjs/operators'; + +import { getCurrentUploadStateFromQueue, processedUploadQueueIdSelector } from '../../selectors'; +import { + createVideoAction, + errorAction, + loadingAction, + loadingProgressAction, + setUploadVideoCcFilesAction, + uploadCcFilesAction, +} from '../../slices'; +import { requestEventAction, responseEventAction } from '@/modules/@common/request/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(uploadCcFilesAction.type), + switchMap(() => { + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + const { ccFiles, uploadVideo } = getCurrentUploadStateFromQueue(state$.value); + + if (uploadVideo.ccFiles?.length) { + const upload = []; + const totalSize = ccFiles.reduce((acc, value) => acc + value.fileObject.size, 0); + let loadedStorage = 0; + const makeUploadStream = ({ file, uploadUrl }) => { + const progressSubscriber = new Subject(); + const request = ajax({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Type': 'application/octet-stream', + }, + crossDomain: true, + withCredentials: true, + body: file, + progressSubscriber, + }); + + const requestObservable = request.pipe( + concatMap(() => { + const state = getCurrentUploadStateFromQueue(state$.value); + const ccFilesWithUploadState = state.uploadVideo.ccFiles.map((ccFile) => ({ + ...ccFile, + isUploaded: ccFile.uploadUrl === uploadUrl || ccFile.isUploaded, + })); + return of( + setUploadVideoCcFilesAction({ + ccFiles: ccFilesWithUploadState, + processedUploadQueueId, + }), + ); + }), + ); + + const stream$ = merge( + progressSubscriber.pipe( + map((e) => { + loadedStorage += e.loaded; + + const { video } = getCurrentUploadStateFromQueue(state$.value); + const loaded = video ? video.size + loadedStorage : loadedStorage; + const total = video ? video.size + totalSize : totalSize; + + return { percentage: loaded / total }; + }), + map((data) => + loadingProgressAction({ + percentage: data.percentage, + processedUploadQueueId, + }), + ), + ), + requestObservable, + ).pipe( + catchError(() => { + const errorActions = [ + of( + errorAction({ + reason: 'error', + message: 'Network connection error', + }), + ), + of(loadingAction()), + ]; + + return concat(...errorActions); + }), + ); + + return concat(of(requestEventAction()), stream$, of(responseEventAction())); + }; + uploadVideo.ccFiles.forEach((ccFile, index) => { + const { uploadUrl } = ccFile; + const { fileObject } = ccFiles[index]; + + upload.push( + makeUploadStream({ + file: fileObject, + uploadUrl, + }), + ); + }); + + const uploadStream = merge(...upload).pipe( + switchMap((action) => { + const state = getCurrentUploadStateFromQueue(state$.value); + const shouldCreateVideo = state.uploadVideo.ccFiles.every((ccFile) => ccFile?.isUploaded); + + if (shouldCreateVideo) { + return of(createVideoAction()); + } + + if (action?.type) { + return of(action); + } + + return EMPTY; + }), + ); + + return concat(uploadStream); + } + + return of(createVideoAction()); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCsvBranch.js b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCsvBranch.js new file mode 100644 index 0000000..afa768d --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadCsvBranch.js @@ -0,0 +1,75 @@ +import { ofType } from 'redux-observable'; +import { concat, merge, of, Subject } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, concatMap, filter, map, switchMap, takeUntil } from 'rxjs/operators'; + +import { getCurrentUploadStateFromQueue, processedUploadQueueIdSelector } from '../../selectors'; +import { + createVideoFromCsvAction, + errorAction, + loadingProgressAction, + uploadCsvAction, + uploadVideoCancelledAction, +} from '../../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(uploadCsvAction.type), + switchMap((action) => { + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + const { csvFile } = getCurrentUploadStateFromQueue(state$.value); + const { + payload: { uploadUrl }, + } = action; + + const progressSubscriber = new Subject(); + + const request = ajax({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Type': 'application/octet-stream', + }, + crossDomain: true, + withCredentials: true, + body: csvFile, + progressSubscriber, + }); + + const requestObservable = request.pipe(concatMap(() => of(createVideoFromCsvAction()))); + + const stream$ = merge( + progressSubscriber.pipe( + map((e) => ({ percentage: e.loaded / e.total })), + map((data) => + loadingProgressAction({ + percentage: data.percentage, + processedUploadQueueId, + }), + ), + ), + requestObservable, + ).pipe( + takeUntil( + action$.pipe( + ofType(uploadVideoCancelledAction.type), + filter(({ payload }) => payload.processedUploadQueueId === processedUploadQueueId), + ), + ), + catchError(() => { + const actions = [ + of( + errorAction({ + reason: 'error', + message: 'Network connection error', + }), + ), + ]; + + return concat(...actions); + }), + ); + + return stream$; + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadThumbnailBranch.js b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadThumbnailBranch.js new file mode 100644 index 0000000..2e8af47 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadThumbnailBranch.js @@ -0,0 +1,32 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCurrentUploadStateFromQueue } from '../../selectors'; +import { uploadCcFilesAction, uploadThumbnailAction } from '../../slices'; +import { uploadS3 } from '@/modules/@common/request'; + +export default (action$, state$) => + action$.pipe( + ofType(uploadThumbnailAction.type), + switchMap(() => { + const { + thumbnail, + uploadVideo: { thumbnailUploadUrl }, + } = getCurrentUploadStateFromQueue(state$.value); + + const stream$ = uploadS3(thumbnailUploadUrl, thumbnail).pipe( + switchMap(({ errors }) => { + const actions = []; + + if (!errors.length) { + actions.push(of(uploadCcFilesAction())); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadVideoBranch.js b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadVideoBranch.js new file mode 100644 index 0000000..0415f7e --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/uploadFlow/uploadVideoBranch.js @@ -0,0 +1,96 @@ +import { ofType } from 'redux-observable'; +import { concat, merge, of, Subject } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, concatMap, filter, map, startWith, switchMap, takeUntil } from 'rxjs/operators'; + +import { getCurrentUploadStateFromQueue, processedUploadQueueIdSelector } from '../../selectors'; +import { + errorAction, + loadingProgressAction, + uploadCcFilesAction, + uploadThumbnailAction, + uploadVideoAction, + uploadVideoCancelledAction, +} from '../../slices'; +import { requestEventAction, responseEventAction } from '@/modules/@common/request/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(uploadVideoAction.type), + switchMap((action) => { + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + const { video, thumbnail, ccFiles } = getCurrentUploadStateFromQueue(state$.value); + const { + payload: { uploadUrl }, + } = action; + const totalCcFilesSize = ccFiles.reduce((acc, value) => acc + value.fileObject.size, 0); + + const progressSubscriber = new Subject(); + + const request = ajax({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Type': 'application/octet-stream', + }, + crossDomain: true, + withCredentials: true, + body: video, + progressSubscriber, + }); + + const requestObservable = request.pipe( + concatMap(() => { + if (thumbnail) { + return of(uploadThumbnailAction()); + } + + return of(uploadCcFilesAction()); + }), + ); + + const stream$ = merge( + progressSubscriber.pipe( + map((e) => ({ percentage: e.loaded / (e.total + totalCcFilesSize) })), + map((data) => + loadingProgressAction({ + percentage: data.percentage, + processedUploadQueueId, + }), + ), + ), + requestObservable, + ).pipe( + takeUntil( + action$.pipe( + ofType(uploadVideoCancelledAction.type), + filter(({ payload }) => payload.processedUploadQueueId === processedUploadQueueId), + ), + ), + catchError(() => { + const actions = [ + of( + errorAction({ + reason: 'error', + message: 'Network connection error', + }), + ), + ]; + + return concat(...actions); + }), + ); + + const actions = []; + + if (video) { + actions.push(concat(stream$.pipe(startWith(requestEventAction())), of(responseEventAction()))); + } else if (thumbnail) { + actions.push(of(uploadThumbnailAction())); + } else { + actions.push(of(uploadCcFilesAction())); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/chooseUploadBranch.js b/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/chooseUploadBranch.js new file mode 100644 index 0000000..c45cafa --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/chooseUploadBranch.js @@ -0,0 +1,29 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getCurrentUploadStateFromQueue } from '../../selectors'; +import { chooseVideoVersionUploadBranchAction, createVideoVersionAction, uploadVideoVersionAction } from '../../slices'; + +export default (action$, state$) => + action$.pipe( + ofType(chooseVideoVersionUploadBranchAction.type), + switchMap(() => { + const { + video, + videoUrl, + + uploadVideo, + } = getCurrentUploadStateFromQueue(state$.value); + + const actions = []; + + if (video) { + actions.push(of(uploadVideoVersionAction(uploadVideo))); + } else if (videoUrl) { + actions.push(of(createVideoVersionAction())); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/startUploadProcess.js b/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/startUploadProcess.js new file mode 100644 index 0000000..e00e8d8 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/startUploadProcess.js @@ -0,0 +1,87 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPLOAD_FLOW_CREATE_VIDEO_VERSION } from '../../../constants'; + +import { titleSelector, videoSelector } from '../../selectors'; +import { + addToUploadQueueAction, + clearAction, + setS3LinksAction, + startUploadFromQueueAction, + startVideoVersionUploadAction, +} from '../../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { selectedVideoSelector } from '@/modules/editorial/editorialVideoDetails/redux/selectors'; + +const queryGQL = ` + query Query( + $name: String, + $filename: String, + $contentOwnerId: Float! + ) { + S3UploadLink( + name: $name, + filename: $filename, + contentOwnerId: $contentOwnerId + ) { + uploadUrl + downloadUrl + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(startVideoVersionUploadAction.type), + switchMap(() => { + const video = videoSelector(state$.value); + const title = titleSelector(state$.value); + const selectedVideo = selectedVideoSelector(state$.value); + + const filename = video && video.name; + const contentOwnerId = selectedVideo.contentOwner; + + const actions = []; + + if (filename) { + const stream$ = gqlRequest({ + query: queryGQL, + variables: { + contentOwnerId, + filename, + name: title, + }, + }).pipe( + switchMap(({ data, errors }) => { + const actions$ = []; + + if (!errors.length && data.S3UploadLink) { + const s3uploadParams = { ...data.S3UploadLink }; + + actions$.push( + of( + setS3LinksAction({ + key: 'uploadVideo', + s3uploadParams, + }), + ), + ); + } + + return concat(...actions$); + }), + ); + + actions.push(stream$); + } + + return concat( + ...actions, + of(addToUploadQueueAction()), + of(clearAction()), + of(startUploadFromQueueAction(UPLOAD_FLOW_CREATE_VIDEO_VERSION)), + ); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/uploadVideoBranch.js b/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/uploadVideoBranch.js new file mode 100644 index 0000000..01eca28 --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/epics/videoVersionUploadFlow/uploadVideoBranch.js @@ -0,0 +1,82 @@ +import { ofType } from 'redux-observable'; +import { concat, merge, of, Subject } from 'rxjs'; +import { ajax } from 'rxjs/ajax'; +import { catchError, concatMap, filter, map, startWith, switchMap, takeUntil } from 'rxjs/operators'; + +import { getCurrentUploadStateFromQueue, processedUploadQueueIdSelector } from '../../selectors'; +import { + createVideoVersionAction, + errorAction, + loadingProgressAction, + uploadVideoCancelledAction, + uploadVideoVersionAction, +} from '../../slices'; +import { requestEventAction, responseEventAction } from '@/modules/@common/request/redux/slices'; + +export default (action$, state$) => + action$.pipe( + ofType(uploadVideoVersionAction.type), + switchMap((action) => { + const processedUploadQueueId = processedUploadQueueIdSelector(state$.value); + const { video } = getCurrentUploadStateFromQueue(state$.value); + const { + payload: { uploadUrl }, + } = action; + + const progressSubscriber = new Subject(); + + const request = ajax({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Type': 'application/octet-stream', + }, + crossDomain: true, + withCredentials: true, + body: video, + progressSubscriber, + }); + + const requestObservable = request.pipe(concatMap(() => of(createVideoVersionAction()))); + + const stream$ = merge( + progressSubscriber.pipe( + map((e) => ({ percentage: e.loaded / e.total })), + map((data) => + loadingProgressAction({ + percentage: data.percentage, + processedUploadQueueId, + }), + ), + ), + requestObservable, + ).pipe( + takeUntil( + action$.pipe( + ofType(uploadVideoCancelledAction.type), + filter(({ payload }) => payload.processedUploadQueueId === processedUploadQueueId), + ), + ), + catchError(() => { + const actions = [ + of( + errorAction({ + reason: 'error', + message: 'Network connection error', + }), + ), + ]; + + return concat(...actions); + }), + ); + + const actions = []; + + if (video) { + actions.push(concat(stream$.pipe(startWith(requestEventAction())), of(responseEventAction()))); + } + + return concat(...actions); + }), + ); diff --git a/anyclip/src/modules/uploaderNew/redux/selectors/index.js b/anyclip/src/modules/uploaderNew/redux/selectors/index.js new file mode 100644 index 0000000..cf98fdc --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/selectors/index.js @@ -0,0 +1,102 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const videoSelector = (state) => state[nameSpace].video; +export const csvFileSelector = (state) => state[nameSpace].csvFile; +export const videoUrlSelector = (state) => state[nameSpace].videoUrl; +export const thumbnailSelector = (state) => state[nameSpace].thumbnail; +export const dateSelector = (state) => state[nameSpace].date; +export const titleSelector = (state) => state[nameSpace].title; +export const refIdSelector = (state) => state[nameSpace].refId; +export const descriptionSelector = (state) => state[nameSpace].description; +export const ownerSelector = (state) => state[nameSpace].owner; +export const feedSelector = (state) => state[nameSpace].feed; +export const feedListSelector = (state) => state[nameSpace].feedList; +export const languageSelector = (state) => state[nameSpace].language; +export const labelsSelector = (state) => state[nameSpace].labels; +export const keywordsSelector = (state) => state[nameSpace].keywords; +export const iabSelector = (state) => state[nameSpace].iab; +export const isNotifySelector = (state) => state[nameSpace].isNotify; +export const uploadVideoSelector = (state) => state[nameSpace].uploadVideo; +export const uploadCsvSelector = (state) => state[nameSpace].uploadCsv; +export const errorSelector = (state) => state[nameSpace].error; +export const loadingSelector = (state) => state[nameSpace].loading; +export const loadingProgressSelector = (state) => state[nameSpace].loadingProgress; +export const isOpenSelector = (state) => state[nameSpace].isOpen; +export const evergreenSelector = (state) => state[nameSpace].evergreen; +export const notesSelector = (state) => state[nameSpace].notes; +export const ccFilesSelector = (state) => state[nameSpace].ccFiles; +export const accessLevelSelector = (state) => state[nameSpace].accessLevel; +export const sitesSelector = (state) => state[nameSpace].sites; +export const sitesListSelector = (state) => state[nameSpace].sitesList; +export const activeTabSelector = (state) => state[nameSpace].activeTab; +export const queueSelector = (state) => state[nameSpace].queue; +export const uploadConfirmDialogSelector = (state) => state[nameSpace].uploadConfirmDialog; +export const sendHubsNotificationSelector = (state) => state[nameSpace].sendHubsNotification; +export const processedUploadQueueIdSelector = (state) => state[nameSpace].processedUploadQueueId; + +export const isOpenVideoVersionUploadSelector = (state) => state[nameSpace].isOpenVideoVersionUpload; +export const isNotifyToAccessUsersSelector = (state) => state[nameSpace].isNotifyToAccessUsers; +export const videoVersionIdSelector = (state) => state[nameSpace].videoVersionId; +export const videoVersionNotesSelector = (state) => state[nameSpace].videoVersionNotes; + +// sponsored content attribute +export const landingPageLinkSelector = (state) => state[nameSpace].landingPageLink; +export const primaryTextSelector = (state) => state[nameSpace].primaryText; +export const buttonLabelSelector = (state) => state[nameSpace].buttonLabel; +export const secondaryTextSelector = (state) => state[nameSpace].secondaryText; +export const timeToAppearSelector = (state) => state[nameSpace].timeToAppear; +export const advertiserIdSelector = (state) => state[nameSpace].advertiserId; +export const advertiserNameSelector = (state) => state[nameSpace].advertiserName; +export const advertiserLogoSelector = (state) => state[nameSpace].advertiserLogo; +export const advertiserOptionsSelector = (state) => state[nameSpace].advertiserOptions; +export const sponsoredSelector = (state) => state[nameSpace].sponsored; + +export const getSelectors = (state) => ({ + date: dateSelector(state), + description: descriptionSelector(state), + error: errorSelector(state), + feed: feedSelector(state), + feedList: feedListSelector(state), + isNotify: isNotifySelector(state), + isOpen: isOpenSelector(state), + labels: labelsSelector(state), + keywords: keywordsSelector(state), + iab: iabSelector(state), + language: languageSelector(state), + loading: loadingSelector(state), + loadingProgress: loadingProgressSelector(state), + owner: ownerSelector(state), + refId: refIdSelector(state), + thumbnail: thumbnailSelector(state), + title: titleSelector(state), + video: videoSelector(state), + csvFile: csvFileSelector(state), + videoUrl: videoUrlSelector(state), + evergreen: evergreenSelector(state), + notes: notesSelector(state), + ccFiles: ccFilesSelector(state), + accessLevel: accessLevelSelector(state), + sites: sitesSelector(state), + sitesList: sitesListSelector(state), + activeTab: activeTabSelector(state), + queue: queueSelector(state), + uploadConfirmDialog: uploadConfirmDialogSelector(state), + sendHubsNotification: sendHubsNotificationSelector(state), + landingPageLink: landingPageLinkSelector(state), + primaryText: primaryTextSelector(state), + buttonLabel: buttonLabelSelector(state), + secondaryText: secondaryTextSelector(state), + timeToAppear: timeToAppearSelector(state), + advertiserId: advertiserIdSelector(state), + advertiserName: advertiserNameSelector(state), + advertiserLogo: advertiserLogoSelector(state), + advertiserOptions: advertiserOptionsSelector(state), + sponsored: sponsoredSelector(state), +}); + +export const getCurrentUploadStateFromQueue = (state) => { + const { queue: q, processedUploadQueueId } = state[nameSpace]; + return q[processedUploadQueueId] || {}; +}; diff --git a/anyclip/src/modules/uploaderNew/redux/slices/index.js b/anyclip/src/modules/uploaderNew/redux/slices/index.js new file mode 100644 index 0000000..14e6c8c --- /dev/null +++ b/anyclip/src/modules/uploaderNew/redux/slices/index.js @@ -0,0 +1,313 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { UPLOAD_STATUS_WAIT } from '@/modules/uploaderNew/constants'; + +const initialState = { + activeTab: 0, + video: null, + csvFile: null, + videoUrl: null, + thumbnail: null, + date: null, + title: '', + refId: '', + description: '', + owner: null, + feed: null, + feedList: [], + language: { label: 'English', value: 'EN' }, + labels: [], + keywords: [], + iab: [], + isNotify: true, + uploadVideo: {}, + uploadCsv: {}, + uploadVideoCancelled: true, + error: {}, + loading: false, + loadingProgress: 0, + isOpen: false, + evergreen: false, + notes: '', + ccFiles: [], + accessLevel: '', + sites: [], + sitesList: null, + sendHubsNotification: false, + + // upload new video file ( video version feature) + isOpenVideoVersionUpload: false, + videoId: null, + isNotifyToAccessUsers: false, + videoVersionId: null, + videoVersionNotes: null, + videoAccess: null, + + // upload queue + processedUploadQueueId: null, + processedUploadStatus: UPLOAD_STATUS_WAIT, + uploadConfirmDialog: null, + queue: {}, + + // sponsored content attribute + landingPageLink: '', + primaryText: '', + buttonLabel: '', + secondaryText: '', + timeToAppear: 0, + advertiserId: '', + advertiserName: '', + advertiserLogo: '', + advertiserOptions: null, + sponsored: false, +}; + +export const slice = createSlice({ + name: '@@uploaderNew/UPLOADER', + initialState, + reducers: { + videoAction: (state, action) => { + state.video = action.payload || initialState.video; + }, + csvFileAction: (state, action) => { + state.csvFile = action.payload || initialState.csvFile; + }, + videoUrlAction: (state, action) => { + state.videoUrl = action.payload || initialState.videoUrl; + }, + thumbnailAction: (state, action) => { + state.thumbnail = action.payload || initialState.thumbnail; + }, + dateAction: (state, action) => { + state.date = action.payload || initialState.date; + }, + titleAction: (state, action) => { + state.title = action.payload || initialState.title; + }, + refIdAction: (state, action) => { + state.refId = action.payload || initialState.refId; + }, + descriptionAction: (state, action) => { + state.description = action.payload || initialState.description; + }, + ownerAction: (state, action) => { + state.owner = action.payload || initialState.owner; + }, + feedAction: (state, action) => { + state.feed = action.payload || initialState.feed; + }, + feedListAction: (state, action) => { + state.feedList = action.payload || initialState.feedList; + }, + languageAction: (state, action) => { + state.language = action.payload || initialState.language; + }, + labelsAction: (state, action) => { + state.labels = action.payload || initialState.labels; + }, + keywordsAction: (state, action) => { + state.keywords = action.payload || initialState.keywords; + }, + isNotifyAction: (state, action) => { + state.isNotify = action.payload; + }, + uploadVideoCancelledAction: (state, action) => { + state.uploadVideoCancelled = action.payload || initialState.uploadVideoCancelled; + }, + errorAction: (state, action) => { + state.error = action.payload || initialState.error; + }, + loadingAction: (state, action) => { + state.loading = action.payload || initialState.loading; + }, + loadingProgressAction: (state, action) => { + state.queue = { + ...state.queue, + [action.payload.processedUploadQueueId]: { + ...state.queue[action.payload.processedUploadQueueId], + loadingProgress: action.payload.percentage || 0, + }, + }; + }, + isOpenAction: (state, action) => { + state.isOpen = action.payload || initialState.isOpen; + }, + evergreenAction: (state, action) => { + state.evergreen = action.payload || initialState.evergreen; + }, + notesAction: (state, action) => { + state.notes = action.payload || initialState.notes; + }, + setCcFilesAction: (state, action) => { + state.ccFiles = action.payload || initialState.ccFiles; + }, + setUploadVideoCcFilesAction: (state, action) => { + state.queue = { + ...state.queue, + [action.payload.processedUploadQueueId]: { + ...state.queue[action.payload.processedUploadQueueId], + uploadVideo: { + ...state.queue[action.payload.processedUploadQueueId].uploadVideo, + ccFiles: action.payload.ccFiles, + }, + }, + }; + }, + accessLevelAction: (state, action) => { + state.accessLevel = action.payload || initialState.accessLevel; + }, + setSitesListAction: (state, action) => { + state.sitesList = action.payload || initialState.sitesList; + }, + setActiveTabAction: (state, action) => { + state.activeTab = action.payload; + }, + sendHubsNotificationAction: (state, action) => { + state.sendHubsNotification = action.payload || initialState.sendHubsNotification; + }, + clearAction: (state) => { + Object.keys(initialState).forEach((key) => { + if (!['queue', 'processedUploadQueueId'].includes(key)) { + state[key] = initialState[key]; + } + }); + }, + clearAllAction: (state) => { + Object.keys(initialState).forEach((key) => { + state[key] = initialState[key]; + }); + }, + setS3LinksAction: (state, action) => { + state[action.payload.key] = action.payload.s3uploadParams; + }, + addToUploadQueueAction: (state) => { + const { queue, ...slicedState } = state; + state.queue = { + ...state.queue, + [`${Math.random()}-${Math.random()}`]: slicedState, + }; + }, + setProcessUploadQueueIdAction: (state, action) => { + state.processedUploadQueueId = action.payload; + }, + setProcessUploadStatusAction: (state, action) => { + state.queue[action.payload.processedUploadQueueId] = { + ...state.queue[action.payload.processedUploadQueueId], + processedUploadStatus: action.payload.status, + }; + }, + showCancelUploadConfirmDialogAction: (state, action) => { + state.uploadConfirmDialog = action.payload; + }, + hideCancelUploadConfirmDialogAction: (state) => { + state.uploadConfirmDialog = null; + }, + openVideoVersionUploadAction: (state, action) => { + state.isOpenVideoVersionUpload = true; + state.videoId = action.payload.videoId; + state.videoAccess = action.payload.access; + }, + isNotifyToAccessUsersAction: (state, action) => { + state.isNotifyToAccessUsers = action.payload; + }, + setVideoVersionIdAction: (state, action) => { + state.videoVersionId = action.payload; + }, + setVideoVersionNotesAction: (state, action) => { + state.videoVersionNotes = action.payload; + }, + uploadVideoAction: (state) => state, + uploadCsvAction: (state) => state, + uploadThumbnailAction: (state) => state, + createVideoAction: (state) => state, + createVideoFromCsvAction: (state) => state, + uploadCcFilesAction: (state) => state, + getSitesListAction: (state) => state, + sitesAction: (state, action) => { + state.sites = action.payload; + }, + startUploadAction: (state) => state, + startUploadFromQueueAction: (state) => state, + chooseUploadBranchAction: (state) => state, + clearUploadQueueAction: (state) => state, + handleCancelUploadFromConfirmDialogAction: (state) => state, + getFeedSourcesAction: (state) => state, + startVideoVersionUploadAction: (state) => state, + chooseVideoVersionUploadBranchAction: (state) => state, + uploadVideoVersionAction: (state) => state, + createVideoVersionAction: (state) => state, + iabAction: (state, action) => { + state.iab = action.payload || initialState.iab; + }, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getAdvertiserOptions: (state) => state, + }, +}); + +export const { + videoAction, + csvFileAction, + videoUrlAction, + thumbnailAction, + dateAction, + titleAction, + refIdAction, + descriptionAction, + ownerAction, + feedAction, + feedListAction, + languageAction, + labelsAction, + keywordsAction, + isNotifyAction, + uploadVideoCancelledAction, + errorAction, + loadingAction, + loadingProgressAction, + isOpenAction, + evergreenAction, + notesAction, + setCcFilesAction, + setUploadVideoCcFilesAction, + accessLevelAction, + setSitesListAction, + setActiveTabAction, + sendHubsNotificationAction, + clearAction, + clearAllAction, + setS3LinksAction, + addToUploadQueueAction, + setProcessUploadQueueIdAction, + setProcessUploadStatusAction, + showCancelUploadConfirmDialogAction, + hideCancelUploadConfirmDialogAction, + openVideoVersionUploadAction, + isNotifyToAccessUsersAction, + setVideoVersionIdAction, + setVideoVersionNotesAction, + uploadVideoAction, + uploadCsvAction, + uploadThumbnailAction, + createVideoAction, + createVideoFromCsvAction, + uploadCcFilesAction, + getSitesListAction, + sitesAction, + startUploadAction, + startUploadFromQueueAction, + chooseUploadBranchAction, + clearUploadQueueAction, + handleCancelUploadFromConfirmDialogAction, + getFeedSourcesAction, + startVideoVersionUploadAction, + chooseVideoVersionUploadBranchAction, + uploadVideoVersionAction, + createVideoVersionAction, + iabAction, + setAction, + getAdvertiserOptions, +} = slice.actions; diff --git a/src/modules/userRulesSettings/components/CollapsedContainer/CollapsedContainer.module.scss b/anyclip/src/modules/userRulesSettings/components/CollapsedContainer/CollapsedContainer.module.scss similarity index 100% rename from src/modules/userRulesSettings/components/CollapsedContainer/CollapsedContainer.module.scss rename to anyclip/src/modules/userRulesSettings/components/CollapsedContainer/CollapsedContainer.module.scss diff --git a/src/modules/userRulesSettings/components/CollapsedContainer/index.jsx b/anyclip/src/modules/userRulesSettings/components/CollapsedContainer/index.jsx similarity index 100% rename from src/modules/userRulesSettings/components/CollapsedContainer/index.jsx rename to anyclip/src/modules/userRulesSettings/components/CollapsedContainer/index.jsx diff --git a/src/modules/userRulesSettings/components/Empty/Empty.module.scss b/anyclip/src/modules/userRulesSettings/components/Empty/Empty.module.scss similarity index 100% rename from src/modules/userRulesSettings/components/Empty/Empty.module.scss rename to anyclip/src/modules/userRulesSettings/components/Empty/Empty.module.scss diff --git a/src/modules/userRulesSettings/components/Empty/index.jsx b/anyclip/src/modules/userRulesSettings/components/Empty/index.jsx similarity index 100% rename from src/modules/userRulesSettings/components/Empty/index.jsx rename to anyclip/src/modules/userRulesSettings/components/Empty/index.jsx diff --git a/src/modules/userRulesSettings/components/UserRulesSettings.module.scss b/anyclip/src/modules/userRulesSettings/components/UserRulesSettings.module.scss similarity index 100% rename from src/modules/userRulesSettings/components/UserRulesSettings.module.scss rename to anyclip/src/modules/userRulesSettings/components/UserRulesSettings.module.scss diff --git a/src/modules/userRulesSettings/components/UsersAutocomplete/UsersAutocomplete.module.scss b/anyclip/src/modules/userRulesSettings/components/UsersAutocomplete/UsersAutocomplete.module.scss similarity index 100% rename from src/modules/userRulesSettings/components/UsersAutocomplete/UsersAutocomplete.module.scss rename to anyclip/src/modules/userRulesSettings/components/UsersAutocomplete/UsersAutocomplete.module.scss diff --git a/src/modules/userRulesSettings/components/UsersAutocomplete/index.jsx b/anyclip/src/modules/userRulesSettings/components/UsersAutocomplete/index.jsx similarity index 100% rename from src/modules/userRulesSettings/components/UsersAutocomplete/index.jsx rename to anyclip/src/modules/userRulesSettings/components/UsersAutocomplete/index.jsx diff --git a/src/modules/userRulesSettings/components/index.jsx b/anyclip/src/modules/userRulesSettings/components/index.jsx similarity index 100% rename from src/modules/userRulesSettings/components/index.jsx rename to anyclip/src/modules/userRulesSettings/components/index.jsx diff --git a/src/modules/userRulesSettings/components/useLogic.jsx b/anyclip/src/modules/userRulesSettings/components/useLogic.jsx similarity index 100% rename from src/modules/userRulesSettings/components/useLogic.jsx rename to anyclip/src/modules/userRulesSettings/components/useLogic.jsx diff --git a/anyclip/src/modules/userRulesSettings/constants/index.js b/anyclip/src/modules/userRulesSettings/constants/index.js new file mode 100644 index 0000000..927f783 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/constants/index.js @@ -0,0 +1,36 @@ +export const RULE_TYPE_ALL_MY_VIDEO = 'allMyVideo'; +export const RULE_TYPE_LABELS = 'labels'; +export const RULE_TYPE_WORDS_IN_TITLE = 'wordsInTitle'; +export const RULE_TYPE_SOURCES = 'sources'; + +export const RULE_TYPE_OPTIONS = [ + { label: 'All my videos', value: RULE_TYPE_ALL_MY_VIDEO }, + { label: 'My videos contain one or more custom tags', value: RULE_TYPE_LABELS }, + { label: 'My videos contain one or more words', value: RULE_TYPE_WORDS_IN_TITLE }, + { label: 'My videos from source', value: RULE_TYPE_SOURCES }, +]; + +export const SHARE_WITH_TYPE_ALL_RELATED_USERS = 'allRelatedUsers'; +export const SHARE_WITH_TYPE_USERS = 'users'; +export const SHARE_WITH_TYPE_HUBS = 'hubs'; + +export const SHARE_WITH_TYPE_OPTIONS = [ + { label: 'List of people', value: SHARE_WITH_TYPE_USERS }, + { label: 'List of hubs', value: SHARE_WITH_TYPE_HUBS }, +]; + +export const MAX_RULE_SELECT_INDEX = 2; +export const MAX_RULE_SHARE_WITH_INDEX = 1; +export const FIRST_RULE_SELECT_INDEX = 0; +export const FIRST_RULE_SHARE_WITH_INDEX = 0; + +export const SAVE_RULE_ACTION_SAVE = 'save'; +export const SAVE_RULE_ACTION_REMOVE = 'remove'; + +export const CONDITION_ENUM_AND = 'AND'; +export const CONDITION_ENUM_OR = 'OR'; + +export const CONDITION_OPTIONS = [ + { label: 'AND', value: CONDITION_ENUM_AND }, + { label: 'OR', value: CONDITION_ENUM_OR }, +]; diff --git a/src/modules/userRulesSettings/helpers/calculationsFromStore.js b/anyclip/src/modules/userRulesSettings/helpers/calculationsFromStore.js similarity index 100% rename from src/modules/userRulesSettings/helpers/calculationsFromStore.js rename to anyclip/src/modules/userRulesSettings/helpers/calculationsFromStore.js diff --git a/anyclip/src/modules/userRulesSettings/helpers/createEmptyRules.js b/anyclip/src/modules/userRulesSettings/helpers/createEmptyRules.js new file mode 100644 index 0000000..5a631fe --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/helpers/createEmptyRules.js @@ -0,0 +1,32 @@ +import { CONDITION_ENUM_AND } from '../constants'; + +const createEmptyRules = ({ uiIndex, indexInArray }) => ({ + ruleId: undefined, + ruleName: `Share videos rule ${indexInArray}`, + ruleOrder: undefined, + ruleSelectType: [ + { + type: '', // one of RULE_TYPE see in constance.js + value: [], + condition: CONDITION_ENUM_AND, + }, + ], + ruleShareWithType: [ + { + type: '', // one of SHARE_WITH_TYPE see in constance.js + value: [], + }, + ], + initialFromApi: null, + // options + sourcesOptions: null, + hubsOptions: null, + usersOptions: null, + // ui state + uiIndex, + uiIsOpen: true, + uiIsSaved: false, + uiIsNameInEditMode: true, +}); + +export default createEmptyRules; diff --git a/anyclip/src/modules/userRulesSettings/helpers/createRequestBodyFromState.js b/anyclip/src/modules/userRulesSettings/helpers/createRequestBodyFromState.js new file mode 100644 index 0000000..d362c32 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/helpers/createRequestBodyFromState.js @@ -0,0 +1,91 @@ +import { + RULE_TYPE_ALL_MY_VIDEO, + RULE_TYPE_LABELS, + RULE_TYPE_SOURCES, + RULE_TYPE_WORDS_IN_TITLE, + SHARE_WITH_TYPE_ALL_RELATED_USERS, + SHARE_WITH_TYPE_HUBS, + SHARE_WITH_TYPE_USERS, +} from '../constants'; + +import { rulesSelector } from '@/modules/userRulesSettings/redux/selectors'; + +const mapToId = (o) => ({ id: o.value }); + +const getSharedVideoRules = (ruleSelectType) => { + const rules = {}; + + ruleSelectType.forEach((rule, ruleIndex) => { + rules[rule.type] = { + order: ruleIndex, + condition: rule.condition, + }; + + if (rule.type === RULE_TYPE_ALL_MY_VIDEO) { + rules[rule.type].value = true; + } + + if ([RULE_TYPE_LABELS, RULE_TYPE_WORDS_IN_TITLE].includes(rule.type)) { + rules[rule.type].value = rule.value; + } + + if (rule.type === RULE_TYPE_SOURCES) { + rules[rule.type].value = rule.value.map(mapToId); + } + }); + + return Object.keys(rules).length ? rules : null; +}; + +const getShareWithRules = (shareWithType) => { + const rules = {}; + + shareWithType.forEach((rule, ruleIndex) => { + if (rule.type === SHARE_WITH_TYPE_ALL_RELATED_USERS) { + rules[rule.type] = { value: true, order: ruleIndex }; + } + + if (rule.type === SHARE_WITH_TYPE_HUBS) { + rules[rule.type] = { value: rule.value.map(mapToId), order: ruleIndex }; + } + + if (rule.type === SHARE_WITH_TYPE_USERS) { + rules[rule.type] = { value: rule.value, order: ruleIndex }; + } + }); + + return Object.keys(rules).length ? rules : null; +}; + +const createRequestBodyFromState = (state) => { + const rules = rulesSelector(state); + + const body = rules + .filter((rule) => rule.initialFromApi) + .map((rule, ruleIndex) => { + const result = { + ruleName: rule.uiIsSaved ? rule.ruleName : rule.initialFromApi.ruleName, + ruleOrder: ruleIndex, + }; + const sharedVideoRules = getSharedVideoRules( + rule.uiIsSaved ? rule.ruleSelectType : rule.initialFromApi.ruleSelectType, + ); + const shareWith = getShareWithRules( + rule.uiIsSaved ? rule.ruleShareWithType : rule.initialFromApi.ruleShareWithType, + ); + + if (sharedVideoRules) { + result.sharedVideoRules = sharedVideoRules; + } + + if (shareWith) { + result.shareWith = shareWith; + } + + return result; + }); + + return body; +}; + +export default createRequestBodyFromState; diff --git a/anyclip/src/modules/userRulesSettings/helpers/index.js b/anyclip/src/modules/userRulesSettings/helpers/index.js new file mode 100644 index 0000000..c3550f2 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/helpers/index.js @@ -0,0 +1,6 @@ +import createEmptyRules from './createEmptyRules'; +import createRequestBodyFromState from './createRequestBodyFromState'; +import mapArray from './mapArray'; +import parseResponseToState from './parseResponseToState'; + +export { createEmptyRules, mapArray, createRequestBodyFromState, parseResponseToState }; diff --git a/anyclip/src/modules/userRulesSettings/helpers/mapArray.js b/anyclip/src/modules/userRulesSettings/helpers/mapArray.js new file mode 100644 index 0000000..fc455e0 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/helpers/mapArray.js @@ -0,0 +1,13 @@ +const mapArray = (arrayOfObject, mapFunctions) => + arrayOfObject.map((entity) => { + const extra = {}; + const mapParamNames = Object.keys(mapFunctions); + + mapParamNames.forEach((param) => { + extra[param] = mapFunctions[param](entity); + }); + + return { ...entity, ...extra }; + }); + +export default mapArray; diff --git a/anyclip/src/modules/userRulesSettings/helpers/parseResponseToState.js b/anyclip/src/modules/userRulesSettings/helpers/parseResponseToState.js new file mode 100644 index 0000000..f9d8603 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/helpers/parseResponseToState.js @@ -0,0 +1,60 @@ +import { CONDITION_ENUM_AND, RULE_TYPE_SOURCES, SHARE_WITH_TYPE_HUBS } from '../constants'; + +const getRulesAction = (rules, shouldAddCondition) => { + const res = []; + + Object.keys(rules).forEach((ruleType) => { + const rule = rules[ruleType]; + + if (rule) { + const ruleToState = { + type: ruleType, + value: [RULE_TYPE_SOURCES, SHARE_WITH_TYPE_HUBS].includes(ruleType) + ? rule.value.map((o) => ({ label: o.name, value: o.id })) + : rule.value, + }; + + if (shouldAddCondition) { + ruleToState.condition = rule.condition || CONDITION_ENUM_AND; + } + + res[rule.order] = ruleToState; + } + }); + + return res.filter((o) => o); +}; + +const parseResponseToState = (response) => { + const rules = response.map((rule) => { + const ruleSelectType = getRulesAction(rule.sharedVideoRules, true); + const ruleShareWithType = getRulesAction(rule.shareWith); + const entity = { + ruleId: rule.id, + ruleName: rule.ruleName, + ruleOrder: rule.ruleOrder, + ruleSelectType, + ruleShareWithType, + initialFromApi: { + ruleName: rule.ruleName, + ruleSelectType, + ruleShareWithType, + }, + // options + sourcesOptions: null, + hubsOptions: null, + usersOptions: null, + // ui state + uiIndex: `${Math.random()}-${Math.random()}`, + uiIsOpen: true, + uiIsSaved: true, + uiIsNameInEditMode: false, + }; + + return entity; + }); + + return rules; +}; + +export default parseResponseToState; diff --git a/src/modules/userRulesSettings/index.jsx b/anyclip/src/modules/userRulesSettings/index.jsx similarity index 100% rename from src/modules/userRulesSettings/index.jsx rename to anyclip/src/modules/userRulesSettings/index.jsx diff --git a/anyclip/src/modules/userRulesSettings/redux/epics/get.js b/anyclip/src/modules/userRulesSettings/redux/epics/get.js new file mode 100644 index 0000000..e24622f --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/epics/get.js @@ -0,0 +1,98 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { parseResponseToState } from '../../helpers'; +import { getRulesAction, setModuleStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetUserRules($status: Int) { + getUserRules( + status: $status + ) { + rules { + ruleId + ruleName + ruleOrder + sharedVideoRules { + allMyVideo { + value + order + } + sources { + value { + id + name + } + order + condition + } + labels { + value + order + condition + } + wordsInTitle { + value + order + condition + } + } + shareWith { + allRelatedUsers { + value + order + } + users { + value { + id + email + firstName + lastName + } + order + } + hubs { + value { + id + name + } + order + } + } + } + } + } +`; + +const getResponse = ({ data: { getUserRules } }) => getUserRules.rules; + +export default (action$) => + action$.pipe( + ofType(getRulesAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: { + status: 1, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + const res = getResponse(response); + const rulesState = parseResponseToState(res); + return of( + setModuleStateAction({ + rules: rulesState, + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/userRulesSettings/redux/epics/getHubsAutocomplete.js b/anyclip/src/modules/userRulesSettings/redux/epics/getHubsAutocomplete.js new file mode 100644 index 0000000..1fda355 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/epics/getHubsAutocomplete.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { getHubsOptionsAction, setModuleStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetUserRulesHubs( + $pageSize: Int, + $searchText: String, + ) { + getUserRulesHubs( + pageSize: $pageSize, + searchText: $searchText + ) { + id + name + } + } +`; + +const getResponse = ({ data: { getUserRulesHubs } }) => + getUserRulesHubs.map((record) => ({ label: record.name, value: record.id })); + +export default (action$) => + action$.pipe( + ofType(getHubsOptionsAction.type), + switchMap((action) => { + const variables = { + pageSize: 30, + }; + + if (action.payload) { + variables.searchText = action.payload; + } + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setModuleStateAction({ + hubsOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setModuleStateAction({ + hubsOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/userRulesSettings/redux/epics/getSourcesAutocomplete.js b/anyclip/src/modules/userRulesSettings/redux/epics/getSourcesAutocomplete.js new file mode 100644 index 0000000..089c749 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/epics/getSourcesAutocomplete.js @@ -0,0 +1,74 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { FEED_TYPES, TYPE_LIVE, TYPE_SITEMAP, TYPE_STORY_API } from '@/modules/@common/constants'; + +import { getSourcesOptionAction, setModuleStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query GetUserRulesFeedSources( + $feedTypeFilter: [String], + $pageSize: Int, + $searchText: String, + ) { + getUserRulesFeedSources( + feedTypeFilter: $feedTypeFilter, + pageSize: $pageSize, + searchText: $searchText + ) { + id + name + display_name + } + } +`; + +const getResponse = ({ data: { getUserRulesFeedSources } }) => + getUserRulesFeedSources.map((record) => ({ + label: record.display_name || record.name, + value: record.id, + })); + +export default (action$) => + action$.pipe( + ofType(getSourcesOptionAction.type), + switchMap((action) => { + const variables = { + feedTypeFilter: FEED_TYPES.filter((type) => ![TYPE_STORY_API, TYPE_SITEMAP, TYPE_LIVE].includes(type)), + searchText: action.payload, + pageSize: 30, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setModuleStateAction({ + sourcesOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setModuleStateAction({ + sourcesOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/userRulesSettings/redux/epics/getUsersAutocomplete.js b/anyclip/src/modules/userRulesSettings/redux/epics/getUsersAutocomplete.js new file mode 100644 index 0000000..cdeafda --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/epics/getUsersAutocomplete.js @@ -0,0 +1,79 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SORT_ASC } from '@/modules/@common/constants/sort'; + +import { getUsersOptionsAction, setModuleStateAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query getUsersForUserRules( + $searchText: String, + $searchIn: [String], + $page: Int, + $pageSize: Int, + $sortOrder: String, + $sortBy: String, + ) { + getUsersForUserRules( + searchText: $searchText, + searchIn: $searchIn, + page: $page, + pageSize: $pageSize, + sortOrder: $sortOrder, + sortBy: $sortBy, + ) { + id + email + firstName + lastName + } + } +`; + +const getResponse = ({ data: { getUsersForUserRules } }) => getUsersForUserRules; + +export default (action$) => + action$.pipe( + ofType(getUsersOptionsAction.type), + switchMap((action) => { + const variables = { + pageSize: 30, + searchText: action.payload, + searchIn: ['firstName', 'lastName', 'email'], + sortBy: 'firstName', + sortOrder: SORT_ASC, + }; + + const stream$ = gqlRequest({ + query, + variables, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setModuleStateAction({ + usersOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setModuleStateAction({ + usersOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/userRulesSettings/redux/epics/index.js b/anyclip/src/modules/userRulesSettings/redux/epics/index.js new file mode 100644 index 0000000..b0abd6d --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/epics/index.js @@ -0,0 +1,9 @@ +import { combineEpics } from 'redux-observable'; + +import get from './get'; +import getHubsAutocomplete from './getHubsAutocomplete'; +import getSourcesAutocomplete from './getSourcesAutocomplete'; +import getUsersAutocomplete from './getUsersAutocomplete'; +import save from './save'; + +export default combineEpics(getSourcesAutocomplete, getHubsAutocomplete, getUsersAutocomplete, save, get); diff --git a/anyclip/src/modules/userRulesSettings/redux/epics/save.js b/anyclip/src/modules/userRulesSettings/redux/epics/save.js new file mode 100644 index 0000000..c53c636 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/epics/save.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { SAVE_RULE_ACTION_REMOVE, SAVE_RULE_ACTION_SAVE } from '@/modules/userRulesSettings/constants'; + +import { createRequestBodyFromState } from '../../helpers'; +import { saveRulesAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + mutation SaveUserRules( + $rules: [UserRulesInputType] + ) { + saveUserRules( + rules: $rules + ) { + rules { + ruleId + ruleName + } + } + } +`; + +// const getResponse = ({ data: { saveUserRules } }) => saveUserRules; + +export default (action$, state$) => + action$.pipe( + ofType(saveRulesAction.type), + switchMap((action) => { + const rules = createRequestBodyFromState(state$.value); + const messages = { + [SAVE_RULE_ACTION_SAVE]: 'Rule saved', + [SAVE_RULE_ACTION_REMOVE]: 'Rule deleted', + }; + const message = messages[action.payload]; + + const stream$ = gqlRequest({ + query, + variables: { + rules, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message, + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/userRulesSettings/redux/selectors/index.js b/anyclip/src/modules/userRulesSettings/redux/selectors/index.js new file mode 100644 index 0000000..c44687f --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/selectors/index.js @@ -0,0 +1,9 @@ +import { slice } from '../slices'; + +const nameSpace = slice.name; + +export const rulesSelector = (state) => state[nameSpace].rules; +export const searchSelector = (state) => state[nameSpace].search; +export const sourcesOptionsSelector = (state) => state[nameSpace].sourcesOptions; +export const usersOptionsSelector = (state) => state[nameSpace].usersOptions; +export const hubsOptionsSelector = (state) => state[nameSpace].hubsOptions; diff --git a/anyclip/src/modules/userRulesSettings/redux/slices/index.js b/anyclip/src/modules/userRulesSettings/redux/slices/index.js new file mode 100644 index 0000000..c84f615 --- /dev/null +++ b/anyclip/src/modules/userRulesSettings/redux/slices/index.js @@ -0,0 +1,37 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + rules: [], + search: '', + sourcesOptions: null, + usersOptions: null, + hubsOptions: null, +}; + +export const slice = createSlice({ + name: '@@userRulesSettings', + initialState, + + reducers: { + getSourcesOptionAction: (state) => state, + getHubsOptionsAction: (state) => state, + getUsersOptionsAction: (state) => state, + saveRulesAction: (state) => state, + getRulesAction: (state) => state, + setModuleStateAction: (state, action) => ({ + ...state, + ...action.payload, + }), + }, +}); + +export const { + getHubsOptionsAction, + getRulesAction, + getSourcesOptionAction, + getUsersOptionsAction, + saveRulesAction, + setModuleStateAction, +} = slice.actions; + +export default slice.reducer; diff --git a/anyclip/src/modules/users/Editor/components/Editor.jsx b/anyclip/src/modules/users/Editor/components/Editor.jsx new file mode 100644 index 0000000..ba62e22 --- /dev/null +++ b/anyclip/src/modules/users/Editor/components/Editor.jsx @@ -0,0 +1,239 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; +import { LockOpen, LockPersonRounded } from '@mui/icons-material'; + +import { TAB_GENERAL } from '../constants'; +import { ACCOUNT, API, EXTERNAL, INTERNAL } from '@/modules/@common/user/constants/rolesType'; + +import { getCanReadOnly } from '../helpers/getRestrictions'; +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + impersonateUserAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; +import { getUserPermissionsSelector, getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; +import { resetPasswordAction } from '@/modules/users/List/redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + Tab, + TabContent, + Tabs, + Typography, +} from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const initialEmail = useSelector(selectors.initialEmailSelector); + const initialRoleType = useSelector(selectors.initialRoleTypeSelector); + const initialRoleName = useSelector(selectors.initialRoleNameSelector); + const initialStatus = useSelector(selectors.initialStatusSelector); + + const firstName = useSelector(selectors.firstNameSelector); + const lastName = useSelector(selectors.lastNameSelector); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const [confirmResetPassword, setResetPassword] = useState(false); + const userPermissions = useSelector(getUserPermissionsSelector); + const hasAccount = useSelector(getUserRoleTypeSelector) === ACCOUNT; + + const id = parseInt(router.query.id, 10); + + const canReadOnly = getCanReadOnly(id, userPermissions); + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId, fieldName }) => { + if (!tabs.some((tab) => tab.id === tabId)) { + return false; + } + + if (fieldName === 'account') { + return !hasAccount && allProps.role && ![API, INTERNAL, EXTERNAL].includes(allProps.role.type); + } + + if (fieldName === 'publisherIds' || fieldName === 'publisherId') { + return allProps.role && ![EXTERNAL, INTERNAL, API].includes(allProps.role.type); + } + + if (fieldName === 'contentOwner') { + return allProps.role && allProps.role.type === API; + } + + if (fieldName === 'email') { + return !id; + } + + return true; + }) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + const showLoginAs = + initialRoleName !== 'admin' && !!initialStatus && !hasAccount && initialRoleType && initialRoleType !== API; + + return ( +
    + + + {id ? `${firstName} ${lastName} > Settings` : 'New User'} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + > + {tabs.map((tab) => ( + + ))} + + )} + + {!!id && initialRoleType && initialRoleType !== API && ( + + )} + {!!id && showLoginAs && ( + + )} + + + {!canReadOnly && ( + + )} + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    + {!!confirmResetPassword && ( + setResetPassword(false)}> + setResetPassword(false)}>Reset Password + {`Are you sure you want to reset password for ${initialEmail}?`} + + + + + + )} +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/users/Editor/components/Editor.module.scss b/anyclip/src/modules/users/Editor/components/Editor.module.scss new file mode 100644 index 0000000..dccc0e0 --- /dev/null +++ b/anyclip/src/modules/users/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__uMvL_","Title":"Editor_Title__Zj73S","Controls":"Editor_Controls__SqC3j","Tabs":"Editor_Tabs__3PJDI"}; \ No newline at end of file diff --git a/anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..3cf8802 --- /dev/null +++ b/anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,518 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { useRouter } from 'next/router'; + +import { STATUSES_ACTIVE, STATUSES_INACTIVE } from '../../../../List/constants'; +import { HUB_ADMIN_ROLE, OPTION_ALL, OPTION_ALL_ID } from '../../../constants'; +import { ACCOUNT, API, EXTERNAL, INTERNAL } from '@/modules/@common/user/constants/rolesType'; + +import { getCanReadOnly } from '../../../helpers/getRestrictions'; +import * as selectors from '../../../redux/selectors'; +import { + createDepartmentAction, + getAccountOptionsAction, + getContentOwnersOptionsAction, + getDepartmentOptionsAction, + getRoleOptionsAction, + getTimezoneOptionsAction, + removeErrorByPropAction, + setAction, +} from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { deepClone } from '@/modules/@common/helpers'; +import { + getPublisherIdsSelector, + getUserAccountIdSelector, + getUserAccountSelector, + getUserPermissionsSelector, + getUserRoleTypeSelector, + getUserTimezoneSelector, +} from '@/modules/@common/user/redux/selectors'; + +import { FormRow, FormRowItem, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Button, Paper, Switch, TextField } from '@/mui/components'; + +import styles from './GeneralTab.module.scss'; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + // selectors + const firstName = useSelector(selectors.firstNameSelector); + const lastName = useSelector(selectors.lastNameSelector); + const email = useSelector(selectors.emailSelector); + const status = useSelector(selectors.statusSelector); + const timezone = useSelector(selectors.timezoneSelector); + const timezoneOptions = useSelector(selectors.timezoneOptionsSelector); + const account = useSelector(selectors.accountSelector); + const accountOptions = useSelector(selectors.accountOptionsSelector); + const contentOwner = useSelector(selectors.contentOwnerSelector); + const contentOwnersOptions = useSelector(selectors.contentOwnersOptionsSelector); + const role = useSelector(selectors.roleSelector); + const roleOptions = useSelector(selectors.roleOptionsSelector); + const enable2fa = useSelector(selectors.enable2faSelector); + const scheme = useSelector(selectors.schemeSelector); + const publisherIds = useSelector(selectors.publisherIdsSelector); + const defaultHub = useSelector(selectors.defaultHubSelector); + const allSites = useSelector(selectors.allSitesSelector); + const sendInvite = useSelector(selectors.sendInviteSelector); + const department = useSelector(selectors.departmentSelector); + const departmentOptions = useSelector(selectors.departmentOptionsSelector); + const updateDate = useSelector(selectors.updateDateSelector); + const updatedBy = useSelector(selectors.updatedBySelector); + + const userPermissions = useSelector(getUserPermissionsSelector); + const hasAccount = useSelector(getUserRoleTypeSelector) === ACCOUNT; + const userTimezone = useSelector(getUserTimezoneSelector); + const userAccount = useSelector(getUserAccountSelector); + const userAccountId = useSelector(getUserAccountIdSelector); + const userPublisherIds = useSelector(getPublisherIdsSelector); + + const autocompleteRoleOptions = useMemo(() => { + const roles$ = hasAccount + ? (roleOptions?.filter(({ readOnlyInSelfServe }) => !readOnlyInSelfServe) ?? []) + : (roleOptions ?? []); + + return roles$.filter(({ type }) => type !== EXTERNAL); + }, [hasAccount, roleOptions]); + + const id = parseInt(router.query.id, 10); + const canReadOnly = getCanReadOnly(id, userPermissions); + + const [departmentQuery, setDepartmentQuery] = useState(''); + + // handlers + const handleSetState = (state) => { + if (Object.prototype.hasOwnProperty.call(state, 'role')) { + if (state.role.type === INTERNAL && state.role.name === 'admin') { + state.enable2fa = true; + } + } + + return dispatch(setAction(state)); + }; + + const timezoneValue = useMemo(() => { + const res = timezoneOptions?.find((zone) => zone.value === timezone); + if (res) { + return { + label: res.label, + value: res.value, + }; + } + + return timezone; + }, [timezone]); + + useEffect(() => { + dispatch(getTimezoneOptionsAction()); + dispatch(getRoleOptionsAction()); + }, []); + + useEffect(() => { + if (hasAccount && !account) { + handleSetState({ + account: deepClone({ + id: +userAccountId, + ...userAccount, + }), + }); + } + }); + + useEffect(() => { + if (!timezone && timezoneOptions) { + handleSetState({ + timezone: userTimezone, + }); + } + }, [timezone, timezoneOptions, userTimezone]); + + const hubs = useMemo(() => { + if (allSites) { + return [{ ...OPTION_ALL }]; + } + if (account?.publishers) { + return (publisherIds || []).map((id$) => { + const res = account.publishers.find((p) => p.id === id$); + + return { + name: res.name, + id: res.id, + }; + }); + } + + return []; + }, [account?.publishers, publisherIds, allSites]); + + const publisherOptions = useMemo(() => { + if (!userPublisherIds) { + return role?.name === HUB_ADMIN_ROLE + ? account?.publishers || [] + : [{ ...OPTION_ALL }, ...(account?.publishers || [])]; + } + const accountPublishers = account?.publishers.filter((p) => userPublisherIds.includes(p.id)) || []; + const allIncluded = account?.publishers.length === accountPublishers.length; + + return allIncluded && role?.name !== HUB_ADMIN_ROLE + ? [{ ...OPTION_ALL }, ...(accountPublishers || [])] + : accountPublishers; + }, [account?.publishers, role]); + + const defaultPublisherValue = useMemo(() => { + if (defaultHub === null || defaultHub === undefined) { + return null; + } + + const option = publisherOptions.find((p) => p.id === defaultHub); + + return { + name: option?.name ?? defaultHub, + id: defaultHub, + }; + }, [defaultHub, publisherOptions]); + + return ( + <> + + handleSetState({ firstName: e.target.value })} + {...getInputPropsByName(scheme, ['firstName'])} + onFocus={() => dispatch(removeErrorByPropAction(['firstName']))} + /> + + + handleSetState({ lastName: e.target.value })} + {...getInputPropsByName(scheme, ['lastName'])} + onFocus={() => dispatch(removeErrorByPropAction(['lastName']))} + /> + + + handleSetState({ email: e.target.value })} + {...getInputPropsByName(scheme, ['email'])} + onFocus={() => dispatch(removeErrorByPropAction(['email']))} + /> + + + dispatch(getTimezoneOptionsAction())} + onChange={(e, selected) => + handleSetState({ + timezone: selected?.value || selected, + }) + } + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['timezone']))} + /> + )} + /> + + + dispatch(getRoleOptionsAction())} + onChange={(e, selected) => { + const updateObj = { + role: selected, + }; + + if (role && role.name !== selected.name) { + if (!hasAccount) { + updateObj.account = null; + } + + updateObj.enable2fa = role?.name === 'admin'; + updateObj.defaultHub = null; + updateObj.department = null; + updateObj.publisherIds = []; + updateObj.allSites = 0; + updateObj.contentOwner = null; + } + + return handleSetState(updateObj); + }} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['role']))} + /> + )} + /> + + {role && ![INTERNAL, EXTERNAL].includes(role.type) && ( + <> + {role.type === API ? ( + + { + handleSetState({ + contentOwner: selected, + }); + }} + onOpen={() => { + dispatch(getContentOwnersOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getContentOwnersOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['contentOwner']))} + /> + )} + /> + + ) : ( + <> + {!hasAccount && ( + + { + handleSetState({ + account: selected, + defaultHub: null, + department: null, + publisherIds: [], + }); + }} + onOpen={() => { + dispatch(getAccountOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getAccountOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['account']))} + /> + )} + /> + + )} + {!!account && ( + <> + + { + if (!publisherIds$.length) { + handleSetState({ + publisherIds: [], + defaultHub: null, + allSites: 0, + }); + } else if (single?.option.id === OPTION_ALL_ID) { + handleSetState({ + publisherIds: [], + allSites: 1, + }); + } else { + const selectedHubIds = publisherIds$ + .filter((item) => item.id !== OPTION_ALL_ID) + .map((item) => item.id); + + handleSetState({ + allSites: 0, + publisherIds: selectedHubIds, + ...(defaultHub && !selectedHubIds.includes(defaultHub) ? { defaultHub: null } : {}), + }); + } + }} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['publisherIds']))} + /> + )} + /> + + {!!hubs.length && ( + + item.id !== OPTION_ALL_ID) + : account?.publishers || [] + } + size={size} + optionLabelKey="name" + optionValueKey="id" + onChange={(e, selected) => + handleSetState({ + defaultHub: selected ? selected.id : null, + }) + } + renderInput={(params) => } + /> + + )} + + dispatch(getDepartmentOptionsAction(''))} + onInputChange={(e, searchText) => { + setDepartmentQuery(searchText); + + dispatch(getDepartmentOptionsAction(searchText)); + }} + onChange={(e, selected) => handleSetState({ department: selected })} + renderInput={(params) => } + PaperComponent={({ children }) => ( + + {!!departmentQuery && + departmentOptions && + !departmentOptions?.some((d) => d.name === departmentQuery) && ( +
    + +
    + )} + {children} +
    + )} + /> +
    + + )} + + )} + + )} + + + + handleSetState({ + enable2fa: e.target.checked, + }) + } + /> + + {!!id && ( + + + handleSetState({ + status: e.target.checked ? STATUSES_ACTIVE : STATUSES_INACTIVE, + }) + } + /> + + )} + {!id && ( + + + handleSetState({ + sendInvite: !!e.target.checked, + }) + } + /> + + )} + {updateDate && ( + + {dayjs(updateDate).format('MMM D, YYYY hh:mm A')} + + )} + {updatedBy && ( + + {updatedBy} + + )} + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss b/anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss new file mode 100644 index 0000000..5d3ed6d --- /dev/null +++ b/anyclip/src/modules/users/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"DepartmentPaper":"GeneralTab_DepartmentPaper__39bXN","DepartmentButtonWrapper":"GeneralTab_DepartmentButtonWrapper__1_0CL"}; \ No newline at end of file diff --git a/anyclip/src/modules/users/Editor/constants/index.js b/anyclip/src/modules/users/Editor/constants/index.js new file mode 100644 index 0000000..07943bc --- /dev/null +++ b/anyclip/src/modules/users/Editor/constants/index.js @@ -0,0 +1,13 @@ +export const ROWS_PER_PAGE_DEFAULT = 30; + +export const TAB_GENERAL = 'general'; + +export const REDUX_FIELD_NAME = 'commonForm'; + +export const OPTION_ALL_ID = -1; + +export const OPTION_ALL = { + id: OPTION_ALL_ID, + name: 'All Hubs', +}; +export const HUB_ADMIN_ROLE = 'hub_admin'; diff --git a/anyclip/src/modules/users/Editor/helpers/getRestrictions.js b/anyclip/src/modules/users/Editor/helpers/getRestrictions.js new file mode 100644 index 0000000..a779394 --- /dev/null +++ b/anyclip/src/modules/users/Editor/helpers/getRestrictions.js @@ -0,0 +1,9 @@ +import { PCN_GET_USERS } from '@/modules/@common/acl/constants'; + +import { hasPermission } from '@/modules/@common/user/helpers'; + +export const getCanReadOnly = (id, userPermissions) => + !(id + ? // todo: fix permissions + hasPermission(PCN_GET_USERS /* PCN_PUT_CUSTOM_REPORTS */, userPermissions) + : hasPermission(PCN_GET_USERS /* PCN_POST_CUSTOM_REPORTS */, userPermissions)); diff --git a/anyclip/src/modules/users/Editor/helpers/validationScheme.js b/anyclip/src/modules/users/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..8b506d0 --- /dev/null +++ b/anyclip/src/modules/users/Editor/helpers/validationScheme.js @@ -0,0 +1,98 @@ +import { TAB_GENERAL } from '../constants'; +import { emailRegExp } from '@/modules/@common/constants/validation'; + +export const validationScheme = [ + { + fieldName: 'firstName', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'lastName', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'email', + tabId: TAB_GENERAL, + validation: (value) => { + const trimmedValue = value?.trim(); + let errorMessage = ''; + + if (!trimmedValue) { + errorMessage = 'Field cannot be empty'; + } else if (!emailRegExp.test(trimmedValue)) { + errorMessage = 'Invalid email'; + } + + return errorMessage; + }, + }, + { + fieldName: 'timezone', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'role', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'account', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'contentOwner', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'publisherIds', + tabId: TAB_GENERAL, + validation: (value, allProps) => { + if (!allProps.allSites && !value?.length) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/users/Editor/redux/epics/createDepartment.js b/anyclip/src/modules/users/Editor/redux/epics/createDepartment.js new file mode 100644 index 0000000..96fdf4b --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/createDepartment.js @@ -0,0 +1,58 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_DEPARTMENT } from '@/graphql/services/users/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/department'; + +import { createDepartmentAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import * as selectors from '@/modules/users/Editor/redux/selectors'; + +const query = ` + mutation ${CREATE_DEPARTMENT}($payload: ${PAYLOAD_NAME}){ + ${CREATE_DEPARTMENT}(payload: $payload) { + id + name + } + } +`; + +const getResponse = ({ data }) => data[CREATE_DEPARTMENT]; + +export default (action$, state$) => + action$.pipe( + ofType(createDepartmentAction.type), + switchMap(({ payload }) => { + const account = selectors.accountSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + accountId: account.id, + name: payload, + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setAction({ + department: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/createItem.js b/anyclip/src/modules/users/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..380c02b --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/createItem.js @@ -0,0 +1,88 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_USER } from '@/graphql/services/users/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { API } from '@/modules/@common/user/constants/rolesType'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/item'; + +import * as selectors from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_USER}($payload: ${PAYLOAD_NAME}) { + ${CREATE_USER}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const firstName = selectors.firstNameSelector(state$.value); + const lastName = selectors.lastNameSelector(state$.value); + const enable2fa = selectors.enable2faSelector(state$.value); + const role = selectors.roleSelector(state$.value); + const timezone = selectors.timezoneSelector(state$.value); + const department = selectors.departmentSelector(state$.value); + const defaultHub = selectors.defaultHubSelector(state$.value); + const publisherIds = selectors.publisherIdsSelector(state$.value); + const allSites = selectors.allSitesSelector(state$.value); + const email = selectors.emailSelector(state$.value); + const sendInvite = selectors.sendInviteSelector(state$.value); + const account = selectors.accountSelector(state$.value); + + const payload = { + firstName, + lastName, + enable2fa, + roleId: role.id, + timezone, + departmentId: department?.id, + defaultHub: defaultHub || null, + publisherIds, + allSites: !!allSites, + email, + sendInvite, + }; + + if (account?.id) { + payload.accountId = account.id; + } + + if (role.type === API) { + const contentOwner = selectors.contentOwnerSelector(state$.value); + + payload.contentOwnerId = contentOwner.id; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/users'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'User created successfully', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/getAccountOptions.js b/anyclip/src/modules/users/Editor/redux/epics/getAccountOptions.js new file mode 100644 index 0000000..8a24c3b --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/getAccountOptions.js @@ -0,0 +1,67 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_USER_ACCOUNTS_OPTIONS } from '@/graphql/services/users/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/accounts'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_USER_ACCOUNTS_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_USER_ACCOUNTS_OPTIONS}(payload: $payload) { + id + name + type + publishers { + id + name + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_USER_ACCOUNTS_OPTIONS].map((account) => ({ + ...account, + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const accountOptions = getResponse(response); + + actions.push( + of( + setAction({ + accountOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/getContentOwnersOptions.js b/anyclip/src/modules/users/Editor/redux/epics/getContentOwnersOptions.js new file mode 100644 index 0000000..f543731 --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/getContentOwnersOptions.js @@ -0,0 +1,62 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_CONTENT_OWNERS_OPTIONS } from '@/graphql/services/users/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/contentOwners'; + +import { getContentOwnersOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_CONTENT_OWNERS_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_CONTENT_OWNERS_OPTIONS}(payload: $payload) { + id + name + } + } +`; + +const getResponse = ({ data }) => + data[GET_CONTENT_OWNERS_OPTIONS].map((account) => ({ + ...account, + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getContentOwnersOptionsAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload, + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const contentOwnersOptions = getResponse(response); + + actions.push( + of( + setAction({ + contentOwnersOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/getDepartmentOptions.js b/anyclip/src/modules/users/Editor/redux/epics/getDepartmentOptions.js new file mode 100644 index 0000000..1e8b79e --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/getDepartmentOptions.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_DEPARTMENT_OPTIONS } from '@/graphql/services/users/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/departmentList'; + +import { getDepartmentOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import * as selectors from '@/modules/users/Editor/redux/selectors'; + +const query = ` + query ${GET_DEPARTMENT_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_DEPARTMENT_OPTIONS}(payload: $payload) { + id + name + } + } +`; + +const getResponse = ({ data }) => data[GET_DEPARTMENT_OPTIONS]; + +export default (action$, state$) => + action$.pipe( + ofType(getDepartmentOptionsAction.type), + switchMap(({ payload }) => { + const account = selectors.accountSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: payload, + accountId: account.id, + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setAction({ + departmentOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + departmentOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/getItem.js b/anyclip/src/modules/users/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..02d2c87 --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/getItem.js @@ -0,0 +1,157 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_USER } from '@/graphql/services/users/constants'; +import { TYPE_ERROR, TYPE_WARNING } from '@/modules/@common/notify/constants'; +import { ACCOUNT } from '@/modules/@common/user/constants/rolesType'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/itemDetails'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_USER}($payload: ${PAYLOAD_NAME}) { + ${GET_USER}(payload: $payload) { + id + email + firstName + lastName + status + enable2fa + allSites + contentOwner { + id + name + } + updateDate + updatedBy + account { + id + name + type + publishers { + id + name + } + } + role { + id + name + type + displayName + readOnlyInSelfServe + } + timezone + defaultHub + publisherIds + department { + id + name + } + } + } +`; + +const getResponse = ({ data }) => { + const user = data[GET_USER]; + + return { + ...user, + contentOwner: user.contentOwner + ? { + ...user.contentOwner, + label: user.contentOwner.name, + value: user.contentOwner.id, + } + : user.role, + role: user.role + ? { + ...user.role, + label: user.role.displayName, + value: user.role.id, + } + : user.role, + account: user.account + ? { + ...user.account, + label: user.account.name, + value: user.account.id, + } + : user.account, + }; +}; + +export default (action$, state$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const hasAccount = getUserRoleTypeSelector(state$.value) === ACCOUNT; + + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: action.payload.id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open user for edit", + }), + ), + ); + + Router.push('/users'); + } else { + const data = getResponse(response); + + if (hasAccount && data.role.readOnlyInSelfServe) { + Router.push('/users'); + + actions.push( + of( + showNotificationAction({ + type: TYPE_WARNING, + message: "You can't open the user for edit", + }), + ), + ); + } else { + actions.push( + of( + setAction({ + ...data, + initialEmail: data.email, + initialStatus: data.status, + initialRoleType: data.role?.type ?? null, + initialRoleName: data.role?.name ?? null, + }), + ), + ); + } + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/getRoleOptions.js b/anyclip/src/modules/users/Editor/redux/epics/getRoleOptions.js new file mode 100644 index 0000000..681690d --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/getRoleOptions.js @@ -0,0 +1,69 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_USER_ROLE_OPTIONS } from '@/graphql/services/users/constants'; +import { ACCOUNT } from '@/modules/@common/user/constants/rolesType'; + +import { getRoleOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query ${GET_USER_ROLE_OPTIONS} { + ${GET_USER_ROLE_OPTIONS} { + id + name + type + displayName + visibleInSelfServe + readOnlyInSelfServe + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getRoleOptionsAction.type), + switchMap(() => { + const isAccountType = getUserRoleTypeSelector(state$.value) === ACCOUNT; + + const stream$ = gqlRequest({ + query, + variables: {}, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const roleOptions = response.data[GET_USER_ROLE_OPTIONS].filter( + (item) => !isAccountType || (!item.readOnlyInSelfServe && item.visibleInSelfServe), + ).map((role) => ({ + value: role.id, + label: role.displayName, + ...role, + })); + + actions.push( + of( + setAction({ + roleOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + roleOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/getTimezoneOptions.js b/anyclip/src/modules/users/Editor/redux/epics/getTimezoneOptions.js new file mode 100644 index 0000000..54fcc8a --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/getTimezoneOptions.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_USER_TIMEZONE_OPTIONS } from '@/graphql/services/users/constants'; + +import { getTimezoneOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_USER_TIMEZONE_OPTIONS} { + ${GET_USER_TIMEZONE_OPTIONS} { + name + displayName + } + } +`; + +const getResponse = ({ data }) => + data[GET_USER_TIMEZONE_OPTIONS].map((account) => ({ + value: account.name, + label: account.displayName, + })); + +export default (action$) => + action$.pipe( + ofType(getTimezoneOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: {}, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setAction({ + timezoneOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + timezoneOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/impersonate.js b/anyclip/src/modules/users/Editor/redux/epics/impersonate.js new file mode 100644 index 0000000..c5d442d --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/impersonate.js @@ -0,0 +1,89 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, EMPTY, of } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; + +import { TYPE_ERROR, TYPE_INFO } from '@/modules/@common/notify/constants'; +import { TOKEN_COOKIE_NAME, TOKEN_COOKIE_VALUE } from '@/modules/@common/token/constants'; + +import { impersonateUserAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { getToken, setTokenCookieName, setTokenCookieValue } from '@/modules/@common/token/helpers'; + +export default (action$) => + action$.pipe( + ofType(impersonateUserAction.type), + switchMap(({ payload: userId }) => { + const stream$ = defer(() => { + const impersonateUser = async () => { + const response = await fetch(`${process.env.APP_PCN_API_BASE_URL_FE}/private/users/impersonate`, { + credentials: 'include', + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: getToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId, + }), + }); + + const result = await response.json(); + + if (response.status !== 200) { + throw new Error('Could not Login As'); + } + + try { + await fetch(`${window.location.origin}/api/auth/login`, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: getToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token: result.token, + impersonateToken: getToken(), + [TOKEN_COOKIE_NAME]: result?.cookieName, + [TOKEN_COOKIE_VALUE]: result?.cookieValue, + }), + }); + } catch { + // Should be changed later on, this is temporary + } + + window.localStorage.clear(); + + if (result?.cookieName) { + setTokenCookieName(result.cookieName); + setTokenCookieValue(result.cookieValue); + } + + window.location.href = `${window.location.origin}/`; + }; + + return impersonateUser(); + }).pipe( + switchMap(() => EMPTY), + catchError(() => + of( + notifyAction({ + type: TYPE_ERROR, + message: 'Error Logging As', + }), + ), + ), + ); + + return concat( + of( + notifyAction({ + type: TYPE_INFO, + message: 'Start Logging As', + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/epics/index.js b/anyclip/src/modules/users/Editor/redux/epics/index.js new file mode 100644 index 0000000..e91f73e --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/index.js @@ -0,0 +1,25 @@ +import { combineEpics } from 'redux-observable'; + +import createDepartment from './createDepartment'; +import createItem from './createItem'; +import getAccountOptions from './getAccountOptions'; +import getContentOwnersOptions from './getContentOwnersOptions'; +import getDepartmentOptions from './getDepartmentOptions'; +import getItem from './getItem'; +import getRoleOptions from './getRoleOptions'; +import getTimezoneOptions from './getTimezoneOptions'; +import impersonate from './impersonate'; +import updateItem from './updateItem'; + +export default combineEpics( + getAccountOptions, + getContentOwnersOptions, + getItem, + createItem, + updateItem, + impersonate, + getRoleOptions, + getTimezoneOptions, + getDepartmentOptions, + createDepartment, +); diff --git a/anyclip/src/modules/users/Editor/redux/epics/updateItem.js b/anyclip/src/modules/users/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..1c5a463 --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/epics/updateItem.js @@ -0,0 +1,85 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_USER } from '@/graphql/services/users/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; +import { API } from '@/modules/@common/user/constants/rolesType'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/item'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_USER}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_USER}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap(({ payload: id }) => { + const firstName = selectors.firstNameSelector(state$.value); + const lastName = selectors.lastNameSelector(state$.value); + const enable2fa = selectors.enable2faSelector(state$.value); + const role = selectors.roleSelector(state$.value); + const timezone = selectors.timezoneSelector(state$.value); + const department = selectors.departmentSelector(state$.value); + + const defaultHub = selectors.defaultHubSelector(state$.value); + const publisherIds = selectors.publisherIdsSelector(state$.value); + const allSites = selectors.allSitesSelector(state$.value); + const status = selectors.statusSelector(state$.value); + const account = selectors.accountSelector(state$.value); + + const payload = { + id, + accountId: account?.id || null, + firstName, + lastName, + enable2fa, + roleId: role.id, + timezone, + departmentId: department?.id ?? null, + defaultHub: defaultHub || null, + publisherIds: publisherIds || [], + allSites: !!allSites, + status, + }; + + if (role.type === API) { + const contentOwner = selectors.contentOwnerSelector(state$.value); + + payload.contentOwnerId = contentOwner.id; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/users'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'User updated successfully', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/Editor/redux/selectors/index.js b/anyclip/src/modules/users/Editor/redux/selectors/index.js new file mode 100644 index 0000000..c7c3bc2 --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/selectors/index.js @@ -0,0 +1,44 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; + +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; + +export const contentOwnerSelector = (state) => state[nameSpace].contentOwner; +export const contentOwnersOptionsSelector = (state) => state[nameSpace].contentOwnersOptions; + +export const idSelector = (state) => state[nameSpace].id; +export const emailSelector = (state) => state[nameSpace].email; +export const firstNameSelector = (state) => state[nameSpace].firstName; +export const lastNameSelector = (state) => state[nameSpace].lastName; +export const statusSelector = (state) => state[nameSpace].status; +export const timezoneSelector = (state) => state[nameSpace].timezone; +export const timezoneOptionsSelector = (state) => state[nameSpace].timezoneOptions; +export const roleSelector = (state) => state[nameSpace].role; +export const roleOptionsSelector = (state) => state[nameSpace].roleOptions; +export const enable2faSelector = (state) => state[nameSpace].enable2fa; +export const defaultHubSelector = (state) => state[nameSpace].defaultHub; +export const publisherIdsSelector = (state) => state[nameSpace].publisherIds; +export const departmentSelector = (state) => state[nameSpace].department; +export const departmentOptionsSelector = (state) => state[nameSpace].departmentOptions; +export const allSitesSelector = (state) => state[nameSpace].allSites; +export const sendInviteSelector = (state) => state[nameSpace].sendInvite; +export const updateDateSelector = (state) => state[nameSpace].updateDate; +export const updatedBySelector = (state) => state[nameSpace].updatedBy; + +export const initialRoleTypeSelector = (state) => state[nameSpace].initialRoleType; +export const initialRoleNameSelector = (state) => state[nameSpace].initialRoleName; +export const initialEmailSelector = (state) => state[nameSpace].initialEmail; +export const initialStatusSelector = (state) => state[nameSpace].initialStatus; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/users/Editor/redux/slices/index.js b/anyclip/src/modules/users/Editor/redux/slices/index.js new file mode 100644 index 0000000..596df72 --- /dev/null +++ b/anyclip/src/modules/users/Editor/redux/slices/index.js @@ -0,0 +1,109 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { STATUSES_ACTIVE, STATUSES_INACTIVE } from '../../../List/constants'; +import { REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + email: '', + firstName: '', + lastName: '', + status: STATUSES_ACTIVE, + enable2fa: false, + sendInvite: true, + allSites: false, + + updateDate: '', + updatedBy: '', + + timezone: null, + timezoneOptions: null, + + role: null, + roleOptions: null, + + defaultHub: null, + publisherIds: [], + + department: null, + departmentOptions: null, + + account: null, + accountOptions: null, + + contentOwner: null, + contentOwnersOptions: null, + + initialRoleName: null, + initialRoleType: null, + initialEmail: null, + initialStatus: STATUSES_INACTIVE, + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@USERS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + impersonateUserAction: (state) => state, + getItemAction: (state) => state, + getAccountOptionsAction: (state) => state, + getContentOwnersOptionsAction: (state) => state, + getDemandAccountsOptionsAction: (state) => state, + getTemplatePlayerOptionsAction: (state) => state, + createDepartmentAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + getRoleOptionsAction: (state) => state, + getTimezoneOptionsAction: (state) => state, + getDepartmentOptionsAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + impersonateUserAction, + getAccountOptionsAction, + getContentOwnersOptionsAction, + createItemAction, + updateItemAction, + createDepartmentAction, + getRoleOptionsAction, + getTimezoneOptionsAction, + getDepartmentOptionsAction, + + setActiveTabIdAction, + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/users/List/components/Empty/Empty.jsx b/anyclip/src/modules/users/List/components/Empty/Empty.jsx similarity index 100% rename from src/modules/users/List/components/Empty/Empty.jsx rename to anyclip/src/modules/users/List/components/Empty/Empty.jsx diff --git a/src/modules/users/List/components/Empty/Empty.module.scss b/anyclip/src/modules/users/List/components/Empty/Empty.module.scss similarity index 100% rename from src/modules/users/List/components/Empty/Empty.module.scss rename to anyclip/src/modules/users/List/components/Empty/Empty.module.scss diff --git a/src/modules/users/List/components/List.jsx b/anyclip/src/modules/users/List/components/List.jsx similarity index 100% rename from src/modules/users/List/components/List.jsx rename to anyclip/src/modules/users/List/components/List.jsx diff --git a/src/modules/users/List/components/List.module.scss b/anyclip/src/modules/users/List/components/List.module.scss similarity index 100% rename from src/modules/users/List/components/List.module.scss rename to anyclip/src/modules/users/List/components/List.module.scss diff --git a/anyclip/src/modules/users/List/constants/index.js b/anyclip/src/modules/users/List/constants/index.js new file mode 100644 index 0000000..df4f070 --- /dev/null +++ b/anyclip/src/modules/users/List/constants/index.js @@ -0,0 +1,18 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +// Status Select +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = 0; + +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Disabled', value: STATUSES_INACTIVE }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updateDate'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/users/List/helpers/computedState.js b/anyclip/src/modules/users/List/helpers/computedState.js new file mode 100644 index 0000000..0ed7a3a --- /dev/null +++ b/anyclip/src/modules/users/List/helpers/computedState.js @@ -0,0 +1,18 @@ +import { STATUSES_ALL } from '../constants'; + +import * as selectors from '../redux/selectors'; + +export const shouldShowEmpty = (state) => { + const data = selectors.dataSelector(state); + const page = selectors.pageSelector(state); + const search = selectors.searchSelector(state); + const account = selectors.accountSelector(state); + const status = selectors.statusSelector(state); + const isLoading = selectors.isLoadingSelector(state); + + return ( + !isLoading && Array.isArray(data) && !data.length && page === 1 && !search && !account && status === STATUSES_ALL + ); +}; + +export default {}; diff --git a/src/modules/users/List/helpers/index.js b/anyclip/src/modules/users/List/helpers/index.js similarity index 100% rename from src/modules/users/List/helpers/index.js rename to anyclip/src/modules/users/List/helpers/index.js diff --git a/anyclip/src/modules/users/List/redux/epics/bulkUpdate.js b/anyclip/src/modules/users/List/redux/epics/bulkUpdate.js new file mode 100644 index 0000000..164ff77 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/bulkUpdate.js @@ -0,0 +1,68 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, filter, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { BULK_USER_ACTIONS } from '@/graphql/services/users/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/bulkActions'; + +import * as selectors from '../selectors'; +import { bulkUpdateAction, getDataAction, setTableAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${BULK_USER_ACTIONS} ($payload: ${PAYLOAD_NAME}) { + ${BULK_USER_ACTIONS}(payload: $payload){ + ids + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(bulkUpdateAction.type), + filter(() => { + const selected = selectors.selectedSelector(state$.value); + + return selected.length > 0; + }), + switchMap((action) => { + const selected = selectors.selectedSelector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + ids: selected, + field: action.payload.field, + value: action.payload.value, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Action completed successfully', + }), + ), + of( + setTableAction({ + selected: [], + }), + ), + of(getDataAction()), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/List/redux/epics/generateToken.js b/anyclip/src/modules/users/List/redux/epics/generateToken.js new file mode 100644 index 0000000..5e39bb3 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/generateToken.js @@ -0,0 +1,60 @@ +import { ofType } from 'redux-observable'; +import { concat, defer, EMPTY } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { GENERATE_TOKEN } from '@/graphql/services/users/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/apiToken'; + +import { generateTokenAction } from '../slices'; +import copyToClipboard from '@/modules/@common/helpers/copy'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${GENERATE_TOKEN}($payload: ${PAYLOAD_NAME}) { + ${GENERATE_TOKEN}(payload: $payload){ + access + refresh + } + } +`; + +const getResponse = ({ data }) => data[GENERATE_TOKEN]; + +export default (action$) => + action$.pipe( + ofType(generateTokenAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + accountId: action.payload.accountId, + userId: action.payload.userId, + expirationDate: action.payload.expirationDate, + resources: action.payload.resources, + isExpirationDateActive: action.payload.isExpirationDateActive, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return defer(() => copyToClipboard(JSON.stringify(getResponse(response), null, ' '))).pipe( + map(() => + notifyAction({ + type: TYPE_SUCCESS, + message: 'Access and Refresh tokens were copied to Clipboard', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/List/redux/epics/getAccounts.js b/anyclip/src/modules/users/List/redux/epics/getAccounts.js new file mode 100644 index 0000000..4d1d000 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/getAccounts.js @@ -0,0 +1,59 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_USER_ACCOUNTS_OPTIONS } from '@/graphql/services/users/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/accounts'; + +import { getAccountOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_USER_ACCOUNTS_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_USER_ACCOUNTS_OPTIONS}(payload: $payload) { + id + name + } + } +`; + +const getResponse = ({ data }) => + data[GET_USER_ACCOUNTS_OPTIONS].map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getAccountOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + accountOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/List/redux/epics/getApiSet.js b/anyclip/src/modules/users/List/redux/epics/getApiSet.js new file mode 100644 index 0000000..49332d0 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/getApiSet.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_API_SET } from '@/graphql/services/users/constants'; + +import { getApiSetAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_API_SET} { + ${GET_API_SET} { + resource + } + } +`; + +const getResponse = ({ data }) => data[GET_API_SET].map(({ resource }) => resource); + +export default (action$) => + action$.pipe( + ofType(getApiSetAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + variables: {}, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + apiSet: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat( + of( + setAction({ + apiSet: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/users/List/redux/epics/getData.js b/anyclip/src/modules/users/List/redux/epics/getData.js new file mode 100644 index 0000000..7322b6a --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/getData.js @@ -0,0 +1,90 @@ +import { STATUSES_ALL } from '../../constants'; +import { GET_USERS } from '@/graphql/services/users/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/list'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_USERS}($payload: ${PAYLOAD_NAME}) { + ${GET_USERS}(payload: $payload) { + records { + id + email + firstName + lastName + accountId + departmentId + status + updateDate + updatedBy + lastLoginDate + department { + id + name + } + role { + name + displayName + type + readOnlyInSelfServe + } + account { + name + } + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const account = selectors.accountSelector(state); + const role = selectors.roleSelector(state); + const department = selectors.departmentSelector(state); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = status; + } + + if (account) { + variables.accountId = account.value; + } + + if (role) { + variables.roleId = role.value; + } + + if (department) { + variables.departmentId = department.value; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const users = data[GET_USERS]; + + return { + records: users.records, + recordsTotal: users.recordsTotal, + allRecordsCount: users.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/users/List/redux/epics/getDepartmentOptions.js b/anyclip/src/modules/users/List/redux/epics/getDepartmentOptions.js new file mode 100644 index 0000000..9ea5175 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/getDepartmentOptions.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_DEPARTMENT_OPTIONS } from '@/graphql/services/users/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/departmentList'; + +import { getDepartmentOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_DEPARTMENT_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_DEPARTMENT_OPTIONS}(payload: $payload) { + id + name + } + } +`; + +const getResponse = ({ data }) => + data[GET_DEPARTMENT_OPTIONS].map((account) => ({ + value: account.id, + label: account.name, + })); + +export default (action$) => + action$.pipe( + ofType(getDepartmentOptionsAction.type), + switchMap(({ payload }) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: payload, + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + actions.push( + of( + setAction({ + departmentOptions: getResponse(response), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + departmentOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/users/List/redux/epics/getRoleOptions.js b/anyclip/src/modules/users/List/redux/epics/getRoleOptions.js new file mode 100644 index 0000000..78b59a5 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/getRoleOptions.js @@ -0,0 +1,75 @@ +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_USER_ROLE_OPTIONS } from '@/graphql/services/users/constants'; +import { TYPE_BUSINESS, TYPE_PUBLISHER, TYPE_VAST } from '@/modules/@common/constants/account'; +import { ACCOUNT } from '@/modules/@common/user/constants/rolesType'; + +import { getRoleOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { getUserRoleTypeSelector } from '@/modules/@common/user/redux/selectors'; + +const query = ` + query ${GET_USER_ROLE_OPTIONS} { + ${GET_USER_ROLE_OPTIONS} { + id + name + displayName + visibleInSelfServe + readOnlyInSelfServe + } + } +`; + +export default (action$, state$) => + action$.pipe( + ofType(getRoleOptionsAction.type), + switchMap((action) => { + const isAccountType = getUserRoleTypeSelector(state$.value) === ACCOUNT; + const { searchText = '' } = action.payload ?? {}; + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText, + pageSize: 30, + filtersValues: [TYPE_PUBLISHER, TYPE_BUSINESS, TYPE_VAST], + }, + }, + }).pipe( + switchMap((response) => { + const actions = []; + + if (!response.errors.length) { + const roleOptions = response.data[GET_USER_ROLE_OPTIONS].filter( + (item) => !isAccountType || item.visibleInSelfServe, + ).map((account) => ({ + value: account.id, + label: account.displayName, + })); + + actions.push( + of( + setAction({ + roleOptions, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat( + of( + setAction({ + roleOptions: null, + }), + ), + stream$, + ); + }), + ); diff --git a/anyclip/src/modules/users/List/redux/epics/index.js b/anyclip/src/modules/users/List/redux/epics/index.js new file mode 100644 index 0000000..3b76322 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/index.js @@ -0,0 +1,21 @@ +import { combineEpics } from 'redux-observable'; + +import bulkUpdate from './bulkUpdate'; +import generateToken from './generateToken'; +import getAccounts from './getAccounts'; +import getApiSet from './getApiSet'; +import getData from './getData'; +import getDepartmentOptions from './getDepartmentOptions'; +import getRoleOptions from './getRoleOptions'; +import resetPassword from './resetPassword'; + +export default combineEpics( + getData, + getAccounts, + getRoleOptions, + bulkUpdate, + getApiSet, + generateToken, + resetPassword, + getDepartmentOptions, +); diff --git a/anyclip/src/modules/users/List/redux/epics/resetPassword.js b/anyclip/src/modules/users/List/redux/epics/resetPassword.js new file mode 100644 index 0000000..5d744a2 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/epics/resetPassword.js @@ -0,0 +1,50 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { RESET_PASSWORD } from '@/graphql/services/users/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/users/types/payload/resetPassword'; + +import { resetPasswordAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${RESET_PASSWORD}($payload: ${PAYLOAD_NAME}) { + ${RESET_PASSWORD}(payload: $payload){ + email + } + } +`; + +export default (action$) => + action$.pipe( + ofType(resetPasswordAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + email: action.payload, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Password reset successfully', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/users/List/redux/selectors/index.js b/anyclip/src/modules/users/List/redux/selectors/index.js new file mode 100644 index 0000000..e318f85 --- /dev/null +++ b/anyclip/src/modules/users/List/redux/selectors/index.js @@ -0,0 +1,31 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const statusSelector = (state) => state[nameSpace].status; +export const searchSelector = (state) => state[nameSpace].search; +export const accountSelector = (state) => state[nameSpace].account; +export const accountOptionsSelector = (state) => state[nameSpace].accountOptions; + +export const departmentSelector = (state) => state[nameSpace].department; +export const departmentOptionsSelector = (state) => state[nameSpace].departmentOptions; + +export const roleSelector = (state) => state[nameSpace].role; +export const roleOptionsSelector = (state) => state[nameSpace].roleOptions; + +export const apiSetSelector = (state) => state[nameSpace].apiSet; diff --git a/anyclip/src/modules/users/List/redux/slices/index.js b/anyclip/src/modules/users/List/redux/slices/index.js new file mode 100644 index 0000000..58a85cf --- /dev/null +++ b/anyclip/src/modules/users/List/redux/slices/index.js @@ -0,0 +1,66 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ACTIVE, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + account: null, + accountOptions: null, // null need for loading state + role: null, + roleOptions: null, // null need for loading state + + department: null, + departmentOptions: null, // null need for loading state + + status: STATUSES_ACTIVE, + apiSet: null, +}; + +export const slice = createSlice({ + name: '@@USERS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getRoleOptionsAction: (state) => state, + getAccountOptionsAction: (state) => state, + getApiSetAction: (state) => state, + generateTokenAction: (state) => state, + resetPasswordAction: (state) => state, + bulkUpdateAction: (state) => state, + getDepartmentOptionsAction: (state) => state, + }, +}); + +export const { + getDataAction, + setTableAction, + setAction, + getRoleOptionsAction, + getAccountOptionsAction, + getApiSetAction, + generateTokenAction, + resetPasswordAction, + bulkUpdateAction, + getDepartmentOptionsAction, +} = slice.actions; diff --git a/anyclip/src/modules/xRay/campaigns/Editor/components/Editor.jsx b/anyclip/src/modules/xRay/campaigns/Editor/components/Editor.jsx new file mode 100644 index 0000000..af7473b --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/components/Editor.jsx @@ -0,0 +1,133 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_GENERAL } from '../constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const name = useSelector(selectors.nameSelector); + + const id = parseInt(router.query.id, 10); + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + ].filter(Boolean); + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id) { + dispatch(updateItemAction(id)); + } else { + dispatch(createItemAction()); + } + + dispatch(setErrorByPropAction(validation)); + }; + + return ( +
    + + + {id ? `${name} > Settings` : 'New X-Ray Campaign'} + + + + {tabs.length > 1 && ( + + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/xRay/campaigns/Editor/components/Editor.module.scss b/anyclip/src/modules/xRay/campaigns/Editor/components/Editor.module.scss new file mode 100644 index 0000000..4fd0d6a --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__yRa0I","Title":"Editor_Title__sJFFn","Controls":"Editor_Controls__s9X5b","Tabs":"Editor_Tabs__Xk6wl"}; \ No newline at end of file diff --git a/anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..c607191 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import * as selectors from '../../../redux/selectors'; +import { + createAdvertiserAction, + getAdvertiserOptionsAction, + getHubOptionsAction, + removeErrorByPropAction, + setAction, +} from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Button, Paper, Stack, TextField } from '@/mui/components'; + +import styles from './GeneralTab.module.scss'; + +const NAME_MAX_LENGTH = 40; +const ADVERTISER_MAX_LENGTH = 40; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + const [advertiserSearchText, setAdvertiserSearchText] = useState(''); + + // selectors + const name = useSelector(selectors.nameSelector); + const advertiser = useSelector(selectors.advertiserSelector); + const advertiserOptions = useSelector(selectors.advertiserOptionsSelector); + const hub = useSelector(selectors.hubSelector); + const hubOptions = useSelector(selectors.hubOptionsSelector); + + const scheme = useSelector(selectors.schemeSelector); + + const id = parseInt(router.query.id, 10); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + + { + handleSetState({ + hub: selected, + }); + }} + onOpen={() => { + dispatch(getHubOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getHubOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['hub']))} + /> + )} + /> + + + + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + + + { + handleSetState({ + advertiser: selected, + }); + }} + onOpen={() => { + dispatch(getAdvertiserOptionsAction('')); + }} + onInputChange={(e, searchText) => { + setAdvertiserSearchText(searchText); + dispatch(getAdvertiserOptionsAction(searchText)); + }} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['advertiser']))} + /> + )} + PaperComponent={({ children }) => ( + + {!!advertiserSearchText && + advertiserOptions && + !advertiserOptions?.some((d) => d.name === advertiserSearchText) && ( +
    + +
    + )} + {children} +
    + )} + /> +
    +
    + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss b/anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss new file mode 100644 index 0000000..2918e69 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/components/Tabs/GeneralTab/GeneralTab.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"AddPaper":"GeneralTab_AddPaper__XAkyy","AddPaperButtonWrapper":"GeneralTab_AddPaperButtonWrapper__pm8BK"}; \ No newline at end of file diff --git a/anyclip/src/modules/xRay/campaigns/Editor/constants/index.js b/anyclip/src/modules/xRay/campaigns/Editor/constants/index.js new file mode 100644 index 0000000..815cdd2 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/constants/index.js @@ -0,0 +1,4 @@ +export const TAB_GENERAL = 'general'; +export const REDUX_FIELD_NAME = 'commonForm'; + +export const PUBLIC_ACCOUNT_TYPE = 'SYNDICATION'; diff --git a/anyclip/src/modules/xRay/campaigns/Editor/helpers/validationScheme.js b/anyclip/src/modules/xRay/campaigns/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..149a672 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/helpers/validationScheme.js @@ -0,0 +1,41 @@ +import { TAB_GENERAL } from '../constants'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'hub', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'advertiser', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createAdvertiser.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createAdvertiser.js new file mode 100644 index 0000000..202c595 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createAdvertiser.js @@ -0,0 +1,52 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_XRAY_CAMPAIGN_ADVERTISER_BRAND } from '@/graphql/services/xRayCampaings/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/advertiserItem'; + +import { createAdvertiserAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_XRAY_CAMPAIGN_ADVERTISER_BRAND}($payload: ${PAYLOAD_NAME}) { + ${CREATE_XRAY_CAMPAIGN_ADVERTISER_BRAND}(payload: $payload) { + id + name + } +}`; + +export default (action$) => + action$.pipe( + ofType(createAdvertiserAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + name: action.payload, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of(setAction({ advertiser: response.data[CREATE_XRAY_CAMPAIGN_ADVERTISER_BRAND] })), + of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Created', + }), + ), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createItem.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..56687c0 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/createItem.js @@ -0,0 +1,58 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_XRAY_CAMPAIGN } from '@/graphql/services/xRayCampaings/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/xRayCampaignItem'; + +import * as selectors from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_XRAY_CAMPAIGN}($payload: ${PAYLOAD_NAME}) { + ${CREATE_XRAY_CAMPAIGN}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const name = selectors.nameSelector(state$.value); + const advertisingBrandId = selectors.advertiserSelector(state$.value)?.id; + const publisherId = selectors.hubSelector(state$.value)?.id; + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + name, + advertisingBrandId, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/x-ray/campaigns'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Created', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getAdvertisers.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getAdvertisers.js new file mode 100644 index 0000000..60ef622 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getAdvertisers.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS } from '@/graphql/services/xRayCampaings/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/advertiser'; + +import { getAdvertiserOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getAdvertiserOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + search: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + advertiserOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getHubs.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getHubs.js new file mode 100644 index 0000000..d116a8a --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getHubs.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CAMPAIGNS_HUB_OPTIONS } from '@/graphql/services/xRayCampaings/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_CAMPAIGNS_HUB_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getItem.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..98a70ff --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/getItem.js @@ -0,0 +1,90 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CAMPAIGN_ITEM } from '@/graphql/services/xRayCampaings/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/xRayCampaignItem'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_XRAY_CAMPAIGN_ITEM}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CAMPAIGN_ITEM}(payload: $payload) { + name + advertisingBrand { + id + name + } + publisher { + id + name + } + } + } +`; + +const getResponse = ({ data }) => { + const item = data[GET_XRAY_CAMPAIGN_ITEM]; + + return { + name: item.name, + advertiser: item.advertisingBrand, + hub: item.publisher, + }; +}; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: action.payload.id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open for edit", + }), + ), + ); + + Router.push('/x-ray/campaigns'); + } else { + const data = getResponse(response); + + actions.push( + of( + setAction({ + ...data, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/index.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/index.js new file mode 100644 index 0000000..d8c824d --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/index.js @@ -0,0 +1,10 @@ +import { combineEpics } from 'redux-observable'; + +import createAdvertiser from './createAdvertiser'; +import createItem from './createItem'; +import getAdvertisers from './getAdvertisers'; +import getHubs from './getHubs'; +import getItem from './getItem'; +import updateItem from './updateItem'; + +export default combineEpics(getItem, getAdvertisers, getHubs, createItem, updateItem, createAdvertiser); diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/updateItem.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..aeedbee --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/epics/updateItem.js @@ -0,0 +1,59 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_XRAY_CAMPAIGN } from '@/graphql/services/xRayCampaings/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/xRayCampaignItem'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_XRAY_CAMPAIGN}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_XRAY_CAMPAIGN}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const name = selectors.nameSelector(state$.value); + const advertisingBrandId = selectors.advertiserSelector(state$.value)?.id; + const publisherId = selectors.hubSelector(state$.value)?.id; + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: action.payload, + name, + advertisingBrandId, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + Router.push('/x-ray/campaigns'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Updated', + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/selectors/index.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/selectors/index.js new file mode 100644 index 0000000..5c40eb5 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/selectors/index.js @@ -0,0 +1,20 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const idSelector = (state) => state[nameSpace].id; +export const nameSelector = (state) => state[nameSpace].name; +export const advertiserSelector = (state) => state[nameSpace].advertiser; +export const advertiserOptionsSelector = (state) => state[nameSpace].advertiserOptions; +export const hubSelector = (state) => state[nameSpace].hub; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +// forms +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/xRay/campaigns/Editor/redux/slices/index.js b/anyclip/src/modules/xRay/campaigns/Editor/redux/slices/index.js new file mode 100644 index 0000000..322e380 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/Editor/redux/slices/index.js @@ -0,0 +1,65 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + name: '', + advertiser: null, + advertiserOptions: null, + hub: null, + hubOptions: null, + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@XRAY_CAMPAIGNS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getAdvertiserOptionsAction: (state) => state, + createAdvertiserAction: (state) => state, + getHubOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getAdvertiserOptionsAction, + createAdvertiserAction, + getHubOptionsAction, + createItemAction, + updateItemAction, + + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/xRay/campaigns/List/components/Empty/Empty.jsx b/anyclip/src/modules/xRay/campaigns/List/components/Empty/Empty.jsx similarity index 100% rename from src/modules/xRay/campaigns/List/components/Empty/Empty.jsx rename to anyclip/src/modules/xRay/campaigns/List/components/Empty/Empty.jsx diff --git a/src/modules/xRay/campaigns/List/components/Empty/Empty.module.scss b/anyclip/src/modules/xRay/campaigns/List/components/Empty/Empty.module.scss similarity index 100% rename from src/modules/xRay/campaigns/List/components/Empty/Empty.module.scss rename to anyclip/src/modules/xRay/campaigns/List/components/Empty/Empty.module.scss diff --git a/src/modules/xRay/campaigns/List/components/List.jsx b/anyclip/src/modules/xRay/campaigns/List/components/List.jsx similarity index 100% rename from src/modules/xRay/campaigns/List/components/List.jsx rename to anyclip/src/modules/xRay/campaigns/List/components/List.jsx diff --git a/src/modules/xRay/campaigns/List/components/List.module.scss b/anyclip/src/modules/xRay/campaigns/List/components/List.module.scss similarity index 100% rename from src/modules/xRay/campaigns/List/components/List.module.scss rename to anyclip/src/modules/xRay/campaigns/List/components/List.module.scss diff --git a/anyclip/src/modules/xRay/campaigns/List/constants/index.js b/anyclip/src/modules/xRay/campaigns/List/constants/index.js new file mode 100644 index 0000000..c6d3d1d --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/constants/index.js @@ -0,0 +1,16 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = -1; +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Archived', value: STATUSES_INACTIVE }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/src/modules/xRay/creatives/List/helpers/computedState.js b/anyclip/src/modules/xRay/campaigns/List/helpers/computedState.js similarity index 100% rename from src/modules/xRay/creatives/List/helpers/computedState.js rename to anyclip/src/modules/xRay/campaigns/List/helpers/computedState.js diff --git a/src/modules/xRay/campaigns/List/helpers/index.js b/anyclip/src/modules/xRay/campaigns/List/helpers/index.js similarity index 100% rename from src/modules/xRay/campaigns/List/helpers/index.js rename to anyclip/src/modules/xRay/campaigns/List/helpers/index.js diff --git a/anyclip/src/modules/xRay/campaigns/List/redux/epics/archive.js b/anyclip/src/modules/xRay/campaigns/List/redux/epics/archive.js new file mode 100644 index 0000000..79264cf --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/redux/epics/archive.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ARCHIVE_XRAY_CAMPAIGN } from '@/graphql/services/xRayCampaings/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/archive'; + +import { archiveAction, getDataAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${ARCHIVE_XRAY_CAMPAIGN} ($payload: ${PAYLOAD_NAME}) { + ${ARCHIVE_XRAY_CAMPAIGN}(payload: $payload) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(archiveAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { id: action.payload }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Archived', + }), + ), + of(getDataAction()), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/List/redux/epics/getAdvertisers.js b/anyclip/src/modules/xRay/campaigns/List/redux/epics/getAdvertisers.js new file mode 100644 index 0000000..60ef622 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/redux/epics/getAdvertisers.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS } from '@/graphql/services/xRayCampaings/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/advertiser'; + +import { getAdvertiserOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_CAMPAIGNS_ADVERTISER_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getAdvertiserOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + search: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + advertiserOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/List/redux/epics/getData.js b/anyclip/src/modules/xRay/campaigns/List/redux/epics/getData.js new file mode 100644 index 0000000..c4b0570 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/redux/epics/getData.js @@ -0,0 +1,77 @@ +import { STATUSES_ALL } from '../../constants'; +import { GET_XRAY_CAMPAIGNS } from '@/graphql/services/xRayCampaings/constants'; + +import { PAYLOAD_GET_XRAY_CAMPAIGNS } from '@/graphql/services/xRayCampaings/types/payload/xRayCampaigns'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_XRAY_CAMPAIGNS}($payload: ${PAYLOAD_GET_XRAY_CAMPAIGNS}) { + ${GET_XRAY_CAMPAIGNS}(payload: $payload) { + records { + id + name + publisherName + advertisingBrandName + xrayLineItems { + xrayCampaignId + status + startTime + endTime + } + status + startTime + endTime + updatedBy + updatedAt + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const advertisingBrandId = selectors.advertiserSelector(state)?.id; + const publisherId = selectors.hubSelector(state)?.id; + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = status; + } + + if (advertisingBrandId) { + variables.advertisingBrandId = advertisingBrandId; + } + + if (publisherId) { + variables.publisherId = publisherId; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const res = data[GET_XRAY_CAMPAIGNS]; + + return { + records: res.records, + recordsTotal: res.recordsTotal, + allRecordsCount: res.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/xRay/campaigns/List/redux/epics/getHubs.js b/anyclip/src/modules/xRay/campaigns/List/redux/epics/getHubs.js new file mode 100644 index 0000000..d116a8a --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/redux/epics/getHubs.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CAMPAIGNS_HUB_OPTIONS } from '@/graphql/services/xRayCampaings/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_CAMPAIGNS_HUB_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/campaigns/List/redux/epics/index.js b/anyclip/src/modules/xRay/campaigns/List/redux/epics/index.js new file mode 100644 index 0000000..d1accd9 --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import archive from './archive'; +import getAdvertisers from './getAdvertisers'; +import getData from './getData'; +import getHubs from './getHubs'; + +export default combineEpics(getData, getAdvertisers, getHubs, archive); diff --git a/anyclip/src/modules/xRay/campaigns/List/redux/selectors/index.js b/anyclip/src/modules/xRay/campaigns/List/redux/selectors/index.js new file mode 100644 index 0000000..feca88b --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/redux/selectors/index.js @@ -0,0 +1,27 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const statusSelector = (state) => state[nameSpace].status; +export const searchSelector = (state) => state[nameSpace].search; +export const advertiserSelector = (state) => state[nameSpace].advertiser; +export const hubSelector = (state) => state[nameSpace].hub; + +// autocomplete options +export const advertiserOptionsSelector = (state) => state[nameSpace].advertiserOptions; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; diff --git a/anyclip/src/modules/xRay/campaigns/List/redux/slices/index.js b/anyclip/src/modules/xRay/campaigns/List/redux/slices/index.js new file mode 100644 index 0000000..bc67e9c --- /dev/null +++ b/anyclip/src/modules/xRay/campaigns/List/redux/slices/index.js @@ -0,0 +1,55 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ACTIVE, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + status: STATUSES_ACTIVE, + + advertiser: null, + advertiserOptions: null, + + hub: null, + hubOptions: null, +}; + +export const slice = createSlice({ + name: '@@XRAY_CAMPAIGNS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getAdvertiserOptionsAction: (state) => state, + getHubOptionsAction: (state) => state, + archiveAction: (state) => state, + }, +}); + +export const { + getDataAction, + setTableAction, + setAction, + getAdvertiserOptionsAction, + getHubOptionsAction, + archiveAction, +} = slice.actions; diff --git a/anyclip/src/modules/xRay/creatives/Editor/components/Editor.jsx b/anyclip/src/modules/xRay/creatives/Editor/components/Editor.jsx new file mode 100644 index 0000000..a00fa12 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/components/Editor.jsx @@ -0,0 +1,174 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_ADVANCED, TAB_GENERAL } from '../constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { Form, FormContent, FormSection } from '@/modules/@common/Form'; +import AdvancedTab from './Tabs/AdvancedTab/AdvancedTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const name = useSelector(selectors.nameSelector); + const image = useSelector(selectors.imageSelector); + + const id = parseInt(router.query.id, 10); + const isDuplicate = router.asPath.split('/').filter(Boolean).reverse()[0] === 'duplicate'; + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + { + title: 'Advanced', + id: TAB_ADVANCED, + content: AdvancedTab, + }, + ].filter(Boolean); + + const getTitle = () => { + if (id && !isDuplicate) { + return `${name} > Settings`; + } + + if (isDuplicate) { + return 'Duplicate X-Ray Creative'; + } + + return 'New X-Ray Creative'; + }; + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } + + if (!image) { + dispatch( + showNotificationAction({ + key: 'Image is required', + type: TYPE_ERROR, + message: 'Image is required', + }), + ); + } + + if (!errorList.length && image) { + const action = id && !isDuplicate ? updateItemAction(id) : createItemAction(id); + dispatch(action); + } + + dispatch(setErrorByPropAction(validation)); + }; + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id, isDuplicate })); + } + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + return ( +
    + + + {getTitle()} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + className={styles.Tabs} + > + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/xRay/creatives/Editor/components/Editor.module.scss b/anyclip/src/modules/xRay/creatives/Editor/components/Editor.module.scss new file mode 100644 index 0000000..223df9b --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__zesjD","Title":"Editor_Title__JjGRh","Controls":"Editor_Controls__OtLjK","Tabs":"Editor_Tabs__PoFdO"}; \ No newline at end of file diff --git a/anyclip/src/modules/xRay/creatives/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx b/anyclip/src/modules/xRay/creatives/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx new file mode 100644 index 0000000..34930b8 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/components/Tabs/AdvancedTab/AdvancedTab.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import * as selectors from '../../../redux/selectors'; +import { removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormRow, useFormSettings } from '@/modules/@common/Form'; +import { TextField } from '@/mui/components'; + +const MAX_LENGTH = 512; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const scheme = useSelector(selectors.schemeSelector); + + // selectors + const impressionTracker = useSelector(selectors.impressionTrackerSelector); + const clickTracker1 = useSelector(selectors.clickTracker1Selector); + const clickTracker2 = useSelector(selectors.clickTracker2Selector); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + handleSetState({ impressionTracker: e.target.value })} + {...getInputPropsByName(scheme, ['impressionTracker'])} + onFocus={() => dispatch(removeErrorByPropAction(['impressionTracker']))} + label="" + /> + + + handleSetState({ clickTracker1: e.target.value })} + {...getInputPropsByName(scheme, ['clickTracker1'])} + onFocus={() => dispatch(removeErrorByPropAction(['clickTracker1']))} + label="" + /> + + + handleSetState({ clickTracker2: e.target.value })} + {...getInputPropsByName(scheme, ['clickTracker2'])} + onFocus={() => dispatch(removeErrorByPropAction(['clickTracker2']))} + label="" + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/xRay/creatives/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/xRay/creatives/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..b3197ae --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import * as selectors from '../../../redux/selectors'; +import { getHubOptionsAction, removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +import { FormGroupTitle, FormImageUploader, FormRow, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Stack, TextField } from '@/mui/components'; + +const NAME_MAX_LENGTH = 40; +const IMAGE_MAX_SIZE = 1024 * 1024 * 5; // 5 MB +const IMAGE_MIN_WIDTH = 141; +const IMAGE_MIN_HEIGHT = 141; + +async function validateImageDimensions(file) { + return new Promise((resolve) => { + const img = new Image(); + const reader = new FileReader(); + + reader.onload = (e) => { + img.src = e.target.result; + }; + + img.onload = () => { + const width = img.naturalWidth; + const height = img.naturalHeight; + const isValid = !(width < IMAGE_MIN_WIDTH || height < IMAGE_MIN_HEIGHT); + resolve(isValid); + }; + + reader.readAsDataURL(file); + }); +} + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + // selectors + const name = useSelector(selectors.nameSelector); + const hub = useSelector(selectors.hubSelector); + const hubOptions = useSelector(selectors.hubOptionsSelector); + const title = useSelector(selectors.titleSelector); + const image = useSelector(selectors.imageSelector); + const buttonLabel1 = useSelector(selectors.buttonLabel1Selector); + const buttonUrl1 = useSelector(selectors.buttonUrl1Selector); + const buttonLabel2 = useSelector(selectors.buttonLabel2Selector); + const buttonUrl2 = useSelector(selectors.buttonUrl2Selector); + + const scheme = useSelector(selectors.schemeSelector); + + const id = parseInt(router.query.id, 10); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + <> + + + { + handleSetState({ + hub: selected, + }); + }} + onOpen={() => { + dispatch(getHubOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getHubOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['hub']))} + /> + )} + /> + + + + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + Creative Elements + + + handleSetState({ title: e.target.value })} + {...getInputPropsByName(scheme, ['title'])} + onFocus={() => dispatch(removeErrorByPropAction(['title']))} + /> + + + + { + const allowedFormats = ['image/jpeg', 'image/jpg', 'image/png']; + + // Check image dimensions + const isValidDimensions = await validateImageDimensions(file); + if (!isValidDimensions) { + dispatch( + showNotificationAction({ + key: 'Invalid image dimensions', + type: TYPE_ERROR, + message: `File has the wrong dimensions. + Image dimensions must be at least ${IMAGE_MIN_WIDTH}px by ${IMAGE_MIN_HEIGHT}px`, + }), + ); + return false; + } + + if (!allowedFormats.includes(file.type)) { + dispatch( + showNotificationAction({ + key: 'The file type is not supported', + type: TYPE_ERROR, + message: 'Invalid image file, the image must be either a PNG or JPG file', + }), + ); + return false; + } + + if (file.size >= IMAGE_MAX_SIZE) { + dispatch( + showNotificationAction({ + key: 'File is too large!', + type: TYPE_ERROR, + message: 'File is too large! Max size is 5Mb', + }), + ); + return false; + } + + return true; + }} + onLoad={(event, file, fileResult) => { + handleSetState({ + image: fileResult, + }); + }} + onError={(event, file, error) => { + dispatch( + showNotificationAction({ + key: error, + type: TYPE_ERROR, + message: error, + }), + ); + }} + onRemove={() => { + handleSetState({ + image: '', + }); + }} + /> + + + + handleSetState({ buttonLabel1: e.target.value })} + {...getInputPropsByName(scheme, ['buttonLabel1'])} + onFocus={() => dispatch(removeErrorByPropAction(['buttonLabel1']))} + /> + + + + handleSetState({ buttonUrl1: e.target.value })} + {...getInputPropsByName(scheme, ['buttonUrl1'])} + onFocus={() => dispatch(removeErrorByPropAction(['buttonUrl1']))} + /> + + + + handleSetState({ buttonLabel2: e.target.value })} + /> + + + + handleSetState({ buttonUrl2: e.target.value })} + {...getInputPropsByName(scheme, ['buttonUrl2'])} + onFocus={() => dispatch(removeErrorByPropAction(['buttonUrl2']))} + label="" + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/xRay/creatives/Editor/constants/index.js b/anyclip/src/modules/xRay/creatives/Editor/constants/index.js new file mode 100644 index 0000000..4e7ba1e --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/constants/index.js @@ -0,0 +1,5 @@ +export const TAB_GENERAL = 'general'; +export const TAB_ADVANCED = 'advanced'; +export const REDUX_FIELD_NAME = 'commonForm'; + +export const PUBLIC_ACCOUNT_TYPE = 'SYNDICATION'; diff --git a/anyclip/src/modules/xRay/creatives/Editor/helpers/validationScheme.js b/anyclip/src/modules/xRay/creatives/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..a6b8563 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/helpers/validationScheme.js @@ -0,0 +1,126 @@ +import { TAB_ADVANCED, TAB_GENERAL } from '../constants'; + +// req: https://anyclip.atlassian.net/browse/VMW-4729 +const isValidURL = (url) => { + const urlPattern = /^(https?:\/\/)([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(:\d{1,5})?(.*)?$/; + + return urlPattern.test(url); +}; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'hub', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'title', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'buttonLabel1', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'buttonUrl1', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (!isValidURL(value)) { + return 'Field should be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'buttonUrl2', + tabId: TAB_GENERAL, + validation: (value) => { + if (value && !isValidURL(value)) { + return 'Field should be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'impressionTracker', + tabId: TAB_ADVANCED, + validation: (value) => { + if (value && !isValidURL(value)) { + return 'Field should be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'clickTracker1', + tabId: TAB_ADVANCED, + validation: (value) => { + if (value && !isValidURL(value)) { + return 'Field should be a valid URL'; + } + + return ''; + }, + }, + { + fieldName: 'clickTracker2', + tabId: TAB_ADVANCED, + validation: (value) => { + if (value && !isValidURL(value)) { + return 'Field should be a valid URL'; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/xRay/creatives/Editor/redux/epics/createItem.js b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..b6b0451 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/createItem.js @@ -0,0 +1,89 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_XRAY_CREATIVE } from '@/graphql/services/xRayCreatives/constants'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCreatives/types/payload/xRayCreativeItem'; + +import * as selectors from '../selectors'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_XRAY_CREATIVE}($payload: ${PAYLOAD_NAME}) { + ${CREATE_XRAY_CREATIVE}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap((action) => { + const name = selectors.nameSelector(state$.value); + const publisherId = selectors.hubSelector(state$.value).id; + const title = selectors.titleSelector(state$.value); + const image = selectors.imageSelector(state$.value); + const buttonLabel1 = selectors.buttonLabel1Selector(state$.value); + const buttonUrl1 = selectors.buttonUrl1Selector(state$.value); + const buttonLabel2 = selectors.buttonLabel2Selector(state$.value); + const buttonUrl2 = selectors.buttonUrl2Selector(state$.value); + const impressionTracker = selectors.impressionTrackerSelector(state$.value); + const clickTracker1 = selectors.clickTracker1Selector(state$.value); + const clickTracker2 = selectors.clickTracker2Selector(state$.value); + + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + name, + publisherId, + title, + image, + buttonLabel1, + buttonUrl1, + buttonLabel2, + buttonUrl2, + impressionTracker, + clickTracker1, + clickTracker2, + ...(action.payload ? { copyId: action.payload } : {}), + }, + }, + }, + { showNotificationMessage: false }, + ).pipe( + switchMap((response) => { + const error = response.errors?.[0]; + if (!error) { + Router.push('/x-ray/creatives'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Created', + }), + ); + } + + const message = + error?.response?.status === 409 + ? `An X-Ray Creative with name ${name} already exists` + : "Can't create X-Ray Line"; + + return of( + showNotificationAction({ + type: TYPE_ERROR, + message, + }), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/creatives/Editor/redux/epics/getHubs.js b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/getHubs.js new file mode 100644 index 0000000..d116a8a --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/getHubs.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CAMPAIGNS_HUB_OPTIONS } from '@/graphql/services/xRayCampaings/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_CAMPAIGNS_HUB_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/creatives/Editor/redux/epics/getItem.js b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..1b6eb86 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/getItem.js @@ -0,0 +1,104 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CREATIVE_ITEM } from '@/graphql/services/xRayCreatives/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCreatives/types/payload/xRayCreativeItem'; + +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_XRAY_CREATIVE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CREATIVE_ITEM}(payload: $payload) { + id + publisher { + id + name + } + name + title + image + buttonLabel1 + buttonUrl1 + buttonLabel2 + buttonUrl2 + impressionTracker + clickTracker1 + clickTracker2 + } + } +`; + +const getResponse = ({ data }) => { + const { publisher, ...item } = data[GET_XRAY_CREATIVE_ITEM]; + + return { + ...item, + hub: publisher, + }; +}; + +const trimUrl = (url) => (typeof url === 'string' ? url.trim() : ''); + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const { id, isDuplicate } = action.payload; + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open for edit", + }), + ), + ); + + Router.push('/x-ray/creatives'); + } else { + const data = getResponse(response); + + actions.push( + of( + setAction({ + ...data, + name: isDuplicate ? `Copy of ${data.name}` : data.name, + buttonUrl1: trimUrl(data.buttonUrl1), + buttonUrl2: trimUrl(data.buttonUrl2), + impressionTracker: trimUrl(data.impressionTracker), + clickTracker1: trimUrl(data.clickTracker1), + clickTracker2: trimUrl(data.clickTracker2), + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/creatives/Editor/redux/epics/index.js b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/index.js new file mode 100644 index 0000000..0f9fd66 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/index.js @@ -0,0 +1,8 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getHubs from './getHubs'; +import getItem from './getItem'; +import updateItem from './updateItem'; + +export default combineEpics(getItem, getHubs, createItem, updateItem); diff --git a/anyclip/src/modules/xRay/creatives/Editor/redux/epics/updateItem.js b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..90522d2 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/redux/epics/updateItem.js @@ -0,0 +1,85 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_XRAY_CREATIVE } from '@/graphql/services/xRayCreatives/constants'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCreatives/types/payload/xRayCreativeItem'; + +import * as selectors from '../selectors'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_XRAY_CREATIVE}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_XRAY_CREATIVE}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const name = selectors.nameSelector(state$.value); + const title = selectors.titleSelector(state$.value); + const image = selectors.imageSelector(state$.value); + const buttonLabel1 = selectors.buttonLabel1Selector(state$.value); + const buttonUrl1 = selectors.buttonUrl1Selector(state$.value); + const buttonLabel2 = selectors.buttonLabel2Selector(state$.value); + const buttonUrl2 = selectors.buttonUrl2Selector(state$.value); + const impressionTracker = selectors.impressionTrackerSelector(state$.value); + const clickTracker1 = selectors.clickTracker1Selector(state$.value); + const clickTracker2 = selectors.clickTracker2Selector(state$.value); + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + id: action.payload, + name, + title, + image, + buttonLabel1, + buttonUrl1, + buttonLabel2, + buttonUrl2, + impressionTracker, + clickTracker1, + clickTracker2, + }, + }, + }).pipe( + switchMap((response) => { + const error = response.errors?.[0]; + + if (!error) { + Router.push('/x-ray/creatives'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Updated', + }), + ); + } + + const message = + error?.response?.status === 409 + ? `An X-Ray Creative with name ${name} already exists` + : "Can't create X-Ray Line"; + + return of( + showNotificationAction({ + type: TYPE_ERROR, + message, + }), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/creatives/Editor/redux/selectors/index.js b/anyclip/src/modules/xRay/creatives/Editor/redux/selectors/index.js new file mode 100644 index 0000000..4504f83 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/redux/selectors/index.js @@ -0,0 +1,28 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const idSelector = (state) => state[nameSpace].id; +export const hubSelector = (state) => state[nameSpace].hub; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; +export const nameSelector = (state) => state[nameSpace].name; +export const titleSelector = (state) => state[nameSpace].title; +export const imageSelector = (state) => state[nameSpace].image; +export const buttonLabel1Selector = (state) => state[nameSpace].buttonLabel1; +export const buttonUrl1Selector = (state) => state[nameSpace].buttonUrl1; +export const buttonLabel2Selector = (state) => state[nameSpace].buttonLabel2; +export const buttonUrl2Selector = (state) => state[nameSpace].buttonUrl2; +export const impressionTrackerSelector = (state) => state[nameSpace].impressionTracker; +export const clickTracker1Selector = (state) => state[nameSpace].clickTracker1; +export const clickTracker2Selector = (state) => state[nameSpace].clickTracker2; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +// forms +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/xRay/creatives/Editor/redux/slices/index.js b/anyclip/src/modules/xRay/creatives/Editor/redux/slices/index.js new file mode 100644 index 0000000..c98ea08 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/Editor/redux/slices/index.js @@ -0,0 +1,74 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { REDUX_FIELD_NAME, TAB_GENERAL } from '../../constants'; + +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + hub: null, + hubOptions: null, + name: '', + title: '', + image: '', + buttonLabel1: '', + buttonUrl1: '', + buttonLabel2: '', + buttonUrl2: '', + impressionTracker: '', + clickTracker1: '', + clickTracker2: '', + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@XRAY_CREATIVES/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getHubOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getHubOptionsAction, + createItemAction, + updateItemAction, + + setActiveTabIdAction, + + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/xRay/creatives/List/components/Empty/Empty.jsx b/anyclip/src/modules/xRay/creatives/List/components/Empty/Empty.jsx similarity index 100% rename from src/modules/xRay/creatives/List/components/Empty/Empty.jsx rename to anyclip/src/modules/xRay/creatives/List/components/Empty/Empty.jsx diff --git a/src/modules/xRay/creatives/List/components/Empty/Empty.module.scss b/anyclip/src/modules/xRay/creatives/List/components/Empty/Empty.module.scss similarity index 100% rename from src/modules/xRay/creatives/List/components/Empty/Empty.module.scss rename to anyclip/src/modules/xRay/creatives/List/components/Empty/Empty.module.scss diff --git a/src/modules/xRay/creatives/List/components/List.jsx b/anyclip/src/modules/xRay/creatives/List/components/List.jsx similarity index 100% rename from src/modules/xRay/creatives/List/components/List.jsx rename to anyclip/src/modules/xRay/creatives/List/components/List.jsx diff --git a/src/modules/xRay/creatives/List/components/List.module.scss b/anyclip/src/modules/xRay/creatives/List/components/List.module.scss similarity index 100% rename from src/modules/xRay/creatives/List/components/List.module.scss rename to anyclip/src/modules/xRay/creatives/List/components/List.module.scss diff --git a/anyclip/src/modules/xRay/creatives/List/constants/index.js b/anyclip/src/modules/xRay/creatives/List/constants/index.js new file mode 100644 index 0000000..c6d3d1d --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/List/constants/index.js @@ -0,0 +1,16 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = -1; +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Archived', value: STATUSES_INACTIVE }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/src/modules/xRay/lineItems/List/helpers/computedState.js b/anyclip/src/modules/xRay/creatives/List/helpers/computedState.js similarity index 100% rename from src/modules/xRay/lineItems/List/helpers/computedState.js rename to anyclip/src/modules/xRay/creatives/List/helpers/computedState.js diff --git a/src/modules/xRay/creatives/List/helpers/index.js b/anyclip/src/modules/xRay/creatives/List/helpers/index.js similarity index 100% rename from src/modules/xRay/creatives/List/helpers/index.js rename to anyclip/src/modules/xRay/creatives/List/helpers/index.js diff --git a/anyclip/src/modules/xRay/creatives/List/redux/epics/archive.js b/anyclip/src/modules/xRay/creatives/List/redux/epics/archive.js new file mode 100644 index 0000000..35fc7bd --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/List/redux/epics/archive.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ARCHIVE_XRAY_CREATIVE } from '@/graphql/services/xRayCreatives/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCreatives/types/payload/archive'; + +import { archiveAction, getDataAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${ARCHIVE_XRAY_CREATIVE} ($payload: ${PAYLOAD_NAME}) { + ${ARCHIVE_XRAY_CREATIVE}(payload: $payload) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(archiveAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { id: action.payload }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Archived', + }), + ), + of(getDataAction()), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/creatives/List/redux/epics/getData.js b/anyclip/src/modules/xRay/creatives/List/redux/epics/getData.js new file mode 100644 index 0000000..caae971 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/List/redux/epics/getData.js @@ -0,0 +1,65 @@ +import { STATUSES_ALL } from '../../constants'; +import { GET_XRAY_CREATIVES } from '@/graphql/services/xRayCreatives/constants'; + +import { PAYLOAD_GET_XRAY_CREATIVES } from '@/graphql/services/xRayCreatives/types/payload/xRayCreatives'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_XRAY_CREATIVES}($payload: ${PAYLOAD_GET_XRAY_CREATIVES}) { + ${GET_XRAY_CREATIVES}(payload: $payload) { + records { + id + image + title + name + publisherName + updatedBy + updatedAt + status + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const publisherId = selectors.hubSelector(state)?.id; + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = status; + } + + if (publisherId) { + variables.publisherId = publisherId; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const res = data[GET_XRAY_CREATIVES]; + + return { + records: res.records, + recordsTotal: res.recordsTotal, + allRecordsCount: res.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/xRay/creatives/List/redux/epics/getHubs.js b/anyclip/src/modules/xRay/creatives/List/redux/epics/getHubs.js new file mode 100644 index 0000000..d116a8a --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/List/redux/epics/getHubs.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_CAMPAIGNS_HUB_OPTIONS } from '@/graphql/services/xRayCampaings/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayCampaings/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_CAMPAIGNS_HUB_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_CAMPAIGNS_HUB_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/creatives/List/redux/epics/index.js b/anyclip/src/modules/xRay/creatives/List/redux/epics/index.js new file mode 100644 index 0000000..3a99431 --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/List/redux/epics/index.js @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import archive from './archive'; +import getData from './getData'; +import getHubs from './getHubs'; + +export default combineEpics(getData, getHubs, archive); diff --git a/anyclip/src/modules/xRay/creatives/List/redux/selectors/index.js b/anyclip/src/modules/xRay/creatives/List/redux/selectors/index.js new file mode 100644 index 0000000..850584c --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/List/redux/selectors/index.js @@ -0,0 +1,25 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const statusSelector = (state) => state[nameSpace].status; +export const searchSelector = (state) => state[nameSpace].search; +export const hubSelector = (state) => state[nameSpace].hub; + +// autocomplete options +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; diff --git a/anyclip/src/modules/xRay/creatives/List/redux/slices/index.js b/anyclip/src/modules/xRay/creatives/List/redux/slices/index.js new file mode 100644 index 0000000..001888c --- /dev/null +++ b/anyclip/src/modules/xRay/creatives/List/redux/slices/index.js @@ -0,0 +1,44 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ACTIVE, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + status: STATUSES_ACTIVE, + + hub: null, + hubOptions: null, +}; + +export const slice = createSlice({ + name: '@@XRAY_CREATIVES/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getHubOptionsAction: (state) => state, + archiveAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, getHubOptionsAction, archiveAction } = slice.actions; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/components/Editor.jsx b/anyclip/src/modules/xRay/lineItems/Editor/components/Editor.jsx new file mode 100644 index 0000000..32ec1b9 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/components/Editor.jsx @@ -0,0 +1,168 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useRouter } from 'next/router'; + +import { TAB_DELIVERY, TAB_GENERAL, TAB_TARGETING } from '../constants'; + +import * as selectors from '../redux/selectors'; +import { + createItemAction, + getItemAction, + getTimezoneOptionsAction, + setActiveTabIdAction, + setErrorByPropAction, + setInitialAction, + setScrollToFieldNameAction, + updateItemAction, + validateFields, +} from '../redux/slices'; + +import { Form, FormContent } from '@/modules/@common/Form'; +import DeliveryTab from './Tabs/DeliveryTab/DeliveryTab'; +import GeneralTab from './Tabs/GeneralTab/GeneralTab'; +import TargetingTab from './Tabs/TargetingTab/TargetingTab'; +import { Button, Stack, Tab, TabContent, Tabs, Typography } from '@/mui/components'; + +import styles from './Editor.module.scss'; + +function Editor() { + const store = useStore(); + const dispatch = useDispatch(); + const router = useRouter(); + + const activeTabId = useSelector(selectors.activeTabIdSelector); + const name = useSelector(selectors.nameSelector); + + const id = parseInt(router.query.id, 10); + const isDuplicate = router.asPath.split('/').filter(Boolean).reverse()[0] === 'duplicate'; + + const tabs = [ + { + title: 'General', + id: TAB_GENERAL, + content: GeneralTab, + }, + { + title: 'Delivery', + id: TAB_DELIVERY, + content: DeliveryTab, + }, + { + title: 'Targeting', + id: TAB_TARGETING, + content: TargetingTab, + }, + ].filter(Boolean); + + const getTitle = () => { + if (id && !isDuplicate) { + return `${name} > Settings`; + } + + if (isDuplicate) { + return 'Duplicate X-Ray Line Item'; + } + + return 'New X-Ray Line Item'; + }; + + const saveToServerForm = () => { + const state = store.getState(); + const allProps = selectors.fullAccessToStoreFieldsForValidation(state); + + const { validation, errorList } = validateFields( + selectors + .schemeSelector(state) + .filter(({ tabId }) => tabs.some((tab) => tab.id === tabId)) + .map(({ fieldName }) => fieldName), + allProps, + ); + + if (errorList.length) { + const errorField = errorList.find((error) => error.tabId === activeTabId) ?? errorList[0]; + + dispatch(setActiveTabIdAction(errorField.tabId)); + dispatch(setScrollToFieldNameAction(errorField.fieldName)); + } else if (id && !isDuplicate) { + dispatch(updateItemAction(id)); + } else { + // if duplicate need pass parent id + dispatch(createItemAction(id)); + } + + dispatch(setErrorByPropAction(validation)); + }; + + useEffect(() => { + if (id) { + dispatch(getItemAction({ id, isDuplicate })); + } + + dispatch(getTimezoneOptionsAction()); + + return () => { + dispatch(setInitialAction()); + }; + }, [id]); + + return ( +
    + + + {getTitle()} + + + + {tabs.length > 1 && ( + dispatch(setActiveTabIdAction(value))} + className={styles.Tabs} + > + {tabs.map((tab) => ( + + ))} + + )} + + + + + +
    + + {tabs.map((tab) => { + const Content = tab.content; + + return ( + + + + ); + })} + +
    +
    + ); +} + +export default Editor; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/components/Editor.module.scss b/anyclip/src/modules/xRay/lineItems/Editor/components/Editor.module.scss new file mode 100644 index 0000000..a956c35 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/components/Editor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"Editor_Wrapper__fWBqr","Title":"Editor_Title__cjtKJ","Controls":"Editor_Controls__NNw4u","Tabs":"Editor_Tabs__aEGjT"}; \ No newline at end of file diff --git a/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/DeliveryTab/DeliveryTab.jsx b/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/DeliveryTab/DeliveryTab.jsx new file mode 100644 index 0000000..2eee9ec --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/DeliveryTab/DeliveryTab.jsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { MonetizationOnRounded } from '@mui/icons-material'; + +import * as selectors from '../../../redux/selectors'; +import { removeErrorByPropAction, setAction } from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormGroupTitle, FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import { DateTimePicker, InputAdornment, MenuItem, NumberField, Select } from '@/mui/components'; + +const MAX_RATE = 999; +const MAX_IMPRESSIONS = 999999999; +const MAX_SPEND = 999999; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const startTime = useSelector(selectors.startTimeSelector); + const endTime = useSelector(selectors.endTimeSelector); + const timezone = useSelector(selectors.timezoneSelector); + const timezoneOptions = useSelector(selectors.timezoneOptionsSelector); + const rate = useSelector(selectors.rateSelector); + const impressionsBudget = useSelector(selectors.impressionsBudgetSelector); + const spendBudget = useSelector(selectors.spendBudgetSelector); + + const scheme = useSelector(selectors.schemeSelector); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + + + handleSetState({ startTime: date?.toDate().getTime() })} + onOpen={() => dispatch(removeErrorByPropAction(['startTime']))} + /> + + handleSetState({ endTime: date?.toDate().getTime() })} + /> + + + + + + + + { + handleSetState({ rate: e.target.value }); + }} + {...getInputPropsByName(scheme, ['rate'])} + onFocus={() => dispatch(removeErrorByPropAction(['rate']))} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + Total Budget + + + { + handleSetState({ impressionsBudget: e.target.value }); + }} + {...getInputPropsByName(scheme, ['impressionsBudget'])} + onFocus={() => dispatch(removeErrorByPropAction(['impressionsBudget']))} + label="" + /> + + + + { + handleSetState({ spendBudget: e.target.value }); + }} + {...getInputPropsByName(scheme, ['spendBudget'])} + onFocus={() => dispatch(removeErrorByPropAction(['spendBudget']))} + label="" + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/GeneralTab/GeneralTab.jsx b/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/GeneralTab/GeneralTab.jsx new file mode 100644 index 0000000..ade2093 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/GeneralTab/GeneralTab.jsx @@ -0,0 +1,354 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Inventory2Rounded } from '@mui/icons-material'; + +import { STATUSES_INACTIVE, STATUSES_OPTIONS_FOR_FORM } from '../../../../List/constants'; + +import * as selectors from '../../../redux/selectors'; +import { + getHubOptionsAction, + getXRayCampaignOptionsAction, + getXRayCreativeOptionsAction, + removeErrorByPropAction, + setAction, +} from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import { FormGroupTitle, FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import { + Autocomplete, + IconButton, + InputAdornment, + MenuItem, + Select, + Stack, + TextField, + Tooltip, +} from '@/mui/components'; + +const NAME_MAX_LENGTH = 149; + +function GeneralTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + const router = useRouter(); + + // selectors + const hub = useSelector(selectors.hubSelector); + const hubOptions = useSelector(selectors.hubOptionsSelector); + const xrayCampaign = useSelector(selectors.xrayCampaignSelector); + const xrayCampaignOptions = useSelector(selectors.xrayCampaignOptionsSelector); + const name = useSelector(selectors.nameSelector); + const status = useSelector(selectors.statusSelector); + const priority = useSelector(selectors.prioritySelector); + const xrayCreative1 = useSelector(selectors.xrayCreative1Selector); + const xrayCreative2 = useSelector(selectors.xrayCreative2Selector); + const xrayCreative3 = useSelector(selectors.xrayCreative3Selector); + const xrayCreativeOptionsFromStater = useSelector(selectors.xrayCreativeOptionsSelectors); + + const scheme = useSelector(selectors.schemeSelector); + + const id = parseInt(router.query.id, 10); + const isDuplicate = router.asPath.split('/').filter(Boolean).reverse()[0] === 'duplicate'; + + const shouldDisableWhenUpdate = id && !isDuplicate; + + const xrayCreativeOptions = + xrayCreativeOptionsFromStater?.filter( + (o) => ![xrayCreative1?.id, xrayCreative2?.id, xrayCreative3?.id].includes(o.id), + ) || null; + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + + return ( + + + + { + const payload = { hub: selected }; + + if (hub?.id !== selected?.id) { + payload.xrayCampaign = null; + payload.xrayCreative1 = null; + payload.xrayCreative2 = null; + payload.xrayCreative3 = null; + payload.watch = null; + payload.watchChannel = null; + payload.player = null; + payload.player = null; + payload.domains = []; + payload.video = null; + payload.people = []; + payload.brands = []; + payload.keywords = []; + payload.labels = []; + } + + handleSetState(payload); + }} + onOpen={() => { + dispatch(getHubOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getHubOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['hub']))} + /> + )} + /> + + + + + + { + handleSetState({ + xrayCampaign: selected, + }); + }} + onOpen={() => { + dispatch(getXRayCampaignOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getXRayCampaignOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['xrayCampaign']))} + /> + )} + /> + + + + + handleSetState({ name: e.target.value })} + {...getInputPropsByName(scheme, ['name'])} + onFocus={() => dispatch(removeErrorByPropAction(['name']))} + /> + + + + + + + + + + + Creatives + + + + { + handleSetState({ + xrayCreative1: selected, + }); + }} + onOpen={() => { + dispatch(getXRayCreativeOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getXRayCreativeOptionsAction(searchText))} + renderInput={(params) => ( + + + null}> + + + + + ), + } + : {}), + }} + {...getInputPropsByName(scheme, ['xrayCreative1'])} + onFocus={() => dispatch(removeErrorByPropAction(['xrayCreative1']))} + /> + )} + /> + + + + + + { + handleSetState({ + xrayCreative2: selected, + }); + }} + onOpen={() => { + dispatch(getXRayCreativeOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getXRayCreativeOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['xrayCreative2']))} + label="" + InputProps={{ + ...params.InputProps, + ...(xrayCreative2?.status === STATUSES_INACTIVE + ? { + endAdornment: ( + + + null}> + + + + + ), + } + : {}), + }} + /> + )} + /> + + + + + + { + handleSetState({ + xrayCreative3: selected, + }); + }} + onOpen={() => { + dispatch(getXRayCreativeOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getXRayCreativeOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['xrayCreative3']))} + label="" + InputProps={{ + ...params.InputProps, + ...(xrayCreative3?.status === STATUSES_INACTIVE + ? { + endAdornment: ( + + + null}> + + + + + ), + } + : {}), + }} + /> + )} + /> + + + + ); +} + +export default GeneralTab; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/TargetingTab/TargetingTab.jsx b/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/TargetingTab/TargetingTab.jsx new file mode 100644 index 0000000..b4f2638 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/components/Tabs/TargetingTab/TargetingTab.jsx @@ -0,0 +1,451 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import { Laptop, LaunchOutlined, Smartphone, Visibility, VisibilityOff } from '@mui/icons-material'; + +import { DEVICE_DESKTOP, DEVICE_MOBILE, PLAYER_LARGE, PLAYER_MEDIUM, PLAYER_SMALL } from '../../../constants'; +import { EDITORIAL_PAGE } from '@/modules/@common/router/constants'; + +import * as selectors from '../../../redux/selectors'; +import { + getBrandSafetyOptionsAction, + getBrandsOptionsAction, + getDomainOptionsAction, + getGeographyOptionsAction, + getKeywordsOptionsAction, + getLabelsOptionsAction, + getPeopleOptionsAction, + getPlayerOptionsAction, + getVideoOptionsAction, + getWatchOptionsAction, + removeErrorByPropAction, + setAction, +} from '../../../redux/slices'; +import { getInputPropsByName } from '@/modules/@common/Form/helpers'; + +import ActionAutocomplete from '@/modules/@common/ActionAutocomplete'; +import ActionIAB from '@/modules/@common/ActionIAB'; +import { FormGroupTitle, FormRow, FormSection, useFormSettings } from '@/modules/@common/Form'; +import { Autocomplete, Chip, Stack, TextField, TimePicker, ToggleButton, ToggleButtonGroup } from '@/mui/components'; + +function TargetingTab() { + const { size } = useFormSettings(); + const dispatch = useDispatch(); + + const hub = useSelector(selectors.hubSelector); + + const watch = useSelector(selectors.watchSelectors); + const watchOptions = useSelector(selectors.watchOptionsSelectors); + const watchChannel = useSelector(selectors.watchChannelSelectors); + const watchChannelOptions = useSelector(selectors.watchChannelOptionsSelectors); + const domains = useSelector(selectors.domainsSelectors); + const domainOptions = useSelector(selectors.domainOptionsSelectors); + const devices = useSelector(selectors.devicesSelectors); + const geography = useSelector(selectors.geographySelectors); + const geographyOptions = useSelector(selectors.geographyOptionsSelectors); + const inview = useSelector(selectors.inviewSelectors); + const notInview = useSelector(selectors.notInviewSelectors); + const player = useSelector(selectors.playerSelectors); + const playerOptions = useSelector(selectors.playerOptionsSelectors); + const playerSizes = useSelector(selectors.playerSizesSelectors); + const video = useSelector(selectors.videoSelector); + const videoOptions = useSelector(selectors.videoOptionsSelector); + const videoFromTimestamp = useSelector(selectors.videoFromTimestampSelector); + const videoToTimestamp = useSelector(selectors.videoToTimestampSelector); + const iabCategories = useSelector(selectors.iabCategoriesSelector); + const people = useSelector(selectors.peopleSelector); + const peopleOptions = useSelector(selectors.peopleOptionsSelector); + const brands = useSelector(selectors.brandsSelector); + const brandsOptions = useSelector(selectors.brandsOptionsSelector); + const keywords = useSelector(selectors.keywordsSelector); + const keywordsOptions = useSelector(selectors.keywordsOptionsSelector); + const labels = useSelector(selectors.labelsSelector); + const labelsOptions = useSelector(selectors.labelsOptionsSelectors); + const brandSafety = useSelector(selectors.brandSafetySelector); + const brandSafetyOptions = useSelector(selectors.brandSafetyOptionsSelector); + + const scheme = useSelector(selectors.schemeSelector); + + // handlers + const handleSetState = (state) => dispatch(setAction(state)); + const handleSetDevice = (deviceId) => { + const updateDevices = devices.includes(deviceId) ? devices.filter((d) => d !== deviceId) : [...devices, deviceId]; + + dispatch( + setAction({ + devices: updateDevices, + }), + ); + }; + + useEffect(() => { + if (watch && !watchOptions) { + dispatch(getWatchOptionsAction(watch.title)); + } else if (watchChannel && !watchChannelOptions) { + const selectedWatch = watchOptions.find((watchOption) => watchOption.id === watch.id); + dispatch(setAction({ watchChannelOptions: selectedWatch?.watchChannels || [] })); + } + }, [watchChannel, watchChannelOptions, watch, watchOptions]); + + return ( + <> + + Placement Details + + + { + handleSetState({ + watch: selected, + watchChannel: null, + watchChannelOptions: selected?.watchChannels || [], + }); + }} + onOpen={() => { + dispatch(getWatchOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getWatchOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['watch']))} + label="" + /> + )} + /> + + + + + { + handleSetState({ + watchChannel: selected, + }); + }} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['watchChannel']))} + label="" + /> + )} + /> + + + + + { + handleSetState({ + player: selected, + }); + }} + onOpen={() => { + dispatch(getPlayerOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getPlayerOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['player']))} + label="" + /> + )} + /> + + + + { + handleSetState({ + playerSizes: newValue, + }); + }} + > + {[ + { + title: 'S', + id: PLAYER_SMALL, + }, + { + title: 'M', + id: PLAYER_MEDIUM, + }, + { + title: 'L', + id: PLAYER_LARGE, + }, + ].map((checkbox) => ( + + {checkbox.title} + + ))} + + + + handleSetState({ domains: domainsValue })} + onOpen={() => dispatch(getDomainOptionsAction(''))} + onInputChange={(searchText) => dispatch(getDomainOptionsAction(searchText))} + /> + + + + + + handleSetDevice(DEVICE_MOBILE)}> + + + handleSetDevice(DEVICE_DESKTOP)}> + + + + + + + + handleSetState({ geography: geographyValue })} + onOpen={() => dispatch(getGeographyOptionsAction(''))} + /> + + + + + + handleSetState({ inview: !inview })}> + + + handleSetState({ notInview: !notInview })}> + + + + + + + + Video Details + + + { + handleSetState({ + video: selected, + }); + }} + onOpen={() => { + dispatch(getVideoOptionsAction('')); + }} + onInputChange={(e, searchText) => dispatch(getVideoOptionsAction(searchText))} + renderInput={(params) => ( + dispatch(removeErrorByPropAction(['video']))} + label="" + /> + )} + /> + {video?.id && ( + } + onDelete={() => null} + /> + )} + + + + { + handleSetState({ + videoFromTimestamp: dayjs(triggerTime).format('HH:mm:ss'), + }); + }} + ampm={false} + views={['hours', 'minutes', 'seconds']} + format="HH:mm:ss" + label="" + disabled={!hub || !video} + onOpen={() => dispatch(removeErrorByPropAction(['videoFromTimestamp']))} + /> + + + { + handleSetState({ + videoToTimestamp: dayjs(triggerTime).format('HH:mm:ss'), + }); + }} + ampm={false} + views={['hours', 'minutes', 'seconds']} + format="HH:mm:ss" + disabled={!hub || !video} + /> + + + handleSetState({ iabCategories: categories })} + /> + + + handleSetState({ people: peopleValue })} + onOpen={() => dispatch(getPeopleOptionsAction(''))} + onInputChange={(searchText) => dispatch(getPeopleOptionsAction(searchText))} + /> + + + handleSetState({ brands: brandsValue })} + onOpen={() => dispatch(getBrandsOptionsAction(''))} + onInputChange={(searchText) => dispatch(getBrandsOptionsAction(searchText))} + /> + + + handleSetState({ keywords: keywordsValue })} + onOpen={() => dispatch(getKeywordsOptionsAction(''))} + onInputChange={(searchText) => dispatch(getKeywordsOptionsAction(searchText))} + /> + + + option.groupBy} + disabled={!hub} + onChange={(labelsValue) => handleSetState({ labels: labelsValue })} + onOpen={() => dispatch(getLabelsOptionsAction(''))} + onInputChange={(searchText) => dispatch(getLabelsOptionsAction(searchText))} + /> + + + handleSetState({ brandSafety: brandSafetyValue })} + onOpen={() => dispatch(getBrandSafetyOptionsAction(''))} + /> + + + + ); +} + +export default TargetingTab; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/constants/index.js b/anyclip/src/modules/xRay/lineItems/Editor/constants/index.js new file mode 100644 index 0000000..8cf1e19 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/constants/index.js @@ -0,0 +1,19 @@ +export const TAB_GENERAL = 'general'; +export const TAB_DELIVERY = 'delivery'; +export const TAB_TARGETING = 'targeting'; + +export const DEVICE_MOBILE = 2; +export const DEVICE_DESKTOP = 1; + +export const PLAYER_SMALL = 1; +export const PLAYER_MEDIUM = 2; +export const PLAYER_LARGE = 3; + +export const INCLUDE = 'INCLUDE'; +export const EXCLUDE = 'EXCLUDE'; + +export const TAXONOMY_PEOPLE = 'PEOPLE'; +export const TAXONOMY_BRANDS = 'BRANDS'; +export const TAXONOMY_KEYWORDS = 'KEYWORDS'; + +export const REDUX_FIELD_NAME = 'commonForm'; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/helpers/buildBody.js b/anyclip/src/modules/xRay/lineItems/Editor/helpers/buildBody.js new file mode 100644 index 0000000..73e3f1e --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/helpers/buildBody.js @@ -0,0 +1,139 @@ +import dayjs from 'dayjs'; + +import { stringToTimestamp } from '../helpers/timestamp'; +import * as selectors from '@/modules/xRay/lineItems/Editor/redux/selectors'; + +function prepareData(state) { + // general tab + const name = selectors.nameSelector(state); + const status = selectors.statusSelector(state); + const priority = selectors.prioritySelector(state); + const xrayCreative1 = selectors.xrayCreative1Selector(state); + const xrayCreative2 = selectors.xrayCreative2Selector(state); + const xrayCreative3 = selectors.xrayCreative3Selector(state); + // delivery tab + const startTime = selectors.startTimeSelector(state); + const endTime = selectors.endTimeSelector(state); + const timezone = selectors.timezoneSelector(state); + const rate = +selectors.rateSelector(state); + const impressionsBudget = +selectors.impressionsBudgetSelector(state) || null; + const spendBudget = +selectors.spendBudgetSelector(state) || null; + // targeting tab + const watch = selectors.watchSelectors(state); + const watchChannel = selectors.watchChannelSelectors(state); + const player = selectors.playerSelectors(state); + const domains = selectors.domainsSelectors(state); + const devices = selectors.devicesSelectors(state); + const geography = selectors.geographySelectors(state); + const inview = selectors.inviewSelectors(state); + const notInview = selectors.notInviewSelectors(state); + const playerSizes = selectors.playerSizesSelectors(state); + const video = selectors.videoSelector(state); + const videoFromTimestamp = stringToTimestamp(selectors.videoFromTimestampSelector(state)); + const videoToTimestamp = stringToTimestamp(selectors.videoToTimestampSelector(state)); + const iabCategories = selectors.iabCategoriesSelector(state); + const people = selectors.peopleSelector(state); + const brands = selectors.brandsSelector(state); + const keywords = selectors.keywordsSelector(state); + const labels = selectors.labelsSelector(state); + const brandSafety = selectors.brandSafetySelector(state); + + const toIncludeExclude = (list, valueKey) => { + const initial = { include: [], exclude: [] }; + if (!list?.length) { + return initial; + } + + return list.reduce((acc, o) => { + acc[o.include ? 'include' : 'exclude'].push(o[valueKey]); + return acc; + }, initial); + }; + + const toIncludeExcludeTaxonomy = (list) => { + const initial = { include: [], exclude: [] }; + if (!list?.length) { + return initial; + } + + return list.reduce((acc, o) => { + const entity = { + name: o.label, + taxonomyId: o.taxonomyId, + }; + acc[o.include ? 'include' : 'exclude'].push(entity); + return acc; + }, initial); + }; + + const toIncludeExcludeTaxonomyLabels = (list) => { + const initial = { include: [], exclude: [] }; + if (!list?.length) { + return initial; + } + + return list.reduce((acc, o) => { + const entity = { + name: o.name || o.label, // o.label preserved to avoid crashing old saved x-ray items + value: o.label, + color: o.color, + taxonomyId: o.taxonomyId, + }; + + acc[o.include ? 'include' : 'exclude'].push(entity); + return acc; + }, initial); + }; + + const body = { + name, + status, + priority, + xrayCreatives: [xrayCreative1?.id, xrayCreative2?.id, xrayCreative3?.id].filter(Boolean), + startTime: startTime ? dayjs(startTime).valueOf() : startTime, + endTime: endTime ? dayjs(endTime).valueOf() : endTime, + timezoneId: timezone?.id, + rate, + impressionsBudget, + spendBudget, + watchId: watch?.id, + watchChannelId: watchChannel?.id, + playerId: player?.id, + videoName: video?.name, + videoUid: video?.id, + videoFromTimestamp, + videoToTimestamp, + targeting: { + domains: toIncludeExclude(domains, 'label'), + devices, + geography: toIncludeExclude(geography, 'value'), + inview, + notInview, + iabCategories: toIncludeExclude(iabCategories, 'id'), + people: toIncludeExcludeTaxonomy(people), + brands: toIncludeExcludeTaxonomy(brands), + keywords: toIncludeExcludeTaxonomy(keywords), + labels: toIncludeExcludeTaxonomyLabels(labels), + brandSafety: toIncludeExclude(brandSafety, 'value'), + playerSizes, + }, + }; + + return body; +} + +export function updateBody(state) { + return prepareData(state); +} + +export function createBody(state) { + const hub = selectors.hubSelector(state); + const xrayCampaign = selectors.xrayCampaignSelector(state); + const data = prepareData(state); + + return { + publisherId: hub?.id, + xrayCampaignId: xrayCampaign?.id, + ...data, + }; +} diff --git a/anyclip/src/modules/xRay/lineItems/Editor/helpers/timestamp.js b/anyclip/src/modules/xRay/lineItems/Editor/helpers/timestamp.js new file mode 100644 index 0000000..30efec8 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/helpers/timestamp.js @@ -0,0 +1,17 @@ +// Convert "HH:MM:SS" string to timestamp (seconds) +export function stringToTimestamp(timeStr) { + const [h, m, s] = timeStr.split(':').map(Number); + return h * 3600 + m * 60 + s; +} + +// Convert timestamp (seconds) to "HH:MM:SS" string +export function timestampToString(timestamp) { + const h = Math.floor(timestamp / 3600) + .toString() + .padStart(2, '0'); + const m = Math.floor((timestamp % 3600) / 60) + .toString() + .padStart(2, '0'); + const s = (timestamp % 60).toString().padStart(2, '0'); + return `${h}:${m}:${s}`; +} diff --git a/anyclip/src/modules/xRay/lineItems/Editor/helpers/validationScheme.js b/anyclip/src/modules/xRay/lineItems/Editor/helpers/validationScheme.js new file mode 100644 index 0000000..db6c941 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/helpers/validationScheme.js @@ -0,0 +1,114 @@ +import dayjs from 'dayjs'; + +import { TAB_DELIVERY, TAB_GENERAL, TAB_TARGETING } from '../constants'; + +import { stringToTimestamp } from '../helpers/timestamp'; + +export const validationScheme = [ + { + fieldName: 'name', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + if (value.length < 2) { + return 'Minimum 2 letters'; + } + + return ''; + }, + }, + { + fieldName: 'hub', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'xrayCampaign', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'xrayCreative1', + tabId: TAB_GENERAL, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'startTime', + tabId: TAB_DELIVERY, + validation: (value, fields) => { + if (value) { + return !dayjs(value).isBefore(fields.endTime) ? 'Must be before then End' : ''; + } + + return ''; + }, + }, + { + fieldName: 'rate', + tabId: TAB_DELIVERY, + validation: (value) => { + if (!value) { + return 'Field cannot be empty'; + } + + return ''; + }, + }, + { + fieldName: 'impressionsBudget', + tabId: TAB_DELIVERY, + validation: (value) => { + if (value && value <= 0) { + return 'Minimum amount is 1'; + } + + return ''; + }, + }, + { + fieldName: 'spendBudget', + tabId: TAB_DELIVERY, + validation: (value) => { + if (value && value <= 0) { + return 'Minimum amount is 1'; + } + + return ''; + }, + }, + { + fieldName: 'videoFromTimestamp', + tabId: TAB_TARGETING, + validation: (value, fields) => { + if (value && value !== '00:00:00') { + const videoFromTimestamp = stringToTimestamp(value); + const videoToTimestamp = stringToTimestamp(fields.videoToTimestamp); + + return videoFromTimestamp >= videoToTimestamp ? 'Must be lower then To timestamp' : ''; + } + + return ''; + }, + }, +]; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/createItem.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/createItem.js new file mode 100644 index 0000000..2166c36 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/createItem.js @@ -0,0 +1,67 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { CREATE_XRAY_LINE_ITEM } from '@/graphql/services/xRayLineItems/constants'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/xRayLineItemUpsert'; + +import { createBody } from '../../helpers/buildBody'; +import { createItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${CREATE_XRAY_LINE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${CREATE_XRAY_LINE_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(createItemAction.type), + switchMap(() => { + const body = createBody(state$.value); + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + ...body, + }, + }, + }, + { showNotificationMessage: false }, + ).pipe( + switchMap((response) => { + const error = response.errors?.[0]; + if (!error) { + Router.push('/x-ray/line-items'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Created', + }), + ); + } + + const message = + error?.response?.status === 409 + ? `An X-Ray Line Item with name ${body.name} already exists` + : "Can't create X-Ray Line"; + + return of( + showNotificationAction({ + type: TYPE_ERROR, + message, + }), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrandSafety.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrandSafety.js new file mode 100644 index 0000000..eccb358 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrandSafety.js @@ -0,0 +1,61 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_BRAND_SAFETY_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/brandSafety'; + +import { getBrandSafetyOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_BRAND_SAFETY_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_BRAND_SAFETY_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_BRAND_SAFETY_OPTIONS].records.map((o) => ({ + label: o.name, + value: o.id, + })); + +export default (action$) => + action$.pipe( + ofType(getBrandSafetyOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + brandSafetyOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrands.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrands.js new file mode 100644 index 0000000..77f0ace --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getBrands.js @@ -0,0 +1,72 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { TAXONOMY_BRANDS } from '../../constants'; +import { GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/taxonomy'; + +import { hubSelector } from '../selectors'; +import { getBrandsOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS}(payload: $payload) { + records { + taxonomyId + value + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS].records.map((o) => ({ + label: o.value, + value: o.taxonomyId, + taxonomyId: o.taxonomyId, + })); + +export default (action$, state$) => + action$.pipe( + ofType(getBrandsOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + category: TAXONOMY_BRANDS, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + brandsOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCampaings.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCampaings.js new file mode 100644 index 0000000..464ddf8 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCampaings.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEMS_CAMPAINGS_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/campain'; + +import { getXRayCampaignOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { hubSelector } from '@/modules/xRay/lineItems/Editor/redux/selectors'; + +const query = ` + query ${GET_XRAY_LINE_ITEMS_CAMPAINGS_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEMS_CAMPAINGS_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_LINE_ITEMS_CAMPAINGS_OPTIONS].records; + +export default (action$, state$) => + action$.pipe( + ofType(getXRayCampaignOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + xrayCampaignOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCreatives.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCreatives.js new file mode 100644 index 0000000..d04c86a --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getCreatives.js @@ -0,0 +1,65 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_CREATIVES_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/creative'; + +import { hubSelector } from '../selectors'; +import { getXRayCreativeOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_CREATIVES_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_CREATIVES_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_LINE_ITEM_CREATIVES_OPTIONS].records; + +export default (action$, state$) => + action$.pipe( + ofType(getXRayCreativeOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + publisherId, + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + xrayCreativeOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getDomains.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getDomains.js new file mode 100644 index 0000000..a388a48 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getDomains.js @@ -0,0 +1,66 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_DOMAIN_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/domain'; + +import { hubSelector } from '../selectors'; +import { getDomainOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_DOMAIN_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_DOMAIN_OPTIONS}(payload: $payload) { + records { + id + domain + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_DOMAIN_OPTIONS].records.map((o) => ({ label: o.domain, value: o.id })); + +export default (action$, state$) => + action$.pipe( + ofType(getDomainOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + domainOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getGeographies.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getGeographies.js new file mode 100644 index 0000000..873c85f --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getGeographies.js @@ -0,0 +1,46 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_GEO_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { getGeographyOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_GEO_OPTIONS} { + ${GET_XRAY_LINE_ITEM_GEO_OPTIONS} { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_GEO_OPTIONS].records.map((o) => ({ label: o.name, value: o.id })); + +export default (action$) => + action$.pipe( + ofType(getGeographyOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + geographyOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getHubs.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getHubs.js new file mode 100644 index 0000000..a9bc27f --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getHubs.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEMS_HUB_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEMS_HUB_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEMS_HUB_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_LINE_ITEMS_HUB_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getItem.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getItem.js new file mode 100644 index 0000000..c38b2c6 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getItem.js @@ -0,0 +1,239 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { STATUSES_ACTIVE } from '../../../List/constants'; +import { INCLUDE } from '../../constants'; +import { GET_XRAY_LINE_ITEM } from '@/graphql/services/xRayLineItems/constants'; +import { TYPE_ERROR } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/xRayLineItemList'; + +import { timestampToString } from '../../helpers/timestamp'; +import { getItemAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = ` + query ${GET_XRAY_LINE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM}(payload: $payload) { + id + publisher { + id + name + } + xrayCampaign { + id + name + } + name + status + priority + xrayCreatives { + id + name + status + } + startTime + endTime + timezone { + id + + } + rate + impressionsBudget + spendBudget + watch { + id + title + } + watchChannel { + id + title + } + player { + id + name + alias + } + videoName + videoUid + videoFromTimestamp + videoToTimestamp + targeting { + domains { + id + name + filterAction + } + devices { + deviceId + } + geography { + id + name + filterAction + } + inview + notInview + playerSizes { + playerSizeId + } + iabCategories { + name + filterAction + } + people { + id + name + filterAction + taxonomyId + } + brands { + id + name + filterAction + taxonomyId + } + keywords { + id + name + filterAction + taxonomyId + } + labels { + name + value + color + filterAction + taxonomyId + } + brandSafety { + id + name + filterAction + } + } + } + } +`; + +const getResponse = ({ data }) => { + const { publisher, ...item } = data[GET_XRAY_LINE_ITEM]; + + return { + ...item, + hub: publisher, + }; +}; + +const mapToActionAutocompleteFormat = (values) => { + if (!values?.length) { + return []; + } + + return values.map((o) => ({ + value: o.id, + label: o.name, + include: o.filterAction === INCLUDE, + taxonomyId: o.taxonomyId, + })); +}; + +export default (action$) => + action$.pipe( + ofType(getItemAction.type), + switchMap((action) => { + const { id, isDuplicate } = action.payload; + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id, + }, + }, + }, + { + showNotificationMessage: false, + }, + ).pipe( + switchMap((response) => { + const actions = []; + + if (response.errors.length) { + actions.push( + of( + showNotificationAction({ + type: TYPE_ERROR, + message: "Can't open for edit", + }), + ), + ); + + Router.push('/x-ray/line-items'); + } else { + const { + name, + xrayCreatives, + targeting, + videoName, + videoUid, + videoFromTimestamp, + videoToTimestamp, + status, + ...restData + } = getResponse(response); + const [xrayCreative1 = null, xrayCreative2 = null, xrayCreative3 = null] = xrayCreatives; + + actions.push( + of( + setAction({ + name: isDuplicate ? `Copy of ${name}` : name, + status: isDuplicate ? STATUSES_ACTIVE : status, + xrayCreative1, + xrayCreative2, + xrayCreative3, + domains: mapToActionAutocompleteFormat(targeting.domains), + devices: targeting.devices ? targeting.devices.map((o) => o.deviceId) : [], + geography: mapToActionAutocompleteFormat(targeting.geography), + playerSizes: targeting.playerSizes ? targeting.playerSizes.map((o) => o.playerSizeId) : [], + inview: targeting.inview, + notInview: targeting.notInview, + video: videoUid ? { id: videoUid, name: videoName } : null, + videoFromTimestamp: timestampToString(videoFromTimestamp), + videoToTimestamp: timestampToString(videoToTimestamp), + iabCategories: targeting.iabCategories + ? targeting.iabCategories.map((o) => ({ + id: o.name, + include: o.filterAction === INCLUDE, + })) + : [], + people: mapToActionAutocompleteFormat(targeting.people), + brands: mapToActionAutocompleteFormat(targeting.brands), + keywords: mapToActionAutocompleteFormat(targeting.keywords), + brandSafety: mapToActionAutocompleteFormat(targeting.brandSafety), + labels: targeting.labels + ? targeting.labels.map((o) => ({ + label: o.value, + value: `${o.taxonomyId}|${o.value}`, + name: o.name, + color: o.color, + include: o.filterAction === INCLUDE, + taxonomyId: o.taxonomyId, + })) + : [], + ...restData, + }), + ), + ); + } + + return concat(...actions); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getKeywords.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getKeywords.js new file mode 100644 index 0000000..0acfee6 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getKeywords.js @@ -0,0 +1,72 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { TAXONOMY_KEYWORDS } from '../../constants'; +import { GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/taxonomy'; + +import { hubSelector } from '../selectors'; +import { getKeywordsOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS}(payload: $payload) { + records { + taxonomyId + value + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS].records.map((o) => ({ + label: o.value, + value: o.taxonomyId, + taxonomyId: o.taxonomyId, + })); + +export default (action$, state$) => + action$.pipe( + ofType(getKeywordsOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + category: TAXONOMY_KEYWORDS, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + keywordsOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getLabels.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getLabels.js new file mode 100644 index 0000000..b76f474 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getLabels.js @@ -0,0 +1,84 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_LABEL_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/label'; + +import { hubSelector } from '../selectors'; +import { getLabelsOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_LABEL_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_LABEL_OPTIONS}(payload: $payload) { + records { + name + values { + value + } + color + labelId + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_LABEL_OPTIONS].records.reduce((acc, curr) => { + const labels = curr.values + ? curr.values.map((label) => ({ + label: label.value, + value: `${curr.labelId}|${label.value}`, + name: curr.name, + color: curr.color, + groupBy: `${curr.name}|${curr.labelId}|${curr.color}`, + labelId: curr.labelId, + taxonomyId: curr.labelId, + })) + : []; + + return [...acc, ...labels]; + }, []); + +export default (action$, state$) => + action$.pipe( + ofType(getLabelsOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + labelsOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPeoples.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPeoples.js new file mode 100644 index 0000000..ae7b948 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPeoples.js @@ -0,0 +1,72 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { TAXONOMY_PEOPLE } from '../../constants'; +import { GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/taxonomy'; + +import { hubSelector } from '../selectors'; +import { getPeopleOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS}(payload: $payload) { + records { + taxonomyId + value + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_TAXONOMY_OPTIONS].records.map((o) => ({ + label: o.value, + value: o.taxonomyId, + taxonomyId: o.taxonomyId, + })); + +export default (action$, state$) => + action$.pipe( + ofType(getPeopleOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + category: TAXONOMY_PEOPLE, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + peopleOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPlayers.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPlayers.js new file mode 100644 index 0000000..ea2717c --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getPlayers.js @@ -0,0 +1,66 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_PLAYER_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/player'; + +import { hubSelector } from '../selectors'; +import { getPlayerOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_PLAYER_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_PLAYER_OPTIONS}(payload: $payload) { + records { + id + name + alias + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_LINE_ITEM_PLAYER_OPTIONS].records; + +export default (action$, state$) => + action$.pipe( + ofType(getPlayerOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + playerOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getTimezones.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getTimezones.js new file mode 100644 index 0000000..1aa64d8 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getTimezones.js @@ -0,0 +1,46 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_TIMEZONE_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { getTimezoneOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_TIMEZONE_OPTIONS} { + ${GET_XRAY_LINE_TIMEZONE_OPTIONS} { + records { + id + name + displayName + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_LINE_TIMEZONE_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getTimezoneOptionsAction.type), + switchMap(() => { + const stream$ = gqlRequest({ + query, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + timezoneOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getVideos.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getVideos.js new file mode 100644 index 0000000..f0b3854 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getVideos.js @@ -0,0 +1,66 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_VIDEO_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/video'; + +import { hubSelector } from '../selectors'; +import { getVideoOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_VIDEO_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_VIDEO_OPTIONS}(payload: $payload) { + records { + name + uid + } + } + } +`; + +const getResponse = ({ data }) => + data[GET_XRAY_LINE_ITEM_VIDEO_OPTIONS].records.map((o) => ({ name: o.name, id: o.uid })); + +export default (action$, state$) => + action$.pipe( + ofType(getVideoOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + videoOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getWatches.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getWatches.js new file mode 100644 index 0000000..8978dc1 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/getWatches.js @@ -0,0 +1,69 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEM_WATCH_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/watch'; + +import { hubSelector } from '../selectors'; +import { getWatchOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEM_WATCH_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEM_WATCH_OPTIONS}(payload: $payload) { + records { + id + title + watchChannels { + id + title + } + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_LINE_ITEM_WATCH_OPTIONS].records; + +export default (action$, state$) => + action$.pipe( + ofType(getWatchOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search?.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const publisherId = hubSelector(state$.value)?.id; + + if (!publisherId) { + return EMPTY; + } + + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + publisherId, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + watchOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/index.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/index.js new file mode 100644 index 0000000..e11a6d5 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/index.js @@ -0,0 +1,39 @@ +import { combineEpics } from 'redux-observable'; + +import createItem from './createItem'; +import getBrands from './getBrands'; +import getBrandSafety from './getBrandSafety'; +import getCampaings from './getCampaings'; +import getCreatives from './getCreatives'; +import getDomains from './getDomains'; +import getGeographies from './getGeographies'; +import getHubs from './getHubs'; +import getItem from './getItem'; +import getKeywords from './getKeywords'; +import getLabels from './getLabels'; +import getPeoples from './getPeoples'; +import getPlayers from './getPlayers'; +import getTimezoneOptions from './getTimezones'; +import getVideos from './getVideos'; +import getWatches from './getWatches'; +import updateItem from './updateItem'; + +export default combineEpics( + getItem, + getHubs, + createItem, + updateItem, + getCreatives, + getTimezoneOptions, + getWatches, + getDomains, + getGeographies, + getPlayers, + getVideos, + getPeoples, + getBrands, + getKeywords, + getLabels, + getBrandSafety, + getCampaings, +); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/updateItem.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/updateItem.js new file mode 100644 index 0000000..d3a707f --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/epics/updateItem.js @@ -0,0 +1,68 @@ +import Router from 'next/router'; +import { ofType } from 'redux-observable'; +import { concat, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { UPDATE_XRAY_LINE_ITEM } from '@/graphql/services/xRayLineItems/constants'; +import { TYPE_ERROR, TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/xRayLineItemUpsert'; + +import { updateBody } from '../../helpers/buildBody'; +import { updateItemAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; +import { showNotificationAction } from '@/modules/layout/redux/slices'; + +const query = `mutation ${UPDATE_XRAY_LINE_ITEM}($payload: ${PAYLOAD_NAME}) { + ${UPDATE_XRAY_LINE_ITEM}(payload: $payload) { + id + } +}`; + +export default (action$, state$) => + action$.pipe( + ofType(updateItemAction.type), + switchMap((action) => { + const body = updateBody(state$.value); + const stream$ = gqlRequest( + { + query, + variables: { + payload: { + id: action.payload, + ...body, + }, + }, + }, + { showNotificationMessage: false }, + ).pipe( + switchMap((response) => { + const error = response.errors?.[0]; + if (!error) { + Router.push('/x-ray/line-items'); + + return of( + showNotificationAction({ + type: TYPE_SUCCESS, + message: 'Updated', + }), + ); + } + + const message = + error?.response?.status === 409 + ? `An X-Ray Line Item with name ${body.name} already exists` + : "Can't update X-Ray Line"; + + return of( + showNotificationAction({ + type: TYPE_ERROR, + message, + }), + ); + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/selectors/index.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/selectors/index.js new file mode 100644 index 0000000..7012eda --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/selectors/index.js @@ -0,0 +1,66 @@ +import { REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createFormSelector from '@/modules/@common/Form/redux/selectors'; + +const nameSpace = slice.name; +const formSelectors = createFormSelector(REDUX_FIELD_NAME, nameSpace); + +export const idSelector = (state) => state[nameSpace].id; +// general tab +export const hubSelector = (state) => state[nameSpace].hub; +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; +export const xrayCampaignSelector = (state) => state[nameSpace].xrayCampaign; +export const xrayCampaignOptionsSelector = (state) => state[nameSpace].xrayCampaignOptions; +export const nameSelector = (state) => state[nameSpace].name; +export const statusSelector = (state) => state[nameSpace].status; +export const prioritySelector = (state) => state[nameSpace].priority; +export const xrayCreative1Selector = (state) => state[nameSpace].xrayCreative1; +export const xrayCreative2Selector = (state) => state[nameSpace].xrayCreative2; +export const xrayCreative3Selector = (state) => state[nameSpace].xrayCreative3; +export const xrayCreativeOptionsSelectors = (state) => state[nameSpace].xrayCreativeOptions; +// delivery tab +export const startTimeSelector = (state) => state[nameSpace].startTime; +export const endTimeSelector = (state) => state[nameSpace].endTime; +export const timezoneSelector = (state) => state[nameSpace].timezone; +export const timezoneOptionsSelector = (state) => state[nameSpace].timezoneOptions; +export const rateSelector = (state) => state[nameSpace].rate; +export const impressionsBudgetSelector = (state) => state[nameSpace].impressionsBudget; +export const spendBudgetSelector = (state) => state[nameSpace].spendBudget; +// targeting tab +export const watchSelectors = (state) => state[nameSpace].watch; +export const watchOptionsSelectors = (state) => state[nameSpace].watchOptions; +export const watchChannelSelectors = (state) => state[nameSpace].watchChannel; +export const watchChannelOptionsSelectors = (state) => state[nameSpace].watchChannelOptions; +export const playerSelectors = (state) => state[nameSpace].player; +export const playerOptionsSelectors = (state) => state[nameSpace].playerOptions; +export const domainsSelectors = (state) => state[nameSpace].domains; +export const domainOptionsSelectors = (state) => state[nameSpace].domainOptions; +export const devicesSelectors = (state) => state[nameSpace].devices; +export const geographySelectors = (state) => state[nameSpace].geography; +export const geographyOptionsSelectors = (state) => state[nameSpace].geographyOptions; +export const inviewSelectors = (state) => state[nameSpace].inview; +export const notInviewSelectors = (state) => state[nameSpace].notInview; +export const playerSizesSelectors = (state) => state[nameSpace].playerSizes; +export const videoSelector = (state) => state[nameSpace].video; +export const videoOptionsSelector = (state) => state[nameSpace].videoOptions; +export const videoFromTimestampSelector = (state) => state[nameSpace].videoFromTimestamp; +export const videoToTimestampSelector = (state) => state[nameSpace].videoToTimestamp; +export const iabCategoriesSelector = (state) => state[nameSpace].iabCategories; +export const peopleSelector = (state) => state[nameSpace].people; +export const peopleOptionsSelector = (state) => state[nameSpace].peopleOptions; +export const brandsSelector = (state) => state[nameSpace].brands; +export const brandsOptionsSelector = (state) => state[nameSpace].brandsOptions; +export const keywordsSelector = (state) => state[nameSpace].keywords; +export const keywordsOptionsSelector = (state) => state[nameSpace].keywordsOptions; +export const labelsSelector = (state) => state[nameSpace].labels; +export const labelsOptionsSelectors = (state) => state[nameSpace].labelsOptions; +export const brandSafetySelector = (state) => state[nameSpace].brandSafety; +export const brandSafetyOptionsSelector = (state) => state[nameSpace].brandSafetyOptions; + +export const activeTabIdSelector = (state) => state[nameSpace].activeTabId; + +// forms +export const scrollFieldSelector = (state) => formSelectors.getScrollField(state); +export const schemeSelector = (state) => formSelectors.schemeSelector(state); +export const fullAccessToStoreFieldsForValidation = (state) => state[nameSpace]; diff --git a/anyclip/src/modules/xRay/lineItems/Editor/redux/slices/index.js b/anyclip/src/modules/xRay/lineItems/Editor/redux/slices/index.js new file mode 100644 index 0000000..e815f66 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/Editor/redux/slices/index.js @@ -0,0 +1,150 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { STATUSES_ACTIVE } from '../../../List/constants'; +import { + DEVICE_DESKTOP, + DEVICE_MOBILE, + PLAYER_LARGE, + PLAYER_MEDIUM, + PLAYER_SMALL, + REDUX_FIELD_NAME, + TAB_GENERAL, +} from '../../constants'; + +import { timestampToString } from '../../helpers/timestamp'; +import { validationScheme } from '../../helpers/validationScheme'; +import createFormSlice from '@/modules/@common/Form/redux/slices'; + +const formSlice = createFormSlice(REDUX_FIELD_NAME, validationScheme); + +export const { validateFields, validateSingleField } = formSlice; + +const initialState = { + id: null, + // general tab + hub: null, + hubOptions: null, + xrayCampaign: null, + xrayCampaignOptions: null, + name: '', + status: STATUSES_ACTIVE, + priority: 5, + xrayCreative1: null, + xrayCreative2: null, + xrayCreative3: null, + xrayCreativeOptions: null, + + // delivery tab + startTime: null, + endTime: null, + timezone: { id: 1, name: 'UTC' }, + timezoneOptions: null, + rate: null, + impressionsBudget: null, + spendBudget: null, + + // targeting tab + watch: null, + watchOptions: null, + watchChannel: null, + watchChannelOptions: null, + player: null, + playerOptions: [], + domains: [], + domainsOptions: null, + devices: [DEVICE_DESKTOP, DEVICE_MOBILE], + geography: [], + geographyOptions: null, + inview: true, + notInview: true, + playerSizes: [PLAYER_SMALL, PLAYER_MEDIUM, PLAYER_LARGE], + video: null, + videoOptions: null, + videoFromTimestamp: timestampToString(0), + videoToTimestamp: timestampToString(0), + iabCategories: [], + people: [], + peopleOptions: null, + brands: [], + brandsOptions: null, + keywords: [], + keywordsOptions: null, + labels: [], + labelsOptions: null, + brandSafety: [], + brandSafetyOptions: null, + + activeTabId: TAB_GENERAL, + + ...formSlice.state, +}; + +export const slice = createSlice({ + name: '@@XRAY_LINEITEMS/EDITOR', + initialState, + reducers: { + setAction: (state, action) => { + Object.entries(action.payload).forEach(([key, value]) => { + state[key] = value; + }); + }, + setInitialAction: () => ({ + ...initialState, + }), + getItemAction: (state) => state, + getHubOptionsAction: (state) => state, + getTimezoneOptionsAction: (state) => state, + getXRayCampaignOptionsAction: (state) => state, + getXRayCreativeOptionsAction: (state) => state, + getWatchOptionsAction: (state) => state, + getDomainOptionsAction: (state) => state, + getGeographyOptionsAction: (state) => state, + getPlayerOptionsAction: (state) => state, + getVideoOptionsAction: (state) => state, + getPeopleOptionsAction: (state) => state, + getBrandsOptionsAction: (state) => state, + getKeywordsOptionsAction: (state) => state, + getLabelsOptionsAction: (state) => state, + getBrandSafetyOptionsAction: (state) => state, + createItemAction: (state) => state, + updateItemAction: (state) => state, + + setActiveTabIdAction: (state, action) => { + state.activeTabId = action.payload; + }, + + setScrollToFieldNameAction: formSlice.actions.setScrollToFieldAction, + setErrorByPropAction: formSlice.actions.updateValidationSchemeAction, + removeErrorByPropAction: formSlice.actions.removeErrorByFieldNameAction, + }, +}); + +export const { + setAction, + setInitialAction, + getItemAction, + getHubOptionsAction, + getTimezoneOptionsAction, + getXRayCampaignOptionsAction, + getXRayCreativeOptionsAction, + getWatchOptionsAction, + getDomainOptionsAction, + getGeographyOptionsAction, + getPlayerOptionsAction, + getVideoOptionsAction, + getPeopleOptionsAction, + getBrandsOptionsAction, + getKeywordsOptionsAction, + getLabelsOptionsAction, + getBrandSafetyOptionsAction, + createItemAction, + updateItemAction, + + setActiveTabIdAction, + + removeErrorByPropAction, + setErrorByPropAction, + setScrollToFieldNameAction, +} = slice.actions; + +export default slice.reducer; diff --git a/src/modules/xRay/lineItems/List/components/Empty/Empty.jsx b/anyclip/src/modules/xRay/lineItems/List/components/Empty/Empty.jsx similarity index 100% rename from src/modules/xRay/lineItems/List/components/Empty/Empty.jsx rename to anyclip/src/modules/xRay/lineItems/List/components/Empty/Empty.jsx diff --git a/src/modules/xRay/lineItems/List/components/Empty/Empty.module.scss b/anyclip/src/modules/xRay/lineItems/List/components/Empty/Empty.module.scss similarity index 100% rename from src/modules/xRay/lineItems/List/components/Empty/Empty.module.scss rename to anyclip/src/modules/xRay/lineItems/List/components/Empty/Empty.module.scss diff --git a/src/modules/xRay/lineItems/List/components/List.jsx b/anyclip/src/modules/xRay/lineItems/List/components/List.jsx similarity index 100% rename from src/modules/xRay/lineItems/List/components/List.jsx rename to anyclip/src/modules/xRay/lineItems/List/components/List.jsx diff --git a/src/modules/xRay/lineItems/List/components/List.module.scss b/anyclip/src/modules/xRay/lineItems/List/components/List.module.scss similarity index 100% rename from src/modules/xRay/lineItems/List/components/List.module.scss rename to anyclip/src/modules/xRay/lineItems/List/components/List.module.scss diff --git a/anyclip/src/modules/xRay/lineItems/List/constants/index.js b/anyclip/src/modules/xRay/lineItems/List/constants/index.js new file mode 100644 index 0000000..085abed --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/constants/index.js @@ -0,0 +1,23 @@ +// Search +export const SEARCH_TEXT_MAX_LENGTH = 100; + +export const STATUSES_ALL = null; +export const STATUSES_ACTIVE = 1; +export const STATUSES_INACTIVE = -1; +export const STATUSES_PAUSED = 0; +export const STATUSES_OPTIONS = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Archived', value: STATUSES_INACTIVE }, + { label: 'Paused', value: STATUSES_PAUSED }, +]; +export const STATUSES_OPTIONS_FOR_FORM = [ + { label: 'Active', value: STATUSES_ACTIVE }, + { label: 'Paused', value: STATUSES_PAUSED }, + { label: 'Archived', value: STATUSES_INACTIVE }, +]; + +export const ROWS_PER_PAGE_DEFAULT = 15; + +export const TABLE_SORT_BY = 'updatedAt'; + +export const TABLE_REDUX_FIELD_NAME = 'commonTable'; diff --git a/anyclip/src/modules/xRay/lineItems/List/helpers/computedState.js b/anyclip/src/modules/xRay/lineItems/List/helpers/computedState.js new file mode 100644 index 0000000..5380bd1 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/helpers/computedState.js @@ -0,0 +1,15 @@ +import { STATUSES_ALL } from '../constants'; + +import * as selectors from '../redux/selectors'; + +export const shouldShowEmpty = (state) => { + const data = selectors.dataSelector(state); + const page = selectors.pageSelector(state); + const search = selectors.searchSelector(state); + const status = selectors.statusSelector(state); + const isLoading = selectors.isLoadingSelector(state); + + return !isLoading && Array.isArray(data) && !data.length && page === 1 && !search && status === STATUSES_ALL; +}; + +export default {}; diff --git a/src/modules/xRay/lineItems/List/helpers/index.js b/anyclip/src/modules/xRay/lineItems/List/helpers/index.js similarity index 100% rename from src/modules/xRay/lineItems/List/helpers/index.js rename to anyclip/src/modules/xRay/lineItems/List/helpers/index.js diff --git a/anyclip/src/modules/xRay/lineItems/List/redux/epics/archive.js b/anyclip/src/modules/xRay/lineItems/List/redux/epics/archive.js new file mode 100644 index 0000000..14a9298 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/redux/epics/archive.js @@ -0,0 +1,51 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { ARCHIVE_XRAY_LINE_ITEM } from '@/graphql/services/xRayLineItems/constants'; +import { TYPE_SUCCESS } from '@/modules/@common/notify/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/archive'; + +import { archiveAction, getDataAction } from '../slices'; +import { notifyAction } from '@/modules/@common/notify/redux/slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + mutation ${ARCHIVE_XRAY_LINE_ITEM} ($payload: ${PAYLOAD_NAME}) { + ${ARCHIVE_XRAY_LINE_ITEM}(payload: $payload) { + id + } + } +`; + +export default (action$) => + action$.pipe( + ofType(archiveAction.type), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { id: action.payload }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return concat( + of( + notifyAction({ + type: TYPE_SUCCESS, + message: 'Archived', + }), + ), + of(getDataAction()), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/List/redux/epics/getData.js b/anyclip/src/modules/xRay/lineItems/List/redux/epics/getData.js new file mode 100644 index 0000000..a52b4bf --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/redux/epics/getData.js @@ -0,0 +1,73 @@ +import { STATUSES_ALL } from '../../constants'; +import { GET_XRAY_LINE_ITEMS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_GET_XRAY_LINE_ITEMS } from '@/graphql/services/xRayLineItems/types/payload/xRayLineItemsList'; + +import * as selectors from '../selectors'; +import { getDataAction, setTableAction } from '../slices'; +import createEpicGetData from '@/modules/@common/Table/redux/epics'; + +const gqlQuery = ` + query ${GET_XRAY_LINE_ITEMS}($payload: ${PAYLOAD_GET_XRAY_LINE_ITEMS}) { + ${GET_XRAY_LINE_ITEMS}(payload: $payload) { + records { + id + name + publisherName + rate + status + startTime + endTime + updatedBy + updatedAt + } + recordsTotal + } + } +`; + +export default createEpicGetData({ + gqlQuery, + triggerActionType: getDataAction.type, + processBodyRequest: (state) => { + const status = selectors.statusSelector(state); + const publisherId = selectors.hubSelector(state)?.id; + + const queryParams = new URLSearchParams(window.location.search); + const xrayCampaignId = +queryParams.get('xrayCampaignId'); + + const variables = { + page: selectors.pageSelector(state), + pageSize: selectors.pageSizeSelector(state), + sortBy: selectors.sortBySelector(state), + sortOrder: selectors.sortOrderSelector(state), + searchText: selectors.searchSelector(state), + }; + + if (status !== STATUSES_ALL) { + variables.status = status; + } + + if (publisherId) { + variables.publisherId = publisherId; + } + + if (xrayCampaignId) { + variables.xrayCampaignId = xrayCampaignId; + } + + return { + payload: variables, + }; + }, + processResponse: ({ data }) => { + const res = data[GET_XRAY_LINE_ITEMS]; + + return { + records: res.records, + recordsTotal: res.recordsTotal, + allRecordsCount: res.recordsTotal, + }; + }, + setTableAction, +}); diff --git a/anyclip/src/modules/xRay/lineItems/List/redux/epics/getHubs.js b/anyclip/src/modules/xRay/lineItems/List/redux/epics/getHubs.js new file mode 100644 index 0000000..dc15160 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/redux/epics/getHubs.js @@ -0,0 +1,57 @@ +import { ofType } from 'redux-observable'; +import { concat, EMPTY, of, timer } from 'rxjs'; +import { debounce, switchMap } from 'rxjs/operators'; + +import { GET_XRAY_LINE_ITEMS_HUB_OPTIONS } from '@/graphql/services/xRayLineItems/constants'; + +import { PAYLOAD_NAME } from '@/graphql/services/xRayLineItems/types/payload/hub'; + +import { getHubOptionsAction, setAction } from '../slices'; +import { gqlRequest } from '@/modules/@common/request'; + +const query = ` + query ${GET_XRAY_LINE_ITEMS_HUB_OPTIONS}($payload: ${PAYLOAD_NAME}) { + ${GET_XRAY_LINE_ITEMS_HUB_OPTIONS}(payload: $payload) { + records { + id + name + } + } + } +`; + +const getResponse = ({ data }) => data[GET_XRAY_LINE_ITEMS_HUB_OPTIONS].records; + +export default (action$) => + action$.pipe( + ofType(getHubOptionsAction.type), + debounce((action) => { + const search = action.payload; + return timer(search.length > 1 ? 1000 : 0); + }), + switchMap((action) => { + const stream$ = gqlRequest({ + query, + variables: { + payload: { + searchText: action.payload ?? '', + pageSize: 30, + }, + }, + }).pipe( + switchMap((response) => { + if (!response.errors.length) { + return of( + setAction({ + hubOptions: getResponse(response), + }), + ); + } + + return EMPTY; + }), + ); + + return concat(stream$); + }), + ); diff --git a/anyclip/src/modules/xRay/lineItems/List/redux/epics/index.js b/anyclip/src/modules/xRay/lineItems/List/redux/epics/index.js new file mode 100644 index 0000000..3a99431 --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/redux/epics/index.js @@ -0,0 +1,7 @@ +import { combineEpics } from 'redux-observable'; + +import archive from './archive'; +import getData from './getData'; +import getHubs from './getHubs'; + +export default combineEpics(getData, getHubs, archive); diff --git a/anyclip/src/modules/xRay/lineItems/List/redux/selectors/index.js b/anyclip/src/modules/xRay/lineItems/List/redux/selectors/index.js new file mode 100644 index 0000000..850584c --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/redux/selectors/index.js @@ -0,0 +1,25 @@ +import { TABLE_REDUX_FIELD_NAME } from '../../constants'; + +import { slice } from '../slices'; +import createTableSelector from '@/modules/@common/Table/redux/selectors'; + +const nameSpace = slice.name; +// table +export const { + dataSelector, + pageSelector, + pageSizeSelector, + totalCountSelector, + sortBySelector, + sortOrderSelector, + selectedSelector, + isLoadingSelector, +} = createTableSelector(TABLE_REDUX_FIELD_NAME, nameSpace); + +// filters +export const statusSelector = (state) => state[nameSpace].status; +export const searchSelector = (state) => state[nameSpace].search; +export const hubSelector = (state) => state[nameSpace].hub; + +// autocomplete options +export const hubOptionsSelector = (state) => state[nameSpace].hubOptions; diff --git a/anyclip/src/modules/xRay/lineItems/List/redux/slices/index.js b/anyclip/src/modules/xRay/lineItems/List/redux/slices/index.js new file mode 100644 index 0000000..b198a8b --- /dev/null +++ b/anyclip/src/modules/xRay/lineItems/List/redux/slices/index.js @@ -0,0 +1,44 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ROWS_PER_PAGE_DEFAULT, STATUSES_ACTIVE, TABLE_REDUX_FIELD_NAME, TABLE_SORT_BY } from '../../constants'; +import { SORT_DESC } from '@/modules/@common/constants/sort'; + +import createTableSlice from '@/modules/@common/Table/redux/slices'; + +const tableSlice = createTableSlice(TABLE_REDUX_FIELD_NAME, { + page: 1, + pageSize: ROWS_PER_PAGE_DEFAULT, + sortBy: TABLE_SORT_BY, + sortOrder: SORT_DESC, +}); + +const initialState = { + // table + ...tableSlice.state, + + // filters + search: '', + status: STATUSES_ACTIVE, + + hub: null, + hubOptions: null, +}; + +export const slice = createSlice({ + name: '@@XRAY_LINEITEMS/LIST', + initialState, + + reducers: { + getDataAction: tableSlice.actions.getTableDataAction, + setTableAction: tableSlice.actions.setTableAction, + setAction: (state, action) => { + Object.keys(action.payload).forEach((key) => { + state[key] = action.payload[key]; + }); + }, + getHubOptionsAction: (state) => state, + archiveAction: (state) => state, + }, +}); + +export const { getDataAction, setTableAction, setAction, getHubOptionsAction, archiveAction } = slice.actions; diff --git a/anyclip/src/mui/components/@extendedComponents/ColorPicker/ButtonColorPicker.tsx b/anyclip/src/mui/components/@extendedComponents/ColorPicker/ButtonColorPicker.tsx new file mode 100644 index 0000000..73aa403 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/ColorPicker/ButtonColorPicker.tsx @@ -0,0 +1,102 @@ +import React, { ComponentProps, forwardRef, useEffect, useRef, useState } from 'react'; +import classNames from 'clsx'; +import type { RgbaColor } from 'colord'; +import { colord } from 'colord'; +import type { PopperPlacementType } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { KeyboardArrowDownRounded } from '@mui/icons-material'; + +import type { ShapeProp } from '@/mui/types'; + +import Button from '../../Button/Button'; +import ClickAwayListener from '../../ClickAwayListener/ClickAwayListener'; +import Popper from '../../Popper/Popper'; +import StaticColorPicker from './StaticColorPicker'; + +import styles from './ColorPicker.module.scss'; + +type ButtonProps = ComponentProps; + +type ButtonColorPickerProps = { + color?: string; + open?: boolean; + shape?: ShapeProp; + disabled?: boolean; + size?: ButtonProps['size']; + dense?: 'margin' | 'padding'; + placement?: PopperPlacementType; + disableAlpha?: boolean; + presetColors?: string[]; + onClick?: (event: React.MouseEvent) => void; + onPickerChange?: (color: { hex: string; rgb: RgbaColor }) => void; +}; + +const ButtonColorPicker = forwardRef((props, ref) => { + const theme = useTheme(); + const color = props.color && colord(props.color).isValid() ? props.color : '#fff'; + const contrastText = theme.palette.getContrastText(color); + + const buttonRef = useRef(null); + const [open, setOpen] = useState(props.open || false); + const isControlled = typeof props.open === 'boolean'; + + useEffect(() => { + if (isControlled) setOpen(props.open || false); + }, [props.open]); + + return ( + <> + + ); + + case 'cancel': + return ( + + ); + + case 'accept': + return ( + + ); + + case 'today': + return ( + + ); + + case 'next': + return ( + + ); + + case 'nextOrAccept': + if (hasNextStep) { + return ( + + ); + } + return ( + + ); + + default: + return null; + } + }); + + return {buttons}; +} + +const PickersActionBar = React.memo(PickersActionBarComponent); + +export default PickersActionBar; diff --git a/anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.module.scss b/anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.module.scss new file mode 100644 index 0000000..921b145 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"DotPagination_Wrapper__4Fqlu","Content":"DotPagination_Content__Hgs46","Item":"DotPagination_Item__Tepn3","Item___primary":"DotPagination_Item___primary__AKyb6","Item___active":"DotPagination_Item___active__yF2Gn"}; \ No newline at end of file diff --git a/anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.tsx b/anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.tsx new file mode 100644 index 0000000..9bbda49 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/DotPagination/DotPagination.tsx @@ -0,0 +1,64 @@ +import React, { CSSProperties, forwardRef, useMemo } from 'react'; +import classNames from 'clsx'; +import type { BoxProps } from '@mui/material'; +import { Box as MuiBox } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { getNumberInRange, isInRange } from '@/modules/@common/helpers/number'; + +import styles from './DotPagination.module.scss'; + +const size = 8; + +type Props = { + boundaryCount: number; + count: number; + page: number; +}; + +const DotPagination = forwardRef((props, ref) => { + const theme = useTheme(); + const spacing = theme.spacing(1); + const boundaryCount = props.boundaryCount ?? 5; + const activeIndex = Math.max(0, props.page - 1); + const spacingNum = parseInt(spacing, 10); + const activeShiftIndex = getNumberInRange(activeIndex - 2, 0, props.count - boundaryCount); + + const items = useMemo(() => new Array(props.count).fill(0).map((_, index) => index), [props.count]); + + return ( + +
    + {items.map((id) => ( +
    = props.count - 1 ? 2 : 1), + activeIndex + (activeIndex === 0 ? 2 : 1), + ), + })} + /> + ))} +
    + + ); +}); + +DotPagination.displayName = 'DotPagination'; + +export default DotPagination; diff --git a/anyclip/src/mui/components/@extendedComponents/DurationField/DurationField.tsx b/anyclip/src/mui/components/@extendedComponents/DurationField/DurationField.tsx new file mode 100644 index 0000000..57e89f7 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/DurationField/DurationField.tsx @@ -0,0 +1,381 @@ +import React, { ComponentProps, forwardRef, useEffect, useMemo, useRef, useState } from 'react'; + +import { + buildBlocks, + formatFromMs, + getBlockIndexFromPos, + joinMs, + PAD, + parseBlock, + parseTemplateOrMs, + putBlock, + safetyParseValue, +} from './helpers'; +import { getNumberInRange } from '@/modules/@common/helpers/number'; + +import { TextField } from '@/mui/components'; + +type Props = ComponentProps & { + value: number | string; + wrap?: boolean; + hh?: boolean; + ms?: boolean; + onChange: (ms: number) => void; +}; + +const isMac = navigator.platform.toUpperCase().includes('MAC'); + +const DurationField = forwardRef( + ({ value, onChange, wrap = false, hh = true, ms = true, disabled, ...props }, ref) => { + const [BLOCKS_CUR, SEP_POS, TEMPLATE, durationTemplate] = useMemo(() => { + const blocks = buildBlocks({ hh, ms }); + + return [ + blocks, + blocks.map(({ end }) => end), + blocks.map(({ len }) => '0'.repeat(len)).join(':'), + blocks + .map((b) => { + if (b.key === 'H') { + return 'HH'; + } + + if (b.key === 'm') { + return 'mm'; + } + + if (b.key === 's') { + return 'ss'; + } + + return 'MSS'; + }) + .join(':'), + ]; + }, [hh, ms]); + + const innerRef = useRef(null); + const selectingRef = useRef(false); + const typedCountRef = useRef(0); + const lastBlockRef = useRef(0); + const reselectId = useRef(null); + + const [text, setText] = useState(() => formatFromMs(safetyParseValue(value), BLOCKS_CUR)); + const [activeBlock, setActiveBlock] = useState(0); + const [isComposing, setIsComposing] = useState(false); + + const selectBlock = (idx: number) => { + const input = innerRef.current; + const block = BLOCKS_CUR[idx]; + + if (input && block) { + if (reselectId.current != null) { + window.cancelAnimationFrame(reselectId.current); + reselectId.current = null; + } + + selectingRef.current = true; + window.queueMicrotask(() => { + window.requestAnimationFrame(() => { + reselectId.current = window.requestAnimationFrame(() => { + input.setSelectionRange(block.start, block.end); + setTimeout(() => { + selectingRef.current = false; + }, 0); + }); + }); + }); + } + }; + + const resetTypedCount = (idx = activeBlock) => { + typedCountRef.current = 0; + lastBlockRef.current = idx; + }; + + const commitFromText = (t: string) => { + let h = 0; + let mm = 0; + let ss = 0; + let mss = 0; + + // eslint-disable-next-line no-restricted-syntax + for (const b of BLOCKS_CUR) { + const val = parseBlock(t, b.start, b.end); + + if (b.key === 'H') { + h = val; + } else if (b.key === 'm') { + mm = val; + } else if (b.key === 's') { + ss = val; + } else if (b.key === 'M') { + mss = val; + } + } + onChange(joinMs(h, mm, ss, mss)); + }; + + useEffect(() => { + setText(formatFromMs(safetyParseValue(value), BLOCKS_CUR)); + }, [value, hh, ms]); + + useEffect(() => { + if (props.autoFocus) { + innerRef.current?.focus(); + selectBlock(activeBlock); + } + }, [props.autoFocus]); + + const setBlockNumLocal = (idx: number, num: number) => { + const block = BLOCKS_CUR[idx]; + const next = getNumberInRange(num, block.min, block.max); + const digits = PAD(next, block.len); + const nextText = putBlock(text, block.start, block.end, digits); + + setText(nextText); + + return nextText; + }; + + const increment = (idx: number, delta: number) => { + const block = BLOCKS_CUR[idx]; + const curr = parseBlock(text, block.start, block.end); + let next = curr + delta; + + if (wrap) { + const range = block.max - block.min + 1; + next = ((((next - block.min) % range) + range) % range) + block.min; + } else { + next = getNumberInRange(next, block.min, block.max); + } + + const nt = setBlockNumLocal(idx, next); + + commitFromText(nt); + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (disabled || isComposing) return; + const idx = activeBlock; + const isCtrl = isMac ? e.metaKey : e.ctrlKey; + + if (e.key === 'Enter' || isCtrl) { + return; + } + + if (e.key === 'Tab') { + const atFirst = idx === 0; + const atLast = idx === BLOCKS_CUR.length - 1; + if (e.shiftKey) { + if (!atFirst) { + e.preventDefault(); + const n = idx - 1; + setActiveBlock(n); + resetTypedCount(n); + selectBlock(n); + } + } else if (!atLast) { + e.preventDefault(); + const n = idx + 1; + setActiveBlock(n); + resetTypedCount(n); + selectBlock(n); + } + return; + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + const n = Math.max(0, idx - 1); + setActiveBlock(n); + resetTypedCount(n); + selectBlock(n); + return; + } + if (e.key === 'ArrowRight') { + e.preventDefault(); + const n = Math.min(BLOCKS_CUR.length - 1, idx + 1); + setActiveBlock(n); + resetTypedCount(n); + selectBlock(n); + return; + } + if (e.key === 'Home') { + e.preventDefault(); + setActiveBlock(0); + resetTypedCount(0); + selectBlock(0); + return; + } + if (e.key === 'End') { + e.preventDefault(); + const last = BLOCKS_CUR.length - 1; + setActiveBlock(last); + resetTypedCount(last); + selectBlock(last); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + resetTypedCount(idx); + increment(idx, +1); + selectBlock(idx); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + resetTypedCount(idx); + increment(idx, -1); + selectBlock(idx); + return; + } + + if (/^[0-9]$/.test(e.key)) { + e.preventDefault(); + + if (idx !== lastBlockRef.current) { + resetTypedCount(idx); + } + + const block = BLOCKS_CUR[idx]; + const baseStr = typedCountRef.current === 0 ? '0'.repeat(block.len) : text.slice(block.start, block.end); + const shifted = (baseStr + e.key).slice(-block.len); + const num = parseInt(shifted, 10) || 0; + + const nt = setBlockNumLocal(idx, num); + + typedCountRef.current += 1; + const filled = typedCountRef.current >= block.len; + const atLast = idx === BLOCKS_CUR.length - 1; + + if (filled) { + commitFromText(nt); + + if (!atLast) { + const nextIdx = idx + 1; + setActiveBlock(nextIdx); + resetTypedCount(nextIdx); + selectBlock(nextIdx); + } else { + selectBlock(idx); + resetTypedCount(idx); + } + } else { + selectBlock(idx); + } + + return; + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + resetTypedCount(idx); + const nt = setBlockNumLocal(idx, 0); + commitFromText(nt); + selectBlock(idx); + return; + } + + if (e.key.length === 1) { + e.preventDefault(); + } + }; + + const handleSelect: React.ReactEventHandler = () => { + const input = innerRef.current; + if (!input || selectingRef.current) return; + + const pos = input.selectionStart ?? 0; + const idx = getBlockIndexFromPos(pos, SEP_POS); + + if (idx !== activeBlock) { + setActiveBlock(idx); + resetTypedCount(idx); + selectBlock(idx); + } + }; + + const handlePaste: React.ClipboardEventHandler = (e) => { + e.preventDefault(); + const data = e.clipboardData.getData('text').trim(); + const parsed = parseTemplateOrMs(data, BLOCKS_CUR); + + if (parsed !== null) { + const next = formatFromMs(parsed, BLOCKS_CUR); + + setText(next); + commitFromText(next); + selectBlock(activeBlock); + } + }; + + return ( + {}} + onKeyDown={handleKeyDown} + onKeyUp={(e) => { + if (disabled || isComposing) return; + if ( + /^[0-9]$/.test(e.key) || + e.key.startsWith('Arrow') || + e.key === 'Home' || + e.key === 'End' || + e.key === 'Tab' + ) { + selectBlock(activeBlock); + } + }} + onMouseDown={() => { + window.requestAnimationFrame(() => { + if (innerRef.current) { + const pos = innerRef.current.selectionStart ?? 0; + const idx = getBlockIndexFromPos(pos, SEP_POS); + + setActiveBlock(idx); + resetTypedCount(idx); + selectBlock(idx); + } + }); + }} + onFocus={(event) => { + selectBlock(activeBlock); + props.onFocus?.(event); + }} + onSelect={handleSelect} + onPaste={handlePaste} + onBlur={(event) => { + commitFromText(text); + props.onBlur?.(event); + }} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + inputRef={(node) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref && 'current' in ref) { + ref.current = node; + } + + innerRef.current = node; + }} + inputProps={{ + ...(props.inputProps || {}), + readOnly: true, + inputMode: 'numeric', + maxLength: TEMPLATE.length, + }} + placeholder={TEMPLATE} + autoComplete="off" + spellCheck={false} + /> + ); + }, +); + +export default DurationField; diff --git a/anyclip/src/mui/components/@extendedComponents/DurationField/helpers/index.ts b/anyclip/src/mui/components/@extendedComponents/DurationField/helpers/index.ts new file mode 100644 index 0000000..6a85958 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/DurationField/helpers/index.ts @@ -0,0 +1,112 @@ +import { Block } from '../types'; + +import { getNumberInRange } from '@/modules/@common/helpers/number'; + +export const PAD = (n: number, len: number) => n.toString().padStart(len, '0'); + +export const safetyParseValue = (value: number | string) => { + const num = typeof value === 'number' ? value : parseInt(value, 10); + + return Number.isFinite(num) ? num : 0; +}; + +export const buildBlocks = (opts: { hh: boolean; ms: boolean }): Block[] => { + let pos = 0; + + const blocks: Array | undefined> = [ + opts.hh ? { key: 'H', len: 2, min: 0, max: 99 } : undefined, + { key: 'm', len: 2, min: 0, max: 59 }, + { key: 's', len: 2, min: 0, max: 59 }, + opts.ms ? { key: 'M', len: 3, min: 0, max: 999 } : undefined, + ]; + + return blocks + .filter((block) => block !== undefined) + .map((block) => { + const start = pos; + const end = start + block.len; + pos = end + 1; + + return { ...block, start, end }; + }); +}; + +export const joinMs = (h: number, m: number, s: number, ms: number) => h * 3_600_000 + m * 60_000 + s * 1_000 + ms; + +export const formatFromMs = (msRaw: number, blocks: Block[]) => { + const ms = !Number.isFinite(msRaw) || msRaw < 0 ? 0 : msRaw; + + const hours = Math.floor(ms / 3_600_000); + const minutes = getNumberInRange(Math.floor((ms % 3_600_000) / 60_000), 0, 59); + const seconds = getNumberInRange(Math.floor((ms % 60_000) / 1_000), 0, 59); + const millis = getNumberInRange(Math.floor(ms % 1_000), 0, 999); + + const parts: string[] = []; + + // eslint-disable-next-line no-restricted-syntax + for (const b of blocks) { + if (b.key === 'H') { + parts.push(PAD(getNumberInRange(hours, b.min, b.max), b.len)); + } + + if (b.key === 'm') { + parts.push(PAD(minutes, b.len)); + } + + if (b.key === 's') { + parts.push(PAD(seconds, b.len)); + } + + if (b.key === 'M') { + parts.push(PAD(millis, b.len)); + } + } + + return parts.join(':'); +}; + +export const parseBlock = (str: string, start: number, end: number) => parseInt(str.slice(start, end), 10) || 0; +export const putBlock = (str: string, start: number, end: number, nextDigits: string) => + str.slice(0, start) + nextDigits + str.slice(end); + +export const getBlockIndexFromPos = (pos: number, sepEnds: number[]): number => { + for (let i = 0; i < sepEnds.length; i++) { + if (pos <= sepEnds[i]) { + return i; + } + } + + return sepEnds.length - 1; +}; + +export const parseTemplateOrMs = (input: string, blocks: Block[]): number | null => { + const re = new RegExp(`^${blocks.map((b) => `(\\d{${b.len}})`).join(':')}$`); + const m = input.match(re); + + if (m) { + let h = 0; + let mm = 0; + let ss = 0; + let mss = 0; + let gi = 1; + + // eslint-disable-next-line no-restricted-syntax + for (const b of blocks) { + const val = parseInt(m[(gi += 1)] ?? '0', 10) || 0; + if (b.key === 'H') h = val; + else if (b.key === 'm') { + mm = val; + } else if (b.key === 's') { + ss = val; + } else if (b.key === 'M') { + mss = val; + } + } + + return joinMs(h, mm, ss, mss); + } + + const asNum = parseInt(input, 10); + + return Number.isFinite(asNum) ? asNum : null; +}; diff --git a/anyclip/src/mui/components/@extendedComponents/GridList/GridList.module.scss b/anyclip/src/mui/components/@extendedComponents/GridList/GridList.module.scss new file mode 100644 index 0000000..4358b32 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/GridList/GridList.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"GridContainer":"GridList_GridContainer__HVAMa","GridContainer___fullWidth":"GridList_GridContainer___fullWidth___2BAe","GridContainer___row":"GridList_GridContainer___row__qlOoq","GridContainer___column":"GridList_GridContainer___column__4P6wy"}; \ No newline at end of file diff --git a/anyclip/src/mui/components/@extendedComponents/GridList/GridList.tsx b/anyclip/src/mui/components/@extendedComponents/GridList/GridList.tsx new file mode 100644 index 0000000..c9a6c1b --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/GridList/GridList.tsx @@ -0,0 +1,45 @@ +import React, { CSSProperties, ReactNode } from 'react'; +import classNames from 'clsx'; +import { useTheme } from '@mui/material/styles'; + +import styles from './GridList.module.scss'; + +type Props = { + children: ReactNode; + gap?: number; + rowCount: number; + fullWidth?: boolean; + direction?: 'row' | 'column'; +}; + +function GridList({ children, fullWidth = false, direction = 'column', rowCount, gap = 3 }: Props) { + const theme = useTheme(); + const childrenList = (Array.isArray(children) ? children : [children]).filter(Boolean); + const fixedRowCount = Math.max(1, Math.min(childrenList.length, rowCount)); + const columnCount = Math.ceil(childrenList.length / fixedRowCount); + + return ( +
    + {childrenList.map((child, key) => ( +
    {child}
    + ))} +
    + ); +} + +GridList.displayName = 'GridList'; + +export default GridList; diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.module.scss b/anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.module.scss new file mode 100644 index 0000000..cd37237 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"TextFieldWrapper":"Autocomplete_TextFieldWrapper___1jqr","InputRoot":"Autocomplete_InputRoot__eWGIS","InputRoot___view":"Autocomplete_InputRoot___view__RGZ1M","InputRoot___readOnly":"Autocomplete_InputRoot___readOnly__7uKr4","TextFieldInputWrapper":"Autocomplete_TextFieldInputWrapper__JRyty","Input":"Autocomplete_Input__aDMg1","EndAdornment":"Autocomplete_EndAdornment__7z_G1","ActionButton":"Autocomplete_ActionButton__TDutC"}; \ No newline at end of file diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.tsx b/anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000..6a7a640 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/Autocomplete/Autocomplete.tsx @@ -0,0 +1,118 @@ +import React, { ComponentProps, CSSProperties, useEffect, useState } from 'react'; +import classNames from 'clsx'; +import type { TypographyVariant } from '@mui/material'; + +import { SIZE_MEDIUM } from '@/mui/constants'; + +import { getInputStyleCssVariables, useInputStyles } from '../helpers'; + +import Autocomplete from '../../../Autocomplete/Autocomplete'; +import TextField from '../../../TextField/TextField'; + +import styles from './Autocomplete.module.scss'; + +type Props = ComponentProps & { + readOnly?: boolean; + editable?: boolean; + placeholder?: string; + variant: TypographyVariant; + fontWeight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + error?: boolean; + helperText?: string; +}; + +function InlineEditAutocomplete({ + disabled, + readOnly, + editable, + placeholder, + variant, + fontWeight, + error, + helperText, + options, + optionLabelKey, + optionValueKey, + onOpen, + onClose, + onInputChange, + ...props +}: Props) { + const canBeEdit = !disabled && !readOnly; + const [stateEditable, setEditState] = useState(editable && canBeEdit); + const [symbols, setSymbols] = useState(placeholder?.length || 0); + const { fontSize, fontWeight: stateFontWeight, lineHeight, letterSpacing } = useInputStyles(variant, fontWeight); + + useEffect(() => setEditState(editable && canBeEdit), [disabled, editable, readOnly]); + + const style = { + ...getInputStyleCssVariables(fontSize, stateFontWeight, lineHeight, letterSpacing), + '--internal-min-symbols': !stateEditable ? `${symbols}ch` : 'auto', + } as CSSProperties; + + return ( + { + if (canBeEdit) { + if (onOpen) { + onOpen(...args); + } + setEditState(true); + } + }} + onClose={(...args) => { + if (onClose) { + onClose(...args); + } + setEditState(false); + }} + onInputChange={(event, newValue, reason) => { + setSymbols(newValue.length || placeholder?.length || 0); + if (onInputChange) { + onInputChange(event, newValue, reason); + } + }} + renderInput={(params) => ( + + )} + /> + ); +} + +export default InlineEditAutocomplete; diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DatePicker.tsx b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DatePicker.tsx new file mode 100644 index 0000000..9691d8d --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DatePicker.tsx @@ -0,0 +1,131 @@ +import React, { ComponentProps, CSSProperties, useEffect, useState } from 'react'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import type { InputBaseComponentProps, TypographyVariant } from '@mui/material'; +import { InsertInvitationRounded } from '@mui/icons-material'; + +import { SIZE_MEDIUM } from '@/mui/constants'; + +import { getInputStyleCssVariables, useInputStyles } from '../helpers'; +import { useInputWidth } from './helpers'; + +import DatePicker from '../../../DatePicker/DatePicker'; +import IconButton from '../../../IconButton/IconButton'; +import InputAdornment from '../../../InputAdornment/InputAdornment'; + +import styles from './DateTimePicker.module.scss'; + +type Props = ComponentProps & { + variant?: TypographyVariant; + format?: string; + fontWeight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + editable?: boolean; + readOnly?: boolean; +}; + +function InlineEditDatePicker({ + variant = 'body2', + format = 'MMM D, YYYY', + fontWeight: initialFontWeight, + editable: initialEditable, + readOnly: initialReadOnly, + disabled, + value, + onChange, + ...props +}: Props) { + const canBeEdit = !disabled && !initialReadOnly; + const [stateValue, setValue] = useState(value); + const [editable, setEditState] = useState(!!initialEditable && canBeEdit); + + useEffect(() => setValue(value), [value]); + + useEffect(() => setEditState(!!initialEditable && canBeEdit), [initialEditable, canBeEdit]); + + const { fontSize, fontWeight, lineHeight, letterSpacing } = useInputStyles(variant, initialFontWeight); + + const inputWidth = useInputWidth( + stateValue ? dayjs(stateValue).format(format) : format, + fontSize, + fontWeight as number, + ); + + const inputProps: InputBaseComponentProps = { + placeholder: format, + readOnly: !value || initialReadOnly, + }; + + if (!stateValue) { + inputProps.value = ''; + } + + return ( + { + setValue(value$); + + if (onChange) { + onChange(value$, context); + } + }} + onClose={() => { + setEditState(false); + }} + slotProps={{ + textField: { + variant: 'filled', + size: SIZE_MEDIUM, + fullWidth: false, + classes: { + root: styles.TextFieldWrapper, + }, + style: { + ...getInputStyleCssVariables(fontSize, fontWeight, lineHeight, letterSpacing), + '--internal-input-width': inputWidth, + } as CSSProperties, + InputProps: { + onClick: () => { + if (canBeEdit) { + setEditState(true); + } + }, + classes: { + root: classNames(styles.InputRoot, { + [styles.InputRoot___readOnly]: !canBeEdit, + [styles.InputRoot___view]: !editable, + }), + input: styles.Input, + }, + startAdornment: ( + + + + + + ), + endAdornment: ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + ), + }, + inputProps, + }, + }} + /> + ); +} + +export default InlineEditDatePicker; diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.module.scss b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.module.scss new file mode 100644 index 0000000..fdd0a41 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"TextFieldWrapper":"DateTimePicker_TextFieldWrapper__uB5oC","InputRoot":"DateTimePicker_InputRoot__tneMD","InputRoot___view":"DateTimePicker_InputRoot___view__rFT61","InputRoot___readOnly":"DateTimePicker_InputRoot___readOnly__WKOeo","IconWrapper":"DateTimePicker_IconWrapper__VSGko","IconWrapper___readOnly":"DateTimePicker_IconWrapper___readOnly__fvZVJ","InputAdornment":"DateTimePicker_InputAdornment__XLfpZ","Input":"DateTimePicker_Input__ef9vv"}; \ No newline at end of file diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.tsx b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.tsx new file mode 100644 index 0000000..f21c307 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/DateTimePicker.tsx @@ -0,0 +1,131 @@ +import React, { ComponentProps, CSSProperties, useEffect, useState } from 'react'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import type { InputBaseComponentProps, TypographyVariant } from '@mui/material'; +import { InsertInvitationRounded } from '@mui/icons-material'; + +import { SIZE_MEDIUM } from '@/mui/constants'; + +import { getInputStyleCssVariables, useInputStyles } from '../helpers'; +import { useInputWidth } from './helpers'; + +import DateTimePicker from '../../../DateTimePicker/DateTimePicker'; +import IconButton from '../../../IconButton/IconButton'; +import InputAdornment from '../../../InputAdornment/InputAdornment'; + +import styles from './DateTimePicker.module.scss'; + +type Props = ComponentProps & { + variant?: TypographyVariant; + format?: string; + fontWeight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + editable?: boolean; + readOnly?: boolean; +}; + +function InlineEditDateTimePicker({ + variant = 'body2', + format = 'MMM D, YYYY hh:mm A', + fontWeight: initialFontWeight, + editable: initialEditable, + readOnly: initialReadOnly, + disabled, + value, + onChange, + ...props +}: Props) { + const canBeEdit = !disabled && !initialReadOnly; + const [stateValue, setValue] = useState(value); + const [editable, setEditState] = useState(!!initialEditable && canBeEdit); + + useEffect(() => setValue(value), [value]); + + useEffect(() => setEditState(!!initialEditable && canBeEdit), [initialEditable, canBeEdit]); + + const { fontSize, fontWeight, lineHeight, letterSpacing } = useInputStyles(variant, initialFontWeight); + + const inputWidth = useInputWidth( + stateValue ? dayjs(stateValue).format(format) : format, + fontSize, + fontWeight as number, + ); + + const inputProps: InputBaseComponentProps = { + placeholder: format, + readOnly: true, + }; + + if (!stateValue) { + inputProps.value = ''; + } + + return ( + { + setValue(value$); + + if (onChange) { + onChange(value$, context); + } + }} + onClose={() => { + setEditState(false); + }} + slotProps={{ + textField: { + variant: 'filled', + size: SIZE_MEDIUM, + fullWidth: false, + classes: { + root: styles.TextFieldWrapper, + }, + style: { + ...getInputStyleCssVariables(fontSize, fontWeight, lineHeight, letterSpacing), + '--internal-input-width': inputWidth, + } as CSSProperties, + InputProps: { + onClick: () => { + if (canBeEdit) { + setEditState(true); + } + }, + classes: { + root: classNames(styles.InputRoot, { + [styles.InputRoot___readOnly]: !canBeEdit, + [styles.InputRoot___view]: !editable, + }), + input: styles.Input, + }, + startAdornment: ( + + + + + + ), + endAdornment: ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + ), + }, + inputProps, + }, + }} + /> + ); +} + +export default InlineEditDateTimePicker; diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/TimePicker.tsx b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/TimePicker.tsx new file mode 100644 index 0000000..49c6a14 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/TimePicker.tsx @@ -0,0 +1,131 @@ +import React, { ComponentProps, CSSProperties, useEffect, useState } from 'react'; +import classNames from 'clsx'; +import dayjs from 'dayjs'; +import type { InputBaseComponentProps, TypographyVariant } from '@mui/material'; +import { AccessTimeRounded } from '@mui/icons-material'; + +import { SIZE_MEDIUM } from '@/mui/constants'; + +import { getInputStyleCssVariables, useInputStyles } from '../helpers'; +import { useInputWidth } from './helpers'; + +import IconButton from '../../../IconButton/IconButton'; +import InputAdornment from '../../../InputAdornment/InputAdornment'; +import TimePicker from '../../../TimePicker/TimePicker'; + +import styles from './DateTimePicker.module.scss'; + +type Props = ComponentProps & { + variant?: TypographyVariant; + format?: string; + fontWeight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + editable?: boolean; + readOnly?: boolean; +}; + +function InlineEditTimePicker({ + variant = 'body2', + format = 'hh:mm A', + fontWeight: initialFontWeight, + editable: initialEditable, + readOnly: initialReadOnly, + disabled, + value, + onChange, + ...props +}: Props) { + const canBeEdit = !disabled && !initialReadOnly; + const [stateValue, setValue] = useState(value); + const [editable, setEditState] = useState(!!initialEditable && canBeEdit); + + useEffect(() => setValue(value), [value]); + + useEffect(() => setEditState(!!initialEditable && canBeEdit), [initialEditable, canBeEdit]); + + const { fontSize, fontWeight, lineHeight, letterSpacing } = useInputStyles(variant, initialFontWeight); + + const inputWidth = useInputWidth( + stateValue ? dayjs(stateValue).format(format) : format, + fontSize, + fontWeight as number, + ); + + const inputProps: InputBaseComponentProps = { + placeholder: format, + readOnly: !value || initialReadOnly, + }; + + if (!value) { + inputProps.value = ''; + } + + return ( + { + setValue(value$); + + if (onChange) { + onChange(value$, context); + } + }} + onClose={() => { + setEditState(false); + }} + slotProps={{ + textField: { + variant: 'filled', + size: SIZE_MEDIUM, + fullWidth: false, + classes: { + root: styles.TextFieldWrapper, + }, + style: { + ...getInputStyleCssVariables(fontSize, fontWeight, lineHeight, letterSpacing), + '--internal-input-width': inputWidth, + } as CSSProperties, + InputProps: { + onClick: () => { + if (canBeEdit) { + setEditState(true); + } + }, + classes: { + root: classNames(styles.InputRoot, { + [styles.InputRoot___readOnly]: !canBeEdit, + [styles.InputRoot___view]: !editable, + }), + input: styles.Input, + }, + startAdornment: ( + + + + + + ), + endAdornment: ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + ), + }, + inputProps, + }, + }} + /> + ); +} + +export default InlineEditTimePicker; diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/helpers/index.ts b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/helpers/index.ts new file mode 100644 index 0000000..e02d275 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/DateTime/helpers/index.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; + +export const useInputWidth = (format: string, fontSize: number, fontWeight: number) => + useMemo(() => { + if (typeof window !== 'undefined' && !CSS.supports('field-sizing', 'content')) { + // Create a temporary span element to calculate the width of the content + const input = document.createElement('input'); + input.value = format.trim(); + + input.style.boxSizing = 'border-box'; + input.style.width = '0'; + input.style.borderStyle = 'none'; + input.style.fontSize = `${fontSize}px`; + input.style.fontWeight = `${fontWeight}`; + input.style.letterSpacing = '0'; + input.style.padding = '0'; + input.style.position = 'fixed'; + input.style.top = '0'; + input.style.left = '0'; + input.style.zIndex = '-9999999'; + input.style.visibility = 'hidden'; + input.style.opacity = '0'; + + document.body.appendChild(input); + const width = input.scrollWidth; + document.body.removeChild(input); + + return `${width}px`; + } + + return 'auto'; + }, [format]); diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.module.scss b/anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.module.scss new file mode 100644 index 0000000..f746b58 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Typography":"TextField_Typography__0Wt5G","Typography___multiline":"TextField_Typography___multiline__6_cod","Typography___edit":"TextField_Typography___edit__sV_xQ","Scrollable":"TextField_Scrollable__XcuTB","TextFieldWrapper":"TextField_TextFieldWrapper__1lIEm","InputRoot":"TextField_InputRoot__VPkxc","Input":"TextField_Input__nEUnG","HelperText":"TextField_HelperText__kblTx"}; \ No newline at end of file diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.tsx b/anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.tsx new file mode 100644 index 0000000..3a4e30a --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/TextField/TextField.tsx @@ -0,0 +1,181 @@ +import React, { ComponentProps, CSSProperties, useEffect, useRef, useState } from 'react'; +import classNames from 'clsx'; +import type { TypographyVariant } from '@mui/material'; + +import { getInputStyleCssVariables, useInputStyles } from '../helpers'; + +import TextField from '../../../TextField/TextField'; +import Typography from '../../../Typography/Typography'; + +import styles from './TextField.module.scss'; + +type Props = Omit, 'variant'> & { + value: string; + fontWeight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; // The font weight of the text + variant?: TypographyVariant; // The variant of the text style, e.g., 'body1', 'body2' + error: boolean; // Error flag, indicates the error state of the text field + minRows: number; // The minimum number of rows in the text field + rows: number; // The number of rows in the text field + editable: boolean; // Flag indicating if the text field can be edited + disabled: boolean; // Flag indicating if the text field is disabled + readOnly: boolean; // Flag indicating if the text field is read-only + onCancel: (event: React.KeyboardEvent) => void; // Function called when editing is canceled + onApply: ( + event: React.FocusEvent | React.KeyboardEvent, + trimmedValue: string, + ) => void; // Function called when changes are applied + placeholder: string; // Text displayed in the text field when it is empty + helperText: string; // Helper text displayed below the text field + maxLength: number; // Maximum length of the input text + textPlaceholder: string; // Text displayed when there is no value in the text field +}; + +function InlineEditTextField({ + variant = 'body2', + rows, + minRows, + maxLength, + disabled, + readOnly, + value, + editable, + fontWeight, + placeholder, + error, + helperText, + textPlaceholder, + onApply, + onCancel, + ...props +}: Props) { + const multiline = rows !== 1; + const canBeEdit = !disabled && !readOnly; + const inputRef = useRef(null); + const focusPosition = React.useRef(0); + const [stateValue, setValue] = useState(value); + const [stateEditable, setEditState] = useState(editable && canBeEdit); + + const { fontSize, fontWeight: stateFontWeight, lineHeight, letterSpacing } = useInputStyles(variant, fontWeight); + + useEffect(() => setValue(value), [value]); + + useEffect(() => setEditState(editable && canBeEdit), [disabled, editable, readOnly]); + + useEffect(() => { + if (stateEditable && inputRef.current) { + inputRef.current.focus(); + inputRef.current.setSelectionRange(focusPosition.current, focusPosition.current); + } + }, [stateEditable]); + + const style = { + ...getInputStyleCssVariables(fontSize, stateFontWeight, lineHeight, letterSpacing), + '--internal-min-rows': `${(minRows || 0) * (fontSize * lineHeight)}px`, + } as CSSProperties; + + const stateOnApply = ( + event: React.FocusEvent | React.KeyboardEvent, + ) => { + const trimmedValue = stateValue.trim(); + onApply(event, trimmedValue); + setEditState(false); + setValue(trimmedValue); + focusPosition.current = 0; + }; + + let textColor = 'text.primary'; + + if (disabled) { + textColor = 'text.disabled'; + } else if (!stateValue) { + textColor = 'text.secondary'; + } + + return error || stateEditable ? ( + setValue(target.value)} + onBlur={stateOnApply} + onKeyDown={(event) => { + if (event.code === 'Escape') { + if (onCancel) { + onCancel(event); + } + setEditState(false); + setValue(value); + focusPosition.current = 0; + } else if (event.key === 'Enter') { + if (!multiline || (multiline && !event.shiftKey)) { + event.preventDefault(); + stateOnApply(event); + } + } + }} + classes={{ + root: styles.TextFieldWrapper, + }} + InputProps={{ + classes: { + root: styles.InputRoot, + input: styles.Input, + }, + style, + }} + FormHelperTextProps={{ + classes: { + root: styles.HelperText, + }, + }} + inputProps={{ + maxLength, + }} + /> + ) : ( + { + const selection = window.getSelection(); + + if (selection?.type !== 'Range') { + focusPosition.current = selection?.focusOffset ?? 0; + setEditState(true); + } + } + : undefined + } + style={style} + > + 1 ? rows : undefined} + noWrap={rows === 1} + className={rows <= 0 ? styles.Scrollable : undefined} + > + {stateValue || textPlaceholder || '\u00A0'} + + + ); +} + +export default InlineEditTextField; diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/constants/index.ts b/anyclip/src/mui/components/@extendedComponents/InlineEdit/constants/index.ts new file mode 100644 index 0000000..3f4c0b7 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/constants/index.ts @@ -0,0 +1 @@ +export const staticLineHeight = 1.5; diff --git a/anyclip/src/mui/components/@extendedComponents/InlineEdit/helpers/index.ts b/anyclip/src/mui/components/@extendedComponents/InlineEdit/helpers/index.ts new file mode 100644 index 0000000..9b8e912 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/InlineEdit/helpers/index.ts @@ -0,0 +1,29 @@ +import type { TypographyVariant } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; + +import { staticLineHeight } from '../constants'; + +export const useInputStyles = (variant: TypographyVariant, fontWeight: string | number) => { + const theme = useTheme(); + + const targetFont = theme.typography[variant]; + + return { + fontSize: parseFloat(targetFont.fontSize as string), + lineHeight: staticLineHeight, + letterSpacing: 0, + fontWeight: fontWeight ?? targetFont.fontWeight, + }; +}; + +export const getInputStyleCssVariables = ( + fontSize: number, + fontWeight: number | string, + lineHeight: number, + letterSpacing: number, +) => ({ + '--internal-font-size': `${fontSize}px`, + '--internal-line-height': lineHeight, + '--internal-letter-spacing': `${letterSpacing}px`, + '--internal-font-weight': fontWeight, +}); diff --git a/anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.module.scss b/anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.module.scss new file mode 100644 index 0000000..706001c --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.module.scss @@ -0,0 +1,2 @@ +// extracted by mini-css-extract-plugin +module.exports = {"Wrapper":"JSONEditor_Wrapper__k_4_b","InnerWrapper":"JSONEditor_InnerWrapper__LyidN","InnerWrapper___error":"JSONEditor_InnerWrapper___error___H5MB","FormattingWrapper":"JSONEditor_FormattingWrapper__9_BFv","Formatting":"JSONEditor_Formatting__VWOUI","ContentWrapper":"JSONEditor_ContentWrapper__s14hU","Content":"JSONEditor_Content__zumAA","Lines":"JSONEditor_Lines__JXcbI","CodeEditor":"JSONEditor_CodeEditor__VSQ_0","Code":"JSONEditor_Code___L5hI","Code___syntax":"JSONEditor_Code___syntax__i_Q6G"}; \ No newline at end of file diff --git a/anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.tsx b/anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.tsx new file mode 100644 index 0000000..0c90d20 --- /dev/null +++ b/anyclip/src/mui/components/@extendedComponents/JSONEditor/JSONEditor.tsx @@ -0,0 +1,450 @@ +import React, { + CSSProperties, + forwardRef, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from 'react'; +import classNames from 'clsx'; +import Prism from 'prismjs'; +import { CodeRounded } from '@mui/icons-material'; + +import './helpers/prismJson'; +import { useDebouncedCallback } from './helpers'; +import { useCombinedRefs } from '@/mui/helpers'; + +import { Button, FormHelperText, Tooltip } from '@/mui/components'; + +import styles from './JSONEditor.module.scss'; + +type UndoState = { + value: string; + selectionStart: number; + selectionEnd: number; +}; + +type Props = { + value?: string; + disabled?: boolean; + error?: boolean; + minRows?: number; + maxRows?: number; + fieldName?: string; + helperText?: string | React.ReactNode; + onChange: (value: string) => void; + onFocus?: (event: React.FocusEvent) => void; +}; + +const MAX_UNDO = 40; +const TAB_SIZE = 2; +const fontSize = 14; +const lineHeight = 1.5; + +const isMac = navigator.platform.toUpperCase().includes('MAC'); + +const JSONEditor = forwardRef( + ( + { + value = '', + disabled = false, + fieldName = '', + helperText = '', + error, + minRows = 5, + maxRows, + onChange, + onFocus, + }: Props, + ref, + ) => { + const wrapperRef = useRef(null); + const wrapperScroll = useRef(0); + const textareaRef = useRef(null); + const undoStack = useRef([]); + + const [innerValue, setInnerValue] = useState(value || ''); + const rows = useMemo(() => innerValue.split('\n').map((_, i) => i + 1), [innerValue]); + const [highlightedCode, setHighlightedCode] = useState(() => + Prism.highlight(innerValue, Prism.languages.json, 'json'), + ); + const [, startTransition] = useTransition(); + + const setUndoStack = ({ value: value$, selectionStart, selectionEnd }: UndoState) => { + if (undoStack.current.length >= MAX_UNDO) { + undoStack.current.shift(); + } + undoStack.current.push({ value: value$, selectionStart, selectionEnd }); + }; + + const setCaretPosition = (start: number, end: number) => { + const textarea$ = textareaRef.current; + + if (!textarea$) { + return; + } + textarea$.selectionStart = start; + textarea$.selectionEnd = end; + }; + + const handleScrollToCaret = () => { + const textarea$ = textareaRef.current; + const wrapper$ = wrapperRef.current; + + if (!textarea$ || !wrapper$) return; + + const { selectionStart } = textarea$; + const lines = innerValue.slice(0, selectionStart).split('\n'); + const lineIndex = lines.length - 1; + + const lineHeightPx = fontSize * lineHeight; + const caretY = lineIndex * lineHeightPx; + + if (caretY < wrapper$.scrollTop) { + wrapper$.scrollTop = caretY; + } else if (caretY > wrapper$.scrollTop + wrapper$.clientHeight - lineHeightPx) { + wrapper$.scrollTop = caretY - wrapper$.clientHeight + lineHeightPx; + } + }; + + useEffect(() => setInnerValue(value || ''), [value]); + + const debouncedOnChange = useDebouncedCallback((val: string) => { + if (onChange) { + onChange(val); + } + }, 300); + + useEffect(() => { + startTransition(() => setHighlightedCode(Prism.highlight(innerValue, Prism.languages.json, 'json'))); + + debouncedOnChange(innerValue); + }, [innerValue]); + + useEffect(() => { + window.requestAnimationFrame(handleScrollToCaret); + }, [innerValue]); + + const handleKeyDown = (e: KeyboardEvent) => { + const textarea$ = e.currentTarget; + const { selectionStart, selectionEnd, value: value$ } = textarea$; + + const isCtrl = isMac ? e.metaKey : e.ctrlKey; + + if (isCtrl && e.key === 'c') { + return; + } + + if (isCtrl && e.key === 'z') { + e.preventDefault(); + if (undoStack.current.length > 0) { + const previousState = { ...undoStack.current.pop()! }; + + setInnerValue(previousState.value); + + window.requestAnimationFrame(() => { + setCaretPosition(previousState.selectionStart, previousState.selectionEnd); + }); + } + return; + } + + if ( + !e.ctrlKey && + !e.metaKey && + !(e.key === 'Backspace' && selectionStart <= 0 && selectionStart === selectionEnd) + ) { + setUndoStack({ value: innerValue, selectionStart, selectionEnd }); + } + + if (isCtrl && e.key === 'ArrowLeft') { + const lineStart = value$.lastIndexOf('\n', selectionStart - 1) + 1; + const symbolPos = value$.slice(lineStart, selectionStart).match(/^ +/)?.[0]?.length || 0; + + const absoluteCursorPos = lineStart + symbolPos; + + window.requestAnimationFrame(() => { + setCaretPosition(absoluteCursorPos, e.shiftKey ? selectionEnd : absoluteCursorPos); + }); + } + + if (e.key === 'Tab') { + e.preventDefault(); + + const before = value$.slice(0, selectionStart); + const after = value$.slice(selectionEnd); + const tabInsert = ' '.repeat(TAB_SIZE); + + setInnerValue(before + tabInsert + after); + + window.requestAnimationFrame(() => { + setCaretPosition(selectionStart + tabInsert.length, selectionStart + tabInsert.length); + }); + } + + if (e.key === 'Backspace') { + e.preventDefault(); + + const before = value$.slice(0, selectionStart); + const after = value$.slice(selectionEnd); + let newValue; + let newSelectionStart = selectionStart; + + if (selectionStart !== selectionEnd) { + newValue = before + after; + } else if (selectionStart > 0) { + const lastNewline = before.lastIndexOf('\n'); + const beforeLine = before.slice(lastNewline + 1); + + if (/^\s*$/.test(beforeLine)) { + newValue = before.slice(0, lastNewline) + after; + newSelectionStart = lastNewline > 0 ? lastNewline : 0; + } else if (before.endsWith(' ')) { + newValue = before.slice(0, -2) + after; + newSelectionStart -= 2; + } else { + const prevChar = before.slice(-1); + const nextChar = after.slice(0, 1); + + const pairMap: Record = { + '{': '}', + '[': ']', + }; + + if (pairMap[prevChar] === nextChar) { + newValue = before.slice(0, -1) + after.slice(1); + newSelectionStart -= 1; + } else { + newValue = before.slice(0, -1) + after; + newSelectionStart -= 1; + } + } + } else { + return; + } + + if (/^\s*$/.test(newValue)) { + newValue = ''; + newSelectionStart = 0; + } + + setInnerValue(newValue); + + window.requestAnimationFrame(() => { + setCaretPosition(newSelectionStart, newSelectionStart); + }); + } + + if (e.key === 'Enter') { + e.preventDefault(); + + const before = value$.slice(0, selectionStart); + const after = value$.slice(selectionEnd); + const lineStart = before.lastIndexOf('\n') + 1; + const leadingSpaces = before.slice(lineStart).match(/^\s*/)?.[0] || ''; + const prevChar = before.replace(/ +$/, ''); + const nextChar = after.replace(/^ +/, ''); + + let newLine = `\n${leadingSpaces}`; + let caretPos = 0; + + if (prevChar.endsWith('{') || prevChar.endsWith('[')) { + if (before.split('\n').pop()?.trim()) { + newLine += ' '.repeat(TAB_SIZE); + } + + if (nextChar.startsWith('}') || nextChar.startsWith(']')) { + caretPos = newLine.length; + newLine += `\n${leadingSpaces}`; + } else { + caretPos = newLine.length; + } + } else { + caretPos = newLine.length; + } + + setInnerValue(before + newLine + after); + + window.requestAnimationFrame(() => { + setCaretPosition(selectionStart + caretPos, selectionStart + caretPos); + }); + } + + if (['{', '['].includes(e.key)) { + e.preventDefault(); + + const closingChar = e.key === '{' ? '}' : ']'; + + if (selectionStart !== selectionEnd) { + const selectedText = value$.slice(selectionStart, selectionEnd); + + setInnerValue( + value$.slice(0, selectionStart) + e.key + selectedText + closingChar + value$.slice(selectionEnd), + ); + + window.requestAnimationFrame(() => { + setCaretPosition(selectionStart + 1, selectionEnd + 1 + selectedText.length); + }); + } else { + setInnerValue(value$.slice(0, selectionStart) + e.key + closingChar + value$.slice(selectionEnd)); + + window.requestAnimationFrame(() => { + setCaretPosition(selectionStart + 1, selectionEnd + 1); + }); + } + } + + if (e.key === '"') { + e.preventDefault(); + + const before = value$.slice(0, selectionStart); + const after = value$.slice(selectionEnd); + const selectedText = value$.slice(selectionStart, selectionEnd); + const prevChar = before.slice(-1); + const nextChar = after.slice(0, 1); + + let newValue; + if (selectionStart !== selectionEnd) { + newValue = `${before}"${selectedText}"${after}`; + } else if (prevChar !== '"' && nextChar !== '"') { + newValue = `${before}""${after}`; + } else { + newValue = `${before}"${after}`; + } + + setInnerValue(newValue); + + window.requestAnimationFrame(() => { + setCaretPosition(selectionStart + 1, selectionStart + 1); + }); + } + }; + + let isPasteEvent = false; + + const combinedRefs = useCombinedRefs(ref, textareaRef); + + return ( +
    +
    +
    +
    +
    {rows.join('\n')}
    +
    +