commit ad09e57c41489af817378e390e6c145ff1a03d30 Author: SPACEBROWSER_DEV Date: Thu Aug 7 20:28:59 2025 -0400 [FACTORY]: Init Commit diff --git a/component_factory/README.md b/component_factory/README.md new file mode 100755 index 0000000..e0efaac --- /dev/null +++ b/component_factory/README.md @@ -0,0 +1,28 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Purpose + +sandbox project to build the reactive components for the website + + + diff --git a/component_factory/next-env.d.ts b/component_factory/next-env.d.ts new file mode 100755 index 0000000..1b3be08 --- /dev/null +++ b/component_factory/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/component_factory/next.config.ts b/component_factory/next.config.ts new file mode 100755 index 0000000..e9ffa30 --- /dev/null +++ b/component_factory/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/component_factory/package.json b/component_factory/package.json new file mode 100755 index 0000000..49e60ff --- /dev/null +++ b/component_factory/package.json @@ -0,0 +1,29 @@ +{ + "name": "interfacemod_project", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "antd": "^5.26.7", + "formik": "^2.4.6", + "next": "15.4.5", + "react": "19.1.0", + "react-dom": "19.1.0", + "redux": "^5.0.1", + "sass": "^1.89.2", + "yup": "^1.6.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/component_factory/postcss.config.mjs b/component_factory/postcss.config.mjs new file mode 100755 index 0000000..c7bcb4b --- /dev/null +++ b/component_factory/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/component_factory/public/file.svg b/component_factory/public/file.svg new file mode 100755 index 0000000..004145c --- /dev/null +++ b/component_factory/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/component_factory/public/globe.svg b/component_factory/public/globe.svg new file mode 100755 index 0000000..567f17b --- /dev/null +++ b/component_factory/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/component_factory/public/next.svg b/component_factory/public/next.svg new file mode 100755 index 0000000..5174b28 --- /dev/null +++ b/component_factory/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/component_factory/public/vercel.svg b/component_factory/public/vercel.svg new file mode 100755 index 0000000..7705396 --- /dev/null +++ b/component_factory/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/component_factory/public/window.svg b/component_factory/public/window.svg new file mode 100755 index 0000000..b2b2a44 --- /dev/null +++ b/component_factory/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/component_factory/src/app/css/App.scss b/component_factory/src/app/css/App.scss new file mode 100644 index 0000000..a86dfb3 --- /dev/null +++ b/component_factory/src/app/css/App.scss @@ -0,0 +1,38 @@ +body, html { + font-family: "SFProDisplay", sans-serif !important; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f4f3f3; +} + +::-webkit-scrollbar-thumb { + background: #979797; + border-radius: 6px; +} + +::-webkit-scrollbar-thumb:hover { + background: #979797; +} + +::-webkit-scrollbar { + z-index: -1; + width: 8px; + height: 8px; +} + +@media only screen and (max-width: 768px) { + ::-webkit-scrollbar { + width: 0; + background: transparent; + } +} + +body, html { + font-family: "SFProDisplay", sans-serif !important; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f4f3f3; +} + diff --git a/component_factory/src/app/css/AvailabilityCalendar.scss b/component_factory/src/app/css/AvailabilityCalendar.scss new file mode 100644 index 0000000..1e1c29c --- /dev/null +++ b/component_factory/src/app/css/AvailabilityCalendar.scss @@ -0,0 +1,89 @@ +@import 'variables.scss'; + +.calendar-container { + margin: 0 calc((100% - 64px) / 7 / 2 * -1); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + } + + &__table { + width: 100%; + table-layout: fixed; + min-height: 350px; + + .month-slot { + height: 82px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background-color: $grey; + } + } + + & tbody { + min-height: 350px; + } + + & tbody > tr > td { + text-align: center; + cursor: default; + height: 50px; + } + + .day-content { + user-select: none; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + &__title { + width: 32px; + height: 32px; + border-radius: 50%; + position: relative; + + .ant-typography { + line-height: 17px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + &__dot { + position: absolute; + left: 50%; + bottom: -7px; + width: 4px; + height: 4px; + background-color: $blue; + border-radius: 50%; + transform: translate(-50%, -50%); + } + } + + .disabled-day { + .day-content__title { + opacity: 0.4; + } + } + + .selected-day { + .day-content__title { + background-color: $blue; + > span { + color: $white; + } + } + } + } +} diff --git a/component_factory/src/app/css/ImageUpload.scss b/component_factory/src/app/css/ImageUpload.scss new file mode 100644 index 0000000..3f868ea --- /dev/null +++ b/component_factory/src/app/css/ImageUpload.scss @@ -0,0 +1,151 @@ +@import './variables.scss'; + +.photo-upload-item { + width: 100%; + height: 252px; + margin-bottom: 24px; + + .ant-upload-picture-card-wrapper { + width: 100%; + height: 100%; + } + + .ant-upload.ant-upload-select-picture-card { + display: block; + width: 100%; + height: 100%; + position: relative; + margin: 0; + background-color: $white; + border: 1px dashed $gray4; + + .ant-upload { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 0; + height: 100%; + position: absolute; + top: 0; + width: 100%; + } + } + + .ant-upload--has-data { + .ant-upload { + border: none; + } + } + + .photo-preview-wrapper { + position: relative; + width: 100%; + height: 100%; + &__hover { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: $borderRadius; + background: rgba(255, 255, 255, 0.6); + } + img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: top; + border-radius: $borderRadius; + } + + .btn-delete { + position: absolute; + top: -12px; + right: -12px; + z-index: 99; + width: 24px; + height: 24px; + border: none; + background-color: $white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12) !important; + padding: 0; + + svg { + width: 24px; + height: 24px; + } + } + } +} + +@media only screen and (max-width: 768px) { + .photo-upload-item { + height: 163px; + + .profile-upload { + width: 163px !important; + + .ant-upload { + height: 163px !important; + } + } + } +} + + +//LINK EDITOR PHOTO PICTURE CARD +.link-editor-modal { + .ant-upload-select-picture-card { + display: block; + width: 100%; + height: 100%; + position: relative; + margin: 0; + background-color: #fff; + border: 1px dashed #636363; + border-radius: 8px; + + } + + .ant-upload.ant-upload-select-picture-card .ant-upload { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 0; + height: 100%; + position: absolute; + top: 0; + width: 100%; + } + + .ant-upload.ant-upload-select-picture-card .ant-upload { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 0; + height: 100%; + position: absolute; + top: 0; + width: 100%; + } + + .ant-upload.ant-upload-select-picture-card .ant-upload { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 0; + height: 100%; + position: absolute; + top: 0; + width: 100%; + } + + .ant-upload-picture-card-wrapper { + width: 100%; + height: 100%; + } +} \ No newline at end of file diff --git a/component_factory/src/app/css/LinkEditorModal.scss b/component_factory/src/app/css/LinkEditorModal.scss new file mode 100644 index 0000000..6a412f9 --- /dev/null +++ b/component_factory/src/app/css/LinkEditorModal.scss @@ -0,0 +1,68 @@ +@import 'variables.scss'; + +.link-editor { + &__cover { + width: 123px !important; + height: 123px !important; + margin-bottom: 20px !important; + } + + &__actions { + @include modalActions; + } + + &-modal { + .offer-form-item { + .ant-row { + margin-bottom: 0 !important; + } + } + + .ant-tabs { + &-nav-list { + width: 100%; + } + + &.is-edit { + .ant-tabs-ink-bar-animated { + width: 100% !important; + } + } + + &-tab { + padding: 0 0 8px; + border-bottom: 1px solid $gray12; + width: 100%; + justify-content: center; + margin: 0; + + span { + font-weight: bold; + font-size: 14px; + line-height: 20px; + text-align: center; + letter-spacing: 1px; + text-transform: uppercase; + color: $default; + + &.disabled { + color: $gray6; + cursor: not-allowed; + } + } + + &-active { + border-bottom: 1px solid $blue; + } + } + + &-ink-bar { + bottom: 0 !important; + background: $blue; + width: 50% !important; + } + } + } + + +} diff --git a/component_factory/src/app/css/ModuleOndemandVideo.scss b/component_factory/src/app/css/ModuleOndemandVideo.scss new file mode 100644 index 0000000..7ba54a7 --- /dev/null +++ b/component_factory/src/app/css/ModuleOndemandVideo.scss @@ -0,0 +1,54 @@ +@import 'variables.scss'; + +.ant-alert-info { + background: $grey; + border: none; +} + +.on-demand-list { + display: flex; + flex-direction: column; + user-select: none; + overflow: auto; + padding-bottom: 16px; + + &::-webkit-scrollbar:horizontal { + width: 5px !important; + height: 5px !important; + } + + &::-webkit-scrollbar-thumb { + background: $gray2; + border-radius: 16px; + } + + &::-webkit-scrollbar-track { + background: $white; + } + + &__container { + flex-grow: 1; + display: inline-flex; + } + + &__dropzone { + width: 100%; + min-height: 60px; + } + + &__item { + width: 100%; + transition: border-box 0.1s ease; + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + &--dragging { + .border-box { + box-shadow: $boxShadow2; + } + } + } +} diff --git a/component_factory/src/app/css/ModuleShopify.scss b/component_factory/src/app/css/ModuleShopify.scss new file mode 100644 index 0000000..8ca295f --- /dev/null +++ b/component_factory/src/app/css/ModuleShopify.scss @@ -0,0 +1,102 @@ +@import 'variables.scss'; + +.module-shopify__tooltip { + border-radius: 8px; + max-width: 367px; + padding-top: 2px; + + @include mobile { + max-width: 339px; + } + + &.ant-tooltip-placement-bottomLeft { + .ant-tooltip-arrow { + left: 6px; + } + } + + .ant-tooltip { + &-inner { + background: $white; + border: 1px solid $grey2; + border-radius: 8px; + padding: 10px; + font-size: $small; + color: $text; + margin-left: -28px; + + @include mobile { + margin-left: 0; + } + } + + &-arrow { + top: -10px; + + &-content { + box-shadow: none; + border: 1px solid $grey2; + border-bottom-width: 0; + width: 8px; + height: 8px; + } + } + } +} +.shopify-module__alert { + .ant-alert-message { + margin-bottom: 0; + } + .ant-alert-description { + font-size: $xl-small; + font-weight: 400; + line-height: 20px; + letter-spacing: -0.24px; + } +} + +.collection-list { + display: flex; + flex-direction: column; + user-select: none; + overflow: auto; + &::-webkit-scrollbar:horizontal { + width: 5px !important; + height: 5px !important; + } + + &::-webkit-scrollbar-thumb { + background: $gray2; + border-radius: 16px; + } + + &::-webkit-scrollbar-track { + background: $white; + } + + &__container { + flex-grow: 1; + display: inline-flex; + } + + &__dropzone { + width: 100%; + min-height: 60px; + } + + &__item { + margin-bottom: 20px; + transition: border-box 0.1s ease; + flex-shrink: 0; + + &:last-child { + margin-bottom: 0; + } + + &--dragging { + .border-box { + box-shadow: $boxShadow2; + } + } + } +} diff --git a/component_factory/src/app/css/ProductEditorModal.scss b/component_factory/src/app/css/ProductEditorModal.scss new file mode 100644 index 0000000..9f6a7ae --- /dev/null +++ b/component_factory/src/app/css/ProductEditorModal.scss @@ -0,0 +1,22 @@ +@import 'variables.scss'; + +.product-editor { + &-form { + .ant-form-item-control-input { + input, + textarea { + height: 52px !important; + } + } + } + + &__cover { + width: 123px !important; + height: 123px !important; + margin-bottom: 20px !important; + } + + &__actions { + @include modalActions; + } +} diff --git a/component_factory/src/app/css/base/button.scss b/component_factory/src/app/css/base/button.scss new file mode 100644 index 0000000..549bfe7 --- /dev/null +++ b/component_factory/src/app/css/base/button.scss @@ -0,0 +1,51 @@ +.link-editor-modal +{ + button.ant-btn { + height: 36px; + box-shadow: none !important; + text-shadow: none; + border-radius: 123px; + font-weight: 700; + font-size: 16px; + line-height: 24px; + border: 2px solid #e0e0e0; + letter-spacing: 1px; + } + + button.ant-btn-primary { + border: none; + color: #121212; + background: #121212; + font-weight: 700; + line-height: 24px; + border-radius: 123px; + text-transform: uppercase; + letter-spacing: 1px; + } + + button.ant-btn:focus, button.ant-btn:hover { + color: #121212 !important; + border: 2px solid #e0e0e0 !important; + background-color: #e0e0e0 !important; + } + + button.ant-btn-primary:focus, button.ant-btn-primary:hover { + border: none !important; + background: rgba(18, 18, 18, .8) !important; + } + + button.ant-btn.ant-btn-xl { + height: 52px; + border-radius: 123px; + min-width: 160px; + } + + button.ant-btn-uppercase>span { + text-transform: uppercase; + } + + button.ant-btn-primary .ant-typography, button.ant-btn-primary span { + color: #fff; + } + +} \ No newline at end of file diff --git a/component_factory/src/app/css/base/display.scss b/component_factory/src/app/css/base/display.scss new file mode 100644 index 0000000..ab97123 --- /dev/null +++ b/component_factory/src/app/css/base/display.scss @@ -0,0 +1,7 @@ +.link-editor-modal { + + .d--block { + display: block !important; + } + +} \ No newline at end of file diff --git a/component_factory/src/app/css/base/form.scss b/component_factory/src/app/css/base/form.scss new file mode 100644 index 0000000..3277348 --- /dev/null +++ b/component_factory/src/app/css/base/form.scss @@ -0,0 +1,36 @@ +.link-editor-modal { + + .ant-form-item { + font-weight: 500; + } + + .ant-form-item-control-input input, .ant-form-item-control-input textarea { + height: 52px; + padding-left: 14px; + font-weight: 600; + font-size: 16px; + line-height: 22px; + } + + .ant-input:placeholder-shown { + text-overflow: ellipsis; + } + + .m__b--20 { + margin-bottom: 20px !important; + } + + .m__t--8 { + margin-top: 8px !important; + } + + .opacity--60 { + opacity: .6; + } + + /**@media only screen and (max-width: 600px) { + .offer-form-item .ant-form-item { + margin-bottom: 0 !important; + } + }**/ +} \ No newline at end of file diff --git a/component_factory/src/app/css/base/modal.scss b/component_factory/src/app/css/base/modal.scss new file mode 100644 index 0000000..8165228 --- /dev/null +++ b/component_factory/src/app/css/base/modal.scss @@ -0,0 +1,41 @@ +.link-editor-modal +{ + + .ant-modal-header { + background-color: rgba(96, 229, 232, .02); + padding: 20px; + border-bottom: 1px solid #e0e0e0; + border-radius: 16px 16px 0 0; + margin-bottom: 0; + } + + .ant-modal-content { + border-radius: 16px; + padding: 0; + } + + .ant-modal-title { + line-height: 28px; + } + + .ant-modal-header .ant-modal-title { + text-align: left; + font-weight: 600; + font-size: 18px; + } + + .ant-modal-header .ant-modal-title { + font-size: 20px; + } + + + .ant-modal-body { + padding: 24px; + } + + + .ant-modal .ant-form-item { + flex-direction: column; + } +} + diff --git a/component_factory/src/app/css/base/spacing.scss b/component_factory/src/app/css/base/spacing.scss new file mode 100644 index 0000000..be6a597 --- /dev/null +++ b/component_factory/src/app/css/base/spacing.scss @@ -0,0 +1,32 @@ +.link-editor-modal +{ + + .m__t--24 { + margin-top: 24px; + } + + .m__y--32 { + margin-top: 32px !important; + margin-bottom: 32px !important; + } + + .m__b--4 { + margin-bottom: 4px !important; + } + + .p__y--8 { + padding-top: 8px !important; + padding-bottom: 8px !important; + } + + .p__x--8 { + padding-left: 8px !important; + padding-right: 8px !important; + } + + .m__t--16 { + margin-top: 16px !important; + } + +} + diff --git a/component_factory/src/app/css/base/tabs.scss b/component_factory/src/app/css/base/tabs.scss new file mode 100644 index 0000000..4d0f150 --- /dev/null +++ b/component_factory/src/app/css/base/tabs.scss @@ -0,0 +1,3 @@ +.link-editor-modal .ant-tabs-tab, .ant-tabs-tab:hover { + color: #121212; +} \ No newline at end of file diff --git a/component_factory/src/app/css/base/text.scss b/component_factory/src/app/css/base/text.scss new file mode 100644 index 0000000..f4e7647 --- /dev/null +++ b/component_factory/src/app/css/base/text.scss @@ -0,0 +1,36 @@ +.link-editor-modal { + .text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: #121212; + } + + .text__align--center { + text-align: center !important; + } + + .text--semibold18.ant-typography { + font-weight: 600 !important; + font-size: 18px !important; + line-height: 28px; + } + + .text--regular14.ant-typography { + font-weight: 400 !important; + font-size: 14px !important; + line-height: 20px; + } + + .text--gray4, .text--gray4.ant-typography, .text--grey4, .text--grey4.ant-typography { + color: #636363 !important; + } + + .leading-22 { + line-height: 22px !important; + } + + .text--tundola, .text--tundola.ant-typography { + color: #414141 !important; + } +} diff --git a/component_factory/src/app/css/onboardingFlow.scss b/component_factory/src/app/css/onboardingFlow.scss new file mode 100644 index 0000000..f7e07ad --- /dev/null +++ b/component_factory/src/app/css/onboardingFlow.scss @@ -0,0 +1,23 @@ +:root { + --ktc-color-background-brandbar-default: #000000 !important; + --ktc-color-background-button-medium-primary-default: #323134 !important; + --ktc-color-background-button-medium-primary-hover: #47474b !important; + --ktc-color-background-button-medium-primary-active: #5d5d62 !important; + --ktc-color-background-button-medium-primary-disabled: #a6a5aa !important; + --ktc-color-background-progress-tracker-default: #f1f1f2 !important; + --ktc-color-background-progress-tracker-track: #d5d5d7 !important; + --ktc-color-text-progress-tracker-step-label: #8a898f !important; + --ktc-color-icon-progress-tracker-step-icon-future: #d5d5d7 !important; + --ktc-color-border-progress-tracker-step-icon-present: #8a898f !important; + --ktc-color-background-slider-track: #d5d5d7 !important; + --ktc-color-border-slider-knob-default: #747279 !important; + --ktc-color-border-slider-knob-hover: #8a898f !important; + --ktc-color-border-slider-knob-active: #9d9ca1 !important; + --ktc-color-border-chip-filter-selected-default: #747279 !important; + --ktc-color-background-chip-filter-selected-default: #f1f1f2 !important; + --ktc-color-background-chip-filter-selected-hover: #d5d5d7 !important; + --ktc-color-background-chip-filter-selected-active: #c2c2c5 !important; + --kts-color-border-outline-default: #d5d5d7 !important; + --kts-color-border-card-default: #d5d5d7 !important; + --ktc-color-text-card-default: #323134 !important; +} diff --git a/component_factory/src/app/css/variables.scss b/component_factory/src/app/css/variables.scss new file mode 100644 index 0000000..89865d8 --- /dev/null +++ b/component_factory/src/app/css/variables.scss @@ -0,0 +1,190 @@ +$default: #121212; +$dark: #121212; +$darkSidebar: #1e1f20; +$primary: #60e5e8; +$blue: #257ffc; +$blue2: #5e64ff; +$pink: #ff07c9; +$white: #ffffff; +$border: #e9e9eb; +$border2: #e5e5e5; +$black: #000000; +$black2: #121727; +$gray: #e6e7e9; +$gray2: #f9f9f9; +$gray3: #e4e4e4; +$grey: #f4f3f3; +$grey2: #e6e6e6; +$grey3: #e0e0e0; +$grey4: #636363; +$gray2: #e6e6e6; +$gray3: #e0e0e0; +$gray4: #636363; +$gray5: #f5f5f5; +$gray6: #cdcdcd; +$gray7: #e6e6e6; +$gray8: #d3d3d3; +$gray9: #e9e9eb; +$gray10: #f4f3f3; +$gray11: #f1f1f1; +$gray12: #f3f3f3; +$red: #ff3c45; +$secondaryRed: #fc0d1b; +$inputBorder: #e4e4ed; +$checked: linear-gradient(360deg, #76ba18 2.22%, #76ba18 100%); +$button: linear-gradient( + 270deg, + #17e391 21.47%, + #58e5df 77.04%, + #60e5e8 77.05% +); +$checkedBorder: #76ba18; +$blueDodger: #5e64ff; +$lavender: #cd5ce0; +$yellow: #ffba00; +$green: #76ba18; +$violet: #ec8efc; +$magenta: #cd5ce0; +$anakiwa: #8feaff; +$codGray: #141414; +$darkGray: #1a1a1a; +$orange: #ffa41c; +$tundola: #414141; +$mineShaft: #292929; +$text: #121212; +$borderRadius: 16px; +$warning: #ffa41c; +$success: #76ba18; +$alto: #e0e0e0; +$lightGreen: #50c32c; +$secondaryGrey: #c4c4c4; +$dusty_gray: #979797; +$serenade: #fff6e8; +$shark: #1e1f20; +$error: #ff3c45; +$grey5: #e0e0e0; +$mercury: #e6e6e6; + +$successBackground: #f1f8e8; + +$xx-small: 10px; +$x-small: 12px; +$small: 14px; +$xl-small: 15px; +$medium1: 16px; +$medium2: 18px; +$normal: 20px; +$normal1: 22px; +$normal2: 24px; +$large: 32px; +$larger: 36px; +$x-large: 40px; +$xx-large: 56px; +$xxl-large: 72px; + +$regular: 400; +$medium: 500; +$semi-bold: 600; +$bold: 700; +$extra-bold: 800; + +$container: 616px; + +$mobile-width: 480px; +$tablet-width: 768px; +$desktop-width: 1024px; +$small-height: 650px; +$videoCallHeight: calc((50vw - 64px) * 720 / 1280); +$fontGotham: 'Gotham Pro', sans-serif; +$fontSFProDisplay: 'SFProDisplay', sans-serif; + +$boxShadow: + rgba(60, 64, 67, 0.3) 0 1px 2px 0, + rgba(60, 64, 67, 0.15) 0 1px 3px 1px; + +$boxShadow2: 0 0 10px rgba(0, 0, 0, 0.25); +$boxShadow3: 0 0 15px rgba(0, 0, 0, 0.15); + +$icons-url: 'https://komi-assets.s3.amazonaws.com/icons'; + +// KDS Colors +$color-blue-100: #bbe1ff; +$color-blue-800: #0f69e6; +$color-accent-blue: $color-blue-800; +$color-color-turquoise-200: #60e5e8; +$color-overlay-turquoise-10: #60e5e81a; +$color-overlay-turquoise-20: #60e5e833; + +// :root { +// --ant-primary-color: #10848b; +// --ant-success-color: rgba(255, 255, 255, 0.1); +// --ant-info-color: #ffffff; +// } + +@mixin mobile { + @media (max-width: #{$mobile-width}) { + @content; + } +} + +@mixin md { + @media (min-width: #{$mobile-width}) and (max-width: #{$tablet-width}) { + @content; + } +} + +@mixin tablet { + @media (min-width: #{$tablet-width}) and (max-width: #{$desktop-width - 1px}) { + @content; + } +} + +@mixin mobile-and-tablet { + @media (max-width: #{$desktop-width - 1px}) { + @content; + } +} + +@mixin max-width($max-width) { + @media (max-width: #{$max-width}) { + @content; + } +} + +@mixin desktop { + @media (min-width: #{$desktop-width}) { + @content; + } +} + +@mixin smallHeight { + @media (max-height: #{$small-height}) { + @content; + } +} + +@mixin modalActions { + display: flex; + justify-content: flex-end; + + @include mobile { + flex-direction: column-reverse; + justify-content: center; + } + + .ant-btn { + width: 123px; + + &:nth-child(1) { + margin-right: 24px; + } + + @include mobile { + width: 100%; + + &:nth-child(1) { + margin-top: 16px; + } + } + } +} diff --git a/component_factory/src/app/favicon.ico b/component_factory/src/app/favicon.ico new file mode 100755 index 0000000..718d6fe Binary files /dev/null and b/component_factory/src/app/favicon.ico differ diff --git a/component_factory/src/app/globals.css b/component_factory/src/app/globals.css new file mode 100755 index 0000000..a2dc41e --- /dev/null +++ b/component_factory/src/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/component_factory/src/app/layout.tsx b/component_factory/src/app/layout.tsx new file mode 100755 index 0000000..f7fa87e --- /dev/null +++ b/component_factory/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/component_factory/src/app/page.tsx b/component_factory/src/app/page.tsx new file mode 100755 index 0000000..8a10b92 --- /dev/null +++ b/component_factory/src/app/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import React, { useState, useEffect } from "react"; + + +export default function Page() { + + return ( + <> +

Flat Interface Home Page

+ +

1: Simple Modal

+ + ); +} diff --git a/component_factory/src/app/simple_modal/page.tsx b/component_factory/src/app/simple_modal/page.tsx new file mode 100644 index 0000000..a24251c --- /dev/null +++ b/component_factory/src/app/simple_modal/page.tsx @@ -0,0 +1,463 @@ +"use client"; + +import React, { useRef, useState, useEffect, useLayoutEffect } from "react"; + +import { Upload } from "antd"; +import { Col, Row } from "antd/lib/grid"; + +import "../css/base/App.scss" +import "../css/base/spacing.scss" +import "../css/base/modal.scss" +import "../css/base/tabs.scss" +import "../css/base/text.scss" +import "../css/base/display.scss" +import "../css/base/form.scss" +import "../css/base/button.scss" +import "../css/onboardingFlow.scss" +import "../css/LinkEditorModal.scss"; +import "../css/ProductEditorModal.scss"; +import "../css/ModuleOndemandVideo.scss"; +import "../css/ModuleShopify.scss" + + +import Tabs from "antd/lib/tabs"; +import Button from "antd/lib/button"; +import Alert from "antd/lib/alert"; +import * as yup from "yup"; +import classNames from "classnames"; +import { Field, Form, Formik, FormikProps } from "formik"; + +import Modal from "@/components/Modal/Modal"; // Adjust this path as needed +import { Text } from "@/components/Typography/Text"; +import ImageUpload from "@/components/ImageUpload/ImageUpload"; +import { AntInput, FieldType } from "@/components/Form/FormItem"; +import { LinkItem } from "@/models/talent/talent-profile-module.model"; + +//import icon +import InfoIcon from "@/icons/Infoicon"; +import CloseOutlineIcon from "@/icons/Closeoutlineicon"; + +const initialProductValues: LinkItem = { + title: "", + url: "", + order: 0, + visible: true, +}; + +const initialSpecialOfferValues: LinkItem = { + title: "", + url: "", + order: 0, + specialOffer: { + thumbnail: "", + title: "", + storeUrl: "", + couponCode: "", + }, + visible: true, +}; +const LINK_TYPES = { + NORMAL: "NORMAL", + SPECIAL_OFFER: "SPECIAL_OFFER", +}; + +const Icon = ({ className, name, width, height }: { className?: string; name: string; width?: number; height?: number }) => { + return ( + + [icon: {name}] + + ); +}; + +const dummyPhoto = { + url: "https://via.placeholder.com/369", // static test image + fileName: "placeholder.jpg", + loading: false, +}; + +const onUploadSuccess = (file: any) => { + console.log("Upload success:", file); +}; + +const onRemove = (file: any) => { + console.log("Removed:", file); +}; + +const validateLinkSchema = yup.object().shape({ + title: yup.string().required("Please enter the link title"), + url: yup + .string() + .required("Please enter the URL") + .url("Please enter a valid URL"), +}); + +const validateSpecialOfferSchema = yup.object().shape({ + specialOffer: yup.object().shape({ + title: yup + .string() + .required("Please add the offer information") + .test("len", "Offer not more than 50 characters", (val) => { + if (val == undefined) { + return true; + } + return val.length == 0 || (val.length >= 1 && val.length <= 50); + }), + storeUrl: yup + .string() + .required("Please enter the URL") + .url("Please enter a valid URL"), + }), +}); + +export type LinkEditorModalFormValues = Pick< + LinkItem, + "thumbnail" | "url" | "title" | "specialOffer" | "order" | "visible" +>; + + +export default function Page() { + const [isModalOpen, setIsModalOpen] = useState(false); + + // Dummy state vars to eliminate TS errors + const [linkType, setLinkType] = useState(LINK_TYPES.NORMAL); + const [isEdit, setIsEdit] = useState(false); + const [showAlert, setShowAlert] = useState(true); + + const [initialValues, setInitialValues] = useState(); + + function handleSubmit(values: LinkEditorModalFormValues, formikHelpers: FormikHelpers): void | Promise { + throw new Error("Function not implemented."); + } + + const dummyOptions = [ + { label: "Option A", value: "A" }, + { label: "Option B", value: "B" }, + ]; + + const [modalTop, setModalTop] = useState(); // default safe gap + const modalRef = useRef(null); + let newTop: number; + + //CUSTOM LOGIC: HANDLE THE TOP OF THE MODAL OF DIFFERENT PLATFORMS + useEffect(() => { + const updateModalTop = () => { + const modalEl = document.querySelector('.link-editor-modal') as HTMLElement; + if (!modalEl) return; + + const modalHeight = modalEl.getBoundingClientRect().height; + const windowHeight = window.innerHeight; + + const isMobile = window.innerWidth <= 768; + + //PLATFORM LOGIC + if(!isMobile) { + if (modalHeight + 60 > windowHeight) { + newTop = 20; + setModalTop(newTop); + //console.log('DESKTOP OVERFLOW'); + + } else { + //newTop = Math.max((windowHeight - modalHeight) / 4, 30); + newTop = 20; + setModalTop(newTop); + //console.log('DESKTOP CENTERED'); + + } + } + else { + if (modalHeight + 60 > windowHeight) { + newTop = 20; + setModalTop(newTop); + //console.log('MOBILE OVERFLOW'); + } else { + //newTop = 20; + //setModalTop(newTop); + newTop = Math.max( (windowHeight / 2) - (2.4 * modalHeight), 30); + setModalTop(newTop); + //modalEl.style.removeProperty('top'); + //console.log('MOBILE CENTERED'); + } + } + + modalEl.style.top = `${newTop}px`; // 🔥 Direct DOM update + + }; + + if (isModalOpen) { + setTimeout(updateModalTop, 100); // Wait for DOM to fully render + window.addEventListener("resize", updateModalTop); + } + + return () => { + window.removeEventListener("resize", updateModalTop); + }; + }, [isModalOpen, linkType]); + + + useLayoutEffect(() => { + const adjustMargin = () => { + if (!isModalOpen) return; + + if (linkType === LINK_TYPES.SPECIAL_OFFER) { + //console.log('SPECIAL') + const form = document.querySelector(".offer-form-item") as HTMLElement; + const inputBox = form.getElementsByClassName('ant-form-item-has-success')[0] as HTMLElement; + const hasError = form.querySelector(".ant-form-item-explain-error") as HTMLElement; + + + if (inputBox) { + inputBox.style.marginBottom = hasError ? "24px" : "0px"; + } + } + + if (linkType === LINK_TYPES.NORMAL) { + //console.log('NORMAL') + const form = document.querySelector(".link-editor-form") as HTMLElement; + const inputBoxes = form?.getElementsByClassName("ant-form-item-has-success"); + const inputBox = inputBoxes?.[1] as HTMLElement; + + if (inputBox) { + inputBox.style.marginBottom = "24px"; + } + } + }; + + // Delay just until after DOM mounts, but before paint + requestAnimationFrame(() => { + adjustMargin(); + }); + + }, [linkType, isModalOpen]); + + //DEALING WITH ERROR LABELS + const [errorActivated, setErrorActivated] = useState(false); + + useEffect(() => { + if (!isModalOpen) return; + + const formRoot = document.querySelector(".offer-form-item"); + if (!formRoot) return; + + const observer = new MutationObserver(() => { + const errorLabels = formRoot.querySelectorAll(".ant-form-item-explain-error"); + + // ✅ You can react to the presence of ANY error + if (errorLabels.length > 0) { + //console.log("🚨 Error label activated"); + // Optional: do something here + setErrorActivated(true) + } else { + console.log("✅ No errors"); + } + }); + + observer.observe(formRoot, { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, [isModalOpen, linkType]); + + + //TRIGGER FOR ACTIVATED ERROR + useEffect(() => { + + if(errorActivated) { + console.log('ERROR ACTIVATED'); + const form = document.querySelector(".offer-form-item") as HTMLElement; + const inputBox = form.getElementsByClassName('ant-form-item')[0] as HTMLElement; + + + if (inputBox) { + inputBox.style.marginBottom = "24px"; + } + } + + }, [errorActivated]); + + + + return ( + <> + {/** INLINE ROOT STYLING */} + ', + fullName: "Andorra", + countryCode: "AD", + }, + AE: { + icon: '', + fullName: "United Arab Emirates", + countryCode: "AE", + }, + AF: { + icon: '', + fullName: "Afghanistan", + countryCode: "AF", + }, + AG: { + icon: '', + fullName: "Antigua and Barbuda", + countryCode: "AG", + }, + AI: { + icon: '', + fullName: "Anguilla", + countryCode: "AI", + }, + AL: { + icon: '', + fullName: "Albania", + countryCode: "AL", + }, + AM: { + icon: '', + fullName: "Armenia", + countryCode: "AM", + }, + AO: { + icon: '', + fullName: "Angola", + countryCode: "AO", + }, + AQ: { + icon: '', + fullName: "Antarctica", + countryCode: "AQ", + }, + AR: { + icon: '', + fullName: "Argentina", + countryCode: "AR", + }, + AS: { + icon: '', + fullName: "American Samoa", + countryCode: "AS", + }, + AT: { + icon: '', + fullName: "Austria", + countryCode: "AT", + }, + AU: { + icon: '', + fullName: "Australia", + countryCode: "AU", + }, + AW: { + icon: '', + fullName: "Aruba", + countryCode: "AW", + }, + AX: { + icon: '', + fullName: "Åland Islands", + countryCode: "AX", + }, + AZ: { + icon: '', + fullName: "Azerbaijan", + countryCode: "AZ", + }, + BA: { + icon: '', + fullName: "Bosnia and Herzegovina", + countryCode: "BA", + }, + BB: { + icon: '', + fullName: "Barbados", + countryCode: "BB", + }, + BD: { + icon: '', + fullName: "Bangladesh", + countryCode: "BD", + }, + BE: { + icon: '', + fullName: "Belgium", + countryCode: "BE", + }, + BF: { + icon: '', + fullName: "Burkina Faso", + countryCode: "BF", + }, + BG: { + icon: '', + fullName: "Bulgaria", + countryCode: "BG", + }, + BH: { + icon: '', + fullName: "Bahrain", + countryCode: "BH", + }, + BI: { + icon: '', + fullName: "Burundi", + countryCode: "BI", + }, + BJ: { + icon: '', + fullName: "Benin", + countryCode: "BJ", + }, + BL: { + icon: '', + fullName: "Saint Barthélemy", + countryCode: "BL", + }, + BM: { + icon: '', + fullName: "Bermuda", + countryCode: "BM", + }, + BN: { + icon: '', + fullName: "Brunei Darussalam", + countryCode: "BN", + }, + BO: { + icon: '', + fullName: "Bolivia (Plurinational State of)", + countryCode: "BO", + }, + BQ: { + icon: '', + fullName: "Bonaire, Sint Eustatius and Saba", + countryCode: "BQ", + }, + BR: { + icon: '', + fullName: "Brazil", + countryCode: "BR", + }, + BS: { + icon: '', + fullName: "Commonwealth of The Bahamas", + countryCode: "BS", + }, + BT: { + icon: '', + fullName: "Bhutan", + countryCode: "BT", + }, + BV: { + icon: '', + fullName: "Bouvet Island", + countryCode: "BV", + }, + BW: { + icon: '', + fullName: "Botswana", + countryCode: "BW", + }, + BY: { + icon: '', + fullName: "Belarus", + countryCode: "BY", + }, + BZ: { + icon: '', + fullName: "Belize", + countryCode: "BZ", + }, + CA: { + icon: '', + fullName: "Canada", + countryCode: "CA", + }, + CC: { + icon: '', + fullName: "Cocos (Keeling) Islands", + countryCode: "CC", + }, + CD: { + icon: '', + fullName: "Democratic Republic of the Congo", + countryCode: "CD", + }, + CF: { + icon: '', + fullName: "Central African Republic", + countryCode: "CF", + }, + CG: { + icon: '', + fullName: "Republic of the Congo", + countryCode: "CG", + }, + CH: { + icon: '', + fullName: "Switzerland", + countryCode: "CH", + }, + CI: { + icon: '', + fullName: "Côte d'Ivoire", + countryCode: "CI", + }, + CK: { + icon: '', + fullName: "Cook Islands", + countryCode: "CK", + }, + CL: { + icon: '', + fullName: "Chile", + countryCode: "CL", + }, + CM: { + icon: '', + fullName: "Cameroon", + countryCode: "CM", + }, + CN: { + icon: '', + fullName: "China", + countryCode: "CN", + }, + CO: { + icon: '', + fullName: "Colombia", + countryCode: "CO", + }, + CR: { + icon: '', + fullName: "Costa Rica", + countryCode: "CR", + }, + CU: { + icon: '', + fullName: "Cuba", + countryCode: "CU", + }, + CV: { + icon: '', + fullName: "Cabo Verde", + countryCode: "CV", + }, + CW: { + icon: '', + fullName: "Curaçao", + countryCode: "CW", + }, + CX: { + icon: '', + fullName: "Christmas Island", + countryCode: "CX", + }, + CY: { + icon: '', + fullName: "Cyprus", + countryCode: "CY", + }, + CZ: { + icon: '', + fullName: "Czechia", + countryCode: "CZ", + }, + DE: { + icon: '', + fullName: "Germany", + countryCode: "DE", + }, + DJ: { + icon: '', + fullName: "Djibouti", + countryCode: "DJ", + }, + DK: { + icon: '', + fullName: "Denmark", + countryCode: "DK", + }, + DM: { + icon: '', + fullName: "Dominica", + countryCode: "DM", + }, + DO: { + icon: '', + fullName: "Dominican Republic", + countryCode: "DO", + }, + DZ: { + icon: '', + fullName: "Algeria", + countryCode: "DZ", + }, + EC: { + icon: '', + fullName: "Ecuador", + countryCode: "EC", + }, + EE: { + icon: '', + fullName: "Estonia", + countryCode: "EE", + }, + EG: { + icon: '', + fullName: "Egypt", + countryCode: "EG", + }, + EH: { + icon: '', + fullName: "Western Sahara", + countryCode: "EH", + }, + ER: { + icon: '', + fullName: "Eritrea", + countryCode: "ER", + }, + ES: { + icon: '', + fullName: "Spain", + countryCode: "ES", + }, + ET: { + icon: '', + fullName: "Ethiopia", + countryCode: "ET", + }, + FI: { + icon: '', + fullName: "Finland", + countryCode: "FI", + }, + FJ: { + icon: '', + fullName: "Fiji", + countryCode: "FJ", + }, + FK: { + icon: '', + fullName: "Falkland Islands", + countryCode: "FK", + }, + FM: { + icon: '', + fullName: "Micronesia (Federated States of)", + countryCode: "FM", + }, + FO: { + icon: '', + fullName: "Faroe Islands", + countryCode: "FO", + }, + FR: { + icon: '', + fullName: "France", + countryCode: "FR", + }, + GA: { + icon: '', + fullName: "Gabon", + countryCode: "GA", + }, + GB: { + icon: '', + fullName: "United Kingdom", + countryCode: "GB", + }, + GD: { + icon: '', + fullName: "Grenada", + countryCode: "GD", + }, + GE: { + icon: '', + fullName: "Georgia", + countryCode: "GE", + }, + GF: { + icon: '', + fullName: "French Guiana", + countryCode: "GF", + }, + GG: { + icon: '', + fullName: "Guernsey", + countryCode: "GG", + }, + GH: { + icon: '', + fullName: "Ghana", + countryCode: "GH", + }, + GI: { + icon: '', + fullName: "Gibraltar", + countryCode: "GI", + }, + GL: { + icon: '', + fullName: "Greenland", + countryCode: "GL", + }, + GM: { + icon: '', + fullName: "Gambia", + countryCode: "GM", + }, + GN: { + icon: '', + fullName: "Guinea", + countryCode: "GN", + }, + GP: { + icon: '', + fullName: "Guadeloupe", + countryCode: "GP", + }, + GQ: { + icon: '', + fullName: "Equatorial Guinea", + countryCode: "GQ", + }, + GR: { + icon: '', + fullName: "Greece", + countryCode: "GR", + }, + GS: { + icon: '', + fullName: "South Georgia and the South Sandwich Islands", + countryCode: "GS", + }, + GT: { + icon: '', + fullName: "Guatemala", + countryCode: "GT", + }, + GU: { + icon: '', + fullName: "Guam", + countryCode: "GU", + }, + GW: { + icon: '', + fullName: "Guinea-Bissau", + countryCode: "GW", + }, + GY: { + icon: '', + fullName: "Guyana", + countryCode: "GY", + }, + HK: { + icon: '', + fullName: "Hong Kong", + countryCode: "HK", + }, + HM: { + icon: '', + fullName: "Territory of Heard Island and McDonald Islands", + countryCode: "HM", + }, + HN: { + icon: '', + fullName: "Honduras", + countryCode: "HN", + }, + HR: { + icon: '', + fullName: "Croatia", + countryCode: "HR", + }, + HT: { + icon: '', + fullName: "Haiti", + countryCode: "HT", + }, + HU: { + icon: '', + fullName: "Hungary", + countryCode: "HU", + }, + ID: { + icon: '', + fullName: "Indonesia", + countryCode: "ID", + }, + IE: { + icon: '', + fullName: "Ireland", + countryCode: "IE", + }, + IL: { + icon: '', + fullName: "Israel", + countryCode: "IL", + }, + IM: { + icon: '', + fullName: "Isle of Man", + countryCode: "IM", + }, + IN: { + icon: '', + fullName: "India", + countryCode: "IN", + }, + IO: { + icon: '', + fullName: "British Indian Ocean Territory", + countryCode: "IO", + }, + IQ: { + icon: '', + fullName: "Iraq", + countryCode: "IQ", + }, + IR: { + icon: '', + fullName: "Iran (Islamic Republic of)", + countryCode: "IR", + }, + IS: { + icon: '', + fullName: "Iceland", + countryCode: "IS", + }, + IT: { + icon: '', + fullName: "Italy", + countryCode: "IT", + }, + JE: { + icon: '', + fullName: "Jersey", + countryCode: "JE", + }, + JM: { + icon: '', + fullName: "Jamaica", + countryCode: "JM", + }, + JO: { + icon: '', + fullName: "Jordan", + countryCode: "JO", + }, + JP: { + icon: '', + fullName: "Japan", + countryCode: "JP", + }, + KE: { + icon: '', + fullName: "Kenya", + countryCode: "KE", + }, + KG: { + icon: '', + fullName: "Kyrgyzstan", + countryCode: "KG", + }, + KH: { + icon: '', + fullName: "Cambodia", + countryCode: "KH", + }, + KI: { + icon: '', + fullName: "Kiribati", + countryCode: "KI", + }, + KM: { + icon: '', + fullName: "Comoros", + countryCode: "KM", + }, + KN: { + icon: '', + fullName: "Saint Kitts and Nevis", + countryCode: "KN", + }, + KP: { + icon: '', + fullName: "North Korea", + countryCode: "KP", + }, + KR: { + icon: '', + fullName: "South Korea", + countryCode: "KR", + }, + KW: { + icon: '', + fullName: "Kuwait", + countryCode: "KW", + }, + KY: { + icon: '', + fullName: "Cayman Islands", + countryCode: "KY", + }, + KZ: { + icon: '', + fullName: "Kazakhstan", + countryCode: "KZ", + }, + LA: { + icon: '', + fullName: "Lao People's Democratic Republic", + countryCode: "LA", + }, + LB: { + icon: '', + fullName: "Lebanon", + countryCode: "LB", + }, + LC: { + icon: '', + fullName: "Saint Lucia", + countryCode: "LC", + }, + LI: { + icon: '', + fullName: "Liechtenstein", + countryCode: "LI", + }, + LK: { + icon: '', + fullName: "Sri Lanka", + countryCode: "LK", + }, + LR: { + icon: '', + fullName: "Liberia", + countryCode: "LR", + }, + LS: { + icon: '', + fullName: "Lesotho", + countryCode: "LS", + }, + LT: { + icon: '', + fullName: "Lithuania", + countryCode: "LT", + }, + LU: { + icon: '', + fullName: "Luxembourg", + countryCode: "LU", + }, + LV: { + icon: '', + fullName: "Latvia", + countryCode: "LV", + }, + LY: { + icon: '', + fullName: "Libya", + countryCode: "LY", + }, + MA: { + icon: '', + fullName: "Morocco", + countryCode: "MA", + }, + MC: { + icon: '', + fullName: "Monaco", + countryCode: "MC", + }, + MD: { + icon: '', + fullName: "Republic of Moldova", + countryCode: "MD", + }, + ME: { + icon: '', + fullName: "Montenegro", + countryCode: "ME", + }, + MF: { + icon: '', + fullName: "Saint Martin (French part)", + countryCode: "MF", + }, + MG: { + icon: '', + fullName: "Madagascar", + countryCode: "MG", + }, + MH: { + icon: '', + fullName: "Republic of the Marshall Islands", + countryCode: "MH", + }, + MK: { + icon: '', + fullName: "North Macedonia", + countryCode: "MK", + }, + ML: { + icon: '', + fullName: "Mali", + countryCode: "ML", + }, + MM: { + icon: '', + fullName: "Myanmar", + countryCode: "MM", + }, + MN: { + icon: '', + fullName: "Mongolia", + countryCode: "MN", + }, + MO: { + icon: '', + fullName: "Macao", + countryCode: "MO", + }, + MP: { + icon: '', + fullName: "Commonwealth of the Northern Mariana Islands", + countryCode: "MP", + }, + MQ: { + icon: '', + fullName: "Martinique", + countryCode: "MQ", + }, + MR: { + icon: '', + fullName: "Mauritania", + countryCode: "MR", + }, + MS: { + icon: '', + fullName: "Montserrat", + countryCode: "MS", + }, + MT: { + icon: '', + fullName: "Malta", + countryCode: "MT", + }, + MU: { + icon: '', + fullName: "Mauritius", + countryCode: "MU", + }, + MV: { + icon: '', + fullName: "Maldives", + countryCode: "MV", + }, + MW: { + icon: '', + fullName: "Malawi", + countryCode: "MW", + }, + MX: { + icon: '', + fullName: "Mexico", + countryCode: "MX", + }, + MY: { + icon: '', + fullName: "Malaysia", + countryCode: "MY", + }, + MZ: { + icon: '', + fullName: "Mozambique", + countryCode: "MZ", + }, + NA: { + icon: '', + fullName: "Namibia", + countryCode: "NA", + }, + NC: { + icon: '', + fullName: "New Caledonia", + countryCode: "NC", + }, + NE: { + icon: '', + fullName: "Niger", + countryCode: "NE", + }, + NF: { + icon: '', + fullName: "Norfolk Island", + countryCode: "NF", + }, + NG: { + icon: '', + fullName: "Nigeria", + countryCode: "NG", + }, + NI: { + icon: '', + fullName: "Nicaragua", + countryCode: "NI", + }, + NL: { + icon: '', + fullName: "Netherlands", + countryCode: "NL", + }, + NO: { + icon: '', + fullName: "Norway", + countryCode: "NO", + }, + NP: { + icon: '', + fullName: "Nepal", + countryCode: "NP", + }, + NR: { + icon: '', + fullName: "Nauru", + countryCode: "NR", + }, + NU: { + icon: '', + fullName: "Niue", + countryCode: "NU", + }, + NZ: { + icon: '', + fullName: "New Zealand", + countryCode: "NZ", + }, + OM: { + icon: '', + fullName: "Oman", + countryCode: "OM", + }, + PA: { + icon: '', + fullName: "Panama", + countryCode: "PA", + }, + PE: { + icon: '', + fullName: "Peru", + countryCode: "PE", + }, + PF: { + icon: '', + fullName: "French Polynesia", + countryCode: "PF", + }, + PG: { + icon: '', + fullName: "Papua New Guinea", + countryCode: "PG", + }, + PH: { + icon: '', + fullName: "Philippines", + countryCode: "PH", + }, + PK: { + icon: '', + fullName: "Pakistan", + countryCode: "PK", + }, + PL: { + icon: '', + fullName: "Poland", + countryCode: "PL", + }, + PM: { + icon: '', + fullName: "Saint Pierre and Miquelon", + countryCode: "PM", + }, + PN: { + icon: '', + fullName: "Pitcairn", + countryCode: "PN", + }, + PR: { + icon: '', + fullName: "Puerto Rico", + countryCode: "PR", + }, + PS: { + icon: '', + fullName: "Palestine, State of", + countryCode: "PS", + }, + PT: { + icon: '', + fullName: "Portugal", + countryCode: "PT", + }, + PW: { + icon: '', + fullName: "Palau", + countryCode: "PW", + }, + PY: { + icon: '', + fullName: "Paraguay", + countryCode: "PY", + }, + QA: { + icon: '', + fullName: "Qatar", + countryCode: "QA", + }, + RE: { + icon: '', + fullName: "Réunion", + countryCode: "RE", + }, + RO: { + icon: '', + fullName: "Romania", + countryCode: "RO", + }, + RS: { + icon: '', + fullName: "Serbia", + countryCode: "RS", + }, + RU: { + icon: '', + fullName: "Russia", + countryCode: "RU", + }, + RW: { + icon: '', + fullName: "Rwanda", + countryCode: "RW", + }, + SA: { + icon: '', + fullName: "Saudi Arabia", + countryCode: "SA", + }, + SB: { + icon: '', + fullName: "Solomon Islands", + countryCode: "SB", + }, + SC: { + icon: '', + fullName: "Seychelles", + countryCode: "SC", + }, + SD: { + icon: '', + fullName: "Sudan", + countryCode: "SD", + }, + SE: { + icon: '', + fullName: "Sweden", + countryCode: "SE", + }, + SG: { + icon: '', + fullName: "Singapore", + countryCode: "SG", + }, + SH: { + icon: '', + fullName: "Saint Helena, Ascension and Tristan da Cunha", + countryCode: "SH", + }, + SI: { + icon: '', + fullName: "Slovenia", + countryCode: "SI", + }, + SJ: { + icon: '', + fullName: "Svalbard and Jan Mayen", + countryCode: "SJ", + }, + SK: { + icon: '', + fullName: "Slovakia", + countryCode: "SK", + }, + SL: { + icon: '', + fullName: "Sierra Leone", + countryCode: "SL", + }, + SM: { + icon: '', + fullName: "Republic of San Marino", + countryCode: "SM", + }, + SN: { + icon: '', + fullName: "Senegal", + countryCode: "SN", + }, + SO: { + icon: '', + fullName: "Somalia", + countryCode: "SO", + }, + SR: { + icon: '', + fullName: "Suriname", + countryCode: "SR", + }, + SS: { + icon: '', + fullName: "South Sudan", + countryCode: "SS", + }, + ST: { + icon: '', + fullName: "Sao Tome and Principe", + countryCode: "ST", + }, + SV: { + icon: '', + fullName: "El Salvador", + countryCode: "SV", + }, + SX: { + icon: '', + fullName: "Sint Maarten (Dutch part)", + countryCode: "SX", + }, + SY: { + icon: '', + fullName: "Syrian Arab Republic", + countryCode: "SY", + }, + SZ: { + icon: '', + fullName: "Eswatini", + countryCode: "SZ", + }, + TC: { + icon: '', + fullName: "Turks and Caicos Islands", + countryCode: "TC", + }, + TD: { + icon: '', + fullName: "Chad", + countryCode: "TD", + }, + TF: { + icon: '', + fullName: "French Southern and Antarctic Lands", + countryCode: "TF", + }, + TG: { + icon: '', + fullName: "Togo", + countryCode: "TG", + }, + TH: { + icon: '', + fullName: "Thailand", + countryCode: "TH", + }, + TJ: { + icon: '', + fullName: "Tajikistan", + countryCode: "TJ", + }, + TK: { + icon: '', + fullName: "Tokelau", + countryCode: "TK", + }, + TL: { + icon: '', + fullName: "Timor-Leste", + countryCode: "TL", + }, + TM: { + icon: '', + fullName: "Turkmenistan", + countryCode: "TM", + }, + TN: { + icon: '', + fullName: "Tunisia", + countryCode: "TN", + }, + TO: { + icon: '', + fullName: "Tonga", + countryCode: "TO", + }, + TR: { + icon: '', + fullName: "Turkey", + countryCode: "TR", + }, + TT: { + icon: '', + fullName: "Trinidad and Tobago", + countryCode: "TT", + }, + TV: { + icon: '', + fullName: "Tuvalu", + countryCode: "TV", + }, + TW: { + icon: '', + fullName: "Taiwan, Province of China", + countryCode: "TW", + }, + TZ: { + icon: '', + fullName: "United Republic of Tanzania", + countryCode: "TZ", + }, + UA: { + icon: '', + fullName: "Ukraine", + countryCode: "UA", + }, + UG: { + icon: '', + fullName: "Uganda", + countryCode: "UG", + }, + UM: { + icon: '', + fullName: "United States Minor Outlying Islands", + countryCode: "UM", + }, + US: { + icon: '', + fullName: "United States of America", + countryCode: "US", + }, + UY: { + icon: '', + fullName: "Uruguay", + countryCode: "UY", + }, + UZ: { + icon: '', + fullName: "Uzbekistan", + countryCode: "UZ", + }, + VA: { + icon: '', + fullName: "Holy See", + countryCode: "VA", + }, + VC: { + icon: '', + fullName: "Saint Vincent and the Grenadines", + countryCode: "VC", + }, + VE: { + icon: '', + fullName: "Venezuela (Bolivarian Republic of)", + countryCode: "VE", + }, + VG: { + icon: '', + fullName: "Virgin Islands (British)", + countryCode: "VG", + }, + VI: { + icon: '', + fullName: "Virgin Islands (U.S.)", + countryCode: "VI", + }, + VN: { + icon: '', + fullName: "Vietnam", + countryCode: "VN", + }, + VU: { + icon: '', + fullName: "Vanuatu", + countryCode: "VU", + }, + WF: { + icon: '', + fullName: "Wallis and Futuna", + countryCode: "WF", + }, + WS: { + icon: '', + fullName: "Samoa", + countryCode: "WS", + }, + YE: { + icon: '', + fullName: "Yemen", + countryCode: "YE", + }, + YT: { + icon: '', + fullName: "Mayotte", + countryCode: "YT", + }, + ZA: { + icon: '', + fullName: "South Africa", + countryCode: "ZA", + }, + ZM: { + icon: '', + fullName: "Zambia", + countryCode: "ZM", + }, + ZW: { + icon: '', + fullName: "Zimbabwe", + countryCode: "ZW", + }, +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/datepicker.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/datepicker.ts new file mode 100644 index 0000000..73c36ad --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/datepicker.ts @@ -0,0 +1,14 @@ +import { DateCadence } from "../types/datepicker"; +import { SelectOption } from "../types/select"; + +export const dateCadenceDayMap: Record = { + [DateCadence.Day]: 1, + [DateCadence.Week]: 7, + [DateCadence.Month]: 28, + [DateCadence.Year]: 365, +}; +export const cadenceOptions: SelectOption[] = [ + { label: 'days', value: DateCadence.Day }, + { label: 'weeks', value: DateCadence.Week }, + { label: 'months', value: DateCadence.Month }, +]; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/attributes.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/attributes.ts new file mode 100644 index 0000000..3562355 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/attributes.ts @@ -0,0 +1,609 @@ +import { countries } from "../countries"; + +import { + SegmentActionAttributeType, + SegmentActionProperty, + SegmentActionAttributeTag, + SegmentAction, + SegmentProperty, + SegmentValue, + SegmentActionAttribute, + SegmentPropertyAttribute, +} from "@komi-app/fans-sdk"; +import { CommonSelectOptions, SelectOption } from "../../types/select"; +import { fetchPropValues, segmentAttributesToOptions } from "../../utils/attributes"; +import { objectKeysToOptions } from "../../utils/select"; +import { emailMarketingStatusMap } from "@komi-app/fans-sdk"; +import { format } from "date-fns"; + +export const anySelectionValues = [CommonSelectOptions.Any]; +export const anyOption: SelectOption = { + label: "Any", + value: CommonSelectOptions.Any, +}; + +export const segmentAttributeActionFilterOptions: SelectOption[] = [ + { label: "All actions", value: SegmentActionAttributeType.Any }, + { label: "Profile page actions", value: SegmentActionAttributeType.ProfilePage }, + { label: "Campaign actions", value: SegmentActionAttributeType.Campaign }, +]; + +// TODO: As this requires auth, should this be moved and injected from +// komi-client? +export const segmentActionAttributeMap: Record = { + [SegmentAction.ClickBandsintownRsvpLink]: { + name: SegmentAction.ClickBandsintownRsvpLink, + tags: [ + SegmentActionAttributeTag.Event, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + name: SegmentActionProperty.VenueName, + valueType: SegmentValue.Option, + getValues: () => fetchPropValues("/api/segments/bandsintown/venue-names"), + } + ], + }, + [SegmentAction.ClickBandsintownTicketLink]: { + name: SegmentAction.ClickBandsintownTicketLink, + tags: [ + SegmentActionAttributeTag.Event, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=BANDSINTOWN"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.VenueName, + getValues: () => fetchPropValues("/api/segments/bandsintown/venue-names"), + }, + ], + }, + [SegmentAction.ClickCustomEventTicketLink]: { + name: SegmentAction.ClickCustomEventTicketLink, + tags: [ + SegmentActionAttributeTag.Event, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=EVENTS"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.LinkTitle, + getValues: () => fetchPropValues("/api/segments/events/titles"), + }, + ], + }, + [SegmentAction.ClickCustomLink]: { + name: SegmentAction.ClickCustomLink, + tags: [ + SegmentActionAttributeTag.Link, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=LINKS"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.LinkTitle, + getValues: () => fetchPropValues("/api/segments/links/titles"), + }, + ], + }, + [SegmentAction.ClickMusicProvider]: { + name: SegmentAction.ClickMusicProvider, + tags: [ + SegmentActionAttributeTag.Music, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=MUSIC"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.MusicTitle, + getValues: () => fetchPropValues("/api/segments/music/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/music/platforms"), + }, + ], + }, + [SegmentAction.ClickPlayFullPodcast]: { + name: SegmentAction.ClickPlayFullPodcast, + tags: [ + SegmentActionAttributeTag.Podcast, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=PODCASTS"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.PodcastTitle, + getValues: () => fetchPropValues("/api/segments/podcasts/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/podcasts/platforms"), + }, + ], + }, + [SegmentAction.ClickPlayFullSong]: { + name: SegmentAction.ClickPlayFullSong, + tags: [ + SegmentActionAttributeTag.Music + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=MUSIC"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.MusicTitle, + getValues: () => fetchPropValues("/api/segments/music/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/music/platforms"), + }, + ], + }, + [SegmentAction.ClickPodcastProvider]: { + name: SegmentAction.ClickPodcastProvider, + tags: [ + SegmentActionAttributeTag.Podcast, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=PODCASTS"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.PodcastTitle, + getValues: () => fetchPropValues("/api/segments/podcasts/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/podcasts/platforms"), + }, + ], + }, + [SegmentAction.ClickPresaveProvider]: { + name: SegmentAction.ClickPresaveProvider, + tags: [ + SegmentActionAttributeTag.Music, + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=MUSIC"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.MusicTitle, + getValues: () => fetchPropValues("/api/segments/music/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/music/platforms"), + }, + ], + }, + [SegmentAction.ClickProductLink]: { + name: SegmentAction.ClickProductLink, + tags: [ + SegmentActionAttributeTag.Store + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=PRODUCT"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ProductName, + getValues: () => fetchPropValues("/api/segments/products/titles"), + }, + ], + }, + [SegmentAction.ClickShopMyShelfProduct]: { + name: SegmentAction.ClickShopMyShelfProduct, + tags: [ + SegmentActionAttributeTag.Store + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=SHOP_MY_SHELF"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ProductTitle, + getValues: () => fetchPropValues("/api/segments/shopmyshelf/titles"), + }, + ], + }, + [SegmentAction.CopySpecialOfferCode]: { + name: SegmentAction.CopySpecialOfferCode, + tags: [ + SegmentActionAttributeTag.Link + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/links/special-offer/module-names"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.LinkTitle, + getValues: () => fetchPropValues("/api/segments/links/special-offer/titles"), + }, + ], + }, + [SegmentAction.ClickSpecialOfferLink]: { + name: SegmentAction.ClickSpecialOfferLink, + tags: [ + SegmentActionAttributeTag.Link + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/links/special-offer/module-names"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.LinkTitle, + getValues: () => fetchPropValues("/api/segments/links/special-offer/titles"), + }, + ], + }, + [SegmentAction.ClickSeatedPromotedLink]: { + name: SegmentAction.ClickSeatedPromotedLink, + tags: [ + SegmentActionAttributeTag.Event + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=SEATED"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.VenueName, + getValues: () => fetchPropValues("/api/segments/seated/venue-names"), + }, + ], + }, + [SegmentAction.ClickSeatedTicketLink]: { + name: SegmentAction.ClickSeatedTicketLink, + tags: [ + SegmentActionAttributeTag.Event + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=SEATED"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.VenueName, + getValues: () => fetchPropValues("/api/segments/seated/venue-names"), + }, + ], + }, + [SegmentAction.PlayYoutubeVideo]: { + name: SegmentAction.PlayYoutubeVideo, + tags: [ + SegmentActionAttributeTag.Video + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=VIDEO"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.VideoTitle, + getValues: () => fetchPropValues("/api/segments/youtube/titles"), + }, + ], + }, + [SegmentAction.PreviewMusic]: { + name: SegmentAction.PreviewMusic, + tags: [ + SegmentActionAttributeTag.Music + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=MUSIC"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.MusicTitle, + getValues: () => fetchPropValues("/api/segments/music/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/music/platforms"), + }, + ], + }, + [SegmentAction.PreviewPodcast]: { + name: SegmentAction.PreviewPodcast, + tags: [ + SegmentActionAttributeTag.Podcast + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=PODCASTS"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.PodcastTitle, + getValues: () => fetchPropValues("/api/segments/podcasts/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/podcasts/platforms"), + }, + ], + }, + [SegmentAction.SuccessfullPresave]: { + name: SegmentAction.SuccessfullPresave, + tags: [ + SegmentActionAttributeTag.Music + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/module-names?types=MUSIC"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.MusicTitle, + getValues: () => fetchPropValues("/api/segments/music/titles"), + }, + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.Platform, + getValues: () => fetchPropValues("/api/segments/music/platforms"), + }, + ], + }, + [SegmentAction.ViewProfile]: { + name: SegmentAction.ViewProfile, + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.PageName, + getValues: () => fetchPropValues("/api/segments/profiles/page-names"), // TODO + }, + ], + }, + [SegmentAction.ClickSecretLink]: { + name: SegmentAction.ClickSecretLink, + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/"), // TODO + }, + ], + }, + [SegmentAction.CopyCouponCode]: { + name: SegmentAction.CopyCouponCode, + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.ModuleName, + getValues: () => fetchPropValues("/api/segments/"), // TODO + }, + ], + }, + [SegmentAction.OpenEmail]: { + name: SegmentAction.OpenEmail, + tags: [ + SegmentActionAttributeTag.Campaign + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.CampaignName, + getValues: () => fetchPropValues("/api/segments/campaigns/names"), + }, + ], + }, + [SegmentAction.ClickLinkInEmail]: { + name: SegmentAction.ClickLinkInEmail, + tags: [ + SegmentActionAttributeTag.Campaign + ], + clauseProperties: [ + { + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + name: SegmentActionProperty.CampaignName, + getValues: () => fetchPropValues("/api/segments/campaigns/names"), + }, + // SegmentAttributePropertyName.EmailLink, // TODO + ], + }, +}; +export const segmentActionAttributes: SegmentActionAttribute[] = Object.values(segmentActionAttributeMap); + +export const segmentPropertyAttributeMap: Partial< + Record +> = { + [SegmentProperty.Age]: { + name: SegmentProperty.Age, + defaultValues: ["18", "65"], + valueType: SegmentValue.Number, + }, + [SegmentProperty.Country]: { + name: SegmentProperty.Country, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => { + const countryEntries = Object.entries(countries); + const formattedCountries = countryEntries.map(([code, { fullName }]) => ({ + value: code, + label: fullName, + })); + // alphabetical sorting + return formattedCountries.sort((a, b) => a.label.localeCompare(b.label)) + } + }, + [SegmentProperty.EmailMarketingStatus]: { + name: SegmentProperty.EmailMarketingStatus, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => objectKeysToOptions(emailMarketingStatusMap), + }, + [SegmentProperty.SignUpDate]: { + name: SegmentProperty.SignUpDate, + defaultValues: [format(Date.now(), "yyyy-MM-dd"),format(Date.now(), "yyyy-MM-dd")], + valueType: SegmentValue.Option, + getOptions: () => objectKeysToOptions(emailMarketingStatusMap), + }, + // + // These are descoped, but may be useful in the future + // + // [SegmentProperty.Name]: { + // name: SegmentProperty.Name, + // valueType: SegmentValue.Option, + // getValues: () => fetchPropValues("/api/segments/names"), + // }, + // + // These are for > v1 + // + // [SegmentProperty.City]: { + // name: SegmentProperty.City, + // valueType: SegmentValue.Option, + // getValues: () => fetchPropValues("/api/segments/cities"), + // }, + // [SegmentProperty.OperatingSystem]: { + // name: SegmentProperty.OperatingSystem, + // valueType: SegmentValue.Option, + // getValues: () => fetchPropValues("/api/segments/operating-systems"), + // }, + // [SegmentProperty.Gender]: { + // name: SegmentProperty.Gender, + // valueType: SegmentValue.Option, + // getValues: () => fetchPropValues("/api/segments/genders"), + // }, + // [SegmentProperty.SignupSource]: { + // name: SegmentProperty.SignupSource, + // valueType: SegmentValue.Option, + // getValues: () => fetchPropValues("/api/segments/signup-sources"), + // }, + // [SegmentProperty.ReceiveAnEmail]: { + // name: SegmentProperty.ReceiveAnEmail, + // valueType: SegmentValue.Boolean, + // }, + // [SegmentProperty.CampaignEngagement]: { + // name: SegmentProperty.CampaignEngagement, + // valueType: SegmentValue.Option, + // }, + // [SegmentProperty.ClickSocialLink]: { + // name: SegmentProperty.ClickSocialLink, + // valueType: SegmentValue.Option, + // }, + // [SegmentProperty.OriginPlatform]: { + // name: SegmentProperty.OriginPlatform, + // valueType: SegmentValue.Option, + // }, +}; +export const segmentPropertyAttributes: SegmentPropertyAttribute[] = Object.values(segmentPropertyAttributeMap); + +export const segmentAttributeActionOptions = segmentAttributesToOptions(segmentActionAttributes); +export const segmentAttributePropertyOptions = segmentAttributesToOptions( + segmentPropertyAttributes, + "Audience Properties", +); diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/comparators.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/comparators.ts new file mode 100644 index 0000000..7a03e9a --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/comparators.ts @@ -0,0 +1,18 @@ +import { SegmentAttributeComparator } from "@komi-app/fans-sdk"; +import { SelectOption } from "../../types/select"; + +export const comparatorLabelMap: Record = { + [SegmentAttributeComparator.Lt]: 'Less than', + [SegmentAttributeComparator.Lte]: 'Less than or equal to', + [SegmentAttributeComparator.Gt]: 'Greater than', + [SegmentAttributeComparator.Gte]: 'Greater than or equal to', + [SegmentAttributeComparator.Eq]: 'Equals', + [SegmentAttributeComparator.Neq]: 'Does not equal', + [SegmentAttributeComparator.Bt]: 'Between', + [SegmentAttributeComparator.Nbt]: 'Not between', +}; + +const comparators: SegmentAttributeComparator[] = Object.values(SegmentAttributeComparator); +export const comparatorOptions = comparators.map>( + (comparator) => ({ label: comparatorLabelMap[comparator], value: comparator }), +); diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/dateRangeComparator.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/dateRangeComparator.ts new file mode 100644 index 0000000..35da1dc --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/dateRangeComparator.ts @@ -0,0 +1,24 @@ +import { SegmentAttributeComparator } from "@komi-app/fans-sdk"; +import { SelectOption } from "../../types/select"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +export function getDateRangeComparatorOptions() { + const dateRangeComparatorLabelMap: Record = { + [SegmentAttributeComparator.Lte]: "Before", + [SegmentAttributeComparator.Gte]: "After", + [SegmentAttributeComparator.Eq]: "On", + }; + + //Check if between option should be shown + dateRangeComparatorLabelMap[SegmentAttributeComparator.Bt] = "Between"; + + const dateRangeComparators = Object.keys(dateRangeComparatorLabelMap); + + const dateRangeComparatorOptions = dateRangeComparators.map< + SelectOption + >((comparator) => ({ + label: dateRangeComparatorLabelMap[comparator], + value: comparator, + })); + return dateRangeComparatorOptions; +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/labelMaps.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/labelMaps.ts new file mode 100644 index 0000000..a7dfe10 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/labelMaps.ts @@ -0,0 +1,13 @@ +import { + SegmentMenuAction, +} from "@komi-app/fans-sdk"; + +import { SelectOption } from "../../types/select"; +import {SegmentOptionVariant} from "../../types/select" + +export const segmentMenuActionOptions: SelectOption[] = [ + { label: "Send a Campaign", value: SegmentMenuAction.Send }, + // DESCOPED: + // { label: "Export as CSV", value: SegmentMenuAction.ExportCsv }, + { label: "Delete", value: SegmentMenuAction.Delete, variant: SegmentOptionVariant.Warning }, +]; \ No newline at end of file diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/operators.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/operators.ts new file mode 100644 index 0000000..12bca46 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/constants/segments/operators.ts @@ -0,0 +1,12 @@ +import { SegmentExpressionOperator } from "@komi-app/fans-sdk"; +import { SelectOption } from "../../types/select"; + +export const operatorLabelMap: Record = { + [SegmentExpressionOperator.And]: "and", + [SegmentExpressionOperator.Or]: "or", +}; + +const comparators: SegmentExpressionOperator[] = Object.values(SegmentExpressionOperator); +export const operatorOptions = comparators.map>( + (comparator) => ({ label: operatorLabelMap[comparator], value: comparator }), +); \ No newline at end of file diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/modals.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/modals.ts new file mode 100644 index 0000000..97f81fb --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/modals.ts @@ -0,0 +1,80 @@ +import { OpenConfirmationModalProps } from "@komi-app/components"; + +type VoidFn = () => void | Promise; +export interface ModalContext { + onConfirm: VoidFn; + onCancel?: VoidFn; +}; + +export const unsavedModalContext = (props: ModalContext): OpenConfirmationModalProps => ({ + title: "You Have Unsaved Changes", + description: "You haven’t saved your latest changes, would you like to save them before you leave?", + buttons: [ + { + label: "Don't save", + action: "cancel", + variant: "secondary", + }, + { + label: "Save", + action: "confirm", + variant: "primary", + }, + ], + className: "segment-page__modal", + ...props, +}); +export const deleteModalContext = (props: ModalContext): OpenConfirmationModalProps => ({ + title: "Are you sure you want to delete this segment?", + description: "This segment will be deleted and you can’t undo this action.", + buttons: [ + { + label: "No thanks", + action: "cancel", + variant: "primary", + }, + { + label: "Delete", + action: "confirm", + variant: "secondary", + }, + ], + className: "segment-page__modal", + ...props, +}); +export const noConsentModalContext = (props: ModalContext): OpenConfirmationModalProps => ({ + title: "Not All Contacts Have Consented to Receive Marketing Communications", + description: "Marketing to contacts who have not granted their consent may be a breach of privacy laws and / or may violate our terms of service. ", + buttons: [ + { + label: "Cancel", + action: "cancel", + variant: "secondary", + }, + { + label: "Save", + action: "confirm", + variant: "primary", + }, + ], + className: "segment-page__modal", + ...props, +}); +export const exportModalContext = (props: ModalContext): OpenConfirmationModalProps => ({ + title: "You’re About to Export Audience Contact Data", + description: "By continuing with this export you agree to only use this data in line with our terms of service, your privacy policy and the relevant laws of your jurisdiction.", + buttons: [ + { + label: "Cancel", + action: "cancel", + variant: "secondary", + }, + { + label: "Export", + action: "confirm", + variant: "primary", + }, + ], + className: "segment-page__modal", + ...props, +}); diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/types/datepicker.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/types/datepicker.ts new file mode 100644 index 0000000..93a40b8 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/types/datepicker.ts @@ -0,0 +1,30 @@ +import { Nullable } from "./nullable"; + +export type NullableDateRange = { + start: Nullable; + end: Nullable; + cadence?: DateCadence; +} +export type DateRange = { + start: Date; + end: Date; + cadence?: DateCadence; +} + +export enum DateCadence { + Day = "DAY", + Week = "WEEK", + Month = "MONTH", + Year = "YEAR", +} +export enum DateRangeType { + All = "ALL_TIME", + Custom = "CUSTOM", + Since = "SINCE", + Last = "LAST", +} +export enum DatePickerType { + After = "After", + Before = "Before", + Between = "Between", +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/types/select.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/types/select.ts new file mode 100644 index 0000000..5df773d --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/types/select.ts @@ -0,0 +1,32 @@ +export enum CommonSelectOptions { + Any = "ANY", +} +export type CommonOptionType = T | CommonSelectOptions; + +export enum SegmentOptionVariant { + Warning = "warning", +} +export interface SelectOption< + T extends unknown = string, +> { + groupName?: string; + index?: number; + label: string; + tags?: string[]; + value: T; + variant?: SegmentOptionVariant; +} + +export interface SelectFilter< + OptionType extends unknown, + FilterType extends string = string +> { + allOptionLabel?: string; + label: string; + options: SelectOption[]; + selectedValue?: FilterType; + filter: ( + options: SelectOption[], + selectedValue: FilterType, + ) => SelectOption[]; +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/array.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/array.ts new file mode 100644 index 0000000..0176ef8 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/array.ts @@ -0,0 +1,21 @@ +/** + * Maps arguments of the same type to a flat array, excluding null and undefined values + * + * @param {T | T[] | null | undefined} args + * @returns {T[]} + */ +export const arrayify = (...args: (T | T[] | null | undefined)[]): T[] => { + return args + .flatMap(arg => arg ?? []) + .filter(arg => arg !== null && arg !== undefined); +}; + +/** + * Gets the first value of an array, or the value itself if it's not an array + * + * @param {T | T[]} arg + * @returns {T} + */ +export const getFirstOrValue = (arg: T | T[] = []) => ( + Array.isArray(arg) ? arg[0] : arg +); diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/attributes.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/attributes.ts new file mode 100644 index 0000000..ed8c9e2 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/attributes.ts @@ -0,0 +1,88 @@ +import { + SegmentActionProperty, + SegmentActionPropertyAttribute, + SegmentAttribute, + SegmentAttributeComparator, +} from "@komi-app/fans-sdk"; +import { SelectOption } from "../types/select"; + +// TODO: add config service +const serverEndpoint = `http://localhost:3000`; + +/** + * Validates if the comparator is a range comparator + * + * @param comparator The comparator to check + * @returns If the comparator is a range comparator + */ +export const isRange = (comparator: SegmentAttributeComparator) => ( + comparator === SegmentAttributeComparator.Bt + || comparator === SegmentAttributeComparator.Nbt +); + +/** + * Fetches the property values from the Komi server + * + * @param {string} endpoint The Komi server endpoint to fetch from + * @returns Fetched property values + */ +export const fetchPropValues = async (endpoint: string): Promise => { + const propertyValueEndpoint = `${serverEndpoint}${endpoint}`; + const response = await fetch( + propertyValueEndpoint, + { + // headers: mockHeaders, + }, + ); + + const json = await response.json(); + return json.payload; +}; + +/** + * Gets the attribute by name from the attributes array + * + * @param {string} name The name of the attribute to search + * @param {SegmentAttribute[]} attributes The attributes to search + * @returns The attribute with the given name + */ +export const getAttributeByName = ( + name: string, + attributes: SegmentAttribute[], +): SegmentAttribute => { + const attribute = attributes.find(attribute => attribute.name === name); + if (!attribute) { + throw new Error(`Attribute ${name} not found`); + } + + return attribute; +}; + +/** + * Converts the properties to options + * + * @param {SegmentActionPropertyAttribute[]} properties The properties to convert + * @returns Converted options from properties + */ +export const segmentAttributePropertiesToOptions = ( + properties: SegmentActionPropertyAttribute[], +) => ( + properties.map>( + ({ name }) => ({ label: name, value: name }) + ) +); + +/** + * Converts the attributes to options + * + * @param {SegmentAttribute[]} attributes The attributes to convert + * @param {string} groupName The group name to use for the options + * @returns The options converted from attributes + */ +export const segmentAttributesToOptions = (attributes: SegmentAttribute[], groupName?: string) => ( + attributes.map>((attribute) => ({ + groupName, + label: attribute.name, + value: attribute, + })) +); diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/fns.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/fns.ts new file mode 100644 index 0000000..47335ac --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/fns.ts @@ -0,0 +1,30 @@ +export const noop = (arg?: T) => { }; +export const identity = (args: T) => args; + +export function invert(invertable: boolean): boolean; +export function invert(invertable: (toggle: boolean) => void): (toggle: boolean) => void; +export function invert(invertable: boolean | ((toggle: boolean) => void)) { + return (typeof invertable === 'function') + ? (toggle: boolean) => invertable(!toggle) + : !invertable; +} + +export const compose = (...functions: ((val: T) => T)[]): (val: T) => T => ( + (input: T): T => functions.reduceRight((value, func) => func(value), input) +); +export const series = async (functions: ((val: T) => Promise)[], input: T): Promise => { + let result = input; + + for (const func of functions) { + result = await func(result); + } + + return result; +}; +export const sequence = async (...functions: ((val: T) => void)[]): Promise<(val: T) => void> => { + return (input: T): void => { + for (const func of functions) { + func(input); + } + }; +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/segments.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/segments.ts new file mode 100644 index 0000000..00450d5 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/segments.ts @@ -0,0 +1,156 @@ +import { anySelectionValues } from "../constants/segments/attributes"; + +import { DateRangeType } from "../../BuilderPage/types/datepicker"; +import { + SegmentExpressionOperator, + SegmentExpressionType, + NestedSegmentStatement, + SegmentAttribute, + SegmentAttributeComparator, + SegmentAttributeType, + SegmentExpression, + Segment, + ContactType, + MarketingPermission, +} from "@komi-app/fans-sdk"; + +import { v4 } from "uuid"; + +export const createDefaultSegment = ( + talentProfileId: string, +): Segment => ({ + segmentId: v4(), + talentProfileId, + name: "", + contactType: ContactType.Any, + marketingPermission: MarketingPermission.Any, + expressions: [], +}); +export const createDefaultExpression = ( + attributeType: SegmentAttributeType, + expression?: Partial, +): SegmentExpression => ({ + expressionId: v4(), + ruleOrder: 0, + expressionOrder: 0, + expressionType: SegmentExpressionType.Clause, + expressionOperator: SegmentExpressionOperator.And, + attributeName: "", + attributeType, + attributeComparator: SegmentAttributeComparator.Eq, + attributeValues: anySelectionValues, + dateRangeEnd: 0, + dateRangeStart: 0, + dateRangeType: DateRangeType.All, + inverted: false, + ...expression, +}); +export const createDefaultClause = ( + attributeType: SegmentAttributeType, + expression?: Partial, +): SegmentExpression => ({ + expressionId: v4(), + ruleOrder: 0, + expressionOrder: 0, + expressionType: SegmentExpressionType.Clause, + expressionOperator: SegmentExpressionOperator.And, + attributeType, + attributeName: "", + attributeComparator: SegmentAttributeComparator.Eq, + attributeValues: anySelectionValues, + dateRangeEnd: 0, + dateRangeStart: 0, + dateRangeType: DateRangeType.All, + inverted: false, + ...expression, +}); +export const createDefaultStatement = ( + attributeType: SegmentAttributeType, + expression?: Partial, +): SegmentExpression => ({ + expressionId: v4(), + ruleOrder: 0, + expressionOrder: 0, + expressionType: SegmentExpressionType.Statement, + expressionOperator: SegmentExpressionOperator.And, + attributeType, + attributeName: "", + attributeComparator: SegmentAttributeComparator.Gte, + attributeValues: ["1"], + dateRangeEnd: 0, + dateRangeStart: 0, + dateRangeType: DateRangeType.All, + inverted: false, + ...expression, +}); + +/** + * Order the expressions within a rule, according to their `expressionOrder` property + * + * @param {SegmentRule} rule The rule whose expressions will be ordered + * @returns {SegmentRule} The rule with ordered expressions + */ +export const orderExpressions = (expressions: SegmentExpression[]) => { + return expressions.reduce( + (expressions, expression) => { + expressions[expression.expressionOrder] = expression; + return expressions; + }, + [], + ); +}; + +/** + * Sets the expressionOrder of expressions to their current array positions, offset + * by an optional start order + * + * @param {SegmentExpression[]} expressions + * @param {number} startOrder + * @returns {SegmentExpression[]} + */ +export const reorderExpressions = (expressions: SegmentExpression[], startOrder: number = 0) => { + return expressions.map((expression, expressionIndex) => { + const modifiedExpression: SegmentExpression = { + ...expression, + expressionOrder: expressionIndex + startOrder, + }; + return modifiedExpression; + }); +}; + +/** + * Flattens an array of nested statements into an array of statements and clauses, preserving the + * input order of the nested statement expressions. + * + * @param {NestedSegmentStatement[]} nestedStatements The nested statements to flatten + * @returns {SegmentExpression[]} A flattened array of statements and their clauses, with retained order + */ +export const flattenNestedStatements = (nestedStatements: NestedSegmentStatement[]) => { + return nestedStatements.reduce( + (expressions, nestedStatement) => { + const { clauses, statementProperties, ...unnestedStatement } = nestedStatement; + + return [ + ...expressions, + unnestedStatement, + ...clauses, + ]; + }, + [], + ); +}; + +/** + * Unnest the statement from a nested statement, returning the statement and + * the nested properties separately + * + * @param {INestedSegmentStatement} nestedStatement The statement to unnest + */ +export const unnestStatement = (nestedStatement: NestedSegmentStatement): { + clauses: SegmentExpression[], + statementProperties: SegmentAttribute[], + statement: SegmentExpression, +} => { + const { clauses, statementProperties, ...statement } = nestedStatement; + return { clauses, statementProperties, statement }; +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/select.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/select.ts new file mode 100644 index 0000000..f9c25da --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/BuilderPage/utils/select.ts @@ -0,0 +1,135 @@ +import { SelectOption } from "../types/select"; +import { arrayify } from "./array"; + +/** + * Finds the first option, if any, that matches the given value. + * + * @param {OptionType[] | OptionType | undefined} values The values to find + * @param {SelectOptionType[]} options The options to search in + * @returns {SelectOptionType | undefined} The first option that matches the given value + */ +export const findValuesInOptions = < + OptionType extends unknown = string, + SelectOptionType extends SelectOption = SelectOption, +>( + values: OptionType[] | OptionType | undefined, + options: SelectOptionType[], +) => { + const resolvedValues = arrayify(values); + + // Create a value-option map + const maybeOptions = resolvedValues.map(value => { + const option = options.find(option => option.value === value); + return option; + }); + + return maybeOptions.filter( + (maybeOption): maybeOption is SelectOptionType => Boolean(maybeOption), + ); +}; + +/** + * Convert an array of values to an array of options. + * + * @param {OptionType[] | OptionType | undefined} values The values to convert + * @returns {SelectOption[]} The array of options + */ +export const valuesToOptions = < + OptionType extends string = string, +>( + values: OptionType[] | OptionType | undefined, +) => { + const resolvedValues = arrayify(values); + + // Map values to options + return resolvedValues.map>(value => ({ + label: value, + value, + })); +}; + +/** + * Convert an object to an array of options, with its entry values + * as option values, and its entry keys as option labels. + * + * @param {Record} obj The object to convert + * @returns {SelectOption[]} The array of options + */ +export const objectValuesToOptions = ( + obj: Record, +): SelectOption[] => { + const entries = Object.entries(obj); + return entries.map>( + ([label, value]) => ({ label, value }) + ); +}; + +/** + * Convert an object to an array of options, with its entry keys + * as option values, and its entry values as option labels. + * + * @param {Record} obj The object to convert + * @returns {SelectOption[]} The array of options + **/ +export const objectKeysToOptions = ( + obj: Record, +): SelectOption[] => { + const entries = Object.entries(obj); + return entries.map( + ([value, label]) => ({ label, value }) + ); +}; + +/** + * Group options by their group name in a map + * + * @param {SelectOptionType[]} options + * @returns {Record} + */ +export const groupOptions = < + OptionType extends unknown, + SelectOptionType extends SelectOption, +>(options: SelectOptionType[]): Record => ( + options.reduce>( + (groupedOptions, option) => { + const { groupName = "" } = option; + + // Create the group if it doesn't exist + if (!groupedOptions[groupName]) { + groupedOptions[groupName] = []; + } + + // Add the option to the group map entry + groupedOptions[groupName].push(option); + + return groupedOptions; + }, + {}, + ) +); + +/** + * Convert an array of options to a string label. + * + * @param {SelectOption[]} options The options to labelize + * @returns {string} The label, abbreviated if necessary + */ +export const optionsToAbbreviatedLabel = (options: SelectOption[]): string => { + // Resolve label if selected options is array + if (options.length === 0) { + return ""; + } + + const firstOption = options[0]; + if (options.length === 1) { + return firstOption.label; + } + + const secondOption = options[1]; + if (options.length === 2) { + return `${firstOption.label} and ${secondOption.label}`; + } + + const remainingLength = options.length - 2; + return `${firstOption.label}, ${secondOption.label} and ${remainingLength} more`; +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/SegmentQueryBuilderPage.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/SegmentQueryBuilderPage.tsx new file mode 100644 index 0000000..4c22c00 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/SegmentQueryBuilderPage.tsx @@ -0,0 +1,211 @@ +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +import React, { useEffect, useState } from "react"; + +import { segmentsUrl } from "constants/dca/segments"; + +import { useDebouncedCallback } from "hooks/useDebouncedCallback"; + +import { services } from "services"; +import { Prompt, useHistory } from "react-router-dom"; +import { Location } from "history"; + +import { + SegmentMenuAction, + SegmentMode, + Segment, + ZSegmentIdObject, + SegmentProperty, +} from "@komi-app/fans-sdk"; + +import notification from "utils/notification"; +import { createDefaultSegment } from "utils/dca/segments"; +import { useTalentProfileId } from "redux/User/utils"; + +import classnames from "classnames"; +import "./SegmentQueryBuilderPage.scss"; +import { getSegmentPropertyAttributes } from "./attributes"; +import { SegmentPage } from "./BuilderPage/BuilderPage"; + +export interface SegmentQueryBuilderProps + extends React.HTMLProps { + mode?: SegmentMode; + segmentId?: string; +} + +const SegmentQueryBuilder: React.FC = ({ + mode, + segmentId, + ...props +}) => { + const router = useHistory(); + const talentProfileId = useTalentProfileId(); + + const [segment, setSegment] = useState( + createDefaultSegment(talentProfileId), + ); + const [contactCount, setContactCount] = useState(0); + const [navigatingLocation, setNavigatingLocation] = + useState | null>(null); + + const [loading, setLoading] = useState(false); + const [isUnsaved, setIsUnsaved] = useState(false); + + const fetchSegment = async (segmentId: string) => { + setLoading(true); + + const fetchedSegment = await services.segment().getSegment(segmentId); + if (fetchedSegment) { + setSegment(fetchedSegment); + } + + setLoading(false); + }; + + const fetchContactCount = async () => { + const res = ZSegmentIdObject.safeParse(segment); + if (!res.success) return; + + // TODO: modify to accept entire segment for querying, so that + // we may handle unsaved segments + const count = (await services.segment().countSegmentQuery(segment)) || 0; + setContactCount(count); + }; + const debouncedFetchContactCount = useDebouncedCallback( + fetchContactCount, + 750, + ); + + /** + * Updates the segment state and fetches the contact count + * @param {Segment} updatedSegment + */ + const handleSegmentChange = (updatedSegment: Segment) => { + setIsUnsaved(true); + setSegment(updatedSegment); + }; + + /** + * Navigates back to the segments list + */ + const handleBackClick = () => { + setIsUnsaved(false); + router.push(segmentsUrl); + }; + + /** + * Saves the segment + */ + const handleSaveClick = async () => { + const { segmentId } = segment; + + const segmentName = encodeURIComponent(segment.name); + + if (!segment.name) { + return false; + } + const nameExists = await services.segment().exists(segmentName); + + const fetchedSegment = await services + .segment() + .getSegmentByName(segmentName); + const isCurrentSegment = Boolean(fetchedSegment?.segmentId === segmentId); + + if (!isCurrentSegment && nameExists) { + notification.error({ + message: "A segment with that name already exists.", + }); + return false; + } + + // Attempt to save or create + // TODO: create an upsert endpoint? + try { + if (mode === SegmentMode.Edit) { + await services.segment().updateSegment(segment); + } else { + await services.segment().createSegment(segment); + } + + notification.success({ message: "Segment saved successfully." }); + + setIsUnsaved(false); + router.push(segmentsUrl); + + return true; + } catch (error) { + notification.error({ message: "There was an issue saving the segment." }); + + return false; + } + }; + + const handleMenuOptionClick = async (option: SegmentMenuAction) => { + switch (option) { + case SegmentMenuAction.ExportCsv: + break; + + case SegmentMenuAction.Delete: + await services.segment().deleteSegment(segment.segmentId); + router.push(segmentsUrl); + + break; + + case SegmentMenuAction.Send: + break; + } + }; + + const handleBlockedNavigation = (nextLocation: Location) => { + if (isUnsaved) { + setNavigatingLocation(nextLocation); + } + + // Allow navigation if there are no unsaved changes + return !isUnsaved; + }; + + useEffect(() => { + if (navigatingLocation && !isUnsaved) { + // Navigate to the previous blocked location with your navigate function + window.location.href = navigatingLocation.pathname; + } + }, [isUnsaved, navigatingLocation]); + + /** + * Fetches the segment on component load + */ + useEffect(() => { + if (mode === SegmentMode.Edit && segmentId) { + fetchSegment(segmentId); + } + }, [mode, segmentId]); + + /** + * Fetches the contact count on segment change + */ + useEffect(() => void debouncedFetchContactCount(), [segment]); + + const classNames = classnames("segment-page"); + + return ( +
+ + + +
+ ); +}; + +export default SegmentQueryBuilder; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/attributes.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/attributes.ts new file mode 100644 index 0000000..348cdde --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentQueryBuilderPage/attributes.ts @@ -0,0 +1,199 @@ +import { CommonSelectOptions } from "../SegmentQueryBuilderPage/BuilderPage/types/select"; + +import { getCustomTagsSDK, getProductsSDK, services } from "services"; +import { + SegmentProperty, + SegmentPropertyAttribute, + SegmentValue, +} from "@komi-app/fans-sdk"; +import { format } from "date-fns"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +export const anySelectionValues = [CommonSelectOptions.Any]; + +export function getSegmentPropertyAttributes() { + const showSegmentByPhoneCountry = useFeatureIsOn( + FLAGS.FEAT_CRM_887_COUNTRY_CODE_SEGMENTS, + ); + + const showSegmentByProductPurchased = useFeatureIsOn( + FLAGS.FREE_DOWNLOADS_TALENT, + ); + + const segmentPropertyAttributes: SegmentPropertyAttribute[] = [ + { + name: SegmentProperty.Age, + defaultValues: ["18", "65"], + valueType: SegmentValue.Number, + }, + { + name: SegmentProperty.Country, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => services.fanGeoAgg().getCountries(), + }, + { + name: SegmentProperty.State, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => services.fanGeoAgg().getStates(), + }, + { + name: SegmentProperty.City, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => services.fanGeoAgg().getCities(), + }, + { + name: SegmentProperty.EmailMarketingStatus, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => [ + { + value: "SUBSCRIBED", + label: "Subscribed", + }, + { + value: "UNSUBSCRIBED", + label: "Unsubscribed", + }, + { + value: "NON_SUBSCRIBED", + label: "Non-subscribed", + }, + ], + }, + ]; + + segmentPropertyAttributes.push(segmentPropertyAttributesWithCustomTag); + if (showSegmentByProductPurchased) { + segmentPropertyAttributes.push(segmentPropertyAttributesWithProductPurchased); + } + segmentPropertyAttributes.push(segmentPropertyAttributeSignUp); + + if (showSegmentByPhoneCountry) { + segmentPropertyAttributes.push( + segmentPropertyAttributePhoneNumberCountryCode, + ); + } + + return segmentPropertyAttributes; +} + +export const segmentPropertyAttributes: SegmentPropertyAttribute[] = [ + { + name: SegmentProperty.Age, + defaultValues: ["18", "65"], + valueType: SegmentValue.Number, + }, + { + name: SegmentProperty.Country, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => services.fanGeoAgg().getCountries(), + }, + { + name: SegmentProperty.State, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => services.fanGeoAgg().getStates(), + }, + { + name: SegmentProperty.City, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => services.fanGeoAgg().getCities(), + }, + { + name: SegmentProperty.EmailMarketingStatus, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => [ + { + value: "SUBSCRIBED", + label: "Subscribed", + }, + { + value: "UNSUBSCRIBED", + label: "Unsubscribed", + }, + { + value: "NON_SUBSCRIBED", + label: "Non-subscribed", + }, + ], + }, +]; + +export const segmentPropertyAttributeSignUp = { + name: SegmentProperty.SignUpDate, + defaultValues: [ + format(Date.now(), "yyyy-MM-dd"), + format(Date.now(), "yyyy-MM-dd"), + ], + valueType: SegmentValue.DateRange, +}; + +export const segmentPropertyAttributesWithCustomTag = { + name: SegmentProperty.CustomTag, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => getCustomTagsList(), +}; +export const segmentPropertyAttributesWithProductPurchased = { + name: SegmentProperty.ProductPurchased, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => getProductList(), +}; + +export const segmentPropertyAttributePhoneNumberCountryCode = { + name: SegmentProperty.PhoneNumberCountryCode, + defaultValues: anySelectionValues, + valueType: SegmentValue.Option, + getOptions: () => getDistinctPhoneCountryCodes(), +}; + +const getDistinctPhoneCountryCodes = async () => { + const { countryCodes } = await services + .segment() + .getPhoneNumberCountryCodeOptions(); + + const sorted = countryCodes.sort((a, b) => a.label.localeCompare(b.label)); + + return sorted; +}; + +async function getCustomTagsList() { + //Fetch all the active tags + const tagCollection = await getCustomTagsSDK().getTags({ isActive: true }); + + /** + * Map tags to dropdown options + * Sort them by the label + */ + const mappedTags = tagCollection + .map((tag) => { + return { value: tag.id, label: tag.tagText }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + + return mappedTags; +} + +async function getProductList() { + //Fetch all the products + const productCollection = await getProductsSDK().getProducts({}); + + /** + * Map products to dropdown options + * Sort them by the label + */ + const mappedProducts = productCollection + .map((product) => { + return { value: product.productId, label: product.productName }; + }) + .sort((a, b) => a.label.localeCompare(b.label)); + + return mappedProducts; +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentsPage/SegmentsTable.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentsPage/SegmentsTable.tsx new file mode 100644 index 0000000..3aeb2ea --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentsPage/SegmentsTable.tsx @@ -0,0 +1,195 @@ +import React, { useMemo } from "react"; +import { + useConfirmationModal, + Pagination, + EmptyStateWithButton, + Paginate, +} from "@komi-app/components"; + +import { services } from "services"; +import { + Segment, + SegmentMode, + SegmentItemAction, + SegmentDeleteError, +} from "@komi-app/fans-sdk"; + +import { useHistory } from "react-router-dom"; +import { createSegmentUrl } from "utils/dca/segments"; +import { createNewCampaignWithSegment } from "utils/dca/campaigns"; +import notification from "utils/notification"; + +import "./SegmentsTable.scss"; +import { useSegmentsList } from "../Utils"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { SegmentItemSkeleton } from "../SegmentQueryBuilderPage/BuilderPage/Segment/SegmentItemSkeleton"; +import { + SegmentItemWithCount, + SegmentItemWithoutCount, +} from "../SegmentQueryBuilderPage/BuilderPage/Segment/SegmentItem"; + +export interface SegmentListProps { + searchTerm?: string; + onSegmentCreateClick: () => any; +} + +const SegmentsTable = ({ + searchTerm = "", + onSegmentCreateClick, +}: SegmentListProps) => { + const { + data, + isLoading, + currentPageIndex, + changePageIndex, + pageCount, + reset, + } = useSegmentsList(searchTerm); + + const router = useHistory(); + const confirmDeleteModal = useConfirmationModal(); + + const showConfirmDeleteModal = (segment: Segment) => { + confirmDeleteModal.open({ + title: "Are you sure you want to delete this segment?", + description: `This segment will be deleted and you can’t undo this action.`, + className: "segments-page__modal", + onConfirm: () => deleteSegment(segment), + buttons: [ + { + label: "No thanks", + action: "cancel", + variant: "primary", + }, + { + label: "Delete", + action: "confirm", + variant: "secondary", + }, + ], + }); + }; + + const onActionItemClick = (action: string, segment: Segment) => { + switch (action) { + case SegmentItemAction.DELETE: + return showConfirmDeleteModal(segment); + + case SegmentItemAction.EDIT: + return editSegment(segment); + + case SegmentItemAction.SEND: + return createCampaign(segment); + + case SegmentItemAction.EXPORT: + return; + } + }; + + const getSegmentCount = async (segment: Segment) => { + const segmentName = encodeURIComponent(segment.name); + + const segmentExists = await services.segment().exists(segmentName); + if (!segmentExists) { + return 0; + } + + const count = + (await services.segment().countSegmentQueryById(segment.segmentId)) || 0; + return count; + }; + + /** + * Deletes a single segment + * First checks if any campaigns are associated with the segment + * if yes, then it will not delete the segment + * @param deletedSegment + * @returns void + */ + const deleteSegment = async (deletedSegment: Segment) => { + try { + await services.segment().deleteSegment(deletedSegment.segmentId); + await reset(); + + notification.success({ + message: `Your segment ${deletedSegment.name} has been deleted.`, + }); + } catch (error) { + if (error instanceof SegmentDeleteError) { + if (error.status === 409) { + notification.error({ + message: `The segment is currently being used by campaigns and cannot be deleted.`, + }); + return; + } + } + + notification.error({ + message: `Unable to delete segment.`, + }); + } + }; + const editSegment = ({ segmentId }: Segment) => { + router.push(createSegmentUrl({ mode: SegmentMode.Edit, segmentId })); + }; + + const createCampaign = (segment: Segment) => { + // TODO: integrate campaigns + router.push(createNewCampaignWithSegment(segment.segmentId)); + }; + + const skeletonItems = useMemo( + () => + Array.from({ length: 10 }, (_, index) => ( + + )), + [], + ); + + return ( +
+
+ {isLoading ? ( + skeletonItems + ) : data.length === 0 ? ( + + ) : ( + data.map((segment, index) => ( + editSegment(segment)} + /> + )) + )} + + {searchTerm && !isLoading && data.length === 0 && ( +
+ No results matching "{searchTerm}" +
+ )} +
+ +
+ changePageIndex(selected)} + pageRangeDisplayed={5} + marginPagesDisplayed={1} + /> +
+
+ ); +}; + +export default SegmentsTable; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentsPage/SegmentsWithCursorPage.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentsPage/SegmentsWithCursorPage.tsx new file mode 100644 index 0000000..f269c9b --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/SegmentsPage/SegmentsWithCursorPage.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from "react"; +import { TalentButton, Input } from "@komi-app/components"; + +import { SegmentMode } from "@komi-app/fans-sdk"; + +import { useHistory, useLocation } from "react-router-dom"; +import { createSegmentUrl } from "utils/dca/segments"; + +import "./SegmentsPage.scss"; +import SegmentsTable from "./SegmentsTable"; +import CustomTagsLimitError from "../../Common/CustomTagLimitError"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useIsCustomTagsLimitCrossed } from "utils/dca/custom-tags"; +import notification from "utils/notification"; +import { ReviewSuccessfulState } from "pages/AudienceAndCampaigns/typesUtils"; + +const SegmentsWithCursorPage = () => { + const location = useLocation(); + const router = useHistory(); + const [searchTerm, setSearchTerm] = useState(""); + + const isCustomTagsEnabled = useFeatureIsOn(FLAGS.FEAT_CRM_CUSTOM_TAGS_IMPORT); + const hasCrossedTagLimit = isCustomTagsEnabled + ? useIsCustomTagsLimitCrossed() + : false; + + const createSegment = () => { + router.push(createSegmentUrl({ mode: SegmentMode.Create })); + }; + + useEffect(() => { + //If custom tags aren't enabled, we do not show a message + if (!isCustomTagsEnabled) return; + + /** + * wasReviewSuccessful - Coming from custom tags review page, + * if the value of wasReviewSuccessful is true, we need to show + * the user a success message; + */ + const wasReviewSuccessful = Boolean(location.state?.wasReviewSuccessful); + + if (wasReviewSuccessful) { + //Show success message to user + notification.success({ + message: + "Your tags have been saved and added to the imported contacts.", + }); + // Clear the state + router.replace({ + ...location, + state: undefined, + }); + } + }, []); + + return ( +
+
+ +
+
+ +
+ + NEW SEGMENT + +
+
+ +
+
+
+ ); +}; + +export default SegmentsWithCursorPage; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/Utils.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/Utils.ts new file mode 100644 index 0000000..670e76c --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Segments/Utils.ts @@ -0,0 +1,23 @@ +import { usePagination } from "hooks/usePagination"; +import { services } from "services"; + +export function useSegmentsList(searchTerm: string) { + return usePagination( + "segments", + async (queryParams) => { + return await services.segment().getSegmentsWithCursor( + queryParams, + searchTerm + ); + }, + async (countParams) => { + return await services.segment().getSegmentsCount( + countParams, + searchTerm, + ); + }, + 10, + "DESC", + [searchTerm] + ); +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/SystemTemplates.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/SystemTemplates.tsx new file mode 100644 index 0000000..7a159d0 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/SystemTemplates.tsx @@ -0,0 +1,91 @@ +import { useHistory } from "react-router-dom"; +import { useCallback, useEffect, useState } from "react"; +import { + ChipFilterProps, + EmailTemplateListPage, + useToast, +} from "@komi-app/creator-ui"; +import { GetSystemTemplatesQuery } from "@komi-app/messaging-sdk"; +import { getSystemTemplatesSDK } from "services"; +import { dcaTemplatesCreateUrl, dcaTemplatesUrl } from "constants/dca"; +import { useSystemTemplates, useSystemCategories } from "./utils"; +import { allCategoriesOption, blankSystemTemplate } from "./config"; +import { TemplateCreateRouteState } from "../TemplateBuilderPage/TemplateBuilderPage"; + +const SystemTemplates: React.FC = () => { + const [filters, setFilters] = useState({}); + const [categories, setCategories] = useState([]); + + const systemTemplates = useSystemTemplates(filters); + const { systemCategories, hasFetchedCategories } = useSystemCategories(); + const router = useHistory(); + const { createToast } = useToast(); + + const handleCategoryClick = useCallback( + (id: string) => { + try { + // Update selected state for categories + setCategories( + categories.map((category) => ({ + ...category, + selected: category.id === id, + })), + ); + + // Update filters + if (id === allCategoriesOption.id) { + const { category, ...rest } = filters; + setFilters(rest); + } else { + setFilters({ ...filters, category: id }); + } + } catch (error) { + createToast({ + semantic: "error", + text: "We couldn’t load goals. Try again.", + dismissible: true, + }); + } + }, + [categories, filters], + ); + + const handleTemplateSelect = useCallback(async (templateId: string) => { + try { + const redirectState: TemplateCreateRouteState = {}; + + if (templateId !== blankSystemTemplate.id) { + const { template } = + await getSystemTemplatesSDK().getSystemTemplate(templateId); + redirectState.selectedTemplate = template.json; + } + + router.push(dcaTemplatesCreateUrl, redirectState); + } catch (error) { + createToast({ + semantic: "error", + text: "We couldn’t load the selected template. Try again.", + dismissible: true, + }); + } + }, []); + + useEffect(() => { + setCategories(systemCategories); + }, [hasFetchedCategories]); + + return ( + <> + + + ); +}; + +export default SystemTemplates; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/config.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/config.tsx new file mode 100644 index 0000000..9d7e945 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/config.tsx @@ -0,0 +1,42 @@ +import { + MdApps as Apps, + MdMailOutline as MailOutline, + MdOutlineGroup as GroupOutlined, + MdOutlineAudiotrack as AudiotrackOutlined, + MdPodcasts as Podcasts, + MdOutlineSmartDisplay as SmartDisplayOutlined, + MdOutlineConfirmationNumber as ConfirmationNumberOutlined, + MdOutlineShoppingBag as ShoppingBagOutlined, + MdOutlinePaid as PaidOutlined, +} from "react-icons/md"; +import { EmailTemplateItem } from "@komi-app/creator-ui"; +import config from "config"; + +// Category icons mapping with improved type safety +export const categoryIcons: Record = { + "Show all": , + "Send a newsletter": , + "Welcome subscribers": , + "Promote music": , + "Promote podcast": , + "Promote video": , + "Sell tickets": , + "Sell merch": , + "Promote affiliate links": , +}; + +export const getCategoryIcon = (category: string): React.ReactNode => + categoryIcons[category] || ; + +export const blankSystemTemplate: EmailTemplateItem = { + id: "START_FROM_SCRATCH", + title: "Start from scratch", + imageURL: `${config.service.assetUrl}/assets/images/blank-system-template.svg`, +}; + +export const allCategoriesOption = { + id: "Show all", + label: "Show all", + selected: true, + prefixIcon: getCategoryIcon("Show all"), +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/utils.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/utils.tsx new file mode 100644 index 0000000..5f99b57 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/SystemTemplates/utils.tsx @@ -0,0 +1,135 @@ +import { useQuery } from "@tanstack/react-query"; +import { + ChipFilterProps, + EmailTemplateItem, + useToast, +} from "@komi-app/creator-ui"; +import { + GetSystemTemplateCategoriesResponse, + GetSystemTemplatesQuery, + GetSystemTemplatesResponse, + SystemTemplate, + SystemTemplateCategory, +} from "@komi-app/messaging-sdk"; +import { getSystemTemplatesSDK } from "services"; +import { + allCategoriesOption, + blankSystemTemplate, + getCategoryIcon, +} from "./config"; +import { useEffect } from "react"; + +interface UseSystemCategoriesResult { + systemCategories: ChipFilterProps[]; + hasFetchedCategories: boolean; +} + +interface ErrorNotification { + isError: boolean; + text: string; + dismissible?: boolean; +} + +/** + * Hook to fetch and transform system templates based on filters + * @param filters Query parameters for filtering templates + */ +export const useSystemTemplates = ( + filters: GetSystemTemplatesQuery, +): EmailTemplateItem[] => { + const { data, isError } = useQuery({ + queryKey: ["systemTemplates", filters], + queryFn: () => getSystemTemplatesSDK().getSystemTemplates(filters), + refetchOnWindowFocus: false, + }); + //Show toast if there was an error + useErrorNotification({ + isError, + text: "We couldn’t load the templates. Try again.", + }); + + return mapSystemTemplates(data); +}; + +/** + * Hook to fetch and transform system template categories + */ +export const useSystemCategories = (): UseSystemCategoriesResult => { + const { data, isFetched, isError } = useQuery({ + queryKey: ["systemTemplateCategories"], + queryFn: () => getSystemTemplatesSDK().getSystemTemplateCategories(), + refetchOnWindowFocus: false, + }); + + //Show toast if there was an error + useErrorNotification({ + isError, + text: "We couldn’t load goals. Try again.", + }); + + return { + systemCategories: mapSystemTemplateCategories(data), + hasFetchedCategories: isFetched, + }; +}; + +export function useErrorNotification({ + isError, + text, + dismissible = true, +}: ErrorNotification) { + const { createToast } = useToast(); + + useEffect(() => { + if (isError) { + createToast({ + semantic: "error", + text, + dismissible, + }); + } + }, [isError]); +} + +/** + * Maps API template data to frontend template format. + * First template options is always "Blank template" + * @param data Response from the templates API + */ +const mapSystemTemplates = ( + data: GetSystemTemplatesResponse | undefined, +): EmailTemplateItem[] => { + if (!data?.templates) return [blankSystemTemplate]; + return [ + blankSystemTemplate, + + ...data.templates.map((template: SystemTemplate) => ({ + id: template.id, + title: template.name, + imageURL: template.thumbnailUrl, + })), + ]; +}; + +/** + * Maps API category data to frontend chip filter format. + * First category is always "All categories" + * @param data Response from the categories API + */ +const mapSystemTemplateCategories = ( + data: GetSystemTemplateCategoriesResponse | undefined, +): ChipFilterProps[] => { + if (!data?.categories.length) return [allCategoriesOption]; + + return [ + allCategoriesOption, + ...data.categories.map((category: SystemTemplateCategory) => { + return { + id: category.key, + label: category.name, + prefixIcon: getCategoryIcon(category.name), + selected: false, + }; + }), + ]; +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilder/TemplateBuilder.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilder/TemplateBuilder.tsx new file mode 100644 index 0000000..b51136a --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilder/TemplateBuilder.tsx @@ -0,0 +1,137 @@ +import React, { useState } from "react"; +import EmailEditor, { Design, Editor, EditorRef } from "react-email-editor"; + +import { TemplateAddressDto } from "types/templates"; + +import { startingTemplate } from "./assets/startingTemplate"; +import { emptyAddress } from "../utils"; +import { unlayerOptions } from "../constants"; +import { noop } from "utils/noop"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +export interface TemplateBuilderProps { + // eslint-disable-next-line @typescript-eslint/ban-types + initialTemplate?: object; + onDesignUpdated: () => void; + setAddress: (address: TemplateAddressDto) => void; + setDesign: (design: Design) => void; + setEmailEditor: (editor: Editor) => void; + setHtml: (html: string) => void; + setIsLoading: (isSaving: boolean) => void; +} + +const TemplateBuilder = React.forwardRef( + ( + { + initialTemplate, + onDesignUpdated, + setAddress, + setDesign = noop, + setEmailEditor, + setHtml, + setIsLoading, + }, + emailEditorRef, + ) => { + const isAllowDeleteFooter = useFeatureIsOn( + FLAGS.FEAT_CRM_1267_ALLOW_DELETE_FOOTER, + ); + + const [templateSet, setTemplateSet] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + + const emailEditor = + typeof emailEditorRef === "function" + ? null + : emailEditorRef?.current?.editor; + const setDefaultContentWidth = useFeatureIsOn( + FLAGS.FEAT_CRM_1249_EMAIL_TEMPLATE_DEFAULT_WIDTH, + ); + + // Called when the editor is ready + const onReady = () => { + if (!hasLoaded) { + setHasLoaded(true); + setIsLoading(false); + } + + if (!emailEditor) return; + setEmailEditor(emailEditor); + + // Add a listener for when the design is updated + emailEditor.addEventListener("design:updated", function () { + onDesignUpdated(); + + if (!emailEditor) return; + + emailEditor.exportHtml((data) => { + setHtml(data.html); + setDesign(data.design); + + const address = getAddress(data.design); + + setAddress(address ?? emptyAddress()); + }); + }); + + if (templateSet) return; + + const designToLoad = initialTemplate + ? initialTemplate + : startingTemplate(isAllowDeleteFooter, setDefaultContentWidth); + + emailEditor.loadDesign(designToLoad as Design); + + setTemplateSet(true); + }; + + // Some more than average gnarlyness here. + // We have to manually retrieve the values of the custom block + // so we can parse the address and pass them up + const getAddress = (design: Design) => { + // If there's no custom block return undefined + const addressBlock = (design.body.rows as any) + .flatMap((x: any) => x.columns.flatMap((y: any) => y.contents)) + .find((z: any) => z.slug === "komi_campaign_email_footer")?.values; + + if (!addressBlock) return undefined; + + const { + address_line_a: firstLine, + address_line_b: secondLine, + address_city: city, + address_country: country, + address_zip_code: zipcode, + } = addressBlock; + + if (!firstLine && !secondLine && !city && !country && !zipcode) + return undefined; + + // Parse the address values and return them + const address = { + firstLine: firstLine === "" ? null : firstLine, + secondLine: secondLine === "" ? null : secondLine, + city: city === "" ? null : city, + country: country === "" ? null : country, + zipcode: zipcode === "" ? null : zipcode, + }; + + return address; + }; + + // Custom blocks are created within Unlayer itself: + // - Komi Footer: https://dashboard.unlayer.com/projects/148721/design/tools/7868 + return ( +
+ +
+ ); + }, +); + +TemplateBuilder.displayName = "TemplateBuilder"; +export default TemplateBuilder; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilder/assets/startingTemplate.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilder/assets/startingTemplate.ts new file mode 100644 index 0000000..89ecf36 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilder/assets/startingTemplate.ts @@ -0,0 +1,141 @@ +export const startingTemplate = ( + allowFooterDelete = false, + setDefaultContentWidth = false, +) => ({ + counters: { + u_column: 2, + u_row: 2, + u_content_custom_komi_campaign_email_footer: 2, + }, + body: { + id: "NPqIFx6lQH", + rows: [ + { + id: "2xw47muTU-", + cells: [1], + columns: [ + { + id: "DW26HiyGIx", + contents: [ + { + id: "mRWpRs2uUo", + type: "custom", + slug: "komi_campaign_email_footer", + values: { + _meta: { + htmlID: "u_content_custom_komi_campaign_email_footer_2", + htmlClassNames: + "u_content_custom_komi_campaign_email_footer", + }, + anchor: "", + hideable: false, + deletable: allowFooterDelete, + draggable: true, + selectable: true, + text_color: "#FFFFFF", + address_city: "", + duplicatable: false, + address_line_a: "", + address_line_b: "", + address_country: "", + address_zip_code: "", + background_color: "#292929", + containerPadding: "10px", + displayCondition: null, + unsubscribe_label: "Unsubscribe", + }, + }, + ], + values: { + _meta: { + htmlID: "u_column_2", + htmlClassNames: "u_column", + }, + }, + }, + ], + values: { + _meta: { + htmlID: "u_row_2", + htmlClassNames: "u_row", + }, + anchor: "", + columns: false, + padding: "0px", + hideable: false, + deletable: false, + draggable: true, + selectable: true, + hideDesktop: false, + duplicatable: true, + backgroundColor: "", + backgroundImage: { + url: "", + size: "custom", + repeat: "no-repeat", + position: "center", + fullWidth: true, + }, + displayCondition: null, + columnsBackgroundColor: "", + }, + }, + ], + values: { + popupPosition: "center", + popupWidth: "600px", + popupHeight: "auto", + borderRadius: "10px", + contentAlign: "center", + contentVerticalAlign: "center", + contentWidth: setDefaultContentWidth ? "600px" : "500px", + fontFamily: { + label: "Arial", + value: "arial,helvetica,sans-serif", + }, + textColor: "#000000", + popupBackgroundColor: "#FFFFFF", + popupBackgroundImage: { + url: "", + fullWidth: true, + repeat: "no-repeat", + size: "cover", + position: "center", + }, + popupOverlay_backgroundColor: "rgba(0, 0, 0, 0.1)", + popupCloseButton_position: "top-right", + popupCloseButton_backgroundColor: "#DDDDDD", + popupCloseButton_iconColor: "#000000", + popupCloseButton_borderRadius: "0px", + popupCloseButton_margin: "0px", + popupCloseButton_action: { + name: "close_popup", + attrs: { + onClick: + "document.querySelector('.u-popup-container').style.display = 'none';", + }, + }, + backgroundColor: "#e7e7e7", + backgroundImage: { + url: "", + fullWidth: true, + repeat: "no-repeat", + size: "custom", + position: "center", + }, + preheaderText: "", + linkStyle: { + body: true, + linkColor: "#0000ee", + linkHoverColor: "#0000ee", + linkUnderline: true, + linkHoverUnderline: true, + }, + _meta: { + htmlID: "u_body", + htmlClassNames: "u_body", + }, + }, + }, + schemaVersion: 15, +}); diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderModal/CampaignTemplateBuilderEditModal.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderModal/CampaignTemplateBuilderEditModal.tsx new file mode 100644 index 0000000..7318549 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderModal/CampaignTemplateBuilderEditModal.tsx @@ -0,0 +1,110 @@ +import React, { useState } from "react"; +import { + Modal, + TalentButtonWithIcon, + useConfirmationModal, +} from "@komi-app/components"; +import { CampaignTemplateCreateDto, TemplateDto } from "types/templates"; +import { campaignTemplateService } from "services"; +import { TemplateBuilderPage } from ".."; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { templateQueryKey } from "../utils"; + +export interface CampaignTemplateBuilderEditModalProps { + templateId: string; + onOpen: () => void; +} + +const CampaignTemplateBuilderEditModal = ({ + templateId, + onOpen, +}: CampaignTemplateBuilderEditModalProps) => { + + const [modalOpen, setModalOpen] = useState(false); + const query = useQueryClient(); + const confirmationModal = useConfirmationModal(); + + const renderConfirmationModal = () => { + if (!modalOpen) return; + + confirmationModal.open({ + className: "confirmation-modal-campaign-create", + title: "Are you sure you want to leave this page?", + description: "Any changes made to this template will be lost.", + onConfirm: () => { + setModalOpen(false); + cancel(); + }, + buttons: [ + { + label: "Leave", + action: "confirm", + variant: "primary", + }, + { + label: "Cancel", + action: "cancel", + variant: "secondary", + }, + ], + }); + }; + + const { data: template } = useQuery(["getTemplate", templateId], () => + campaignTemplateService.getCampaignTemplate(templateId).then((res) => { + return res as TemplateDto; + }), + ); + + const updateTemplate = async (template: CampaignTemplateCreateDto) => { + const res = await campaignTemplateService.updateCampaignTemplate( + templateId, + template, + ); + if (!res) return false; + + query.invalidateQueries({ queryKey: templateQueryKey(templateId) }); + setModalOpen(false); + + return true; + }; + + function cancel() { + setModalOpen(false); + } + + function handleOpen() { + setModalOpen(true); + onOpen(); + } + + return ( + <> + + + {modalOpen && ( + + )} + + + ); +}; + +export default CampaignTemplateBuilderEditModal; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderModal/TemplateBuilderCreateModal.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderModal/TemplateBuilderCreateModal.tsx new file mode 100644 index 0000000..3d5e3c8 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderModal/TemplateBuilderCreateModal.tsx @@ -0,0 +1,90 @@ +import { + Modal, + TalentButton, + useConfirmationModal, +} from "@komi-app/components"; +import React, { useState } from "react"; +import { CampaignTemplateCreateDto, TemplateDto } from "types/templates"; +import { campaignTemplateService } from "services"; +import { TemplateBuilderPage } from ".."; + +export interface TemplateBuilderCreateModalProps { + onTemplateSelected: (template: TemplateDto) => void; + onCancel: () => void; + onOpen: () => void; +} + +const TemplateBuilderCreateModal = ({ + onTemplateSelected, + onCancel, + onOpen, +}: TemplateBuilderCreateModalProps) => { + const [modalOpen, setModalOpen] = useState(false); + const confirmationModal = useConfirmationModal(); + + const renderConfirmationModal = () => { + confirmationModal.open({ + className: "confirmation-modal-campaign-create", + title: "Are you sure you want to leave this page?", + description: "Any changes made to this template will be lost.", + onConfirm: () => { + setModalOpen(false); + onCancel(); + }, + buttons: [ + { + label: "Leave", + action: "confirm", + variant: "primary", + }, + { + label: "Cancel", + action: "cancel", + variant: "secondary", + }, + ], + }); + }; + + const saveTemplate = async (template: CampaignTemplateCreateDto) => { + const createdTemplate = + await campaignTemplateService.createCampaignTemplate(template); + + if (!createdTemplate) return false; + + onTemplateSelected(createdTemplate); + setModalOpen(false); + + return true; + }; + + function handleNewTemplateClick() { + setModalOpen(true); + onOpen(); + } + + return ( + <> + + NEW TEMPLATE + + + + + + + ); +}; + +export default TemplateBuilderCreateModal; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderPage/TemplateBuilderPage.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderPage/TemplateBuilderPage.tsx new file mode 100644 index 0000000..086836a --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateBuilderPage/TemplateBuilderPage.tsx @@ -0,0 +1,381 @@ +import React, { useMemo, useRef, useState } from "react"; +import { Location } from "history"; +import { Prompt, useLocation } from "react-router-dom"; +import { Editor, EditorRef } from "react-email-editor"; + +import config from "config"; + +import { + InlineMessage, + Input, + LoadingOverlay, + TalentButton, + useConfirmationModal, +} from "@komi-app/components"; + +import { + CampaignTemplateCreateDto, + TemplateAddressDto, + TemplateDto, +} from "types/templates"; +import { ClassicEditor, UnlayerDesign } from "types/unlayer"; + +import { TemplateBuilder } from ".."; +import { ImportedTemplateEditor } from ".."; + +import { join } from "utils/string"; +import { emptyAddress } from "../utils"; +import { isClassicTemplate } from "../TemplateImport/guards"; + +import "./TemplateBuilderPage.scss"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useUnsavedChangesRedirectionWarning } from "hooks/useUnsavedChangesRedirectionWarning"; + +export interface TemplateBuilderPageProps + extends React.HTMLProps { + existingTemplate?: Partial; + initialTemplate?: UnlayerDesign; + isCampaignTemplate?: boolean; + backButtonText: string; + handleGoBack: () => void; + handleCreateOrUpdateTemplate: ( + template: CampaignTemplateCreateDto, + ) => Promise; + disablePrompt?: boolean; +} + +export type TemplateCreateRouteState = { + selectedTemplate?: Record; +}; + +export const TemplateBuilderPage: React.FC = ({ + handleGoBack, + handleCreateOrUpdateTemplate, + isCampaignTemplate = false, + existingTemplate, + initialTemplate, + backButtonText, + disablePrompt = false, +}) => { + const location = useLocation(); + const locationState = location.state as TemplateCreateRouteState; + let selectedTemplate: Record | undefined; + if (locationState && locationState.selectedTemplate) { + selectedTemplate = locationState.selectedTemplate; + } + const template = + existingTemplate?.templateJson || selectedTemplate || initialTemplate; + const isImportedTemplate = isClassicTemplate(template); + + const isExistingTemplate = useMemo( + () => Boolean(existingTemplate), + [existingTemplate], + ); + const existingTemplateName = useMemo( + () => existingTemplate?.name, + [existingTemplate], + ); + + const [html, setHtml] = useState(() => { + const initialHtml = isImportedTemplate + ? template.html + : existingTemplate?.templateHtml; + + return initialHtml || ""; + }); + const [templateName, setTemplateName] = useState( + existingTemplate?.name || "", + ); + const [nameError, showNameError] = useState(false); + const [builderUpdated, setBuilderUpdated] = useState(!isExistingTemplate); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isConfirming, setIsConfirming] = useState(false); + + const emailEditorRef = useRef(null); + const [emailEditor, setEmailEditor] = useState( + null, + ); + + const isTemplateImportFixEnabled = useFeatureIsOn( + FLAGS.FIX_CAC_97_IMPORT_TEMPLATE_HTML, + ); + const isUnsavedChangeRedirectionWarningOn = useFeatureIsOn( + FLAGS.CRM_901_PREVIEW_LINKS_DISABLED, + ); + + const isUnsubscribeDeletable = useFeatureIsOn( + FLAGS.FEAT_CRM_1267_ALLOW_DELETE_FOOTER, + ); + + // const [design, setDesign] = useState(); + const [address, setAddress] = useState(emptyAddress()); + + const confirmationModal = useConfirmationModal(); + isUnsavedChangeRedirectionWarningOn && + useUnsavedChangesRedirectionWarning( + !isSaving && !isConfirming && builderUpdated, + ); + + const isUnsubscribeTagPresent = useMemo(() => { + if (!isUnsubscribeDeletable) + return isImportedTemplate && html.includes("{%unsubscribe%}"); + + return ( + html.includes("{%unsubscribe%}") || html.includes("_unsubscribe_link_") + ); + }, [html, isImportedTemplate, isUnsubscribeDeletable]); + + // Check if there is an svg image in the template and disable saving if so + const isSvgPresent = useMemo(() => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const svgElements = doc.querySelectorAll("svg"); + return svgElements.length > 0; + }, [html]); + + const isTemplateNameValid = (templateName: string) => { + return templateName && templateName.trim() !== ""; + }; + + const isSavingEnabled = useMemo(() => { + // Conditions to meet to disable saving button: + // - If the name is invalid + if (!isTemplateNameValid(templateName)) return false; + + // - If an svg image is present + if (isSvgPresent) return false; + + // - If a template has no unsubscribe tag + if ( + !isUnsubscribeTagPresent && + (isImportedTemplate || isUnsubscribeDeletable) + ) { + return false; + } + + // Conditions to meet to enable saving button: + // - If the template does not exist + if (!existingTemplate) return true; + // - If the existing template has updates + if (builderUpdated) return true; + // - If an existing template has no updates and the name has changed + if (existingTemplateName !== templateName) return true; + + return false; // By default, enable the save button + }, [ + existingTemplate, + isUnsubscribeTagPresent, + templateName, + builderUpdated, + isSvgPresent, + isUnsubscribeDeletable, + ]); + + const upsertTemplate = async (template: CampaignTemplateCreateDto) => { + const success = await handleCreateOrUpdateTemplate(template); + + setIsLoading(false); + + if (!success) return; + + setBuilderUpdated(false); + setIsSaving(false); + }; + + const onSaveButtonClicked = async () => { + if (!isTemplateNameValid(templateName)) { + return showNameError(true); + } + + setIsSaving(true); + setBuilderUpdated(false); + + if (!emailEditor || !html) return; + + setIsLoading(true); + + if (isImportedTemplate) { + // Ensure the template is saved with the correct html + const templateJson = isTemplateImportFixEnabled + ? { + classic: true, + html, + } + : template; + + // We can add the icon as the template thumbnail here. + return upsertTemplate({ + name: templateName, + templateJson, + thumbnailUrl: `${config.service.assetUrl}/assets/images/template-placeholder.svg`, + templateHtml: html, + address, + }); + } + + emailEditor.exportImage(async function (data) { + return upsertTemplate({ + name: templateName, + templateJson: data.design, + thumbnailUrl: data.url, + templateHtml: html, + address, + }); + }); + }; + + const handleDesignUpdated = () => { + setBuilderUpdated(true); + }; + + const handleNameChange = (updatedName: string) => { + setTemplateName(updatedName); + setBuilderUpdated(true); + showNameError(false); + }; + + const handleHtmlChange = (updatedHtml: string) => { + setHtml(updatedHtml); + setBuilderUpdated(true); + }; + + const confirmNavigate = (location: Location) => { + setIsConfirming(true); + + confirmationModal.open({ + className: isPromptFixOn ? "confirmation-modal-template" : undefined, + title: "Are you sure you want to leave this page?", + description: "Any changes made to this template will be lost.", + minWidth: "520px", + onConfirm: async () => { + window.location.href = location.pathname; + }, + onCancel: () => { + setIsConfirming(false); + }, + buttons: [ + { + label: "Leave", + action: "confirm", + variant: "primary", + }, + { + label: "Cancel", + action: "cancel", + variant: "secondary", + }, + ], + }); + return false; + }; + + const isPromptFixOn = useFeatureIsOn( + FLAGS.FIX_CRM_83_TEMPLATE_SHOW_CONFIRM_PROMPT, + ); + + return ( + <> + +
+ {!existingTemplate && ( + + )} + {!isUnsubscribeTagPresent && + (isImportedTemplate || (html && isUnsubscribeDeletable)) && ( + + )} + {isSvgPresent && ( + + )} + + {isCampaignTemplate ? ( + + ) : ( + + )} +
+ {isImportedTemplate ? ( + + ) : ( + setEmailEditor(editor)} + /> + )} +
+ +
+ + {backButtonText} + + + + Save + +
+
+
+ {/** + * Cannot have multiple prompts on the same page + * Gives option to disable prompt + * Component is reused in a modal and a page + */} + {!disablePrompt && ( + + )} + + ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateCreate.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateCreate.tsx new file mode 100644 index 0000000..61f39eb --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateCreate.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useHistory } from "react-router"; +import { templateService } from "services"; +import { CampaignTemplateCreateDto } from "types/templates"; +import { TemplateBuilderPage } from "."; +import { TemplatePageHeader } from "."; +import "./Templates.scss"; +import { templatesUrl } from "constants/dca/templates"; +import { UnlayerDesign } from "types/unlayer"; + +export interface TemplateCreateProps { + initialTemplate?: UnlayerDesign | undefined; +} + +const TemplateCreate: React.FC = ({ initialTemplate }) => { + const router = useHistory(); + + const saveTemplate = async (template: CampaignTemplateCreateDto) => { + const res = await templateService.createTemplate(template); + if (res) { + router.push(templatesUrl); + return true; + } + return false; + }; + + const handleBack = () => { + router.goBack(); + }; + + return ( +
+ +
+ +
+
+ ); +}; + +export default TemplateCreate; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateEdit.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateEdit.tsx new file mode 100644 index 0000000..aa42144 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateEdit.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHistory } from "react-router"; +import { templateService } from "services"; +import { CampaignTemplateCreateDto, TemplateDto } from "types/templates"; +import { TemplateBuilderPage } from "."; +import { TemplatePageHeader } from "."; +import { templatesUrl } from "constants/dca/templates"; +import { dcaBaseUrl } from "constants/dca"; + +import "./Templates.scss"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +export interface TemplateEditProps { + templateId: string; + campaignId?: string; +} + +export const TemplateEdit = ({ templateId, campaignId }: TemplateEditProps) => { + const router = useHistory(); + const query = useQueryClient(); + + const { data: template } = useQuery(["getTemplate", templateId], () => + templateService.getTemplate(templateId).then((res) => { + return res as TemplateDto; + }), + ); + + const backLink = campaignId + ? // FLAG + `${dcaBaseUrl}/campaign-edit?campaignId=${campaignId}` + : templatesUrl; + + const updateTemplate = async (template: CampaignTemplateCreateDto) => { + const res = await templateService.updateTemplate(templateId, template); + if (res) { + await query.invalidateQueries({ queryKey: ["getTemplate"] }); + return true; + } + return false; + }; + + return ( +
+ {template && ( + <> + router.push(backLink)} + /> + +
+ router.push(backLink)} + handleCreateOrUpdateTemplate={updateTemplate} + backButtonText="Back" + /> +
+ + )} +
+ ); +}; + +export default TemplateEdit; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateGalleryPage/TemplateCard.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateGalleryPage/TemplateCard.tsx new file mode 100644 index 0000000..23ee95e --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateGalleryPage/TemplateCard.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import { Menu } from "@headlessui/react"; +import "./TemplateCard.scss"; +import classNames from "classnames"; +import { + CampaignTemplate, + SelectOption, + TemplateMenuOptions, +} from "types/templates"; +import { Icon } from "@komi-app/components"; +import Skeleton from "react-loading-skeleton"; +import { isClassicTemplate } from "../TemplateImport/guards"; + +const CARDS_PER_PAGE = 12; + +const objectToOptions = ( + obj: Record +): SelectOption[] => { + const entries = Object.entries(obj); + return entries.map>(([label, value]) => ({ label, value })); +}; + +export const templateMenuOptions = objectToOptions(TemplateMenuOptions); + +export interface TemplateCardProps { + template: CampaignTemplate; + onMenuOptionClick: (option: SelectOption) => void; + selectMode: boolean; + onTemplateSelected?: (template: CampaignTemplate) => void; + cardIndex: number; +} +export const TemplateCard: React.FC = ({ + template, + onMenuOptionClick, + selectMode, + onTemplateSelected, + cardIndex, +}) => { + const [imageLoaded, setImageLoaded] = useState(false); + const [clickedPositionX, setClickedPositionX] = useState(0); + + const indexOnPage = cardIndex % CARDS_PER_PAGE; + const isBottomRow = indexOnPage >= CARDS_PER_PAGE - 4; + + const handleCardMenuClick = (event: React.MouseEvent) => { + if (onTemplateSelected) onTemplateSelected(template); + const clickX = event.clientX; + setClickedPositionX(clickX); + }; + + return ( +
+ {!imageLoaded && } + setImageLoaded(true)} + /> + +
+

+ {template.name} +

+ + {!selectMode && ( + + )} +
+
+ ); +}; + +export interface TemplateCardMenuProps { + onMenuOptionClick: (option: SelectOption) => void; + isBottomRow: boolean; + clickedPositionX: number; +} + +export const TemplateCardMenu = ({ + onMenuOptionClick, + isBottomRow, + clickedPositionX, +}: TemplateCardMenuProps) => { + const bottomMenuStyles = { bottom: "264px", left: clickedPositionX - 234 }; + return ( + + + + + + {templateMenuOptions.map((option) => { + return ( + + {() => ( +
onMenuOptionClick(option)} + > + {option.label} +
+ )} +
+ ); + })} +
+
+ ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateGalleryPage/TemplateWithCursorGalleryPage.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateGalleryPage/TemplateWithCursorGalleryPage.tsx new file mode 100644 index 0000000..90aca39 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateGalleryPage/TemplateWithCursorGalleryPage.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { TemplateCard } from "./TemplateCard"; +import { + Illustration, + Input, + Paginate, + Pagination, + TalentButton, + TalentButtonWithIcon, + Typography, +} from "@komi-app/components"; + +import { + CampaignTemplate, + SelectOption, + TemplateMenuOptions, +} from "types/templates"; + +import classnames from "classnames"; +import "./TemplateGalleryPage.scss"; + +export interface TemplatePWithCursorageProps + extends React.HTMLProps { + allowTemplateImport?: boolean; + templates: CampaignTemplate[]; + selectMode: boolean; + pageIndex: number; + pageCount: number; + isLoading?: boolean; + onTemplateSelected?: (template: CampaignTemplate) => void; + onTemplateImportClick: () => void; + onTemplateNewClick: () => void; + onTemplateMenuOptionClick: ( + option: SelectOption, + template: CampaignTemplate, + ) => void; + onSearchTermChange: (searchTerm: string) => unknown; + onPageIndexChange: (pageIndex: number) => unknown; +} +const TemplateWithCursorGalleryPage: React.FC = ({ + allowTemplateImport: allowImport = true, + selectMode, + templates, + pageIndex, + pageCount, + isLoading, + onTemplateImportClick, + onTemplateNewClick, + onTemplateMenuOptionClick, + onTemplateSelected, + onSearchTermChange, + onPageIndexChange, + ...props +}) => { + const classNames = classnames("template__page", props.className); + + return ( +
+
+ + +
+ {allowImport && ( + + Import template + + )} + {!selectMode && ( + onTemplateNewClick()} + variant="primary" + > + NEW TEMPLATE + + )} +
+
+ {templates.length > 0 ? ( +
+
+ {templates.map((template, index) => ( +
+ + onTemplateMenuOptionClick(option, template) + } + /> +
+ ))} +
+ + + onPageIndexChange(pageIndex) + } + pageRangeDisplayed={5} + marginPagesDisplayed={1} + /> +
+ ) : isLoading ? null : ( +
+ + + Nothing Here Yet + + + Click the button below to get started + + + onTemplateNewClick()} + type="text" + prefixIcon="addFilled" + /> +
+ )} +
+ ); +}; + +export default TemplateWithCursorGalleryPage; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/HtmlCodeEditor/HtmlCodeEditor.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/HtmlCodeEditor/HtmlCodeEditor.tsx new file mode 100644 index 0000000..4f702ea --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/HtmlCodeEditor/HtmlCodeEditor.tsx @@ -0,0 +1,156 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import CodeMirror from "@uiw/react-codemirror"; +import { html as htmlLang } from "@codemirror/lang-html"; + +import { alison, alisonCodemirror } from "../constants/alison"; + +import { Hint } from "htmlhint/types"; + +import { HTMLHint } from "htmlhint"; +import { occurrences } from "utils/string"; +import { debounce } from "utils/debounce"; + +import classnames from "classnames"; +import "./HtmlCodeEditor.scss"; + +export interface HtmlCodeEditorProps extends React.HTMLProps { + html: string; + isErrorPaneVisible?: boolean; + onHtmlChange: (html: string) => void; + validateHtml?: (isValid: boolean) => void; +} + +export const HtmlCodeEditor: React.FC = ({ + html, + isErrorPaneVisible = true, + onHtmlChange, + validateHtml, + ...props +}) => { + const [errors, setErrors] = useState([]); + + const lines = document.querySelector( + ".komi-code-editor--body-lines", + ); + const textarea = document.querySelector( + ".komi-code-editor--body-textarea", + ); + const pre = document.querySelector( + ".komi-code-editor--body-pre", + ); + + // Resize the editor to fit the content. If we don't do this, the editor won't scroll + // horizontally when the textarea grows. This makes the parent container grow with the + // textarea + const handleResize = () => { + if (textarea && lines) { + const scrollbarWidth = textarea.offsetWidth - textarea.clientWidth; + const widthWithScrollbar = textarea.scrollWidth + scrollbarWidth; + + const widthWithScrollbarPx = `${widthWithScrollbar}px`; + + const heightWithScrollbar = textarea.scrollHeight + scrollbarWidth; + const heightWithScrollbarPx = `${heightWithScrollbar}px`; + + // Set the width of the editor, textarea, and pre to the width of the textarea + // plus the width of the scrollbar. This will make the editor scroll horizontally + // when the textarea grows past its initial width + // Set the height of the textarea and pre to the height of the textarea, excluding + // the editor container, so that the editor container will scroll vertically and + // scroll the full-height textarea and pre + if (textarea) { + textarea.style.width = widthWithScrollbarPx; + textarea.style.height = heightWithScrollbarPx; + } + if (pre) { + pre.style.width = widthWithScrollbarPx; + pre.style.height = heightWithScrollbarPx; + } + } + }; + + // Run the resize function on mount and whenever the window is resized + useEffect(() => { + // Call the function on initial render + handleResize(); + // Call the function whenever the window is resized + window.addEventListener("resize", handleResize); + + // Clean up + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + // Verify the HTML on change + const htmlVerification = useCallback( + (changedHtml: string) => { + const rules = HTMLHint.defaultRuleset; // Use default rules + const result = HTMLHint.verify(changedHtml, rules); + + setErrors(result); + + if (validateHtml) { + const isHTMLValid = !result.length; + validateHtml(isHTMLValid); + } + }, + [validateHtml], + ); + // Debounce the HTML verification so it doesn't run on every keystroke + const debouncedHtmlVerification = useMemo( + () => debounce(htmlVerification, 1000), + [htmlVerification], + ); + + const handleHtmlChange = (changedHtml: string) => { + debouncedHtmlVerification(changedHtml); + onHtmlChange(changedHtml); + + handleResize(); + }; + + const codeLineCount = occurrences(html, "\n") + 1; + const lineNumbers = Array.from({ length: codeLineCount }, (x, i) => i); + + const classNames = classnames("komi-code-editor", props.className); + + return ( +
+
+ +
+ + {errors.length > 0 && isErrorPaneVisible && ( +
+ {errors.map((error, key) => ( +
+

+ {error.message} +

+ + at {error.line}:{error.col} + +

+ in {error.evidence} +

+
+ ))} +
+ )} +
+ ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedEmailEditor/ImportedEmailEditor.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedEmailEditor/ImportedEmailEditor.tsx new file mode 100644 index 0000000..a83926e --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedEmailEditor/ImportedEmailEditor.tsx @@ -0,0 +1,84 @@ +import React, { useEffect } from "react"; + +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +import { ClassicEditor, UnlayerEditor } from "types/unlayer"; + +import { unlayerOptions } from "../../constants"; +import { + defaultUnlayerScriptUrl, + komiHostedUnlayerScriptUrl, +} from "constants/unlayer"; + +import { loadScript } from "./loadScript"; +import { createClassicTemplate } from "../utils"; + +import classnames from "classnames"; +import "./ImportedEmailEditor.scss"; + +export interface ImportedEmailEditorProps + extends React.HTMLProps { + emailEditor: ClassicEditor | null; + html: string; + onHtmlChange: (html: string) => void; + setIsLoading: (isLoading: boolean) => void; + setEmailEditor: (editor: UnlayerEditor) => void; +} + +export const ImportedEmailEditor: React.FC = ({ + emailEditor, + html, + onHtmlChange, + setEmailEditor, + setIsLoading, + ...props +}) => { + const isModifiedUnlayerScript = useFeatureIsOn( + FLAGS.FIX_CRM_675_TEMPLATE_IMPORT_HTML_UX, + ); + const resolvedHtml = html || "
"; + + const handleEditorLoaded = () => { + setIsLoading(false); + }; + + // Loads the script and the editor from Unlayer + useEffect(() => { + // Load the editor once the script is loaded + const loadEditor = () => { + const editor = window.unlayer?.createEditor({ + id: "editor", + ...unlayerOptions, + }); + editor?.loadDesign(createClassicTemplate(resolvedHtml)); + + editor?.addEventListener("design:updated", () => { + editor.exportHtml(({ html }) => onHtmlChange(html)); + }); + editor?.addEventListener("design:loaded", handleEditorLoaded); + + setEmailEditor(editor); + }; + + // Load the script on mount + if (window.unlayer && emailEditor) { + emailEditor.loadDesign(createClassicTemplate(resolvedHtml)); + + emailEditor.addEventListener("design:updated", () => { + emailEditor.exportHtml(({ html }) => onHtmlChange(html)); + }); + emailEditor.addEventListener("design:loaded", handleEditorLoaded); + } else { + loadScript( + loadEditor, + isModifiedUnlayerScript + ? komiHostedUnlayerScriptUrl + : defaultUnlayerScriptUrl, + ); + } + }, [html, resolvedHtml]); + + const classNames = classnames("imported-template--email-editor"); + + return
; +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedEmailEditor/loadScript.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedEmailEditor/loadScript.ts new file mode 100644 index 0000000..f81dd56 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedEmailEditor/loadScript.ts @@ -0,0 +1,47 @@ +import { noop } from "utils/noop"; +import { defaultUnlayerScriptUrl } from "constants/unlayer"; + +const callbacks: (() => void)[] = []; + +const isScriptInjected = (scriptUrl: string) => { + const scripts = document.querySelectorAll("script"); + let injected = false; + + scripts.forEach((script) => { + if (script.src.includes(scriptUrl)) { + injected = true; + } + }); + + return injected; +}; + +const addCallback = (callback: () => void) => { + callbacks.push(callback); +}; + +const runCallbacks = () => { + let callback; + + while ((callback = callbacks.shift())) { + callback(); + } +}; + +export const loadScript = ( + callback: () => void = noop, + scriptUrl = defaultUnlayerScriptUrl, +) => { + addCallback(callback); + + if (!isScriptInjected(scriptUrl)) { + const embedScript = document.createElement("script"); + embedScript.setAttribute("src", scriptUrl); + embedScript.onload = () => { + runCallbacks(); + }; + document.head.appendChild(embedScript); + } else { + runCallbacks(); + } +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateEditor/ImportedTemplateEditor.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateEditor/ImportedTemplateEditor.tsx new file mode 100644 index 0000000..83e99c1 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateEditor/ImportedTemplateEditor.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Tab, TabSection, TabView, TabViews, Tabs } from "@komi-app/components"; + +import { HtmlCodeEditor } from "../HtmlCodeEditor"; +import { ImportedEmailEditor } from "../ImportedEmailEditor/ImportedEmailEditor"; + +import { ClassicEditor, UnlayerEditor } from "types/unlayer"; + +import classnames from "classnames"; +import "./ImportedTemplateEditor.scss"; + +export interface ImportedTemplateEditorProps { + emailEditor: ClassicEditor | null; + html: string; + setHtml: (html: string) => void; + setIsLoading: (isLoading: boolean) => void; + setEmailEditor: (editor: UnlayerEditor | null) => void; +} + +export const ImportedTemplateEditor: React.FC = ({ + emailEditor, + html, + setHtml, + setIsLoading, + setEmailEditor, +}) => { + const classNames = classnames("imported-template--editor"); + + const handleDesignUpdated = (html: string) => { + setHtml(html); + }; + + return ( +
+ + + + + + + + + + + + + + +
+ ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/ImportedTemplateModal.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/ImportedTemplateModal.tsx new file mode 100644 index 0000000..b41816a --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/ImportedTemplateModal.tsx @@ -0,0 +1,87 @@ +import React, { useState } from "react"; +import { useHistory } from "react-router-dom"; +import { Modal } from "@komi-app/components"; + +import { ImportedTemplateMethodStep } from "./steps/ImportedTemplateMethodStep"; +import { ImportedTemplateUploadStep } from "./steps/ImportedTemplateUploadStep"; +import { ImportedTemplateEditorStep } from "./steps/ImportedTemplateEditorStep"; + +import { createTemplateUrl } from "constants/dca"; + +import { TemplateImportMethod } from "../types"; + +import { noop } from "utils/noop"; +import { createClassicTemplate } from "../utils"; + +import classnames from "classnames"; +import "./ImportedTemplateModal.scss"; + +export enum TemplateImportStep { + Method = "Method", + UploadFile = "UploadFile", + PasteInCode = "PasteInCode", +} + +export interface ImportedTemplateModalProps + extends React.HTMLProps { + onCancel: () => void; + visible: boolean; +} + +export const ImportedTemplateModal: React.FC = ({ + className, + onCancel = noop, + ...props +}) => { + const history = useHistory(); + const [step, setStep] = useState(TemplateImportStep.Method); + + const classNames = classnames(className, "imported-template--modal"); + + // Display the next step based on the selected method + const handleMethodSelection = (method: TemplateImportMethod) => { + setStep(TemplateImportStep[method]); + }; + // Pass the uploaded HTML to the create template page + const handleTemplateUpload = (html: string) => { + // This is the Unlayer structure for an HTML template + // The classic flag is used to determine the Unlayer editor type + const initialTemplate = createClassicTemplate(html); + + history.push({ + pathname: createTemplateUrl, + state: { initialTemplate }, + }); + }; + + // Going back will always take the user back to the method selection step + const handleBack = () => setStep(TemplateImportStep.Method); + + return ( + + {step === TemplateImportStep.Method && ( + + )} + {step === TemplateImportStep.UploadFile && ( + + )} + {step === TemplateImportStep.PasteInCode && ( + + )} + + ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateEditorStep.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateEditorStep.tsx new file mode 100644 index 0000000..0777606 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateEditorStep.tsx @@ -0,0 +1,61 @@ +import React, { useState, useCallback } from "react"; +import { HtmlCodeEditor } from "../../HtmlCodeEditor"; +import { TalentButton } from "@komi-app/components"; +import notification from "utils/notification"; +import classnames from "classnames"; +import "./ImportedTemplateEditorStep.scss"; +import "../../HtmlCodeEditor/HtmlCodeEditor.scss"; + +export interface ImportedTemplateEditorStepProps + extends React.HTMLProps { + onBack: () => void; + onTemplateUpload: (template: string) => void; +} + +export const ImportedTemplateEditorStep: React.FC< + ImportedTemplateEditorStepProps +> = ({ onBack, onTemplateUpload, ...props }) => { + const [html, setHtml] = useState(""); + const [isValidHtml, setIsValidHtml] = useState(false); + + const handleHtmlChange = (updatedHtml: string) => setHtml(updatedHtml); + const handleValidateHtml = (isValid: boolean) => setIsValidHtml(isValid); + const handleBack = () => onBack(); + + const handleTemplateUpload = useCallback(() => { + if (html !== "") { + onTemplateUpload(html); + } + }, [isValidHtml, html]); + + const classNames = classnames("imported-template--code"); + + return ( +
+ + +
+ + Previous + + + Import + +
+
+ ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateMethodStep.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateMethodStep.tsx new file mode 100644 index 0000000..c740bd3 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateMethodStep.tsx @@ -0,0 +1,80 @@ +import React, { useState } from "react"; +import { TalentButton } from "@komi-app/components"; +import { CardButton } from "components/CardButton/CardButton"; + +import { TemplateImportMethod } from "../../types"; + +import { noop } from "utils/noop"; + +import classnames from "classnames"; +import "./ImportedTemplateMethodStep.scss"; + +export interface ImportedTemplateModalProps + extends React.HTMLProps { + onMethodSelected: (method: TemplateImportMethod) => void; + onCancel: () => void; +} + +export const ImportedTemplateMethodStep: React.FC< + ImportedTemplateModalProps +> = ({ className, onMethodSelected, onCancel = noop }) => { + const [selectedTemplateOption, setSelectedTemplateOption] = useState( + TemplateImportMethod.UploadFile + ); + + const classNames = classnames(className, "imported-template--modal__options"); + const getOptionClassName = (option: TemplateImportMethod) => + classnames("imported-template--modal__options-upload", { + "card-button--selected": selectedTemplateOption === option, + }); + + const handleFileUploadClick = () => { + setSelectedTemplateOption(TemplateImportMethod.UploadFile); + }; + const handlePasteInCodeClick = () => { + setSelectedTemplateOption(TemplateImportMethod.PasteInCode); + }; + const handleMethodSelection = () => { + onMethodSelected(selectedTemplateOption); + }; + + return ( + +
+ + +
+ +
+ + Cancel + + + Next + +
+
+ ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateUploadStep.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateUploadStep.tsx new file mode 100644 index 0000000..85e2c89 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/ImportedTemplateModal/steps/ImportedTemplateUploadStep.tsx @@ -0,0 +1,148 @@ +import React, { useRef, useState, MouseEvent } from "react"; +import { TalentButton } from "@komi-app/components"; +import { Icon } from "components/Icon"; + +import classnames from "classnames"; +import "./ImportedTemplateUploadStep.scss"; + +export interface ImportedTemplateUploadStepProps + extends React.HTMLProps { + onTemplateUpload: (html: string) => void; + onBack: () => void; +} + +export const ImportedTemplateUploadStep: React.FC< + ImportedTemplateUploadStepProps +> = ({ onTemplateUpload, onBack, ...props }) => { + const [selectedFile, setSelectedFile] = useState(null); + const [errors, setErrors] = useState({ noHtmlFile: false }); + + const fileInputRef = useRef(null); + + const handleFileChange = (file: File | null) => { + setErrors({ noHtmlFile: !file || file.type !== "text/html" }); + + if (file && file.type === "text/html") setSelectedFile(file); + }; + + // Prevent automatic opening of file + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + // Handle file dropping + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + + const files = e.dataTransfer.files; + handleFileChange(files[0]); + }; + + // Handle upload button click + const handleUploadClick = (): void => fileInputRef.current?.click(); + + // Handle file input changes on upload selection via click + const handleFileSelectionUpload = ( + e: React.ChangeEvent + ): void => { + const files = e.target.files || []; + handleFileChange(files[0]); + }; + + const handleNext = async () => { + if (selectedFile) { + onTemplateUpload(await selectedFile.text()); + } else { + setErrors({ noHtmlFile: true }); + } + }; + const handleBack = () => { + onBack(); + }; + + const handleClearDropzoneValue = (event: MouseEvent) => { + event.stopPropagation(); + setSelectedFile(null); + }; + + const classNames = classnames("imported-template--upload"); + const uploadClassNames = classnames( + "imported-template--upload__dropzone", + { + "imported-template--upload__dropzone-error": errors.noHtmlFile, + }, + selectedFile && "imported-template--upload__dropzone-uploaded" + ); + + return ( +
+
+ + +

+ {selectedFile ? "Template Uploaded" : "Upload Template"} +

+

+ {selectedFile + ? selectedFile.name + : "Drop your HTML file here to upload"} +

+ + + {selectedFile ? ( +
handleClearDropzoneValue(e)} + data-testid="clear-dropzone-button" + > + +
+ ) : null} +
+

+ {errors.noHtmlFile && "Please upload an HTML file"} +

+ +
+ + Previous + + + Import + +
+
+ ); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/constants/alison.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/constants/alison.ts new file mode 100644 index 0000000..350cfdc --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/constants/alison.ts @@ -0,0 +1,162 @@ +import { PrismTheme } from "prism-react-renderer"; +import createTheme from "@uiw/codemirror-themes"; +import { tags as t } from "@lezer/highlight"; + +const commentColor = "#B8B8B8"; +const generalColor = "#31353A"; +const selectionColor = "#BBE1FF"; + +const htmlTagColor = "#01762E"; +const htmlAttributeNameColor = "#6F37BE"; +const htmlAttributeValueColor = "#0E3565"; + +const jsClassNameColor = "purple"; +const jsKeywordColor = "#C70000"; +const jsFnColor = "#1F69C7"; +const jsOperatorColor = "#CE4A00"; + +const cssSelectorColor = "orange"; +const cssPropertyColor = "#6F37BE"; +const cssUnitColor = "#1F69C7"; +const cssImportantColor = "#C7000A"; + +export const alison: PrismTheme = { + styles: [ + { + types: ["comment"], + style: { + color: commentColor, + }, + }, + { + types: ["builtin", "changed", "keyword"], + style: { + color: jsKeywordColor, + }, + }, + { + types: ["punctuation", "operator"], + style: { + color: jsOperatorColor, + }, + }, + { + types: ["number", "string", "inserted"], + style: { + color: jsFnColor, + }, + }, + { + types: ["class-name"], + style: { + color: jsClassNameColor, + }, + }, + // { + // types: ["constant"], + // style: { + // color: "#1F69C7" + // } + // }, + { + types: ["attr-name", "variable"], + style: { + color: htmlAttributeNameColor, + }, + }, + { + types: ["tag", "doctype"], + languages: ["markup"], + style: { + color: htmlTagColor, + }, + }, + { + types: ["attr-value"], + style: { + color: htmlAttributeValueColor, + }, + }, + { + types: ["punctuation"], + languages: ["markup"], + style: { + color: "#31353A", + }, + }, + { + types: ["deleted"], + style: { + color: "#0E3565", + }, + }, + { + types: ["selector"], + style: { + color: cssSelectorColor, + }, + }, + { + types: ["unit"], + style: { + color: cssUnitColor, + }, + }, + { + types: ["property"], + style: { + color: cssPropertyColor, + }, + }, + { + types: ["important"], + style: { + color: cssImportantColor, + }, + }, + ], + plain: { + color: undefined, + cursor: undefined, + background: undefined, + backgroundImage: undefined, + backgroundColor: undefined, + textShadow: undefined, + fontStyle: undefined, + fontWeight: undefined, + textDecorationLine: undefined, + opacity: undefined, + }, +}; + +export const alisonCodemirror = createTheme({ + theme: "light", + settings: { + background: "transparent", + foreground: generalColor, + caret: htmlAttributeNameColor, + selection: selectionColor, + selectionMatch: selectionColor, + gutterActiveForeground: generalColor, + lineHighlight: "#8a91991a", + gutterBackground: "#FFF", + fontFamily: "'IBM Plex Mono', monospace", + gutterForeground: commentColor, + gutterBorder: "transparent", + }, + styles: [ + { tag: t.comment, color: commentColor }, + { tag: t.variableName, color: jsFnColor }, + { tag: [t.string, t.special(t.brace)], color: generalColor }, + { tag: t.number, color: htmlAttributeNameColor }, + { tag: t.bool, color: htmlAttributeNameColor }, + { tag: t.null, color: htmlAttributeNameColor }, + { tag: t.keyword, color: jsKeywordColor }, + { tag: t.operator, color: jsOperatorColor }, + { tag: t.className, color: generalColor }, + { tag: t.tagName, color: htmlTagColor }, + { tag: t.attributeName, color: htmlAttributeNameColor }, + { tag: t.unit, color: cssUnitColor }, + { tag: t.propertyName, color: htmlAttributeNameColor }, + ], +}); diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/guards.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/guards.ts new file mode 100644 index 0000000..4d076fc --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/guards.ts @@ -0,0 +1,6 @@ +import { UnlayerClassicDesign } from "types/unlayer"; + +export const isClassicTemplate = ( + design: any +): design is UnlayerClassicDesign => + design !== undefined && "classic" in design && design.classic === true; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/types.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/types.ts new file mode 100644 index 0000000..2b74d7a --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/types.ts @@ -0,0 +1,4 @@ +export enum TemplateImportMethod { + UploadFile = "UploadFile", + PasteInCode = "PasteInCode", +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/utils.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/utils.ts new file mode 100644 index 0000000..ed7b823 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplateImport/utils.ts @@ -0,0 +1,60 @@ +import { templateService } from "services"; +import { UnlayerClassicDesign } from "types/unlayer"; + +/** + * Creates a classic template object + * + * @param {string} html The HTML of the template + * @returns {UnlayerClassicDesign} The classic template object + */ +export const createClassicTemplate = (html: string): UnlayerClassicDesign => { + return { + classic: true, + html, + }; +}; + +/** + * Uploads the template thumbnail to S3 using a signed URL + * + * @param {Blob} file The file blob to upload + * @returns {Promise} + */ +export const uploadEmailThumbnail = async (file: Blob): Promise => { + const signedThumbnailUploadUrl = + await templateService.getSignedThumbnailUploadUrl(); + // TODO: in the case of failure, what's the best course of action? For now, let's + // continue, but we should probably throw. + if (!signedThumbnailUploadUrl) return ""; + + const options = { + method: "PUT", + body: file, + headers: { + "Content-Type": "image/png", + }, + }; + + const response = await fetch(signedThumbnailUploadUrl.signedUrl, options); + if (!response.ok) { + throw new Error("Failed to upload template thumbnail"); + } + return response.url; +}; + +/** + * Converts a data URI to a blob + * + * @param {string} dataUri The data URI to convert + * @returns {Blob} The converted blob + */ +export const dataURItoBlob = (dataUri: string, mimetype: string) => { + const binary = atob(dataUri.split(",")[1]); + const array = []; + + for (let i = 0; i < binary.length; i++) { + array.push(binary.charCodeAt(i)); + } + + return new Blob([new Uint8Array(array)], { type: mimetype }); +}; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplatePageHeader/TemplatePageHeader.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplatePageHeader/TemplatePageHeader.tsx new file mode 100644 index 0000000..a7a3199 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplatePageHeader/TemplatePageHeader.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { ButtonIcon, Typography } from "@komi-app/components"; +import "./TemplatePageHeader.scss"; + +export interface TemplatePageHeaderProps { + name: string; + onBackClick: () => void; +} + +const TemplatePageHeader = ({ name, onBackClick }: TemplatePageHeaderProps) => { + return ( +
+
+ + + {name} + +
+
+ ); +}; + +export default TemplatePageHeader; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplatesWithCursor.tsx b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplatesWithCursor.tsx new file mode 100644 index 0000000..3090ff9 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/TemplatesWithCursor.tsx @@ -0,0 +1,137 @@ +import React, { useMemo, useState } from "react"; +import { ImportedTemplateModal } from "./TemplateImport/ImportedTemplateModal/ImportedTemplateModal"; + +import { templateService } from "services"; + +import { useConfirmationModal } from "@komi-app/components"; +import { useHistory } from "react-router"; + +import { CampaignTemplate, TemplateMenuOptions } from "types/templates"; + +import { createTemplateEditUrl } from "utils/dca/templates"; + +import { dcaBaseUrl, dcaSystemTemplatesUrl } from "constants/dca"; + +import "./Templates.scss"; +import { useTemplatesListPagination } from "./utils"; +import TemplateWithCursorGalleryPage from "./TemplateGalleryPage/TemplateWithCursorGalleryPage"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +const TemplatesWithCursor = () => { + const router = useHistory(); + + const [searchTerm, setSearchTerm] = useState(""); + + const { + data, + reset, + currentPageIndex, + pageCount, + changePageIndex, + isLoading, + } = useTemplatesListPagination(searchTerm); + const isSystemTemplatesVisible = useFeatureIsOn( + FLAGS.FEAT_CRM_1284_SYSTEM_TEMPLATES, + ); + + const [isImportModalVisible, setIsImportModalVisible] = useState(false); + + const confirmationModal = useConfirmationModal(); + + const handleSearchTermUpdate = (searchTerm: string) => { + setSearchTerm(searchTerm); + }; + + const handleTemplateMenuClicked = async ( + option: any, + template: CampaignTemplate, + ) => { + switch (option.label) { + case TemplateMenuOptions.Delete: + confirmationModal.open({ + title: "Are you sure you want to delete this template?", + description: "This action is not reversible.", + minWidth: "520px", + onConfirm: async () => { + await templateService.deleteTemplate(template.id, template.name); + // Clears the query cache and forces a it to get again + reset(); + }, + + buttons: [ + { + label: "Delete", + action: "confirm", + variant: "primary", + }, + { + label: "Cancel", + action: "cancel", + variant: "secondary", + }, + ], + }); + break; + case TemplateMenuOptions.Duplicate: + await templateService.createTemplate({ + ...template, + name: `Copy of ${template.name}`, + }); + // Clears the query cache and forces a it to get again + reset(); + break; + case TemplateMenuOptions.Edit: + reset(); + router.push(createTemplateEditUrl(template.id)); + break; + } + }; + + const templates = useMemo(() => { + return data.map((x) => { + const mappedTemplate: CampaignTemplate = { + id: x.templateId, + name: x.name, + templateJson: x.templateJson, + templateHtml: x.templateHtml, + thumbnailUrl: x.thumbnailUrl, + address: x.address, + }; + return mappedTemplate; + }); + }, [data, searchTerm]); + + const handleTemplateNewClick = () => { + if (isSystemTemplatesVisible) router.push(dcaSystemTemplatesUrl); + else router.push(`${dcaBaseUrl}/templates-create`); + }; + const handleTemplateImportClick = () => { + setIsImportModalVisible(true); + }; + const handleTemplateModalClose = () => { + setIsImportModalVisible(false); + }; + + return ( +
+ + +
+ ); +}; + +export default TemplatesWithCursor; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/constants.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/constants.ts new file mode 100644 index 0000000..f9f8fbf --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/constants.ts @@ -0,0 +1,25 @@ +import { UnlayerOptions } from "react-email-editor"; + +export const unlayerOptions = { + projectId: 148721, + // We use our own loader so hide the unlayer one + appearance: { + loader: { + html: "
", + css: "", + }, + }, + tabs: { + blocks: { + enabled: false, + }, + }, + tools: { + "custom#KOMI_CAMPAIGN_EMAIL_FOOTER": { + enabled: false, + }, + "custom#Footer": { + enabled: false, + }, + }, +} as UnlayerOptions; diff --git a/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/utils.ts b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/utils.ts new file mode 100644 index 0000000..840adde --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Pages/Templates/utils.ts @@ -0,0 +1,41 @@ +import { usePagination } from "hooks/usePagination"; +import { templateService } from "services"; + +export const templateQueryKey = (templateId: string | null) => { + return ["campaignTemplate", templateId] as const; +}; + +export const emptyAddress = () => { + return { + firstLine: null, + secondLine: null, + city: null, + country: null, + zipcode: null, + }; +}; + +export function useTemplatesListPagination( + searchTerm: string, + options = { limit: 16 } +) { + return usePagination( + "templates", + async (queryParams) => { + return ( + (await templateService.getTemplatesWithCursor( + queryParams, + searchTerm + )) ?? [] + ); + }, + async (countParams) => { + return ( + (await templateService.getTemplatesCount(countParams, searchTerm)) ?? 0 + ); + }, + options.limit, + "DESC", + [searchTerm] + ); +} diff --git a/interface_base/src/pages/AudienceAndCampaigns/Settings.tsx b/interface_base/src/pages/AudienceAndCampaigns/Settings.tsx new file mode 100644 index 0000000..f1ba424 --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/Settings.tsx @@ -0,0 +1,185 @@ +import React, { useMemo } from "react"; +import { Redirect, useLocation, useParams } from "react-router-dom"; +import { Text } from "components/Typography"; + +import Contacts from "./Pages/Contacts/Contacts"; +import CampaignCreatePage from "./Pages/Campaigns/CampaignCreatePage/CampaignCreatePage"; +import CampaignUpdatePage from "./Pages/Campaigns/CampaignUpdatePage/CampaignUpdatePage"; +import TemplateCreate from "./Pages/Templates/TemplateCreate"; +import TemplateEdit from "./Pages/Templates/TemplateEdit"; +import { SegmentQueryBuilderPage } from "./Pages/Segments/SegmentQueryBuilderPage"; + +import { enumFromValue } from "utils/enum"; +import { SegmentMode } from "@komi-app/fans-sdk"; +import { + AudienceAndCampaignsPage, + AudienceAndCampaignsPages, + AudienceAndCampaignsTabs, +} from "./typesUtils"; + +import "./Settings.scss"; +import SegmentsWithCursorPage from "./Pages/Segments/SegmentsPage/SegmentsWithCursorPage"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import EmbedPage from "./Pages/Embed/EmbedPage"; +import TemplatesWithCursor from "./Pages/Templates/TemplatesWithCursor"; +import { + audiencesUrl, + campaignsUrl, + embedUrl, + segmentsUrl, + templatesUrl, +} from "constants/dca"; +import OmniChannelCampaigns from "./Pages/Campaigns/OmniChannelCampaignListing/OmniChannelCampaigns"; + +import CampaignReportPageV2 from "./Pages/Campaigns/CampaignReportPage/CampaignReportPageV2"; +import EmailCampaignDetails from "./Pages/Campaigns/EmailCampaign/EmailCampaignDetails/EmailCampaignDetails"; +import ContactsQuotaMessage from "./Pages/Common/ContactsQuotaMessage"; + +const AudienceAndCampaignsSettings = () => { + const isEmbedOn = useFeatureIsOn(FLAGS.FEAT_CAC_305_EDCM); + + const { section: currentPage = AudienceAndCampaignsPages.Contacts } = + useParams<{ + section: AudienceAndCampaignsPages; + }>(); + + const isRestrictedDCMForSSXOn = useFeatureIsOn( + FLAGS.FEAT_SB_627_SSX_RESTRICTED_DCM + ); + const isSmartSchedulingOn = useFeatureIsOn(FLAGS.SMART_SCHEDULE_TALENT); + + const location = useLocation(); + const search = new URLSearchParams(location.search); + + const locationProps = Object(location.state) as Record; + + const campaignId = String(search.get("campaignId")); + const templateId = String(search.get("templateId")); + const type = String(search.get("type")); + const segmentId = String(search.get("segmentId")); + const mode = enumFromValue(String(search.get("mode")), SegmentMode); + + const contactTab = [ + { + name: "Contacts", + key: AudienceAndCampaignsTabs.Contacts, + uri: audiencesUrl, + }, + ]; + const otherTabs = [ + { + name: "Segments", + key: AudienceAndCampaignsTabs.Segments, + uri: segmentsUrl, + }, + { + name: "Campaigns", + key: AudienceAndCampaignsTabs.Campaigns, + uri: campaignsUrl, + }, + { + name: "Templates", + key: AudienceAndCampaignsTabs.Templates, + uri: templatesUrl, + }, + { + name: "Website Embed", + key: AudienceAndCampaignsTabs.Embed, + uri: embedUrl, + }, + ]; + const tabs = isRestrictedDCMForSSXOn + ? contactTab + : [...contactTab, ...otherTabs]; + + const pages: Record = { + [AudienceAndCampaignsPages.Contacts]: { + component: , + tab: AudienceAndCampaignsTabs.Contacts, + }, + [AudienceAndCampaignsPages.Segments]: { + component: , + tab: AudienceAndCampaignsTabs.Segments, + }, + [AudienceAndCampaignsPages.Segment]: { + component: , + tab: AudienceAndCampaignsTabs.Segments, + }, + [AudienceAndCampaignsPages.Templates]: { + component: , + tab: AudienceAndCampaignsTabs.Templates, + }, + [AudienceAndCampaignsPages.TemplatesCreate]: { + component: , + tab: AudienceAndCampaignsTabs.Templates, + }, + [AudienceAndCampaignsPages.TemplatesEdit]: { + component: , + tab: AudienceAndCampaignsTabs.Templates, + }, + [AudienceAndCampaignsPages.Campaigns]: { + component: , + tab: AudienceAndCampaignsTabs.Campaigns, + }, + [AudienceAndCampaignsPages.CampaignCreate]: { + component: isSmartSchedulingOn ? ( + + ) : ( + + ), + tab: AudienceAndCampaignsTabs.Campaigns, + }, + [AudienceAndCampaignsPages.CampaignEdit]: { + component: isSmartSchedulingOn ? ( + + ) : ( + + ), + tab: AudienceAndCampaignsTabs.Campaigns, + }, + [AudienceAndCampaignsPages.CampaignReport]: { + component: , + tab: AudienceAndCampaignsTabs.Campaigns, + }, + [AudienceAndCampaignsPages.Embed]: { + component: , + tab: AudienceAndCampaignsTabs.Embed, + }, + }; + + const page = pages[currentPage]; + + // When unknown section is passed + if (typeof page === "undefined" || !page || !page.tab) { + return ; + } + + if (page.tab === AudienceAndCampaignsTabs.SMSCampaigns) return page.component; + + const title = useMemo(() => { + const current = tabs.find(({ key }) => key === page.tab); + + return current ? current.name : "Audiences & Campaigns"; + }, [page]); + + return ( +
+
+ + {title} + +
+ {[ + AudienceAndCampaignsTabs.Contacts, + AudienceAndCampaignsTabs.Segments, + ].includes(page.tab) && ( +
{}
+ )} + {page.tab !== AudienceAndCampaignsTabs.Embed || isEmbedOn ? ( +
{page.component}
+ ) : null} +
+ ); +}; + +export default AudienceAndCampaignsSettings; diff --git a/interface_base/src/pages/AudienceAndCampaigns/typesUtils.ts b/interface_base/src/pages/AudienceAndCampaigns/typesUtils.ts new file mode 100644 index 0000000..861243b --- /dev/null +++ b/interface_base/src/pages/AudienceAndCampaigns/typesUtils.ts @@ -0,0 +1,105 @@ +import { CampaignStatuses } from "@komi-app/components"; +import { ContactType, MarketingPermission } from "@komi-app/fans-sdk"; +import { + campaignsUrl, + audiencesUrl, + segmentsUrl, + templatesUrl, +} from "constants/dca"; +import { CursorPaginatedItem } from "hooks/usePagination"; +import { embedUrl } from "constants/dca/embed"; + +export interface AudienceAndCampaignsPage { + component: JSX.Element; + tab: AudienceAndCampaignsTabs; +} +export enum AudienceAndCampaignsPages { + Contacts = "contacts", + Segments = "segments", + Segment = "segment", + Templates = "templates", + TemplatesCreate = "templates-create", + TemplatesEdit = "templates-edit", + Campaigns = "campaigns", + CampaignCreate = "campaign-create", + CampaignEdit = "campaign-edit", + CampaignReport = "campaign-report", + Embed = "embed", +} + +export interface ExistingCampaignRouteState { + campaignId?: string; +} + +export enum AudienceAndCampaignsTabs { + Contacts = "contacts", + Segments = "segments", + Campaigns = "campaigns", + SMSCampaigns = "sms-campaigns", + Templates = "templates", + Embed = "embed", +} + +export const tabs = [ + { + name: "Contacts", + key: AudienceAndCampaignsTabs.Contacts, + uri: audiencesUrl, + }, + { + name: "Segments", + key: AudienceAndCampaignsTabs.Segments, + uri: segmentsUrl, + }, + { + name: "Campaigns", + key: AudienceAndCampaignsTabs.Campaigns, + uri: campaignsUrl, + }, + { + name: "Templates", + key: AudienceAndCampaignsTabs.Templates, + uri: templatesUrl, + }, + { + name: "Website Embed", + key: AudienceAndCampaignsTabs.Embed, + uri: embedUrl, + }, +]; + +export interface ContactsTableRow { + name: string; + email: string[]; + phone: string; + firstSignedUp: string; +} +export interface CampaignsTableRow { + campaignId: string; + name: string; + status: CampaignStatuses; + segmentData: { segmentName: string }[]; + openRate: number | null; + clickThroughRate: number | null; + scheduledSendTimestamp: number | null; +} +export type CampaignsWithCursorTableRow = + CursorPaginatedItem; + +// Move into test files when api integrated +export const getTestSegments = (rows: number) => { + const segments = []; + + for (let i = 0; i < rows; i++) { + segments.push({ + segmentId: `${i}`, + name: `Segment ${i}`, + contactType: ContactType.Any, + marketingPermission: MarketingPermission.Any, + expressions: [], + }); + } + return segments; +}; + +export type ReviewSuccessfulState = { wasReviewSuccessful: boolean }; diff --git a/interface_base/src/pages/BecomeATalent/Reducer/action.ts b/interface_base/src/pages/BecomeATalent/Reducer/action.ts new file mode 100644 index 0000000..58525d1 --- /dev/null +++ b/interface_base/src/pages/BecomeATalent/Reducer/action.ts @@ -0,0 +1,8 @@ +import { createGenericTypes, createGenericActions } from "utils/createAction"; + +export const updateTalentProfileTypes = createGenericTypes( + "UPDATE_TALENT_PROFILE" +); +export const updateTalentProfileActions = createGenericActions( + updateTalentProfileTypes +); diff --git a/interface_base/src/pages/Cello/Cello.tsx b/interface_base/src/pages/Cello/Cello.tsx new file mode 100644 index 0000000..be48038 --- /dev/null +++ b/interface_base/src/pages/Cello/Cello.tsx @@ -0,0 +1,62 @@ +import { fullname } from "hooks/useNavUser"; +import React, { useEffect } from "react"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectUserData } from "redux/User/selector"; +import { getCelloService } from "services"; + +const Cello = () => { + useSelf(); + + /* + This component has no view as it is rendered from the Cello library + + Ref : https://docs.cello.so/docs/javascript-browser + */ + return <>; +}; + +export default Cello; + +export function useSelf() { + const user = useTypedSelector(selectUserData); + const fullName = fullname(user); + + useEffect(() => { + const initializeCello = async () => { + const celloData = await getCelloService().generateCelloToken(); + + if (celloData) { + (window as any).cello = (window as any).cello || { cmd: [] }; + (window as any).cello.cmd.push(async function (cello: any) { + try { + await cello.boot({ + productId: celloData.productId, + token: celloData.token, + language: "en", + productUserDetails: { fullName, email: user?.email }, + onTokenExpiring: updateCelloToken, + onTokenExpired: updateCelloToken, + }); + } catch (error) { + console.error("Failed to boot cello:", error); + } + }); + } + }; + + const updateCelloToken = async () => { + const newTokenData = await getCelloService().generateCelloToken(); + try { + if (newTokenData?.token) { + await (window as any).cello("updateToken", newTokenData.token); + } + } catch (error) { + console.error("Error updating cello token"); + } + }; + + initializeCello(); + }, [user, fullName]); + + return {}; +} diff --git a/interface_base/src/pages/CountriesLocalizationSelect/CountriesLocalizationSelect.tsx b/interface_base/src/pages/CountriesLocalizationSelect/CountriesLocalizationSelect.tsx new file mode 100644 index 0000000..87891f2 --- /dev/null +++ b/interface_base/src/pages/CountriesLocalizationSelect/CountriesLocalizationSelect.tsx @@ -0,0 +1,183 @@ +import { Col, Row } from "antd"; +import Checkbox from "antd/lib/checkbox/Checkbox"; +import Select from "antd/lib/select"; +import React, { useMemo } from "react"; +import { useCallback } from "react"; +import { Icon } from "../../components/Icon"; +import { countryFlags, FlagIconName } from "../../components/Icon/CountryFlag"; +import { Paragraph } from "../../components/Typography"; +import { CountriesCurrency } from "../../constants/countries-currency"; +import { selectLocalizations } from "redux/User/selector"; +import { useTypedSelector } from "redux/rootReducer"; +import { LocalizationItem as LocalizationItemType } from "redux/User/types"; +import classNames from "classnames"; +import "./CountriesLocalizationSelect.scss"; +import { getCircleFlagByCode } from "utils/image"; +const { Option } = Select; + +interface CountriesLocalizationSelectProps { + values?: string[]; + disabled?: boolean; + onChange?: (items: string[]) => void; + withFilter?: boolean; + className?: string; +} +const CountriesLocalizationSelect = ({ + values, + onChange, + disabled, + withFilter, + className = "", +}: CountriesLocalizationSelectProps) => { + const localizations = useTypedSelector(selectLocalizations); + const countriesLocalizations = useMemo(() => { + return [ + ...new Set( + ( + localizations?.map( + (locale: LocalizationItemType) => locale.countries + ) || [] + ).flat(1) + ), + ]; + }, [localizations]); + const countriesLocalizationSelected = useMemo( + () => countriesLocalizations.filter((el) => !values?.includes(el)), + [countriesLocalizations, values] + ); + const renderCountries = useCallback(() => { + return Object.keys(CountriesCurrency) + .filter( + (key) => !countriesLocalizationSelected.includes(key.toLowerCase()) + ) + .map((key) => { + const value = key.toLowerCase(); + const isFlag = key in countryFlags; + const isSelected = values?.includes(value); + const flagUrl = getCircleFlagByCode(key.toLowerCase()); + + return ( + + ); + }); + }, [values]); + const renderSelectedCountries = useCallback(() => { + return countriesLocalizationSelected.map((item: string, index: number) => { + const key = item.toUpperCase(); + const value = item; + const isFlag = key in countryFlags; + const isDisabled = true; + const isFirst = index === 0; + const flagUrl = getCircleFlagByCode(key); + + return ( + + ); + }); + }, [values]); + + return ( + + ); +}; + +export default CountriesLocalizationSelect; diff --git a/interface_base/src/pages/CurrencySelect/CurrencySelect.tsx b/interface_base/src/pages/CurrencySelect/CurrencySelect.tsx new file mode 100644 index 0000000..085c72e --- /dev/null +++ b/interface_base/src/pages/CurrencySelect/CurrencySelect.tsx @@ -0,0 +1,64 @@ +import { Col, Row } from "antd"; +import Select from "antd/lib/select"; +import * as React from "react"; +import { useCallback, useState } from "react"; +import { Icon } from "../../components/Icon"; +import { Paragraph } from "../../components/Typography"; +import { CurrencyCodes } from "../../constants/currency-code"; +import "./CurrencySelect.scss"; +const { Option } = Select; + +interface CurrencySelectProps { + value?: string; + onChange?: (item: string) => void; +} +const CurrencySelect = ({ value, onChange }: CurrencySelectProps) => { + const [visible, setVisible] = useState(false); + + const renderCountries = useCallback(() => { + return Object.keys(CurrencyCodes).map((key) => { + const item = CurrencyCodes[key]; + return ( + + ); + }); + }, [value]); + + return ( + + ); +}; + +export default CurrencySelect; diff --git a/interface_base/src/pages/ExpertTerms/ExpertTerms.tsx b/interface_base/src/pages/ExpertTerms/ExpertTerms.tsx new file mode 100644 index 0000000..7a37cde --- /dev/null +++ b/interface_base/src/pages/ExpertTerms/ExpertTerms.tsx @@ -0,0 +1,6799 @@ +import React from "react"; +import "./ExpertTerms.scss"; + +const ExpertTerms = () => { + return ( +
+

+ + Komi Terms for Experts + +

+

+ + Last updated: 18 November 2020 + +

+

+ + Welcome to Komi. Join our expert community! + +

+

+ + These terms (“ + + + Expert Terms + + + ”), and the documents we refer to in them, apply to your use of the + Komi platform and to transactions involving the platform, including + our website and our mobile and tablet applications (“ + + + Komi + + + ”).  + +

+

+ + Please read through our Expert Terms carefully, to understand what we + can do for you, how you can use Komi and how transactions through Komi + work. + +

+

+ + We suggest that you also read through the Komi Terms of Service (” + + + User Terms + + + ”), as they govern how Komi (and content, subscriptions and services + you make available through Komi) is made available to consumers.  + +

+

+ + When you use Komi, and when you create an account with us, you are + agreeing to these Expert Terms. + +

+

+ + Who we are + +

+

+ + Komi App Limited (“ + + + we + + + ”, “ + + + us + + + ”) is a company registered in England and Wales, with company number + 12268875 and registered address: 21 Bedford Square, London, United + Kingdom, WC1B 3HH. Email address:  + + + + am@komiapp.co + + + + . + +

+

+ + Komi works for experts + +

+

+ + Komi is a platform to connect you together with your audience and + consumers at large so that you can focus on doing what you do best: + providing great expert content and services directly to them. + +

+

+ + We don’t want to get in your way. When a consumer purchases your + content, subscription offering or services, such as to attend a + live-streamed class, a 1-to-1 tutorial or other content or services + you choose to make available through Komi, their agreement is with + you. We act as your vendor, such as for managing payment from + customers and so we can help manage their experience in Komi. + +

+

+ + Your Komi Expert account + +

+

+ + Before you can begin to make your expert content, subscriptions and + services available through Komi, you will first need to create a Komi + Expert account. To be able to create a Komi Expert account, you must: + +

+
    +
  • +

    + + Be at least 18 years old.  + +

    +
  • +
  • +

    + + If requested by us, provide information about the type of category + of content and services you intend to offer to consumers through + Komi. + +

    +
  • +
  • +

    + + Provide us with any other accurate information that we might + reasonably request in writing from you, such as during the Komi + Expert account setup process, so that we can understand your + expert skills and experience and check that we think the Komi + expert community is the right place for you and your content and + services. + +

    +
  • +
+

+ + We will need you to select an account password and other security + settings, to help us keep your Komi Expert account safe. You must not + share your Komi Expert account details with others. You are + responsible for control of your Komi Expert account, such as keeping + your password and account details safe and secure, and making sure + that the content and services you make available to consumers are what + you intend and are appropriate for the platform.  + +

+

+ + To the extent necessary for us to be able to provide Komi, you approve + the content of the User Terms, authorise us to act in accordance with + them and confirm you will abide by them (to the extent they directly + or indirectly refer or relate to obligations from you to consumers). + +

+

+ + What to do when making content or services available through Komi + +

+

+ + Describe your content and services accurately + +

+

+ + We want to make sure Komi consumer users understand what they are + paying you for. To help make that happen, you must ensure that you + take care to describe your content and services accurately before + making them available for consumers to consider and purchase through + Komi (and before making available any information about your skills, + biographic information, accreditations, qualifications and experiences + (your “ + + + Relevant Descriptions + + + ”)). You are solely responsible for your content and services and, in + particular but without limitation, ensuring that your Relevant + Descriptions are accurate, truthful and not misleading. Failure to + provide accurate Relevant Descriptions may result in loss of access to + Komi (including without limitation the suspension or termination of + your Komi Expert account). + +

+

+ + Make sure you have the necessary rights first + +

+

+ + For content and services you make available through Komi, it is our + expectation that all content and services you provide will be original + creations and works by you only. It is your responsibility to ensure + you have the necessary rights and clearances in order to be able to + provide your content and services, to grant us the rights and licences + that you grant us under these Expert Terms and, in particular, all + necessary rights and licences so that we are able to grant the rights + and licences to consumers that we do regarding your content and + services under the User Terms. + +

+

+ + Checking your materials + +

+

+ + We give Komi Expert account holders control over your content and + services. We don’t moderate your posts / messages, content, services + or offerings before they are made live to consumers. You acknowledge + and accept that you are solely responsible for your content, + subscriptions, services, materials, posts and offerings including + without limitation the legality, accuracy and compliance of them with + all applicable laws and regulations. Failure to comply with the + foregoing may result, at our discretion, in us suspending or + terminating your Komi Expert account. + +

+

+ + Identify 18+ content appropriately + +

+

+ + We require that you identify appropriately to consumers, such as in + descriptions and titles you make available, which of your content, + subscriptions or services are only appropriate for those aged 18 or + over. You are responsible for making this clear to consumers and + exercising best efforts so that your services, subscriptions or + content are not made available to an inappropriate audience. + +

+

+ + Take control of your pricing + +

+

+ + You can decide how much to charge consumers for your content and + services through Komi. Notwithstanding the foregoing, we reserve the + right to change, amend or reject pricing in our discretion (without + obligation to notify you). We may apply dynamic pricing, our + processing overheads or similar costs and measures. We reserve the + right to remove listed content or services you try to offer through + Komi and/or to cancel purchases of content or services you have + offered, where in our discretion it appears to us that your pricing is + grossly exploitative of consumers, is illegal or is in error. + +

+

+ + Keep the Komi user experience in mind + +

+

+ + We want to make sure Komi is a great experience for everyone. To do + that, we need your help and for you to apply your best efforts in + ensuring: (i) the good quality and experience of all content, + subscriptions and services you make available through Komi; and (ii) + good standards of customer services that you provide to consumers, + where applicable (such as where they ask you a question or ask for + support); and (iii) bookings / purchases made by Komi consumer users + are honoured and fulfilled. You acknowledge and agree that failure to + comply with the foregoing is a breach of these Expert Terms and may + result in, without limitation, us suspending or terminating your Komi + Expert account access. + +

+

+ + How payments through Komi work + +

+
    +
  • +

    + + You can make your chosen content, subscriptions and services + available through Komi, using the tools and functionality we make + available to you (which may vary from time to time in our + discretion). + +

    +
  • +
  • +

    + + When a consumer purchases content or services from you, we will + act as your vendor with handling, managing and concluding the + consumer’s purchase and experience. We would also help with + payment-related issues such as suspected fraud, cancellations / + chargebacks and helping to solve disputes with customers. + +

    +
  • +
  • +

    + + To maintain the quality and integrity of Komi, we reserve the + right to reject to process (or to cancel, if processing has + already happened) any purchases made through Komi that appear to + us to be mistakes, exploitative, in breach of these Expert Terms + or illegal. If we do so, we will refund the consumer on your + behalf. + +

    +
  • +
  • +

    + + We may not pay you any relevant fees for a transaction otherwise + due to you where a refund, or similar, is processed by us in + accordance with these Expert Terms. For the avoidance of doubt, + where a refund or similar occurs after the relevant sum is paid to + you, we will then have the right to recoup that sum from you. + +

    +
  • +
  • +

    + + We require that consumers pay in full when making their purchase + for any transaction through Komi.  + +

    +
  • +
  • +

    + + We will hold these funds (and/or our elected payment processor + vendor will) until you have completed the relevant service and/or + provided the relevant content and/or booking. After that date, + reasonably promptly, we will make the funds available to you, for + payment to your elected bank account. We are able to arrange such + payments by electronic bank transfers only, unless otherwise + agreed in writing by us. + +

    +
  • +
  • +

    + + At our discretion and taking account of the nature of transactions + relevant to your Komi Expert account, we may either make such + payments to you on an ad hoc basis or on a recurring, + date-specific basis.  + +

    +
  • +
  • +

    + + We know you want to be paid quickly; please bear with us as, + before paying you for the transaction, we need to deduct + first-party platform fees and costs first and, in some cases, + payment to you may take longer if we suspect, and need to + investigate, whether a transaction is in error or is fraudulent or + if it is subject to an ongoing customer complaint. + +

    +
  • +
  • +

    + + If, because of the processing of refunds, cancellations or + otherwise because of the processing of deductions from or + liabilities regarding your Komi Expert account, you may owe us a + correction payment. If that is the case, we reserve the right to + recover the correction payment from future payments we would have + otherwise made to you. We also reserve the right to require + either: (i) that you make direct payment to the relevant consumers + (such as of the refund); or (ii) that you pay such relevant + correction payment amounts back to us. + +

    +
  • +
  • +

    + + Payment by a consumer to us satisfies their obligation to pay you + for the content and services relevant to the transaction.  + +

    +
  • +
  • +

    + + You are responsible for your own tax and tax reporting (including + without limitation national insurance payments, income tax, + corporation tax or similar or equivalent), such as in respect of + income you receive from transactions relating to Komi and + purchases by consumers in accordance with these Expert Terms. + +

    +
  • +
+

+ + Gratuity + +

+

+ + Komi may provide consumers with an option to provide a gratuity to + you. Gratuities are voluntary for consumers and are additional to + ordinary purchase prices for consumers. If a consumer chooses to + provide a gratuity to you, the gratuity will be paid to us in the + first instance but we will then make it available to you reasonably + promptly (less payment processing costs (if any are applicable)). + +

+

+ + What you pay to Komi + +

+

+ + We want to provide you with certainty over what our fees are. + Regardless of how the consumer pays you or what they pay you for, the + Komi Service Fee (defined below) is the same. We do not charge you a + member subscription or overall platform fee for your Komi Expert + account. Instead, we only charge you a 20% per-transaction service fee + when consumers purchase your content, subscriptions (whether monthly, + annual or otherwise) or services through Komi (“ + + + Komi Service Fee + + + ”). + +

+

+ + The Komi Service Fee is deducted from the sum paid to you by the + consumer through Komi, which we collect on your behalf, only after + first-party platform fees are deducted first (such as, for example, of + the Apple App Store) and other reasonable costs. We then, after any + applicable taxes, make payment onwards to you for the relevant + transaction sum in accordance with these Expert Terms. + +

+

+ + Komi’s usage rules for experts  + +

+

+ + Please use Komi safely and responsibly, and keeping in mind the safety + of Komi's consumer users, following the usage rules for experts + below. References to ‘posting’ or ‘post’ below include selling or + making available for sale or purchase, communicating (including + without limitation by audio-visual material, stream or other media), + transmitting, conducting or otherwise making available in any content, + services or promotion of them. + +

+
    +
  • +

    + + Do not hold yourself out as acting for us, as having the right to + bind us to any legal agreement, or that you are authorised to + speak on our behalf or imply that you are entitled to do so. + +

    +
  • +
  • +

    + + Except through our Komi functionality that specifically allows for + setting out your e-commerce links and references, do not use Komi + to promote, market, advertise, endorse, support or feature in any + way (including without limitation within your posts) any third + party products or services. For the avoidance of doubt, you are + permitted to promote your own content and services for which you + have registered your Komi Expert account regarding their + availability within Komi. + +

    +
  • +
  • +

    + + Do not offer or promote any regulated content, services or + products through Komi including without limitation alcohol, + gambling, tobacco products (including e-cigarettes), drugs, + chemicals, financial advice or medical advice. + +

    +
  • +
  • +

    + + Do not post any content in Komi which seeks or makes any + arrangement to meet any person under the age of 18. + +

    +
  • +
  • +

    + + Do not impersonate or attempt to impersonate any other persons or + organisations whilst using Komi, including without limitation in + your posts. + +

    +
  • +
  • +

    + + Do not post in Komi, or use or attempt to use in Komi, any + content, services or materials which infringe the rights of any + third party (including without limitation their intellectual + property rights or privacy rights). + +

    +
  • +
  • +

    + + Do not post in Komi any materials or content which is + pornographic, sexual or sexually explicit, offensive, immoral (in + our view, acting reasonably), discriminatory, illegal (under the + laws of England and Wales and the territory in which you are + located), defamatory, threatening, violent or otherwise intended + to harm or harass others. We reserve the right to decide at our + sole discretion whether particular content or materials breach the + foregoing restrictions or the spirit of them. + +

    +
  • +
  • +

    + + Do not post in Komi any content which is homophobic, abusive, + hateful, seditious, encourages violation of these Expert Terms, + involves unsolicited marketing or SPAM, or which is technically + harmful or may be technically harmful such as viruses, logic + bombs, harmful data or any other malicious software or process. + +

    +
  • +
  • +

    + + Do not sell, copy, loan, rent, scrape, index, crawl, data mine, + publish, modify, adapt or reproduce any part of Komi. However, + using tools we make available, you can post on your personal + social media about Komi (including to promote the availability of + your content and services on Komi). + +

    +
  • +
  • +

    + + Do not attempt to gain unauthorised access to or to disrupt Komi + or any part of it or content within Komi, circumvent security + measures, interfere with or damage any part of Komi or content + within Komi or our systems, devices or servers. + +

    +
  • +
  • +

    + + Do not (or attempt to) hack, mimic, disable or obscure Komi + software or content in Komi, or merge, disassemble, decompile or + reverse-engineer Komi software or content. + +

    +
  • +
  • +

    + + You must only use Komi for lawful purposes (under the laws of + England and Wales and the territory in which you are located). Do + not use or attempt to use Komi in any way that breaches any + national, local or international law or regulation. You are + responsible for ensuring that your use of Komi and all of your + posts, actions, activities, content and services provided through + or in connection with your Komi Expert account are in full + compliance with applicable law. + +

    +
  • +
  • +

    + + You must conduct yourself in a reasonably professional manner at + all times and in such a way as to avoid reputational harm to us or + the Komi platform. + +

    +
  • +
  • +

    + + From time to time we may make additional community, safety and + security rules and guidelines available to you through Komi. This + may include guidelines for the whole Komi community, or which + apply specifically to Komi Expert account holders. Where we do so, + you must read and comply with them. + +

    +
  • +
+

+ + Your discounts and promotions + +

+

+ + You have the right to operate lawful promotions and discounts in + regards to your offering of content and services through Komi to + consumers. Where you choose to make a promotion or discount available, + please be aware that it is your responsibility to ensure that the + promotion or discount is in full compliance with all applicable laws + and industry codes of good practice. You must make clear in your + posts, marketing and promotion of your discounts and promotions that + the discount or promotion is offered by you.  + +

+

+ + From time to time in our discretion, we may assist you with driving + audience and consumer engagement to your Komi Expert account and your + content or services such as (whether specifically or as part of our + holistic efforts for promoting Komi) by operating and offering to + consumers on your behalf certain limited promotional offers, free + copies or access, price discounting and/or similar activities which + may involve your Komi Expert account and/or your content and services. + You authorise us to do the foregoing and you also acknowledge and + agree that no payments will be due to you (for example, without + limitation, where a promotion involves free-of-charge access to + consumers). + +

+

+ + Cancellations, charge-backs and refunds  + +

+

+ + Where a cancellation, charge-back or refund applies regarding a + purchase a consumer made regarding your content or services made + available through Komi in accordance with applicable law and/or the + User Terms, the relevant sum will be deducted from sums we would + otherwise owe you under these Expert Terms. In the event neither of + the above are possible, you shall remain directly liable to pay to us + the shortfall arising from cancellations, charge-backs or refunds that + arise for any cause. + +

+

+ + Notwithstanding anything to the contrary, we shall have the right to + (and you irrevocably authorise us on your behalf to) in our discretion + cancel purchases, process refunds, rescind or reject transactions or + to otherwise exercise our discretion over accepting or rejecting + refunds. + +

+

+ + Off-platform actions + +

+

+ + You agree that you will obtain our prior written (including email) + consent (not to be unreasonably withheld or delayed by us) before + taking any materials, assets or content from Komi, or which is + released, made available or communicated in Komi (including content + you submit into Komi as part of your content and services offering to + consumers), outside of Komi (such as reposting the same of your + (paid-for by consumers) content on third party services). + +

+

+ + In regards to the licences Komi grants you under these Terms, such + licences apply only to use within Komi and use outside of Komi is + prohibited unless expressly set out to the contrary. + +

+

+ + Your right to use Komi  + +

+

+ + We grant you a limited, non-transferable, revocable, non-exclusive + licence to use: (i) Komi for your use as a Komi expert (including our + website and the apps we make available), including for the purposes of + fulfilling your obligations and exercising your rights hereunder; and + (ii) ‘Komi’ branding and intellectual property rights we make + available in our discretion, including our logo and/or trade marks + (subject in all cases to the usage restrictions under these Expert + Terms). The foregoing licence includes the rights (through Komi in + each case) for you to transact for, fulfil, market and promote the + availability of your content and services in Komi for consumers to + purchase and access. + +

+

+ + You are granted a limited, non-transferable, revocable, non-exclusive + licence to use Komi consumers’ user-generated content (such as + consumers’ messages and reviews) we make available to you through + Komi, for the purposes of fulfilling your obligations under these + Expert Terms.  + +

+

+ + From time to time we may provide you with certain promotional + materials and assets which we identify as being for use by you for the + promotion of the availability to consumers of your Komi Expert account + (and the content, subscriptions and/or services you make available in + Komi) through your personal social media accounts, websites and blogs. + In regards to such materials and assets, we grant you a limited, + revocable, non-transferable, non-exclusive licence to use them for + those purposes only. We may require that you cease using them and/or + remove them from your social media accounts, websites and/or blogs at + our discretion on written notice to you.    + +

+

+ + Our rights to use your content + +

+

+ + When you create a Komi Expert account, you are appointing us as your + vendor. You retain ownership of the content you submit into Komi. + However, we do need you to grant us a licence, as follows, so that we + are able to use your works, content and intellectual property rights + in accordance with these Expert Terms.  + +

+

+ + You grant us an exclusive, irrevocable, worldwide, royalty-free right + and licence to use content, assets and materials (of any kind or + nature including without limitation copy, audio, audio-visual + materials) that you submit into Komi, such as through your posts, + messages and through your offerings to consumers of your content, + subscriptions and services (and your fulfilment and promotion of + them), but only for the following permitted purposes: + +

+
    +
  • +

    + + To provide the Komi platforms, content and services and fulfil + relevant transactions and in order for us to exercise and fulfil + our rights, duties and obligations under these Expert Terms and + under the User Terms; and + +

    +
  • +
  • +

    + + To promote and market Komi, including without limitation the + availability of your content and/or services through Komi. + +

    +
  • +
+

+ + The foregoing licence that you grant to us survives termination or + expiry of these Expert Terms. + +

+

+ + Our warranties to you and our liability  + +

+

+ + We warrant to you that: + +

+
    +
  • +

    + + We are entitled to enter into these Expert Terms and fulfil our + obligations under them; + +

    +
  • +
  • +

    + + We have and will have all necessary rights and licences to be able + to grant the rights and licences we grant to you under these + Expert Terms; and + +

    +
  • +
  • +

    + + Our services to you, in relation to Komi, will be provided with + reasonable care and skill. + +

    +
  • +
+

+ + Aside from the above, we provide Komi and its services to you  + + + “as is + + + ”; meaning that we make no other representations, warranties or + guarantees to you and that, to the maximum extent not prohibited by + applicable law, we exclude any and all implied warranties, + representations or guarantees. We do not limit our liability for + personal injury or death caused by our negligence, or for our fraud or + our fraudulent representation.  + +

+

+ + Except where otherwise expressly provided by these Expert Terms, and + to the maximum extent permitted by law: + +

+
    +
  • +

    + + We do not accept any liability for: (i) any unforeseeable, + indirect or consequential losses or costs (including without + limitation loss of opportunities, potential savings, profits, + revenues, customers, sponsors, damage to reputation or losses of + data); or (ii) personal injury or death not caused by our + negligence; and + +

    +
  • +
  • +

    + + To the maximum extent permitted by law, our liability to you shall + not exceed the greater of the following sums: (i) £500; or (ii) + the total amount you paid to us in Komi Service Fees (for the + avoidance of doubt, less first-party platform costs, distribution + fees and less any applicable taxes) in the preceding 12 months + prior to the relevant date of liability first incurring. + +

    +
  • +
+

+ + Your warranties to us + +

+

+ + You warrant, represent and undertake to us that: + +

+
    +
  • +

    + + You are entitled to, and have all necessary rights to, enter into + these Expert Terms and perform your obligations and grant all + necessary rights that you grant hereunder. + +

    +
  • +
  • +

    + + You will abide at all times with these Expert Terms and also with + the User Terms (to the extent the User Terms are relevant to your + performance, duties and obligations). + +

    +
  • +
  • +

    + + You will abide at all times by all applicable laws (under the laws + of England and Wales and also of the territory in which you are + located). + +

    +
  • +
  • +

    + + Your content, subscriptions and services made available through + Komi to consumers will be for recreational and leisure services + only and you will not hold out your content, subscriptions or + services as being for other purposes, including without limitation + as being medical advice, financial advice or advice on which any + consumer should place reliance. + +

    +
  • +
  • +

    + + Your content, subscriptions and services made available through + Komi to consumers will be appropriate for the Komi platform, + lawful and original works that you have created. + +

    +
  • +
  • +

    + + Your content, subscriptions and services made available through + Komi to consumers will be of the category that you disclosed to us + in your Komi Expert account creation process (as may be amended + with our written consent). + +

    +
  • +
  • +

    + + Your content, subscriptions and services made available through + Komi to consumers, and any assets, content and materials you + provide to us, will not infringe the rights of any third party + (including without limitation their intellectual property rights + or privacy rights). + +

    +
  • +
  • +

    + + You will exercise all reasonable care and skill in providing your + content, subscriptions and services through Komi to consumers. + +

    +
  • +
  • +

    + + You have all necessary skills, experience and qualifications + (including without limitation those you set out in any content or + descriptions you provide in Komi to consumers) for the purposes of + the content, subscriptions and services you provide to consumers. + +

    +
  • +
  • +

    + + Without prejudice to the above, you will provide your content, + subscriptions and services in such a manner so that all + commitments provided to consumers in the User Terms are met. + +

    +
  • +
+

+ + Indemnity + +

+

+ + You indemnify us, and our directors, staff, suppliers, licensors and + group companies, from any and all liabilities, penalties, losses and + costs (including without limitation legal and professional expenses) + that arise from any of the following: + +

+
    +
  • +

    + + Your breach of these Expert Terms (including without limitation + the warranties you grant to us in these Expert Terms and the + commitments you make to us regarding our confidential and/or + proprietary information); + +

    +
  • +
  • +

    + + Your use of Komi in any illegal (according to laws of England and + Wales as well as the territory in which you are located) way, such + as if you use Komi to access, share or post illegal content, + subscriptions and services or promote them; and + +

    +
  • +
  • +

    + + Third party claims against us (such as for, without limitation, + infringement of intellectual property or privacy rights), arising + from your use of Komi such as, without limitation, content you + make available through Komi. You also indemnify us from third + party claims against us arising from your failure to fulfil your + obligations to Komi consumer users. + +

    +
  • +
+

+ + In such instances, we shall have the right to control the legal + defence of any third party claim or regulatory investigation and you + shall provide us with all assistance we request, acting reasonably. + +

+

+ + Non-solicitation + +

+

+ + You shall not, without our prior written consent, at any date within + the period commencing from your creation of an Expert Account up to + the date which is six (6) months from the termination or expiry of + these Expert Terms for any cause, solicit or entice away (or attempt + to) from Komi to any alternative platform or service that a reasonable + business person would regard as a competitor to Komi, either of: + +

+
    +
  1. +

    + + Any consumer users of Komi; or + +

    +
  2. +
  3. +

    + + Any Komi Expert account holders. + +

    +
  4. +
+

+ + Failure to comply with the foregoing can, without limitation, result + in us suspending or terminating your Komi Expert account and, + additionally, we would reserve our right to seek damages or other + remedies. + +

+

+ + Data protection and data ownership + +

+

+ + We process your personal data, and that of Komi consumer users, in + accordance with our Komi Privacy and Cookie Policy{" "} + + + https://komiapp.co/privacy-policy + + + .  + +

+

+ + We do not share the personal data of Komi consumer users with Komi + experts. However, we may from time to time provide you with anonymised + usage reports at our discretion. + +

+

+ + For the purposes of these Expert Terms and the exercise of duties and + obligations hereunder by you and us, we both acknowledge and agree + that neither of us acts as a data processor of personal data on behalf + of the other (as ‘data processor’ and ‘personal data’ are defined in + the EU General Data Protection Regulation 2016/679). Both parties + instead act as independent controllers only, at all times for the + purposes of these Expert Terms. + +

+

+ + In regards to any intellectual property rights in Komi consumer user + data and databases (including without limitation copyrights, database + rights and any other intellectual property rights of any kind or + nature or equivalent rights in any territory), such rights vest solely + in us and are expressly reserved by us. On termination of these Expert + Terms for any cause, you will not be entitled to receive a copy of or + access to any Komi user data or databases (regardless of whether such + data or databases include personal data).  + +

+

+ + Confidentiality + +

+

+ + In the course of our working relationship with you, we may disclose to + you (but without obligation to do so) certain information which is of + a proprietary and/or confidential nature, such as regarding our + business affairs, pre-release marketing materials, marketing plans, + suppliers, business partners, viewing figures, engagement figures or + other of our confidential and/or proprietary information. All such + information, whether marked confidential or not and regardless of the + medium through which it is provided, must be held in strictest + confidentiality and not disclosed except: (i) with our prior written + approval; (ii) to your professional advisors or staff, to the extent + strictly necessary for the purposes of fulfilling your obligations + under these Expert Terms or exercising your rights under these Expert + Terms (and provided they are made subject to obligations of + confidentiality at least as stringent as under these Expert Terms); or + (iii) where required by a court or regulator of competent authority + (but providing us with advance written notice). + +

+

+ + Your rights to end the agreement + +

+

+ + You can close your Komi Expert account with us at any time, either by + using settings within Komi or by contacting us. However, we may need a + reasonable amount of time in order to process your closure request and + update Komi. + +

+

+ + Being fair to Komi’s consumer users is important to us. If you choose + to close your Komi Expert account, there may still be consumers who + have purchased previous content from you and who still want to access + that content. Closure of your account does not mean we will take down + or stop making available the relevant historical content. + +

+

+ + Our rights to end the agreement + +

+

+ + We may temporarily discontinue making available Komi or any part of it + for any reason, including without limitation upgrades, maintenance or + service administration reasons at our discretion. Updates we make to + Komi do not terminate these Expert Terms. We will try to limit the + downtime of the availability of Komi.  + +

+

+ + We reserve our right to take any action we deem reasonably necessary + against you if you breach these Expert Terms, and that may include, + without limitation, terminating these Expert Terms, suspending your + Komi Expert account, limiting account access, deleting your Komi + Expert account or your posts, materials, content, services or other + assets within Komi. However, if what you have done can be put right, + we will give you a reasonable opportunity to do so. + +

+

+ + We, additionally, have the right to terminate these Expert Terms (in + whole or in part) for convenience on notice to you (including without + limitation in writing or by email), without penalty. + +

+

+ + Termination effects + +

+

+ + If these Expert Terms are terminated for any reason (such as if you + delete your account, or if we terminate the Expert Terms because of + your breach of them or we terminate at-will): + +

+
    +
  • +

    + + The rights and licences granted from us to you shall immediately + terminate, including without limitation your licence to use Komi + or any part of it or our content therein; + +

    +
  • +
  • +

    + + The rights and licences you grant to us under these Expert Terms + will survive and continue unaffected; and + +

    +
  • +
  • +

    + + All other rights and liabilities accrued up to the termination + date shall be unaffected (including our obligation to pay you any + outstanding, undisputed fees we owe to you regarding purchases by + consumers of your services or content pursuant to these Expert + Terms). + +

    +
  • +
+

+ + Reserving our rights + +

+

+ + We, and our licensors, are the owners of all rights in Komi including + without limitation intellectual property rights. Except for licences + expressly granted to you under these Expert Terms, no other rights or + licences are granted and all of our and our licensors’ other rights + are reserved.  + +

+

+ + Your equipment, production and music licensing + +

+

+ + You are responsible for your own devices, equipment, production of + assets and content, overheads, development costs, hardware costs, + internet connection costs and other costs regarding any content, + subscriptions and services you provide as part of being able to fulfil + your obligations and exercise your rights hereunder. + +

+

+ + In no event shall we be responsible for paying royalties to you or any + third party or collection society regarding third party intellectual + property rights in any content you choose to input into Komi, + including without limitation music you feature in your content. + Clearance and licensing arrangements for music utilised in any and all + content, assets and materials you provide to us or make available + through Komi is solely your responsibility. You indemnify us from all + costs, liabilities and expenses we incur from your breach of the + foregoing. + +

+

+ + Komi subscriptions + +

+

+ + We may make available functionality in Komi that permits you to + operate subscriptions with your audience, for example for them to + subscribe to access content and/or services from your Komi Expert + account that you choose to make available to them in exchange for + their paid-for, recurring subscription. You acknowledge and agree + that: + +

+
    +
  • +

    + + We shall have the right to amend, update or remove subscription + functionality from Komi in our discretion, or to amend + subscription pricing; + +

    +
  • +
  • +

    + + The subscription functionality is provided by us in accordance + with the relevant terms in the User Terms and you acknowledge + those terms and authorise us to manage the subscriptions relevant + to your Komi Expert account in accordance with them; + +

    +
  • +
  • +

    + + For the avoidance of doubt and without prejudice to their + application in other areas, these Expert Terms apply to the + subscriptions offered to Komi consumer users by you (including + regarding all relevant warranties, undertakings, representations, + obligations and restrictions in these Terms which are relevant to + your offering of the subscriptions and/or the content or services + offered by you as part of the subscriptions). For example and + without limitation, you are responsible for and must ensure that + what is made available to consumers by you in exchange for + subscriptions is accurately described to the consumers and not + misleading;  + +

    +
  • +
  • +

    + + We reserve the right in our discretion to limit the Komi Expert + accounts who are permitted to offer subscriptions to consumers + and/or to withdraw that functionality from your account; and + +

    +
  • +
  • +

    + + If we make a refund of a subscription purchase to a consumer in + our discretion, without prejudice to our other rights, we may + recoup the refund from you. + +

    +
  • +
+

+ + Your interactions with Komi consumer users + +

+

+ + In all of your interactions with Komi consumer users you must comply + with these Expert Terms and any relevant provisions of the User Terms. + Similarly, we include protections and restrictions in the User Terms + regarding how Komi consumer users are required to interact with you. + However, to the maximum extent permissible by law, we are not + responsible for Komi consumer user-generated content (and we do not + moderate content from Komi consumer users), including without + limitation messages, images, audio, videos, video streams or similar + content Komi consumer users may provide to you. If you believe a Komi + consumer user has breached the User Terms, you must notify us + immediately. + +

+

+ + Application store terms + +

+

+ + For our Komi mobile / tablet applications (including iOS and, where we + make it available, Android or other platform versions), the + application stores (such as Apple App Store and Google Play) (each a “ + + + store + + + ”) require us to let you know that: + +

+
    +
  • +

    + + To use Komi applications, you acknowledge you have agreed to the + relevant store’s terms of use and service. + +

    +
  • +
  • +

    + + These Expert Terms are between you and us and not with any store + provider, including without limitation Apple Inc. (“ + + + Apple + + + ”) nor its subsidiaries or affiliates. + +

    +
  • +
  • +

    + + We are responsible for Komi and its content (except for the + content you provide to us) in accordance with these Expert Terms - + not Apple. We are the party responsible for providing application + support and maintenance. Apple has no obligation to provide + maintenance or support services for the application. + +

    +
  • +
  • +

    + + Licences for your use of Komi and content within Komi (except for + the content you provide to us) granted under these Expert Terms + are for use via the devices you own or control (e.g. your iOS + device), and as permitted by the store's usage rules. + +

    +
  • +
  • +

    + + We are solely responsible for providing support and maintenance + for the app. You acknowledge that Apple has no obligation + whatsoever to furnish any maintenance and support services with + respect to the Komi apps.  + +

    +
  • +
  • +

    + + We (not Apple) are responsible for addressing claims you may have + relating to the Komi iOS application and its possession or use. + +

    +
  • +
  • +

    + + In the event of a third party claim that the Komi iOS application + itself infringes a third party’s intellectual property rights, we + are the party responsible for the review, investigation, defence + and (where appropriate) settlement or discharge of such matter – + not Apple. + +

    +
  • +
  • +

    + + To be able to use Komi and create your Komi Expert account, you + confirm it is legal for you to access Komi in the territory you + are in and that you are not located in a country which is subject + to US Government embargoes or which is designated by the US + Government as a “terrorist-supporting country”. You also confirm + you are not listed on any US Government prohibited or restricted + parties lists. + +

    +
  • +
  • +

    + + You agree that Apple is entitled to enforce these Expert Terms as + a third party beneficiary, including against you if you were to + breach them. + +

    +
  • +
  • +

    + + To the extent of a conflict between store terms and our own, the + store terms prevail. + +

    +
  • +
+

+ + Komi Expert Addendum + +

+

+ + We appreciate that what you do may be unique and we may need to agree + some further, more tailored terms between us. For those purposes, we + may agree a separate Komi Expert Addendum with you in writing + (including email), to set out any special terms that are needed. To + the extent of any conflict or ambiguity between these Expert Terms and + the Komi Expert Addendum, the Komi Expert Addendum prevails. +    + +

+

+ + General + +

+
    +
  • +

    + + We may make changes to these Expert Terms from time to time in our + discretion. When we do so, we will post them here. Please check + back here from time to time for updates. Each time you use Komi, + or access or provide any content or services through Komi, you are + agreeing to the latest version of these Expert Terms. Until any + updated Expert Terms are agreed to by you in accordance with the + foregoing, the existing set shall apply. + +

    +
  • +
  • +

    + + These Expert Terms are the entire agreement between you and us in + respect of your use of Komi. Any representations not set out in + these Expert Terms are superseded and replaced by these Expert + Terms. + +

    +
  • +
  • +

    + + Even if we delay in enforcing our rights, including under these + Expert Terms, we still have the right to do so later. Failure to + enforce any particular rights or obligations under these Expert + Terms does not prejudice our ability to enforce any other rights + or obligations under these Expert Terms. + +

    +
  • +
  • +

    + + Our remedies set out in these Expert Terms, including without + limitation regarding suspending or terminating your Komi Expert + account, are without limitation to any rights and remedies + available to us under these Expert Terms or otherwise at law. + +

    +
  • +
  • +

    + + We have the right to subcontract, sublicense, assign, novate or + otherwise transfer our rights and obligations (in whole or part) + under these Expert Terms. For example, without limitation, we may + do so if we are acquired or restructured. If this happens, we will + notify you. + +

    +
  • +
  • +

    + + You are not permitted to subcontract, sublicense, assign or + otherwise transfer your rights or obligations under these Expert + Terms to a third party, unless we agree in writing. + +

    +
  • +
  • +

    + + You agree that the character of these Expert Terms is unique and + of a special value to Komi and that, if you breach the Expert + Terms, damages alone may not be a sufficient remedy and that Komi + shall additionally have the rights (without bond or security) to + equitable remedies including without limitation injunctive relief + and other equitable remedies as may be available at law. + +

    +
  • +
  • +

    + + Without prejudice to the express provisions of these Expert Terms, + these Expert Terms do not give rise to rights under the Contracts + (Rights of Third Parties) Act 1999 to enforce these terms. + +

    +
  • +
  • +

    + + In the event these Expert Terms or a part of them are held by any + court or tribunal of competent authority to be unenforceable, + these Expert Terms (or relevant part of them) will be interpreted + in such a way that the rest of the terms are unaffected and such + unenforceable terms shall be deemed replaced by terms which are + amended to the minimum extent possible so as to be enforceable and + reflect the parties intentions with full force and effect. + +

    +
  • +
  • +

    + + These Expert Terms will be governed and construed by the laws of + England and Wales. + +

    +
  • +
  • +

    + + If you have a dispute about these Expert Terms or arising in + connection with them, please contact us first to discuss it. The + courts of England and Wales shall have exclusive jurisdiction to + settle disputes or claims arising from or in connection with these + Expert Terms. + +

    +
  • +
+
+ ); +}; + +export default ExpertTerms; diff --git a/interface_base/src/pages/Logout/Logout.tsx b/interface_base/src/pages/Logout/Logout.tsx new file mode 100644 index 0000000..11d8c74 --- /dev/null +++ b/interface_base/src/pages/Logout/Logout.tsx @@ -0,0 +1,22 @@ +import Loading from "components/Loading"; +import React, { useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { logoutActions } from "redux/User/actions"; + +const Logout = () => { + const dispatch = useDispatch(); + + useEffect(() => { + // because this page is public so we have to force redirect to consumer manual + dispatch(logoutActions.REQUEST({ force: true })); + sessionStorage.clear(); + }, [dispatch]); + + return ( + + + + ); +}; + +export default Logout; diff --git a/interface_base/src/pages/Profile/AddModule/AddModule.tsx b/interface_base/src/pages/Profile/AddModule/AddModule.tsx new file mode 100644 index 0000000..3600192 --- /dev/null +++ b/interface_base/src/pages/Profile/AddModule/AddModule.tsx @@ -0,0 +1,1016 @@ +import { Col, Dropdown, Menu, Row } from "antd"; +import classNames from "classnames"; +import ShopifyModal from "components/ShopifyModal"; +import ShopifyStoreDomainInputModal from "components/ShopifyStoreDomainInputModal"; +import { Paragraph, Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { TipsLinkItem } from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { + FLAGS, + IfFeatureDisabled, + IfFeatureEnabled, + useFeatureIsOn, +} from "@komi-app/flags-sdk"; +import { useDispatch } from "react-redux"; +import { useTypedSelector } from "redux/rootReducer"; +import { triggerCheckCollectionAction } from "redux/Shopify/actions"; +import { selectShopifyStores } from "redux/Shopify/selector"; +import { setActiveModuleAction } from "redux/Talent/actions"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { MODAL_STEPS } from "redux/Shopify/types"; +import BandsintownEditorModal from "../../../components/BandsintownEditorModal"; +import SeatedEditorModal from "../../../components/SeatedEditorModal"; +import "./AddModule.scss"; +import ShopMyShelfEditorModal from "components/ShopMyShelfEditorModal"; +import ShopListEditorModal from "components/ShopListEditorModal"; +import DataCaptureAudienceModal from "components/DataCaptureAudience/DataCaptureCreationModal"; +import { DCAFormStepsProps } from "components/DataCaptureAudience/CreationModalForms/schemas"; +import { dcaFormValuesToItem } from "components/DataCaptureAudience/dca-item-mapping-fns"; + +import { Icon as TalentIcon } from "components/Icon"; +import RyeShopifyModal from "components/RyeShopifyModal"; +import { RYE_MODAL_STEPS } from "redux/RyeShopify/types"; +import RyeShopifyStoreDomainInputModal from "components/RyeShopifyStoreDomainInputModal/RyeShopifyStoreDomainInputModal"; +import { selectRyeShopifyStores } from "redux/RyeShopify/selector"; +import { triggerRyeCheckCollectionAction } from "redux/RyeShopify/actions"; +import { getMenuItems } from "./get-menu-items"; +import { getMenuItemsUpdated } from "./get-menu-items-updated"; +import { Icon } from "@komi-app/components"; +import { useWindowSize } from "../../../hooks"; +import ThroneLinkModal from "components/ThroneLinkModal"; +import { useAudienceAndCampaignFeatureOn } from "../../../hooks/useAudienceAndCampaignMobileBlock"; +import { ModuleCreationModals } from "../ModuleCreationModals/ModuleCreationModals"; +import { useHistory } from "react-router-dom"; +import TipsLinkModal from "../../../components/TipsLinkModal"; +import { ModuleType } from "@komi-app/shared-types"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { DEFAULT_UUID } from "@komi-app/profiles-sdk"; +import { trackingProps } from "utils/tracking"; + +interface AddModuleProps { + isSolid?: boolean; + groupId?: string; + + /** + * Show menu + * If passed, will be controlled by parent else will be controlled by internal state + * @default undefined + */ + showMenu?: boolean; + setShowMenu?: (value: boolean) => void; +} + +interface ControlledAddModuleProps { + isSolid?: boolean; + groupId?: string; + showMenu: boolean; + setShowMenu: (value: boolean) => void; +} + +const AddModule: React.FC = ({ + isSolid = true, + groupId, + showMenu: propShowMenu, + setShowMenu: propSetShowMenu, +}) => { + const [internalShowMenu, setInternalShowMenu] = useState(false); + + const showMenu = propShowMenu ?? internalShowMenu; + const setShowMenu = + propShowMenu !== undefined ? propSetShowMenu : setInternalShowMenu; + + return ( + {})} + /> + ); +}; + +const ControlledAddModule: React.FC = ({ + isSolid = true, + groupId, + showMenu, + setShowMenu, +}) => { + const dispatch = useDispatch(); + const { modules, setModuleList } = useModules(); + const [showShopifyInputModal, setShowShopifyInputModal] = useState(false); + const [visibleShopifyModal, setVisibleShopifyModal] = useState(false); + const [step, setStep] = useState(MODAL_STEPS.LOGIN); + const [showRyeShopifyInputModal, setShowRyeShopifyInputModal] = + useState(false); + const [visibleRyeShopifyModal, setVisibleRyeShopifyModal] = useState(false); + const [ryeStep, setRyeStep] = useState( + RYE_MODAL_STEPS.LOGIN + ); + const [visibleBandsintown, setVisibleBandsintown] = useState(false); + const [visibleSeated, setVisibleSeated] = useState(false); + const [visibleShopMyShelf, setVisibleShopMyShelf] = useState(false); + const [visibleShopList, setVisibleShopList] = useState(false); + const [visibleThroneLink, setVisibleThroneLink] = useState(false); + const [visibleTipsLink, setVisibleTipsLink] = useState(false); + const [dataCaptureModalVisible, setDataCaptureModalVisible] = useState(false); + const [dataCaptureCreationType, setDataCaptureCreationType] = useState< + | ModuleType.DATA_CAPTURE_COUPON_CODE + | ModuleType.DATA_CAPTURE_SECRET_LINK + | ModuleType.DATA_CAPTURE_SIGNUP + >(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const [openKeys, setOpenKeys] = useState([]); + const stores = useTypedSelector(selectShopifyStores); + const ryeStores = useTypedSelector(selectRyeShopifyStores); + const handleScroll = useCallback(() => { + showMenu && setShowMenu(false); + setOpenKeys([]); + }, [showMenu, setShowMenu]); + + const { sendSegmentEvent } = useAnalytics(); + const history = useHistory(); + const isContentMenuEnabled = useFeatureIsOn(FLAGS.FEAT_SB_389_ADD_ITEM_MENU); + const isNewYoutube = useFeatureIsOn(FLAGS.NEW_YOUTUBE); + const isNewPodcast = useFeatureIsOn(FLAGS.NEW_PODCAST); + const isShopifyOn = useFeatureIsOn(FLAGS.SHOPIFY); + const isRyeShopifyOn = useFeatureIsOn(FLAGS.RYE); + const useTiers = useAudienceAndCampaignFeatureOn(FLAGS.USE_TIERS_TALENT); + const isAudiencesAndCampaignsOn = + useAudienceAndCampaignFeatureOn(FLAGS.DCA) || useTiers; + const isNewAddModuleMenuOn = useFeatureIsOn( + FLAGS.GS_130_UPDATE_ADD_MODULES_MENU + ); + const isThroneOn = useFeatureIsOn(FLAGS.FEAT_SB_263_THRONE_LINK_MODULE); + const isTikTokOn = useFeatureIsOn(FLAGS.FEAT_SB_390_TIKTOK_MODULE); + const isTipsModuleOn = + useFeatureIsOn(FLAGS.FEAT_SB_634_DONATION_MODULES) || true; + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking(); + const moduleCreated = createTracker(CreatorEvent.MODULE_CREATED); + const elementCreated = createTracker(CreatorEvent.ELEMENT_CREATED); + const openModuleCreationMenu = createTracker( + CreatorEvent.OPEN_MODULE_CREATION_MENU + ); + const shopifyConnectionFlowCompleted = createTracker( + CreatorEvent.SHOPIFY_CONNECTION_FLOW_COMPLETED + ); + + const { height } = useWindowSize(); + + const buttonText = isContentMenuEnabled ? "Add Content" : "Add New Module"; + + useEffect(() => { + if (showMenu) { + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + } + }, [showMenu]); + const onAddNewModule = useCallback( + (type: ModuleType, data?: any) => { + const newModule = { + id: uuidv4(), // TODO: set from backend + order: 0, + name: "New Module", + type: type, + items: [], + expand: true, + isEdit: true, + isCreate: true, + isLoading: false, + groupId, + showTitle: true, + ...data, + }; + + const list = + modules?.map((module: any) => { + return { ...module, order: module.order + 1 }; + }) || []; + if (groupId) { + const module = list.find((item: any) => item.id === groupId); + if (module) { + module.items = [newModule, ...module.items].map((el, index) => ({ + ...el, + order: index, + })); + } + setModuleList?.(list); + return; + } + + setModuleList?.([newModule, ...list]); + dispatch(setActiveModuleAction(newModule.id)); + + window.scrollTo({ + behavior: "smooth", + top: 0, + }); + }, + [modules, groupId, setModuleList] + ); + + // lets ShopifyModule know that it has to check collection + const callbackAddShopifyModule = useCallback(() => { + onAddNewModule(ModuleType.SHOPIFY); + dispatch(triggerCheckCollectionAction(true)); + setVisibleShopifyModal(false); + }, [dispatch, modules, onAddNewModule]); + + const handleLoginCallback = () => { + setVisibleShopifyModal(false); + setShowShopifyInputModal(true); + }; + + const callbackAddRyeShopifyModule = useCallback(() => { + onAddNewModule(ModuleType.RYE_SHOPIFY); + dispatch(triggerRyeCheckCollectionAction(true)); + setVisibleRyeShopifyModal(false); + }, [dispatch, modules, onAddNewModule]); + + const handleRyeLoginCallback = () => { + setVisibleRyeShopifyModal(false); + setShowRyeShopifyInputModal(true); + }; + + const onAddModule = useCallback( + (menu: any) => () => { + setShowMenu(false); + + if (menu.type === ModuleType.SHOPIFY) { + if (!stores.length) { + // set login step for new connection + step !== MODAL_STEPS.LOGIN && setStep(MODAL_STEPS.LOGIN); + setVisibleShopifyModal(true); + return; + } + } + if (menu.type === ModuleType.RYE_SHOPIFY) { + if (!ryeStores.length) { + // set login step for new connection + ryeStep !== RYE_MODAL_STEPS.LOGIN && + setRyeStep(RYE_MODAL_STEPS.LOGIN); + setVisibleRyeShopifyModal(true); + return; + } + } + + if (menu.type === ModuleType.BANDSINTOWN) { + setTimeout(() => { + setVisibleBandsintown(true); + }, 150); + return; + } + if (menu.type === ModuleType.SEATED) { + setTimeout(() => { + setVisibleSeated(true); + }, 150); + return; + } + if (menu.type === ModuleType.SHOP_MY_SHELF) { + setTimeout(() => { + setVisibleShopMyShelf(true); + }, 150); + return; + } + if (menu.type === ModuleType.SHOP_LIST) { + setTimeout(() => { + setVisibleShopList(true); + }, 150); + return; + } + if ( + menu.type === ModuleType.DATA_CAPTURE_COUPON_CODE || + menu.type === ModuleType.DATA_CAPTURE_SECRET_LINK || + menu.type === ModuleType.DATA_CAPTURE_SIGNUP + ) { + setDataCaptureCreationType(menu.type); + setTimeout(() => { + setDataCaptureModalVisible(true); + }, 150); + return; + } + if (menu.type === ModuleType.THRONE_LINK) { + setTimeout(() => { + setVisibleThroneLink(true); + }, 150); + return; + } + + if ( + menu.type === ModuleType.PAYPAL_LINK || + menu.type === ModuleType.VENMO_LINK || + menu.type === ModuleType.CASHAPP_LINK || + menu.type === ModuleType.PATREON_LINK + ) { + setTimeout(() => { + setVisibleTipsLink(true); + }, 150); + return; + } + + const { pageId, pageName, type } = trackingProps( + menu.type === ModuleType.EVENTS ? "CUSTOM EVENT" : menu.type, + localizationSelected + ); + + useAnalyticsSDK + ? moduleCreated({ + pageId, + pageName, + type, + }) + : sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + + // settimeout for nice behavior + setTimeout(() => { + onAddNewModule(menu.type); + }, 150); + }, + [ + localizationSelected, + modules, + stores, + ryeStores, + step, + groupId, + onAddNewModule, + sendSegmentEvent, + ] + ); + + const handleAddBandsintown = useCallback( + (values) => { + setVisibleBandsintown(false); + + const { pageId, pageName, type } = trackingProps( + ModuleType.BANDSINTOWN, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "BANDSINTOWN EVENT", // FIXME + "Page ID": pageId, + "Page Name": pageName, + }); + } + onAddNewModule(ModuleType.BANDSINTOWN, { + items: [{ ...values, order: 0 }], + }); + }, + [localizationSelected, onAddNewModule, sendSegmentEvent] + ); + const handleAddSeated = useCallback( + (values) => { + setVisibleBandsintown(false); + + const { pageId, pageName, type } = trackingProps( + ModuleType.SEATED, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "SEATED EVENT", + "Page ID": pageId, + "Page Name": pageName, + }); + } + + onAddNewModule(ModuleType.SEATED, { + items: [{ ...values, order: 0 }], + }); + }, + [localizationSelected, onAddNewModule, sendSegmentEvent] + ); + const handleShopMyShelf = useCallback( + (values) => { + setVisibleShopMyShelf(false); + + const { pageId, pageName, type } = trackingProps( + "SHOPMYSHELF", + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + } + + onAddNewModule(ModuleType.SHOP_MY_SHELF, { + name: values.name || "New Module", + items: [{ url: values.url, order: 0 }], + }); + }, + [localizationSelected, onAddNewModule, sendSegmentEvent] + ); + const handleShopList = useCallback( + (values) => { + setVisibleShopList(false); + + const { pageId, pageName, type } = trackingProps( + "SHOPLIST", + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + } + + onAddNewModule(ModuleType.SHOP_LIST, { + name: values.name || "New Module", + items: [{ url: values.url, order: 0 }], + }); + }, + [localizationSelected, onAddNewModule, sendSegmentEvent] + ); + const handleDataCaptureFormCreated = useCallback( + (values: DCAFormStepsProps) => { + setDataCaptureModalVisible(false); + + if (!values.type) { + console.log("No creation type specified"); + return; + } + + const newModule = dcaFormValuesToItem(values, { + name: "New Module", + order: 0, + }); + onAddNewModule(newModule.type, { + name: newModule.name, + items: [newModule], + }); + }, + [localizationSelected, onAddNewModule, sendSegmentEvent] + ); + const handleThroneLink = useCallback( + (url) => { + const { pageId, pageName, type } = trackingProps( + ModuleType.THRONE_LINK, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": type, + "Page ID": pageId, + "Page Name": pageName, + }); + } + + setVisibleThroneLink(false); + onAddNewModule(ModuleType.LINK, { + name: "Throne", + items: [ + { + url, + title: "My Throne Wishlist", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/-W1BJQRavoGWC8VlU_qTU.png", + }, + ], + }); + }, + [onAddNewModule, sendSegmentEvent] + ); + + const handleTipsLink = useCallback( + ({ src, title, url, platform }: TipsLinkItem) => { + const { pageId, pageName, type } = trackingProps( + `${platform} LINK`, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": platform, + "Page ID": pageId, + "Page Name": pageName, + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": `${platform} LINK`, + "Page ID": pageId, + "Page Name": pageName, + }); + } + + setVisibleTipsLink(false); + onAddNewModule(ModuleType.LINK, { + name: "Tips", + items: [ + { + url, + title, + thumbnail: src, + visible: true, + }, + ], + }); + }, + [onAddNewModule, sendSegmentEvent] + ); + + const menu = useMemo(() => { + if (isNewAddModuleMenuOn) { + return ( + { + setOpenKeys(data); + }} + expandIcon={ + <> +
+ +
+ + } + data-tour="onboarding-module-menu" + className={classNames("add_module__menu")} + style={{ width: "100%", maxWidth: "390px" }} + triggerSubMenuAction={"click"} + > + {getMenuItemsUpdated(stores, ryeStores, { + isNewPodcast, + isNewYoutube, + isShopifyOn, + isRyeShopifyOn, + isAudiencesAndCampaignsOn, + isThroneOn, + isTikTokOn, + })?.map((item, index) => { + if (!item.subMenu) { + return ( + +
+
{item.icon}
+
+ {item.title} + {item.description && ( + + {item.description} + + )} +
+
+
+ ); + } + return ( + + {item.title} + {item.description && ( + + {item.description} + + )} +
+ } + icon={ +
+
{item.icon}
+
+ } + > + {item.subMenu.map((el: any, index) => ( + + {el.type !== "ON_DEMAND_VIDEO" ? ( + + + {el.icon} + + + {el.title} + + + + + ) : ( + <> + )} + + ))} + + ); + })} + + ); + } + + return ( + { + setOpenKeys(data); + }} + data-tour="onboarding-module-menu" + className={classNames("add_module__menu")} + style={{ width: 285 }} + triggerSubMenuAction={"click"} + > + {getMenuItems(stores, ryeStores, { + isNewPodcast, + isNewYoutube, + isShopifyOn, + isRyeShopifyOn, + isAudiencesAndCampaignsOn, + })?.map((item, index) => { + if (!item.subMenu) { + return ( + + + {item.icon} + + {item.title} + + + + ); + } + return ( + {item.icon}
} + > + {item.subMenu.map((el: any, index) => ( + + {el.type !== "ON_DEMAND_VIDEO" ? ( + + + {el.icon} + + {el.title} + + + + ) : ( + <> + )} + + ))} + + ); + })} + + ); + }, [onAddModule, stores, showMenu, openKeys, isNewAddModuleMenuOn]); + + const onVisibleChange = (visible: boolean) => { + if (isContentMenuEnabled) { + history.push({ + pathname: "/admin/modules/new", + search: groupId ? `?groupId=${groupId}` : "", + }); + } else { + setShowMenu(visible); + if (visible) { + const pageId = localizationSelected?.id || DEFAULT_UUID; + const pageName = localizationSelected?.name || "Default"; + + useAnalyticsSDK + ? openModuleCreationMenu({ pageId, pageName }) + : sendSegmentEvent(SEGMENT_EVENTS.OPEN_MODULE_CREATION_MENU, { + "Page ID": pageId, + "Page Name": pageName, + }); + } + } + }; + + const onSubmitDomainCallback = useCallback(() => { + setVisibleShopifyModal(true); + setStep(MODAL_STEPS.LOGIN_SUCCESS); + }, []); + + const onCreateAccountCallback = useCallback(() => { + setStep(MODAL_STEPS.CREATE_ACCOUNT); + }, []); + + const connectToStore = useCallback(() => { + setStep(MODAL_STEPS.LOGIN); + }, []); + + const onSubmitRyeDomainCallback = useCallback(() => { + const count = (stores?.length ?? 0) + 1; + const from = "Modules page"; + const success = true; + + useAnalyticsSDK + ? shopifyConnectionFlowCompleted({ + from, + stores: count, + success, + }) + : sendSegmentEvent(SEGMENT_EVENTS.SHOPIFY_CONNECTION_FLOW_COMPLETED, { + "Number of connected stores": count, + "Added from": from, + "Is successful": success, + }); + + setVisibleRyeShopifyModal(true); + setRyeStep(RYE_MODAL_STEPS.LOGIN_SUCCESS); + }, [stores?.length]); + + const connectToRyeStore = useCallback(() => { + setRyeStep(RYE_MODAL_STEPS.LOGIN); + }, []); + + const modalType = useMemo(() => { + // this is a temporary port - this logic will be removed once `Content Menu` has been released + if (visibleBandsintown) { + return ModuleType.BANDSINTOWN; + } + if (visibleSeated) { + return ModuleType.SEATED; + } + if (visibleShopMyShelf) { + return ModuleType.SHOP_MY_SHELF; + } + if (visibleShopList) { + return ModuleType.SHOP_LIST; + } + if (dataCaptureModalVisible) { + return dataCaptureCreationType; + } + if (visibleThroneLink) { + return ModuleType.THRONE_LINK; + } + if (visibleShopifyModal || showShopifyInputModal) { + return ModuleType.SHOPIFY; + } + if (visibleRyeShopifyModal || showRyeShopifyInputModal) { + return ModuleType.RYE_SHOPIFY; + } + + return undefined; + }, [ + visibleBandsintown, + visibleSeated, + visibleShopMyShelf, + visibleShopList, + dataCaptureModalVisible, + dataCaptureCreationType, + visibleThroneLink, + visibleShopifyModal, + showShopifyInputModal, + visibleRyeShopifyModal, + showRyeShopifyInputModal, + ]); + + return ( + + {isNewAddModuleMenuOn && ( +
+ )} +
+ + {isSolid ? ( + + + + {buttonText} + + + ) : ( + + + + + + + {buttonText} + + + + )} + +
+ + + { + // this is a temporary port - this logic will be removed once `Content Menu` has been released + setVisibleBandsintown(false); + setVisibleSeated(false); + setVisibleShopMyShelf(false); + setVisibleShopList(false); + setVisibleThroneLink(false); + setVisibleTipsLink(false); + setDataCaptureModalVisible(false); + setVisibleShopifyModal(false); + setShowShopifyInputModal(false); + setVisibleRyeShopifyModal(false); + setShowRyeShopifyInputModal(false); + }} + type={modalType} + /> + + + + + + {showShopifyInputModal && ( + + )} + {showRyeShopifyInputModal && ( + + )} + + + + + {isAudiencesAndCampaignsOn && dataCaptureCreationType && ( + + )} + {isThroneOn && ( + + )} + {isTipsModuleOn && ( + + )} + + + ); +}; + +export default AddModule; diff --git a/interface_base/src/pages/Profile/AddModule/get-menu-items-updated.tsx b/interface_base/src/pages/Profile/AddModule/get-menu-items-updated.tsx new file mode 100644 index 0000000..ed15b66 --- /dev/null +++ b/interface_base/src/pages/Profile/AddModule/get-menu-items-updated.tsx @@ -0,0 +1,219 @@ +import { ShopifyStore } from "../../../redux/Shopify/types"; +import { RyeShopifyStore } from "../../../redux/RyeShopify/types"; +import { Icon } from "@komi-app/components"; +import { Icon as TalentIcon } from "../../../components/Icon"; +import React from "react"; +import { MenuItem } from "./types"; +import { SocialIcon, SocialLinkType } from "@komi-app/creator-ui"; +import { ModuleType } from "@komi-app/shared-types" + +const getShopifySubMenuCaption = ( + stores: (ShopifyStore | RyeShopifyStore)[] +): string => + stores && stores.length + ? "Add Products from Shopify" + : "Connect a Shopify Store"; +const customProductsSubMenu = { + title: "Add Custom Products", + icon: , + type: ModuleType.PRODUCT, +}; +const storeSubMenu = ( + stores: ShopifyStore[], + ryeStores: RyeShopifyStore[], + isShopifyOn: boolean, + isRyeShopifyOn: boolean, + isThroneOn: boolean +): { + title: string; + icon: JSX.Element; + type?: ModuleType; +}[] => { + const items = [customProductsSubMenu]; + if (isShopifyOn) { + if (isRyeShopifyOn) { + items.push({ + title: getShopifySubMenuCaption(ryeStores), + icon: , + type: ModuleType.RYE_SHOPIFY, + }); + } else { + items.push({ + title: getShopifySubMenuCaption(stores), + icon: , + type: ModuleType.SHOPIFY, + }); + } + } + + items.push({ + title: "Add Products from Shop My", + icon: , + type: ModuleType.SHOP_MY_SHELF, + }); + + if (isThroneOn) { + items.push({ + title: "Add Throne Wishlist", + icon: , + type: ModuleType.THRONE_LINK, + }); + } + + return items; +}; +const getDataCaptureMenu = (isAudiencesAndCampaignsOn: boolean): MenuItem => { + return isAudiencesAndCampaignsOn + ? { + key: "dataCapture", + title: "Data Capture Form", + testId: "sub-menu-dca", + description: "Collect your audience's email and phone number", + icon: , + subMenu: [ + { + title: "Email/SMS Signup", + icon: ( + + ), + type: ModuleType.DATA_CAPTURE_SIGNUP, + testId: "email-sms-menu", + }, + { + title: "Secret Link", + icon: , + type: ModuleType.DATA_CAPTURE_SECRET_LINK, + }, + { + title: "Secret Code", + icon: , + type: ModuleType.DATA_CAPTURE_COUPON_CODE, + }, + ], + } + : { + key: "data-capture-from", + testId: "sub-menu-dcf", + title: "Data Capture Form", + description: "Collect your audience's email and phone number", + icon: , + type: ModuleType.FORM_DATA, + }; +}; +export const getMenuItemsUpdated = ( + stores: ShopifyStore[], + ryeStores: RyeShopifyStore[], + { + isNewPodcast, + isNewYoutube, + isShopifyOn, + isRyeShopifyOn, + isAudiencesAndCampaignsOn, + isThroneOn, + isTikTokOn, + }: { [_: string]: boolean } +): MenuItem[] => [ + { + title: "External Link", + description: "Direct visitors to another site or highlight special offers", + testId: "menu-item-link", + icon: , + type: ModuleType.LINK, + }, + isTikTokOn + ? { + title: "Video", + testId: "menu-item-video", + description: "Showcase videos from your channels", + icon: , + key: "Video", + subMenu: [ + { + title: "YouTube", + testId: "menu-item-youtube-video", + icon: , + type: isNewYoutube + ? ModuleType.YOUTUBE + : ModuleType.YOUTUBE_VIDEO, + }, + { + title: "TikTok", + testId: "menu-item-youtube-video", + icon: , + type: ModuleType.TIKTOK_VIDEO, + }, + ], + } + : { + title: "YouTube Video", + testId: "menu-item-youtube-video", + description: "Showcase videos directly from YouTube", + icon: , + type: isNewYoutube + ? ModuleType.YOUTUBE + : ModuleType.YOUTUBE_VIDEO, + }, + { + title: "Music", + testId: "menu-item-music", + description: "Share music instantly with smart links and pre-release links", + icon: , + type: ModuleType.MUSIC, + }, + { + title: "Podcast", + testId: "menu-item-podcast", + description: "Sync your latest episodes or add episodes individually", + icon: , + type: isNewPodcast + ? ModuleType.PODCAST_SELECT + : ModuleType.PODCAST, + }, + { + title: "Product", + description: isThroneOn + ? "Feature your items, wishlists or promote affiliate products" + : "Feature your items or promote affiliate products", + testId: "sub-menu-store", + icon: , + key: "Store", + subMenu: storeSubMenu( + stores, + ryeStores, + isShopifyOn, + isRyeShopifyOn, + isThroneOn + ), + }, + { + title: "Events", + description: "Add your events or sync with Bandsintown and Seated", + testId: "sub-menu-events", + icon: , + key: "menuEvents", + subMenu: [ + { + title: "Add Custom Events", + testId: "", + icon: , + type: ModuleType.EVENTS, + }, + { + title: "Add from Bandsintown", + icon: , + type: ModuleType.BANDSINTOWN, + }, + { + title: "Add from Seated", + icon: , + type: ModuleType.SEATED, + }, + ], + }, + getDataCaptureMenu(isAudiencesAndCampaignsOn), +]; diff --git a/interface_base/src/pages/Profile/AddModule/get-menu-items.tsx b/interface_base/src/pages/Profile/AddModule/get-menu-items.tsx new file mode 100644 index 0000000..33b4322 --- /dev/null +++ b/interface_base/src/pages/Profile/AddModule/get-menu-items.tsx @@ -0,0 +1,189 @@ +import { ShopifyStore } from "../../../redux/Shopify/types"; +import { RyeShopifyStore } from "../../../redux/RyeShopify/types"; +import { MenuItem } from "./types"; +import { Icon } from "@komi-app/components"; +import { Icon as TalentIcon } from "../../../components/Icon"; +import React from "react"; +import { ModuleType } from "@komi-app/shared-types" + +const getShopifySubMenuCaption = ( + stores: (ShopifyStore | RyeShopifyStore)[] +): string => + stores && stores.length + ? "Add Products from Shopify" + : "Connect a Shopify Store"; + +const customProductsSubMenu = { + title: "Add Custom Products", + icon: , + type: ModuleType.PRODUCT, +}; + +const storeSubMenu = ( + stores: ShopifyStore[], + ryeStores: RyeShopifyStore[], + isShopifyOn: boolean, + isRyeShopifyOn: boolean +): { + title: string; + icon: JSX.Element; + type?: ModuleType; +}[] => { + const items = [customProductsSubMenu]; + if (isShopifyOn) { + if (isRyeShopifyOn) { + items.push({ + title: getShopifySubMenuCaption(ryeStores), + icon: , + type: ModuleType.RYE_SHOPIFY, + }); + } else { + items.push({ + title: getShopifySubMenuCaption(stores), + icon: , + type: ModuleType.SHOPIFY, + }); + } + } + + items.push({ + title: "Add Products from Shop My", + icon: , + type: ModuleType.SHOP_MY_SHELF, + }); + + return items; +}; + +const getDataCaptureMenu = (isAudiencesAndCampaignsOn: boolean): MenuItem => { + return isAudiencesAndCampaignsOn + ? { + key: "dataCapture", + title: "Data Capture", + testId: "sub-menu-dca", + icon: , + subMenu: [ + { + title: "Email/SMS Signup", + icon: ( + + ), + type: ModuleType.DATA_CAPTURE_SIGNUP, + testId: "email-sms-menu", + }, + { + title: "Secret Link", + icon: , + type: ModuleType.DATA_CAPTURE_SECRET_LINK, + }, + { + title: "Secret Code", + icon: , + type: ModuleType.DATA_CAPTURE_COUPON_CODE, + }, + ], + } + : { + key: "data-capture-from", + testId: "sub-menu-dcf", + title: "Data Capture Form", + icon: , + type: ModuleType.FORM_DATA, + }; +}; + +export const getMenuItems = ( + stores: ShopifyStore[], + ryeStores: RyeShopifyStore[], + { + isNewPodcast, + isNewYoutube, + isShopifyOn, + isRyeShopifyOn, + isAudiencesAndCampaignsOn, + }: { [key: string]: boolean } +): MenuItem[] => [ + { + title: "Link", + testId: "menu-item-link", + icon: , + type: ModuleType.LINK, + }, + { + title: "Video", + testId: "sub-menu-video", + icon: , + key: "Video", + subMenu: [ + { + title: "On-demand Video", + icon: , + type: ModuleType.ON_DEMAND_VIDEO, + }, + { + title: "YouTube Video", + icon: , + type: isNewYoutube + ? ModuleType.YOUTUBE + : ModuleType.YOUTUBE_VIDEO, + }, + ], + }, + { + title: "Audio", + testId: "sub-menu-audio", + icon: , + key: "Audio", + subMenu: [ + { + title: "Music", + icon: , + type: ModuleType.MUSIC, + }, + { + title: "Podcast", + icon: , + type: isNewPodcast + ? ModuleType.PODCAST_SELECT + : ModuleType.PODCAST, + }, + ], + }, + { + title: "Store", + testId: "sub-menu-store", + icon: , + key: "Store", + subMenu: storeSubMenu(stores, ryeStores, isShopifyOn, isRyeShopifyOn), + }, + { + title: "Events", + testId: "sub-menu-events", + icon: , + key: "menuEvents", + subMenu: [ + { + title: "Add Custom Events", + testId: "", + icon: , + type: ModuleType.EVENTS, + }, + { + title: "Add from Bandsintown", + icon: , + type: ModuleType.BANDSINTOWN, + }, + { + title: "Add from Seated", + icon: , + type: ModuleType.SEATED, + }, + ], + }, + getDataCaptureMenu(isAudiencesAndCampaignsOn), +]; diff --git a/interface_base/src/pages/Profile/AddShortVideo/routes.ts b/interface_base/src/pages/Profile/AddShortVideo/routes.ts new file mode 100644 index 0000000..6f79b26 --- /dev/null +++ b/interface_base/src/pages/Profile/AddShortVideo/routes.ts @@ -0,0 +1,13 @@ +export enum ShortVideoPage { + SELECT_TYPE = "SELECT_TYPE", + MANUAL_ENTRY = "MANUAL_ENTRY", + AUTOMATION = "AUTOMATION", + AUTOMATION_EDIT = "AUTOMATION_EDIT", +} + +export const routes: Record = { + [ShortVideoPage.SELECT_TYPE]: "/", + [ShortVideoPage.MANUAL_ENTRY]: "/manual", + [ShortVideoPage.AUTOMATION]: "/automatic", + [ShortVideoPage.AUTOMATION_EDIT]: "/automatic/edit", +}; diff --git a/interface_base/src/pages/Profile/Header/Custom/CustomImageReZoom.tsx b/interface_base/src/pages/Profile/Header/Custom/CustomImageReZoom.tsx new file mode 100644 index 0000000..075e5ca --- /dev/null +++ b/interface_base/src/pages/Profile/Header/Custom/CustomImageReZoom.tsx @@ -0,0 +1,167 @@ +import { Modal, Slider } from "antd"; +import LocaleReceiver from "antd/lib/locale-provider/LocaleReceiver"; +import useBreakpoint from "antd/lib/grid/hooks/useBreakpoint"; +// import ImageBlobReduce from "image-blob-reduce"; +import Loading from "components/Loading"; +import React, { + forwardRef, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import "./CustomImageReZoom.less"; +import { Text } from "components/Typography"; + +const pkg = "antd-img-re-zoom"; +const noop = () => { + console.log(""); +}; + +const ZOOM_STEP = 5; + +const ImageReZoom = (props: ImageReZoomProps) => { + const { + src, + minZoom = 50, + maxZoom = 100, + modalTitle, + modalWidth, + modalOk, + modalCancel, + children, + onSetImageWidth, + defaultZoom, + } = props; + + const [zoomVal, setZoomVal] = useState(maxZoom); + const [loading, setLoading] = useState(false); + const [isShowModal, setIsShowModal] = useState(false); + const screens = useBreakpoint(); + + /** Controls */ + const isMinZoom = zoomVal - ZOOM_STEP < minZoom; + const isMaxZoom = zoomVal + ZOOM_STEP > maxZoom; + const subZoomVal = useCallback(() => { + if (!isMinZoom) setZoomVal(zoomVal - ZOOM_STEP); + }, [isMinZoom, zoomVal]); + + const addZoomVal = useCallback(() => { + if (!isMaxZoom) setZoomVal(zoomVal + ZOOM_STEP); + }, [isMaxZoom, zoomVal]); + + const modalProps = useMemo(() => { + const obj: any = { + width: modalWidth, + okText: modalOk, + cancelText: modalCancel, + }; + Object.keys(obj).forEach((key) => { + if (!obj[key]) delete obj[key]; + }); + return obj; + }, [modalCancel, modalOk, modalWidth]); + + const onClose = useCallback(() => { + setIsShowModal(false); + }, []); + + const onOk = () => { + onClose(); + onSetImageWidth?.(`${zoomVal}%`); + }; + + const handleOnClick = () => { + setIsShowModal(true); + }; + useEffect(() => { + if (defaultZoom) { + const val = parseInt(defaultZoom.replace("%", "")); + setZoomVal(val); + } + }, [defaultZoom]); + + const renderComponent = (titleOfModal: string) => ( + <> +
+ UPDATE +
+
+ + {children} + + {isShowModal ? ( + +
+
+ +
+
+ +
+ + `${value}%`} + /> + +
+
+ ) : loading && screens["xs"] ? ( + + ) : ( + <> + )} + + ); + + if (modalTitle) return renderComponent(modalTitle); + + return ( + + {(locale, localeCode) => + renderComponent(localeCode === "zh-cn" ? "编辑图片" : "Edit image") + } + + ); +}; + +interface ImageReZoomProps { + shape?: "rect" | "round"; + grid?: boolean; + rotate?: boolean; + minZoom?: number; + maxZoom?: number; + fillColor?: string; + resizeMaxSize?: number; + modalTitle?: string; + modalWidth?: number | string; + modalOk?: string; + modalCancel?: string; + beforeCrop?: any; + cropperProps?: any; + children?: any; + onSetImageWidth?: (value: string) => void; + src?: string; + defaultZoom?: string; +} + +export default forwardRef(ImageReZoom); diff --git a/interface_base/src/pages/Profile/Header/Custom/CustomImageUploader.tsx b/interface_base/src/pages/Profile/Header/Custom/CustomImageUploader.tsx new file mode 100644 index 0000000..0469a80 --- /dev/null +++ b/interface_base/src/pages/Profile/Header/Custom/CustomImageUploader.tsx @@ -0,0 +1,173 @@ +import { Col, Row, Upload } from "antd"; +import { RcFile } from "antd/lib/upload"; +import { UploadFile } from "antd/lib/upload/interface"; +import classNames from "classnames"; +import { Icon } from "components/Icon"; +import Progress from "components/Progress"; +import { Text } from "components/Typography"; +import React, { useCallback, useEffect, useState } from "react"; +import "react-lazy-load-image-component/src/effects/blur.css"; +import { useDispatch } from "react-redux"; +import { MediaUpload } from "redux/Common/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { setTalentProfileAction } from "redux/User/actions"; +import { selectUserData } from "redux/User/selector"; +import { User } from "redux/User/types"; +import { onBeforeUpload, pushImageToS3 } from "utils/photo"; +import ImageReZoom from "../../../../components/ImageReZoom"; +import { loadImage } from "../../../../utils/image"; +import notification from "../../../../utils/notification"; +import config from "config"; + +import "./ProfileCustomImage.scss"; + +const API_URL = config.api.url; +const { Dragger } = Upload; +interface ProfileCustomPictureProps { + disabled?: boolean; + isUploader?: boolean; + title?: string; +} +const CustomImageUploader = ({ + disabled, + title = "Add your Image", + ...props +}: ProfileCustomPictureProps) => { + const dispatch = useDispatch(); + const user: User | null | undefined = useTypedSelector(selectUserData); + const [photoUpload, setPhotoUpload] = useState(); + + const [imageWidthScale, setImageWidthScale] = useState("100%"); + useEffect(() => { + if (!user) return; + setPhotoUpload({ + id: 0, + url: user.talentProfile?.displayNameImage, + fileName: user.talentProfile?.displayNameImage, + }); + setImageWidthScale(user.talentProfile?.displayNameImageScale || "100%"); + }, [user]); + + const updateProfileDisplayNameImage = useCallback( + (photo: MediaUpload) => { + dispatch( + setTalentProfileAction({ + displayNameImage: photo.url, + }) + ); + }, + [dispatch] + ); + + const handleChange = async ( + file: UploadFile, + id: { metaId: number | undefined } + ) => { + try { + let photo: MediaUpload = {}; + if (file.status === "uploading") { + photo.loading = true; + photo.fileName = file.name; + setPhotoUpload({ ...photoUpload, ...photo }); + return; + } + + // TODO: remove status error when integrate with API + if (file.status === "done" || file.status === "error") { + const url = await pushImageToS3(file, (value: number) => { + setPhotoUpload((values) => ({ ...values, percent: value })); + }); + + if (!url) { + photo = { + id: id.metaId, + fileName: undefined, + loading: false, + }; + } else { + photo = { + id: id.metaId, + fileName: file.name, + url, + loading: false, + }; + } + } + + updateProfileDisplayNameImage({ ...photoUpload, ...photo }); + setPhotoUpload({ ...photoUpload, ...photo }); + } catch (ex) { + console.error(ex); + } + }; + const onSetImageWidthScale = (value: string) => { + setImageWidthScale(value); + dispatch( + setTalentProfileAction({ + displayNameImageScale: value, + }) + ); + }; + const handleBeforeCrop = async (file: RcFile) => { + const data: any = await loadImage(file); + if (data) { + if (data.width < 564) { + notification.error({ + message: "Your image must be wider than 564 pixels", + }); + return false; + } + } + + return onBeforeUpload(file, true); + }; + (window as any).handleBeforeCrop = handleBeforeCrop; + return ( +
+
+ + { + handleChange(file, { + metaId: photoUpload?.id, + }); + }} + data-testid="profile-custom-image__drag" + > + {(photoUpload?.percent as number) < 100 || photoUpload?.loading ? ( + + ) : ( + + + + + + + {title} + + + Use an size that’s at least 564 pixels wide and 6MB or less. + For best results, use an image with transparent background. + + + + )} + + +
+
+ ); +}; +export default CustomImageUploader; diff --git a/interface_base/src/pages/Profile/Header/Custom/ProfileCustomImage.tsx b/interface_base/src/pages/Profile/Header/Custom/ProfileCustomImage.tsx new file mode 100644 index 0000000..d1b6b12 --- /dev/null +++ b/interface_base/src/pages/Profile/Header/Custom/ProfileCustomImage.tsx @@ -0,0 +1,118 @@ +import { Button, Row } from "antd"; +import { RcFile } from "antd/lib/upload"; +import { Icon } from "components/Icon"; +import React, { useCallback, useEffect, useState } from "react"; +import { LazyLoadImage } from "react-lazy-load-image-component"; +import "react-lazy-load-image-component/src/effects/blur.css"; +import { useDispatch } from "react-redux"; +import { MediaUpload } from "redux/Common/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { setTalentProfileAction } from "redux/User/actions"; +import { selectUserData } from "redux/User/selector"; +import { User } from "redux/User/types"; +import { onBeforeUpload } from "utils/photo"; +import { loadImage } from "../../../../utils/image"; +import notification from "../../../../utils/notification"; +import CustomImageReZoom from "./CustomImageReZoom"; +import "./ProfileCustomImage.scss"; +import config from "config"; + +interface ProfileCustomImageProps { + disabled?: boolean; + isUploader?: boolean; +} +const ProfileCustomImage = ({}: ProfileCustomImageProps) => { + const dispatch = useDispatch(); + const user: User | null | undefined = useTypedSelector(selectUserData); + const [photoUpload, setPhotoUpload] = useState(); + const [imageWidthScale, setImageWidthScale] = useState("100%"); + + const [src, setSrc] = useState(""); + + useEffect(() => { + if (!user) return; + setPhotoUpload({ + id: 0, + url: user.talentProfile?.displayNameImage, + fileName: user.talentProfile?.displayNameImage, + }); + setImageWidthScale(user.talentProfile?.displayNameImageScale || "100%"); + }, [user]); + + const updateProfileDisplayNameImage = useCallback( + (photo: MediaUpload) => { + dispatch( + setTalentProfileAction({ + displayNameImage: photo.url, + }) + ); + }, + [dispatch] + ); + + const handleRemoveUpload = () => { + setPhotoUpload({}); + updateProfileDisplayNameImage({ url: "" }); + }; + + const onSetImageWidthScale = (value: string) => { + setImageWidthScale(value); + dispatch( + setTalentProfileAction({ + displayNameImageScale: value, + }) + ); + }; + + const handleBeforeCrop = async (file: RcFile) => { + const data: any = await loadImage(file); + if (data) { + if (data.width < 564) { + notification.error({ + message: "Your image must be wider than 564 pixels", + }); + return false; + } + } + return onBeforeUpload(file, true); + }; + + const handleClickImage = () => { + setSrc(photoUpload?.url?.toString() || ""); + }; + + return !photoUpload?.url ? null : ( + + + + + + + ); +}; +export default ProfileCustomImage; diff --git a/interface_base/src/pages/Profile/Header/Custom/ProfileDisplayName.tsx b/interface_base/src/pages/Profile/Header/Custom/ProfileDisplayName.tsx new file mode 100644 index 0000000..1894350 --- /dev/null +++ b/interface_base/src/pages/Profile/Header/Custom/ProfileDisplayName.tsx @@ -0,0 +1,323 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useDispatch } from "react-redux"; +import { Col, Form, Row } from "antd"; +import { useForm } from "antd/lib/form/Form"; +import Input from "antd/lib/input"; +import classNames from "classnames"; + +import { TalentProfileDisplayNameTypes } from "../../../../redux/User/types"; +import { useTypedSelector } from "../../../../redux/rootReducer"; +import { selectUserData } from "../../../../redux/User/selector"; +import { setTalentProfileAction } from "../../../../redux/User/actions"; + +import { Icon } from "components/Icon"; +import { Paragraph, Text } from "components/Typography"; + +import CustomImageUploader from "./CustomImageUploader"; +import ProfileCustomImage from "./ProfileCustomImage"; + +import "./ProfileDisplayName.scss"; + +const ProfileDisplayName: React.FC = () => { + const { + form, + inputRef, + isActive, + statusActive, + user, + handleSubmit, + handleChangeActive, + handleClickInput, + handleClickItem, + onBlur, + } = useProfileDisplayName(); + + return ( +
+
+
+ + + + Display Name or Logo + + + +
+ +
+ +
+ + + You can add a display name manually or update a custom logo to suit + your branding + + +
+ {!isActive && ( +
+ )} + + + + + Text + + + +
+ +
+ + + + + Logo + + + +
+ +
+ + +
+ + + + Text + + + + Type your display name into the field below + + +
+ + + + {isActive && !user?.talentProfile?.displayName && ( +
+ Please enter a display name{" "} +
+ )} +
+ +
+ + + + + Logo + + + Replace your display name with a custom logo. + + + + + {user?.talentProfile?.displayNameImage && ( + + + + + + )} + + + + + {isActive && !user?.talentProfile?.displayNameImage && ( +
+ Please upload a logo{" "} +
+ )} + + +
+
+
+
+
+
+ ); +}; + +export const useProfileDisplayName = () => { + const [form] = useForm(); + const inputRef = useRef(null); + const dispatch = useDispatch(); + const user = useTypedSelector(selectUserData); + const [isActive, setIsActive] = useState(false); + const [statusActive, setStatusActive] = useState( + TalentProfileDisplayNameTypes.TEXT + ); + + useEffect(() => { + if (!user?.talentProfile || !form) return; + const name = user?.talentProfile?.displayName || ""; + form.setFieldsValue({ displayName: name || "" }); + setIsActive(!!user.talentProfile?.showDisplayName); + setStatusActive( + user.talentProfile?.displayNameType || TalentProfileDisplayNameTypes.TEXT + ); + }, [user?.talentProfile, form]); + + const handleSubmit = useCallback( + (values) => { + dispatch(setTalentProfileAction(values)); + }, + [dispatch] + ); + const onBlur = () => { + form?.submit(); + }; + const handleChangeActive = useCallback(() => { + if (!user) { + return; + } + setIsActive(!isActive); + console.log("dispatch"); + dispatch(setTalentProfileAction({ showDisplayName: !isActive })); + }, [user]); + + const handleClickItem = (status: TalentProfileDisplayNameTypes) => () => { + if (!user) { + return; + } + if (!isActive) { + setIsActive(true); + setStatusActive(status); + dispatch( + setTalentProfileAction({ + showDisplayName: true, + displayNameType: status, + }) + ); + return; + } + setStatusActive(status); + dispatch(setTalentProfileAction({ displayNameType: status })); + }; + const handleClickInput = useCallback(() => { + // if (isVisibleTour) { + // setIsVisibleTour(false); + // setTimeout(() => { + // inputRef?.current?.focus(); + // }, 100); + // } + }, [inputRef]); + + return { + form, + inputRef, + isActive, + statusActive, + user, + handleSubmit, + handleChangeActive, + handleClickInput, + handleClickItem, + onBlur, + }; +}; + +export default ProfileDisplayName; diff --git a/interface_base/src/pages/Profile/Header/ProfileHeader.tsx b/interface_base/src/pages/Profile/Header/ProfileHeader.tsx new file mode 100644 index 0000000..9c2c0c4 --- /dev/null +++ b/interface_base/src/pages/Profile/Header/ProfileHeader.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Row } from "antd/lib/grid"; +import classNames from "classnames"; + +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +import ProfileHeaderModule from "./ProfileHeaderModule"; +import "./ProfileHeader.scss"; + +interface ProfileHeaderProps { + className?: string; +} + +const ProfileHeader: React.FC = ({ className }) => { + const isHeaderFixEnabled = useFeatureIsOn(FLAGS.FIX_GS_107_HEADER_BACKGROUND); + + return ( +
+ + + +
+ ); +}; + +export default ProfileHeader; diff --git a/interface_base/src/pages/Profile/Header/ProfileHeaderModule.tsx b/interface_base/src/pages/Profile/Header/ProfileHeaderModule.tsx new file mode 100644 index 0000000..632ff2d --- /dev/null +++ b/interface_base/src/pages/Profile/Header/ProfileHeaderModule.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import ProfilePhoto from "./ProfilePhoto/ProfilePhoto"; +import ProfilePhotoV2 from "./ProfilePhotoV2/ProfilePhotoV2"; +import ProfileDisplayName from "./Custom/ProfileDisplayName"; +import { FLAGS, IfFeature } from "@komi-app/flags-sdk"; + +const ProfileHeaderModule: React.FC = () => { + return ( + + } + disabled={} + /> + + + ); +}; + +export default ProfileHeaderModule; diff --git a/interface_base/src/pages/Profile/Header/ProfilePhoto/ProfilePhoto.tsx b/interface_base/src/pages/Profile/Header/ProfilePhoto/ProfilePhoto.tsx new file mode 100644 index 0000000..2736277 --- /dev/null +++ b/interface_base/src/pages/Profile/Header/ProfilePhoto/ProfilePhoto.tsx @@ -0,0 +1,459 @@ +import "./ProfilePhoto.scss"; +import { Button, Col, Row, Upload } from "antd"; +import { UploadFile } from "antd/lib/upload/interface"; +import React, { useCallback, useEffect, useState, useRef } from "react"; +import { useDispatch } from "react-redux"; +import { MediaUpload } from "redux/Common/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { setTalentProfileAction } from "redux/User/actions"; +import { selectUserData, selectIsAutoSaveSuccess } from "redux/User/selector"; +import { User } from "redux/User/types"; +import { onBeforeUpload, pushImageToS3, uploadImageToS3 } from "utils/photo"; +import ProfileImageCropUpload from "components/ProfileImageCrop/Upload"; +import ProfileImageCropUpdate from "components/ProfileImageCrop/Update"; +import { Paragraph, Text } from "components/Typography"; +import { Icon } from "components/Icon"; +import Progress from "components/Progress"; +import ProfilePhotoLoading from "./ProfilePhotoLoading"; +import notification from "../../../../utils/notification"; +import { loadImage } from "../../../../utils/image"; +import { RcFile } from "antd/lib/upload"; +import { LazyLoadImage } from "react-lazy-load-image-component"; +import config from "config"; +import "react-lazy-load-image-component/src/effects/blur.css"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useAnalytics } from "../../../../hooks/useAnalytics"; +import { SEGMENT_EVENTS } from "../../../../constants/segment"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { Area } from "react-easy-crop/types"; +import When from "@komi-app/when"; + +const API_URL = config.api.url; +const { Dragger } = Upload; + +const ProfilePhoto: React.FC = () => { + const { + handleBeforeCrop, + handleChange, + handleCompletedCrop, + handleCompletedCropUpdate, + handleRemovePicture, + isAutoSaveSuccess, + photoUpload, + photoUploadSrc, + user, + isImageRequirementsOn, + } = useProfilePhoto(); + + return ( +
+ {!!photoUpload?.url && ( + + + + Profile Photo + + + + )} +
+ + {photoUpload?.url && ( +
+ {!user ? ( + + ) : ( + <> + +
+ +
+
+ + + + )} +
+ )} +
+ {!photoUpload?.url && ( + + + Profile Photo + + + )} +
+ {!user ? ( + + ) : ( + <> + + { + handleChange(file, { + metaId: photoUpload?.id, + }); + }} + data-testid="profile-photo__upload-dragger" + > + {(photoUpload?.percent as number) < 100 || + photoUpload?.loading ? ( + + ) : ( + + + + + + + Replace Profile Photo + + + Use a size that’s at least 564 x 710 pixels + and 6MB or less + + } + /> + + + )} + + + + )} +
+ {user && !user?.talentProfile?.avatar && isAutoSaveSuccess && ( + + Please upload an image + + )} +
+
+
+
+ ); +}; + +export const useProfilePhoto = () => { + const dispatch = useDispatch(); + const isTemplateSelectionOn = useFeatureIsOn( + FLAGS.GS_146_ADD_TEMPLATE_SELECTION, + ); + const isProfilePictureUploadErrorEventsOn = useFeatureIsOn( + FLAGS.GS_155_PROFILE_PICTURE_UPLOAD_ERROR_EVENTS, + ); + const isProfilePictureRequirementsOn = !useFeatureIsOn( + FLAGS.FEAT_GS_198_REMOVE_HEADER_IMAGE_REQUIREMENTS, + ); + const isNewEditorOn = useFeatureIsOn(FLAGS.FEAT_SB_495_HEADER_ASPECT_RATIO); + const { sendSegmentEvent } = useAnalytics(); + const user: User | null | undefined = useTypedSelector(selectUserData); + const isAutoSaveSuccess = useTypedSelector(selectIsAutoSaveSuccess); + const [photoUpload, setPhotoUpload] = useState(); + const photoUploadSrc = photoUpload?.url?.toString(); + const [cropParams, setCropParams] = useState({ + width: 0, + height: 0, + x: 0, + y: 0, + cropperParams: { + x: 0, + y: 0, + zoomVal: 1, + }, + }); + const imgRef: any = useRef(null); // cache preload data img + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking(); + const profilePictureDimensionsTooSmallError = createTracker( + CreatorEvent.PROFILE_PICTURE_DIMENSIONS_TOO_SMALL_ERROR, + ); + const profilePictureFileTooLargeError = createTracker( + CreatorEvent.PROFILE_PICTURE_FILE_TOO_LARGE_ERROR, + ); + + useEffect(() => { + if (photoUpload?.url) { + const img = new Image(); + const imgObj = new URL(photoUpload.url.toString()); + imgRef.current = img; // reference for img, make sure it wont be clear + img.src = `${imgObj.origin}${imgObj.pathname}`; // preload data img + } + }, [photoUpload?.url]); + + useEffect(() => { + setPhotoUpload({ + id: 0, + url: user?.talentProfile?.avatar, + fileName: user?.talentProfile?.avatar, + }); + }, []); + + useEffect(() => { + if (isTemplateSelectionOn) { + setPhotoUpload({ + id: 0, + url: user?.talentProfile?.avatar, + fileName: user?.talentProfile?.avatar, + }); + } + }, [user?.talentProfile?.avatar, isTemplateSelectionOn]); + + const updateProfilePicture = useCallback( + (photo: MediaUpload) => { + dispatch( + setTalentProfileAction({ + avatar: photo.url, + }), + ); + }, + [dispatch], + ); + + // final step for upload new photo + const handleChange = async ( + file: UploadFile, + id: { metaId: number | undefined }, + ) => { + try { + let photo: MediaUpload = {}; + if (file.status === "uploading") { + photo.loading = true; + photo.fileName = file.name; + setPhotoUpload({ ...photoUpload, ...photo }); + return; + } + + // TODO: remove status error when integrate with API + if (file.status === "done" || file.status === "error") { + const url = await pushImageToS3(file, (value: number) => + setPhotoUpload((values) => ({ ...values, percent: value })), + ); + + if (!url) { + photo = { + id: id.metaId, + fileName: undefined, + loading: false, + }; + } else { + const urlObj = new URL(url); + + // set crop params for imagekit api + urlObj.searchParams.set( + "tr", + `w-${cropParams.width},h-${cropParams.height},cm-extract,x-${cropParams.x},y-${cropParams.y}`, + ); + + // set cropper params for next time edit photo + urlObj.searchParams.set( + "crp", + JSON.stringify(cropParams.cropperParams), + ); + photo = { + id: id.metaId, + fileName: file.name, + url: urlObj.toString(), + loading: false, + }; + } + } + + updateProfilePicture({ ...photoUpload, ...photo }); + setPhotoUpload({ ...photoUpload, ...photo }); + } catch (ex) { + console.error(ex); + } + }; + + const uploadAndUpdateProfileImage = async ( + imageFile: File, + cropParams: Area, + ) => { + const imageUrl = await uploadImageToS3(imageFile); + + let photo: MediaUpload = {}; + if (!imageUrl) { + photo = { + id: 0, + fileName: undefined, + loading: false, + }; + } else { + const urlObj = new URL(imageUrl); + + // set crop params for imagekit api + urlObj.searchParams.set( + "tr", + `w-${cropParams.width},h-${cropParams.height},cm-extract,x-${cropParams.x},y-${cropParams.y}`, + ); + + // set cropper params for next time edit photo + urlObj.searchParams.set("crp", JSON.stringify(cropParams)); + photo = { + fileName: imageFile.name, + url: urlObj.toString(), + loading: false, + }; + } + + const mediaUpload = { ...photoUpload, ...photo }; + + updateProfilePicture(mediaUpload); + setPhotoUpload((prevState) => ({ ...prevState, ...photo })); + + return mediaUpload.url; + }; + + // Use for upload new photo, Set crop params before call handleChange + const handleCompletedCrop = ({ cropParams }: any) => { + setCropParams(cropParams); + }; + + const handleRemovePicture = () => { + setPhotoUpload({}); + dispatch( + setTalentProfileAction({ + avatar: null, + }), + ); + }; + + const handleBeforeCrop = async (file: RcFile) => { + const data: any = await loadImage(file); + if (data) { + if ( + isProfilePictureRequirementsOn && + (data.width < 564 || data.height < 710) + ) { + if (isProfilePictureUploadErrorEventsOn) { + const { width, height } = data; + + useAnalyticsSDK + ? profilePictureDimensionsTooSmallError({ + height, + width, + }) + : sendSegmentEvent( + SEGMENT_EVENTS.PROFILE_PICTURE_DIMENSIONS_TOO_SMALL_ERROR, + { + "Image width": width, + "Image height": height, + }, + ); + } + notification.error({ + message: "Your image must be larger than 564 x 710 pixels", + }); + return false; + } + } + + if (!isProfilePictureRequirementsOn) { + // skip validating image size + return; + } + + const beforeUploadResult = onBeforeUpload(file, true); + + if (isProfilePictureUploadErrorEventsOn && !beforeUploadResult) { + const { size } = file; + useAnalyticsSDK + ? profilePictureFileTooLargeError({ size }) + : sendSegmentEvent( + SEGMENT_EVENTS.PROFILE_PICTURE_FILE_TOO_LARGE_ERROR, + { + "Uploaded file size": size, + }, + ); + } + + return beforeUploadResult; + }; + + // Final step when edit profile photo + const handleCompletedCropUpdate = ({ cropParams }: any) => { + setCropParams(cropParams); + + // Set photo url + if (photoUpload?.url) { + const urlObj = new URL(photoUpload?.url.toString()); + urlObj.search = ""; + + // Set crop params for imagekit api + urlObj.searchParams.set( + "tr", + `w-${cropParams.width},h-${cropParams.height},cm-extract,x-${cropParams.x},y-${cropParams.y}`, + ); + + // set cropper params for next time edit photo + urlObj.searchParams.set("crp", JSON.stringify(cropParams.cropperParams)); + + const url = urlObj.toString(); + updateProfilePicture({ ...photoUpload, url }); + setPhotoUpload({ ...photoUpload, url }); + } + }; + + return { + isNewEditorOn, + isAutoSaveSuccess, + photoUpload, + photoUploadSrc, + user, + handleBeforeCrop, + handleChange, + handleCompletedCrop, + handleCompletedCropUpdate, + handleRemovePicture, + uploadAndUpdateProfileImage, + isImageRequirementsOn: isProfilePictureRequirementsOn, + }; +}; + +export default ProfilePhoto; diff --git a/interface_base/src/pages/Profile/Header/ProfilePhoto/ProfilePhotoLoading.tsx b/interface_base/src/pages/Profile/Header/ProfilePhoto/ProfilePhotoLoading.tsx new file mode 100644 index 0000000..91db6f6 --- /dev/null +++ b/interface_base/src/pages/Profile/Header/ProfilePhoto/ProfilePhotoLoading.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import Skeleton from "antd/lib/skeleton"; + +const ProfilePictureLoading: React.FC = () => { + return ( + + ); +}; + +export default ProfilePictureLoading; diff --git a/interface_base/src/pages/Profile/Header/ProfilePhotoV2/ProfilePhotoV2.tsx b/interface_base/src/pages/Profile/Header/ProfilePhotoV2/ProfilePhotoV2.tsx new file mode 100644 index 0000000..ae1bac1 --- /dev/null +++ b/interface_base/src/pages/Profile/Header/ProfilePhotoV2/ProfilePhotoV2.tsx @@ -0,0 +1,187 @@ +import { HeaderEditor } from "@komi-app/creator-ui"; +import { Col, Row } from "antd"; +import { Text } from "components/Typography"; +import { useAutoSave } from "hooks/useAutoSave"; +import { useEffect, useMemo, useState } from "react"; +import { Area } from "react-easy-crop/types"; +import { + selectLocalizationSelected, + selectTalentProfile, + selectTalentProfileModules, +} from "redux/User/selector"; +import { useTypedSelector } from "redux/rootReducer"; +import { useProfilePhoto } from "../ProfilePhoto/ProfilePhoto"; +import { useDispatch } from "react-redux"; +import { setTalentProfileAction } from "redux/User/actions"; +import { loadFileFromURL } from "utils/file"; + +const useHeaderImage = () => { + const { uploadAndUpdateProfileImage, isAutoSaveSuccess } = useProfilePhoto(); + const { onSaveProfile } = useAutoSave(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const talentProfile = useTypedSelector(selectTalentProfile); + const modules = useTypedSelector(selectTalentProfileModules); + const dispatch = useDispatch(); + + const [fileName, setFileName] = useState(); + const [headerImage, setHeaderImage] = useState(undefined); + const [uploading, setUploading] = useState(false); + const [uploadFailed, setUploadFailed] = useState(false); + + const resolvedFileName = + fileName || talentProfile?.avatarFilename || "header.jpg"; + + useEffect(() => { + const fetchImage = async () => { + if (talentProfile?.avatar) { + if (headerImage) { + setUploading(true); + setHeaderImage(new File([], resolvedFileName)); + } + + const file = await loadFileFromURL( + talentProfile.avatar, + resolvedFileName + ); + setHeaderImage(file); + setUploading(false); + } + }; + fetchImage(); + }, [talentProfile?.avatar, talentProfile?.avatarFilename]); + + const handleImageSelect = (file: File | undefined) => { + setHeaderImage(file); + setUploadFailed(false); + setUploading(false); + + if (file) { + setFileName(file.name); + } else { + // Triggered when an image has been removed from the picker, remove the image from backend + saveImage(null); + } + }; + + const handleCropComplete = async ( + croppedArea: Area, + croppedAreaPixels: Area + ) => { + if (!headerImage) { + return; + } + + const aspectRatio = + // Round aspect ratio to remove any floating point precision errors + Math.round((croppedAreaPixels.height / croppedAreaPixels.width) * 100) / + 100; + + setUploading(true); + + try { + const avatarUrl = await uploadAndUpdateProfileImage( + headerImage, + croppedAreaPixels + ); + + if (typeof avatarUrl !== "string") { + throw new Error("Failed to upload image"); + } + + saveImage(avatarUrl, aspectRatio); + } catch (e) { + setUploadFailed(true); + setUploading(false); + } + }; + + const saveImage = (avatarUrl: string | null, avatarRatio = 1.25) => { + dispatch( + setTalentProfileAction({ + avatar: avatarUrl, + avatarRatio, + avatarFilename: fileName, + }) + ); + + onSaveProfile({ + newTalent: { + ...talentProfile, + avatar: avatarUrl, + avatarRatio, + avatarFilename: fileName, + }, + newModules: modules, + localizationId: localizationSelected?.id, + callback: () => { + setUploading(false); + }, + errorCallback: () => { + setUploading(false); + }, + }); + }; + + const error = useMemo(() => { + if (talentProfile && !talentProfile?.avatar && isAutoSaveSuccess) { + return "Upload an image"; + } + + if (uploadFailed) { + return "Failed to upload image"; + } + }, [talentProfile, isAutoSaveSuccess]); + + return { + headerImage, + handleImageSelect, + handleCropComplete, + uploading, + error, + }; +}; + +const ProfilePhotoV2: React.FC = () => { + const { + headerImage, + handleImageSelect, + handleCropComplete, + uploading, + error, + } = useHeaderImage(); + + return ( +
+ + + + Image + + + +
+ + + + + +
+
+ ); +}; + +export default ProfilePhotoV2; diff --git a/interface_base/src/pages/Profile/ModuleCreationModals/ModuleCreationModals.tsx b/interface_base/src/pages/Profile/ModuleCreationModals/ModuleCreationModals.tsx new file mode 100644 index 0000000..93228f7 --- /dev/null +++ b/interface_base/src/pages/Profile/ModuleCreationModals/ModuleCreationModals.tsx @@ -0,0 +1,662 @@ +import { TipsLinkItem } from "../../../models/talent/talent-profile-module.model"; +import { useDispatch } from "react-redux"; +import { useModules } from "../../../hooks/useModules"; +import { useAnalytics } from "../../../hooks/useAnalytics"; +import { useRyeShopifyModal } from "../../../hooks/useRyeShopifyModal"; +import { useShopifyModal } from "../../../hooks/useShopifyModal"; +import React, { useCallback } from "react"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import When from "@komi-app/when"; + +import { useTypedSelector } from "../../../redux/rootReducer"; +import { selectLocalizationSelected } from "../../../redux/User/selector"; +import { SEGMENT_EVENTS } from "../../../constants/segment"; +import { DCAFormStepsProps } from "../../../components/DataCaptureAudience/CreationModalForms/schemas"; +import { dcaFormValuesToItem } from "../../../components/DataCaptureAudience/dca-item-mapping-fns"; +import { triggerCheckCollectionAction } from "../../../redux/Shopify/actions"; +import { triggerRyeCheckCollectionAction } from "../../../redux/RyeShopify/actions"; +import ShopifyModal from "../../../components/ShopifyModal"; +import ShopifyStoreDomainInputModal from "../../../components/ShopifyStoreDomainInputModal"; +import RyeShopifyModal from "../../../components/RyeShopifyModal"; +import RyeShopifyStoreDomainInputModal from "../../../components/RyeShopifyStoreDomainInputModal"; +import BandsintownEditorModal from "../../../components/BandsintownEditorModal"; +import SeatedEditorModal from "../../../components/SeatedEditorModal"; +import ShopMyShelfEditorModal from "../../../components/ShopMyShelfEditorModal"; +import ShopListEditorModal from "../../../components/ShopListEditorModal"; +import DataCaptureAudienceModal from "../../../components/DataCaptureAudience/DataCaptureCreationModal"; +import ThroneLinkModal from "../../../components/ThroneLinkModal"; +import DataCaptureModal from "components/DataCaptureModal"; +import { useAddNewModule } from "hooks/useAddNewModule"; +import TipsLinkModal from "../../../components/TipsLinkModal"; +import { contactFormItem } from "services/module-templates/templates/ContactForm"; +import { ModuleType } from "@komi-app/shared-types"; +import { AffiliateModal } from "../../../components/AffiliateModal"; +import { AffiliateProduct } from "@komi-app/affiliate-sdk"; +import { AddAffiliateProductResult } from "@komi-app/creator-ui"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +interface ModuleCreationModalsProps { + visible: boolean; + onChangeVisible: (value: boolean) => void; + onModuleAdded?: () => void; + type?: ModuleType; + groupId?: string; +} + +interface ModuleCreationModalsHook { + hasModal: (type: ModuleType) => boolean; + openModal: (type: ModuleType) => boolean; + onChangeModalVisibility: (visibility: boolean) => void; + isModalOpen: boolean; + modalType?: ModuleType; +} + +export function useModuleCreationModals(): ModuleCreationModalsHook { + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [modalType, setModalType] = React.useState(); + + const hasModal = useCallback((type: ModuleType) => { + return [ + ModuleType.SHOPIFY, + ModuleType.RYE_SHOPIFY, + ModuleType.BANDSINTOWN, + ModuleType.SEATED, + ModuleType.SHOP_MY_SHELF, + ModuleType.SHOP_LIST, + ModuleType.DATA_CAPTURE_COUPON_CODE, + ModuleType.DATA_CAPTURE_SECRET_LINK, + ModuleType.DATA_CAPTURE_SIGNUP, + ModuleType.THRONE_LINK, + ModuleType.PAYPAL_LINK, + ModuleType.VENMO_LINK, + ModuleType.CASHAPP_LINK, + ModuleType.PATREON_LINK, + ModuleType.CONTACT_FORM, + ModuleType.AFFILIATE, + ].includes(type); + }, []); + + const openModal = useCallback( + (type: ModuleType) => { + if (!hasModal(type)) { + return false; + } + setModalType(type); + setIsModalOpen(true); + return true; + }, + [hasModal] + ); + + const onChangeModalVisibility = useCallback((visibility: boolean) => { + if (visibility) { + // skip - will already be open + } else { + setModalType(undefined); + setIsModalOpen(false); + } + }, []); + + return { + hasModal, + openModal, + onChangeModalVisibility, + isModalOpen, + modalType, + }; +} + +export function ModuleCreationModals({ + visible, + onChangeVisible, + onModuleAdded, + type, + groupId, +}: ModuleCreationModalsProps) { + const dispatch = useDispatch(); + const { modules } = useModules(); + const { sendSegmentEvent } = useAnalytics(); + const addNewModule = useAddNewModule(groupId); + const isShopifyFixEnabled = useFeatureIsOn( + FLAGS.FIX_GB_518_SHOPIFY_NEW_CONTENT_MENU + ); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking(); + const moduleCreated = createTracker(CreatorEvent.MODULE_CREATED); + const elementCreated = createTracker(CreatorEvent.ELEMENT_CREATED); + + const { + ryeStep, + visibleRyeShopifyModal, + setVisibleRyeShopifyModal, + showRyeShopifyInputModal, + setShowRyeShopifyInputModal, + onSubmitRyeDomainCallback, + connectToRyeStore, + handleRyeLoginCallback, + } = useRyeShopifyModal(visible, type, (type) => { + addNewModule(type); + onModuleAdded(); + }); + + function toggleRyeShopifyModal(visibility: boolean) { + if (!visibility) { + onChangeVisible(false); + } + setVisibleRyeShopifyModal(visibility); + } + + function toggleRyeShopifyInputModal(visibility: boolean) { + if (!visibility) { + onChangeVisible(false); + } + setShowRyeShopifyInputModal(visibility); + } + + const { + step, + showShopifyInputModal, + setShowShopifyInputModal, + visibleShopifyModal, + setVisibleShopifyModal, + onSubmitDomainCallback, + onCreateAccountCallback, + connectToStore, + handleLoginCallback, + } = useShopifyModal(visible, type, (type) => { + addNewModule(type); + onModuleAdded(); + }); + + function toggleShopifyModal(visibility: boolean) { + if (!visibility) { + onChangeVisible(false); + } + setVisibleShopifyModal(visibility); + } + + function toggleShopifyInputModal(visibility: boolean) { + if (!visibility) { + onChangeVisible(false); + } + setShowShopifyInputModal(visibility); + } + + const localizationSelected = useTypedSelector(selectLocalizationSelected); + + const handleAddBandsintown = useCallback( + (values) => { + onChangeVisible(false); + + const { pageId, pageName, type } = trackingProps( + ModuleType.BANDSINTOWN, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": ModuleType.BANDSINTOWN, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "BANDSINTOWN EVENT", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + + addNewModule(ModuleType.BANDSINTOWN, { + items: [{ ...values, order: 0 }], + }); + onModuleAdded(); + }, + [localizationSelected, addNewModule, sendSegmentEvent] + ); + const handleAddSeated = useCallback( + (values) => { + onChangeVisible(false); + + const { pageId, pageName, type } = trackingProps( + ModuleType.SEATED, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": ModuleType.SEATED, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "SEATED EVENT", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + + addNewModule(ModuleType.SEATED, { + items: [{ ...values, order: 0 }], + }); + onModuleAdded(); + }, + [localizationSelected, addNewModule, sendSegmentEvent] + ); + const handleShopMyShelf = useCallback( + (values) => { + onChangeVisible(false); + const { pageId, pageName, type } = trackingProps( + "SHOPMYSHELF", + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": "SHOPMYSHELF", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "SHOPMYSHELF", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + addNewModule(ModuleType.SHOP_MY_SHELF, { + name: values.name || "New Module", + items: [{ url: values.url, order: 0 }], + }); + onModuleAdded(); + }, + [localizationSelected, addNewModule, sendSegmentEvent] + ); + const handleShopList = useCallback( + (values) => { + onChangeVisible(false); + const { pageId, pageName, type } = trackingProps( + "SHOPLIST", + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": "SHOPLIST", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "SHOPLIST", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + addNewModule(ModuleType.SHOP_LIST, { + name: values.name || "New Module", + items: [{ url: values.url, order: 0 }], + }); + onModuleAdded(); + }, + [localizationSelected, addNewModule, sendSegmentEvent] + ); + const handleDataCaptureFormCreated = useCallback( + (values: DCAFormStepsProps) => { + onChangeVisible(false); + + if (!values.type) { + console.log("No creation type specified"); + return; + } + + const newModule = dcaFormValuesToItem(values, { + name: "New Module", + order: 0, + }); + addNewModule(newModule.type, { + name: newModule.name, + items: [newModule], + }); + onModuleAdded(); + }, + [localizationSelected, addNewModule, sendSegmentEvent] + ); + const handleThroneLink = useCallback( + (url) => { + const { pageId, pageName, type } = trackingProps( + ModuleType.THRONE_LINK, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": ModuleType.THRONE_LINK, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "THRONE LINK", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + onChangeVisible(false); + addNewModule(ModuleType.LINK, { + name: "Throne", + items: [ + { + url, + title: "My Throne Wishlist", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/-W1BJQRavoGWC8VlU_qTU.png", + }, + ], + }); + onModuleAdded(); + }, + [addNewModule, sendSegmentEvent] + ); + + const handleContactForm = useCallback( + (values) => { + const { pageId, pageName, type } = trackingProps( + ModuleType.CONTACT_FORM, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": ModuleType.CONTACT_FORM, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "CONTACT FORM", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + onChangeVisible(false); + addNewModule(ModuleType.FORM_DATA, { + name: "Contact Form", + items: [values], + }); + onModuleAdded(); + }, + [addNewModule, sendSegmentEvent] + ); + + const handleTipsLink = useCallback( + ({ src, title, url, platform }: TipsLinkItem) => { + const { pageId, pageName, type } = trackingProps( + `${platform} LINK`, + localizationSelected + ); + + if (useAnalyticsSDK) { + moduleCreated({ pageId, pageName, type }); + elementCreated({ pageId, pageName, type }); + } else { + sendSegmentEvent(SEGMENT_EVENTS.MODULDE_CREATED, { + "Module Type": platform, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": `${platform} LINK`, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + onChangeVisible(false); + addNewModule(ModuleType.LINK, { + name: "Tips", + items: [ + { + url, + title, + thumbnail: src, + }, + ], + }); + onModuleAdded(); + }, + [addNewModule, sendSegmentEvent] + ); + const handleAffiliateProduct = useCallback( + async ( + affiliateProduct: AffiliateProduct, + result: AddAffiliateProductResult + ) => { + onChangeVisible(false); + + addNewModule(ModuleType.AFFILIATE, { + name: "Affiliate Products", + items: [ + { + ...affiliateProduct, + title: result.title, + visible: true, + }, + ], + }); + onModuleAdded(); + }, + [addNewModule] + ); + const callbackAddShopifyModule = useCallback(() => { + addNewModule(ModuleType.SHOPIFY); + dispatch(triggerCheckCollectionAction(true)); + onChangeVisible(false); + onModuleAdded(); + }, [dispatch, modules, addNewModule, onChangeVisible]); + const callbackAddRyeShopifyModule = useCallback(() => { + addNewModule(ModuleType.RYE_SHOPIFY); + dispatch(triggerRyeCheckCollectionAction(true)); + onChangeVisible(false); + onModuleAdded(); + }, [dispatch, modules, addNewModule, onChangeVisible]); + + if (!visible || !type) { + return null; + } + + return ( + <> + + + {showShopifyInputModal && ( + + )} + + ), + [ModuleType.RYE_SHOPIFY]: ( + <> + + {showRyeShopifyInputModal && ( + + )} + + ), + [ModuleType.BANDSINTOWN]: ( + + ), + [ModuleType.SEATED]: ( + + ), + [ModuleType.SHOP_MY_SHELF]: ( + + ), + [ModuleType.SHOP_LIST]: ( + + ), + [ModuleType.DATA_CAPTURE_COUPON_CODE]: ( + + ), + [ModuleType.DATA_CAPTURE_SECRET_LINK]: ( + + ), + [ModuleType.DATA_CAPTURE_SIGNUP]: ( + + ), + [ModuleType.THRONE_LINK]: ( + + ), + [ModuleType.PAYPAL_LINK]: ( + + ), + [ModuleType.VENMO_LINK]: ( + + ), + [ModuleType.CASHAPP_LINK]: ( + + ), + [ModuleType.PATREON_LINK]: ( + + ), + [ModuleType.CONTACT_FORM]: ( + + ), + [ModuleType.AFFILIATE]: ( + + ), + }} + /> + + ); +} diff --git a/interface_base/src/pages/Profile/ModuleEditor/ItemVisibilityToggle.tsx b/interface_base/src/pages/Profile/ModuleEditor/ItemVisibilityToggle.tsx new file mode 100644 index 0000000..8478482 --- /dev/null +++ b/interface_base/src/pages/Profile/ModuleEditor/ItemVisibilityToggle.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Button } from "antd"; +import { Icon } from "components/Icon"; +import { + BaseItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import { useModules } from "hooks/useModules"; +import { flipVisibilityForItemAndCloneArray } from "utils/modules"; + +interface ItemVisibilityToggleProps { + module: TalentProfileModule; + item: BaseItem; + disabled?: boolean; + onClick?: () => void; +} + +const ItemVisibilityToggle: React.FC = ({ + module, + item, + disabled = false, + onClick, +}) => { + const { setModule } = useModules(); + + const handleItemVisibilityChanged = (item: BaseItem) => { + if (onClick) { + onClick(); + return; + } + + const updatedItems = flipVisibilityForItemAndCloneArray(item, module.items); + setModule && setModule({ ...module, isUpdate: true, items: updatedItems }); + }; + + return ( + + ); +}; + +export default ItemVisibilityToggle; diff --git a/interface_base/src/pages/Profile/ModuleEditor/ModuleEditor.tsx b/interface_base/src/pages/Profile/ModuleEditor/ModuleEditor.tsx new file mode 100644 index 0000000..44d02b9 --- /dev/null +++ b/interface_base/src/pages/Profile/ModuleEditor/ModuleEditor.tsx @@ -0,0 +1,742 @@ +import "./ModuleEditor.scss"; +import React, { + useState, + useCallback, + FocusEvent, + MouseEvent, + ChangeEvent, + KeyboardEvent, + useMemo, + useEffect, +} from "react"; +import { useHistory } from "react-router-dom"; +import { useAutoSave } from "hooks/useAutoSave"; +import PanelCollapse from "components/PanelCollapse"; +import { Row, Tooltip } from "antd"; +import Button from "antd/lib/button"; +import { Col } from "antd/lib/grid"; +import useBreakpoint from "antd/lib/grid/hooks/useBreakpoint"; +import Input from "antd/lib/input"; +import Popover from "antd/lib/popover"; +import classNames from "classnames"; +import DragDropButton from "components/Button/DragDropButton"; +import ConfirmDeleteModal from "components/ConfirmDeleteModal"; +import { ConfirmDeleteModalProps } from "components/ConfirmDeleteModal/ConfirmDeleteModal"; +import { Icon } from "components/Icon"; +import LabelTooltip from "components/LabelTooltip/LabelTooltip"; +import Loading from "components/Loading"; +import StripeConnectBox from "components/StripeConnectBox"; +import { Paragraph, Text } from "components/Typography"; +import { useModules } from "hooks/useModules"; +import { + TalentModuleMixItem, + TalentProfileModule, + TalentProfileModuleObj, + BaseItem, + DataCaptureActivationItemNew, +} from "models/talent/talent-profile-module.model"; + +import { + ModuleType, + ShopifyStoreTypes, + RyeShopifyStoreTypes, +} from "@komi-app/shared-types"; + +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { isMobile } from "react-device-detect"; +import { MENU_LINKS } from "routes"; +import "./ModuleEditor.scss"; +import ModuleEditorAction from "./ModuleEditorAction"; +import { closeExpandModule } from "redux/Talent/selector"; +import { useSelector } from "react-redux"; +import cloneDeep from "lodash/cloneDeep"; +import includes from "lodash/includes"; +import filter from "lodash/filter"; +import { updateCloseExpandActions } from "redux/Talent/actions"; +import { useDispatch } from "react-redux"; +import ModuleVisiblityEditor from "./ModuleVisiblityEditor"; +import ModuleVisibilityToggle from "./ModuleVisibilityToggle"; +import { FLAGS, IfFeature, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useWindowSize } from "../../../hooks"; +import When from "@komi-app/when"; +import { Button as CreatorButton, Semantic } from "@komi-app/creator-ui"; +import List from "antd/lib/list"; +interface ModuleEditorProps { + module: TalentProfileModule; + banner?: React.ReactNode; + showStripeWarning?: boolean; + disableModuleVisibility?: boolean; + onEditBandsintownLink?: () => void; + onEditSeatedLink?: () => void; + onEditShopMyShelf?: () => void; + onEditShopList?: () => void; + onEditYoutube?: () => void; + onEditDataCapture?: (dataCapture: DataCaptureActivationItemNew) => void; + dragHandleProps?: DraggableProvidedDragHandleProps; + children?: any; + InfoTooltip?: React.ReactNode; + confirmDeleteModalProps?: ConfirmDeleteModalProps; +} + +const MAX_NAME_LENGTH = 33; +const ModuleEditor: React.FC> = ({ + module, + banner, + dragHandleProps, + showStripeWarning = false, + onEditBandsintownLink, + onEditSeatedLink, + onEditShopList, + children, + InfoTooltip, + confirmDeleteModalProps, + onEditShopMyShelf, + onEditYoutube, + onEditDataCapture, + disableModuleVisibility = false, +}) => { + const dispatch = useDispatch(); + const [moduleNameState, setModuleNameState] = useState(""); + const [showModuleAction, setShowModuleAction] = useState(false); + const [showVisibilityAction, setShowVisibilityAction] = useState(false); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [visibleUnGroup, setVisibleUnGroup] = useState(false); + const [moduleDeleteConfirm, setModuleDeleteConfirm] = useState< + TalentProfileModule + >(() => module); + const screens = useBreakpoint(); + const mobile = isMobile || screens["xs"]; + const history = useHistory(); + const { onForceSave } = useAutoSave(); + const { + selectedModules, + onChangeSelected, + onUnGroup, + setModule, + setSelectedModules, + modules, + removeModule, + } = useModules(); + + const { width } = useWindowSize(); + + const isMobileStyleChangesEnabled = width && width <= 768; + + const isTemplatesV2Enabled = useFeatureIsOn( + FLAGS.FEAT_SB_447_MODULE_TEMPLATES + ); + + const isQuickDeleteEnabled = module.template; + + const closeExpand = useSelector(closeExpandModule); + useEffect(() => { + setModuleNameState(module.name || ""); + }, [module]); + + const isEdit = module.isEdit; + + const isDisableExpand = useMemo( + () => !module.items || !module.items.length, + [module] + ); + + const isDisableDrop = useMemo(() => { + if (module.groupId) { + const group = modules.find((el: any) => el.id === module.groupId); + return group?.items.length <= 1; + } + return modules.length <= 1; + }, [modules, module]); + + const onEditCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + setModule(moduleEdit); + }, + [setModule] + ); + + const onDeleteCallback = useCallback( + (moduleDel: TalentProfileModule) => { + removeModule(moduleDel); + }, + [removeModule] + ); + + const handleUpdateModuleName = () => { + let newModuleName = module.name || ""; + let isUpdate = false; + + newModuleName = moduleNameState; + + if (module.name !== newModuleName) { + isUpdate = true; + } + + onEditCallback({ + ...module, + name: newModuleName, + isEdit: false, + isUpdate: isUpdate, + visible: module.visible ?? true, + }); + }; + + const onChangeInput = (event: ChangeEvent) => { + event.stopPropagation(); + setModuleNameState( + event.target.value.length <= MAX_NAME_LENGTH + ? event.target.value + : moduleNameState + ); + }; + + const onBlurInput = (event: FocusEvent) => { + event.stopPropagation(); + handleUpdateModuleName(); + }; + + const onClickInput = (event: MouseEvent) => { + event.stopPropagation(); + }; + + const onEditInput = (event: MouseEvent) => { + event.stopPropagation(); + onEditCallback({ ...module, isEdit: true }); + }; + + const onKeyUpInput = (event: KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + handleUpdateModuleName(); + } + }; + + const onEditModule = ( + moduleEdit: TalentProfileModule + ) => { + onEditCallback({ ...moduleEdit, isEdit: true }); + }; + + const onSelectModule = (module: TalentProfileModule) => { + setShowModuleAction(false); + + if (!selectedModules.some((el: any) => el.id === module.id)) { + setSelectedModules([...selectedModules, module]); + } + }; + + const onBeforeRemove = ( + moduleDel: TalentProfileModule + ) => { + if ( + selectedModules.some((module: TalentProfileModule) => + [module.id, module.groupId].includes(moduleDel.id) + ) + ) { + const newSelected = selectedModules.filter( + (module: TalentProfileModule) => + ![module.id, module.groupId].includes(moduleDel.id) + ); + setSelectedModules(newSelected); + } + }; + const onDeleteModule = ( + moduleDel: TalentProfileModule + ) => { + if (!moduleDel.items || !moduleDel.items.length) { + onDeleteCallback(moduleDel as TalentProfileModule); + setModuleDeleteConfirm(new TalentProfileModuleObj()); + onBeforeRemove(moduleDel); + } else { + setShowConfirmModal(true); + setModuleDeleteConfirm(moduleDel); + } + }; + + const onDeleteModuleConfirm = () => { + onDeleteCallback( + moduleDeleteConfirm as TalentProfileModule + ); + setModuleDeleteConfirm(new TalentProfileModuleObj()); + onBeforeRemove(moduleDeleteConfirm); + toggleConfirmModal(false); + }; + + const toggleExpand = (value: boolean) => { + onEditCallback({ ...module, expand: value }); + let dataCloseExpand = closeExpand ? cloneDeep(closeExpand) : []; + if (value) { + dataCloseExpand = filter(dataCloseExpand, function (o) { + return o !== module?.id; + }); + } else { + dataCloseExpand.push(module?.id as string); + } + dispatch(updateCloseExpandActions(dataCloseExpand)); + }; + + const toggleConfirmModal = (value: boolean) => { + setShowConfirmModal(value); + }; + + const handleEditBandsintownLink = () => { + setShowModuleAction(false); + onEditBandsintownLink?.(); + }; + const handleEditSeatedLink = () => { + setShowModuleAction(false); + onEditSeatedLink?.(); + }; + + const manageShopifyStores = useCallback(() => { + onForceSave(); + setTimeout(() => { + history.push(`${MENU_LINKS.ACCOUNT_SETTINGS}#Shopify`); + }, 250); + }, []); + + const onManageRyeShopifyStore = useCallback(() => { + onForceSave(); + setTimeout(() => { + history.push(`/admin/settings/integrations`); + }, 250); + }, []); + + // Not ideal, but because the items are stored as json documents in the db, + // we'd have to modify the json during the visibility migration to set visible default to true + // If it's already been set this will just pass over it + const selected = useMemo(() => { + if (module.type === ModuleType.GROUP) { + return module.items.every((item) => + selectedModules.some((el: any) => el.id === item.id) + ); + } + return selectedModules.some((item: any) => item.id === module.id); + }, [module, selectedModules]); + const isUnCheckGroup = useMemo(() => { + return ( + module.type === ModuleType.GROUP && + !selected && + module.items.some((item) => + selectedModules.some((el: any) => el.id === item.id) + ) + ); + }, [module, selected, selectedModules]); + const handleClickUnGroup = useCallback(() => { + setVisibleUnGroup(true); + }, []); + const onUnGroupConfirm = useCallback(() => { + onUnGroup(module.items); + setVisibleUnGroup(false); + }, [onUnGroup, module.items]); + + const handleVisibilityChanged = ( + visible: boolean, + showTitle: boolean, + items: TalentModuleMixItem[] + ) => { + onEditCallback({ ...module, isUpdate: true, visible, showTitle, items }); + }; + + const onQuickDeleteClicked = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onDeleteModule(module); + }, + [onDeleteModule, module] + ); + + const maxLength = useMemo(() => { + if ( + [ + ModuleType.BANDSINTOWN, + ModuleType.SHOPIFY, + ModuleType.RYE_SHOPIFY, + ModuleType.SHOP_LIST, + ModuleType.SHOP_MY_SHELF, + ModuleType.YOUTUBE, + ModuleType.YOUTUBE_COLLECTION, + ModuleType.YOUTUBE_SHORT, + ModuleType.YOUTUBE_VIDEO, + ].includes(module.type as any) && + module.groupId + ) { + return 18; + } + if (mobile) { + return 20; + } + return 33; + }, [module, mobile]); + const renderTag = useCallback(() => { + if (ShopifyStoreTypes.includes(module.type as ModuleType)) { + return ( +
+ + Shopify + +
+ ); + } + if (RyeShopifyStoreTypes.includes(module.type as ModuleType)) { + return ( +
+ + Shopify + +
+ ); + } + let tag = ""; + let desktopOnly = true; + switch (module.type) { + case ModuleType.BANDSINTOWN: + tag = "Bandsintown"; + break; + case ModuleType.SEATED: + tag = "Seated"; + break; + case ModuleType.SHOP_MY_SHELF: + tag = "Shop My"; + break; + case ModuleType.SHOP_LIST: + tag = "Shoplist"; + break; + case ModuleType.YOUTUBE: + case ModuleType.YOUTUBE_COLLECTION: + case ModuleType.YOUTUBE_VIDEO: + tag = "YouTube"; + break; + case ModuleType.YOUTUBE_SHORT: + tag = "Shorts"; + break; + case ModuleType.TIKTOK_VIDEO: + case ModuleType.TIKTOK_AUTOMATION: + tag = "TikTok"; + break; + case ModuleType.INSTAGRAM_REEL: + tag = "Instagram"; + break; + + default: + break; + } + + if (isTemplatesV2Enabled && module.template) { + tag = "Template"; + desktopOnly = false; + } + + if (tag) { + return ( +
+ + {tag} + +
+ ); + } + return null; + }, [module.type, module.template, isTemplatesV2Enabled]); + return ( +
+ + + +
+ +
+ + + ({moduleNameState.length}/33 characters) + +
+ + ) : ( + + + + + {InfoTooltip} + {renderTag()} + + ) + } + isEdit={isEdit} + action={ + + 768 && module.type === ModuleType.GROUP} + then={ + + + + + {`${module.items.length} module${ + module.items.length > 1 ? "s" : "" + } added`} + + + + +
+ +
+
+ +
+ + } + /> + { + // If the module is in a group, show the visibility drop down + module.groupId ? ( + event.stopPropagation()} + className={ + isMobileStyleChangesEnabled ? "m__l--8" : "m__l--16" + } + data-testid="module-editor--visibility" + > + + } + trigger={["click"]} + onVisibleChange={setShowVisibilityAction} + > + + + + ) : ( + // If the module isn't a group, show the visibility button + event.stopPropagation()} + className={ + isMobileStyleChangesEnabled ? "m__l--8" : "m__l--16" + } + data-testid="module-editor--visibility" + > + + + ) + } + + event.stopPropagation()} + className={isMobileStyleChangesEnabled ? "m__l--8" : "m__l--16"} + data-testid="module-editor--options" + > + + } + trigger={["click"]} + onVisibleChange={setShowModuleAction} + > + + + + + event.stopPropagation()} + /> + +
+ } + > + {module.isLoading && } + {children} + {showStripeWarning && } + {banner} +
+ {isQuickDeleteEnabled && ( +
+ +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleEditor); diff --git a/interface_base/src/pages/Profile/ModuleEditor/ModuleEditorAction.tsx b/interface_base/src/pages/Profile/ModuleEditor/ModuleEditorAction.tsx new file mode 100644 index 0000000..396bd15 --- /dev/null +++ b/interface_base/src/pages/Profile/ModuleEditor/ModuleEditorAction.tsx @@ -0,0 +1,203 @@ +import List from "antd/lib/list"; +import React, { useCallback } from "react"; +import { Text } from "components/Typography"; +import { + DataCaptureActivationItemNew, + TalentModuleMixItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import copyToClipboard from "copy-to-clipboard"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectUserData } from "redux/User/selector"; +import { useAutoSave } from "hooks/useAutoSave"; +import notification from "utils/notification"; +import { createKomiDomain } from "services/DomainService"; +import { useWindowSize } from "../../../hooks"; +import When from "@komi-app/when"; + +import { + ModuleType, + ShopifyStoreTypes, + RyeShopifyStoreTypes +} from '@komi-app/shared-types' + +interface ModuleEditorActionProps { + module: TalentProfileModule; + onDelete: (moduleDel: TalentProfileModule) => void; + onEdit: (moduleEdit: TalentProfileModule) => void; + onSelectModule: (module: TalentProfileModule) => void; + onEditBandsintownLink?: () => void; + onEditSeatedLink?: () => void; + onEditShopMyShelf?: () => void; + onEditShopList?: () => void; + onEditYoutubeCollection?: () => void; + onManageShopifyStore?: () => void; + onManageRyeShopifyStore?: () => void; + toggleVisible: (value: boolean) => void; + onEditDataCapture?: (dataCapture: DataCaptureActivationItemNew) => void; +} + +const ModuleEditorAction: React.FC< + ModuleEditorActionProps +> = ({ + module, + onEdit, + onDelete, + toggleVisible, + onEditBandsintownLink, + onEditSeatedLink, + onEditYoutubeCollection, + onEditShopMyShelf, + onEditShopList, + onManageShopifyStore, + onManageRyeShopifyStore, + onSelectModule, + onEditDataCapture, +}) => { + const user = useTypedSelector(selectUserData); + const { localizationSelected } = useAutoSave(); + const onClose = () => { + toggleVisible && toggleVisible(false); + }; + + const onEditModule = ( + moduleEdit: TalentProfileModule + ) => { + onEdit && onEdit(moduleEdit); + onClose(); + }; + + const onDeleteModule = ( + moduleDel: TalentProfileModule + ) => { + onDelete && onDelete(moduleDel); + onClose(); + }; + const handleEditShopMyShelf = () => { + onEditShopMyShelf?.(); + onClose(); + }; + const handleEditShopList = () => { + onEditShopList?.(); + onClose(); + }; + const handleEditYoutubeCollection = () => { + onEditYoutubeCollection?.(); + onClose(); + }; + const handleCopyLinkModule = useCallback(() => { + onClose(); + + const komiLink = createKomiDomain( + user?.username || user?.talentProfile?.user?.username || "" + ); + copyToClipboard(`${komiLink}/#${module?.id}`); + notification.success({ + message: !localizationSelected?.id + ? "The module link has been copied to your clipboard." + : "The module link has been copied to your clipboard. Please note that if the user is not in a region accessible to this page, they will be routed to the top of your profile.", + }); + }, [module, user, localizationSelected]); + + const handleEditDatacaptureModule = () => { + onEditDataCapture?.(); + onClose(); + }; + const isDCM = + module.type === ModuleType.DATA_CAPTURE_COUPON_CODE || + module.type === ModuleType.DATA_CAPTURE_SECRET_LINK || + module.type === ModuleType.DATA_CAPTURE_SIGNUP; + + const { width } = useWindowSize(); + + return ( + + + + Copy Module Link + + + onEditModule(module)}> + + Edit Name + + + + {module.type === ModuleType.YOUTUBE_COLLECTION ? ( + + Edit YouTube Link + + ) : null} + + {isDCM && ( + + Edit + + )} + + {module.type === ModuleType.BANDSINTOWN ? ( + + Edit Bandsintown Link + + ) : null} + {module.type === ModuleType.SEATED ? ( + + Edit Seated Artist ID + + ) : null} + {module.type === ModuleType.SHOP_MY_SHELF ? ( + + Edit Shop My Link + + ) : null} + {module.type === ModuleType.SHOP_LIST ? ( + + Edit Shoplist Link + + ) : null} + + 768 && + module.type !== ModuleType.GROUP && + !isDCM && + !module.groupId + } + then={ + onSelectModule(module)}> + + Create a Module Group + + + } + /> + {ShopifyStoreTypes.includes(module.type as ModuleType) && ( + + Manage Shopify Stores + + )} + {RyeShopifyStoreTypes.includes( + module.type as ModuleType + ) && ( + + Manage Shopify Stores + + )} + onDeleteModule(module)}> + + Delete + + + + ); +}; + +export default ModuleEditorAction; diff --git a/interface_base/src/pages/Profile/ModuleEditor/ModuleVisibilityToggle.tsx b/interface_base/src/pages/Profile/ModuleEditor/ModuleVisibilityToggle.tsx new file mode 100644 index 0000000..b21bb36 --- /dev/null +++ b/interface_base/src/pages/Profile/ModuleEditor/ModuleVisibilityToggle.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Button } from "antd"; +import { Icon } from "components/Icon"; +import { useModules } from "hooks/useModules"; +import { + BaseItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; + +interface ModuleVisibilityToggleProps { + module: TalentProfileModule; + disabled?: boolean; +} + +const ModuleVisibilityToggle: React.FC = ({ + module, + disabled = false +}) => { + const { setModule } = useModules(); + + const handleToggleModuleVisibility = (visible: boolean) => { + setModule({ ...module, visible }); + }; + + return ( + + ); +}; + +export default ModuleVisibilityToggle; diff --git a/interface_base/src/pages/Profile/ModuleEditor/ModuleVisiblityEditor.tsx b/interface_base/src/pages/Profile/ModuleEditor/ModuleVisiblityEditor.tsx new file mode 100644 index 0000000..232b910 --- /dev/null +++ b/interface_base/src/pages/Profile/ModuleEditor/ModuleVisiblityEditor.tsx @@ -0,0 +1,96 @@ +import List from "antd/lib/list"; +import React, { useState } from "react"; +import { Text } from "components/Typography"; +import { + TalentModuleMixItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import { Icon } from "components/Icon"; +import { Button } from "antd"; + +interface ModuleVisibilityEditorProps { + module: TalentProfileModule; + toggleVisible: (value: boolean) => void; + onVisiblityChanged: ( + visible: boolean, + showTitle: boolean, + items: TalentModuleMixItem[] + ) => void; + onEditModule: (moduleEdit: any) => void; +} + +const ModuleVisibilityEditor: React.FC< + ModuleVisibilityEditorProps +> = ({ module, onVisiblityChanged }) => { + const relcalculateModuleItemsVisibilityAndSet = ( + visible: boolean, + showTitle: boolean + ) => { + const updatedItems = Array.from(module.items); + if (!module.visible && updatedItems.length > 0) { + updatedItems.forEach((item: any) => { + return (item.visible = false); + }); + } + + onVisiblityChanged(visible, showTitle, updatedItems); + }; + + const handleToggleModuleVisibility = () => { + // If module and title are currently shown, hide both + if (module.visible) { + relcalculateModuleItemsVisibilityAndSet(false, false); + } // Else if both are hidden, show both + else { + relcalculateModuleItemsVisibilityAndSet(true, true); + } + }; + + const handleToggleTitleVisiblity = () => { + if (module.visible) { + onVisiblityChanged(module.visible, !module.showTitle, module.items); + } + }; + + return ( + Manage Visibility} + > + handleToggleModuleVisibility()}> + + Module + + + + handleToggleTitleVisiblity()}> + + Module Title + + + + + ); +}; + +export default ModuleVisibilityEditor; diff --git a/interface_base/src/pages/Profile/ModuleList/ModuleList.tsx b/interface_base/src/pages/Profile/ModuleList/ModuleList.tsx new file mode 100644 index 0000000..96776aa --- /dev/null +++ b/interface_base/src/pages/Profile/ModuleList/ModuleList.tsx @@ -0,0 +1,121 @@ +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import DragDropContext from "components/DragDropContext"; +import GroupModuleMenu from "components/GroupModuleMenu"; +import { useModules } from "hooks/useModules"; +import React, { useMemo } from "react"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectTalentProfileModules } from "redux/User/selector"; +import AddModule from "../AddModule/AddModule"; +import ModulePortal from "../ModulePortal/ModulePortal"; +import EmptyModules from "../Modules/EmptyModules"; +import { ModuleType } from "@komi-app/meta-profiles"; +import { useDigitalProductFeatures } from "hooks/useDigitalProductFeatures"; +import "./ModuleList.scss"; + +interface ModuleListProps { + showMenu?: boolean; + setShowMenu?: (value: boolean) => void; +} + +const ModuleList: React.FC = ({ showMenu, setShowMenu }) => { + const rawModules = useTypedSelector(selectTalentProfileModules); + const { onDropEndModules, selectedModules } = useModules(); + const isTikTokEnabled = useFeatureIsOn(FLAGS.FEAT_SB_390_TIKTOK_MODULE); + const isReelsEnabled = useFeatureIsOn(FLAGS.FEAT_SB_421_REELS_MODULE); + const isMediaGalleryEnabled = useFeatureIsOn( + FLAGS.FEAT_SB_455_PHOTO_GALLERY_MODULE + ); + const isAffiliateEnabled = useFeatureIsOn( + FLAGS.SB_789_ENABLE_AFFILIATE_SCHEME + ); + const { + isFreeDownloadOn, + isPaidDownloadOn, + isCourseEnabled, + isOneToOneEnabled, + } = useDigitalProductFeatures(); + const isShortVideoAutomationOn = useFeatureIsOn(FLAGS.SHORT_VIDEO_AUTOMATION); + + const talentProfileModules = useMemo(() => { + const typesToFilter: ModuleType[] = []; + + if (!isTikTokEnabled) { + typesToFilter.push(ModuleType.TIKTOK_VIDEO); + } + + if (!isShortVideoAutomationOn) { + typesToFilter.push(ModuleType.TIKTOK_AUTOMATION); + } + + if (!isReelsEnabled) { + typesToFilter.push(ModuleType.INSTAGRAM_REEL); + } + + if (!isMediaGalleryEnabled) { + typesToFilter.push(ModuleType.MEDIA_GALLERY); + } + + if (!isAffiliateEnabled) { + typesToFilter.push(ModuleType.AFFILIATE); + } + + if (!isFreeDownloadOn) { + typesToFilter.push(ModuleType.FREE_DOWNLOAD); + } + + if (!isPaidDownloadOn) { + typesToFilter.push(ModuleType.PAID_DOWNLOAD); + } + + if (!isOneToOneEnabled) { + typesToFilter.push(ModuleType.ONE_TO_ONE_SESSION); + } + + if (!isCourseEnabled) { + typesToFilter.push(ModuleType.COURSE); + } + + return (rawModules || []).filter( + (module) => !typesToFilter.includes(module.type as ModuleType) + ); + }, [ + rawModules, + isTikTokEnabled, + isMediaGalleryEnabled, + isReelsEnabled, + isAffiliateEnabled, + ]); + + return ( + + {!talentProfileModules?.length ? ( + + ) : ( + <> + {!selectedModules?.length && } + + + {(dragProvided, snapshot, item, index) => ( + + )} + + + )} + + ); +}; + +export default ModuleList; diff --git a/interface_base/src/pages/Profile/ModulePortal/ModulePortal.tsx b/interface_base/src/pages/Profile/ModulePortal/ModulePortal.tsx new file mode 100644 index 0000000..65ace22 --- /dev/null +++ b/interface_base/src/pages/Profile/ModulePortal/ModulePortal.tsx @@ -0,0 +1,212 @@ +import classNames from "classnames"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { + TalentModuleMixItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React from "react"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd"; +import { useDispatch } from "react-redux"; +import { useTypedSelector } from "redux/rootReducer"; +import { setActiveModuleAction } from "redux/Talent/actions"; +import { selectActiveModule } from "redux/Talent/selector"; +import ModuleEditor from "../ModuleEditor"; +import ModuleBandsintown from "../Modules/ModuleBandsintown"; +import ModuleDataCaptureForm from "../Modules/ModuleDataCaptureForm"; +import ModuleDigitalProduct from "../Modules/ModuleDigitalProduct"; +import ModuleEvents from "../Modules/ModuleEvents"; +import ModuleExperience from "../Modules/ModuleExperience"; +import ModuleFanClub from "../Modules/ModuleFanClub"; +import ModuleGroup from "../Modules/ModuleGroup"; +import ModuleLink from "../Modules/ModuleLink"; +import ModuleMusic from "../Modules/ModuleMusic"; +import ModuleOndemandVideo from "../Modules/ModuleOndemandVideo"; +import ModulePodcast from "../Modules/ModulePodcast"; +import ModuleProduct from "../Modules/ModuleProduct"; +import ModuleShopify from "../Modules/ModuleShopify"; +import ModuleShopList from "../Modules/ModuleShopList"; +import ModuleShopMyShelf from "../Modules/ModuleShopMyShelf"; +import ModuleSmartPodcast from "../Modules/ModuleSmartPodcast"; +import ModuleYoutube from "../Modules/ModuleYoutube"; +import ModuleYoutubeVideo from "../Modules/ModuleYoutubeVideo"; +import ModuleSeated from "../Modules/ModuleSeated"; +import ModuleDataCaptureActivation from "../Modules/ModuleDataCaptureActivation"; +import RyeModuleShopify from "../Modules/RyeModuleShopify"; +import ModuleTikTok from "../Modules/ModuleTikTok"; +import ModuleMediaGallery from "../Modules/ModuleMediaGallery"; +import ModuleInstagramReels from "../Modules/ModuleInstagramReels"; +import ModuleYoutubeShort from "../Modules/ModuleYoutubeShort"; +import ModuleText from "../Modules/ModuleText"; +import ModuleAffiliate from "../Modules/ModuleAffiliate"; +import { ModuleType } from "@komi-app/shared-types"; +import ModuleTikTokAutomation from "../Modules/ModuleTikTokAutomation"; + +interface ModulePortalProps { + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; + module: TalentProfileModule; +} + +const ModulePortal: React.FC> = ({ + module, + snapshot, + provided, +}) => { + const dispatch = useDispatch(); + const activeModule = useTypedSelector(selectActiveModule); + const onModuleClick = (event: any) => { + event.stopPropagation(); + dispatch(setActiveModuleAction(module.id)); + }; + const isNewPodcastEnabled = useFeatureIsOn(FLAGS.NEW_PODCAST); + const isNewYoutubeEnabled = useFeatureIsOn(FLAGS.NEW_YOUTUBE); + const isTikTokEnabled = useFeatureIsOn(FLAGS.FEAT_SB_390_TIKTOK_MODULE); + const isReelsEnabled = useFeatureIsOn(FLAGS.FEAT_SB_421_REELS_MODULE); + const isShortVideoAutomationEnabled = useFeatureIsOn( + FLAGS.SHORT_VIDEO_AUTOMATION + ); + const isAffiliateEnabled = useFeatureIsOn( + FLAGS.SB_789_ENABLE_AFFILIATE_SCHEME + ); + const isFreeDigitalProductEnabled = useFeatureIsOn( + FLAGS.FREE_DOWNLOADS_TALENT + ); + const isPaidDigitalProductEnabled = useFeatureIsOn( + FLAGS.PAID_DIGITAL_PRODUCT + ); + + let ModuleComponent: any = ModuleEditor; + switch (module.type) { + case ModuleType.LINK: + ModuleComponent = ModuleLink; + break; + case ModuleType.EXPERIENCE: + ModuleComponent = ModuleExperience; + break; + case ModuleType.ON_DEMAND_VIDEO: + ModuleComponent = ModuleOndemandVideo; + break; + case ModuleType.PRODUCT: + ModuleComponent = ModuleProduct; + break; + case ModuleType.MUSIC: + ModuleComponent = ModuleMusic; + break; + case ModuleType.PODCAST: + case ModuleType.PODCAST_AUTOMATION: + case ModuleType.PODCAST_SELECT: + ModuleComponent = isNewPodcastEnabled + ? ModuleSmartPodcast + : ModulePodcast; + break; + case ModuleType.YOUTUBE_VIDEO: + case ModuleType.YOUTUBE_COLLECTION: + case ModuleType.YOUTUBE: + ModuleComponent = isNewYoutubeEnabled + ? ModuleYoutube + : ModuleYoutubeVideo; + break; + case ModuleType.YOUTUBE_SHORT: + //TODO: Change to New type when we support channel/playlist links + ModuleComponent = ModuleYoutubeShort; + break; + case ModuleType.TIKTOK_VIDEO: + if (!isTikTokEnabled) break; + ModuleComponent = ModuleTikTok; + break; + case ModuleType.TIKTOK_AUTOMATION: + if (!isTikTokEnabled || !isShortVideoAutomationEnabled) break; + ModuleComponent = ModuleTikTokAutomation; + break; + case ModuleType.INSTAGRAM_REEL: + if (!isReelsEnabled) break; + ModuleComponent = ModuleInstagramReels; + break; + case ModuleType.FAN_CLUB: + ModuleComponent = ModuleFanClub; + break; + case ModuleType.BANDSINTOWN: + ModuleComponent = ModuleBandsintown; + break; + case ModuleType.SEATED: + ModuleComponent = ModuleSeated; + break; + case ModuleType.SHOPIFY: + case ModuleType.SHOPIFY_PRODUCT: + case ModuleType.SHOPIFY_COLLECTION: + ModuleComponent = ModuleShopify; + break; + case ModuleType.RYE_SHOPIFY: + case ModuleType.RYE_SHOPIFY_PRODUCT: + case ModuleType.RYE_SHOPIFY_COLLECTION: + ModuleComponent = RyeModuleShopify; + break; + case ModuleType.EVENTS: + ModuleComponent = ModuleEvents; + break; + case ModuleType.FORM_DATA: + ModuleComponent = ModuleDataCaptureForm; + break; + case ModuleType.GROUP: + ModuleComponent = ModuleGroup; + break; + case ModuleType.SHOP_MY_SHELF: + ModuleComponent = ModuleShopMyShelf; + break; + case ModuleType.SHOP_LIST: + ModuleComponent = ModuleShopList; + break; + case ModuleType.DATA_CAPTURE_COUPON_CODE: + case ModuleType.DATA_CAPTURE_SECRET_LINK: + case ModuleType.DATA_CAPTURE_SIGNUP: + ModuleComponent = ModuleDataCaptureActivation; + break; + case ModuleType.MEDIA_GALLERY: + ModuleComponent = ModuleMediaGallery; + break; + case ModuleType.TEXT: + ModuleComponent = ModuleText; + break; + case ModuleType.AFFILIATE: + if (!isAffiliateEnabled) break; + ModuleComponent = ModuleAffiliate; + break; + case ModuleType.DIGITAL_PRODUCT: + ModuleComponent = ModuleDigitalProduct; + break; + default: + break; + } + const isActive = + module.id === activeModule || + (module.type === ModuleType.GROUP && + module.items.some((el: any) => el.id === activeModule)); + + return ( + +
+ +
+
+ ); +}; + +export default React.memo(ModulePortal); diff --git a/interface_base/src/pages/Profile/Modules/EmptyModule.tsx b/interface_base/src/pages/Profile/Modules/EmptyModule.tsx new file mode 100644 index 0000000..2f57549 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/EmptyModule.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Row, Col } from "antd/lib/grid"; +import { Icon } from "components/Icon"; +import { Paragraph, Text } from "components/Typography"; +import Button from "antd/lib/button"; +import { useWindowSize } from "../../../hooks"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +interface EmptyModuleProps { + titleAction: JSX.Element | string; + caption?: JSX.Element | string; + onClick?: () => void; +} + +const EmptyModule: React.FC = ({ + titleAction, + caption = "Nothing Here Yet", + onClick, +}) => { + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + return ( + + + + + + + + {caption} + + + Click the button below to get started + + + + + + + + ); +}; + +export default EmptyModule; diff --git a/interface_base/src/pages/Profile/Modules/EmptyModules.tsx b/interface_base/src/pages/Profile/Modules/EmptyModules.tsx new file mode 100644 index 0000000..12810fb --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/EmptyModules.tsx @@ -0,0 +1,44 @@ +import { Col, Row } from "antd/lib/grid"; +import { Icon } from "components/Icon"; +import { Paragraph } from "components/Typography"; +import React from "react"; +import AddModule from "../AddModule/AddModule"; +import "./EmptyModules.scss"; + +interface EmptyModulesProps { + showMenu?: boolean; + setShowMenu?: (value: boolean) => void; +} + +const EmptyModules = ({ showMenu, setShowMenu }: EmptyModulesProps) => { + return ( + +
+ + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + +
+
+ ); +}; + +export default EmptyModules; diff --git a/interface_base/src/pages/Profile/Modules/ModuleAffiliate.tsx b/interface_base/src/pages/Profile/Modules/ModuleAffiliate.tsx new file mode 100644 index 0000000..c492a04 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleAffiliate.tsx @@ -0,0 +1,425 @@ +import { Alert, Button, Col, Row } from "antd"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { useModules } from "hooks/useModules"; +import reduce from "lodash/reduce"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useHistory } from "react-router-dom"; +import AffiliateCardModule from "../../../components/AffiliateCardModule"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import { useWindowSize } from "../../../hooks"; +import { + AffiliateItem, + TalentProfileModule, +} from "../../../models/talent/talent-profile-module.model"; +import ModuleEditor from "../ModuleEditor"; +import { + AddAffiliateProduct, + AddAffiliateProductResult, + useToast, +} from "@komi-app/creator-ui"; +import { + AffiliateModal, + ProfileAmazonTags, + appendAmazonTag, +} from "../../../components/AffiliateModal"; +import { uploadImageWithCrop } from "../../../utils/photo"; +import { useIsOwner } from "../../../hooks/user-hooks"; +import { useSelector } from "react-redux"; +import { selectUserData } from "redux/User/selector"; +import { FLAGS, getFeatureValue } from "@komi-app/flags-sdk"; + +interface ModuleAffiliateProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleAffiliate: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const history = useHistory(); + const profileContext = useModules(); + + const [modalVisible, setModalVisible] = useState(false); + const [modalItem, setModalItem] = useState( + undefined, + ); + const [imageUploadError, setImageUploadError] = useState(""); + const [isUploading, setIsUploading] = useState(false); + + const isOwner = useIsOwner(); + const { createToast } = useToast(); + const user = useSelector(selectUserData); + const profileId = user?.talentProfile?.id; + const amzTagsListJSON: ProfileAmazonTags = getFeatureValue( + FLAGS.FEAT_SB_972_AFFILIATE_AMAZON_ASSOCIATE_TAG, + {}, + ); + + // useRef is needed to access the modal visible state during our async upload callback + const modalVisibleRef = useRef(modalVisible); + useEffect(() => { + modalVisibleRef.current = modalVisible; + }, [modalVisible]); + + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + + const itemList = useMemo(() => module.items, [module.items]); + + const modalItemImage = modalItem?.image; + + const modalItemWithImage = useMemo(() => { + if (!modalItem) return undefined; + + return { + ...modalItem, + url: modalItem.trackingUrl || modalItem.url, + image: modalItemImage, + }; + }, [modalItem, modalItemImage]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule?.(moduleEdit); + }, + [profileContext], + ); + + const onDropEnd = useCallback( + (list: AffiliateItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback], + ); + + const onAddAffiliateItem = useCallback( + async (newItem: AffiliateItem, result: AddAffiliateProductResult) => { + onEditModuleCallback({ + ...module, + items: [ + { + ...newItem, + title: result.title, + visible: true, + }, + ...module.items, + ].map((item, index) => ({ + ...item, + order: index, + })), + isUpdate: true, + }); + + setModalVisible(false); + setModalItem(undefined); + setIsUploading(false); + }, + [module, onEditModuleCallback], + ); + + const onAddItemClicked = useCallback(() => { + setModalItem(undefined); + setModalVisible(true); + }, []); + + const onEditItemClicked = useCallback((affiliateItem: AffiliateItem) => { + // Append the amazon associate tag to the affiliate product URL + const amzTaggedURL = appendAmazonTag( + profileId, + affiliateItem.trackingUrl, + amzTagsListJSON, + ); + + setModalItem({ + ...affiliateItem, + trackingUrl: amzTaggedURL ? amzTaggedURL : affiliateItem.trackingUrl, + }); + setModalVisible(true); + }, []); + + const onCloseEditProductModal = useCallback(() => { + setImageUploadError(""); + setModalVisible(false); + }, []); + + const onEditAffiliateItem = useCallback( + async (existingItem: AffiliateItem, result: AddAffiliateProductResult) => { + let image = existingItem.image; + // We need to upload an image, don't save anything till we've uploaded it + if (result.image && result.croppedAreaPixels) { + setIsUploading(true); + + try { + const uploadedImage = await uploadImageWithCrop( + result.image, + result.croppedAreaPixels, + ); + + image = uploadedImage.url; + } catch (e) { + setImageUploadError(e?.message || "An unknown error occurred"); + setIsUploading(false); + return; + } + } + + // If the user closes the modal before we had a chance to save, don't save anything + if (!modalVisibleRef.current) { + setModalVisible(false); + setModalItem(undefined); + setImageUploadError(""); + setIsUploading(false); + return; + } + + const items = module.items.map((item) => { + if (item.order === existingItem.order) { + return { + ...item, + title: result.title, + image, + }; + } + return item; + }); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + + setModalVisible(false); + setModalItem(undefined); + setImageUploadError(""); + setIsUploading(false); + }, + [module, onEditModuleCallback], + ); + + const onDeleteAffiliateItem = useCallback( + (affiliateItem: AffiliateItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== affiliateItem.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [], + ); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + }, + [module, profileContext.setModule], + ); + + const renderContent = () => { + if (!itemList.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + function redirectToSettings(): void { + if (isOwner) { + history.push("/admin/settings/subscription"); + } else { + createToast({ + text: "You don’t have access to Billing & Payments settings, contact the owner of this Komi mini-site", + semantic: "warning", + dismissible: true, + durationMs: 5000, + }); + } + } + + return ( + + + + + + + + {itemList.length}  + {itemList.length > 1 ? "elements added" : "element added"} + + + + + } + description={ + + Link your PayPal account to earn affiliate revenue.{" "} + + Go to Settings + + + } + showIcon + closeText={} + /> + + + {(dragProvided, snapshot, item, itemIndex) => { + const isRender = active ? active : itemIndex < 2; + + const key = itemIndex; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && itemList.length > 2 && ( +
+ + +{itemList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + {modalVisible && modalItem && modalItemWithImage && ( + onCloseEditProductModal()} + onDone={(updated) => onEditAffiliateItem(modalItem, updated)} + errors={{ image: imageUploadError }} + onPrevious={() => onCloseEditProductModal()} + uploadingImage={isUploading} + loading={isUploading} + /> + )} + {modalVisible && !modalItem && ( + setModalVisible(false)} + onConfirmCallback={(newProduct, result) => + onAddAffiliateItem(newProduct, result) + } + /> + )} + {renderContent()} + + + ); +}; + +export function isIframeVideo(src: string): boolean { + return src.endsWith("/iframe"); +} + +export default React.memo(ModuleAffiliate); diff --git a/interface_base/src/pages/Profile/Modules/ModuleBandsintown.tsx b/interface_base/src/pages/Profile/Modules/ModuleBandsintown.tsx new file mode 100644 index 0000000..46e3aa0 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleBandsintown.tsx @@ -0,0 +1,202 @@ +import { Col } from "antd"; +import DragDropContext from "components/DragDropContext"; +import LoadingModule from "components/LoadingModule"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + BandsintownItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import BandsintownEditorModal from "../../../components/BandsintownEditorModal"; +import BandsintownEventCard from "../../../components/BandsintownEventCard"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import ModuleEditor from "../ModuleEditor"; +import "./ModuleBandsintown.scss"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const BandsintownEventCardMemo = React.memo(BandsintownEventCard); + +interface ModuleBandsintownProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleBandsintown: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const item = useMemo(() => { + return module.items[0]; + }, [module.items]); + const [showModal, setShowModal] = useState(false); + + const { loadingShop, thirdPartyData } = profileContext; + const loading = useMemo(() => { + return loadingShop.some((key: string) => key === module.id); + }, [loadingShop, module.id]); + const data = useMemo(() => { + return thirdPartyData.find((item: any) => item.id === module.id); + }, [thirdPartyData, module.id]); + const events = useMemo(() => data?.events || [], [data]); + + const onEditBandsintownLink = useCallback(() => { + setShowModal(true); + }, []); + + const onSaveLinkCallback = useCallback( + (link: BandsintownItem) => { + const moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex(module.items, 0, link), + isUpdate: true, + }; + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [module, localizationSelected, profileContext.setModule, sendSegmentEvent] + ); + + const onDropEndLinks = useCallback( + (list: BandsintownItem[]) => { + profileContext.setModule({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, profileContext] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + const renderContent = () => { + if (loading) { + return ; + } + if (!events.length) { + return ( + + + + + + + No Events Found + + + You appear to have no events listed on Bandsintown right now, + please add some and then refresh the page + + + + ); + } + return ( + +
+ + {events.length}  + {events.length > 1 ? "dates added" : "date added"} + +
+ + {(dragProvided, snapshot, item, linkIndex) => { + const isRender = active ? active : linkIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && events.length > 2 && ( +
+ + +{events.length - 2} More + +
+ )} +
+ ); + }; + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleBandsintown); diff --git a/interface_base/src/pages/Profile/Modules/ModuleDataCaptureActivation.tsx b/interface_base/src/pages/Profile/Modules/ModuleDataCaptureActivation.tsx new file mode 100644 index 0000000..c4138a1 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleDataCaptureActivation.tsx @@ -0,0 +1,270 @@ +import React, { useCallback, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; + +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; + +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; + +import { Text } from "components/Typography"; +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; + +import { + BaseItem, + DataCaptureActivationItemNew, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; + +import { ArrayServices } from "utils/array"; + +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; + +import { SEGMENT_EVENTS } from "constants/segment"; +import { isEmptyModule } from "utils/modules"; +import DataCaptureModuleCard from "components/DataCaptureAudience/DataCaptureModuleCard"; +import DataCaptureCreationModal from "components/DataCaptureAudience/DataCaptureCreationModal"; +import { DCAFormStepsProps } from "components/DataCaptureAudience/CreationModalForms/schemas"; +import { + dcaFormValuesToItem, + dcaItemToFormValues, +} from "components/DataCaptureAudience/dca-item-mapping-fns"; + +import { ModuleType } from "@komi-app/shared-types" +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +export interface ModuleCardProps { + module: TalentProfileModule; + disableDragDrop: boolean; + index: number; + disableDrag: boolean; + item: T; + onEdit: (item: T) => void; + onDelete: (item: T) => void; + onCopySmartLink: () => void; + dragHandleProps?: DraggableProvidedDragHandleProps; +} +interface CollapsedCardListProps { + module: TalentProfileModule; + items: T[]; + CardModule: React.FC>; + onCopySmartLink: () => void; + onDelete: (item: T) => void; + onDropEndCallback: (item: T[]) => void; + onEdit: (item: T) => void; +} +const CollapsedCardList = ({ + module, + items = [], + CardModule, + onCopySmartLink = () => {}, + onDelete = () => {}, + onDropEndCallback = () => {}, + onEdit = () => {}, + ...props +}: CollapsedCardListProps): React.ReactElement => ( + + + {(dragProvided, snapshot, item, productIndex) => ( + + + + )} + + + {items.length > 2 && ( +
+ + +{items.length - 2} More + +
+ )} +
+); + +interface DataCaptureActivationModuleProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} +interface DcaEditState { + isEdit: boolean; + dataCapture?: DataCaptureActivationItemNew; + dataCaptureEditIndex?: number; +} + +const DataCaptureActivationModule: React.FC< + DataCaptureActivationModuleProps +> = ({ module, dragHandleProps }) => { + const { sendSegmentEvent } = useAnalytics(); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const [showContentModal, setShowContentModal] = useState(false); + const [formState, setFormState] = useState({ + isEdit: false, + }); + + const profileContext = useModules(); + + const localizationSelected = useTypedSelector(selectLocalizationSelected); + + const onAddDataCapture = useCallback(async () => { + setFormState((preState) => ({ + ...preState, + isEdit: false, + dataCapture: undefined, + dataCaptureIndex: undefined, + })); + setShowContentModal(true); + }, []); + + const onEditDataCapture = useCallback(() => { + const dataCapture = module.items[0]; + setFormState((preState) => ({ + ...preState, + isEdit: true, + dataCapture: dataCapture, + dataCaptureIndex: dataCapture.order, + })); + setShowContentModal(true); + }, [module.items]); + + const onDeleteDataCapture = useCallback( + (dataCapture: DataCaptureActivationItemNew) => { + const moduleItems = Array.from(module.items); + const updatedItems = moduleItems.splice(dataCapture.order + 1); + profileContext.setModule({ + ...module, + items: updatedItems, + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onDropEndDataCaptures = useCallback( + (list: DataCaptureActivationItemNew[]) => { + profileContext.setModule({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveProductCallback = useCallback( + (formValues: DCAFormStepsProps) => { + const existing = formState.dataCapture; + const dcaElement = dcaFormValuesToItem(formValues, { + ...existing, + order: existing?.order ?? module.items.length, + name: existing?.name ?? "New Module", + }); + + const moduleItems = formState.isEdit + ? ArrayServices.updateWithIndex( + module.items, + dcaElement.order as number, + dcaElement + ) + : [...module.items, dcaElement]; + + const moduleEdit = { + ...module, + items: moduleItems, + isUpdate: true, + }; + + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + }, + [ + module, + formState, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ] + ); + return ( + + + + {isEmptyModule(module) ? ( + + ) : ( + <> + {}} + CardModule={DataCaptureModuleCard} + onDelete={onDeleteDataCapture} + onDropEndCallback={onDropEndDataCaptures} + onEdit={onEditDataCapture} + /> + + )} + + ); +}; + +export default DataCaptureActivationModule; diff --git a/interface_base/src/pages/Profile/Modules/ModuleDataCaptureForm.tsx b/interface_base/src/pages/Profile/Modules/ModuleDataCaptureForm.tsx new file mode 100644 index 0000000..56603c9 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleDataCaptureForm.tsx @@ -0,0 +1,206 @@ +import ConfirmDeleteModal from "components/ConfirmDeleteModal"; +import DataCaptureFormCard from "components/DataCaptureFormCard"; +import DataCaptureModal from "components/DataCaptureModal"; +import { Paragraph } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + DataCaptureFormItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useHistory } from "react-router-dom"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { isEmptyModule } from "utils/modules"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import "./ModuleDataCaptureForm.scss"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { DataCaptureFormType } from "@komi-app/shared-types"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +interface ModuleDataCaptureFromProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; +} + +const ModuleDataCaptureFrom: React.FC = ({ + module, + dragHandleProps, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const router = useHistory(); + const profileContext = useModules(); + const [visibleDelete, setVisibleDelete] = useState(false); + const [visibleEditor, setVisibleEditor] = useState(false); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const item: any = useMemo(() => { + return module.items[0]; + }, [module.items]); + + const isContactFormFlagOn = useFeatureIsOn(FLAGS.FEAT_SB_630_CONTACT_FORM); + const isContactForm = + isContactFormFlagOn && item?.formType === DataCaptureFormType.CONTACT_FORM; + + const onDeleteItem = useCallback(() => { + profileContext.setModule?.({ + ...module, + items: [], + isUpdate: true, + }); + setVisibleDelete(false); + }, [module, profileContext.setModule]); + + const onSaveModule = useCallback( + (dataForm: DataCaptureFormItem) => { + let moduleEdit; + if (item) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex(module.items, 0, { + ...module.items[0], + ...dataForm, + }), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, dataForm), + isUpdate: true, + }; + } + profileContext.setModule?.(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + }, + [ + module, + item, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ] + ); + + const onClickDeleteForm = useCallback(() => { + setVisibleDelete(true); + }, []); + + const onClickAddForm = useCallback(() => { + setVisibleEditor(true); + }, []); + + const handleClickViewData = useCallback( + (item) => () => { + if (isContactFormFlagOn) { + router.push( + `/admin/data-export?moduleId=${module.id}&formId=${item.id}` + ); + } else { + router.push(`/data-export?moduleId=${module.id}&formId=${item.id}`); + } + }, + [router] + ); + + return ( + +
+ + + This module will be deleted and you can’t undo this action. + + + Don’t worry, your form data won’t be removed and will still be + accessible on the Data Capture Form page, under Past Forms. + +
+ ), + }} + > + {isEmptyModule(module) ? ( + + + + ) : ( + + )} + + + + This module will be deleted and you can’t undo this action. + + + Don’t worry, your form data won’t be removed and will still be + accessible on the Data Capture Form page, under Past Forms. + +
+ } + handleConfirm={onDeleteItem} + /> + +
+ + ); +}; + +export default React.memo(ModuleDataCaptureFrom); diff --git a/interface_base/src/pages/Profile/Modules/ModuleDigitalProduct.tsx b/interface_base/src/pages/Profile/Modules/ModuleDigitalProduct.tsx new file mode 100644 index 0000000..3a7b817 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleDigitalProduct.tsx @@ -0,0 +1,423 @@ +import { DigitalProductItem } from "@komi-app/profiles-sdk"; +import { Button, Col, Row } from "antd"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useHistory } from "react-router-dom"; +import When from "@komi-app/when"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +import DigitalProductModule from "components/DigitalProductModule/DigitalProductModule"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Paragraph, Text } from "components/Typography"; +import { useWindowSize } from "hooks"; +import { useModules } from "hooks/useModules"; +import reduce from "lodash/reduce"; +import { TalentProfileModule } from "models/talent/talent-profile-module.model"; +import ModuleEditor from "../ModuleEditor"; +import { getSalesServiceV1 } from "services"; +import { InlineMessage } from "@komi-app/components"; +import { Link } from "react-router-dom"; +import { DataStripeAccountStatus } from "@komi-app/sales-sdk"; +import { InlineMessageType } from "components/SubscriptionBanner/SubscriptionBanner"; +import { useIsConnectedAccountDisabled } from "hooks/useIsConnectedAccountDisabled"; +import { useDigitalProductFeatures } from "hooks/useDigitalProductFeatures"; + +interface ModuleDigitalProductProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleDigitalProduct: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const profileContext = useModules(); + + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + + const productList = useMemo(() => module.items, [module.items]); + + const { isFreeDownloadOn, isPaidDownloadOn, useNewForms } = + useDigitalProductFeatures(); + + const isUpdateFlowOn = useFeatureIsOn( + FLAGS.FEAT_STRIPE_CONNECT_FLOW_ENHANCEMENTS + ); + const [stripeAccountStatusData, setStripeAccountStatusData] = + useState(null); + const [isStripeAccountStatusLoading, setIsStripeAccountStatusLoading] = + useState(true); + + useEffect(() => { + const fetchStripeAccountStatus = async () => { + try { + setIsStripeAccountStatusLoading(true); + const data = await getSalesServiceV1().getStripeAccountStatus(); + setStripeAccountStatusData(data); + } catch (error) { + console.error("Failed to fetch Stripe account status:", error); + setStripeAccountStatusData(null); + } finally { + setIsStripeAccountStatusLoading(false); + } + }; + + fetchStripeAccountStatus(); + }, []); + + const constructStripeAccountMessage = ( + stripeAccountStatusData?: DataStripeAccountStatus + ): { + message: string; + messageType: InlineMessageType; + redirectionLabel: string; + redirectionLink: string; + showMissingInfoErrorMessage: boolean; + } => { + //When stripe account is not connected/created + if (!stripeAccountStatusData || !stripeAccountStatusData.isEmailConnected) { + return { + message: "A free Stripe account is required to sell digital product.", + messageType: "warning", + redirectionLabel: "Go to Settings", + redirectionLink: "/admin/settings/subscription", + showMissingInfoErrorMessage: true, + }; + } + + const { isPaymentEnabled, isPayoutEnabled, hasPendingActions } = + stripeAccountStatusData; + + let message; + + if (!isPaymentEnabled && !isPayoutEnabled) { + message = + "Your Stripe account has missing information or pending actions. Complete these to ensure you receive payments and payouts for your sales."; + } else if (!isPaymentEnabled) { + message = + "Your Stripe account has missing information or pending actions. Complete these to ensure you receive payments for your sales."; + } else if (!isPayoutEnabled) { + message = + "Your Stripe account has missing information or pending actions. Complete these to ensure you receive payouts for your sales."; + } else if (hasPendingActions) { + message = + "Your Stripe account has missing information or pending actions. Complete these as soon as possible to keep your account active."; + } else { + // Everything is connected and enabled, no message needed + message = ""; + } + + return { + message, + messageType: "error", + redirectionLabel: "Go to Stripe dashboard", + redirectionLink: "https://dashboard.stripe.com/", + showMissingInfoErrorMessage: message !== "", + }; + }; + + const { + message, + showMissingInfoErrorMessage, + messageType, + redirectionLabel, + redirectionLink, + } = constructStripeAccountMessage(stripeAccountStatusData); + + const router = useHistory(); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule?.(moduleEdit); + }, + [profileContext] + ); + + const onDropEnd = useCallback( + (list: DigitalProductItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onAddItemClicked = useCallback(() => { + const url = + useNewForms || productList?.[0]?.landingPage?.regularPrice + ? "digital-product" + : "free-download"; + router.push(`/admin/modules/${url}?moduleId=${module.id}`); + }, []); + + const onEditItemClicked = useCallback((item: DigitalProductItem) => { + const url = + useNewForms || item.landingPage?.regularPrice + ? "digital-product" + : "free-download"; + router.push( + `/admin/modules/${url}/edit?moduleId=${module.id}&itemId=${item.id}` + ); + }, []); + + const onDeleteItem = useCallback( + (product: DigitalProductItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== product.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [] + ); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const isConnectedAccountDisabled = useIsConnectedAccountDisabled( + stripeAccountStatusData + ); + + //Determines if item visibility is enabled or disabled + const isItemVisiblityDisabled = !isUpdateFlowOn + ? !stripeAccountStatusData?.isEnabled && + !!productList?.[0]?.landingPage?.regularPrice + : isConnectedAccountDisabled && + !!productList?.[0]?.landingPage?.regularPrice; + + const renderContent = () => { + const [firstItem] = module.items as DigitalProductItem[]; + const hasPrice = (firstItem?.landingPage?.regularPrice || 0) > 0; + const isDownloadEnabled = + (hasPrice && isPaidDownloadOn) || (!hasPrice && isFreeDownloadOn); + + if (!module.items.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + return ( + + + + + + + + {productList.length}  + {productList.length > 1 ? "items added" : "item added"} + + + + + A free Stripe account is required to receive payouts for + your digital product sales. + +  Go to Settings + + + } + /> + } + /> + } + otherwise={ + + {message} + + {redirectionLabel} + + } + otherwise={ + + {redirectionLabel} + + } + /> + + } + /> + } + /> + } + /> + + {(dragProvided, snapshot, item, productIndex) => { + const isRender = active ? active : productIndex < 2; + + const isConnectedAccountDisabled = useIsConnectedAccountDisabled( + stripeAccountStatusData + ); + + //Calculate disableItemVisibility + const isItemVisiblityDisabled = !isUpdateFlowOn + ? !stripeAccountStatusData?.isEnabled && + !!productList?.[0]?.landingPage?.regularPrice + : isConnectedAccountDisabled && + !!productList?.[0]?.landingPage?.regularPrice; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && productList.length > 2 && ( +
+ + +{productList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleDigitalProduct); diff --git a/interface_base/src/pages/Profile/Modules/ModuleEvents.tsx b/interface_base/src/pages/Profile/Modules/ModuleEvents.tsx new file mode 100644 index 0000000..5437152 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleEvents.tsx @@ -0,0 +1,269 @@ +import Button from "antd/lib/button"; +import DragDropContext from "components/DragDropContext"; +import EventCardModule from "components/EventCardModule"; +import EventEditorModal from "components/EventEditorModal/EventEditorModal"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + BaseItem, + EventItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { + flipVisibilityForItemAndCloneArray, + isEmptyModule, +} from "utils/modules"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const EventCardMemo = React.memo(EventCardModule); + +interface ModuleEventsProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleEvents: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const profileContext = useModules(); + const [events, setEvents] = useState([]); + const [showModal, setShowModal] = useState(false); + const [itemEditState, setItemEditState] = useState<{ + isEdit: boolean; + item?: EventItem; + itemEditIndex?: number; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + + useEffect(() => { + setEvents( + module.items.map((item, index) => ({ + ...item, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onAddItem = useCallback(() => { + setItemEditState((preState) => ({ + ...preState, + isEdit: false, + item: undefined, + itemIndex: undefined, + })); + setShowModal(true); + }, []); + + const onEditItem = useCallback((item: EventItem) => { + setItemEditState((preState) => ({ + ...preState, + isEdit: true, + item, + itemIndex: item.order, + })); + setShowModal(true); + }, []); + + const onDeleteItem = useCallback( + (item: EventItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + item.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveItemCallback = useCallback( + (item: EventItem) => { + let moduleEdit; + if (itemEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + item.order as number, + item + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, item), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type || "CUSTOM EVENT", localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": "CUSTOM EVENT", + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [module, itemEditState, localizationSelected, profileContext.setModule] + ); + + const onDropEndItems = useCallback( + (list: EventItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + return ( + + + + {isEmptyModule(module) ? ( + + + + ) : ( + +
+ + + {`${events.length} date${events.length > 1 ? "s" : ""} added`} + +
+ + + {(dragProvided, snapshot, item, itemIndex) => { + return active ? ( + + + + ) : itemIndex < 2 ? ( + + + + ) : ( + <> + ); + }} + + {!active && events.length > 2 && ( +
+ + +{events.length - 2} More + +
+ )} +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleEvents); diff --git a/interface_base/src/pages/Profile/Modules/ModuleExperience.tsx b/interface_base/src/pages/Profile/Modules/ModuleExperience.tsx new file mode 100644 index 0000000..7607804 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleExperience.tsx @@ -0,0 +1,350 @@ +import Button from "antd/lib/button"; +import List from "antd/lib/list"; +import classNames from "classnames"; +import ContentCard from "components/ContentCard"; +import DragDropContext from "components/DragDropContext"; +import Experience1To1Modal from "components/Experience1To1Modal/Experience1To1Modal"; +import ExperienceContentCard from "components/ExperienceContentCard"; +import ExperienceLiveClassModal from "components/ExperienceInterActiveLiveClassModal"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import SelectContentTypeModal from "components/SelectContentTypeModal"; +import { SelectContentItem } from "components/SelectContentTypeModal/SelectContentTypeModal"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { ReactComponent as OneToOneIcon } from "icons/1-to-1.svg"; +import { ReactComponent as BroadcastIcon } from "icons/live.svg"; +import { + ExperienceItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { EXPERIENCE_TYPE } from "redux/Experience/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { isEmptyModule } from "utils/modules"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import "./ModuleExperience.scss"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; + +const types: SelectContentItem[] = [ + { + name: EXPERIENCE_TYPE.ONE_TO_ONE, + title: "Meet and greet", + description: "Enable fans to meet you one-on-one via live video", + icon: , + }, + { + name: EXPERIENCE_TYPE.INTERACTIVE_LIVE_CLASS, + title: "Live", + description: + "Host a live stream with up to 10 people and stream that to up to a million fans", + icon: , + }, +]; +interface ModuleExperienceProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleExperience: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [items, setItems] = useState([]); + const [itemsError, setItemsError] = useState([]); + const [showContentModal, setShowContentModal] = useState(false); + const [selectedContentType, setSelectedContentType] = + useState(); + const [experienceEditState, setExperienceEditState] = useState<{ + isEdit: boolean; + experience?: ExperienceItem; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + + const totalPriceOfItems = useMemo(() => { + return module.items.reduce( + (acc, curr) => (acc += curr.experiencePrice || 0), + 0 + ); + }, [module]); + + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + useEffect(() => { + setItems( + module.items.map((item, index) => ({ + ...item, + order: index, + })) + ); + + setItemsError( + module.items.filter( + (item) => item.isSuccess === false && typeof item.error === "string" + ) + ); + return () => {}; + }, [module.items, module.visible]); + + const handleSubmitType = useCallback( + (type: EXPERIENCE_TYPE) => { + setShowContentModal(false); + setSelectedContentType(type); + }, + [module] + ); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onAddItem = useCallback(() => { + setExperienceEditState((preState) => ({ + ...preState, + isEdit: false, + experience: undefined, + })); + setSelectedContentType(undefined); + setShowContentModal(true); + }, []); + + const onEditItem = useCallback( + (item: ExperienceItem) => { + setExperienceEditState((preState) => ({ + ...preState, + isEdit: true, + experience: item, + })); + setSelectedContentType(item.type); + }, + [module] + ); + + const onDeleteItem = useCallback( + (item: ExperienceItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + item.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onDropEndItems = useCallback( + (list: ExperienceItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ + ...item, + experienceId: item.id, + order: index, + })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const toggleContentModal = useCallback((value: boolean) => { + setShowContentModal(value); + }, []); + + const onSaveExperienceCallback = useCallback( + (experience: ExperienceItem) => { + let moduleEdit; + if (experienceEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + experienceEditState.experience?.order as number, + experience + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, experience), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + }, + [ + module, + experienceEditState, + localizationSelected, + profileContext.setModule, + ] + ); + + return ( + + + { + setSelectedContentType(undefined); + }} + onCancel={() => { + setSelectedContentType(undefined); + setShowContentModal(true); + }} + onConfirmCallback={onSaveExperienceCallback} + /> + { + setSelectedContentType(undefined); + }} + onCancel={() => { + setSelectedContentType(undefined); + setShowContentModal(true); + }} + onConfirmCallback={onSaveExperienceCallback} + /> + 0} + dragHandleProps={dragHandleProps} + banner={ + 0} + content={ + ( + + {item.name}: {item.error} + + )} + /> + } + handleClose={() => setItemsError([])} + /> + } + > + {isEmptyModule(module) ? ( + + + + ) : ( + + + + {(dragProvided, snapshot, item, itemIndex) => { + const isRender = active ? active : itemIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && items.length > 2 && ( +
+ + +{items.length - 2} More + +
+ )} +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleExperience); diff --git a/interface_base/src/pages/Profile/Modules/ModuleFanClub.tsx b/interface_base/src/pages/Profile/Modules/ModuleFanClub.tsx new file mode 100644 index 0000000..21789fa --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleFanClub.tsx @@ -0,0 +1,60 @@ +import { useModules } from "hooks/useModules"; +import { + FanClubItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useSelector } from "react-redux"; +import { ArrayServices } from "utils/array"; +import FanClubCard from "../../../components/FanClubCard"; +import { selectUserData } from "../../../redux/User/selector"; +import ModuleEditor from "../ModuleEditor"; + +interface ModuleFanClubProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; +} + +const ModuleFanClub: React.FC = ({ + module, + dragHandleProps, +}) => { + const profileContext = useModules(); + const user = useSelector(selectUserData); + const item = useMemo(() => { + return module.items[0]; + }, [module.items]); + + const onSaveFanClubCallback = useCallback( + (fanClub: FanClubItem) => { + const moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex(module.items, 0, fanClub), + isUpdate: true, + }; + profileContext.setModule(moduleEdit); + }, + [module, profileContext.setModule] + ); + + return ( + + + + + + ); +}; + +export default React.memo(ModuleFanClub); diff --git a/interface_base/src/pages/Profile/Modules/ModuleGroup.tsx b/interface_base/src/pages/Profile/Modules/ModuleGroup.tsx new file mode 100644 index 0000000..a9099f5 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleGroup.tsx @@ -0,0 +1,87 @@ +import { Row } from "antd"; +import DragDropContext from "components/DragDropContext"; +import { useModules } from "hooks/useModules"; +import { + GroupItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import AddModule from "../AddModule/AddModule"; +import ModuleEditor from "../ModuleEditor"; +import ModulePortal from "../ModulePortal/ModulePortal"; + +interface ModuleGroupProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleGroup: React.FC = ({ + module, + dragHandleProps, +}) => { + const profileContext = useModules(); + const [moduleList, setModuleList] = useState([]); + + useEffect(() => { + setModuleList( + module.items.map((item, index) => ({ + ...item, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onDropEndItem = useCallback( + (list: GroupItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + return ( + + + + + + + + {(dragProvided, snapshot, item, index) => { + return ( + + ); + }} + + + + + ); +}; + +export default React.memo(ModuleGroup); diff --git a/interface_base/src/pages/Profile/Modules/ModuleInstagramReels.tsx b/interface_base/src/pages/Profile/Modules/ModuleInstagramReels.tsx new file mode 100644 index 0000000..ac10756 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleInstagramReels.tsx @@ -0,0 +1,259 @@ +import { Button, Col, Row } from "antd"; +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { useModules } from "hooks/useModules"; +import { + InstagramReelItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import reduce from "lodash/reduce"; +import ModuleEditor from "../ModuleEditor"; +import { useWindowSize } from "../../../hooks"; +import InstagramReelsModal from "components/InstagramReelsModal"; +import InstagramReelsCardModule from "components/InstagramReelsCardModule"; + +interface ModuleReelProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleReel: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const profileContext = useModules(); + const [modalVisible, setModalVisible] = useState(false); + const [modalItem, setModalItem] = useState( + undefined + ); + + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + + const reelList = useMemo(() => module.items, [module.items]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule?.(moduleEdit); + }, + [profileContext] + ); + + const onDropEndReels = useCallback( + (list: InstagramReelItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onAddVideoClicked = useCallback(() => { + setModalItem(undefined); + setModalVisible(true); + }, []); + + const onEditVideoClicked = useCallback( + (reel: InstagramReelItem) => { + setModalItem(reel); + setModalVisible(true); + }, + [setModalVisible] + ); + + const onAddReel = useCallback( + (url: string) => { + onEditModuleCallback({ + ...module, + items: [{ url, order: module.items.length }, ...module.items], + isUpdate: true, + }); + + setModalVisible(false); + }, + [module, onEditModuleCallback] + ); + + const onEditReel = useCallback( + (existingItem: InstagramReelItem, url: string) => { + const items = module.items.map((item) => { + if (item.order === existingItem.order) { + return { ...item, url }; + } + return item; + }); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + + setModalVisible(false); + setModalItem(undefined); + }, + [module, onEditModuleCallback] + ); + + const onDeleteReel = useCallback( + (reel: InstagramReelItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== reel.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [] + ); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const renderContent = () => { + if (!module.items.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + return ( + + + + + + + + {reelList.length}  + {reelList.length > 1 ? "videos added" : "video added"} + + + + + {(dragProvided, snapshot, item, reelIndex) => { + const isRender = active ? active : reelIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && reelList.length > 2 && ( +
+ + +{reelList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleReel); diff --git a/interface_base/src/pages/Profile/Modules/ModuleLink.tsx b/interface_base/src/pages/Profile/Modules/ModuleLink.tsx new file mode 100644 index 0000000..aafe24d --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleLink.tsx @@ -0,0 +1,283 @@ +import Button from "antd/lib/button"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import LinkCardModule from "components/LinkCardModule"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + BaseItem, + LinkItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { BaseModuleProps } from "redux/Common/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { + flipVisibilityForItemAndCloneArray, + isEmptyModule, +} from "utils/modules"; +import LinkEditorModal from "../../../components/LinkEditorModal/LinkEditorModal"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const LinkCardMemo = React.memo(LinkCardModule); + +interface ModuleLinkProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleLink: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [linkList, setLinkList] = useState([]); + const [showModal, setShowModal] = useState(false); + const [linkEditState, setLinkEditState] = useState<{ + isEdit: boolean; + link?: LinkItem; + linkEditIndex?: number; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + useEffect(() => { + setLinkList( + module.items.map((link, index) => ({ + ...link, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onAddLink = useCallback(() => { + setLinkEditState((preState) => ({ + ...preState, + isEdit: false, + link: undefined, + linkIndex: undefined, + })); + setShowModal(true); + }, []); + + const onEditLink = useCallback((link: LinkItem) => { + setLinkEditState((preState) => ({ + ...preState, + isEdit: true, + link: link, + linkIndex: link.order, + })); + setShowModal(true); + }, []); + + const onDeleteLink = useCallback( + (link: LinkItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + link.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveLinkCallback = useCallback( + (link: LinkItem) => { + onToggleModal(false); + let moduleEdit; + if (linkEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + link.order as number, + link + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, link), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + if (!linkEditState.isEdit) { + const { + pageId, + pageName, + type + } = trackingProps( + link.specialOffer?.storeUrl + ? "SPECIAL OFFER" + : moduleEdit.type + ,localizationSelected + ) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + onToggleModal(false); + }, + [ + module, + linkEditState, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ] + ); + + const onDropEndLinks = useCallback( + (list: LinkItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + return ( + + + + {isEmptyModule(module) ? ( + + + + ) : ( + +
+ + + {linkList.length}  + {linkList.length > 1 ? "links added" : "link added"} + +
+ + + {(dragProvided, snapshot, item, linkIndex) => { + return active ? ( + + + + ) : linkIndex < 2 ? ( + + + + ) : ( + <> + ); + }} + + {!active && linkList.length > 2 && ( +
+ + +{linkList.length - 2} More + +
+ )} +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleLink); diff --git a/interface_base/src/pages/Profile/Modules/ModuleMediaGallery.tsx b/interface_base/src/pages/Profile/Modules/ModuleMediaGallery.tsx new file mode 100644 index 0000000..36559aa --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleMediaGallery.tsx @@ -0,0 +1,308 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Col, Row } from "antd"; + +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { useModules } from "hooks/useModules"; +import MediaGalleryModal from "components/MediaGalleryModal"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import reduce from "lodash/reduce"; +import ModuleEditor from "../ModuleEditor"; +import { useWindowSize } from "../../../hooks"; +import { + MediaGalleryItem, + TalentProfileModule, +} from "../../../models/talent/talent-profile-module.model"; +import MediaGalleryCardModule from "../../../components/MediaGalleryCardModule"; +import { + FLAGS, + IfFeatureDisabled, + IfFeatureEnabled, + useFeatureIsOn, +} from "@komi-app/flags-sdk"; +import When from "@komi-app/when"; + +interface ModuleTikTokProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleMediaGallery: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const profileContext = useModules(); + const isMediaGalleryVideosEnabled = useFeatureIsOn( + FLAGS.FEAT_SB_637_ADD_VIDEOS_TO_MEDIA_GALLERY, + ); + const isThumbnailJumpingFixEnabled = useFeatureIsOn( + FLAGS.FIX_SB_870_THUMBNAILS_JUMPING_ON_UPDATE_MEDIA_GALLERY, + ); + + const [modalVisible, setModalVisible] = useState(false); + const [modalItem, setModalItem] = useState( + undefined, + ); + + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + + const itemList = useMemo(() => { + if (isMediaGalleryVideosEnabled) { + return module.items; + } + + return module.items.filter((item) => !isIframeVideo(item.src)); + }, [module.items, isMediaGalleryVideosEnabled]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule?.(moduleEdit); + }, + [profileContext], + ); + + const onDropEnd = useCallback( + (list: MediaGalleryItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback], + ); + + const onAddItemClicked = useCallback(() => { + setModalItem(undefined); + setModalVisible(true); + }, []); + + const onEditItemClicked = useCallback( + (mediaGalleryItem: MediaGalleryItem) => { + setModalItem(mediaGalleryItem); + setModalVisible(true); + }, + [], + ); + + const onAddMediaGalleryItem = useCallback( + (mediaGalleryItem: MediaGalleryItem) => { + onEditModuleCallback({ + ...module, + items: [mediaGalleryItem, ...module.items].map((item, index) => ({ + ...item, + order: index, + })), + isUpdate: true, + }); + + setModalVisible(false); + }, + [module, onEditModuleCallback], + ); + + const onEditMediaGalleryItem = useCallback( + (existingItem: MediaGalleryItem, newItem: MediaGalleryItem) => { + const items = module.items.map((item) => { + if (item.order === existingItem.order) { + return { ...item, ...newItem }; + } + return item; + }); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + + setModalVisible(false); + setModalItem(undefined); + }, + [module, onEditModuleCallback], + ); + + const onDeleteMediaGalleryItem = useCallback( + (mediaGalleryItem: MediaGalleryItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== mediaGalleryItem.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [], + ); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + }, + [module, profileContext.setModule], + ); + + const renderContent = () => { + if (!itemList.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + return ( + + + + + + + + {itemList.length}  + {itemList.length > 1 ? "item added" : "items added"} + + + + + {(dragProvided, snapshot, item, itemIndex) => { + const isRender = active ? active : itemIndex < 2; + + let key; + if (isThumbnailJumpingFixEnabled) { + key = item.id || item.thumbnailSrc || item.src; + } else { + key = itemIndex; + } + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && itemList.length > 2 && ( +
+ + +{itemList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + + } + /> + + + + + {renderContent()} + + + ); +}; + +export function isIframeVideo(src: string): boolean { + return src.endsWith("/iframe"); +} + +export default React.memo(ModuleMediaGallery); diff --git a/interface_base/src/pages/Profile/Modules/ModuleMusic.tsx b/interface_base/src/pages/Profile/Modules/ModuleMusic.tsx new file mode 100644 index 0000000..5814803 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleMusic.tsx @@ -0,0 +1,509 @@ +import { Button } from "antd"; +import React, { useCallback, useEffect, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; + +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; + +import { useDispatch } from "react-redux"; +import { updateSpotifyUserActions } from "redux/User/actions"; +import { + selectLocalizationSelected, + selectUserData, +} from "redux/User/selector"; +import { SpotifyUser } from "redux/User/types"; +import { useTypedSelector } from "redux/rootReducer"; + +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import MediaCardModule from "components/MediaCardModule"; +import PopupWindow from "components/PopupWindowSpotifyAuth"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { + PreReleaseCustomModal, + PreReleaseSmartModal, +} from "components/PreReleaseModal"; +import SelectContentTypeModal from "components/SelectContentTypeModal"; +import { SelectContentItem } from "components/SelectContentTypeModal/SelectContentTypeModal"; +import { Text } from "components/Typography"; + +import { + MusicItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; + +import copyToClipboard from "copy-to-clipboard"; +import Cookies from "js-cookie"; +import { ArrayServices } from "utils/array"; +import notification from "utils/notification"; +import { convertToUrl, profileUrl } from "utils/url"; + +import { userService } from "services"; +import { + KOMI_SPOTIFY_ACCESS_TOKEN, + KOMI_SPOTIFY_REFRESH_TOKEN, +} from "services/SpotifyService"; + +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; + +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { SmartLinkModal } from "components/SmartLinkGenerator/SmartLinkModal"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { isEmptyModule } from "utils/modules"; +import { + MusicReleaseType, + MusicReleaseMode +} from "@komi-app/shared-types" +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const MusicCardMemo = React.memo(MediaCardModule); + +export const musicContentTypes: SelectContentItem[] = [ + { + name: MusicReleaseType.NORMAL, + title: "Smart Links", + description: + "Share smart links to released tracks that drive your fans to multiple platforms, maximising streams and fan engagement", + icon: , + }, + { + name: MusicReleaseType.PRE_SAVE, + title: "Pre-Release Smart Link", + description: + "Start a pre-release campaign allowing fans to save your tracks or albums to their library before their release, maximising release day streams", + icon: , + }, + { + name: MusicReleaseType.CUSTOM_PRE_SAVE, + title: "Pre-Release Custom Link", + description: + "Use your custom pre-release landing page to redirect fans to multiple streaming platforms", + icon: , + }, +]; + +interface ModuleMusicProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} +interface MusicEditState { + isEdit: boolean; + music?: MusicItem; + musicEditIndex?: number; +} + +const ModuleMusic: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const dispatch = useDispatch(); + + const { sendSegmentEvent } = useAnalytics(); + + const [showContentModal, setShowContentModal] = useState(false); + const [musicList, setMusicList] = useState([]); + const [selectedContentType, setSelectedContentType] = + useState(); + const [musicEditState, setMusicEditState] = useState({ + isEdit: false, + }); + + // const profileContext = useContext(ProfileContext); + const profileContext = useModules(); + + const user = useTypedSelector(selectUserData); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + + const hasSpotifyPopupFix = useFeatureIsOn(FLAGS.FIX_SB_286_SPOTIFY_POPUP); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + useEffect(() => { + setMusicList( + module.items.map((music, index) => ({ + ...music, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + // TODO: Move these outside of the module. They're not dependent on + // anything within the module + const handleRefreshToken = async (callback: any) => { + try { + const result = await userService.spotifyRefreshToken(); + if (result && "ok" in result) { + dispatch( + updateSpotifyUserActions.REQUEST({ + ...result.response, + callback: callback, + }) + ); + } else { + throw new Error("Cann't refresh token spotify."); + } + } catch (ex: any) { + await handleSpotifySignIn(callback); + return; + } + }; + const handleSpotifySignIn = async (callback: any) => { + if (hasSpotifyPopupFix) { + const popup = PopupWindow.open("spotify-authorization", "about:blank", { + height: 1000, + width: 600, + }); + + const result = await userService.spotifyConnect({ + callbackUrl: window.location.href, + }); + + if (!!result && "ok" in result) { + popup.navigate(result.response.url); + + popup.then( + (data: SpotifyUser) => { + dispatch( + updateSpotifyUserActions.REQUEST({ + ...data, + callback: callback, + }) + ); + }, + (error: any) => { + console.error(error); + } + ); + } + } else { + const result = await userService.spotifyConnect({ + callbackUrl: window.location.href, + }); + + if (!!result && "ok" in result) { + const popup = PopupWindow.open( + "spotify-authorization", + result.response.url, + { + height: 1000, + width: 600, + } + ); + + popup.then( + (data: SpotifyUser) => { + dispatch( + updateSpotifyUserActions.REQUEST({ + ...data, + callback: callback, + }) + ); + }, + (error: any) => { + console.error(error); + } + ); + } + } + }; + const handleConnectSpotify = async (callback: any) => { + if ( + user?.spotifyRefreshToken || + user?.thirdPartyInfo?.spotifyRefreshToken || + Cookies.get(KOMI_SPOTIFY_REFRESH_TOKEN) + ) { + await handleRefreshToken(callback); + } else { + await handleSpotifySignIn(callback); + } + }; + + const handleSubmitType = useCallback( + (type: MusicReleaseType) => { + setShowContentModal(false); + setSelectedContentType(type); + }, + [module] + ); + + const onBeforeAddProduct = useCallback(async () => { + if (!Cookies.get(KOMI_SPOTIFY_ACCESS_TOKEN)) { + await handleConnectSpotify(() => onAddMusic()); + return; + } + onAddMusic(); + }, [selectedContentType]); + + const onBeforeEditProduct = useCallback(async (music: MusicItem) => { + //the new pre-save flow requires connecting the spotify api along with the other music modules + if (!Cookies.get(KOMI_SPOTIFY_ACCESS_TOKEN)) { + await handleConnectSpotify(() => onEditMusic(music)); + return; + } + onEditMusic(music); + }, []); + + const onAddMusic = useCallback(() => { + setMusicEditState((preState) => ({ + ...preState, + isEdit: false, + music: undefined, + musicIndex: undefined, + })); + // setVisible(true); + setSelectedContentType(undefined); + setShowContentModal(true); + }, []); + const onEditMusic = useCallback((music: MusicItem) => { + setMusicEditState((preState) => ({ + ...preState, + isEdit: true, + music: music, + musicIndex: music.order, + })); + setSelectedContentType(music.type || MusicReleaseType.NORMAL); + }, []); + const onDeleteMusic = useCallback( + (music: MusicItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + music.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onCopySmartLink = useCallback( + (music: MusicItem) => { + // user?.username coming up null in some cases, therefore parsing the name from the url using utl function 'profileUrl' + // const komiLink = createKomiDomain(user?.username || ""); + + const linkTail = music.urlSlug || convertToUrl(music.metadata?.name); + const link = `${profileUrl}/music/${linkTail}`; + + copyToClipboard(link); + notification.success({ + message: "The smart link has been copied to clipboard", + }); + }, + [user] + ); + + const onDropEndMusics = useCallback( + (list: MusicItem[]) => { + profileContext.setModule({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveProductCallback = useCallback( + (music: MusicItem) => { + const moduleItems = musicEditState.isEdit + ? ArrayServices.updateWithIndex( + module.items, + music.order as number, + music + ) + : ArrayServices.unshift(module.items, music); + + const moduleEdit = { + ...module, + items: moduleItems, + isUpdate: true, + }; + + profileContext.setModule(moduleEdit); + + if (!musicEditState.isEdit) { + let type = "SMART LINK"; + if (music.releaseDate) { + type = + music.releaseType === MusicReleaseMode.CUSTOM + ? "CUSTOM PRE-SAVE" + : "KOMI PRE-SAVE"; + } + + const { + pageId, + pageName, + } = trackingProps(type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + }, + [ + module, + musicEditState, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ] + ); + + const handleSetVisible = () => { + setSelectedContentType(undefined); + setMusicEditState((preState) => ({ + ...preState, + isEdit: false, + music: undefined, + musicIndex: undefined, + })); + }; + + const reduceContentTypeToModal = useCallback( + (contentType?: MusicReleaseType) => { + switch (contentType) { + case MusicReleaseType.NORMAL: + return ( + + ); + + case MusicReleaseType.PRE_SAVE: + return ( + + ); + + case MusicReleaseType.CUSTOM_PRE_SAVE: + return ( + + ); + + default: + throw new Error(`Unhandled music item type '${contentType}'`); + } + }, + [musicEditState] + ); + + return ( + + + {isEmptyModule(module) ? ( + + + + ) : ( + +
+ + + {musicList.length} tracks added + +
+ + + {(dragProvided, snapshot, item, productIndex) => { + const isRender = active ? active : productIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && musicList.length > 2 && ( +
+ + +{musicList.length - 2} More + +
+ )} +
+ )} +
+ + {selectedContentType && reduceContentTypeToModal(selectedContentType)} + + +
+ ); +}; + +export default ModuleMusic; diff --git a/interface_base/src/pages/Profile/Modules/ModuleOndemandVideo.tsx b/interface_base/src/pages/Profile/Modules/ModuleOndemandVideo.tsx new file mode 100644 index 0000000..d7550e9 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleOndemandVideo.tsx @@ -0,0 +1,269 @@ +import "./ModuleOndemandVideo.scss"; +import OnDemandEditorModal from "components/OnDemandEditorModal/OnDemandEditorModal"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { + OnDemandVideoItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import { ArrayServices } from "utils/array"; +import ModuleEditor from "../ModuleEditor"; +import ProfileContext from "../ProfileContext"; +import EmptyModule from "./EmptyModule"; +import { Text } from "components/Typography"; +import Button from "antd/lib/button"; +import { Icon } from "components/Icon"; +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import OnDemandCard from "components/OnDemandCard"; +import { useAnalytics } from "hooks/useAnalytics"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { useTypedSelector } from "redux/rootReducer"; +import { useModules } from "hooks/useModules"; +import { isEmptyModule } from "utils/modules"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const OnDemandCardMemo = React.memo(OnDemandCard); + +interface ModuleOndemandVideoProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleOndemandVideo: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [items, setItems] = useState([]); + const [showModal, setShowModal] = useState(false); + const [itemEditState, setItemEditState] = useState<{ + isEdit: boolean; + item?: OnDemandVideoItem; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const totalPriceOfItems = useMemo(() => { + return module.items.reduce( + (acc, curr) => (acc += curr.experiencePrice || 0), + 0 + ); + }, [module]); + + useEffect(() => { + setItems( + (module.items as OnDemandVideoItem[]).map((exp, index) => ({ + ...exp, + order: index, + })) + ); + return () => {}; + }, [module.items]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onAddItem = useCallback(() => { + setItemEditState((preState) => ({ + ...preState, + isEdit: false, + item: undefined, + itemEditIndex: undefined, + })); + setShowModal(true); + }, []); + + const onEditItem = useCallback((item: OnDemandVideoItem) => { + setItemEditState((preState) => ({ + ...preState, + isEdit: true, + item: item, + })); + setShowModal(true); + }, []); + + const onDeleteItem = useCallback( + (item: OnDemandVideoItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + item.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveItemCallback = useCallback( + (item: OnDemandVideoItem) => { + let moduleEdit; + if (itemEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + item.order as number, + { ...item, experienceId: item.id } + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, { + ...item, + experienceId: item.id, + }), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [ + module, + itemEditState, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ] + ); + + const onDropEndProducts = useCallback( + (list: OnDemandVideoItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ + ...item, + experienceId: item.id, + order: index, + })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + return ( + + + 0} + dragHandleProps={dragHandleProps} + > + {isEmptyModule(module) ? ( + + + + ) : ( + +
+ + + {items.length}  + {items.length > 1 ? "videos added" : "video added"} + +
+ + + {(dragProvided, snapshot, item, itemIndex) => { + const isRender = active ? active : itemIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && items.length > 2 && ( +
+ + +{items.length - 2} More + +
+ )} +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleOndemandVideo); diff --git a/interface_base/src/pages/Profile/Modules/ModulePodcast.tsx b/interface_base/src/pages/Profile/Modules/ModulePodcast.tsx new file mode 100644 index 0000000..cd38c35 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModulePodcast.tsx @@ -0,0 +1,408 @@ +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { Button } from "antd"; +import classNames from "classnames"; +import AddProfileSmartPodcast from "components/AddProfileSmartPodcast"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import PodcastCardModule from "components/PodcastCardModule"; +import PopupWindow from "components/PopupWindowSpotifyAuth"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import copyToClipboard from "copy-to-clipboard"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import Cookies from "js-cookie"; +import { + PodcastItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useDispatch } from "react-redux"; +import { updateSpotifyUserActions } from "redux/User/actions"; +import { + selectLocalizationSelected, + selectUserData, +} from "redux/User/selector"; +import { SpotifyUser } from "redux/User/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { userService } from "services"; +import { createKomiDomain } from "services/DomainService"; +import { + KOMI_SPOTIFY_ACCESS_TOKEN, + KOMI_SPOTIFY_REFRESH_TOKEN, +} from "services/SpotifyService"; +import { ArrayServices } from "utils/array"; +import { isEmptyModule } from "utils/modules"; +import { useWindowSize } from "../../../hooks"; +import notification from "../../../utils/notification"; +import { convertToUrl } from "../../../utils/url"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const MusicCardMemo = React.memo(PodcastCardModule); + +interface ModulePodcastProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModulePodcast: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + const dispatch = useDispatch(); + const [musicList, setMusicList] = useState([]); + const [musicEditState, setMusicEditState] = useState<{ + isEdit: boolean; + music?: PodcastItem; + musicEditIndex?: number; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + + const profileContext = useModules(); + const user = useTypedSelector(selectUserData); + + const [visible, setVisible] = useState(false); + + const hasSpotifyPopupFix = useFeatureIsOn(FLAGS.FIX_SB_286_SPOTIFY_POPUP); + + useEffect(() => { + setMusicList( + module.items.map((music, index) => ({ + ...music, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + const handleRefreshToken = async (callback: any) => { + try { + const result = await userService.spotifyRefreshToken(); + if (result && "ok" in result) { + dispatch( + updateSpotifyUserActions.REQUEST({ + ...result.response, + callback: callback, + }) + ); + } else { + throw new Error("Cann't refresh token spotify."); + } + } catch (ex: any) { + await handleSpotifySignIn(callback); + return; + } + }; + + const handleSpotifySignIn = async (callback: any) => { + if (hasSpotifyPopupFix) { + const popup = PopupWindow.open("spotify-authorization", "about:blank", { + height: 1000, + width: 600, + }); + + const result = await userService.spotifyConnect({ + callbackUrl: window.location.href, + }); + + if (!!result && "ok" in result) { + popup.navigate(result.response.url); + popup.then( + (data: SpotifyUser) => { + dispatch( + updateSpotifyUserActions.REQUEST({ + ...data, + callback: callback, + }) + ); + }, + (error: any) => { + console.error(error); + } + ); + } + } else { + const result = await userService.spotifyConnect({ + callbackUrl: window.location.href, + }); + if (!!result && "ok" in result) { + const popup = PopupWindow.open( + "spotify-authorization", + result.response.url, + { + height: 1000, + width: 600, + } + ); + + popup.then( + (data: SpotifyUser) => { + dispatch( + updateSpotifyUserActions.REQUEST({ + ...data, + callback: callback, + }) + ); + }, + (error: any) => { + console.error(error); + } + ); + } + } + }; + + const handleConnectSpotify = async (callback: any) => { + if ( + user?.spotifyRefreshToken || + user?.thirdPartyInfo?.spotifyRefreshToken || + Cookies.get(KOMI_SPOTIFY_REFRESH_TOKEN) + ) { + await handleRefreshToken(callback); + } else { + await handleSpotifySignIn(callback); + } + }; + + const onBeforeAddProduct = useCallback(async () => { + if (!Cookies.get(KOMI_SPOTIFY_ACCESS_TOKEN)) { + await handleConnectSpotify(() => onAddMusic()); + return; + } + onAddMusic(); + }, []); + + const onBeforeEditProduct = useCallback(async (music: PodcastItem) => { + if (!Cookies.get(KOMI_SPOTIFY_ACCESS_TOKEN)) { + await handleConnectSpotify(() => onEditMusic(music)); + return; + } + onEditMusic(music); + }, []); + + const onAddMusic = useCallback(() => { + setMusicEditState((preState) => ({ + ...preState, + isEdit: false, + music: undefined, + musicIndex: undefined, + })); + setVisible(true); + }, []); + + const onEditMusic = useCallback((music: PodcastItem) => { + setMusicEditState((preState) => ({ + ...preState, + isEdit: true, + music: music, + musicIndex: music.order, + })); + setVisible(true); + }, []); + + const onDeleteMusic = useCallback( + (music: PodcastItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + music.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onDropEndMusics = useCallback( + (list: PodcastItem[]) => { + profileContext.setModule({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveProductCallback = useCallback( + (music: PodcastItem) => { + let moduleEdit; + if (musicEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + music.order as number, + music + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, music), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + }, + [module, musicEditState, localizationSelected, profileContext.setModule] + ); + + const handleSetVisible = (value: boolean) => { + setVisible(value); + setMusicEditState((preState) => ({ + ...preState, + isEdit: false, + music: undefined, + musicIndex: undefined, + })); + }; + + const onCopySmartLink = useCallback( + (music: PodcastItem) => { + const komiLink = createKomiDomain(user?.username || ""); + const link = `${komiLink}/podcast/${convertToUrl( + music.metadata?.name + )}?id=${music.id}`; + copyToClipboard(link); + notification.success({ + message: "The smart link has been copied to clipboard", + }); + }, + [user] + ); + + const { width } = useWindowSize(); + + const isMobileStyleChangesEnabled = width && width <= 768; + + return ( + + + {isEmptyModule(module) ? ( + + + + ) : ( + +
+ + + {musicList.length}  + {musicList.length > 1 ? "podcasts added" : "podcast added"} + +
+ + + {(dragProvided, snapshot, item, productIndex) => { + const isRender = active ? active : productIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + +
+ )} + {!active && musicList.length > 2 && ( +
+ + +{musicList.length - 2} More + +
+ )} +
+ +
+ ); +}; + +export default React.memo(ModulePodcast); diff --git a/interface_base/src/pages/Profile/Modules/ModuleProduct.tsx b/interface_base/src/pages/Profile/Modules/ModuleProduct.tsx new file mode 100644 index 0000000..84232e0 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleProduct.tsx @@ -0,0 +1,268 @@ +import Button from "antd/lib/button"; +import { Col, Row } from "antd/lib/grid"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import ProductCardModule from "components/ProductCardModule"; +import ProductEditorModal from "components/ProductEditorModal/ProductEditorModal"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + BaseItem, + ProductItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { Product } from "redux/Product/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { + flipVisibilityForItemAndCloneArray, + isEmptyModule, +} from "utils/modules"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import "./ModuleProduct.scss"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useWindowSize } from "../../../hooks"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const ProductCardMemo = React.memo(ProductCardModule); + +interface ModuleProductProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleProduct: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [productList, setProductList] = useState([]); + const [showModal, setShowModal] = useState(false); + const [productEditState, setProductEditState] = useState<{ + isEdit: boolean; + product?: ProductItem; + productEditIndex?: number; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const { width } = useWindowSize(); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const isMobileStyleChangesEnabled = width && width <= 768; + + useEffect(() => { + setProductList( + module.items.map((product, index) => ({ + ...product, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onAddProduct = useCallback(() => { + setProductEditState((preState) => ({ + ...preState, + isEdit: false, + product: undefined, + productIndex: undefined, + })); + setShowModal(true); + }, []); + + const onEditProduct = useCallback((product: Product) => { + setProductEditState((preState) => ({ + ...preState, + isEdit: true, + product: product, + productIndex: product.index, + })); + setShowModal(true); + }, []); + + const onDeleteProduct = useCallback( + (product: ProductItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + product.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveProductCallback = useCallback( + (product: ProductItem) => { + let moduleEdit; + if (productEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + product.order as number, + product + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, product), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [module, productEditState, localizationSelected, profileContext.setModule] + ); + + const onDropEndProducts = useCallback( + (list: ProductItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + return ( + + + + {isEmptyModule(module) ? ( + + + + ) : ( + + + + + + + + {productList.length}  + {productList.length > 1 ? "products added" : "product added"} + + + + + + {(dragProvided, snapshot, item, productIndex) => { + const isRender = active ? active : productIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && productList.length > 2 && ( +
+ + +{productList.length - 2} More + +
+ )} +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleProduct); diff --git a/interface_base/src/pages/Profile/Modules/ModuleSeated.tsx b/interface_base/src/pages/Profile/Modules/ModuleSeated.tsx new file mode 100644 index 0000000..a6ce00a --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleSeated.tsx @@ -0,0 +1,249 @@ +import { Col } from "antd"; +import DragDropContext from "components/DragDropContext"; +import LoadingModule from "components/LoadingModule"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + SeatedItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import SeatedEditorModal from "../../../components/SeatedEditorModal"; +import SteatedEventCard from "../../../components/SeatedEventCard"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import ModuleEditor from "../ModuleEditor"; +import "./ModuleSeated.scss"; +import { Alert } from "antd"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const SeatedEventCardMemo = React.memo(SteatedEventCard); + +interface ModuleSeatedProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleSeated: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const item = useMemo(() => { + return module.items[0]; + }, [module.items]); + const [showModal, setShowModal] = useState(false); + + const { loadingShop, thirdPartyData } = profileContext; + const loading = useMemo(() => { + return loadingShop.some((key: string) => key === module.id); + }, [loadingShop, module.id]); + const data = useMemo(() => { + return thirdPartyData.find((item: any) => item.id === module.id); + }, [thirdPartyData, module.id]); + const events = useMemo( + () => data?.events || { message: null, shows: [] }, + [data] + ); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + const onEditSeatedLink = useCallback(() => { + setShowModal(true); + }, []); + + const onSaveLinkCallback = useCallback( + (link: SeatedItem) => { + const moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex(module.items, 0, link), + isUpdate: true, + }; + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [module, localizationSelected, profileContext.setModule, sendSegmentEvent] + ); + + const onDropEndLinks = useCallback( + (list: SeatedItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + const renderContent = () => { + if (data && data.status === "cannot-connect") { + return ( + + Connection with Seated could not be established at this time. Try + reloading the page or check back later. +
+ If there is an issue with Seated, this module will be hidden from + your profile until the connection is reestablished. The rest of + your profile will not be affected and you can continue making and + publishing changes as normal. + + } + icon={} + showIcon + closeText={} + /> + ); + } + if (loading) { + return ; + } + if (!events?.shows?.length) { + return ( + + {events.status !== 200 && + events.message && + events.message.length > 0 && ( + } + showIcon + closable={false} + /> + )} + + + + + + + No Events Found + + + It appears that you have no events on Seated right now. Try adding + events to your Seated profile or edit your Artist ID. + + + + ); + } + return ( + +
+ + {events.shows.length}  + {events.shows.length > 1 ? "dates added" : "date added"} + +
+ + {(dragProvided, snapshot, item, linkIndex) => { + const isRender = active ? active : linkIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && events.shows && events.shows.length > 2 && ( +
+ + +{events.shows.length - 2} More + +
+ )} +
+ ); + }; + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleSeated); diff --git a/interface_base/src/pages/Profile/Modules/ModuleShopList.tsx b/interface_base/src/pages/Profile/Modules/ModuleShopList.tsx new file mode 100644 index 0000000..9286b45 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleShopList.tsx @@ -0,0 +1,252 @@ +import { Alert, Col, Row } from "antd"; +import classNames from "classnames"; +import DragDropContext from "components/DragDropContext"; +import LoadingModule from "components/LoadingModule"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import ShopListEditorModal from "components/ShopListEditorModal"; +import ShopMySelfProductCard from "components/ShopMySelfProductCard"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + ShopListItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import ModuleEditor from "../ModuleEditor"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const ShopMySelfProductCardMemo = React.memo(ShopMySelfProductCard); + +interface ModuleShopListProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleShopList: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const item = useMemo(() => { + return module.items[0]; + }, [module.items]); + const [showModal, setShowModal] = useState(false); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const { loadingShop, thirdPartyData } = profileContext; + const loading = useMemo(() => { + return loadingShop.some((key: string) => key === module.id); + }, [loadingShop, module.id]); + const data = useMemo(() => { + return thirdPartyData.find((item: any) => item.id === module.id); + }, [thirdPartyData, module.id]); + const products = useMemo(() => data?.products || [], [data]); + const error = useMemo(() => { + if (!data || data?.status === "success") { + return undefined; + } + return data.status; + }, [data]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + const onEditModuleLink = useCallback(() => { + setShowModal(true); + }, []); + + const onSaveLinkCallback = useCallback( + (data: any) => { + const moduleEdit = { + ...module, + name: data.name || module.name, + items: ArrayServices.updateWithIndex(module.items, 0, { + url: data.url, + order: 0, + }), + isUpdate: true, + }; + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [module, localizationSelected, profileContext.setModule] + ); + + const onDropEndLinks = useCallback( + (list: ShopListItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + const errorMessage = useMemo(() => { + if (error === "cannot-connect") { + return ( +
+
+ Connection with Shoplist could not be established at this time. Try + reloading the page or check back later. +
+
+ If there is an issue with Shoplist, this module will be hidden from + your profile until the connection is reestablished. The rest of your + profile will not be affected and you can continue making and + publishing changes as normal. +
+
+ ); + } + return "This Shoplist collection no longer exists. Please add a new collection link to display products again."; + }, [error]); + const renderContent = () => { + if (loading) { + return ; + } + if (!products.length) { + return ( + + {!!error && ( + } + closeText={} + showIcon + closable + /> + )} + {error !== "cannot-connect" && ( + + + + + + + No Products Found + + + {error === "not-found" + ? `It appears that your linked Shoplist collection no longer exists. Try adding another collection link to display products again.` + : `It appears that you have no products in this Shoplist collection right now. Try adding products on your Shoplist page or edit your collection link.`} + + + + )} + + ); + } + + return ( + +
+ + {products.length} {products.length > 1 ? "products" : "product"} + +
+ + {(dragProvided, snapshot, item, linkIndex) => { + const isRender = active ? active : linkIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && products.length > 2 && ( +
+ + +{products.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleShopList); diff --git a/interface_base/src/pages/Profile/Modules/ModuleShopMyShelf.tsx b/interface_base/src/pages/Profile/Modules/ModuleShopMyShelf.tsx new file mode 100644 index 0000000..a4fcbce --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleShopMyShelf.tsx @@ -0,0 +1,255 @@ +import { Alert, Col, Row } from "antd"; +import classNames from "classnames"; +import DragDropContext from "components/DragDropContext"; +import LoadingModule from "components/LoadingModule"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import ShopMySelfProductCard from "components/ShopMySelfProductCard"; +import ShopMyShelfEditorModal from "components/ShopMyShelfEditorModal"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + ShopMyShelfItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import ModuleEditor from "../ModuleEditor"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const ShopMySelfProductCardMemo = React.memo(ShopMySelfProductCard); + +interface ModuleShopMyShelfProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleShopMyShelf: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const item = useMemo(() => { + return module.items[0]; + }, [module.items]); + const [showModal, setShowModal] = useState(false); + const { loadingShop, thirdPartyData } = profileContext; + const loading = useMemo(() => { + return loadingShop.some((key: string) => key === module.id); + }, [loadingShop, module.id]); + const data = useMemo(() => { + return thirdPartyData.find((item: any) => item.id === module.id); + }, [thirdPartyData, module.id]); + const products = useMemo(() => data?.products || [], [data]); + + const error = useMemo(() => { + if (!data || data?.status === "success") { + return undefined; + } + return data.status; + }, [data]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + const onEditModuleLink = useCallback(() => { + setShowModal(true); + }, []); + + const onSaveLinkCallback = useCallback( + (data) => { + const moduleEdit = { + ...module, + name: data.name || module.name, + items: ArrayServices.updateWithIndex(module.items, 0, { + url: data.url, + order: 0, + }), + isUpdate: true, + }; + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [module, localizationSelected, profileContext.setModule] + ); + + const onDropEndLinks = useCallback( + (list: ShopMyShelfItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + const errorMessage = useMemo(() => { + if (error === "cannot-connect") { + return ( +
+
+ Connection with Shop My could not be established at this time. Try + reloading the page or check back later. +
+
+ If there is an issue with Shop My, this module will be hidden from + your profile until the connection is reestablished. The rest of your + profile will not be affected and you can continue making and + publishing changes as normal. +
+
+ ); + } + return "This Shop My collection no longer exists. Please add a new collection link to display products again."; + }, [error]); + const renderContent = () => { + if (loading) { + return ; + } + if (!products.length) { + return ( + + {!!error && ( + } + closeText={} + showIcon + closable + /> + )} + {error !== "cannot-connect" && ( + + + + + + + No Products Found + + + {error === "not-found" + ? `It appears that your linked Shop My collection no longer exists. Try adding another collection link to display products again.` + : `It appears that you have no products in this Shop My + collection right now. Try adding products on your Shop My + Shelf page or edit your collection link.`} + + + + )} + + ); + } + + return ( + +
+ + {products.length} {products.length > 1 ? "products" : "product"} + +
+ + {(dragProvided, snapshot, item, linkIndex) => { + const isRender = active ? active : linkIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && products.length > 2 && ( +
+ + +{products.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleShopMyShelf); diff --git a/interface_base/src/pages/Profile/Modules/ModuleShopify.tsx b/interface_base/src/pages/Profile/Modules/ModuleShopify.tsx new file mode 100644 index 0000000..68e7951 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleShopify.tsx @@ -0,0 +1,804 @@ +import { Alert, Button, Spin, Tooltip, Row, Col } from "antd"; +import { useTypedSelector } from "redux/rootReducer"; +import { SHOPIFY_MODULE_TYPE } from "constants/shopify"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import ShopifyProductCard from "components/ShopifyProductCard"; +import ShopifyStoreSelection from "components/ShopifyStoreSelection"; +import { Text } from "components/Typography"; +import { ReactComponent as InfoIcon } from "icons/info.svg"; +import { + ShopifyProductItem, + TalentModuleMixItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { isMobile } from "react-device-detect"; +import { + selectShopifyStores, + selectTriggerCheckCollection, +} from "redux/Shopify/selector"; +import { MODAL_STEPS, ShopifyStore } from "redux/Shopify/types"; +import { + selectLocalizationSelected, + selectTalentProfile, + selectTalentProfileModulesOrigin, +} from "redux/User/selector"; +import { shopifyService } from "services"; +import EmptyModule from "./EmptyModule"; + +import classNames from "classnames"; +import ShopifyModal from "components/ShopifyModal"; +import ConfirmDeleteModal from "components/ShopifyProductCard/ProductDeleteModalConfirm"; +import ShopifyStoreDomainInputModal from "components/ShopifyStoreDomainInputModal/ShopifyStoreDomainInputModal"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { ArrayServices } from "utils/array"; +import notification from "utils/notification"; +import ModuleEditor from "../ModuleEditor"; +import "./ModuleShopify.scss"; +import { triggerCheckCollectionAction } from "redux/Shopify/actions"; +import { useDispatch } from "react-redux"; +import { isEmptyModule } from "utils/modules"; +import { ModuleType } from "@komi-app/shared-types" +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const PRODUCT_TYPES = { + COLLECTION: 1, + INDIVIDUAL: 2, +}; + +const ShopifyProductCardMemo = React.memo(ShopifyProductCard); + +interface ModuleShopifyProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active?: boolean; +} + +const ModuleShopify: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const dispatch = useDispatch(); + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [items, setItems] = useState([]); + const [storeSelected, setStoreSelected] = useState(); + const [collectionList, setCollectionList] = useState([]); + const [isAdding, setIsAdding] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isShowAlert, setIsShowAlert] = useState(false); + const [productList, setProductList] = useState([]); + const [hasProducts, setHasProducts] = useState(); + const [hasCollections, setHasCollections] = useState(); + const [collectionSelected, setCollectionSelected] = useState(); + const [productsSelected, setProductsSelected] = useState< + Array + >([]); + const [availableProducts, setAvailableProducts] = useState< + Array + >([]); + const [productsAdded, setProductAdded] = useState>( + [] + ); + const [productType, setProductType] = useState(); + const [collectionName, setCollectionName] = useState(); + const [showShopifyInputModal, setShowShopifyInputModal] = useState(false); + const [visibleConfirmDeleteModal, setVisibleConfirmDeleteModal] = + useState(false); + const [visibleModal, setVisibleModal] = useState(false); + const [step, setStep] = useState(MODAL_STEPS.LOADING); + const [isFetching, setIsFetching] = useState(false); + const stores = useTypedSelector(selectShopifyStores); + const talentProfile = useTypedSelector(selectTalentProfile); + const trigger = useTypedSelector(selectTriggerCheckCollection); + const talentModulesRevert = useTypedSelector( + selectTalentProfileModulesOrigin + ); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const actualStores = useMemo( + () => stores.filter((store) => store.connected), + [stores] + ); + + const handleCheckCollection = async (store?: ShopifyStore) => { + if (!store) return; + + const client = shopifyService.createClient(store.accessToken, store.domain); + + try { + const [collections, products] = await Promise.all([ + client.collection.fetchAllWithProducts(), + client.product.fetchAll(), + ]); + + setHasCollections(!!collections.length); + setHasProducts(!!products.length); + + if (!collections.length) { + if (!products.length) { + onOpenModal(MODAL_STEPS.EMPTY); + } else { + setProductList(products); + setProductType(PRODUCT_TYPES.INDIVIDUAL); + onOpenModal(MODAL_STEPS.ADD_PRODUCT); + } + } else { + onOpenModal(MODAL_STEPS.SELECT_MODULE_TYPE); + } + } catch (e) { + console.error("[module:shopify:check-collection]", e); + + notification.error({ + message: "Something went wrong with Shopify service", + }); + } + + setIsFetching(false); + }; + + useEffect(() => { + if (!storeSelected && actualStores.length) { + setStoreSelected(actualStores[0]); + } + + /** + * When a store is added for the first time + */ + if ( + trigger && + module.type === ModuleType.SHOPIFY && + actualStores.length === 1 + ) { + setIsFetching(true); + dispatch(triggerCheckCollectionAction(false)); + + handleCheckCollection(actualStores[0]); + } + }, [trigger, module.type, actualStores, hasCollections, hasProducts]); + + const fetchData = useCallback( + async (currentModule: any) => { + try { + setIsLoading(true); + const storeDomain = currentModule.items[0]?.shop || ""; + + const store = actualStores.length + ? actualStores.filter((store) => store.domain === storeDomain)[0] + : talentProfile?.shopifyMerchants[storeDomain]; + setStoreSelected(store); + const client = shopifyService.createClient( + store.accessToken, + storeDomain + ); + if (currentModule.type === SHOPIFY_MODULE_TYPE.SHOPIFY_COLLECTION) { + const colectionId = currentModule.items[0].collectionId || ""; + const collection: any = await client.collection.fetchWithProducts( + colectionId + ); + setCollectionName(collection?.title); + setItems(collection.products); + return; + } + if (currentModule.type === SHOPIFY_MODULE_TYPE.SHOPIFY_PRODUCT) { + setProductType(PRODUCT_TYPES.INDIVIDUAL); + const productIds: any = currentModule.items[0].itemIds; + const products: any = await client.product.fetchMultiple( + productIds as string[] + ); + setItems(products); + setProductsSelected(products); + } + setIsShowAlert(false); + } catch (error) { + } finally { + setIsLoading(false); + } + }, + [actualStores] + ); + + useEffect(() => { + if (actualStores.length && module.items[0]) { + fetchData(module); + } + return () => {}; + }, [fetchData, actualStores, module]); + + const onDeleteItem = useCallback( + (item: ShopifyProductItem) => { + const itemsUpdate = module.items[0].itemIds?.filter( + (id) => id !== item.id + ); + setItems((items) => items.filter((el) => el.id !== item.id)); + profileContext.setModule({ + ...module, + items: [{ ...module.items[0], itemIds: itemsUpdate }], + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onDropEndProducts = useCallback( + (list: ShopifyProductItem[]) => { + const itemsUpdate = list.map((item) => item.id); + setItems(list); + profileContext.setModule({ + ...module, + items: [ + { ...module.items[0], itemIds: itemsUpdate }, + ] as ShopifyProductItem[], + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onOpenModal = (step: MODAL_STEPS) => { + setStep(step); + setVisibleModal(true); + }; + + const handleSubmit = useCallback(() => { + setIsFetching(true); + handleCheckCollection(storeSelected); + + setIsAdding(true); + setIsShowAlert(true); + setProductList([]); + setCollectionList([]); + setProductsSelected([]); + }, [actualStores, storeSelected]); + + const handleChangeStore = useCallback( + (store) => { + setStoreSelected(store); + }, + [setStoreSelected] + ); + + const handleAddInvidualProduct = useCallback(() => { + setIsFetching(true); + onOpenModal(MODAL_STEPS.ADD_MORE_PRODUCT); + if (module.items.length) { + const store = actualStores.filter( + (store) => store.domain === module.items[0].shop + )[0]; + setStoreSelected(store); + const client = shopifyService.createClient( + store.accessToken, + store.domain + ); + + client.product.fetchAll().then((products: any) => { + setProductList(products); + const listProductSelected = products.filter((item: any) => + module.items[0].itemIds?.some((id) => id === item.id) + ); + setProductsSelected(listProductSelected); + setProductAdded(listProductSelected); + const availableItems = products.filter( + (item: ShopifyProductItem) => + !listProductSelected?.some((product: any) => product.id === item.id) + ); + setAvailableProducts(availableItems); + setIsFetching(false); + }); + } else { + setIsFetching(false); + } + }, [isAdding, module.items, actualStores]); + + const handleSelectProductType = async () => { + setIsFetching(true); + const storeDetail = actualStores?.filter( + (store) => store.domain === storeSelected?.domain + )[0]; + + const client = shopifyService.createClient( + storeDetail.accessToken, + storeSelected ? storeSelected.domain : "" + ); + switch (productType) { + case PRODUCT_TYPES.COLLECTION: + onOpenModal(MODAL_STEPS.ADD_COLLECTION); + const collections = await client.collection.fetchAllWithProducts(); + if (!collections.length) { + const products = await client.product.fetchAll(); + setIsFetching(false); + if (!products.length) { + onOpenModal(MODAL_STEPS.EMPTY); + } else { + setProductType(PRODUCT_TYPES.INDIVIDUAL); + setProductList(products); + onOpenModal(MODAL_STEPS.ADD_PRODUCT); + } + } else { + setCollectionList(collections); + onOpenModal(MODAL_STEPS.ADD_COLLECTION); + setIsFetching(false); + } + break; + + default: + onOpenModal(MODAL_STEPS.ADD_PRODUCT); + client.product + .fetchAll() + .then((products: any) => { + if (!products.length) { + setIsFetching(false); + onOpenModal(MODAL_STEPS.EMPTY); + return; + } + setProductList(products); + setIsFetching(false); + }) + .catch((e) => console.log(e)); + break; + } + }; + + const onAddColectionCallback = useCallback(() => { + if (!collectionSelected) { + notification.error({ + message: "Please select the collection!", + }); + return; + } + const colection = collectionList.filter((collection: any) => { + return collection.id === collectionSelected; + })[0]; + + setCollectionName(colection?.title || ""); + + if (!colection?.products?.length) { + return onOpenModal(MODAL_STEPS.EMPTY); + } + + setItems(colection.products); + const moduleEdit = { + ...module, + items: [ + { + shop: storeSelected?.domain, + collectionId: collectionSelected, + }, + ], + isUpdate: true, + type: SHOPIFY_MODULE_TYPE.SHOPIFY_COLLECTION, + }; + profileContext.setModule(moduleEdit); + setVisibleModal(false); + }, [collectionSelected, module, storeSelected, profileContext.setModule]); + + const onAddProductCallBack = useCallback(() => { + if (!productsSelected.length) { + notification.error({ + message: "Please select products!", + }); + return; + } + setItems( + productsSelected.map((exp, index) => ({ + ...exp, + order: index, + })) + ); + const moduleEdit = { + ...module, + items: [ + { + ...module.items[0], + shop: storeSelected?.domain, + itemIds: productsSelected.map((item) => item.id), + } as TalentModuleMixItem, + ], + isUpdate: true, + type: SHOPIFY_MODULE_TYPE.SHOPIFY_PRODUCT, + }; + + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + setVisibleModal(false); + }, [ + module, + productsSelected, + storeSelected?.domain, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ]); + + const onLoginSuccessCallback = useCallback(() => { + setIsFetching(true); + const store = actualStores[actualStores.length - 1]; // the last store added + setStoreSelected(store); + handleCheckCollection(store); + }, [actualStores]); + + const onCreateAccountCallback = useCallback(() => { + onOpenModal(MODAL_STEPS.CREATE_ACCOUNT); + }, []); + + const onSelectProduct = useCallback( + (productSelect: ShopifyProductItem) => { + const products = + productsSelected?.filter((product) => product.id === productSelect.id) + .length > 0 + ? productsSelected.filter( + (product) => product.id !== productSelect.id + ) + : ArrayServices.unshift(productsSelected, productSelect); + setProductsSelected(products); + }, + [productsSelected, productList] + ); + + const connectToStore = useCallback(() => onOpenModal(MODAL_STEPS.LOGIN), []); + const goBackToProductType = useCallback( + () => onOpenModal(MODAL_STEPS.SELECT_MODULE_TYPE), + [] + ); + + const onReload = async () => { + const storeDetail = actualStores?.filter( + (store) => store.domain === storeSelected?.domain + )[0]; + const client = shopifyService.createClient( + storeDetail.accessToken, + storeSelected ? storeSelected.domain : "" + ); + if (productType === PRODUCT_TYPES.COLLECTION) { + const collections: any = await client.collection.fetchAllWithProducts(); + if (!collections.length) { + const products = await client.product.fetchAll(); + if (!products.length) { + return; + } + setProductType(PRODUCT_TYPES.INDIVIDUAL); + setProductList(products); + onOpenModal(MODAL_STEPS.ADD_PRODUCT); + return; + } + setCollectionList(collections); + const selected = collections.find( + (item: any) => item.id === collectionSelected + ); + if (!selected?.products?.length) { + return; + } + setCollectionName(selected?.title || ""); + + setItems(selected.products); + const moduleEdit = { + ...module, + items: [ + { + shop: storeSelected?.domain, + collectionId: selected.id, + }, + ], + isUpdate: true, + type: SHOPIFY_MODULE_TYPE.SHOPIFY_COLLECTION, + }; + profileContext.setModule(moduleEdit); + setVisibleModal(false); + return; + } + const products = await client.product.fetchAll(); + if (!products.length) { + return; + } + onOpenModal(MODAL_STEPS.ADD_PRODUCT); + setProductList(products); + setTimeout(() => { + onOpenModal(MODAL_STEPS.ADD_PRODUCT); + }, 150); + }; + + const onDeteleCollection = () => { + setVisibleConfirmDeleteModal(true); + }; + + const onConfirmDeteleCollection = useCallback(() => { + setItems([]); + profileContext.setModule({ ...module, items: [], isUpdate: true }); + setVisibleConfirmDeleteModal(false); + }, [profileContext.setModule]); + + return ( + + +
+ +
+ + ) : undefined + } + > + + {isEmptyModule(module) || + module.type === ModuleType.SHOPIFY ? ( + <> + Select your store + store?.connected === true + )} + onChange={handleChangeStore} + value={storeSelected as ShopifyStore} + onConnectToAnotherStore={connectToStore} + /> +
+ + + ) : ( + + {!!module.items.length && isShowAlert && ( + } + showIcon + closeText={ + + } + onClose={() => setIsShowAlert(false)} + /> + )} + + {module.type === SHOPIFY_MODULE_TYPE.SHOPIFY_COLLECTION ? ( +
+ {items.length ? ( + <> + + + {collectionName} + + + + + + + {items.length}{" "} + {items.length > 1 ? "products" : "product"} + + + ) : null} +
+ + {(dragProvided, snapshot, item, itemIndex) => { + return active ? ( + + + + ) : itemIndex < 2 ? ( + + + + ) : ( + <> + ); + }} + +
+
+ ) : ( + <> +
+ + + {items.length}  + {items.length > 1 ? "products added" : "product added"} + +
+ + {(dragProvided, snapshot, item, itemIndex) => { + const isRender = active ? active : itemIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + + )} + {!active && items.length > 2 && ( +
+ + +{items.length - 2} More + +
+ )} +
+ )} + + + + + + { + setVisibleModal(false); + setShowShopifyInputModal(true); + }} + selectModuleTypeCallback={handleSelectProductType} + goBackToProductTypeCallback={goBackToProductType} + /> + + {showShopifyInputModal && ( + onOpenModal(MODAL_STEPS.LOGIN_SUCCESS)} + /> + )} + + ); +}; + +export default React.memo(ModuleShopify); diff --git a/interface_base/src/pages/Profile/Modules/ModuleSmartPodcast.tsx b/interface_base/src/pages/Profile/Modules/ModuleSmartPodcast.tsx new file mode 100644 index 0000000..6cc7740 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleSmartPodcast.tsx @@ -0,0 +1,479 @@ +import { Button } from "antd"; +import PopupWindow from "components/PopupWindowSpotifyAuth"; +import copyToClipboard from "copy-to-clipboard"; + +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import classNames from "classnames"; +import DragDropContext from "components/DragDropContext"; +import PodcastCardModule from "components/PodcastCardModule"; +import PodcastLatestSyncItem from "components/PodcastLatestSyncItem"; +import PodcastModal from "components/PodcastModal"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import config from "config"; +import { PODCAST_MODAL_STEP } from "constants/module"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import Cookies from "js-cookie"; +import reduce from "lodash/reduce"; +import { + PodcastItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { + selectLocalizationSelected, + selectUserData, +} from "redux/User/selector"; +import { SpotifyUser } from "redux/User/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { spotifyService, userService } from "services"; +import { + KOMI_SPOTIFY_ACCESS_TOKEN, + KOMI_SPOTIFY_REFRESH_TOKEN, +} from "services/SpotifyService"; +import { ArrayServices } from "utils/array"; +import notification from "utils/notification"; +import { convertToUrl } from "utils/url"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import { useWindowSize } from "../../../hooks"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import { ModuleType } from "@komi-app/shared-types"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const MusicCardMemo = React.memo(PodcastCardModule); + +interface ModuleSmartPodcastProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleSmartPodcast: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const user = useTypedSelector(selectUserData); + const [modalStatus, setModalStatus] = useState<{ + visible: boolean; + status: PODCAST_MODAL_STEP; + item?: PodcastItem; + }>({ + visible: false, + status: PODCAST_MODAL_STEP.SELECT_PODCAST_TYPE, + item: undefined, + }); + const item = useMemo(() => { + return module.items[0]; + }, [module.items]); + const itemList = useMemo(() => module.items, [module.items]); + + const { loadingShop, thirdPartyData } = profileContext; + const loading = useMemo(() => { + return loadingShop.some((key: string) => key === module.id); + }, [loadingShop, module.id]); + const data = useMemo(() => { + return thirdPartyData?.find((item: any) => item.id === module.id); + }, [thirdPartyData, module.id]); + const collection = useMemo(() => data?.collection, [data]); + + const hasSpotifyPopupFix = useFeatureIsOn(FLAGS.FIX_SB_286_SPOTIFY_POPUP); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onEditSyncLatestPodcast = useCallback(() => { + setModalStatus({ + visible: true, + item: undefined, + status: PODCAST_MODAL_STEP.SELECT_PODCAST_TYPE, + }); + setTimeout(() => { + setModalStatus((values) => ({ + ...values, + item, + status: PODCAST_MODAL_STEP.SYNC_LATEST, + })); + }, 50); + }, [item]); + + const onSaveCallback = useCallback( + (data: any) => { + let moduleEdit; + if (modalStatus.item) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + modalStatus.item.order as number, + { ...modalStatus.item, ...data } + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: [{ ...data, order: 0 }, ...module.items].map( + (el, index: number) => ({ ...el, order: index }) + ), + isUpdate: true, + type: + modalStatus.status === PODCAST_MODAL_STEP.SYNC_LATEST + ? ModuleType.PODCAST_AUTOMATION + : ModuleType.PODCAST, + }; + + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps( + modalStatus.status === PODCAST_MODAL_STEP.SYNC_LATEST + ? ModuleType.PODCAST_AUTOMATION + : ModuleType.PODCAST, + localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + profileContext.setModule(moduleEdit); + }, + [module, localizationSelected, profileContext.setModule, modalStatus] + ); + + const onDropEndPodcasts = useCallback( + (list: PodcastItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onAddPodcastModule = useCallback( + (isIndividual: boolean) => () => { + setModalStatus({ + visible: true, + status: PODCAST_MODAL_STEP.SELECT_PODCAST_TYPE, + item: undefined, + }); + if (isIndividual) { + setTimeout(() => { + setModalStatus({ + visible: true, + status: PODCAST_MODAL_STEP.INDIVIDUAL, + item: undefined, + }); + }, 100); + } + }, + [] + ); + + const onEditIndividual = useCallback((podcast: PodcastItem) => { + setModalStatus({ + visible: true, + status: PODCAST_MODAL_STEP.SELECT_PODCAST_TYPE, + item: undefined, + }); + setTimeout(() => { + setModalStatus({ + visible: true, + status: PODCAST_MODAL_STEP.INDIVIDUAL, + item: podcast, + }); + }, 100); + }, []); + + const onDeletePodcast = useCallback( + (podcast: PodcastItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== podcast.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [] + ); + + profileContext.setModule({ + ...module, + items, + isUpdate: true, + type: !items.length + ? ModuleType.PODCAST_SELECT + : module.type, + }); + }, + [module, profileContext.setModule] + ); + + const handleRefreshToken = async () => { + try { + const result = await userService.spotifyRefreshToken(); + + if (result && "ok" in result) { + spotifyService.signin(result.response); + return true; + } else { + throw new Error("Cann't refresh token spotify."); + } + } catch (ex: any) { + const signIn = await handleSpotifySignIn(); + return signIn; + } + }; + + const handleSpotifySignIn = async () => { + return new Promise(async (resolve) => { + if (hasSpotifyPopupFix) { + const popup = PopupWindow.open("spotify-authorization", "about:blank", { + height: 1000, + width: 600, + }); + + const result = await userService.spotifyConnect({ + callbackUrl: window.location.href, + }); + if (!!result && "ok" in result) { + popup.navigate(result.response.url); + + popup.then( + (data: SpotifyUser) => { + spotifyService.signin(data); + resolve(true); + }, + (error: any) => { + resolve(false); + console.error(error); + } + ); + } + } else { + const result = await userService.spotifyConnect({ + callbackUrl: window.location.href, + }); + if (!!result && "ok" in result) { + const popup = PopupWindow.open( + "spotify-authorization", + result.response.url, + { + height: 1000, + width: 600, + } + ); + + popup.then( + (data: SpotifyUser) => { + spotifyService.signin(data); + resolve(true); + }, + (error: any) => { + resolve(false); + console.error(error); + } + ); + } + } + }); + }; + + const handleConnectSpotify = async () => { + const token = Cookies.get(KOMI_SPOTIFY_ACCESS_TOKEN); + if (token) { + return true; + } + if ( + user?.spotifyRefreshToken || + user?.thirdPartyInfo?.spotifyRefreshToken || + Cookies.get(KOMI_SPOTIFY_REFRESH_TOKEN) + ) { + const result = await handleRefreshToken(); + return result; + } else { + const result = await handleSpotifySignIn(); + return result; + } + }; + + const onBeforeEditProduct = useCallback(async (podcast: PodcastItem) => { + onEditIndividual(podcast); + }, []); + const onCopySmartLink = useCallback( + (podcast: PodcastItem) => { + const komiLink = + config.service.env === "development" + ? `${user?.username}-dev.komi.io` + : config.service.env === "staging" + ? `${user?.username}-staging.komi.io` + : `${user?.username}.komi.io`; + const link = `${komiLink}/podcast/${ + podcast.customUrl + ? podcast.customUrl + : convertToUrl(podcast.metadata?.name) + }`; + copyToClipboard(link); + notification.success({ + message: + "The smart link has been copied to clipboard. Your updates need to be published before the link can be accessed.", + }); + }, + [user] + ); + + const renderContent = () => { + if (!module.items.length) { + return ( + + + + ); + } + + if (module.type === ModuleType.PODCAST_AUTOMATION) { + return ( + + ); + } + + const { width } = useWindowSize(); + + const isMobileStyleChangesEnabled = width && width <= 768; + + return ( + +
+ + + {itemList.length}  + {itemList.length > 1 ? "podcasts added" : "podcast added"} + +
+ + + {(dragProvided, snapshot, item, productIndex) => { + const isRender = active ? active : productIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && itemList.length > 2 && ( +
+ + +{itemList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleSmartPodcast); diff --git a/interface_base/src/pages/Profile/Modules/ModuleText.tsx b/interface_base/src/pages/Profile/Modules/ModuleText.tsx new file mode 100644 index 0000000..4f99bd5 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleText.tsx @@ -0,0 +1,257 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Col, Row } from "antd"; + +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { useModules } from "hooks/useModules"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import reduce from "lodash/reduce"; +import ModuleEditor from "../ModuleEditor"; +import { useWindowSize } from "../../../hooks"; +import { + TalentProfileModule, + TextItem, +} from "../../../models/talent/talent-profile-module.model"; +import TextModal from "components/TextModal"; +import TextCardModule from "components/TextCardModule"; + +interface ModuleTextProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleText: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const profileContext = useModules(); + + const [modalVisible, setModalVisible] = useState(false); + const [modalItem, setModalItem] = useState(undefined); + + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + + const itemList = useMemo(() => module.items, [module.items]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule?.(moduleEdit); + }, + [profileContext], + ); + + const onDropEnd = useCallback( + (list: TextItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback], + ); + + const onAddItemClicked = useCallback(() => { + setModalItem(undefined); + setModalVisible(true); + }, []); + + const onEditItemClicked = useCallback((textItem: TextItem) => { + setModalItem(textItem); + setModalVisible(true); + }, []); + + const onAddTextItem = useCallback( + (textItem: TextItem) => { + onEditModuleCallback({ + ...module, + items: [textItem, ...module.items].map((item, index) => ({ + ...item, + order: index, + })), + isUpdate: true, + }); + + setModalVisible(false); + }, + [module, onEditModuleCallback], + ); + + const onEditTextItem = useCallback( + (existingItem: TextItem, newItem: TextItem) => { + const items = module.items.map((item) => { + if (item.order === existingItem.order) { + return { ...item, ...newItem }; + } + return item; + }); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + + setModalVisible(false); + setModalItem(undefined); + }, + [module, onEditModuleCallback], + ); + + const onDeleteTextItem = useCallback( + (textItem: TextItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== textItem.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [], + ); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + }, + [module, profileContext.setModule], + ); + + const renderContent = () => { + if (!module.items.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + return ( + + + + + + + + {itemList.length}  + {itemList.length > 1 ? "item added" : "items added"} + + + + + {(dragProvided, snapshot, item, textIndex) => { + const isRender = active ? active : textIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && itemList.length > 2 && ( +
+ + +{itemList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleText); diff --git a/interface_base/src/pages/Profile/Modules/ModuleTikTok.tsx b/interface_base/src/pages/Profile/Modules/ModuleTikTok.tsx new file mode 100644 index 0000000..e17c87a --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleTikTok.tsx @@ -0,0 +1,275 @@ +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { Button, Col, Row } from "antd"; +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import TikTokModal from "components/TikTokModal"; +import TikTokVideoCardModule from "components/TikTokVideoCardModule"; +import { Text } from "components/Typography"; +import { useModules } from "hooks/useModules"; +import reduce from "lodash/reduce"; +import { + TalentProfileModule, + TikTokItem, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useHistory } from "react-router-dom"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import { useWindowSize } from "../../../hooks"; +import ModuleEditor from "../ModuleEditor"; +import { routes, ShortVideoPage } from "../AddShortVideo/routes"; + +interface ModuleTikTokProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleTikTok: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const profileContext = useModules(); + const [modalVisible, setModalVisible] = useState(false); + const [modalItem, setModalItem] = useState(undefined); + const isAutomationEnabled = useFeatureIsOn(FLAGS.SHORT_VIDEO_AUTOMATION); + const navigate = useHistory(); + + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + + const tiktokList = useMemo(() => module.items, [module.items]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule?.(moduleEdit); + }, + [profileContext] + ); + + const onDropEndTikTokVideos = useCallback( + (list: TikTokItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const editModule = useCallback(() => { + navigate.push( + `/admin/modules/tiktok/${routes[ShortVideoPage.MANUAL_ENTRY]}?id=${module.id}` + ); + }, [module.id, navigate]); + + const onAddVideoClicked = useCallback(() => { + if (isAutomationEnabled) { + editModule(); + return; + } + + setModalItem(undefined); + setModalVisible(true); + }, []); + + const onEditVideoClicked = useCallback( + (tiktok: TikTokItem) => { + if (isAutomationEnabled) { + editModule(); + return; + } + + setModalItem(tiktok); + setModalVisible(true); + }, + [setModalVisible] + ); + + const onAddTikTokVideo = useCallback( + (url: string) => { + onEditModuleCallback({ + ...module, + items: [{ url, order: module.items.length }, ...module.items], + isUpdate: true, + }); + + setModalVisible(false); + }, + [module, onEditModuleCallback] + ); + + const onEditTikTokVideo = useCallback( + (existingItem: TikTokItem, url: string) => { + const items = module.items.map((item) => { + if (item.order === existingItem.order) { + return { ...item, url }; + } + return item; + }); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + + setModalVisible(false); + setModalItem(undefined); + }, + [module, onEditModuleCallback] + ); + + const onDeleteTikTokVideo = useCallback( + (tiktok: TikTokItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== tiktok.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [] + ); + + onEditModuleCallback({ + ...module, + items, + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const renderContent = () => { + if (!module.items.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + return ( + + + + + + + + {tiktokList.length}  + {tiktokList.length > 1 ? "videos added" : "video added"} + + + + + {(dragProvided, snapshot, item, tiktokIndex) => { + const isRender = active ? active : tiktokIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && tiktokList.length > 2 && ( +
+ + +{tiktokList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleTikTok); diff --git a/interface_base/src/pages/Profile/Modules/ModuleTikTokAutomation.tsx b/interface_base/src/pages/Profile/Modules/ModuleTikTokAutomation.tsx new file mode 100644 index 0000000..eeaf7d4 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleTikTokAutomation.tsx @@ -0,0 +1,40 @@ +import { + TalentProfileModule, + TikTokAutomationItem, +} from "models/talent/talent-profile-module.model"; +import React, { useMemo } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import ModuleTikTokSemiAutomated from "./ModuleTikTokSemiAutomated"; +import ModuleTikTokFullyAutomated from "./ModuleTikTokFullyAutomated"; +import { AutomationMode } from "@komi-app/profiles-sdk"; + +export interface ModuleTikTokAutomationProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleTikTokAutomation: React.FC = ( + props +) => { + const { module } = props; + + const SubComponent = useMemo(() => { + if (module.items.length === 0) { + return null; + } + + if (module.items[0].mode === AutomationMode.SEMIAUTO) { + return ModuleTikTokSemiAutomated; + } + return ModuleTikTokFullyAutomated; + }, [module.items]); + + if (!SubComponent) { + return null; + } + + return ; +}; + +export default React.memo(ModuleTikTokAutomation); diff --git a/interface_base/src/pages/Profile/Modules/ModuleTikTokFullyAutomated.tsx b/interface_base/src/pages/Profile/Modules/ModuleTikTokFullyAutomated.tsx new file mode 100644 index 0000000..3d4cfdc --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleTikTokFullyAutomated.tsx @@ -0,0 +1,175 @@ +import { Message, MessageSemantic, Semantic } from "@komi-app/creator-ui"; +import When from "@komi-app/when"; +import Each from "@komi-app/each"; +import { Button, Col, Row } from "antd"; +import TikTokVideoCardModule from "components/TikTokVideoCardModule"; +import { Text } from "components/Typography"; +import { useTikTokAccounts } from "hooks/integrations/useTikTokAccounts"; +import React, { useCallback, useMemo } from "react"; +import { useHistory } from "react-router-dom"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import ModuleEditor from "../ModuleEditor"; +import { ModuleTikTokAutomationProps } from "./ModuleTikTokAutomation"; +import { useModules } from "hooks/useModules"; +import { getShortVideoService } from "services"; +import { routes, ShortVideoPage } from "../AddShortVideo/routes"; +import { ReconnectTikTokPrompt } from "components/ReconnectTikTokPrompt"; + +const ModuleTikTokFullyAutomated: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const automationItem = module.items.length > 0 ? module.items[0] : undefined; + + const navigate = useHistory(); + const { removeModule } = useModules(); + + const tiktokList = useMemo( + () => automationItem?.links || [], + [automationItem?.links] + ); + + const { data: accounts, isLoading } = useTikTokAccounts(); + const moduleAccount = useMemo( + () => + accounts?.find( + (account) => account.platformId === automationItem?.tiktokAccountId + ), + [accounts, automationItem?.tiktokAccountId] + ); + const accountUsername = moduleAccount?.name || "TikTok"; + const accountDisconnected = !isLoading && !moduleAccount; + + const onEditModule = useCallback(() => { + navigate.push( + `/admin/modules/tiktok${routes[ShortVideoPage.AUTOMATION_EDIT]}?id=${module.id}` + ); + }, [module.id, navigate]); + + const onDeleteModule = useCallback(() => { + removeModule && removeModule(module); + }, [module, removeModule]); + + const onAddVideos = useCallback(() => { + onDeleteModule(); + navigate.push(`/admin/modules/tiktok`); + }, [onDeleteModule, navigate]); + + const renderContent = () => { + if (!module.items.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + return ( + + } + otherwise={ + + } + /> + + + + + Latest Videos + + + + + + +
+ + + + {accountUsername} + + + + + {tiktokList.length}  + {tiktokList.length > 1 ? "videos added" : "video added"} + + + + ( +
+ +
+ )} + /> + {!active && tiktokList.length > 2 && ( +
+ + +{tiktokList.length - 2} More + +
+ )} +
+
+ ); + }; + + return ( + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleTikTokFullyAutomated); diff --git a/interface_base/src/pages/Profile/Modules/ModuleTikTokSemiAutomated.tsx b/interface_base/src/pages/Profile/Modules/ModuleTikTokSemiAutomated.tsx new file mode 100644 index 0000000..99161ca --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleTikTokSemiAutomated.tsx @@ -0,0 +1,260 @@ +import { Button, Col, Row } from "antd"; +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { useModules } from "hooks/useModules"; +import { + TalentProfileModule, + TikTokAutomationItem, + TikTokItem, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import ModuleEditor from "../ModuleEditor"; +import TikTokVideoCardModule from "components/TikTokVideoCardModule"; +import { useHistory } from "react-router-dom"; +import { ModuleTikTokAutomationProps } from "./ModuleTikTokAutomation"; +import { useWindowSize } from "hooks"; +import { routes, ShortVideoPage } from "../AddShortVideo/routes"; + +const ModuleTikTokSemiAutomated: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const automationItem = module.items.length > 0 ? module.items[0] : undefined; + const profileContext = useModules(); + const navigate = useHistory(); + + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + + const tiktokList = useMemo( + () => automationItem?.links || [], + [automationItem?.links] + ); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule?.(moduleEdit); + }, + [profileContext] + ); + + const onDropEndTikTokVideos = useCallback( + (list: TikTokItem[]) => { + const newItem: TikTokAutomationItem = { + ...module.items[0], + links: list.map((item, index) => ({ ...item, order: index })), + }; + + onEditModuleCallback({ + ...module, + items: [newItem], + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onAddVideoClicked = useCallback(() => { + navigate.push( + `/admin/modules/tiktok${routes[ShortVideoPage.AUTOMATION_EDIT]}?id=${module.id}&accountId=${automationItem?.tiktokAccountId}` + ); + }, [module.id, navigate, automationItem?.tiktokAccountId]); + + const onDeleteTikTokVideo = useCallback( + (tiktok: TikTokItem) => { + if (!automationItem) return; + + const newLinks = automationItem.links + .filter((item) => item.order !== tiktok.order) + .map((item, index) => ({ ...item, order: index })); + + onEditModuleCallback({ + ...module, + items: [ + { + ...automationItem, + links: newLinks, + }, + ], + isUpdate: true, + }); + }, + [module, profileContext.setModule, automationItem] + ); + + const onVisibilityChange = useCallback( + (item: TikTokItem) => { + if (!automationItem) return; + + const newLinks = automationItem.links.map((link) => { + if (link.order === item.order) { + return { ...link, visible: !link.visible }; + } + return link; + }); + + const allVideosHidden = newLinks.every((link) => !link.visible); + + onEditModuleCallback({ + ...module, + items: [ + { + ...automationItem, + links: newLinks, + }, + ], + isUpdate: true, + visible: !allVideosHidden, + }); + }, + [module, profileContext.setModule, automationItem] + ); + + useEffect(() => { + if (!automationItem || !module.visible) return; + + const allVideosHidden = automationItem.links.every((link) => !link.visible); + + // If all videos are hidden and the module is visible, show all videos + if (allVideosHidden) { + onEditModuleCallback({ + ...module, + isUpdate: true, + items: [ + { + ...automationItem, + links: automationItem.links.map((link) => ({ + ...link, + visible: true, + })), + }, + ], + }); + } + }, [module.visible, automationItem, onVisibilityChange]); + + const renderContent = () => { + if (!module.items.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + + return ( + + + + + + + + {tiktokList.length}  + {tiktokList.length > 1 ? "videos added" : "video added"} + + + + + {(dragProvided, snapshot, item, tiktokIndex) => { + const isRender = active ? active : tiktokIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && tiktokList.length > 2 && ( +
+ + +{tiktokList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleTikTokSemiAutomated); diff --git a/interface_base/src/pages/Profile/Modules/ModuleYoutube.tsx b/interface_base/src/pages/Profile/Modules/ModuleYoutube.tsx new file mode 100644 index 0000000..918025b --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleYoutube.tsx @@ -0,0 +1,359 @@ +import { Button, Col, Row } from "antd"; +import DragDropContext from "components/DragDropContext"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import YoutubeVideoCardModule from "components/YoutubeVideoCardModule"; +import YoutubeModal from "components/YoututeModal"; +import { YOUTUBE_MODAL_STEP } from "constants/module"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + TalentProfileModule, + YoutubeItem, + YoutubeVideoItem, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { Icon } from "../../../components/Icon"; +import { Paragraph } from "../../../components/Typography"; +import reduce from "lodash/reduce"; +import ModuleEditor from "../ModuleEditor"; +import YoutubeCollectionItem from "components/YoutubeCollectionItem"; +import { useWindowSize } from "../../../hooks"; +import { ModuleType } from "@komi-app/shared-types"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +interface ModuleYoutubeProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleYoutube: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const [modalStatus, setModalStatus] = useState<{ + visible: boolean; + status: YOUTUBE_MODAL_STEP; + item?: YoutubeItem; + }>({ + visible: false, + status: YOUTUBE_MODAL_STEP.SELECT_YOUTUBE_TYPE, + item: undefined, + }); + const item = useMemo(() => { + return module.items[0]; + }, [module.items]); + + const { width } = useWindowSize(); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + const isMobileStyleChangesEnabled = width && width <= 768; + + const youtubeList = useMemo(() => module.items, [module.items]); + + const { loadingShop, thirdPartyData } = profileContext; + // console.log("Youtube Module : ", JSON.stringify(module)) + const loading = useMemo(() => { + return loadingShop.some((key: string) => key === module.id); + }, [loadingShop, module.id]); + const data = useMemo(() => { + return thirdPartyData?.find((item: any) => item.id === module.id); + }, [thirdPartyData, module.id]); + const collection = useMemo(() => data?.collection, [data]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onEditYoutubeVideo = useCallback((youtube: YoutubeItem) => { + setModalStatus({ + visible: true, + item: undefined, + status: YOUTUBE_MODAL_STEP.SELECT_YOUTUBE_TYPE, + }); + setTimeout(() => { + setModalStatus({ + visible: true, + item: youtube, + status: YOUTUBE_MODAL_STEP.INDIVIDUAL_VIDEO, + }); + }, 50); + }, []); + const onAddYoutubeVideo = useCallback(() => { + setModalStatus({ + visible: true, + item: undefined, + status: YOUTUBE_MODAL_STEP.SELECT_YOUTUBE_TYPE, + }); + setTimeout(() => { + setModalStatus((values) => ({ + ...values, + status: YOUTUBE_MODAL_STEP.INDIVIDUAL_VIDEO, + })); + }, 50); + }, []); + const onEditYoutubeCollection = useCallback(() => { + setModalStatus({ + visible: true, + item: undefined, + status: YOUTUBE_MODAL_STEP.SELECT_YOUTUBE_TYPE, + }); + setTimeout(() => { + setModalStatus((values) => ({ + ...values, + item, + status: YOUTUBE_MODAL_STEP.VIDEO_COLLECTION, + })); + }, 50); + }, [item]); + + const onSaveYoutubeVideoCallback = useCallback( + (data: any) => { + let moduleEdit; + if (modalStatus.item) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + modalStatus.item.order as number, + { ...modalStatus.item, ...data } + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: [{ ...data, order: 0 }, ...module.items].map( + (el, index: number) => ({ ...el, order: index }) + ), + isUpdate: true, + type: + modalStatus.status === YOUTUBE_MODAL_STEP.VIDEO_COLLECTION + ? ModuleType.YOUTUBE_COLLECTION + : ModuleType.YOUTUBE_VIDEO, + }; + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + } + profileContext.setModule(moduleEdit); + }, + [module, localizationSelected, profileContext.setModule, modalStatus] + ); + + const onDropEndYoutubeVideos = useCallback( + (list: YoutubeVideoItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + }, + [module, onEditModuleCallback] + ); + + const onAddYoutubeModule = useCallback(() => { + setModalStatus({ + visible: true, + status: YOUTUBE_MODAL_STEP.SELECT_YOUTUBE_TYPE, + item: undefined, + }); + }, []); + + const onDeleteYoutubeVideo = useCallback( + (youtube: YoutubeItem) => { + const items = reduce( + module.items, + (results: any, item: any) => { + if (item.order !== youtube.order) { + return [...results, { ...item, order: results.length }]; + } + return results; + }, + [] + ); + + profileContext.setModule({ + ...module, + items, + isUpdate: true, + type: !items.length ? ModuleType.YOUTUBE : module.type, + }); + }, + [module, profileContext.setModule] + ); + + const renderContent = () => { + if (!module.items.length) { + return ( + + + + + + + + Nothing Here Yet + + + Click the button below to get started + + + + + + + + + + ); + } + if (module.type === ModuleType.YOUTUBE_COLLECTION) { + return ( + + ); + } + return ( + + + + + + + + {youtubeList.length}  + {youtubeList.length > 1 ? "videos added" : "video added"} + + + + + {(dragProvided, snapshot, item, youtubeIndex) => { + const isRender = active ? active : youtubeIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && youtubeList.length > 2 && ( +
+ + +{youtubeList.length - 2} More + +
+ )} +
+ ); + }; + + return ( + + + + {renderContent()} + + + ); +}; + +export default React.memo(ModuleYoutube); diff --git a/interface_base/src/pages/Profile/Modules/ModuleYoutubeShort.tsx b/interface_base/src/pages/Profile/Modules/ModuleYoutubeShort.tsx new file mode 100644 index 0000000..b5972d4 --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleYoutubeShort.tsx @@ -0,0 +1,266 @@ +import Button from "antd/lib/button"; +import { Col, Row } from "antd/lib/grid"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + TalentProfileModule, + YoutubeItem, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { isEmptyModule } from "utils/modules"; +import YoutubeShortCardModule from "../../../components/YoutubeShortCardModule"; +import YoutubeShortIndividualVideoEditorModal from "../../../components/YoutubeShortIndividualVideoEditorModal/YoutubeShortIndividualVideoEditorModal"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import { useWindowSize } from "../../../hooks"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +interface ModuleYoutubeShortProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleYoutubeShort: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [youtubeList, setYoutubeList] = useState([]); + const [showModal, setShowModal] = useState(false); + const [youtubeEditState, setYoutubeEditState] = useState<{ + isEdit: boolean; + youtube?: YoutubeItem; + youtubeEditIndex?: number; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + useEffect(() => { + setYoutubeList( + module.items.map((youtube, index) => ({ + ...youtube, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onAddYoutube = useCallback(() => { + setYoutubeEditState((preState) => ({ + ...preState, + isEdit: false, + youtube: undefined, + youtubeIndex: undefined, + })); + setShowModal(true); + }, []); + + const onEditYoutube = useCallback((youtube: YoutubeItem) => { + setYoutubeEditState((preState) => ({ + ...preState, + isEdit: true, + youtube: youtube, + youtubeIndex: youtube.order, + })); + + setShowModal(true); + }, []); + + const onDeleteYoutube = useCallback( + (youtube: YoutubeItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + youtube.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveYoutubeCallback = useCallback( + (youtube: YoutubeItem) => { + let moduleEdit; + if (youtubeEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + youtube.order as number, + youtube + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, youtube), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [ + module, + youtubeEditState, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ] + ); + + const onDropEndYoutubes = useCallback( + (list: YoutubeItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + setYoutubeList(list.map((item, index) => ({ ...item, order: index }))); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + return ( + + + + {isEmptyModule(module) ? ( + + + + ) : ( + + + + + + + + {youtubeList.length}  + {youtubeList.length > 1 ? "videos added" : "video added"} + + + + + {(dragProvided, snapshot, item, youtubeIndex) => { + const isRender = active ? active : youtubeIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && youtubeList.length > 2 && ( +
+ + +{youtubeList.length - 2} More + +
+ )} +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleYoutubeShort); diff --git a/interface_base/src/pages/Profile/Modules/ModuleYoutubeVideo.tsx b/interface_base/src/pages/Profile/Modules/ModuleYoutubeVideo.tsx new file mode 100644 index 0000000..b9e1dcf --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/ModuleYoutubeVideo.tsx @@ -0,0 +1,266 @@ +import Button from "antd/lib/button"; +import { Col, Row } from "antd/lib/grid"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import { Text } from "components/Typography"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { + TalentProfileModule, + YoutubeVideoItem, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { useTypedSelector } from "redux/rootReducer"; +import { selectLocalizationSelected } from "redux/User/selector"; +import { ArrayServices } from "utils/array"; +import { isEmptyModule } from "utils/modules"; +import YoutubeVideoCardModule from "../../../components/YoutubeVideoCardModule"; +import YoutubeVideoEditorModal from "../../../components/YoutubeVideoEditorModal/YoutubeVideoEditorModal"; +import ModuleEditor from "../ModuleEditor"; +import EmptyModule from "./EmptyModule"; +import { useWindowSize } from "../../../hooks"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +interface ModuleYoutubeVideoProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active: boolean; +} + +const ModuleYoutubeVideo: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [youtubeList, setYoutubeList] = useState([]); + const [showModal, setShowModal] = useState(false); + const [youtubeEditState, setYoutubeEditState] = useState<{ + isEdit: boolean; + youtube?: YoutubeVideoItem; + youtubeEditIndex?: number; + }>({ + isEdit: false, + }); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const { width } = useWindowSize(); + const isMobileStyleChangesEnabled = width && width <= 768; + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + + useEffect(() => { + setYoutubeList( + module.items.map((youtube, index) => ({ + ...youtube, + order: index, + })) + ); + return () => {}; + }, [module.items, module.visible]); + + const onEditModuleCallback = useCallback( + (moduleEdit: TalentProfileModule) => { + profileContext.setModule(moduleEdit); + }, + [profileContext.setModule] + ); + + const onAddYoutube = useCallback(() => { + setYoutubeEditState((preState) => ({ + ...preState, + isEdit: false, + youtube: undefined, + youtubeIndex: undefined, + })); + setShowModal(true); + }, []); + + const onEditYoutube = useCallback((youtube: YoutubeVideoItem) => { + setYoutubeEditState((preState) => ({ + ...preState, + isEdit: true, + youtube: youtube, + youtubeIndex: youtube.order, + })); + + setShowModal(true); + }, []); + + const onDeleteYoutube = useCallback( + (youtube: YoutubeVideoItem) => { + profileContext.setModule({ + ...module, + items: ArrayServices.removeWithIndex( + module.items, + youtube.order as number + ), + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onSaveYoutubeCallback = useCallback( + (youtube: YoutubeVideoItem) => { + let moduleEdit; + if (youtubeEditState.isEdit) { + moduleEdit = { + ...module, + items: ArrayServices.updateWithIndex( + module.items, + youtube.order as number, + youtube + ), + isUpdate: true, + }; + } else { + moduleEdit = { + ...module, + items: ArrayServices.unshift(module.items, youtube), + isUpdate: true, + }; + } + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + onToggleModal(false); + }, + [ + module, + youtubeEditState, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ] + ); + + const onDropEndYoutubes = useCallback( + (list: YoutubeVideoItem[]) => { + onEditModuleCallback({ + ...module, + items: list.map((item, index) => ({ ...item, order: index })), + isUpdate: true, + }); + setYoutubeList(list.map((item, index) => ({ ...item, order: index }))); + }, + [module, onEditModuleCallback] + ); + + const onToggleModal = useCallback((value: boolean) => { + setShowModal(value); + }, []); + + return ( + + + + {isEmptyModule(module) ? ( + + + + ) : ( + + + + + + + + {youtubeList.length}  + {youtubeList.length > 1 ? "videos added" : "video added"} + + + + + {(dragProvided, snapshot, item, youtubeIndex) => { + const isRender = active ? active : youtubeIndex < 2; + + return isRender ? ( + + + + ) : ( + <> + ); + }} + + {!active && youtubeList.length > 2 && ( +
+ + +{youtubeList.length - 2} More + +
+ )} +
+ )} +
+
+ ); +}; + +export default React.memo(ModuleYoutubeVideo); diff --git a/interface_base/src/pages/Profile/Modules/RyeModuleShopify.tsx b/interface_base/src/pages/Profile/Modules/RyeModuleShopify.tsx new file mode 100644 index 0000000..c5841af --- /dev/null +++ b/interface_base/src/pages/Profile/Modules/RyeModuleShopify.tsx @@ -0,0 +1,920 @@ +import { Alert, Button, Spin, Tooltip, Row, Col } from "antd"; +import { useTypedSelector } from "redux/rootReducer"; +import { RYE_SHOPIFY_MODULE_TYPE } from "constants/shopify"; +import DragDropContext from "components/DragDropContext"; +import { Icon } from "components/Icon"; +import PortalComponent from "components/PortalComponent/PortalComponent"; +import ShopifyProductCard from "components/ShopifyProductCard"; +import ShopifyStoreSelection from "components/ShopifyStoreSelection"; +import { Text } from "components/Typography"; +import { ReactComponent as InfoIcon } from "icons/info.svg"; +import { + ShopifyProductItem, + TalentModuleMixItem, + TalentProfileModule, +} from "models/talent/talent-profile-module.model"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { isMobile } from "react-device-detect"; +import { selectRyeTriggerCheckCollection } from "redux/RyeShopify/selector"; +import { + selectLocalizationSelected, + selectTalentProfile, + selectTalentProfileModulesOrigin, +} from "redux/User/selector"; +import { ryeShopifyService } from "services"; +import EmptyModule from "./EmptyModule"; + +import classNames from "classnames"; +import ConfirmDeleteModal from "components/ShopifyProductCard/ProductDeleteModalConfirm"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useModules } from "hooks/useModules"; +import { ArrayServices } from "utils/array"; +import notification from "utils/notification"; +import ModuleEditor from "../ModuleEditor"; +import "./ModuleShopify.scss"; +import { triggerCheckCollectionAction } from "redux/Shopify/actions"; +import { useDispatch } from "react-redux"; +import { isEmptyModule } from "utils/modules"; +import { selectRyeShopifyStores } from "redux/RyeShopify/selector"; +import { RYE_MODAL_STEPS, RyeShopifyStore } from "redux/RyeShopify/types"; +import RyeShopifyModal from "components/RyeShopifyModal"; +import RyeShopifyStoreDomainInputModal from "components/RyeShopifyStoreDomainInputModal"; +import { NormalizedCollection, PageInfo } from "services/ryeShopifyServices"; + +import { ModuleType } from "@komi-app/shared-types" +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; +import { trackingProps } from "utils/tracking"; + +const PRODUCT_TYPES = { + COLLECTION: 1, + INDIVIDUAL: 2, +}; + +const ShopifyProductCardMemo = React.memo(ShopifyProductCard); + +interface ModuleShopifyProps { + module: TalentProfileModule; + dragHandleProps?: DraggableProvidedDragHandleProps; + active?: boolean; +} + +const RyeModuleShopify: React.FC = ({ + module, + dragHandleProps, + active, +}) => { + const dispatch = useDispatch(); + const { sendSegmentEvent } = useAnalytics(); + const profileContext = useModules(); + const [items, setItems] = useState([]); + const [storeSelected, setStoreSelected] = useState(); + const [collectionPages, setCollectionPages] = useState< + { collections: NormalizedCollection[]; pageInfo: PageInfo }[] + >([]); + const collectionList = collectionPages.flatMap((x) => x.collections); + const [isAdding, setIsAdding] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isShowAlert, setIsShowAlert] = useState(false); + const [productPages, setProductPages] = useState([]); + const [hasProducts, setHasProducts] = useState(); + const productList = productPages.flat(); + const [hasCollections, setHasCollections] = useState(); + const [collectionSelected, setCollectionSelected] = useState(); + const [productsSelected, setProductsSelected] = useState< + Array + >([]); + + const productsAdded: Array = productList.filter( + (item: any) => module.items[0]?.itemIds?.some((id) => id === item.id) + ); + const availableProducts: Array = productList.filter( + (item: ShopifyProductItem) => + !productsAdded?.some((product: any) => product.id === item.id) + ); + + const [productType, setProductType] = useState(); + const [collectionName, setCollectionName] = useState(); + const [showShopifyInputModal, setShowShopifyInputModal] = useState(false); + const [visibleConfirmDeleteModal, setVisibleConfirmDeleteModal] = + useState(false); + const [visibleModal, setVisibleModal] = useState(false); + const [step, setStep] = useState(RYE_MODAL_STEPS.LOADING); + const [isFetching, setIsFetching] = useState(false); + const stores = useTypedSelector(selectRyeShopifyStores); + const talentProfile = useTypedSelector(selectTalentProfile); + const trigger = useTypedSelector(selectRyeTriggerCheckCollection); + const talentModulesRevert = useTypedSelector( + selectTalentProfileModulesOrigin + ); + const localizationSelected = useTypedSelector(selectLocalizationSelected); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const elementCreate = createTracker(CreatorEvent.ELEMENT_CREATED); + const viewShopifyConnectionFlow = createTracker(CreatorEvent.VIEW_SHOPIFY_CONNECTION_FLOW); + const shopifyConnectionFlowCompleted = createTracker(CreatorEvent.SHOPIFY_CONNECTION_FLOW_COMPLETED); + + const actualStores = useMemo( + () => stores.filter((store) => store.connected), + [stores] + ); + + const handleCheckRyeCollection = async (store?: RyeShopifyStore) => { + if (!store) return; + + try { + const [collectionsResponse, productsResponse] = await Promise.all([ + ryeShopifyService().fetchCollections(store.domain), + ryeShopifyService().fetchProducts(store.domain), + ]); + + setHasCollections(!!collectionsResponse.collections.length); + setHasProducts(!!productsResponse.length); + + if (!collectionsResponse.collections.length) { + if (!productsResponse.length) { + onOpenModal(RYE_MODAL_STEPS.EMPTY); + } else { + setProductPages([productsResponse]); + setProductType(PRODUCT_TYPES.INDIVIDUAL); + onOpenModal(RYE_MODAL_STEPS.ADD_PRODUCT); + } + } else { + onOpenModal(RYE_MODAL_STEPS.SELECT_MODULE_TYPE); + } + } catch (e) { + console.error("[module:rye-shopify:check-collection]", e); + + notification.error({ + message: "Something went wrong with Shopify service", + }); + } + + setIsFetching(false); + }; + + useEffect(() => { + if (!storeSelected && actualStores.length) { + setStoreSelected(actualStores[0]); + } + + /** + * When a store is added for the first time + */ + if ( + trigger && + module.type === ModuleType.RYE_SHOPIFY && + actualStores.length === 1 + ) { + setIsFetching(true); + dispatch(triggerCheckCollectionAction(false)); + + handleCheckRyeCollection(actualStores[0]); + } + }, [trigger, module.type, actualStores, hasCollections, hasProducts]); + + const fetchData = useCallback( + async (currentModule: any) => { + try { + setIsLoading(true); + const storeDomain = currentModule.items[0]?.shop || ""; + + const store = actualStores.length + ? actualStores.filter((store) => store.domain === storeDomain)[0] + : talentProfile?.shopifyMerchants[storeDomain]; + setStoreSelected(store); + + if ( + currentModule.type === RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_COLLECTION + ) { + const collectionId = currentModule.items[0].collectionId; + const collection = await ryeShopifyService().fetchCollection( + collectionId + ); + + setCollectionName(collection?.title); + setItems(collection.products); + return; + } + if ( + currentModule.type === RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_PRODUCT + ) { + setProductType(PRODUCT_TYPES.INDIVIDUAL); + const productIds: string[] = currentModule.items[0].itemIds || []; + const products = await ryeShopifyService().fetchProductsByIDs( + store.domain, + productIds + ); + setItems(products); + setProductsSelected(products); + } + setIsShowAlert(false); + } catch (error) { + } finally { + setIsLoading(false); + } + }, + [actualStores] + ); + + useEffect(() => { + if (actualStores.length && module.items[0]) { + fetchData(module); + } + return () => {}; + }, [fetchData, actualStores, module]); + + const onDeleteItem = useCallback( + (item: ShopifyProductItem) => { + const itemsUpdate = module.items[0].itemIds?.filter( + (id) => id !== item.id + ); + setItems((items) => items.filter((el) => el.id !== item.id)); + profileContext.setModule({ + ...module, + items: [{ ...module.items[0], itemIds: itemsUpdate }], + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onDropEndProducts = useCallback( + (list: ShopifyProductItem[]) => { + const itemsUpdate = list.map((item) => item.id); + setItems(list); + profileContext.setModule({ + ...module, + items: [ + { ...module.items[0], itemIds: itemsUpdate }, + ] as ShopifyProductItem[], + isUpdate: true, + }); + }, + [module, profileContext.setModule] + ); + + const onOpenModal = (step: RYE_MODAL_STEPS) => { + setStep(step); + setVisibleModal(true); + }; + + const handleSubmit = useCallback(() => { + setIsFetching(true); + handleCheckRyeCollection(storeSelected); + + setIsAdding(true); + setIsShowAlert(true); + setProductPages([]); + setCollectionPages([]); + setProductsSelected([]); + }, [actualStores, storeSelected]); + + const handleChangeStore = useCallback( + (store) => { + setStoreSelected(store); + }, + [setStoreSelected] + ); + + const handleAddInvidualProduct = useCallback(async () => { + setIsFetching(true); + onOpenModal(RYE_MODAL_STEPS.ADD_MORE_PRODUCT); + if (module.items.length) { + const store = actualStores.filter( + (store) => store.domain === module.items[0].shop + )[0]; + setStoreSelected(store); + + let productsResponse; + if (module.type === RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_COLLECTION) { + productsResponse = await ryeShopifyService().fetchProducts( + store.domain + ); + } else if (module.type === RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_PRODUCT) { + productsResponse = await ryeShopifyService().fetchProducts( + store.domain + ); + } + + if (productsResponse) { + setProductPages([productsResponse]); + setProductsSelected([]); + } + + setIsFetching(false); + } else { + setIsFetching(false); + } + }, [isAdding, module.items, actualStores]); + + const fetchMoreCollections = async () => { + if (!storeSelected) { + return; + } + + const lastPage = collectionPages[collectionPages.length - 1]; + if (!lastPage.pageInfo.hasNextPage) { + return; + } + + const newCollectionsResponse = await ryeShopifyService().fetchCollections( + storeSelected.domain, + lastPage.pageInfo.endCursor + ); + + setCollectionPages((current: any) => [...current, newCollectionsResponse]); + }; + + const fetchMoreProducts = async () => { + if (!storeSelected) { + return; + } + + const lastPage = productPages[productPages.length - 1]; + if (lastPage.length < ryeShopifyService().productsPageSize) { + return; + } + + const newProductsResponse = await ryeShopifyService().fetchProducts( + storeSelected.domain, + undefined, + productList.length + ); + + setProductPages((current: any) => [...current, newProductsResponse]); + }; + + const handleSelectProductType = async () => { + setIsFetching(true); + const storeDetail = actualStores?.filter( + (store) => store.domain === storeSelected?.domain + )[0]; + + switch (productType) { + case PRODUCT_TYPES.COLLECTION: + onOpenModal(RYE_MODAL_STEPS.ADD_COLLECTION); + const collectionsResponse = await ryeShopifyService().fetchCollections( + storeDetail.domain + ); + + if (!collectionsResponse.collections.length) { + const productsResponse = await ryeShopifyService().fetchProducts( + storeDetail.domain + ); + + setIsFetching(false); + + if (!productsResponse.length) { + onOpenModal(RYE_MODAL_STEPS.EMPTY); + } else { + setProductType(PRODUCT_TYPES.INDIVIDUAL); + setProductPages([productsResponse]); + onOpenModal(RYE_MODAL_STEPS.ADD_PRODUCT); + } + } else { + setCollectionPages([collectionsResponse]); + + onOpenModal(RYE_MODAL_STEPS.ADD_COLLECTION); + setIsFetching(false); + } + break; + + default: + onOpenModal(RYE_MODAL_STEPS.ADD_PRODUCT); + + ryeShopifyService() + .fetchProducts(storeDetail.domain) + .then((products: any) => { + if (!products.length) { + setIsFetching(false); + onOpenModal(RYE_MODAL_STEPS.EMPTY); + return; + } + setProductPages([products]); + setIsFetching(false); + }) + .catch((e) => console.log(e)); + break; + } + }; + + const onAddCollectionCallback = useCallback(() => { + if (!collectionSelected) { + notification.error({ + message: "Please select the collection!", + }); + return; + } + const colection = collectionList.filter((collection: any) => { + return collection.id === collectionSelected; + })[0]; + + setCollectionName(colection?.title || ""); + + const moduleEdit = { + ...module, + items: [ + { + shop: storeSelected?.domain, + collectionId: collectionSelected, + }, + ], + isUpdate: true, + type: RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_COLLECTION, + }; + profileContext.setModule(moduleEdit); + setVisibleModal(false); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + }, [collectionSelected, module, storeSelected, profileContext.setModule]); + + const onAddProductCallBack = useCallback(() => { + if (!productsSelected.length) { + notification.error({ + message: "Please select products!", + }); + return; + } + + const moduleEdit = { + ...module, + items: [ + { + ...module.items[0], + shop: storeSelected?.domain, + itemIds: [ + ...productsSelected.map((item) => item.id), + ...(module.items[0]?.itemIds ?? []), + ], + } as TalentModuleMixItem, + ], + isUpdate: true, + type: RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_PRODUCT, + }; + + profileContext.setModule(moduleEdit); + + const { + pageId, + pageName, + type + } = trackingProps(moduleEdit.type, localizationSelected) + + useAnalyticsSDK + ? elementCreate({ pageId, pageName, type }) + : sendSegmentEvent(SEGMENT_EVENTS.ELEMENT_CREATED, { + "Module Type": moduleEdit.type, + "Page ID": localizationSelected?.id || null, + "Page Name": localizationSelected?.name || "Default", + }); + setVisibleModal(false); + }, [ + module, + productsSelected, + storeSelected?.domain, + localizationSelected, + profileContext.setModule, + sendSegmentEvent, + ]); + + const onLoginSuccessCallback = useCallback(() => { + setIsFetching(true); + const store = actualStores[actualStores.length - 1]; // the last store added + setStoreSelected(store); + handleCheckRyeCollection(store); + }, [actualStores]); + + const onSelectProduct = useCallback( + (productSelect: ShopifyProductItem) => { + const products = + productsSelected?.filter((product) => product.id === productSelect.id) + .length > 0 + ? productsSelected.filter( + (product) => product.id !== productSelect.id + ) + : ArrayServices.unshift(productsSelected, productSelect); + setProductsSelected(products); + }, + [productsSelected, productPages] + ); + + const connectToStore = useCallback(() => { + const from = "Modules page"; + const count = stores?.length ?? 0 + + useAnalyticsSDK + ? viewShopifyConnectionFlow({ from, stores: count }) + : sendSegmentEvent(SEGMENT_EVENTS.VIEW_SHOPIFY_CONNECTION_FLOW, { + "Number of connected stores": count, + "Added from": from, + }); + + return onOpenModal(RYE_MODAL_STEPS.LOGIN); + }, [stores?.length]); + const goBackToProductType = useCallback( + () => onOpenModal(RYE_MODAL_STEPS.SELECT_MODULE_TYPE), + [] + ); + + const onReload = async () => { + if (!storeSelected) { + return; + } + + if (productType === PRODUCT_TYPES.COLLECTION) { + const collectionsResponse = await ryeShopifyService().fetchCollections( + storeSelected.domain + ); + if (!collectionsResponse.collections.length) { + const productsResponse = await ryeShopifyService().fetchProducts( + storeSelected.domain + ); + if (!productsResponse.length) { + return; + } + setProductType(PRODUCT_TYPES.INDIVIDUAL); + setProductPages([productsResponse]); + onOpenModal(RYE_MODAL_STEPS.ADD_PRODUCT); + return; + } + setCollectionPages([collectionsResponse]); + const selected = collectionsResponse.collections.find( + (item: any) => item.id === collectionSelected + ); + if (!selected?.products?.length) { + return; + } + setCollectionName(selected?.title || ""); + + setItems(selected.products); + const moduleEdit = { + ...module, + items: [ + { + shop: storeSelected?.domain, + collectionId: selected.id, + }, + ], + isUpdate: true, + type: RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_COLLECTION, + }; + profileContext.setModule(moduleEdit); + setVisibleModal(false); + return; + } + const productsResponse = await ryeShopifyService().fetchProducts( + storeSelected.domain + ); + if (!productsResponse.length) { + return; + } + onOpenModal(RYE_MODAL_STEPS.ADD_PRODUCT); + setProductPages([productsResponse]); + setTimeout(() => { + onOpenModal(RYE_MODAL_STEPS.ADD_PRODUCT); + }, 150); + }; + + const onDeleteCollection = () => { + setVisibleConfirmDeleteModal(true); + }; + + const onConfirmDeteleCollection = useCallback(() => { + setItems([]); + profileContext.setModule({ ...module, items: [], isUpdate: true }); + setVisibleConfirmDeleteModal(false); + }, [profileContext.setModule]); + + return ( + + +
+ +
+ + ) : undefined + } + > + + {isEmptyModule(module) || + module.type === ModuleType.RYE_SHOPIFY ? ( + <> + Select your store + store?.connected === true + )} + onChange={handleChangeStore} + value={storeSelected} + onConnectToAnotherStore={connectToStore} + /> +
+ + + ) : ( + + {!!module.items.length && isShowAlert && ( + } + showIcon + closeText={ + + } + onClose={() => setIsShowAlert(false)} + /> + )} + + {module.type === + RYE_SHOPIFY_MODULE_TYPE.RYE_SHOPIFY_COLLECTION ? ( + <> + {items.length ? ( + + + Shopify collection + + + {storeSelected?.domain && ( + {`${storeSelected.domain} - Shopify store`} + )} + + + + + + ) : null} +
+ + + {collectionName} + + + + + {items.length}  + {items.length > 1 ? "products" : "product"} + + + + + + {(dragProvided, snapshot, item, itemIndex) => { + const isRender = active ? active : itemIndex < 2; + return isRender ? ( + + + + ) : itemIndex < 2 ? ( + + + + ) : ( + <> + ); + }} + +
+ {!active && items.length > 2 && ( +
+ + +{items.length - 2} More + +
+ )} + + ) : ( + <> +
+ + + {items.length}  + {items.length > 1 ? "products added" : "product added"} + +
+ + {(dragProvided, snapshot, item, itemIndex) => { + const isRender = active ? active : itemIndex < 2; + return isRender ? ( + + + + ) : ( + <> + ); + }} + + + )} + {!active && items.length > 2 && ( +
+ + +{items.length - 2} More + +
+ )} +
+ )} + + + + + + { + setVisibleModal(false); + setShowShopifyInputModal(true); + }} + selectModuleTypeCallback={handleSelectProductType} + goBackToProductTypeCallback={goBackToProductType} + /> + + {showShopifyInputModal && ( + { + const from = "Modules page" + const success = true + const count = (stores?.length ?? 0) + 1 + + useAnalyticsSDK + ? shopifyConnectionFlowCompleted({ + from, + stores: count, + success + }) + : sendSegmentEvent(SEGMENT_EVENTS.SHOPIFY_CONNECTION_FLOW_COMPLETED, { + "Number of connected stores": count, + "Added from": from, + "Is successful": success, + }); + return onOpenModal(RYE_MODAL_STEPS.LOGIN_SUCCESS); + }} + caller="Modules page" + /> + )} + + ); +}; + +export default React.memo(RyeModuleShopify); diff --git a/interface_base/src/pages/Profile/Profile.tsx b/interface_base/src/pages/Profile/Profile.tsx new file mode 100644 index 0000000..b84dd88 --- /dev/null +++ b/interface_base/src/pages/Profile/Profile.tsx @@ -0,0 +1,695 @@ +import { LoadingOutlined } from "@ant-design/icons"; +import { Card, Col, Row, Spin } from "antd"; +import message from "antd/lib/message"; +import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock"; +import classNames from "classnames"; +import BlockingEdit from "components/BlockingEdit"; +import LocalizationSelect from "components/LocalizationSelect"; +import { Text } from "components/Typography"; +import { profileTabs } from "constants/profile"; +import { DARK_THEME } from "constants/profile-theme"; +import { SEGMENT_EVENTS } from "constants/segment"; +import { useAnalytics } from "hooks/useAnalytics"; +import { useAutoSave } from "hooks/useAutoSave"; +import useScrollY from "hooks/useScrollY"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useDispatch } from "react-redux"; +import { Prompt } from "react-router"; +import { useHistory, useLocation } from "react-router-dom"; +import { useTypedSelector } from "redux/rootReducer"; +import { setActiveModuleAction } from "redux/Talent/actions"; +import { + selectIsAutoSaveSuccess, + selectIsPublished, + selectLoadingAutoSave, + selectLoadingPublish, + selectModuleLoading, + selectTalentProfile, + selectTalentProfileModules, + selectUserData, + selectUserLoading, +} from "redux/User/selector"; +import { + TalentProfileDisplayNameTypes, + TalentProfileThemeColor, + TalentProfileThemeTypes, +} from "redux/User/types"; +import notification from "utils/notification"; +import ProfileHeader from "./Header"; +import ModuleList from "./ModuleList"; +import "./Profile.scss"; +import ProfileSocialLinks from "./ProfileModule/ProfileSocialLinks"; +import ProfileTheme from "./ProfileModule/ProfileTheme"; +import ProfilePreview from "./ProfilePreview/ProfilePreview"; +import upcoming, { adjust } from "utils/upcoming"; +import { + FLAGS, + IfFeature, + IfFeatureEnabled, + useFeatureIsOn, +} from "@komi-app/flags-sdk"; +import { TemplateSelectionModal } from "../../components/TemplateSelectionModal/TemplateSelectionModal"; +import { useWindowSize } from "../../hooks"; +import { useHasTemplates } from "hooks/useHasTemplates"; +import When from "@komi-app/when"; +import ProfilePreviewDrawer from "./ProfilePreviewDrawer/ProfilePreviewDrawer"; +import { Button } from "@komi-app/creator-ui"; +import { CurrentTalentSiteLinkCopy } from "../../components/CurrentTalentSiteLinkCopy/CurrentTalentSiteLinkCopy"; +import { TemplateSelectionModalV2 } from "components/TemplateSelectionModalV2/TemplateSelectionModalV2"; +import { ModuleType } from "@komi-app/shared-types" +import { useCreatorTracking, CreatorEvent } from "@komi-app/analytics-sdk"; + +import Cello from "../Cello"; + +const Profile: React.FC = () => { + const { + title, + isBlockingEditState, + isPublished, + isOnModulesPage, + loadingPublish, + handlePublish, + onCheckBeforeChangeRouter, + renderLoadingAutoSave, + renderTab, + onStartFromScratch, + onStartWithTemplates, + } = useProfile(); + const { width } = useWindowSize(); + + const isSmallScreen = width && width <= 768; + const isShowingDrawerPreview = isSmallScreen; + + const isCopyUrlPromptEnabled = useFeatureIsOn( + FLAGS.FEAT_SB_357_COPY_SITE_LINK + ); + + if (isCopyUrlPromptEnabled) { + return ( + + {isBlockingEditState ? ( + + ) : ( + <> + + + + + } + /> + } + disabled={ + + } + disabled={} + /> + } + /> + + + + + } + /> + + +
+ + + {title} + + + + + + + + } + /> + + + {renderLoadingAutoSave()} +
+ + )} +
+ ); + } + + return ( + + {isBlockingEditState ? ( + + ) : ( + <> + + + + } + /> + + +
+ + + {title} + + + + {renderLoadingAutoSave()} +
+ + )} +
+ ); +}; + +export const useProfile = () => { + useScrollY(); + const { sendSegmentEvent } = useAnalytics(); + const { + localizationSelected, + setLocalizationSelected, + setIsVisiblePublish, + isBlockingEditState, + onCheckBeforeChangeRouter, + handleChangeLocalization, + setVisibleConfirmPublish, + } = useAutoSave(); + const router = useHistory(); + const dispatch = useDispatch(); + const location = useLocation(); + const isAutoSaveSuccess = useTypedSelector(selectIsAutoSaveSuccess); + const [defaultCustomColor, setDefaultCustomColor] = + useState(); + const [firstThemeType, setFirstThemeType] = + useState(); + const loading = useTypedSelector(selectUserLoading); + const loadingAutoSave = useTypedSelector(selectLoadingAutoSave); + + const talentProfile = useTypedSelector(selectTalentProfile); + const modules = useTypedSelector(selectTalentProfileModules); + const user = useTypedSelector(selectUserData); + const isPublished = useTypedSelector(selectIsPublished); + const loadingPublish = useTypedSelector(selectLoadingPublish); + const moduleLoading = useTypedSelector(selectModuleLoading); + + const [showAddModuleMenu, setShowAddModuleMenu] = useState(false); + + const hasTemplateModules = useHasTemplates(); + + const isContentMenuEnabled = useFeatureIsOn(FLAGS.FEAT_SB_389_ADD_ITEM_MENU); + const isTemplatesV2Enabled = useFeatureIsOn( + FLAGS.FEAT_SB_447_MODULE_TEMPLATES + ); + const useAnalyticsSDK = useFeatureIsOn(FLAGS.USE_ANALYTICS_SDK_TALENT); + const { createTracker } = useCreatorTracking() + const viewGoalTemplatePage = createTracker(CreatorEvent.VIEW_GOAL_TEMPLATE_PAGE) + const viewHeaderTab = createTracker(CreatorEvent.VIEW_HEADER_TAB) + const viewThemeTab = createTracker(CreatorEvent.VIEW_THEME_TAB) + const viewSocialLinksTab = createTracker(CreatorEvent.VIEW_SOCIAL_LINKS_TAB) + const viewModuleTab = createTracker(CreatorEvent.VIEW_MODULES_TAB) + + const { pathname } = location; + const getCurrentTab = (pathname: string) => { + const [head, tail] = pathname.split("/admin"); + return ( + (tail && tail.substring(1)) || (head && head.substring(1)) || "header" + ); + }; + const [tab, setTab] = useState(getCurrentTab(pathname)); + + const isOnModulesPage = tab === "modules"; + + const title = useMemo(() => { + const tabs = profileTabs(isContentMenuEnabled); + const current = tabs.find(({ key }) => key === tab); + return current ? current.name : "Profile Editor"; + }, [tab, isContentMenuEnabled]); + + const { isUpcoming } = upcoming(); + + const disableBody = (target: any) => { + disableBodyScroll(target); + }; + const enableBody = (target: any) => enableBodyScroll(target); + useEffect(() => { + setTimeout(() => { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + }, 500); + }, []); + + useEffect(() => { + if (!user || firstThemeType) { + return; + } + if (!firstThemeType) { + setFirstThemeType(user?.talentProfile?.themeType); + setDefaultCustomColor(user?.talentProfile?.themeColor || DARK_THEME); + } + }, [user, firstThemeType]); + + useEffect(() => { + setTab(getCurrentTab(pathname)); + }, [pathname]); + + const onStartFromScratch = useCallback(() => { + if (isContentMenuEnabled) { + router.push({ + pathname: "/admin/modules/new", + }); + } else { + setShowAddModuleMenu(true); + } + }, [isContentMenuEnabled]); + + const onStartWithTemplates = useCallback(() => { + if (isTemplatesV2Enabled) { + router.push({ + pathname: "/admin/modules/templates", + }); + useAnalyticsSDK + ? viewGoalTemplatePage() + : sendSegmentEvent(SEGMENT_EVENTS.VIEW_GOAL_TEMPLATE_PAGE); + } + }, [isTemplatesV2Enabled]); + + const onChangeTab = useCallback( + (tab: string) => { + dispatch(setActiveModuleAction("100000")); + setTab(tab); + + if (useAnalyticsSDK) { + switch (tab) { + case "header": + viewHeaderTab() + break; + case "theme": + viewThemeTab() + break; + case "social-links": + viewSocialLinksTab() + break; + case "modules": + viewModuleTab() + break; + + default: + break; + } + } + else { + switch (tab) { + case "header": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_HEADER_TAB); + break; + case "theme": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_THEME_TAB); + break; + case "social-links": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_SOCIAL_LINKS_TAB); + break; + case "modules": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_MODULES_TAB); + break; + + default: + break; + } + } + + router.replace(adjust(`/${tab}`, isUpcoming)); + }, + [router, sendSegmentEvent] + ); + + const scrollToId = useCallback( + (id: string) => { + const scroll = document.querySelector(id); + scroll?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, + [document] + ); + const handleReview = useCallback( + (key: string) => () => { + if (key === "avatar") { + onChangeTab("header"); + setTimeout(() => { + scrollToId("#profile-photo"); + }, 300); + return; + } + if (key === "custom-image") { + onChangeTab("header"); + setTimeout(() => { + scrollToId("#profile-display-name"); + }, 300); + return; + } + }, + [modules, scrollToId, onChangeTab] + ); + const handlePublish = useCallback(() => { + // trigger Google Tag Manager event + window.dataLayer = window.dataLayer || []; + window.dataLayer.push({ event: "publish-profile" }); + + const disabledFanClub = modules?.some( + (el) => + el.type === ModuleType.FAN_CLUB && + !(el.items[0] as any)?.thumbnail + ); + const disabledAvatar = !talentProfile?.avatar; + const disabledCustomPublish = + talentProfile?.showDisplayName && + ((talentProfile?.displayNameType === + TalentProfileDisplayNameTypes.IMAGE && + !talentProfile.displayNameImage) || + (talentProfile?.displayNameType === + TalentProfileDisplayNameTypes.TEXT && + !talentProfile.displayName)); + const messages = []; + if (disabledAvatar) { + messages.push({ + key: "avatar", + message: + "Failed to publish. Please review the errors before continuing.", + onReview: handleReview("avatar"), + }); + } + if (disabledCustomPublish) { + messages.push({ + key: "custom-image", + message: + "Failed to publish. Please review the errors before continuing.", + onReview: handleReview("custom-image"), + }); + } + if (disabledFanClub) { + messages.push({ + key: "fan-club", + message: + "Failed to publish. Please review the errors before continuing.", + onReview: handleReview("fan-club"), + }); + } + if (messages.length) { + notification.showErrorPublish({ + key: "publish-error", + messages: messages, + }); + return; + } + message.destroy("publish-error"); + + if (isTemplatesV2Enabled) { + showPublishModal(); + return; + } + + if ( + talentProfile?.localizationActive && + talentProfile.localizations?.length + ) { + setIsVisiblePublish(true); + return; + } + setVisibleConfirmPublish(true); + }, [talentProfile, modules]); + + const showPublishModal = useCallback(() => { + if ( + talentProfile?.localizationActive && + talentProfile.localizations?.length + ) { + setIsVisiblePublish(true); + return; + } + setVisibleConfirmPublish(true); + }, [talentProfile, hasTemplateModules, isTemplatesV2Enabled]); + + const renderLoadingAutoSave = useCallback(() => { + if (!isPublished && loadingAutoSave) { + return ( + + {} + + Saving + + + ); + } + if (!isPublished && isAutoSaveSuccess) { + return ( + + + Saved + + + ); + } + return null; + }, [isPublished, loadingAutoSave, isAutoSaveSuccess]); + + const renderTab = () => { + return ( + <> + + + + + {user?.talentProfile?.localizationActive && ( + + )} +
+ {moduleLoading ? ( +
+ +
+ ) : ( + + } + disabled={} + /> + )} +
+
+ + ); + }; + + const handleClickOnboardingItem = (item: any) => { + let tab = "header"; + switch (item.key) { + case "module": + tab = "modules"; + break; + case "social": + tab = "social-links"; + break; + default: + break; + } + onChangeTab(tab); + return; + }; + + useEffect(() => { + if (useAnalyticsSDK) { + switch (tab) { + case "header": + viewHeaderTab() + break; + case "theme": + viewThemeTab() + break; + case "social-links": + viewSocialLinksTab() + break; + case "modules": + viewModuleTab() + break; + + default: + break; + } + } + else { + switch (tab) { + case "header": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_HEADER_TAB); + break; + case "theme": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_THEME_TAB); + break; + case "social-links": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_SOCIAL_LINKS_TAB); + break; + case "modules": + sendSegmentEvent(SEGMENT_EVENTS.VIEW_MODULES_TAB); + break; + + default: + break; + } + } + }, [tab]); + + return { + title, + isBlockingEditState, + isPublished, + isOnModulesPage, + loading, + loadingPublish, + modules, + disableBody, + enableBody, + handleClickOnboardingItem, + handlePublish, + onCheckBeforeChangeRouter, + renderLoadingAutoSave, + renderTab, + setDefaultCustomColor, + setFirstThemeType, + onStartFromScratch, + onStartWithTemplates, + }; +}; + +export default Profile; diff --git a/interface_base/src/pages/Profile/ProfileModule/ProfileSocialLinks.tsx b/interface_base/src/pages/Profile/ProfileModule/ProfileSocialLinks.tsx new file mode 100644 index 0000000..b4a3d7d --- /dev/null +++ b/interface_base/src/pages/Profile/ProfileModule/ProfileSocialLinks.tsx @@ -0,0 +1,163 @@ +import { SocialLinkType, getSocialLinkConfig } from "@komi-app/creator-ui"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { Alert } from "antd"; +import classNames from "classnames"; +import { defaultSocialTags } from "constants/social"; +import * as React from "react"; +import { useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import { + selectTalentProfile, + selectTalentRevert, + selectUserData, +} from "redux/User/selector"; +import { SocialProfileLinkItem, User } from "redux/User/types"; +import { useTypedSelector } from "redux/rootReducer"; +import { Icon } from "../../../components/Icon"; +import ProfileSocialLinkList from "../../../components/ProfileSocialLinkList"; +import { Paragraph } from "../../../components/Typography"; +import { + setTalentProfileAction, + updateTalentRevertAction, +} from "../../../redux/User/actions"; +import "./ProfileSocialLinks.scss"; + +interface ProfileSocialLinksProps { + className?: string; +} +const ProfileSocialLinks = ({ className }: ProfileSocialLinksProps) => { + const dispatch = useDispatch(); + const user: User | null | undefined = useTypedSelector(selectUserData); + + const [isInit, setIsInit] = useState(false); + const [tags] = React.useState(defaultSocialTags); + const [links, setLinks] = useState([]); + const [showAlert, setShowAlert] = useState(false); + const talentProfile = useTypedSelector(selectTalentProfile); + const talentRevert = useTypedSelector(selectTalentRevert); + const isTemplateSelectionOn = useFeatureIsOn( + FLAGS.GS_146_ADD_TEMPLATE_SELECTION + ); + const hasNewSocialLinks = useFeatureIsOn( + FLAGS.FEAT_SB_186_ADDITIONAL_SOCIAL_LINKS + ); + + useEffect(() => { + if (!user?.talentProfile?.id) { + return; + } + const show = localStorage.getItem( + `ALERT_SOCIAL_LINKS_${user?.talentProfile?.id}` + ); + if (!show) { + setShowAlert(true); + localStorage.setItem( + `ALERT_SOCIAL_LINKS_${user?.talentProfile?.id}`, + "true" + ); + } + }, [user?.talentProfile?.id]); + useEffect(() => { + if (talentRevert) { + dispatch(updateTalentRevertAction(false)); + setIsInit(false); + } + }, [talentProfile?.socialProfileLinks, talentRevert]); + + const handleSetLinks = (profileLinks: SocialProfileLinkItem[]) => { + setLinks(profileLinks); + const socialProfileLinks = profileLinks + .filter((item) => item.value) + .map((el) => ({ type: el.key, link: el.value })); + dispatch( + setTalentProfileAction({ + socialProfileLinks, + }) + ); + }; + + useEffect(() => { + if (user && !isInit) { + setIsInit(true); + const profileLinks: SocialProfileLinkItem[] = + user?.talentProfile?.socialProfileLinks?.map((item) => { + const tag = tags.find((i) => i.key === item.type); + return { + ...tag, + edit: false, + value: item.link, + } as SocialProfileLinkItem; + }) || []; + setLinks(profileLinks || []); + } + }, [user?.talentProfile?.socialProfileLinks, isInit]); + + useEffect(() => { + if (user && isTemplateSelectionOn) { + const profileLinks: SocialProfileLinkItem[] = ( + user?.talentProfile?.socialProfileLinks?.map((item) => { + if (hasNewSocialLinks) { + // this typecasting is awful but we're stuck with it until we can remove the legacy social link types + const linkType = item.type as unknown as SocialLinkType; + const link = getSocialLinkConfig(linkType); + + // map the new type to fit the old type, + return { + key: linkType, + name: link.label, + icon: "website", + edit: false, + value: item.link, + } as SocialProfileLinkItem; + } + + const tag = tags.find((i) => i.key === item.type); + return { + ...tag, + edit: false, + value: item.link, + } as SocialProfileLinkItem; + }) || [] + ).filter((item) => item.name); + setLinks(profileLinks || []); + } + }, [ + user?.talentProfile?.socialProfileLinks, + isTemplateSelectionOn, + hasNewSocialLinks, + ]); + + return ( +
+ + You can add up to 7 social profile links to your page. + + {showAlert && ( + } + showIcon + closeText={} + onClose={() => setShowAlert(false)} + /> + )} + +
+ ); +}; + +export default ProfileSocialLinks; diff --git a/interface_base/src/pages/Profile/ProfileModule/ProfileTheme.tsx b/interface_base/src/pages/Profile/ProfileModule/ProfileTheme.tsx new file mode 100644 index 0000000..41f6505 --- /dev/null +++ b/interface_base/src/pages/Profile/ProfileModule/ProfileTheme.tsx @@ -0,0 +1,429 @@ +import { Row } from "antd/lib/grid"; +import Tooltip from "antd/lib/tooltip"; +import classNames from "classnames"; +import { ColorPickerInput } from "components/ColorPicker"; +import ContextCard from "components/ContentCard"; +import { Icon } from "components/Icon"; +import { Text } from "components/Typography"; +import { + CUSTOM_THEME, + DARK_THEME, + LIGHT_THEME, + OVERLAY_THEME, +} from "constants/profile-theme"; +import { useDebouncedCallback } from "hooks/useDebouncedCallback"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { isMobile } from "react-device-detect"; +import { useDispatch } from "react-redux"; +import { useTypedSelector } from "redux/rootReducer"; +import { setTalentProfileAction } from "redux/User/actions"; +import { selectUserData } from "redux/User/selector"; +import { + TalentProfileThemeColor, + TalentProfileThemeTypes, + User, +} from "redux/User/types"; +import { detectLightOrDark, isValidColorContrast } from "utils/color"; +import config from "config"; +import "./ProfileTheme.scss"; + +const THEME_TYPE_OPTIONS = [ + TalentProfileThemeTypes.LIGHT, + TalentProfileThemeTypes.DARK, + TalentProfileThemeTypes.CUSTOM, +]; + +const OVERLAY_TYPE_OPTIONS = [ + TalentProfileThemeTypes.LIGHT, + TalentProfileThemeTypes.DARK, +]; +interface ProfileThemeProps { + className?: string; + defaultCustomColor?: TalentProfileThemeColor; + setDefaultCustomColor: (value: TalentProfileThemeColor) => void; + firstThemeType?: TalentProfileThemeTypes; + setFirstThemeType: (value?: TalentProfileThemeTypes) => void; + isOnBoarding?: boolean; +} +const ProfileTheme: React.FC = (props) => { + const { className, isOnBoarding } = props; + + const { + user, + onChangeThemeType, + showWarning, + setShowWarning, + onChangeBackgroundColor, + getOverlayOptionActive, + onChangeOverlayColor, + onChangeTypographyColor, + } = useProfileTheme(props); + + return ( +
+
+
+ + Select Your Theme + +
+
+ {THEME_TYPE_OPTIONS.map((option, index) => ( +
+ + {option.toLowerCase()} + +
{ + onChangeThemeType(option); + }} + data-testid={`profile-theme__toggle-${option.toLowerCase()}`} + > +
+ {option === TalentProfileThemeTypes.CUSTOM ? ( + + ) : ( +
+ )} +
+
+
+ ))} +
+ + {showWarning && ( +
+ setShowWarning(false)} + /> +
+ )} + + + + Colors + +
+ onChangeBackgroundColor(color)} + /> + onChangeTypographyColor(color)} + /> +
+ + + Module Overlay + + +
+ +
+
+
+
+ {OVERLAY_TYPE_OPTIONS.map((option, index) => ( +
+ + {`${option.toLowerCase()}en`} + +
{ + console.log("Click event fired!"); + onChangeOverlayColor(option); + }} + data-testid={`profile-theme__overlay-${option.toLowerCase()}`} + > +
+ +
+
+
+ ))} +
+
+
+
+
+
+ ); +}; + +export const useProfileTheme = ({ + defaultCustomColor, + setDefaultCustomColor, + firstThemeType, + setFirstThemeType, +}: ProfileThemeProps) => { + const dispatch = useDispatch(); + const user: User | null | undefined = useTypedSelector(selectUserData); + + const [showWarning, setShowWarning] = useState(false); + + const typographyColor = useMemo( + () => + user?.talentProfile?.themeColor?.typographyColor || + DARK_THEME.typographyColor, + [user] + ); + + const backgroundColor = useMemo( + () => + user?.talentProfile?.themeColor?.backgroundColor || + DARK_THEME.backgroundColor, + [user] + ); + + const overlayColor = useMemo( + () => + user?.talentProfile?.themeColor?.overlayColor || DARK_THEME.overlayColor, + [user] + ); + + useEffect(() => { + setShowWarning(!isValidColorContrast(backgroundColor, typographyColor)); + }, [backgroundColor, typographyColor, overlayColor]); + + const onChangeThemeType = useCallback( + (type: keyof typeof TalentProfileThemeTypes) => { + dispatch( + setTalentProfileAction({ + themeType: type, + }) + ); + + switch (type) { + case TalentProfileThemeTypes.DARK: + dispatch( + setTalentProfileAction({ + themeColor: DARK_THEME, + }) + ); + break; + case TalentProfileThemeTypes.LIGHT: + dispatch( + setTalentProfileAction({ + themeColor: LIGHT_THEME, + }) + ); + break; + case TalentProfileThemeTypes.CUSTOM: + const themeColor = + firstThemeType === TalentProfileThemeTypes.CUSTOM + ? defaultCustomColor + : user?.talentProfile?.themeColor || DARK_THEME; + dispatch( + setTalentProfileAction({ + themeColor, + }) + ); + break; + default: + break; + } + }, + [ + dispatch, + user?.talentProfile?.themeColor, + defaultCustomColor, + firstThemeType, + ] + ); + + const onChangeBackgroundColor = useDebouncedCallback((color: string) => { + const themeColor = { + ...user?.talentProfile?.themeColor, + backgroundColor: color, + }; + setDefaultCustomColor(themeColor as TalentProfileThemeColor); + if (firstThemeType !== TalentProfileThemeTypes.CUSTOM) { + setFirstThemeType(TalentProfileThemeTypes.CUSTOM); + } + const isLight = detectLightOrDark(color) === "light"; + themeColor.overlayColor = isLight + ? OVERLAY_THEME.DARK.color + : OVERLAY_THEME.LIGHT.color; + dispatch( + setTalentProfileAction({ + themeColor: themeColor, + }) + ); + }, 500); + + const onChangeTypographyColor = useDebouncedCallback((color: string) => { + const themeColor = { + ...user?.talentProfile?.themeColor, + typographyColor: color, + }; + setDefaultCustomColor(themeColor as TalentProfileThemeColor); + if (firstThemeType !== TalentProfileThemeTypes.CUSTOM) { + setFirstThemeType(TalentProfileThemeTypes.CUSTOM); + } + dispatch( + setTalentProfileAction({ + themeColor: themeColor, + }) + ); + }, 500); + + const onChangeOverlayColor = useCallback( + (color: string) => { + const overlayColor = + color === TalentProfileThemeTypes.DARK + ? OVERLAY_THEME.DARK.color + : OVERLAY_THEME.LIGHT.color; + const themeColor = { + ...user?.talentProfile?.themeColor, + overlayColor: overlayColor, + overlayOpacity: 0.1, + }; + setDefaultCustomColor(themeColor as TalentProfileThemeColor); + if (firstThemeType !== TalentProfileThemeTypes.CUSTOM) { + setFirstThemeType(TalentProfileThemeTypes.CUSTOM); + } + dispatch( + setTalentProfileAction({ + themeColor: themeColor, + }) + ); + }, + [dispatch, user?.talentProfile?.themeColor] + ); + + const getOverlayOptionActive = () => { + return user?.talentProfile?.themeColor?.overlayColor === + OVERLAY_THEME.DARK.color + ? TalentProfileThemeTypes.DARK + : TalentProfileThemeTypes.LIGHT; + }; + + return { + user, + onChangeThemeType, + showWarning, + setShowWarning, + onChangeBackgroundColor, + getOverlayOptionActive, + onChangeOverlayColor, + onChangeTypographyColor, + }; +}; + +export default ProfileTheme; diff --git a/interface_base/src/pages/Profile/ProfilePreview/ProfilePreview.tsx b/interface_base/src/pages/Profile/ProfilePreview/ProfilePreview.tsx new file mode 100644 index 0000000..4b52712 --- /dev/null +++ b/interface_base/src/pages/Profile/ProfilePreview/ProfilePreview.tsx @@ -0,0 +1,405 @@ +import Button from "antd/lib/button"; +import { Col } from "antd/lib/grid"; +import Row from "antd/lib/row"; +import { default as classNames, default as classnames } from "classnames"; +import DotLoading from "components/DotLoading"; +import { Icon } from "components/Icon"; +import { Text } from "components/Typography"; +import { useWindowSize } from "hooks"; +import { useModules } from "hooks/useModules"; +import reduce from "lodash/reduce"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useSelector } from "react-redux"; +import { selectActiveModule } from "redux/Talent/selector"; +import { + selectTalentProfileModules, + selectUserData, +} from "redux/User/selector"; +import { IframeMessage } from "utils/iframeMessage"; +import { SHOPIFY_MODULE_TYPE } from "../../../constants/shopify"; +import { selectShopifyStores } from "../../../redux/Shopify/selector"; +import "./ProfilePreview.scss"; +import ProfilePreviewModal from "./ProfilePreviewModal"; +import config from "config"; +import { FLAGS, useFeatureIsOn } from "@komi-app/flags-sdk"; +import { useCurrentProfilePreviewUrl } from "../../../hooks/useCurrentProfilePreviewUrl"; + +import { ModuleType } from "@komi-app/shared-types"; + +export const DEVICE_TYPE = { + MOBILE: "mobile", + TABLET: "tablet", + DESKTOP: "desktop", +}; + +const CONSUMER_URL = config.client.url; +const TALENT_PREVIEW_URL = CONSUMER_URL + "/talent-profile-preview"; + +interface ProfilePreviewProps { + fullWidth?: boolean; + hideControls?: boolean; +} + +const ProfilePreview: React.FC = ({ + fullWidth = false, + hideControls = false, + ...props +}) => { + const iframeRef = useRef(null); + const { width } = useWindowSize(); + + const isProfilePreviewUrlDomainFixEnabled = useFeatureIsOn( + FLAGS.FIX_SB_869_PREVIEW_NOT_PLAYING_VIDEOS + ); + const profilePreviewUrl = useCurrentProfilePreviewUrl(); + + const user = useSelector(selectUserData); + + const talentProfileModules = useSelector(selectTalentProfileModules); + + const stores = useSelector(selectShopifyStores); + const activeModule = useSelector(selectActiveModule); + + const [webLoaded, setWebLoaded] = useState(false); + const [webRect, setWebRect] = useState(); + const [deviceType, setDeviceType] = useState("mobile"); + const [showPreviewMessage, setShowPreviewMessage] = useState( + !(localStorage.getItem("SHOWED_PREVIEW_MESSAGE") === "true") + ); + const [showModal, toggleModal] = useState(false); + const { thirdPartyData } = useModules(); + const currentModules = useMemo(() => { + return reduce( + talentProfileModules, + (result: any, item: any) => { + if ( + [ + ModuleType.SHOP_MY_SHELF, + ModuleType.SHOP_LIST, + ModuleType.YOUTUBE_COLLECTION, + ModuleType.BANDSINTOWN, + ModuleType.PODCAST_AUTOMATION, + ModuleType.SEATED, + ].includes(item.type) + ) { + const data = thirdPartyData.find((el: any) => el.id === item.id); + let items; + switch (item.type) { + case ModuleType.SHOP_MY_SHELF: + case ModuleType.SHOP_LIST: + items = data?.products; + break; + case ModuleType.YOUTUBE_COLLECTION: + items = data?.collection?.items; + break; + case ModuleType.BANDSINTOWN: + items = data?.events || []; + break; + case ModuleType.SEATED: + items = data?.events?.shows || []; + break; + case ModuleType.PODCAST_AUTOMATION: + items = data?.collection?.items || []; + break; + default: + break; + } + return [ + ...result, + { + ...item, + items: items || [], + displayLatest: item.items[0].activeLatestPodcast, + }, + ]; + } + if (item.type === ModuleType.GROUP) { + const items = reduce( + item.items, + (newItems: any, element: any) => { + if ( + [ + ModuleType.SHOP_MY_SHELF, + ModuleType.SHOP_LIST, + ModuleType.YOUTUBE_COLLECTION, + ModuleType.BANDSINTOWN, + ModuleType.SEATED, + ModuleType.PODCAST_AUTOMATION, + ].includes(element.type as ModuleType) + ) { + const data = thirdPartyData.find( + (el: any) => el.id === element.id + ); + + let items; + switch (element.type) { + case ModuleType.SHOP_MY_SHELF: + case ModuleType.SHOP_LIST: + items = data?.products; + break; + case ModuleType.YOUTUBE_COLLECTION: + items = data?.collection?.items; + break; + + case ModuleType.BANDSINTOWN: + items = data?.events || []; + break; + case ModuleType.SEATED: + items = data?.events?.shows || []; + break; + case ModuleType.PODCAST_AUTOMATION: + items = data?.collection?.items || []; + break; + default: + break; + } + + return [ + ...newItems, + { + ...element, + displayLatest: element.items[0].activeLatestPodcast, + items: items || [], + }, + ]; + } + return [...newItems, element]; + }, + [] + ); + + return [ + ...result, + { + ...item, + items, + }, + ]; + } + return [...result, item]; + }, + [] + ); + }, [thirdPartyData, talentProfileModules]); + + const shopifyMerchants = useMemo(() => { + return talentProfileModules?.reduce((result: any, item) => { + if ( + [ + SHOPIFY_MODULE_TYPE.SHOPIFY_COLLECTION, + SHOPIFY_MODULE_TYPE.SHOPIFY_PRODUCT, + ].includes(item.type) + ) { + const storeDomain = (item.items?.[0] as any)?.shop || ""; + + const accessToken = + stores?.find((store) => store.domain === storeDomain)?.accessToken || + user?.talentProfile?.shopifyMerchants?.[storeDomain]?.accessToken; + result[storeDomain] = accessToken; + return result; + } + return result; + }, {}); + }, [stores, user, talentProfileModules]); + + const handleSendTalentProfile = useCallback(async () => { + if (!iframeRef) return; + const message = { + type: "komi_talent_profile", + data: { + ...user, + talentProfile: { + ...user?.talentProfile, + modules: currentModules, + shopifyMerchants, + }, + }, + }; + iframeRef.current?.contentWindow?.postMessage(JSON.stringify(message), "*"); + IframeMessage.postMessage(message); + + // trigger scroll 1px to load lazy image + // window.scrollTo(window.scrollX, window.scrollY + 1); + }, [user, currentModules, shopifyMerchants]); + + const handleMessage = useCallback( + async (event: MessageEvent) => { + const { type, data } = event.data; + if (!showModal) { + switch (type) { + case "komi_consumer_loaded": + setWebLoaded(true); + handleSendTalentProfile(); + break; + case "komi_consumer_rect": + if (!!data.height) { + setWebRect(data); + } + break; + default: + break; + } + } + }, + [webLoaded, user, talentProfileModules, handleSendTalentProfile, showModal] + ); + + useEffect(() => { + IframeMessage.bindEvent(window, "message", handleMessage); + return () => { + IframeMessage.unbindEvent(window, "message", handleMessage); + }; + }, [handleMessage]); + + useEffect(() => { + handleSendTalentProfile(); + }, [shopifyMerchants, talentProfileModules, handleSendTalentProfile]); + + useEffect(() => { + if (!webLoaded) return; + iframeRef?.current?.setAttribute( + "style", + `height:${webRect?.height || 10000}px` + ); + }, [webRect, webLoaded]); + + useEffect(() => { + if (!!activeModule) { + if (!iframeRef) return; + const message = { + type: "komi_talent_profile_active_module", + data: { + activeModule, + }, + }; + iframeRef.current?.contentWindow?.postMessage( + JSON.stringify(message), + "*" + ); + IframeMessage.postMessage(message); + } + }, [iframeRef, activeModule]); + + return ( +
= 1138 + ? 337 + : (width - 96) / 3 + : 337, + maxWidth: "100%", + height: "100%", + }} + {...props} + > + {showPreviewMessage && ( + + + This is a preview + + + )} + {(!webLoaded || !webRect?.height) && ( +
+ +
+ )} + ', + }, + url: "https://www.youtube.com/watch?v=s8rUMJ--dhk", + visible: true, + }, + ], + }, + }); +} + +const createSharePressModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Share Press", + order, + data: { + items: [ + { + url: "https://www.rollingstone.co.uk/tv/features/munya-chawawa-interview-comedy-rolling-stone-33917/", + order: 0, + title: + "Rolling Stone | Munya Chawawa on redefining masculinity through his comedy", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/EZ11NYEI_fHGqX81y4ogp.jpeg", + }, + { + url: "https://www.theguardian.com/lifeandstyle/2023/oct/14/munya-chawawa-looks-back-bullies-shamed-me-for-being-proud-of-my-culture-so-id-go-home-and-write-raps-about-the-cold-hard-streets-of-norfolk", + order: 1, + title: + "The Guardian | Family joke-offs, double life, and going viral", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/GpXr-LjUoaxYA9sCAFXv_.jpeg", + }, + ], + }, + }); +}; + +const createProductModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.PRODUCT, + name: "Our Products", + order, + data: { + items: [ + { + url: "https://www.jomalone.co.uk/product/25969/10082/home-collection/pomegranate-noir-home-candle?size=200g", + order: 0, + price: 56, + title: "Pomegranate Noir Home Candle", + visible: true, + currency: "GBP", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/7bc8hDw2yYpJ2QyWBDWh-.jpg", + }, + { + url: "https://www.jomalone.co.uk/product/26322/103765/gift-sets/most-loved-mini-candles-trio?size=3_x_60g&gad=1&gclid=Cj0KCQjwj5mpBhDJARIsAOVjBdqsAuOmcll5I-Zoui2aCWq8LHinTxgnSq0LmIcRs9me3ZdkcCkWQAMaAnxhEALw_wcB&gclsrc=aw.ds", + order: 1, + price: 84, + title: "Most Loved Mini Candles Trio", + visible: true, + currency: "GBP", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/40b1YJRJMIAjFSHzUzQeb.jpg", + }, + { + url: "https://www.fortnumandmason.com/jo-malone-london-pomegranate-noir-home-candle-200g?gclid=Cj0KCQjwj5mpBhDJARIsAOVjBdpP-DhB8b1u51bgE5p-0xCUReEsoT55FsaBN-zJN7ASPzWyL4KeHzUaApEbEALw_wcB&gclsrc=aw.ds", + order: 2, + price: 56, + title: "Jo Malone London Pomegranate Noir Home Candle, 200g", + visible: true, + currency: "GBP", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/UzSwDKWhzpu5HeUqIEpO8.jpg", + }, + ], + }, + }); +}; + +const createFormModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.FORM_DATA, + name: "Sign Up Now", + order, + data: { + items: [ + { + form: { + name: "Updates", + fields: [ + { + name: "INPUT", + type: "INPUT", + label: "Name", + required: true, + }, + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Email", + required: true, + }, + ], + }, + title: "Exclusive updates", + layout: "left", + visible: true, + subTitle: "Receive updates and exclusive discounts", + enableImage: false, + }, + ], + }, + }); +}; + +const createPostcastAppearancesModule = ({ order }: OrderedParams) => { + return createModule({ + order: 0, + name: "Podcast Appearance", + type: ModuleType.PODCAST, + data: { + items: [ + { + links: [ + { + type: "SPOTIFY", + url: "https://open.spotify.com/episode/0WhokFSgLKuLzwGOWMB8bv?si=4487729d1ce84b61&nd=1&dlsi=f4abac7af2ee47fb", + }, + ], + customUrl: + "e36-hugh-and-hywel-redefining-the-cereal-market-with-eleat-performance-cereal-for-everyone", + metadata: { + name: "E.36 Hugh and Hywel Redefining The Cereal Market with ELEAT; Performance Cereal for Everyone", + images: [ + { + url: "https://i.scdn.co/image/ab67656300005f1f590e4c3c90bca339f64fad26", + }, + ], + publisher: "Georgia Symonds", + previewUrl: + "https://podz-content.spotifycdn.com/audio/clips/4OlZqKBg9goPcec6QRD09V/clip_0_60000.mp3", + }, + order: 0, + visible: true, + }, + ], + }, + }); +}; diff --git a/interface_base/src/services/profile-templates/CreatorInfluencerTemplate.ts b/interface_base/src/services/profile-templates/CreatorInfluencerTemplate.ts new file mode 100644 index 0000000..0c49946 --- /dev/null +++ b/interface_base/src/services/profile-templates/CreatorInfluencerTemplate.ts @@ -0,0 +1,221 @@ +import { OrderedParams, TemplateContent } from "./types"; +import { createModule } from "./createModule"; +import { SocialProfileLinkTypes } from "../../redux/User/types"; +import { ModuleType } from "@komi-app/shared-types" + +export function createInfluencerTemplate(): TemplateContent { + return { + avatar: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/gW6BFq8Hd8sPHWKo9JC6B.png?tr=w-564%2Ch-709%2Ccm-extract%2Cx-0%2Cy-0&crp=%7B%22x%22%3A0%2C%22y%22%3A0%2C%22zoomVal%22%3A1%7D", + socialProfileLinks: [ + { + link: "https://www.instagram.com/instagram", + type: SocialProfileLinkTypes.INSTAGRAM, + }, + { + link: "https://www.tiktok.com/@tiktok", + type: SocialProfileLinkTypes.TIKTOK, + }, + { + link: "https://www.youtube.com/user/youtube", + type: SocialProfileLinkTypes.YOUTUBE, + }, + { + link: "example@example.com", + type: SocialProfileLinkTypes.EMAIL, + }, + { + link: "https://www.example.com", + type: SocialProfileLinkTypes.WEBSITE, + }, + ], + modules: [ + createFeaturedVideoModule({ order: 0 }), + createBrandPartnersModule({ order: 1 }), + createShopModule({ order: 2 }), + createStayUpdatedDataCaptureModule({ order: 3 }), + createWorkTogetherDataCaptureModule({ order: 4 }), + ], + }; +} + +function createFeaturedVideoModule({ order }: { order: number }) { + return createModule({ + type: ModuleType.YOUTUBE_VIDEO, + name: "Featured Video", + order, + data: { + items: [ + { + metadata: { + title: + "my FIRST TIME (hosting obvi ;)) ☆ learning to bake + friends + football !!", + author_name: "anna x sitar", + author_url: "https://www.youtube.com/@annaxsitar", + type: "video", + height: 113, + width: 200, + version: "1.0", + provider_name: "YouTube", + provider_url: "https://www.youtube.com/", + thumbnail_height: 360, + thumbnail_width: 480, + thumbnail_url: "https://i.ytimg.com/vi/FDMmm2YrdfA/hqdefault.jpg", + html: '', + }, + url: "https://www.youtube.com/watch?v=FDMmm2YrdfA", + order: 0, + visible: true, + }, + ], + }, + }); +} + +const createBrandPartnersModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Partners", + order, + data: { + items: [ + { + title: "Rich Roll x On", + url: "https://www.on-running.com/en-us/stories/rich-roll", + order: 0, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/DJZ3cwf5Xg-fArdgq3fHT.jpg", + }, + { + title: "Bulk", + url: "https://www.bulk.com/uk/todays-offers.html?utm_source=http%3A%2F%2Fwww.londonfitnessguy.com&utm_medium=Affiliate_Marketing&utm_campaign=876413&awc=4822_1649848992_7f6dcd0b170d4e2887b8ccc9247d4231", + order: 1, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/n-soLRTuiiVSH8plY8Tec.webp", + }, + { + title: "Technogym", + url: "https://www.technogym.com/en-INT/", + order: 2, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/xAlnp83N_ht4w3Kd0kxyg.webp", + }, + { + title: "Vegamour", + url: "https://vegamour.com/", + order: 3, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/kCOYZxwLwTb3vwPM0icwZ.jpg", + }, + ], + }, + }); +}; + +const createShopModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Shop My Favourites", + order, + data: { + items: [ + { + url: "https://www.shopltk.com/explore/paytonsartain", + order: 0, + title: "Shop LTK", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/64YEZ4HvX5WKspPO0ocM3.png", + }, + { + url: "https://www.amazon.com/shop/paytonsartain", + order: 0, + title: "Amazon", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/LmyZ0rzkaaFAplIfpcMz4.jpg", + }, + ], + }, + }); +}; + +const createStayUpdatedDataCaptureModule = ({ order }: OrderedParams) => + createModule({ + name: "Stay Updated!", + type: ModuleType.FORM_DATA, + order, + data: { + items: [ + { + form: { + name: "Updates", + fields: [ + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Enter email", + order: 1, + required: true, + }, + { + name: "INPUT", + type: "INPUT", + label: "Name", + order: 0, + required: true, + }, + ], + }, + order: 0, + title: "Exclusive updates", + layout: "left", + visible: true, + subTitle: "Receive updates and exclusive discounts", + enableImage: false, + }, + ], + }, + }); + +const createWorkTogetherDataCaptureModule = ({ order }: OrderedParams) => + createModule({ + name: "Work Together!", + type: ModuleType.FORM_DATA, + order, + data: { + items: [ + { + form: { + name: "Updates", + fields: [ + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Enter email", + order: 1, + required: true, + }, + { + name: "INPUT", + type: "INPUT", + label: "Name", + order: 0, + required: true, + }, + ], + }, + order: 0, + title: "Business enquiries", + layout: "left", + visible: true, + subTitle: "Let's explore working together", + enableImage: false, + }, + ], + }, + }); diff --git a/interface_base/src/services/profile-templates/EntertainmentTemplate.ts b/interface_base/src/services/profile-templates/EntertainmentTemplate.ts new file mode 100644 index 0000000..2b4736d --- /dev/null +++ b/interface_base/src/services/profile-templates/EntertainmentTemplate.ts @@ -0,0 +1,150 @@ +import { OrderedParams, TemplateContent } from "./types"; +import { createModule } from "./createModule"; +import { SocialProfileLinkTypes } from "../../redux/User/types"; +import { ModuleType } from "@komi-app/shared-types"; + +export function createEntertainmentTemplate(): TemplateContent { + return { + avatar: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/3R9Ut5hndLxoxogx7bqSF.png?tr=w-564%2Ch-709%2Ccm-extract%2Cx-0%2Cy-0&crp=%7B%22x%22%3A0%2C%22y%22%3A0%2C%22zoomVal%22%3A1%7D", + socialProfileLinks: [ + { + link: "https://www.instagram.com/instagram", + type: SocialProfileLinkTypes.INSTAGRAM, + }, + { + link: "https://www.youtube.com/user/youtube", + type: SocialProfileLinkTypes.YOUTUBE, + }, + { + link: "https://www.facebook.com/facebook", + type: SocialProfileLinkTypes.FACEBOOK, + }, + { + link: "https://www.tiktok.com/@tiktok", + type: SocialProfileLinkTypes.TIKTOK, + }, + { + link: "https://www.example.com", + type: SocialProfileLinkTypes.WEBSITE, + }, + ], + modules: [ + createYoutubeModule({ order: 0 }), + createLinkModule({ order: 1 }), + createProductModule({ order: 2 }), + ], + }; +} + +const createYoutubeModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.YOUTUBE_VIDEO, + name: "Featured Video", + order, + data: { + items: [ + { + url: "https://www.youtube.com/watch?v=EGK5qtXuc1Q", + visible: true, + metadata: { + html: '', + type: "video", + title: "Luther: The Fallen Sun | Official Trailer | Netflix", + width: 200, + height: 113, + version: "1.0", + author_url: "https://www.youtube.com/@Netflix", + author_name: "Netflix", + provider_url: "https://www.youtube.com/", + provider_name: "YouTube", + thumbnail_url: "https://i.ytimg.com/vi/EGK5qtXuc1Q/hqdefault.jpg", + thumbnail_width: 480, + thumbnail_height: 360, + }, + }, + ], + }, + }); +}; + +const createLinkModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Brand Partners", + order, + data: { + items: [ + { + url: "https://vegamour.com/", + order: 0, + title: "Vegamour", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/9_3djhIH2EPEHkQzU3A3z.jpg", + }, + { + url: "https://www.keurig.com/", + order: 1, + title: "Keurig", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/nhrM1-XMKysqdWGDcDOV8.png", + }, + { + url: "https://www.greygoose.com/", + order: 2, + title: "Grey Goose", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/w3DlK7fZAP8howboqpjjs.jpg", + }, + ], + }, + }); +}; + +const createProductModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.PRODUCT, + name: "Store", + order, + data: { + items: [ + { + url: "https://shop.2hrset.com/products/aura-red-hoodie", + order: 0, + price: 70, + title: "AURA RED HOODIE", + visible: true, + currency: "GBP", + isUpdate: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/fAzK-330B9Smx0fnBtKPW.png", + }, + { + url: "https://shop.2hrset.com/products/aura-black-hoodie", + order: 1, + price: 70, + title: "AURA BLACK HOODIE", + visible: true, + currency: "GBP", + isUpdate: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/2WJjgYlE-_9M2Zpn92shM.png", + }, + { + url: "https://shop.2hrset.com/products/cus-i-can-black-t-shirt", + order: 2, + price: 25, + title: "CUS I CAN BLACK T SHIRT", + visible: true, + currency: "GBP", + isUpdate: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/YOU_jh01XWwl1-KOfavyl.png", + }, + ], + }, + }); +}; diff --git a/interface_base/src/services/profile-templates/FashionTemplate.ts b/interface_base/src/services/profile-templates/FashionTemplate.ts new file mode 100644 index 0000000..5e2c074 --- /dev/null +++ b/interface_base/src/services/profile-templates/FashionTemplate.ts @@ -0,0 +1,201 @@ +import { OrderedParams, TemplateContent } from "./types"; +import { createModule } from "./createModule"; +import { SocialProfileLinkTypes } from "../../redux/User/types"; +import { ModuleType } from "@komi-app/shared-types"; + +export function createFashionTemplate(): TemplateContent { + return { + avatar: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/hV66CcZr06BiP--0kaXe0.png?tr=w-564%2Ch-709%2Ccm-extract%2Cx-0%2Cy-0&crp=%7B%22x%22%3A0%2C%22y%22%3A0%2C%22zoomVal%22%3A1%7D", + socialProfileLinks: [ + { + link: "https://www.instagram.com/instagram", + type: SocialProfileLinkTypes.INSTAGRAM, + }, + { + link: "https://www.tiktok.com/@tiktok", + type: SocialProfileLinkTypes.TIKTOK, + }, + { + link: "example@example.com", + type: SocialProfileLinkTypes.EMAIL, + }, + { + link: "https://www.example.com", + type: SocialProfileLinkTypes.WEBSITE, + }, + { + link: "https://www.youtube.com/user/youtube", + type: SocialProfileLinkTypes.YOUTUBE, + }, + ], + modules: [ + createYoutubeModule({ order: 0 }), + createShopMyLookModule({ order: 1 }), + createBrandPartnersModule({ order: 2 }), + createFavoriteProductsModule({ order: 3 }), + createWorkTogetherDataCaptureModule({ order: 4 }), + ], + }; +} + +const createYoutubeModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.YOUTUBE_VIDEO, + name: "Latest Video", + order, + data: { + items: [ + { + url: "https://www.youtube.com/watch?v=nFdNvI76-Sg&pp=ygUcIE1hcmphbiBUYWJpYnphZGFpbnRlcnZpZXdzIA%3D%3D", + visible: true, + metadata: { + html: '', + type: "video", + title: + "Influencers ‘very resilient’ in considering migration from TikTok amid possible ban: Content creator", + width: 200, + height: 113, + version: "1.0", + author_url: "https://www.youtube.com/@YahooFinance", + author_name: "Yahoo Finance", + provider_url: "https://www.youtube.com/", + provider_name: "YouTube", + thumbnail_url: "https://i.ytimg.com/vi/nFdNvI76-Sg/hqdefault.jpg", + thumbnail_width: 480, + thumbnail_height: 360, + }, + }, + ], + }, + }); +}; + +const createShopMyLookModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Shop My Looks", + order, + data: { + items: [ + { + url: "https://www.amazon.com/shop/lianev?ref_=cm_sw_r_apin_aipsfshop_aipsflianev_7R91RBB7YE1FBXK5NG0Z", + order: 0, + title: "Amazon Storefront", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/Ya-qVK3tjepNSFsyPORcb.jpg", + }, + { + url: "https://shopmy.us/annasitar", + order: 1, + title: "ShopMy Shelf", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/f0NJb8RxPZkpj4hJwGO-d.jpg", + }, + ], + }, + }); +}; + +const createBrandPartnersModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Brand Partners", + order, + data: { + items: [ + { + title: "Vegamour", + url: "https://vegamour.com/", + order: 0, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/uzrsfKjHs3sEzgIVrWu_a.jpg", + }, + { + title: "Keurig", + url: "https://www.keurig.com/", + order: 1, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/f6QH8foSR0gQgydDJm053.png", + }, + { + title: "Grey Goose", + url: "https://www.greygoose.com/", + order: 2, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/i6yWNOMHDGnOCQyb_WZoM.jpg", + }, + ], + }, + }); +}; + +const createFavoriteProductsModule = ({ order }: OrderedParams) => + createModule({ + name: "Favorite Products", + type: ModuleType.LINK, + order, + data: { + items: [ + { + title: "Amazon", + url: "https://www.amazon.com/shop/youngcouture", + order: 0, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/nTmAXq2Yd_uv5khMsZanS.jpg", + }, + { + title: "LTK", + url: "https://www.shopltk.com/explore/Youngcouture", + order: 1, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/ZnbCII4MgdRoxd3JGOKE8.png", + }, + ], + }, + }); + +const createWorkTogetherDataCaptureModule = ({ order }: OrderedParams) => + createModule({ + name: "Work Together!", + type: ModuleType.FORM_DATA, + order, + data: { + items: [ + { + form: { + name: "Updates", + fields: [ + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Enter email", + order: 1, + required: true, + }, + { + name: "INPUT", + type: "INPUT", + label: "Name", + order: 0, + required: true, + }, + ], + }, + order: 0, + title: "Business enquiries", + layout: "left", + visible: true, + subTitle: "Let's explore working together", + enableImage: false, + }, + ], + }, + }); diff --git a/interface_base/src/services/profile-templates/LifestyleTemplate.ts b/interface_base/src/services/profile-templates/LifestyleTemplate.ts new file mode 100644 index 0000000..7f0295c --- /dev/null +++ b/interface_base/src/services/profile-templates/LifestyleTemplate.ts @@ -0,0 +1,441 @@ +import { OrderedParams, TemplateContent } from "./types"; +import { createModule } from "./createModule"; +import { SocialProfileLinkTypes } from "../../redux/User/types"; +import { ModuleType } from "@komi-app/shared-types" + +export function createLifestyleTemplate(): TemplateContent { + return { + avatar: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/V7kbZ_qYgCD_OgpJuhlz6.png?tr=w-564%2Ch-709%2Ccm-extract%2Cx-0%2Cy-0&crp=%7B%22x%22%3A0%2C%22y%22%3A0%2C%22zoomVal%22%3A1%7D", + socialProfileLinks: [ + { + link: "https://www.instagram.com/instagram", + type: SocialProfileLinkTypes.INSTAGRAM, + }, + { + link: "https://www.example.com", + type: SocialProfileLinkTypes.WEBSITE, + }, + { + link: "https://www.tiktok.com/@tiktok", + type: SocialProfileLinkTypes.TIKTOK, + }, + { + link: "example@example.com", + type: SocialProfileLinkTypes.EMAIL, + }, + { + link: "https://www.youtube.com/user/youtube", + type: SocialProfileLinkTypes.YOUTUBE, + }, + ], + modules: [ + createFeaturedVideoModule({ order: 0 }), + createJoinMyProgramModule({ order: 2 }), + createBrandPartnersModule({ order: 1 }), + createFavoriteProductsModule({ order: 2 }), + createTrainingPlaylistModule({ order: 3 }), + createWorkTogetherDataCaptureModule({ order: 4 }), + createExclusiveUpdatesDataCaptureModule({ order: 5 }), + ], + }; +} + +function createFeaturedVideoModule({ order }: { order: number }) { + return createModule({ + type: ModuleType.YOUTUBE_VIDEO, + name: "Featured Video", + order, + data: { + items: [ + { + metadata: { + title: "My Biggest Training Mistakes", + author_name: "Mike Thurston", + author_url: "https://www.youtube.com/@MikeThurston", + type: "video", + height: 113, + width: 200, + version: "1.0", + provider_name: "YouTube", + provider_url: "https://www.youtube.com/", + thumbnail_height: 360, + thumbnail_width: 480, + thumbnail_url: "https://i.ytimg.com/vi/lHeMwbsDwic/hqdefault.jpg", + html: '', + }, + url: "https://www.youtube.com/watch?v=lHeMwbsDwic", + order: 0, + visible: true, + }, + ], + }, + }); +} + +const createJoinMyProgramModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Join My Program", + order, + data: { + items: [ + { + url: "https://www.chelseyrosetraining.com/personalized-program", + order: 0, + title: "Get Started", + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/mBCY2ILitgcfKn9psxkG0.webp", + }, + ], + }, + }); +}; + +const createBrandPartnersModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Partners", + order, + data: { + items: [ + { + title: "Rich Roll x On", + url: "https://www.on-running.com/en-us/stories/rich-roll", + order: 0, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/DJZ3cwf5Xg-fArdgq3fHT.jpg", + }, + { + title: "Bulk", + url: "https://www.bulk.com/uk/todays-offers.html?utm_source=http%3A%2F%2Fwww.londonfitnessguy.com&utm_medium=Affiliate_Marketing&utm_campaign=876413&awc=4822_1649848992_7f6dcd0b170d4e2887b8ccc9247d4231", + order: 1, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/n-soLRTuiiVSH8plY8Tec.webp", + }, + ], + }, + }); +}; + +const createFavoriteProductsModule = ({ order }: OrderedParams) => + createModule({ + name: "Favorite Products", + type: ModuleType.LINK, + order, + data: { + items: [ + { + title: "WHOOP", + url: "https://join.whoop.com/en-eu/mikethurston", + order: 0, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/ncF8x778tSHQhy-c_6xy1.jpeg", + }, + { + title: "AG1", + url: "https://drinkAG1.com/richroll", + order: 1, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/tFzKRHarTR8ZgxAdLVoIv.jpeg", + }, + { + title: "Roka", + url: "https://www.roka.com/richroll", + order: 2, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/paF0eqrnn59gn5opk9A-o.jpeg", + }, + ], + }, + }); + +const createTrainingPlaylistModule = ({ order }: OrderedParams) => + createModule({ + name: "Training Playlist", + type: ModuleType.MUSIC, + order, + data: { + items: [ + { + id: "97a75b3d-c336-4a04-89c2-6a0fe63cf9ef", + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/5ayvx8hZKuQxlxbTH3D327", + type: "SPOTIFY", + order: 0, + }, + { + url: "https://music.apple.com/us/album/reckless-with-your-love/1439624490?i=1439624492", + type: "APPLE_MUSIC", + order: 1, + }, + ], + order: 0, + artists: [], + urlSlug: "reckless-with-your-love", + visible: true, + metadata: { + name: "Reckless (With Your Love)", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b273bb7712a3d3d9dfe7517d5e1f", + width: 640, + height: 640, + }, + ], + length: 347, + artists: [ + { + name: "Azari & III", + }, + ], + albumName: "Azari & III", + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview128/v4/19/9b/4a/199b4aa3-484d-3f47-18b8-c25d74cc33c7/mzaf_1513240816008225300.plus.aac.p.m4a", + externalIds: { + upc: "00602527953069", + isrc: "GBX8P1100014", + }, + releaseYear: 2011, + resourceIds: { + ALBUM: "7vd55snhAOrAXNsqxBqX1T", + TRACK: "5ayvx8hZKuQxlxbTH3D327", + }, + }, + }, + { + id: "3c846d60-7163-4a4a-89a9-7e5f9e3e18c7", + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/5kAwEqCAJ4yHdHmlAMSczs", + type: "SPOTIFY", + order: 0, + }, + { + url: "https://music.apple.com/us/album/these-are-just-places-to-me-now/1469880784?i=1469881138", + type: "APPLE_MUSIC", + order: 1, + }, + ], + order: 1, + artists: [], + urlSlug: "these-are-just-places-to-me-now", + visible: true, + metadata: { + name: "These Are Just Places To Me Now", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b273891391ef7b7a14c7d938a788", + width: 640, + height: 640, + }, + ], + length: 422, + artists: [ + { + name: "Folamour", + }, + ], + albumName: "Ordinary Drugs", + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview112/v4/6b/16/ad/6b16ad46-1f3a-a4f0-d68b-2ca3fdd57fb6/mzaf_3170454564746974441.plus.aac.p.m4a", + externalIds: { + upc: "3615939562405", + isrc: "GBKQU1905220", + }, + releaseYear: 2019, + resourceIds: { + ALBUM: "3na24PKpM5Bh0xwvIcpPms", + TRACK: "5kAwEqCAJ4yHdHmlAMSczs", + }, + }, + }, + { + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/5PlBnkB04KU0YtFSEls01E", + type: "SPOTIFY", + order: 0, + }, + { + url: "https://music.apple.com/us/album/play-with-my/1436029151?i=1436029152", + type: "APPLE_MUSIC", + order: 1, + }, + ], + order: 2, + artists: [], + urlSlug: "play-with-my", + visible: true, + metadata: { + name: "Play With My...", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b2733bd282cb191cf4021b1ef24a", + width: 640, + height: 640, + }, + ], + length: 447, + artists: [ + { + name: "N.W.N.", + }, + ], + albumName: "Play With My...", + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview115/v4/ec/3e/00/ec3e008b-da86-6c16-8b5b-a170a2c072d3/mzaf_16941437273554829699.plus.aac.p.m4a", + externalIds: { + upc: "5054283871715", + isrc: "GBKQU1888209", + }, + releaseYear: 2018, + resourceIds: { + ALBUM: "76nSFuthlvEnfJj3Dw83X9", + TRACK: "5PlBnkB04KU0YtFSEls01E", + }, + }, + }, + { + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/4q7XpZJvOrt0huFtzCM41l", + type: "SPOTIFY", + order: 0, + }, + { + url: "https://music.apple.com/us/album/i-can-feel-fingertips-yahlic-remix/1462323789?i=1462323994", + type: "APPLE_MUSIC", + order: 1, + }, + ], + order: 3, + artists: [], + urlSlug: "i-can-feel-fingertips--yahlic-remix", + visible: true, + metadata: { + name: "I Can Feel (Fingertips) - Yahlic Remix", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b273505a9cf68ded84617024fcdb", + width: 640, + height: 640, + }, + ], + length: 298, + artists: [ + { + name: "Ferdinand Weber", + }, + { + name: "Yahlic", + }, + ], + albumName: "I Can Feel (Fingertips) [Remixes]", + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview124/v4/9c/ed/7e/9ced7e73-acef-dc82-5c22-50de5bdabe66/mzaf_11015565411271544447.plus.aac.p.m4a", + externalIds: { + upc: "5054526191815", + isrc: "GBKPL1949809", + }, + releaseYear: 2019, + resourceIds: { + ALBUM: "1wI0RWM0O6GmFYnU1OYITw", + TRACK: "4q7XpZJvOrt0huFtzCM41l", + }, + }, + }, + ], + }, + }); + +const createWorkTogetherDataCaptureModule = ({ order }: OrderedParams) => + createModule({ + name: "Work Together!", + type: ModuleType.FORM_DATA, + order, + data: { + items: [ + { + form: { + name: "Updates", + fields: [ + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Enter email", + order: 1, + required: true, + }, + { + name: "INPUT", + type: "INPUT", + label: "Name", + order: 0, + required: true, + }, + ], + }, + order: 0, + title: "Business enquiries", + layout: "left", + visible: true, + subTitle: "Let's explore working together", + enableImage: false, + }, + ], + }, + }); + +const createExclusiveUpdatesDataCaptureModule = ({ order }: OrderedParams) => + createModule({ + name: "Exclusive updates", + type: ModuleType.FORM_DATA, + order, + data: { + items: [ + { + form: { + name: "Updates", + fields: [ + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Enter email", + order: 1, + required: true, + }, + { + name: "INPUT", + type: "INPUT", + label: "Name", + order: 0, + required: true, + }, + ], + }, + order: 0, + title: "Exclusive updates", + layout: "left", + visible: true, + subTitle: "Receive updates and exclusive discounts", + enableImage: false, + }, + ], + }, + }); diff --git a/interface_base/src/services/profile-templates/MusicTemplate.ts b/interface_base/src/services/profile-templates/MusicTemplate.ts new file mode 100644 index 0000000..079a06b --- /dev/null +++ b/interface_base/src/services/profile-templates/MusicTemplate.ts @@ -0,0 +1,109 @@ +import { + ATOMIC_CITY_MUSIC, + JASON_DERULO_VIDEO, + LOCKED_UP_MUSIC, + MAJID_JORDAN_VIDEO, + ON_MY_MIND_MUSIC, + PURSUIT_OF_HAPPINESS_MUSIC, + STAY_INFORMED_FORM, + TSHIRT_PRODUCT_1, + TSHIRT_PRODUCT_2, + TSHIRT_PRODUCT_3, + TURN_ON_THE_LIGHTS_AGAIN_MUSIC, + U2_STAY_VIDEO, +} from "./data/music-template.data"; +import { OrderedParams, TemplateContent } from "./types"; +import { createModule } from "./createModule"; +import { SocialProfileLinkTypes } from "../../redux/User/types"; +import { ModuleType } from "@komi-app/shared-types"; + +export function createMusicTemplate(): TemplateContent { + return { + avatar: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/PmG0ghRNCmHKi5vULZN0n.png?tr=w-564%2Ch-709%2Ccm-extract%2Cx-0%2Cy-0&crp=%7B%22x%22%3A0%2C%22y%22%3A0%2C%22zoomVal%22%3A1%7D", + socialProfileLinks: [ + { + link: "https://www.instagram.com/instagram", + type: SocialProfileLinkTypes.INSTAGRAM, + }, + { + link: "https://open.spotify.com/user/spotify", + type: SocialProfileLinkTypes.SPOTIFY, + }, + { + link: "https://www.youtube.com/user/username", + type: SocialProfileLinkTypes.YOUTUBE, + }, + { + link: "https://www.tiktok.com/@tiktok", + type: SocialProfileLinkTypes.TIKTOK, + }, + { + link: "https://music.apple.com/gb/artist/artistname/userid", + type: SocialProfileLinkTypes.APPLE_MUSIC, + }, + ], + modules: [ + createHeaderMusicModule({ order: 0 }), + createYoutubeModule({ order: 1 }), + createMusicModule({ order: 2 }), + createStayInformedDataCaptureModule({ order: 3 }), + createProductModule({ order: 4 }), + ], + }; +} + +const createHeaderMusicModule = ({ order }: OrderedParams) => + createModule({ + name: "Latest Release", + type: ModuleType.MUSIC, + order, + data: { + items: [ATOMIC_CITY_MUSIC], + }, + }); + +const createYoutubeModule = ({ order }: OrderedParams) => + createModule({ + name: "Music Videos", + type: ModuleType.YOUTUBE_VIDEO, + order, + data: { + items: [JASON_DERULO_VIDEO, MAJID_JORDAN_VIDEO, U2_STAY_VIDEO], + }, + }); + +const createMusicModule = ({ order }: OrderedParams) => + createModule({ + name: "Playlist", + type: ModuleType.MUSIC, + order, + data: { + items: [ + PURSUIT_OF_HAPPINESS_MUSIC, + LOCKED_UP_MUSIC, + ON_MY_MIND_MUSIC, + TURN_ON_THE_LIGHTS_AGAIN_MUSIC, + ], + }, + }); + +const createStayInformedDataCaptureModule = ({ order }: OrderedParams) => + createModule({ + name: "Stay Informed", + type: ModuleType.FORM_DATA, + order, + data: { + items: [STAY_INFORMED_FORM], + }, + }); + +const createProductModule = ({ order }: OrderedParams) => + createModule({ + name: "Merch", + type: ModuleType.PRODUCT, + order, + data: { + items: [TSHIRT_PRODUCT_1, TSHIRT_PRODUCT_2, TSHIRT_PRODUCT_3], + }, + }); diff --git a/interface_base/src/services/profile-templates/OtherTemplate.ts b/interface_base/src/services/profile-templates/OtherTemplate.ts new file mode 100644 index 0000000..f6a503f --- /dev/null +++ b/interface_base/src/services/profile-templates/OtherTemplate.ts @@ -0,0 +1,229 @@ +import { OrderedParams, TemplateContent } from "./types"; +import { createModule } from "./createModule"; +import { SocialProfileLinkTypes } from "../../redux/User/types"; +import { ModuleType } from "@komi-app/shared-types"; + +export function createOtherTemplate(): TemplateContent { + return { + avatar: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/tB82XzHIYudH6jeP3KJzH.png?tr=w-564%2Ch-709%2Ccm-extract%2Cx-0%2Cy-0&crp=%7B%22x%22%3A0%2C%22y%22%3A0%2C%22zoomVal%22%3A1%7D", + socialProfileLinks: [ + { + link: "https://www.instagram.com/instagram", + type: SocialProfileLinkTypes.INSTAGRAM, + }, + { + link: "example@example.com", + type: SocialProfileLinkTypes.EMAIL, + }, + { + link: "https://www.example.com", + type: SocialProfileLinkTypes.WEBSITE, + }, + { + link: "https://www.facebook.com/facebook", + type: SocialProfileLinkTypes.FACEBOOK, + }, + { + link: "https://www.youtube.com/user/youtube", + type: SocialProfileLinkTypes.YOUTUBE, + }, + ], + modules: [ + createProductModule({ order: 0 }), + createEventsModule({ order: 1 }), + createStayInformedModule({ order: 2 }), + createVideosModule({ order: 3 }), + createSharePressModule({ order: 4 }), + ], + }; +} + +const createProductModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.PRODUCT, + name: "Products", + order, + data: { + items: [ + { + url: "https://shop.dearmedia.com/collections/note-to-self/products/note-to-self-lucky-tee", + order: 0, + price: 35, + title: "NOTE TO SELF: LUCKY TEE", + visible: true, + currency: "USD", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/zJao1pNDd_h8NH-vLG2Pe.jpg", + }, + { + url: "https://shop.dearmedia.com/collections/note-to-self/products/note-to-self-222-tee", + order: 1, + price: 35, + title: "NOTE TO SELF: 222 TEE", + visible: true, + currency: "USD", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/BsUr1sA6ygP0yhXasL139.jpg", + }, + { + url: "https://shop.dearmedia.com/collections/note-to-self/products/note-to-self-members-crew-burgundy", + order: 2, + price: 48, + title: "NOTE TO SELF: MEMBER’S CREW BURGUNDY", + visible: true, + currency: "USD", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/Fx8QGIWTwisdgpuB21-sA.jpg", + }, + ], + }, + }); +}; + +const createEventsModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.EVENTS, + name: "Events", + order, + data: { + items: [ + { + eventDate: "2024-05-09T23:59:59.999Z", + venueName: "Hollywood Bowl", + location: "Los Angeles, CA", + ticketLink: "https://www.bandsintown.com/e/104986854", + soldOut: false, + visible: true, + }, + { + eventDate: "2024-03-22T23:59:59.999Z", + venueName: "Mahaffey Theater", + location: "Saint Petersburg, FL", + ticketLink: "https://www.bandsintown.com/e/104286058", + soldOut: false, + visible: true, + }, + ], + }, + }); +}; + +const createStayInformedModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.FORM_DATA, + name: "Stay Informed", + order, + data: { + items: [ + { + form: { + name: "Updates", + fields: [ + { + name: "INPUT", + type: "INPUT", + label: "Name", + required: true, + }, + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Email", + required: true, + }, + ], + }, + title: "Exclusive updates", + layout: "left", + visible: true, + subTitle: "Receive updates and exclusive discounts", + enableImage: false, + }, + ], + }, + }); +}; + +const createVideosModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.YOUTUBE_VIDEO, + name: "Videos", + order, + data: { + items: [ + { + metadata: { + title: "My Biggest Training Mistakes", + author_name: "Mike Thurston", + author_url: "https://www.youtube.com/@MikeThurston", + type: "video", + height: 113, + width: 200, + version: "1.0", + provider_name: "YouTube", + provider_url: "https://www.youtube.com/", + thumbnail_height: 360, + thumbnail_width: 480, + thumbnail_url: "https://i.ytimg.com/vi/lHeMwbsDwic/hqdefault.jpg", + html: '', + }, + url: "https://www.youtube.com/watch?v=lHeMwbsDwic", + order: 0, + visible: true, + }, + { + url: "https://www.youtube.com/watch?v=nFdNvI76-Sg&pp=ygUcIE1hcmphbiBUYWJpYnphZGFpbnRlcnZpZXdzIA%3D%3D", + order: 1, + visible: true, + metadata: { + html: '', + type: "video", + title: + "Influencers ‘very resilient’ in considering migration from TikTok amid possible ban: Content creator", + width: 200, + height: 113, + version: "1.0", + author_url: "https://www.youtube.com/@YahooFinance", + author_name: "Yahoo Finance", + provider_url: "https://www.youtube.com/", + provider_name: "YouTube", + thumbnail_url: "https://i.ytimg.com/vi/nFdNvI76-Sg/hqdefault.jpg", + thumbnail_width: 480, + thumbnail_height: 360, + }, + }, + ], + }, + }); +}; + +const createSharePressModule = ({ order }: OrderedParams) => { + return createModule({ + type: ModuleType.LINK, + name: "Share Press", + order, + data: { + items: [ + { + title: + "Rolling Stone | Munya Chawawa on redefining masculinity through his comedy", + url: "https://www.rollingstone.co.uk/tv/features/munya-chawawa-interview-comedy-rolling-stone-33917/", + order: 0, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/EZ11NYEI_fHGqX81y4ogp.jpeg", + }, + { + title: + "The Guardian | Family joke-offs, double life, and going viral", + url: "https://www.theguardian.com/lifeandstyle/2023/oct/14/munya-chawawa-looks-back-bullies-shamed-me-for-being-proud-of-my-culture-so-id-go-home-and-write-raps-about-the-cold-hard-streets-of-norfolk", + order: 1, + visible: true, + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/GpXr-LjUoaxYA9sCAFXv_.jpeg", + }, + ], + }, + }); +}; diff --git a/interface_base/src/services/profile-templates/TemplateService.ts b/interface_base/src/services/profile-templates/TemplateService.ts new file mode 100644 index 0000000..13b29cb --- /dev/null +++ b/interface_base/src/services/profile-templates/TemplateService.ts @@ -0,0 +1,32 @@ +import { TemplateContent } from "./types"; +import { createMusicTemplate } from "./MusicTemplate"; +import { createInfluencerTemplate } from "./CreatorInfluencerTemplate"; +import { createBusinessTemplate } from "./BusinessTemplate"; +import { createEntertainmentTemplate } from "./EntertainmentTemplate"; +import { createLifestyleTemplate } from "./LifestyleTemplate"; +import { createFashionTemplate } from "./FashionTemplate"; +import { createOtherTemplate } from "./OtherTemplate"; + +type TemplateFunc = () => TemplateContent; + +const industryToTemplateFunc: Record = { + music: createMusicTemplate, + "influencer / creator": createInfluencerTemplate, + business: createBusinessTemplate, + entertainment: createEntertainmentTemplate, + "lifestyle & wellness": createLifestyleTemplate, + "fashion & beauty": createFashionTemplate, + other: createOtherTemplate, +}; + +const defaultTemplateFunc = createOtherTemplate; + +export function createTemplateForIndustry( + industry: string | undefined +): TemplateContent { + const key = industry?.toLowerCase() || "other"; + + const getTemplateContent = industryToTemplateFunc[key] || defaultTemplateFunc; + + return getTemplateContent(); +} diff --git a/interface_base/src/services/profile-templates/createModule.ts b/interface_base/src/services/profile-templates/createModule.ts new file mode 100644 index 0000000..498f542 --- /dev/null +++ b/interface_base/src/services/profile-templates/createModule.ts @@ -0,0 +1,31 @@ +import { v4 as uuidv4 } from "uuid"; + +import { ModuleInitParams } from "./types"; +import { + TalentModuleMixItem, + TalentProfileModule, +} from "../../models/talent/talent-profile-module.model"; + +export function createModule({ + type, + groupId, + data, + order, + name, +}: ModuleInitParams): TalentProfileModule { + return { + id: uuidv4(), + order, + name, + type: type, + items: [], + expand: true, + visible: true, + isEdit: false, + isCreate: true, + isLoading: false, + groupId, + showTitle: true, + ...data, + }; +} diff --git a/interface_base/src/services/profile-templates/data/music-template.data.ts b/interface_base/src/services/profile-templates/data/music-template.data.ts new file mode 100644 index 0000000..5f5714c --- /dev/null +++ b/interface_base/src/services/profile-templates/data/music-template.data.ts @@ -0,0 +1,424 @@ +export const ATOMIC_CITY_MUSIC = { + id: "39b7b689-74c8-42ba-a60b-ecbb9dfc3829", + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/22Pn1poD9t6T2fcstx5VQj", + type: "SPOTIFY", + order: 0, + }, + { + url: "https://music.apple.com/us/album/atomic-city-mike-will-made-it-remix/1720787653?i=1720787910", + type: "APPLE_MUSIC", + order: 1, + }, + { + url: "https://music.youtube.com/watch?v=LFHFTsjigPw", + type: "YOUTUBE_MUSIC", + order: 2, + }, + { + url: "https://www.deezer.com/track/2580945862", + type: "DEEZER", + order: 3, + }, + ], + order: 0, + artists: [], + urlSlug: "atomic-city--mike-will-madeit-remix", + visible: true, + metadata: { + name: "Atomic City - Mike WiLL Made-It Remix", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b2734548047e8f66e3896abb2194", + width: 640, + height: 640, + }, + ], + length: 207, + artists: [ + { + name: "U2", + }, + { + name: "Mike WiLL Made-It", + }, + ], + albumName: "Atomic City (Mike WiLL Made-It Remix)", + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview126/v4/99/38/2a/99382a82-99a4-23de-b124-b94d8de5d1e7/mzaf_14365377404786663612.plus.aac.p.m4a", + externalIds: { + upc: "00602458975659", + isrc: "GBUM72310720", + }, + releaseYear: 2023, + resourceIds: { + ALBUM: "69sOXcC8u5RrYCszDSCM9S", + TRACK: "22Pn1poD9t6T2fcstx5VQj", + }, + }, +}; + +export const TURN_ON_THE_LIGHTS_AGAIN_MUSIC = { + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/6gdDu39yYqPcaTgCwYEW8i", + type: "SPOTIFY", + isVisible: true, + }, + { + url: "https://music.apple.com/us/album/turn-on-the-lights-again-feat-future/1676669792?i=1676670256", + type: "APPLE_MUSIC", + isVisible: true, + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview116/v4/3f/54/f7/3f54f7f3-b1cb-801b-80e7-c3aa2878b9c9/mzaf_1935685357412859076.plus.aac.p.m4a", + }, + { + url: "https://music.youtube.com/watch?v=rhUxOB7F-jk", + type: "YOUTUBE_MUSIC", + isVisible: true, + }, + { + url: "https://www.deezer.com/track/1832599647", + type: "DEEZER", + isVisible: true, + previewUrl: + "https://cdns-preview-6.dzcdn.net/stream/c-66d198d456ae2d28e4ff22259362ee63-3.mp3", + }, + ], + urlSlug: "turn-on-the-lights-again-feat-future", + visible: true, + isUpdate: true, + metadata: { + name: "Turn On The Lights again.. (feat. Future)", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b273444f129bbe6366c18d533650", + width: 640, + height: 640, + }, + ], + length: 268, + artists: [ + { + name: "Fred again..", + }, + { + name: "Swedish House Mafia", + }, + { + name: "Future", + }, + ], + albumName: "USB", + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview116/v4/3f/54/f7/3f54f7f3-b1cb-801b-80e7-c3aa2878b9c9/mzaf_1935685357412859076.plus.aac.p.m4a", + externalIds: { + upc: "5054197615252", + isrc: "GBAHS2201001", + }, + releaseYear: 2022, + resourceIds: { + ALBUM: "4NGzLSTgDOfMFPEQvB5MDc", + TRACK: "0UZFTyq4ogQ5RvfOHGPVdZ", + }, + externalUrls: {}, + }, +}; + +export const ON_MY_MIND_MUSIC = { + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/5lXY6PTuWXOludKy4zDQwM", + type: "SPOTIFY", + isVisible: true, + }, + ], + order: 1, + urlSlug: "on-my-mind", + visible: true, + metadata: { + name: "On My Mind", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b27341cdac44ad3dd7f37fd8e9a9", + width: 640, + height: 640, + }, + ], + length: 189, + artists: [ + { + name: "Diplo", + }, + { + name: "SIDEPIECE", + }, + ], + albumName: "Do You Dance?", + externalIds: { + upc: "810072040565", + isrc: "USZ4V1900134", + }, + releaseYear: 2021, + resourceIds: { + ALBUM: "6Az907HDvldO5qxqVyysz0", + TRACK: "54hA0ldJYyT1huGfSeOjdQ", + }, + externalUrls: {}, + }, +}; + +export const LOCKED_UP_MUSIC = { + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/7kITGCwFNyrwG98FQTjM53", + type: "SPOTIFY", + isVisible: true, + }, + { + url: "https://music.apple.com/us/album/locked-up/1698961901?i=1698961903", + type: "APPLE_MUSIC", + isVisible: true, + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview126/v4/5e/2d/75/5e2d7514-2f43-d8f3-51e2-653f5022d45d/mzaf_3328970847819539898.plus.aac.p.m4a", + }, + { + url: "https://music.youtube.com/watch?v=0_3JPgLuy-E", + type: "YOUTUBE_MUSIC", + isVisible: true, + }, + { + url: "https://www.deezer.com/track/2381346905", + type: "DEEZER", + isVisible: true, + previewUrl: + "https://cdns-preview-6.dzcdn.net/stream/c-66baeb584a01f775f6fef2633b37bd4e-3.mp3", + }, + ], + urlSlug: "locked-up-ft-akon", + visible: true, + metadata: { + name: "Locked Up (ft. Akon)", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b273e913e1bd44389b2837f419df", + width: 640, + height: 640, + }, + ], + length: 147, + artists: [ + { + name: "Steve Aoki", + }, + { + name: "Trinix", + }, + { + name: "Akon", + }, + ], + albumName: "Locked Up (ft. Akon)", + previewUrl: + "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview126/v4/5e/2d/75/5e2d7514-2f43-d8f3-51e2-653f5022d45d/mzaf_3328970847819539898.plus.aac.p.m4a", + externalIds: { + upc: "196922568008", + isrc: "USA2P2337662", + }, + releaseYear: 2023, + resourceIds: { + ALBUM: "4eUAfbKPrliNUtflCe6sEu", + TRACK: "7kITGCwFNyrwG98FQTjM53", + }, + externalUrls: {}, + }, +}; + +export const PURSUIT_OF_HAPPINESS_MUSIC = { + type: "NORMAL", + links: [ + { + url: "https://open.spotify.com/track/7m47Go71qTMBs4kTH7U8F8", + type: "SPOTIFY", + isVisible: true, + }, + ], + order: 3, + urlSlug: "pursuit-of-happiness--extended-steve-aoki-remix", + visible: true, + metadata: { + name: "Pursuit Of Happiness - Extended Steve Aoki Remix", + type: "TRACK", + images: [ + { + url: "https://i.scdn.co/image/ab67616d0000b273b1fa99e25a08df48d03134cc", + width: 640, + height: 640, + }, + ], + length: 374, + artists: [ + { + name: "Kid Cudi", + }, + { + name: "MGMT", + }, + { + name: "Ratatat", + }, + { + name: "Steve Aoki", + }, + ], + albumName: "Pursuit Of Happiness (International Version)", + externalIds: { + upc: "00602527335469", + isrc: "USUM70917762", + }, + releaseYear: 2010, + resourceIds: { + ALBUM: "5wgcGZb5ESiAS30iiPyNwq", + TRACK: "7m47Go71qTMBs4kTH7U8F8", + }, + externalUrls: {}, + }, +}; + +export const JASON_DERULO_VIDEO = { + id: "a63d8dab-4110-4c0b-a4ab-6a45cd135cf7", + url: "https://www.youtube.com/watch?v=02KkxftGSgI", + order: 0, + visible: true, + metadata: { + html: '', + type: "video", + title: "Jason Derulo - Glad U Came (Official Music Video)", + width: 200, + height: 113, + version: "1.0", + author_url: "https://www.youtube.com/@JasonDerulo", + author_name: "Jason Derulo", + provider_url: "https://www.youtube.com/", + provider_name: "YouTube", + thumbnail_url: "https://i.ytimg.com/vi/02KkxftGSgI/hqdefault.jpg", + thumbnail_width: 480, + thumbnail_height: 360, + }, +}; +export const MAJID_JORDAN_VIDEO = { + id: "4065e153-4ae1-4b53-a3e2-716e2d2860b6", + url: "https://www.youtube.com/watch?v=_LqU_BOGCbo", + order: 1, + visible: true, + metadata: { + html: '', + type: "video", + title: + "Majid Jordan - Waiting For You (ft. Naomi Sharon) [Official Visualizer]", + width: 200, + height: 150, + version: "1.0", + author_url: "https://www.youtube.com/@majidjordan", + author_name: "MAJID JORDAN", + provider_url: "https://www.youtube.com/", + provider_name: "YouTube", + thumbnail_url: "https://i.ytimg.com/vi/_LqU_BOGCbo/hqdefault.jpg", + thumbnail_width: 480, + thumbnail_height: 360, + }, +}; + +export const STAY_INFORMED_FORM = { + form: { + name: "Updates", + fields: [ + { + name: "EMAIL_ADDRESS", + type: "EMAIL_ADDRESS", + label: "Enter email", + order: 1, + required: true, + }, + { + name: "INPUT", + type: "INPUT", + label: "Name", + order: 0, + required: true, + }, + ], + }, + order: 0, + title: "Exclusive updates", + layout: "left", + visible: true, + subTitle: "Receive updates and exclusive discounts", + enableImage: false, +}; + +export const U2_STAY_VIDEO = { + metadata: { + title: "U2 – Stay (Faraway, So Close) (Official Lyric Video)", + author_name: "U2", + author_url: "https://www.youtube.com/@U2official", + type: "video", + height: 113, + width: 200, + version: "1.0", + provider_name: "YouTube", + provider_url: "https://www.youtube.com/", + thumbnail_height: 360, + thumbnail_width: 480, + thumbnail_url: "https://i.ytimg.com/vi/uBz3pfvjz94/hqdefault.jpg", + html: '', + }, + url: "https://youtu.be/uBz3pfvjz94?si=g8zc6FP5vmXDOSVO", + order: 2, + visible: true, +}; + +export const TSHIRT_PRODUCT_1 = { + id: "5d717fb5-ec81-4c04-bd13-44247d6ca65f", + url: "https://everpress.com/slothboogie-discoteca", + order: 0, + price: 30, + title: "BENVENUTO IN DISCOTECA", + visible: true, + currency: "GBP", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/rKN8M5OWMC3TqWKevFaBK.png", +}; + +export const TSHIRT_PRODUCT_2 = { + id: "5e29d801-5e41-4381-b442-5596ea6660d3", + url: "https://everpress.com/fresh-and-delicious", + order: 1, + price: 26, + title: "FRESH AND DELICIOUS", + visible: true, + currency: "GBP", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/LW9bjBuxRGJ9gS2JSQhr1.png", +}; + +export const TSHIRT_PRODUCT_3 = { + id: "700ef58c-6a9e-410d-934f-880db73cf7e0", + url: "https://everpress.com/human-after-all-pod-5", + order: 2, + price: 26, + title: "HUMAN AFTER ALL", + visible: true, + currency: "GBP", + thumbnail: + "https://komi-production-assets.s3-accelerate.amazonaws.com/photos/t7l1nEmwG_r5JrE9vRAsY.png", +}; diff --git a/interface_base/src/services/ryeShopifyServices.ts b/interface_base/src/services/ryeShopifyServices.ts new file mode 100644 index 0000000..e98b7f3 --- /dev/null +++ b/interface_base/src/services/ryeShopifyServices.ts @@ -0,0 +1,186 @@ +import { Response } from "./../redux/Common/types"; +import ShopifyService from "./ShopifyService"; +import RyeApi from "./api/rye.api"; +import { + ReorderShopifyStoreRequest, + ShopifyProductItem, +} from "models/talent/talent-profile-module.model"; + +interface RyeShopifyStore { + connected: boolean; + name: string; + shop: string; +} + +const normalizeProducts = ( + product: any, + shopDomain?: string, + collectionId?: string +) => { + const images = product.images as any[]; + const variants = product.variants as any[]; + return { + id: product.id, + shop: shopDomain, + collectionId: collectionId, + images: images.map((image) => ({ src: image.url })) || [], + title: product.title, + variants: + variants.map((variant) => ({ + price: product.price.displayValue, + priceV2: { + currencyCode: product.price.currency, + amount: String(Number(product.price.value) / 100), + }, + })) || [], + } as ShopifyProductItem; +}; + +export type PageInfo = { + endCursor: string; + hasNextPage: boolean; +}; + +export type NormalizedCollection = { + id: string; + title: string; +}; + +export default class RyeShopifyServices { + constructor( + private readonly api: RyeApi, + private readonly shopifyService: ShopifyService + ) {} + + fetchCollections = async ( + shopDomain: string, + after: string | null = null + ): Promise<{ collections: NormalizedCollection[]; pageInfo: PageInfo }> => { + const apiResponse = await this.api.getCollections(shopDomain, after); + const collections = (apiResponse.response.edges ?? []) as any[]; + + return { + collections: collections.map((x) => x.node), + pageInfo: apiResponse.response.pageInfo, + }; + }; + + fetchCollection = async (collectionId: string): Promise => { + const apiResponse = await this.api.getCollection(collectionId); + const collection = apiResponse.response?.shopifyCollection; + const collectionTitle = collection?.title; + const collectionSrc = undefined; + + const products = collection?.productsConnection.edges ?? []; + const normalizedProducts = products.map((el) => + normalizeProducts(el.node, undefined, collectionId) + ) as ShopifyProductItem[]; + + return { + id: collectionId, + title: collectionTitle, + src: collectionSrc, + products: normalizedProducts, + }; + }; + + fetchProducts = async ( + shopDomain: string, + collectionId?: string, + offset = 0 + ): Promise => { + const data = await this.api.getProducts( + shopDomain, + offset, + this.productsPageSize + ); + const products = data.response as any[]; + const normalizedProducts = products.map((product) => + normalizeProducts(product, shopDomain, collectionId) + ) as ShopifyProductItem[]; + + return normalizedProducts || []; + }; + + get productsPageSize() { + return 100; + } + + fetchProductsByIDs = async ( + shopDomain: string, + productIds: string[] + ): Promise => { + const data = await this.api.getProductsByIds(shopDomain, productIds); + const products = data.response?.productsByIDs.filter(Boolean) ?? []; + const normalizedProducts = products.map((product) => + normalizeProducts(product, shopDomain) + ) as ShopifyProductItem[]; + + return normalizedProducts || []; + }; + + checkIfAppInstalled = async (shopDomain: string): Promise => { + const data = await this.api.checkIfAppInstalled(shopDomain); + return Boolean(data.response); + }; + + reorderStore = (payload: ReorderShopifyStoreRequest[]) => { + return this.shopifyService.reorderRyeShopifyStore(payload); + }; + + getRyeStores = async ( + talentProfileId: string + ): Promise => { + const data = await this.shopifyService.getRyeShopifyStores(talentProfileId); + return data.response?.result?.stores || []; + }; + + createRyeStore = async ( + talentProfileId: string, + storeDomain: string + ): Promise => { + const resp = await this.shopifyService.createRyeShopifyStore( + talentProfileId, + storeDomain + ); + return Boolean(resp.ok && resp?.response?.success); + }; + + updateRyeStoreName = ( + talentProfileId: string, + storeDomain: string, + name: string + ): Promise> => { + return this.shopifyService.updateRyeShopifyStoreName( + talentProfileId, + storeDomain, + name + ); + }; + + updateRyeStoreStatus = ( + talentProfileId: string, + storeDomain: string, + connected: boolean + ): Promise> => { + return this.shopifyService.updateRyeShopifyStoreStatus( + talentProfileId, + storeDomain, + connected + ); + }; + + removeRyeStore = ( + talentProfileId: string, + storeDomain: string + ): Promise> => { + return this.shopifyService.removeRyeShopifyStore( + talentProfileId, + storeDomain + ); + }; + + generateRyeShopifyAppInstalLink(storeCanonicalDomain: string) { + return this.api.generateRyeShopifyAppInstalLink(storeCanonicalDomain); + } +} diff --git a/interface_base/src/styles/styles.ts b/interface_base/src/styles/styles.ts new file mode 100644 index 0000000..ac66b3b --- /dev/null +++ b/interface_base/src/styles/styles.ts @@ -0,0 +1,8 @@ +export const colorLightBlue = "#0085FF"; +export const colorGreyIcon = "#020025"; + +export const propsSignPostIcons = { + width: 24, + height: 24, + stroke: colorLightBlue, +}; diff --git a/interface_base/src/types/campaigns.ts b/interface_base/src/types/campaigns.ts new file mode 100644 index 0000000..9ef9675 --- /dev/null +++ b/interface_base/src/types/campaigns.ts @@ -0,0 +1,126 @@ +// Types + +import { CursorPaginatedItem } from "hooks/usePagination"; +import { TemplateDto } from "./templates"; +import { + CampaignScheduleType, + CampaignStatus as SMSCampaignStatus, + ScheduleType, +} from "@komi-app/messaging-sdk"; +import { FormValues, SmartScheduleFormTabs } from "@komi-app/creator-ui"; + +/** @deprecated Use @komi-app/messaging-sdk */ +export enum CampaignStatus { + Draft = "Draft", + Registered = "Registered", + Scheduled = "Scheduled", + Sending = "Sending", + Completed = "Completed", + Stopped = "Stopped", +} + +/** @deprecated */ +export interface Campaign extends CampaignDto { + campaignId: string; + talentProfileId?: string; + createdAt: number; + updatedAt: number; +} +export type CampaignWithCursor = CursorPaginatedItem; + +/** @deprecated */ +export interface CampaignDto { + name: string; + subject: string; + status: CampaignStatus; + schedule: Schedule; + template: TemplateDto | null; + openRate: number | null; + clickThroughRate: number | null; + segments: { segmentId: string; segmentName: string }[]; +} + +export interface SMSCampaign extends FormValues { + id: string; + status: SMSCampaignStatus; + scheduleType: ScheduleType; +} + +export type CampaignUpsertDto = Omit< + CampaignDto, + /* Relationships */ + | "template" + | "segments" + /* Server-managed */ + | "openRate" + | "clickThroughRate" + | "status" +> & { + templateId: string | null; + segmentIds: string[]; +}; + +export enum SendType { + Once = "Once", + Continuous = "Continuous", +} + +export interface Schedule { + sendType: SendType; + startTimestamp: number | null; + endTimestamp: number | null; + timezone: string | null; +} + +// Responses +export interface CampaignResponse { + campaign: Campaign; +} + +export interface CampaignReport { + name: string; + status: CampaignStatus; + performance: { + emailsSent: number; + openRate: number; + clickThroughRate: number; + bounced: number; + unsubscribed: number; + }; + details: { + templateImageUrl: string; + subject: string; + segments: { segmentId: string; segmentName: string }[]; + scheduledSendTimestamp: number; + recipients: number; + }; + stats: { + links: { linkName: string; uniqueClicks: number; totalClicks: number }[]; + totalOpens: number; + lastOpenedTimestamp: number | null; + }; +} +export interface CampaignReportResponse { + campaign: CampaignReport; +} + +export interface CampaignsResponse { + campaigns: Campaign[]; +} + +export interface CampaignsWithCursorResponse { + campaigns: CampaignWithCursor[]; +} +export interface CheckNameExistsResponse { + success: true; + nameExists: boolean; +} + +export type FormErrors> = Partial< + Record +>; + +export type SmartScheduleTabMap = { + [SmartScheduleFormTabs.SMART_SCHEDULE]: CampaignScheduleType.SMART_SCHEDULE; + [SmartScheduleFormTabs.ALL_AT_ONCE]: CampaignScheduleType.ALL_AT_ONCE; +}; diff --git a/interface_base/src/types/dsp.ts b/interface_base/src/types/dsp.ts new file mode 100644 index 0000000..884725e --- /dev/null +++ b/interface_base/src/types/dsp.ts @@ -0,0 +1,97 @@ +export enum DspType { + WEBSITE = "WEBSITE", + AMAZON_MUSIC = "AMAZON_MUSIC", + APPLE_MUSIC = "APPLE_MUSIC", + DEEZER = "DEEZER", + SOUNDCLOUD = "SOUNDCLOUD", + YOUTUBE_MUSIC = "YOUTUBE_MUSIC", + SPOTIFY = "SPOTIFY", + TIDAL = "TIDAL", + AUDIOMACK = "AUDIOMACK", + NAPSTER = "NAPSTER", + BANDCAMP = "BANDCAMP", + QOBUZ = "QOBUZ", + SIRIUSXM = "SIRIUSXM", + GAANA = "GAANA", + BEATPORT = "BEATPORT", + TIKTOK = "TIKTOK", + IHEARTRADIO = "IHEARTRADIO", + ANGHAMMI = "ANGHAMMI", + MIXCLOUD = "MIXCLOUD", + PANDORA = "PANDORA", + // digital download + ITUNES_DIGITAL_DOWNLOAD = "ITUNES_DIGITAL_DOWNLOAD", + AMAZONE_DIGITAL_DOWNLOAD = "AMAZONE_DIGITAL_DOWNLOAD", + // physical purchase + HMV = "HMV", + TARGET = "TARGET", + WALMART = "WALMART", + URBAN_OUTFITTERS = "URBAN_OUTFITTERS", + RECORD_STORE = "RECORD_STORE", + AMAZONE_PHYSICAL_PURCHASE = "AMAZONE_PHYSICAL_PURCHASE", + OFFICIAL_STORE = "OFFICIAL_STORE", + BANQUET_RECORDS = "BANQUET_RECORDS", + BEAR_TREE_RECORDS = "BEAR_TREE_RECORDS", + BLEEP = "BLEEP", + CRASH_RECORDS = "CRASH_RECORDS", + DEEJAY_DE = "DEEJAY_DE", + JUNO = "JUNO", + DRIFT = "DRIFT", + NORMAN_RECORDS = "NORMAN_RECORDS", + PICCADILLY_RECORDS = "PICCADILLY_RECORDS", + REDEYE = "REDEYE", + RESIDENT = "RESIDENT", + ROUGH_TRADE = "ROUGH_TRADE", + VINILO = "VINILO", +} + +export interface DspMetadata { + dsp: DspType; + smallLogoUrl: string; + bigLogoUrl: string; +} + +export enum DspStatus { + Error = "Error", + Success = "Success", +} + +export interface DspLinkMap { + resourceUrl: string; + previewUrl?: string; +} + +export interface DspLinkPayload { + type: DspType; + status: DspStatus; + message?: string; + items?: DspLinkMap[]; +} + +export interface DspLink { + type: DspType; + url: string; + previewUrl?: string; + isVisible: boolean; +} + +export enum DspResourceType { + ALBUM = "ALBUM", + TRACK = "TRACK", + //Supporting Legacy Types only + SHOW = "show", + USER = "user", + PLAYLIST = "playlist", + ARTIST = "artist", + EPISODE = "episode", +} + +export interface DspResource { + name: string; + albumName?: string; + type: DspResourceType; + upc?: string; + isrc?: string; + location?: string; + artists?: string; +} diff --git a/interface_base/src/types/sales.ts b/interface_base/src/types/sales.ts new file mode 100644 index 0000000..a6651cc --- /dev/null +++ b/interface_base/src/types/sales.ts @@ -0,0 +1,299 @@ +export type PaymentCard = { + brand: string; + country: string | null; + exp_month: number; + exp_year: number; + last4: string; +}; + +export type Customer = { + firstname: string; + lastname: string; + email: string; + user_id: number; +}; + +export type CustomerPayload = { + email: string; + firstname: string; + lastname: string; +}; + +export type CustomerWithDetails = Customer & CustomerDetails; +export type CustomerWithDetailsSuccess = { + success: true; + customer: CustomerWithDetails; +}; + +export enum CustomerCouponDuration { + ONCE = 'once', + REPEATING = 'repeating', + FOREVER = 'forever' +} + +export type CustomerCoupon = { + id: string; + duration: CustomerCouponDuration; + duration_in_months: number; + amount_off: number; + product_key: ProductKey; + message: string; + label: string; +} + +export type CustomerDetails = { + provider: Provider; + pricing?: { + country: string; + region: string; + }; + paymentMethod?: { + card: PaymentCard; // TODO: generic type + }; + subscriptions?: SubscriptionDetails; + coupon?: CustomerCoupon +}; + +export enum Interval { + MONTH = "month", + YEAR = "year", +} + +export type ProductSuccess = { + success: true; + product: Product; +}; + +export type Product = { + key: ProductKey; + name: string; + price_key: string; + prices: Record; +}; + +export enum ProductKey { + PROFILE_BUILDER = "profile_builder", +} + +export enum Provider { + STRIPE = "stripe", +} + +export type Price = { + product_key: ProductKey; + price_key: string; + interval: Interval; + amount: number; + currency: string; + tax_included: boolean; +}; + +export type StripeCustomer = Customer & { + stripe_id: string; +}; + +export type StripeCustomerSuccess = { + success: true; + customer: StripeCustomer; +}; + +export type StripeSetupIntentSuccess = { + success: true; + client_secret: string; +}; + +export type SalesSuccess = { + success: true; +}; + +export type SalesSoftFail = { + success: null; + status?: number; +}; + +export type SalesSetupConfirmationFailure = { + success: false; + status: number; + message: string; +}; + +export type CheckoutInvoiceSuccess = { + success: true; + checkout: CheckoutInvoice; +}; + +export type Subscription = { + talent_profile_id: string; + product_key: ProductKey; + user_id: number; + price_key: string; + interval: Interval; + country_code: string; + region_code: string; + status: SubscriptionStatus; + start_date: number; + end_date?: number | null; + cancel_date?: number | null; + trial_start?: number; + trial_end?: number; + current_period_start?: number; + current_period_end?: number; + payment?: PaymentErrorData; +}; + +export type PaymentErrorData = { + failureCode: string; + failureMessage: string; + success: boolean; + resolvedAt?: number; + nextPaymentAttempt?: number; +}; + +export type SubscriptionDetails = Record; +export type SubscriptionMap = Partial>; + +export type SubscriptionSuccess = { + success: true; + subscription: Subscription; +}; + +export type CanDeletePaymentMethodSuccess = { + succes: true; + canDelete: boolean; +}; + +// based on stripe subscriptions + +export enum SubscriptionStatus { + ACTIVE = "active", + CANCELED = "canceled", + INCOMPLETE = "incomplete", + INCOMPLETE_EXPIRED = "incomplete_expired", + PAST_DUE = "past_due", + TRIALING = "trialing", + UNPAID = "unpaid", +} + +export type SubscriptionSyncPayload = { + subscription: Subscription; + synced_at: number; +}; + +export type TrialSubscription = { + talent_profile_id: string; + product_key: ProductKey; + user_id: number; + country_code: string; + region_code?: string; + status: SubscriptionStatus.TRIALING; + trial_start: number; + trial_end: number; +}; + +export type TrialSubscriptionSuccess = { + success: true; + subscription: TrialSubscription; +}; + +import Stripe from "stripe"; + +export enum SubscriptionPeriod { + Monthly = "Monthly", + Yearly = "Yearly", +} + +export enum SubscriptionCreationStep { + Selection = "Selection", + Setup = "Setup", + Review = "Review", + Confirmation = "Confirmation", + UpdateDetails = "UpdateDetails", + DetailsConfirmation = "DetailsConfirmation", +} + +// TODO: rm +export interface SalesSubscriptionRequest { + country_code: string; + interval: Interval; + product_key: ProductKey; + talent_profile_id: string; +} + +// Reference: https://komi-workspace.slack.com/archives/C041X3085F1/p1667992748527799?thread_ts=1667924502.594359&cid=C041X3085F1 +// Endpoint: POST /sales/stripe/customer/setup +export interface PaymentSetupIntent { + client_secret: string; +} + +export enum Region { + AU = "AU", + CA = "CA", + EU = "EU", + GB = "GB", + US = "US", +} + +export interface PaymentMethod { + id: string; + object: string; + card: Partial; +} + +export enum PaymentMethodAction { + DELETE = "Delete", + UPDATE = "Update", +} + +export enum OverviewStatus { + ACTIVE = "active", + FREE_TRIAL = "free trial", + CANCELLED = "cancelled", + FREE_TRIAL_EXPIRED = "free trial expired", + INACTIVE = "inactive", + OVERDUE = "overdue", +} + +// temporary + +export enum SessionStorageKeys { + CUSTOMER = "customer", + CLIENT_SECRET = "client_secret", + INTERVAL = "interval", + FLOW = "flow", +} + +export const FLOW_CREATE = "FLOW_CREATE"; +export const FLOW_UPDATE = "FLOW_UPDATE"; + +export type CheckoutInvoice = { + amount_due: number; + currency: string; + subtotal: number; + total: number; + total_discount_amounts: any[]; + total_tax_amounts: TaxAmount[]; +}; + +export type TaxAmount = { + amount: number; + inclusive: boolean; + tax_rate: { + active: boolean; + description: string | null; + display_name: string; + inclusive: boolean; + percentage: number; + }; +}; + +export type Language = { + Global: { + Title: string; + }; + SubscriptionSelection: { + CTAButtonLabel: string; + }; + SubscriptionConfirmation: { + Title: string; + }; +}; diff --git a/interface_base/src/types/templates.ts b/interface_base/src/types/templates.ts new file mode 100644 index 0000000..532cdee --- /dev/null +++ b/interface_base/src/types/templates.ts @@ -0,0 +1,72 @@ +import { CursorPaginatedItem } from "hooks/usePagination"; + +export interface TemplateAddressDto { + firstLine: string | null; + secondLine: string | null; + city: string | null; + zipcode: string | null; + country: string | null; +} + +export interface TemplateDto { + templateId: string; + talentProfileId: string; + createdBy: number; + updatedBy: number; + createdAt: number; + updatedAt: number; + name: string; + templateJson: Record; + templateHtml: string; + thumbnailUrl: string; + address: TemplateAddressDto; +} +export type TemplateWithCursorDto = CursorPaginatedItem; + +export interface TemplateResponse { + template: TemplateDto; +} + +export interface CampaignTemplateResponse { + campaignTemplate: TemplateDto; +} + +export interface TemplateSignedUrlResponse { + url: string; + signedUrl: string; +} + +export interface TemplatesResponse { + templates: TemplateDto[]; +} +export interface TemplatesWithCursorResponse { + templates: TemplateWithCursorDto[]; +} + +export interface CampaignTemplateResponse { + campaignTemplate: TemplateDto; +} + +export interface CampaignTemplateCreateDto { + name: string; + templateJson: any; + thumbnailUrl: string; + address: TemplateAddressDto; + templateHtml: string; +} + +export interface CampaignTemplate extends CampaignTemplateCreateDto { + id: string; +} + +export enum TemplateMenuOptions { + Duplicate = "Duplicate", + Edit = "Edit", + Delete = "Delete", +} + +export interface SelectOption { + label: string; + value: OptionType; + tooltip?: string; +} diff --git a/interface_base/src/utils/agoraStore.ts b/interface_base/src/utils/agoraStore.ts new file mode 100644 index 0000000..b8db6cb --- /dev/null +++ b/interface_base/src/utils/agoraStore.ts @@ -0,0 +1,159 @@ +import Cookies from "js-cookie"; + +const readDefaultState = () => { + try { + return JSON.parse(Cookies.get("custom_storage") || ""); + } catch (err) { + return {}; + } +}; + +const defaultState = { + // loading effect + loading: false, + // media devices + streams: [], + localStream: null, + currentStream: null, + otherStreams: [], + devicesList: [], + // web sdk params + config: { + uid: 0, + host: true, + channelName: "", + token: + "0068a9a7badbd3a4b4683d11e5750e3de71IACaM0K8TQ9SVI80lpAL8PVgkuhbg95mSyzGhOXYwpPtmp1fCyoAAAAAEAAB6BGrYLFtXwEAAQBfsW1f", + resolution: "720p_1", + ...readDefaultState(), + microphoneId: "", + cameraId: "", + }, + agoraClient: null, + mode: "live", + codec: "vp8", + muteVideo: false, + muteAudio: false, + screen: false, + profile: false, + // beauty: false +}; + +const reducer = (state: any, action: any) => { + switch (action.type) { + case "config": { + return { ...state, config: action.payload }; + } + case "client": { + return { ...state, client: action.payload }; + } + case "loading": { + return { ...state, loading: action.payload }; + } + case "codec": { + return { ...state, codec: action.payload }; + } + case "video": { + return { ...state, muteVideo: action.payload }; + } + case "audio": { + return { ...state, muteAudio: action.payload }; + } + case "screen": { + return { ...state, screen: action.payload }; + } + case "devicesList": { + return { ...state, devicesList: action.payload }; + } + case "localStream": { + return { ...state, localStream: action.payload }; + } + case "profile": { + return { ...state, profile: action.payload }; + } + case "currentStream": { + const { streams } = state; + const newCurrentStream = action.payload; + const otherStreams = streams.filter( + (it: any) => it.getId() !== newCurrentStream.getId() + ); + return { ...state, currentStream: newCurrentStream, otherStreams }; + } + case "addStream": { + const { streams, currentStream } = state; + const newStream = action.payload; + let newCurrentStream = currentStream; + if (!newCurrentStream) { + newCurrentStream = newStream; + } + if (streams.length === 4) return { ...state }; + const newStreams = [...streams, newStream]; + const otherStreams = newStreams.filter( + (it) => it.getId() !== newCurrentStream.getId() + ); + return { + ...state, + streams: newStreams, + currentStream: newCurrentStream, + otherStreams, + }; + } + case "removeStream": { + const { streams, currentStream } = state; + const { stream, uid } = action; + const targetId = stream ? stream.getId() : uid; + let newCurrentStream = currentStream; + const newStreams = streams.filter( + (stream: any) => stream.getId() !== targetId + ); + if (currentStream && targetId === currentStream.getId()) { + if (newStreams.length === 0) { + newCurrentStream = null; + } else { + newCurrentStream = newStreams[0]; + } + } + const otherStreams = newCurrentStream + ? newStreams.filter( + (it: any) => it.getId() !== newCurrentStream.getId() + ) + : []; + return { + ...state, + streams: newStreams, + currentStream: newCurrentStream, + otherStreams, + }; + } + case "clearAllStream": { + // const {streams, localStream, currentStream, beauty} = state; + const { streams, localStream, currentStream } = state; + streams.forEach((stream: any) => { + if (stream.isPlaying()) { + stream.stop(); + } + // stream.close(); + }); + + if (localStream) { + localStream.isPlaying() && localStream.stop(); + localStream.close(); + } + if (currentStream) { + currentStream.isPlaying() && currentStream.stop(); + currentStream.close(); + } + return { ...state, currentStream: null, localStream: null, streams: [] }; + } + // case 'enableBeauty': { + // return { + // ...state, + // beauty: action.enable + // } + // } + default: + throw new Error("mutation type not defined"); + } +}; + +export { reducer, defaultState }; diff --git a/interface_base/src/utils/analytics.ts b/interface_base/src/utils/analytics.ts new file mode 100644 index 0000000..ec73745 --- /dev/null +++ b/interface_base/src/utils/analytics.ts @@ -0,0 +1,44 @@ +import isEmpty from "lodash/isEmpty"; +import type { + SegmentEventProperties, + SegmentEventName, +} from "constants/segment"; + +export class AnalyticServices { + static facebookPixelTrack( + event: string, + properties?: Record + ): void { + if ( + (window as any).FacebookPixel && + !isEmpty((window as any).FacebookPixel) + ) { + Object.keys((window as any).FacebookPixel).forEach((id) => { + (window as any).FacebookPixel[id].track(event, properties); + }); + } + } + + static track( + event: TEvent, + properties?: SegmentEventProperties[TEvent], + trackFBOnly?: boolean + ): void { + if (!trackFBOnly) { + (window as any)?.analytics?.track(event, properties); + } + this.facebookPixelTrack(event, properties); + } + + static identify(event: string, properties?: Record): void { + (window as any)?.analytics?.identify(event, properties); + } + + static ready(callback: () => void): void { + (window as any)?.analytics?.ready(callback); + } + + static reset(): void { + (window as any)?.analytics?.reset(); + } +} diff --git a/interface_base/src/utils/api.ts b/interface_base/src/utils/api.ts new file mode 100644 index 0000000..2a15aac --- /dev/null +++ b/interface_base/src/utils/api.ts @@ -0,0 +1,30 @@ +import notification from "./notification"; + +import { OkResponse, Response } from "redux/Common/types"; +import { Maybe } from "types/utilities"; + +/** + * Validate if the response is successful, because the API was apparently typed by a gerbil. + * + * @param {unknown} res Potential response from the API + * @returns {res is OkResponse} Whether the response is a successful response + */ +export const isSuccessfulResponse = (res: unknown): res is OkResponse => + typeof res === "object" && res !== null && "ok" in res && "response" in res; + +/** + * Get the API response, or show an error notification if the response is not successful. + * + * @param {Response} res Response from the API + * @returns {Maybe} The API response, or nothing if the response is not successful + */ +export const getApiResponse = async ( + res: Promise> +): Promise> => { + const response = await res; + if (!isSuccessfulResponse(response)) { + return void notification.error({ message: response?.message }); + } + + return response.response; +}; diff --git a/interface_base/src/utils/array.ts b/interface_base/src/utils/array.ts new file mode 100644 index 0000000..2189053 --- /dev/null +++ b/interface_base/src/utils/array.ts @@ -0,0 +1,59 @@ +export class ArrayServices { + static append(array: Array, obj: T) { + return [...array, obj]; + } + static unshift(array: Array, obj: T) { + return [obj, ...array]; + } + static updateWithIndex(array: Array, index: number, obj: T) { + const tempArr = [...array]; + tempArr[index] = obj; + return tempArr; + } + static removeWithIndex(array: Array, index: number) { + const tempArr = [...array]; + return tempArr.filter((item: T, indexItem: number) => indexItem !== index); + } + static updateWithId(array: Array, id: string, obj: T) { + const tempArr = [...array]; + // @ts-ignore + const indexObj = array.findIndex((item: T) => item.id === id); + tempArr[indexObj] = obj; + return tempArr; + } + static removeWithId(array: Array, id: string) { + const tempArr = [...array]; + // @ts-ignore + return tempArr.filter((item: T) => item.id !== id); + } + static updateWithField( + array: Array, + field: string, + key: string | number, + obj: T + ) { + const tempArr = [...array]; + // @ts-ignore + const indexObj = array.findIndex((item: T) => item[field] === key); + tempArr[indexObj] = obj; + return tempArr; + } + static removeWithField( + array: Array, + field: string, + key: string | number + ) { + const tempArr = [...array]; + // @ts-ignore + return tempArr.filter((item: T) => item[field] !== key); + } + static reorder(list: T[], startIndex: number, endIndex: number) { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; + } + static findDuplicate(list: T[]) { + return list.filter((item, index) => list.indexOf(item) != index); + } +} diff --git a/interface_base/src/utils/available-periods.ts b/interface_base/src/utils/available-periods.ts new file mode 100644 index 0000000..558e4ab --- /dev/null +++ b/interface_base/src/utils/available-periods.ts @@ -0,0 +1,93 @@ +import { FieldTimeRange } from "components/Form/FormListTimeRangeField"; +import { AvailablePeriod } from "models/availability-calendar/availability-calendar.model"; +import { DateRange } from "moment-range"; +import { ArrayServices } from "./array"; +import { isSameDay } from "utils/date"; +import moment from "moment"; +import momentTZ from "moment-timezone"; + +export const DATE_FORTMAT = "YYYY-MM-DD"; + +export const isOverlapsTimeRange = ( + ranges: DateRange[] = [], + range: DateRange +): boolean => { + if (!ranges || !ranges.length || (Array.isArray(ranges) && ranges.length < 1)) + return false; + for (let i = 0; i < ranges.length; i++) { + if (range.overlaps(ranges[i])) { + return true; + } + } + return false; +}; + +export const getFirstAvailablePeriods = ( + availablePeriods: Map +) => { + for (const [date, periods] of availablePeriods) { + if ( + moment(date).isSameOrAfter(moment()) || + isSameDay(moment(date as string), moment()) + ) { + return { date, periods }; + } + } + return null; +}; + +export const availablePeriodsToMap = ( + periods: AvailablePeriod[], + timezone: string +): Map => { + const map = periods.reduce((acc, curr) => { + const startTimeTZ = momentTZ.tz(curr.start, timezone); + const endTimeTZ = momentTZ.tz(curr.end, timezone); + const startTimeF = startTimeTZ.clone().format(DATE_FORTMAT); + const endTimeF = endTimeTZ.clone().format(DATE_FORTMAT); + if (startTimeF === endTimeF) { + const key = startTimeTZ.clone().format(DATE_FORTMAT); + const value = { + startTime: startTimeTZ, + endTime: endTimeTZ, + }; + acc.set(key, ArrayServices.append(acc.get(key) || [], value)); + } else { + const keyStart = startTimeTZ.clone().format(DATE_FORTMAT); + const keyEnd = endTimeTZ.clone().format(DATE_FORTMAT); + const value1 = { + startTime: startTimeTZ, + endTime: startTimeTZ.clone().startOf("day").hours(23).minutes(30), + }; + const value2 = { + startTime: endTimeTZ.clone().startOf("day"), + endTime: endTimeTZ, + }; + acc.set(keyStart, ArrayServices.append(acc.get(keyStart) || [], value1)); + acc.set(keyEnd, ArrayServices.append(acc.get(keyEnd) || [], value2)); + } + return acc; + }, new Map()); + return map; +}; + +export const availablePeriodsToUTC = ( + availablePeriods: Map, + timezone: string +): AvailablePeriod[] => { + const values = []; + for (const [date, periods] of availablePeriods) { + for (const range of periods) { + const start = momentTZ + .tz(`${date} ${range.startTime.format("HH:mm:ss")}`, timezone) + .utc() + .toISOString(); + const end = momentTZ + .tz(`${date} ${range.endTime.format("HH:mm:ss")}`, timezone) + .utc() + .toISOString(); + values.push({ start, end }); + } + } + return values; +}; diff --git a/interface_base/src/utils/casing.ts b/interface_base/src/utils/casing.ts new file mode 100644 index 0000000..00eec5d --- /dev/null +++ b/interface_base/src/utils/casing.ts @@ -0,0 +1,3 @@ +export const toTitleCase = (text: string): string => { + return text[0].toUpperCase() + text.substr(1).toLowerCase(); +}; diff --git a/interface_base/src/utils/color.ts b/interface_base/src/utils/color.ts new file mode 100644 index 0000000..5852e95 --- /dev/null +++ b/interface_base/src/utils/color.ts @@ -0,0 +1,76 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const ColorContrastChecker = require("color-contrast-checker"); + +const colorContrastChecker = new ColorContrastChecker(); + +export function hexToRgbA(hex: string, opacity: number) { + if (hex === "" || !hex) return; + let c: any; + if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { + c = hex.substring(1).split(""); + if (c.length == 3) { + c = [c[0], c[0], c[1], c[1], c[2], c[2]]; + } + c = "0x" + c.join(""); + return ( + "rgba(" + + [(c >> 16) & 255, (c >> 8) & 255, c & 255].join(",") + + `,${opacity})` + ); + } + + return hex; +} + +export function isValidColorContrast( + backgroundColor?: string, + typographyColor?: string +) { + if (!backgroundColor || !typographyColor) return true; + try { + return colorContrastChecker.isLevelAA(backgroundColor, typographyColor, 14); + } catch (ex) { + return false; + } +} + +// https://gist.github.com/larryfox/1636338 +export function detectLightOrDark(backgroundColor: string): "light" | "dark" { + try { + let a: any = backgroundColor; + let r: any; + let g: any; + let b: any; + let hsp = 0; + if (a.match(/^rgb/)) { + a = a.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); + r = a[1]; + g = a[2]; + b = a[3]; + } else { + a = +( + "0x" + + a.slice(1).replace( + // thanks to jed : http://gist.github.com/983661 + a.length < 5 && /./g, + "$&$&" + ) + ); + r = a >> 16; + b = (a >> 8) & 255; + g = a & 255; + } + + hsp = Math.sqrt( + // HSP equation from http://alienryderflex.com/hsp.html + 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) + ); + if (hsp > 127.5) { + return "light"; + } else { + return "dark"; + } + } catch { + return "light"; + } +} diff --git a/interface_base/src/utils/container.tsx b/interface_base/src/utils/container.tsx new file mode 100644 index 0000000..19f72d8 --- /dev/null +++ b/interface_base/src/utils/container.tsx @@ -0,0 +1,147 @@ +// tslint:disable + +import Cookies from "js-cookie"; +import React, { + createContext, + useContext, + useEffect, + useReducer, + useState, +} from "react"; +import { defaultState, reducer } from "./agoraStore"; + +const StateContext: any = createContext({}); +const MutationContext: any = createContext({}); + +export const ContainerProvider = ({ children }: any) => { + const [state, dispatch] = useReducer(reducer, defaultState); + + (window as any).rootState = state; + + const [toasts, updateToasts] = useState([]); + + const methods = { + startLoading() { + dispatch({ type: "loading", payload: true }); + }, + stopLoading() { + dispatch({ type: "loading", payload: false }); + }, + updateConfig(params: any) { + dispatch({ type: "config", payload: { ...state.config, ...params } }); + }, + setClient(clientInstance: any) { + dispatch({ type: "client", payload: clientInstance }); + }, + setCodec(param: any) { + dispatch({ type: "codec", payload: param }); + }, + setVideo(param: any) { + dispatch({ type: "video", payload: param }); + }, + setAudio(param: any) { + dispatch({ type: "audio", payload: param }); + }, + setScreen(param: any) { + dispatch({ type: "screen", payload: param }); + }, + setProfile(param: any) { + dispatch({ type: "profile", payload: param }); + }, + toastSuccess(message: any) { + updateToasts([ + ...toasts, + { + variant: "success", + message, + }, + ]); + }, + toastInfo(message: any) { + updateToasts([ + ...toasts, + { + variant: "info", + message, + }, + ]); + }, + toastError(message: any) { + updateToasts([ + ...toasts, + { + variant: "error", + message, + }, + ]); + }, + removeTop() { + const items = toasts.filter((e: any, idx: any) => idx > 0); + updateToasts([...items]); + }, + setLocalStream(param: any) { + dispatch({ type: "localStream", payload: param }); + }, + setCurrentStream(param: any) { + dispatch({ type: "currentStream", payload: param }); + }, + setDevicesList(param: any) { + dispatch({ type: "devicesList", payload: param }); + }, + clearAllStream() { + dispatch({ type: "clearAllStream" }); + }, + addLocal(evt: any) { + const { stream } = evt; + methods.setLocalStream(stream); + methods.setCurrentStream(stream); + }, + addStream(evt: any) { + const { stream } = evt; + dispatch({ type: "addStream", payload: stream }); + }, + removeStream(evt: any) { + const { stream } = evt; + dispatch({ type: "removeStream", stream: stream }); + }, + removeStreamById(evt: any) { + const { uid } = evt; + dispatch({ type: "removeStream", uid: uid }); + }, + connectionStateChanged(evt: any) { + methods.toastInfo(`${evt.curState}`); + }, + // enableBeauty(enable) { + // dispatch({type: 'enableBeauty', enable}); + // } + }; + + useEffect(() => { + Cookies.set( + "custom_storage", + JSON.stringify({ + uid: state.config.uid, + host: state.config.host, + channelName: state.config.channelName, + token: state.config.token, + resolution: state.config.resolution, + }) + ); + }, [state]); + + return ( + + + {children} + + + ); +}; + +export function useGlobalState() { + return useContext(StateContext); +} + +export function useGlobalMutation() { + return useContext(MutationContext); +} diff --git a/interface_base/src/utils/copyTextToClipboard.tsx b/interface_base/src/utils/copyTextToClipboard.tsx new file mode 100644 index 0000000..b2d1ecd --- /dev/null +++ b/interface_base/src/utils/copyTextToClipboard.tsx @@ -0,0 +1,21 @@ +export function copyTextToClipboard(text: string) { + if (window.isSecureContext) { + // navigator.clipboard is only available in secure contexts + return navigator.clipboard.writeText(text); + } else { + // fallback - this should only be run locally + unsecureCopy(text); + return Promise.resolve(); + } +} + +function unsecureCopy(text: string) { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); +} diff --git a/interface_base/src/utils/createAction.ts b/interface_base/src/utils/createAction.ts new file mode 100644 index 0000000..ad8fec5 --- /dev/null +++ b/interface_base/src/utils/createAction.ts @@ -0,0 +1,38 @@ +import { createAction } from "redux-actions"; + +type RequestType = { + REQUEST: string; + SUCCESS: string; + FAILURE: string; +}; + +type ActionType = { + REQUEST: string; + SUCCESS: string; + FAILURE: string; +}; + +export const REQUEST = "REQUEST"; +export const SUCCESS = "SUCCESS"; +export const FAILURE = "FAILURE"; + +export const createGenericTypes = (base: string) => { + const TYPES = [REQUEST, SUCCESS, FAILURE].reduce((types, type) => { + types[type as keyof RequestType] = `${base}_${type}`; + return types; + }, {} as Record); + return TYPES; +}; + +export const createGenericActions = (types: any) => { + return [REQUEST, SUCCESS, FAILURE].reduce((actions, type) => { + actions[type as keyof ActionType] = createAction(types[type]); + return actions; + }, {} as Record); +}; + +export const getActionBaseName = (val: string): string => { + const splitData = val.split("_"); + splitData.pop(); + return splitData.join("_"); +}; diff --git a/interface_base/src/utils/currency.ts b/interface_base/src/utils/currency.ts new file mode 100644 index 0000000..be5525d --- /dev/null +++ b/interface_base/src/utils/currency.ts @@ -0,0 +1,107 @@ +import { CurrencyCodes } from "constants/currency-code"; +import { ExchangeRateDefault } from "constants/exchange-rate"; +import { ExchangeRate } from "redux/User/types"; + +export const getSymbolFromCurrency = (currencyCode: string) => { + const code = currencyCode.toUpperCase(); + if (CurrencyCodes.hasOwnProperty(code)) { + return CurrencyCodes[code].symbol; + } + return ExchangeRateDefault.symbol; +}; + +export const formatToK = (num: number, digits = 2) => { + const lookup = [ + { value: 1, symbol: "" }, + { value: 1e3, symbol: "K" }, + { value: 1e6, symbol: "M" }, + { value: 1e9, symbol: "G" }, + { value: 1e12, symbol: "T" }, + { value: 1e15, symbol: "P" }, + { value: 1e18, symbol: "E" }, + ]; + const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; + const item = lookup + .slice() + .reverse() + .find(function (item) { + return Math.abs(num) >= item.value; + }); + return item + ? (num / item.value).toFixed(digits).replace(rx, "$1") + item.symbol + : "0"; +}; + +export const formatterUSD = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + +export const formatterCurrency = ( + localCurrency: string = ExchangeRateDefault.localCurrency, + minFractionDigits = 0 +) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: localCurrency, + minimumFractionDigits: minFractionDigits, + maximumFractionDigits: 2, + }); + +export const formatterCurrencyEXRate = ( + exchangeRate: ExchangeRate = ExchangeRateDefault +) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: exchangeRate.localCurrency, + minimumFractionDigits: exchangeRate.decimalDigits, + maximumFractionDigits: exchangeRate.decimalDigits, + }); + +export const getPriceWithExchangeRate = ( + price: number, + exchangeRate: ExchangeRate = ExchangeRateDefault +) => { + const priceWithRate = price * Number(exchangeRate?.GBPExchangeRate); + return Number(priceWithRate.toFixed(exchangeRate?.decimalDigits)); +}; + +export const formatPriceWithExchangeRate = ( + price: number, + exchangeRate: ExchangeRate = ExchangeRateDefault +) => { + const priceExchange = getPriceWithExchangeRate(price, exchangeRate); + return formatterCurrencyEXRate(exchangeRate).format(priceExchange); +}; + +export const formatTeenPriceWithExchangeRate = ( + price: number, + exchangeRate: ExchangeRate = ExchangeRateDefault +) => { + const priceExchange = getPriceWithExchangeRate(price, exchangeRate); + const priceFormatK = formatToK(priceExchange, exchangeRate.decimalDigits); + return `${exchangeRate.symbol}${priceFormatK}`; +}; + +export const parseCurrencyToInt = (value: string | number) => { + if (typeof value !== "string") return value; + return parseInt(value.replace(/,/g, "")); +}; + +export const parseCurrencyToFloat = (value: string | number) => { + if (typeof value !== "string") return value; + return parseFloat(value.replace(/,/g, ".")); +}; + +export const getCurrencySymbol = (currency: string) => + (0) + .toLocaleString("en-US", { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + .replace(/\d/g, "") + .trim(); diff --git a/interface_base/src/utils/date.ts b/interface_base/src/utils/date.ts new file mode 100644 index 0000000..d2e96c1 --- /dev/null +++ b/interface_base/src/utils/date.ts @@ -0,0 +1,156 @@ +import moment from "moment"; +import momentTZ from "moment-timezone"; +import { DateOff } from "redux/Experience/types"; +import { TIME_ZONE_DATA } from "../redux/Common/types"; + +moment.updateLocale("en", { + relativeTime: { + future: "in %s", + past: "%s", + s: "a few secs", + ss: "%d secs", + m: "a min", + mm: "%d mins", + h: "an hour", + hh: "%d hours", + d: "a day", + dd: "%d days", + M: "a month", + MM: "%d months", + y: "a year", + yy: "%d years", + }, +}); + +export const date = moment; +export const formatDate = (date: any, format: string) => + moment(date).format(format); +export const dayAgo = (date: any) => { + const now = moment(new Date()); + const end = moment(date); // another date + const duration = moment.duration(now.diff(end)); + const days = duration.asDays(); + return days; +}; + +export const formatMinuteSecond = (second: number) => { + const time = moment.utc(second * 1000); + return time.hours() > 0 ? time.format("hh:mm:ss") : time.format("mm:ss"); +}; + +export const formatHourMinuteSecond = (second: number) => { + if (second <= 0) return "00:00"; + return moment + .utc(second * 1000) + .format(second >= 3600 ? "HH:mm:ss" : "mm:ss"); +}; + +export const formatMinuteSecondWithUnit = (value: number): string => { + if (!value) return ""; + const hour = Math.floor(value / (60 * 60)); + const minute = + hour * 60 * 60 < value ? Math.floor((value - hour * 60 * 60) / 60) : 0; + const second = + minute * 60 + hour * 60 * 60 < value + ? value - (minute * 60 + hour * 60 * 60) + : 0; + + return `${hour !== 0 ? `${hour >= 10 ? hour : `0${hour}`}` + "h " : ""}${ + minute !== 0 ? `${minute >= 10 ? minute : `0${minute}`}` + "m " : "" + }${second === 0 ? "" : second + "s"}`; +}; + +export const formatTime = (date: Date) => moment(date).format("hh:mm a"); + +export const getDiffTime = (startDate: Date, endDate: Date): number => { + const start = moment(startDate); + const end = moment(endDate); + return end.isBefore(start) ? 0 : moment.duration(end.diff(start)).asMinutes(); +}; + +export const isDateOffs = ( + date: moment.Moment, + dateOffs: DateOff[] +): DateOff | undefined => { + const dateTz = momentTZ(date).set({ + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }); + + return dateOffs?.find(({ date }) => { + return moment(date).toISOString() === dateTz.toISOString(); + }); +}; + +export const formatMinsAndSecs = (second: number) => { + const mins = Math.floor(second / 60); + const secs = second % 60; + + return `${mins > 0 ? (mins === 1 ? `${mins} min` : `${mins} mins`) : ""} ${ + secs > 0 ? (secs === 1 ? `${secs} sec` : `${secs} secs`) : "" + }`; +}; + +export const formatMins = (second: number) => { + const mins = Math.floor(second / 60); + return mins > 0 ? (mins === 1 ? `${mins} Min` : `${mins} Mins`) : ""; +}; + +export const convertTZ = (date: any, tzString: string) => { + return new Date( + (typeof date === "string" ? new Date(date) : date).toLocaleString("en-US", { + timeZone: tzString, + }) + ); +}; +export const getTimezoneLabel = (timezone?: string) => { + if (!timezone) { + return TIME_ZONE_DATA[0].data[0]; + } + const select = TIME_ZONE_DATA.find( + (item) => !!item.data.find((el) => el.tz === timezone) + ); + return ( + select?.data.find((el) => el.tz === timezone) || TIME_ZONE_DATA[0].data[0] + ); +}; +export const sortExperiencesByDate = (experiences: any[] | undefined = []) => { + let scheduleList = [...experiences]?.filter( + (experience) => experience?.scheduleTime + ); + let createdAtList = [...experiences]?.filter( + (experience) => !experience?.scheduleTime + ); + + scheduleList = scheduleList?.sort((experienceA, experienceB) => { + const dateA = moment(experienceA?.scheduleTime); + const dateB = moment(experienceB?.scheduleTime); + + return dateA.isBefore(dateB) ? -1 : 1; + }); + + createdAtList = createdAtList?.sort((experienceA, experienceB) => { + const dateA = moment(experienceA?.createdAt); + const dateB = moment(experienceB?.createdAt); + + return dateA.isBefore(dateB) ? -1 : 1; + }); + + return [...scheduleList, ...createdAtList]; +}; +export function validURL(str: string) { + const pattern = new RegExp( + /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.​\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!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\u00​a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u​00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/i + ); // fragment locator + return !!pattern.test(str); +} + +export const isSameDay = (d1: moment.Moment, d2: moment.Moment): boolean => { + const compare = + d1.year() === d2.year() && + d1.month() === d2.month() && + d1.date() === d2.date(); + return compare; +}; diff --git a/interface_base/src/utils/dca/campaigns.ts b/interface_base/src/utils/dca/campaigns.ts new file mode 100644 index 0000000..5fa5ebc --- /dev/null +++ b/interface_base/src/utils/dca/campaigns.ts @@ -0,0 +1,72 @@ +import { CampaignStatuses } from "@komi-app/components"; +import { dcaBaseUrl } from "constants/dca"; +import { CampaignStatus } from "types/campaigns"; +import { parseUrlParamsToSuffix } from "utils/url"; + +import { FLAGS, isOn, isReady } from "@komi-app/flags-sdk"; + +export function toCampaignStatuses( + status: CampaignStatus, + startTimestamp: number | null, +): CampaignStatuses { + const statusMap = { + [CampaignStatus.Draft]: CampaignStatuses.DRAFT, + [CampaignStatus.Scheduled]: CampaignStatuses.SCHEDULED, + [CampaignStatus.Sending]: CampaignStatuses.SENDING, + [CampaignStatus.Completed]: CampaignStatuses.COMPLETED, + [CampaignStatus.Stopped]: CampaignStatuses.STOPPED, + }; + + if (!isReady() || !startTimestamp) return statusMap[status]; + + /** + * this code is a refactor to make campaign statuses consistent everywhere they are displayed. + * + * prev comment said: + * mark it as 'sending' if it's completed and isn't due time. + * this is a short term fix until we sort out the backend and how we mark campaigns + * + * and another from campaign utils said: + * Convert status to Sending if status is Scheduled and scheduledSendTimestamp is in the past + */ + + const currentTime = Date.now(); + const startTime = new Date(startTimestamp).getTime(); + const isPastDateTime = startTime < currentTime; + + switch (status) { + case CampaignStatus.Completed: { + return statusMap[status]; + } + case CampaignStatus.Scheduled: { + if (isPastDateTime) { + return statusMap[CampaignStatus.Sending]; + } + return statusMap[status]; + } + } + + return statusMap[status]; +} + +export const createCampaignEditUrl = (campaignId: string, type = "") => { + const searchParams = parseUrlParamsToSuffix({ campaignId, type }); + return `${dcaBaseUrl}/campaign-edit${searchParams}`; +}; +export const createNewCampaignWithSegment = (segmentId: string) => { + const searchParams = parseUrlParamsToSuffix({ segmentId }); + return `${dcaBaseUrl}/campaign-create${searchParams}`; +}; +export const createCampaignReportUrl = (campaignId: string) => { + const searchParams = parseUrlParamsToSuffix({ campaignId }); + return `${dcaBaseUrl}/campaign-report${searchParams}`; +}; +export const createEmailCampaignReportUrl = (campaignId: string) => { + const searchParams = parseUrlParamsToSuffix({ campaignId }); + return `${dcaBaseUrl}/email-campaign-report${searchParams}`; +}; + +export const createEditSMSCampaignURL = (campaignId: string) => { + const searchParams = parseUrlParamsToSuffix({ campaignId }); + return `${dcaBaseUrl}/sms-campaign-edit${searchParams}`; +}; diff --git a/interface_base/src/utils/dca/custom-tags.ts b/interface_base/src/utils/dca/custom-tags.ts new file mode 100644 index 0000000..20b76c8 --- /dev/null +++ b/interface_base/src/utils/dca/custom-tags.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCustomTagsSDK } from "services"; +import { FAN_TAG_COUNT_LIMIT } from "@komi-app/fans-sdk"; + +export function useIsCustomTagsLimitCrossed() { + const { isLoading, data } = useQuery({ + queryKey: ["CustomTagsSDK-uniqueCount"], + queryFn: () => getCustomTagsSDK().uniqueCount(), + }); + return !isLoading && typeof data === 'number' && data > FAN_TAG_COUNT_LIMIT; + } + + export function useTagsQuery() { + return useQuery({ + queryKey: ["CustomTagsSDK-getTags"], + queryFn: () => getCustomTagsSDK().getTags({}), + }); + } \ No newline at end of file diff --git a/interface_base/src/utils/dca/segments.ts b/interface_base/src/utils/dca/segments.ts new file mode 100644 index 0000000..90ac921 --- /dev/null +++ b/interface_base/src/utils/dca/segments.ts @@ -0,0 +1,26 @@ +import { segmentQueryBuilderUrl } from 'constants/dca/segments'; +import { ContactType, MarketingPermission, Segment, SegmentMode } from '@komi-app/fans-sdk'; +import { v4 } from 'uuid'; + +export interface SegmentUrlProps { + mode: SegmentMode; + segmentId?: string; +} + +export const createSegmentUrl = (params: SegmentUrlProps) => { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) searchParams.append(key, value); + }); + + return `${segmentQueryBuilderUrl}?${searchParams}`; +}; +export const createDefaultSegment = (talentProfileId: string): Segment => ({ + segmentId: v4(), + talentProfileId, + name: "", + contactType: ContactType.Any, + marketingPermission: MarketingPermission.EmailGranted, + expressions: [], +}); diff --git a/interface_base/src/utils/dca/templates.ts b/interface_base/src/utils/dca/templates.ts new file mode 100644 index 0000000..b666e91 --- /dev/null +++ b/interface_base/src/utils/dca/templates.ts @@ -0,0 +1,7 @@ +import { dcaBaseUrl } from "constants/dca"; +import { parseUrlParamsToSuffix } from "utils/url"; + +export const createTemplateEditUrl = (templateId: string) => { + const searchParams = parseUrlParamsToSuffix({ templateId }); + return `${dcaBaseUrl}/templates-edit${searchParams}`; +}; diff --git a/interface_base/src/utils/debounce.ts b/interface_base/src/utils/debounce.ts new file mode 100644 index 0000000..33bb198 --- /dev/null +++ b/interface_base/src/utils/debounce.ts @@ -0,0 +1,20 @@ +/** + * Debounces a function call + * + * @param {Function} fn Function to debounce + * @param {number} ms Milliseconds to wait before calling the function + * @returns {Function} Debounced function + */ +export const debounce = (fn: Function, ms: number) => { + let timeout: NodeJS.Timeout; + + return function executor(...args: any[]) { + const later = () => { + clearTimeout(timeout); + fn(...args); + }; + + clearTimeout(timeout); + timeout = setTimeout(later, ms); + }; +}; diff --git a/interface_base/src/utils/enum.ts b/interface_base/src/utils/enum.ts new file mode 100644 index 0000000..2191e8f --- /dev/null +++ b/interface_base/src/utils/enum.ts @@ -0,0 +1,12 @@ +export const isValueInEnum = >( + value: T[keyof T] | string, + enumeration: T +): value is T[keyof T] => { + return Object.values(enumeration).includes(value); +}; +export const enumFromValue = >( + value: T[keyof T] | string, + enumeration: T +): T[keyof T] | undefined => { + return isValueInEnum(value, enumeration) ? value : undefined; +}; diff --git a/interface_base/src/utils/experience.ts b/interface_base/src/utils/experience.ts new file mode 100644 index 0000000..8d9a8c0 --- /dev/null +++ b/interface_base/src/utils/experience.ts @@ -0,0 +1,31 @@ +import { Experience } from "redux/Experience/types"; +import { IMAGE_REG } from "constants/regexp"; + +export const getFirstAvatar = (experience: Experience): string => { + if (!experience) return ""; + + let result = ""; + + if (!experience.covers) return result; + + for (const val of experience.covers) { + if (IMAGE_REG.test(val)) { + result = val; + break; + } + } + + return result; +}; + +export const getAvatarName = (name: string) => { + if (name) { + const elements = name.split(" "); + return elements.length > 0 + ? elements + .map((item) => (item.length ? item[0].toUpperCase() : "")) + .join("") + : name[0]; + } + return ""; +}; diff --git a/interface_base/src/utils/file.ts b/interface_base/src/utils/file.ts new file mode 100644 index 0000000..c927f79 --- /dev/null +++ b/interface_base/src/utils/file.ts @@ -0,0 +1,226 @@ +import Resizer from "react-image-file-resizer"; +import Axios from "axios"; +import { + UnsupportedFileTypeError, + FileTooLargeError, + uploadImage, + uploadVideo, + ImageTooSmallError, +} from "../utils/photo"; +import { addCacheBusterParameter } from "./image"; + +export const getFileSizeWithUrl = (url: string): Promise => + new Promise(async (resolve, reject) => { + try { + const file = await Axios.get(url); + if (file) { + resolve(Number(file.headers["content-length"]) || 0); + } + } catch (e) { + console.log("getFileSizeWithUrl error:", e); + reject(e); + } + }); + +export const getExtension = (filename: string) => { + if (!filename) return; + const parts = filename.split("."); + return parts[parts.length - 1]; +}; + +export const isImage = (filename: string) => { + const ext = getExtension(filename); + if (!ext) return; + + switch (ext.toLowerCase()) { + case "jpg": + case "jpeg": + case "gif": + case "bmp": + case "png": + case "webp": + //etc + return true; + } + return false; +}; + +export const isVideo = (filename: string) => { + const ext = getExtension(filename); + if (!ext) return; + + switch (ext.toLowerCase()) { + case "m4v": + case "avi": + case "mpg": + case "mp4": + case "mov": + case "quicktime": + // etc + return true; + } + return false; +}; + +export const isDocumentFile = (filename: string) => { + const ext = getExtension(filename); + if (!ext) return; + + switch (ext.toLowerCase()) { + case "pdf": + // etc + return true; + } + return false; +}; + +export const isSoundFile = (filename: string) => { + const ext = getExtension(filename); + if (!ext) return; + + switch (ext.toLowerCase()) { + case "mp3": + // etc + return true; + } + return false; +}; + +export const resizeFile = (file: any) => + new Promise((resolve) => { + Resizer.imageFileResizer( + file, + 1440, + 1440, + "JPEG", + 90, + 0, + (uri) => { + resolve(uri); + }, + "blob" + ); + }); + +export const humanFileSize = (bytes: any) => { + if (bytes === 0) { + return "0.00 B"; + } + const e = Math.floor(Math.log(bytes) / Math.log(1024)); + return ( + (bytes / Math.pow(1024, e)).toFixed(2) + " " + " KMGTP".charAt(e) + "B" + ); +}; +function validateDownloadSpeedParams(baseUrl: string, fileSizeInBytes: number) { + if (typeof baseUrl !== "string") { + throw new Error("baseUrl must be a string"); + } + if (typeof fileSizeInBytes !== "number") { + throw new Error("fileSizeInBytes must be a number"); + } + return; +} + +export function checkDownloadSpeed(baseUrl: string, fileSizeInBytes: number) { + validateDownloadSpeedParams(baseUrl, fileSizeInBytes); + return new Promise((resolve, reject) => { + const download = new Image(); + download.onload = function () { + const endTime = new Date().getTime(); + const duration = (endTime - startTime) / 1000; + // Convert bytes into bits by multiplying with 8 + const bitsLoaded = fileSizeInBytes * 8; + const bps: number = parseFloat((bitsLoaded / duration).toFixed(2)); + const kbps = parseFloat((bps / 1024).toFixed(2)); + const mbps = parseFloat((kbps / 1024).toFixed(2)); + resolve({ bps, kbps, mbps }); + }; + download.onerror = function (err, msg) { + reject(null); + }; + + const startTime = new Date().getTime(); + const cacheBuster = "?nnn=" + startTime; + download.src = baseUrl + cacheBuster; + }).catch((error) => { + throw new Error(error); + }); +} + +export type MediaUploadedOutput = { + src: string; + originalFilename: string; + thumbnailSrc?: string; +}; + +export async function uploadMedia(file: File): Promise { + if (isVideo(file.name)) { + const { thumbnail, playback } = await uploadVideo(file); + + return { + thumbnailSrc: thumbnail, + src: playback.iframe, + originalFilename: file.name, + }; + } + + if (isImage(file.name)) { + const uploadedUrl = await uploadImage(file); + + return { + src: uploadedUrl, + originalFilename: file.name, + }; + } + + throw new UnsupportedFileTypeError("Unsupported file type"); +} + +export const loadFileFromURL = async ( + url: string | Promise, + name: string +) => { + try { + const response = await fetch(addCacheBusterParameter(await url)); + const data = await response.blob(); + + return new File([data], name, { + type: data.type || "image/jpeg", + }); + } catch (e) { + console.error(e); + } + + return undefined; +}; + +export const validateFile = async ({ + file, + maxSizeInMB, + minImageSize, + validTypes, +}: { + file: File; + maxSizeInMB: number; + minImageSize?: number; + validTypes?: string[]; +}) => { + if (file.size > maxSizeInMB * 1024 * 1024) { + throw new FileTooLargeError("File is too large"); + } + + if (validTypes && !validTypes.includes(file.type)) { + throw new UnsupportedFileTypeError("Unsupported file type"); + } + + if (minImageSize && file.type.startsWith("image/")) { + const image = await createImageBitmap(file); + if (image.width < minImageSize || image.height < minImageSize) { + throw new ImageTooSmallError( + `Image dimensions must be at least ${minImageSize}x${minImageSize} pixels` + ); + } + } + + return file; +}; diff --git a/interface_base/src/utils/iframeMessage.ts b/interface_base/src/utils/iframeMessage.ts new file mode 100644 index 0000000..56c903c --- /dev/null +++ b/interface_base/src/utils/iframeMessage.ts @@ -0,0 +1,60 @@ +export const TYPE_MESSAGE_CREATE_EXPERIENCE = { + NOTIFICATION: "NOTIFICATION", + DONE: "DONE", + EXIT: "EXIT", +}; + +export class IframeMessage { + static unbindEvent( + element: Window, + eventName: string, + eventHandler: (event: any) => void + ) { + if (!!element.addEventListener) { + element.removeEventListener(eventName, eventHandler, false); + } else { + (element).detachEvent("on" + eventName, eventHandler); + } + } + + static bindEvent( + element: Window, + eventName: string, + eventHandler: (event: any) => void + ) { + if (!!element.addEventListener) { + element.addEventListener(eventName, eventHandler, false); + } else { + (element).attachEvent("on" + eventName, eventHandler); + } + } + + static inIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } + } + + static postMessage(msg: { type: string; data?: any }) { + try { + window.parent.postMessage(msg, "*"); + } catch (error) {} + } + + static notification(message: string) { + IframeMessage.postMessage({ + type: TYPE_MESSAGE_CREATE_EXPERIENCE.NOTIFICATION, + data: message, + }); + } + + static done() { + IframeMessage.postMessage({ type: TYPE_MESSAGE_CREATE_EXPERIENCE.DONE }); + } + + static exit() { + IframeMessage.postMessage({ type: TYPE_MESSAGE_CREATE_EXPERIENCE.EXIT }); + } +} diff --git a/interface_base/src/utils/image.ts b/interface_base/src/utils/image.ts new file mode 100644 index 0000000..19c9373 --- /dev/null +++ b/interface_base/src/utils/image.ts @@ -0,0 +1,188 @@ +import { useEffect, useState } from "react"; +import { isMobile, isSafari } from "react-device-detect"; +import { FLAGS, isOff } from "@komi-app/flags-sdk"; + +export const getCanvasFromVideo = (selector: string) => { + const video: any = document.querySelector(selector); + const canvas: any = document.createElement("canvas"); + + if (video) { + canvas.width = video.offsetWidth; + canvas.height = video.offsetHeight; + const ratio = video.videoWidth / video.videoHeight; + const newHeight = + video.videoWidth / (video.offsetWidth / video.offsetHeight); + + if (isMobile) { + canvas + .getContext("2d") + .drawImage( + video, + 0, + 0, + video.videoWidth, + video.videoHeight, + 0, + 0, + video.offsetHeight * ratio, + video.offsetHeight + ); + } else { + if (isSafari) { + canvas + .getContext("2d") + .drawImage(video, 0, 0, video.offsetWidth, video.offsetWidth / ratio); + } else { + canvas + .getContext("2d") + .drawImage( + video, + 0, + Math.abs(newHeight - video.videoHeight) / 2, + video.videoWidth, + video.videoHeight, + 0, + 0, + video.offsetWidth, + video.offsetWidth / ratio + ); + } + } + + // convert it to a usable data URL + const dataURL = canvas.toDataURL("image/png"); + return dataURL; + } +}; +export const loadImage = (file: any) => + new Promise((resolve, reject) => { + try { + const image = new Image(); + + image.onload = function () { + resolve(this); + }; + + image.onerror = function () { + reject("Invalid image. Please select an image file."); + }; + + image.src = window.URL.createObjectURL(file); + } catch (e) { + reject(e); + } + }); + +export const getCircleFlagByCode = (code: string) => { + return `https://hatscripts.github.io/circle-flags/flags/${code}.svg`; +}; + +/** + * Add cache buster parameter to image URL. This is forces the browser to fetch the image from the server + * with the latest CORS headers. Image can be cached after the first request so using a static cache buster. + * @param imageUrl + */ +export function addCacheBusterParameter(imageUrl: string) { + if (imageUrl.startsWith("data")) return imageUrl; + + const imageUrlWithCacheBuster = new URL(imageUrl); + imageUrlWithCacheBuster.searchParams.set("cacheBuster", "1"); // cache bust once & then allow caching + return imageUrlWithCacheBuster.toString(); +} + +async function transformImageFromUrl(imageUrl: string): Promise { + const { width, height, cropX, cropY } = + parseTransformationParams(imageUrl) || {}; + + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = addCacheBusterParameter(imageUrl); + + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = width || img.width; + canvas.height = height || img.height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + reject(new Error("Failed to get 2D context")); + return; + } + + // turns transparent backgrounds white, instead of black + ctx.fillStyle = "#FFFFFF"; + ctx.fillRect(0, 0, width || img.width, height || img.height); + + ctx.drawImage( + img, + cropX || 0, + cropY || 0, + width || img.width, + height || img.height, + 0, + 0, + canvas.width, + canvas.height + ); + + const dataUrl = canvas.toDataURL("image/jpeg"); + resolve(dataUrl); + }; + + img.onerror = (err) => { + reject(err); + }; + }); +} + +type TransformationParams = { + width: number; + height: number; + cropX: number; + cropY: number; +}; + +function parseTransformationParams( + url: string +): TransformationParams | undefined { + const urlObj = new URL(url); + const trParams = urlObj.searchParams.get("tr"); + + if (!trParams) { + return undefined; + } + + const { + w: width, + h: height, + + x: cropX, + y: cropY, + } = trParams.split(",").reduce( + (acc, curr) => { + const [key, value] = curr.split("-"); + return { ...acc, [key]: parseInt(value || "0") }; + }, + { + w: 0, + h: 0, + x: 0, + y: 0, + } + ); + + return { + width, + height, + cropX, + cropY, + }; +} + +export const isImageKitUrl = (url: string | null | undefined) => { + if (!url) { + return false; + } + return url.startsWith("https://komi") && url.includes("-assets"); +}; diff --git a/interface_base/src/utils/json-patch.ts b/interface_base/src/utils/json-patch.ts new file mode 100644 index 0000000..51318b4 --- /dev/null +++ b/interface_base/src/utils/json-patch.ts @@ -0,0 +1,71 @@ +import jsonPatch from "fast-json-patch"; +import pick from "lodash/pick"; + +interface PatchOptions { + actions?: + | "add" + | "remove" + | "replace" + | "move" + | "copy" + | "test" + | "_get" + | string[]; + ignoreFields?: string[]; +} + +export class JSONService { + static getPatch = (target: any, source: any, options: PatchOptions = {}) => { + if (typeof target !== "object" || typeof source !== "object") return {}; + const s = jsonPatch.deepClone(source); + const t = jsonPatch.deepClone(target); + + // remove ignore field before compare + if (options?.ignoreFields && options?.ignoreFields.length > 0) { + for (const field of options?.ignoreFields) { + field in s && delete s[field]; + field in t && delete t[field]; + } + } + + const patch = jsonPatch.compare(s, t); + + if (typeof options?.actions === "string") { + return pick( + target, + patch + .filter((p) => p.op === options?.actions) + .map( + (p) => + p.path + .split("/") + .filter((p) => p !== "" && p !== null && p !== undefined)[0] + ) + ); + } + + if (options?.actions && Array.isArray(options?.actions)) { + return pick( + target, + patch + .filter((p) => options?.actions?.includes(p.op)) + .map( + (p) => + p.path + .split("/") + .filter((p) => p !== "" && p !== null && p !== undefined)[0] + ) + ); + } + + return pick( + target, + patch.map( + (p) => + p.path + .split("/") + .filter((p) => p !== "" && p !== null && p !== undefined)[0] + ) + ); + }; +} diff --git a/interface_base/src/utils/modules.ts b/interface_base/src/utils/modules.ts new file mode 100644 index 0000000..64cfc0d --- /dev/null +++ b/interface_base/src/utils/modules.ts @@ -0,0 +1,131 @@ +import { + BandsintownItem, + BaseItem, + DataCaptureFormItem, + EventItem, + ExperienceItem, + FanClubItem, + GroupItem, + LinkItem, + MusicItem, + OnDemandVideoItem, + PodcastItem, + ProductItem, + ShopifyModuleItem, + ShopifyProductItem, + TalentModuleMixItem, + TalentProfileModule, + TalentProfileModuleGroup, + YoutubeVideoItem, +} from "models/talent/talent-profile-module.model"; + +import { ModuleType } from "@komi-app/shared-types" + +// TODO: No `ModuleType` enum value corresponds to `TalentModuleMixItem.DataCaptureFormItem` +// TODO: No `TalentModuleMixItem` enum value corresponds to `ModuleType.SHOP_MY_SHELF` +// TODO: No `TalentModuleMixItem` enum value corresponds to `ModuleType.SHOPIFY_COLLECTION` +// TODO: No `TalentModuleMixItem` enum value corresponds to `ModuleType.YOUTUBE` +// TODO: No `TalentModuleMixItem` enum value corresponds to `ModuleType.YOUTUBE_COLLECTION` + +export const isModuleGroup = ( + module: GroupItem +): module is TalentProfileModuleGroup => { + return module.type === ModuleType.GROUP; +}; + +export const isProductItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.PRODUCT; +}; +export const isLinkItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.LINK; +}; +export const isMusicItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.MUSIC; +}; +export const isOnDemandVideoItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.ON_DEMAND_VIDEO; +}; +export const isYoutubeVideoItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.YOUTUBE_VIDEO; +}; +export const isExperienceItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.EXPERIENCE; +}; +export const isPodcastItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.PODCAST; +}; +export const isFanClubItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.FAN_CLUB; +}; +export const isBandsintownItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.BANDSINTOWN; +}; +export const isShopifyProductItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.SHOPIFY_PRODUCT; +}; +export const isRyeShopifyProductItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.RYE_SHOPIFY_PRODUCT; +}; +export const isShopifyModuleItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.SHOPIFY; +}; +export const isRyeShopifyModuleItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.RYE_SHOPIFY; +}; +export const isEventItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.EVENTS; +}; +export const isDataCaptureFormItem = ( + module: GroupItem +): module is TalentProfileModule => { + return module.type === ModuleType.FORM_DATA; +}; + +export const flipVisibilityForItemAndCloneArray = ( + item: BaseItem, + items: BaseItem[] +): any[] => { + let index: number; + if (item.order === undefined) index = items.findIndex((x) => x == item); + else { + index = item.order; + } + const updatedItem = Object.assign({}, items[index]); + updatedItem.visible = !updatedItem.visible; + updatedItem.isUpdate = true; + const updatedItems = Array.from(items); + updatedItems[index] = updatedItem; + return updatedItems; +}; + +export const isEmptyModule = (module: TalentProfileModule) => { + return !module.items || !module.items.length; +}; diff --git a/interface_base/src/utils/noop.ts b/interface_base/src/utils/noop.ts new file mode 100644 index 0000000..c8ace05 --- /dev/null +++ b/interface_base/src/utils/noop.ts @@ -0,0 +1,4 @@ +/** + * A function that does nothing :vibing-bird: + */ +export const noop = (...args: unknown[]) => {}; diff --git a/interface_base/src/utils/notification.tsx b/interface_base/src/utils/notification.tsx new file mode 100644 index 0000000..6433e1e --- /dev/null +++ b/interface_base/src/utils/notification.tsx @@ -0,0 +1,96 @@ +import React, { ReactNode } from "react"; +import message, { ConfigOptions } from "antd/lib/message"; +import { + NotificationMessage, + NotificationIcon, +} from "components/NotificationMessage"; +import { ErrorPublishNotification } from "components/ErrorPublishNotification"; +import classNames from "classnames"; + +export type NoticeType = "info" | "success" | "error" | "warning" | "loading"; + +const defaultConfig: ConfigOptions = { + top: 5, + duration: 5, + maxCount: 5, +}; + +type MessageParams = { + message: string | ReactNode; + key?: string; + description?: string; + duration?: number; + isOffDuration?: boolean; + multiline?: boolean; +}; +type MessagesParams = { + key: string; + messages: any[]; + isOffDuration?: boolean; +}; + +class Notifiaction { + constructor(config: ConfigOptions) { + message.config(config); + } + + private notify(type: NoticeType, params: MessageParams) { + const key = + typeof params.message === "string" ? params.message : params.key; + + message.open({ + key, + type: type, + className: classNames("noti-message", "noti-message--multiline"), + icon: , + duration: + params.duration === 0 + ? 0 + : params.duration || defaultConfig.duration || 2, + content: ( + message.destroy(key)} + /> + ), + }); + } + + showErrorPublish(params: MessagesParams) { + message.open({ + key: params.key, + type: "error", + className: classNames("noti-message", "noti-message--multiline"), + icon: , + duration: undefined, + content: ( + message.destroy(params.key)} + /> + ), + }); + } + + destroy(key: string) { + message.destroy(key); + } + + error(params: MessageParams) { + this.notify("error", params); + } + + warn(params: MessageParams) { + this.notify("warning", params); + } + + success(params: MessageParams) { + this.notify("success", params); + } + + info(params: MessageParams) { + this.notify("info", params); + } +} + +export default new Notifiaction(defaultConfig); diff --git a/interface_base/src/utils/number.ts b/interface_base/src/utils/number.ts new file mode 100644 index 0000000..caba9ef --- /dev/null +++ b/interface_base/src/utils/number.ts @@ -0,0 +1,74 @@ +import { ExchangeRateDefault } from "constants/exchange-rate"; +import floor from "lodash/floor"; +import { ExchangeRate } from "redux/User/types"; + +export const formatterNumber = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const getRoundingPriceLocal = ( + price: number, + exchangeRate: ExchangeRate = ExchangeRateDefault +) => { + return floor(price, 0) + floor(0.999999, exchangeRate?.decimalDigits); +}; + +export const formatPriceToAppDisplayCurrency = ( + price: string | number | undefined +) => { + if (!price) return 0; + + let priceTxt = price?.toString()?.replace(",", "."); + let result = ""; + + for (const element of priceTxt) { + if (Number(element) || Number(element) === 0) { + result += element; + } + if (element === "." && !result.includes(".")) { + result += "."; + } + } + + priceTxt = result; + if (!Number(priceTxt)) return 0; + + return Number(Number(priceTxt)?.toFixed(2)?.split(".")[0] + ".99"); +}; + +export const findNearestPrice = ( + price: number | string | undefined, + list: Array +) => { + if (!price) return 0; + + if (price > 999.99) { + return parseInt(price as string) + 0.99; + } + + let priceTxt = price?.toString()?.replace(",", "."); + let result = ""; + + for (const element of priceTxt) { + if (Number(element) || Number(element) === 0) { + result += element; + } + if (element === "." && !result.includes(".")) { + result += "."; + } + } + + priceTxt = result; + if (!Number(priceTxt)) return 0; + + return list.find((val) => Number(priceTxt) <= val); +}; + +export const getNumberFromString = (text: string) => { + const value = text.match(/\d+/)?.[0]; + if (!value) { + return undefined; + } + return parseInt(value, 10); +}; diff --git a/interface_base/src/utils/operators.ts b/interface_base/src/utils/operators.ts new file mode 100644 index 0000000..594bd79 --- /dev/null +++ b/interface_base/src/utils/operators.ts @@ -0,0 +1,3 @@ +export function conditionalInclude(condition: boolean, ...item: T[]): T[] { + return condition ? item : []; +} diff --git a/interface_base/src/utils/photo.ts b/interface_base/src/utils/photo.ts new file mode 100644 index 0000000..7a7cf6a --- /dev/null +++ b/interface_base/src/utils/photo.ts @@ -0,0 +1,737 @@ +import { RcFile, UploadFile } from "antd/lib/upload/interface"; +import Axios from "axios"; +import { Video } from "@komi-app/profiles-sdk"; +import { awsService, photoService, videoService } from "services"; +import { PresignedPostData } from "services/externals/AWSService"; +import notification from "utils/notification"; +import { + getExtension, + isDocumentFile, + isImage, + isSoundFile, + isVideo, +} from "./file"; +import { Area } from "react-easy-crop/types"; +import config from "config"; +import { FLAGS, isOn } from "@komi-app/flags-sdk"; + +export const getVideoDuration = (file: any): Promise => + new Promise((resolve, reject) => { + try { + const video = document.createElement("video"); + video.preload = "metadata"; + video.onloadedmetadata = function () { + window.URL.revokeObjectURL(video.src); + resolve(Math.floor(video.duration)); + }; + video.onerror = function () { + reject("Invalid video."); + }; + video.src = window.URL.createObjectURL(file); + } catch (e) { + console.log("getVideoDuration error:", e); + reject(e); + } + }); + +export const getVideoDurationWithUrl = (url: string): Promise => + new Promise((resolve, reject) => { + try { + const video = document.createElement("video"); + video.preload = "metadata"; + video.onloadedmetadata = function () { + window.URL.revokeObjectURL(video.src); + resolve(Math.floor(video.duration)); + }; + video.onerror = function () { + reject("Invalid video."); + }; + video.src = url; + } catch (e) { + console.log("getVideoDuration error:", e); + reject(e); + } + }); + +export const onBeforeUpload = (file: RcFile, checkAvatar?: boolean) => { + const limitSize = isImage(file.name) + ? file.size / 1024 / 1024 < 6 + : file.size / 1024 / 1024 < 1024 * 15; + + if (!limitSize && isImage(file.name)) { + notification.error({ message: "Your image must be smaller than 6MB" }); + } + if (!limitSize && isVideo(file.name)) { + notification.error({ message: "Your Video must smaller than 15GB!" }); + } + if (checkAvatar && isVideo(file.name)) { + notification.error({ message: "Cannot use Video for avatar" }); + } + + if (checkAvatar) return limitSize && !isVideo(file.name); + return limitSize; +}; + +export const handleCreatePresignedUrl = async ( + file: UploadFile | File +): Promise => { + if (!file.name) return; + + const fileName = file.name; + const fileExtension = getExtension(fileName)?.toLowerCase() as string; + const contentType = file.type || ""; + + let result: any; + + if (isDocumentFile(fileName) || isSoundFile(fileName)) { + result = await photoService.getPresignedUrl( + fileExtension, + file.name.split(".")[0], + contentType + ); + } else if (isVideo(fileName)) { + result = await photoService.getPresignedVideoUrl(fileExtension); + } else { + result = await photoService.getPresignedPhotoUrl( + fileExtension, + contentType + ); + } + + if (result && !result.ok) { + throw new Error(result.message); + } else return result.response; +}; + +async function uploadParts( + files: any, + results: { + parts: number; + objectKey: string; + urls: Record; + uploadId: string; + partSize: number; + }, + callback?: (value: number) => void, + threshHold = 3 +): Promise> { + const { parts, objectKey, uploadId, urls, partSize } = results; + const axios = Axios.create(); + delete axios.defaults.headers.put["Content-Type"]; + + const keys = Object.keys(urls); + + let percentageUpload = 0; + // eslint-disable-next-line no-var + var runCallbackInterval = setInterval(() => { + if (percentageUpload < Math.ceil((1 / parts) * 100)) { + callback && callback(percentageUpload); + percentageUpload = percentageUpload + 1; + } + }, 1000); + + const resParts: any = []; + let promises = []; + const MAXIMUM_CONCURRENT = 5; + + // eslint-disable-next-line no-var + var numberUploaded = 0; + // eslint-disable-next-line no-var + var stopUpload = false; + + const uploadPart = async (val: number, blob: Buffer) => { + let tryTimes = 0; + let success = false; + + while (!success && tryTimes <= threshHold) { + const request = async () => { + const result = await axios.put(urls[val + 1], blob); + + if (numberUploaded === 0) clearInterval(runCallbackInterval); + + callback && + callback(Math.ceil((Number(numberUploaded + 1) / parts) * 100)); + + numberUploaded = numberUploaded + 1; + + success = true; + return result; + }; + try { + return await request(); + } catch (err) { + tryTimes++; + } + } + if (!success && !stopUpload) { + await photoService.stopMultipartUpload(objectKey, uploadId); + stopUpload = true; + clearInterval(runCallbackInterval); + throw new Error(""); + } + }; + + let count = 0; + + while (count < keys.length) { + const start = count * partSize; + const end = (count + 1) * partSize; + + const partFile = + count !== keys.length - 1 ? files.slice(start, end) : files.slice(start); + + const blob: Buffer = await fileToBuffer(partFile); + console.log(blob); + + if (promises.length < MAXIMUM_CONCURRENT) { + promises.push(uploadPart(count, blob)); + } + const canCount = + promises.length !== MAXIMUM_CONCURRENT && count !== keys.length - 1; + + if (canCount) count++; + + if (promises.length === MAXIMUM_CONCURRENT || count === keys.length - 1) { + const result = await Promise.all(promises); + resParts.push(...result); + promises = []; + if (!canCount) count++; + } + } + + return resParts.map((part: any, index: number) => ({ + ETag: (part as any)?.headers?.etag, + PartNumber: index + 1, + })); +} + +const handleCreateInitiateMultipartUpload = async ( + file: UploadFile | File +): Promise<{ + urls: Array; + partSize: number; + objectKey: string; + uploadId: string; + parts: number; +}> => { + let result: any; + + const fileName = file.name; + const fileExtension = getExtension(fileName)?.toLowerCase() as string; + const fileSize = file.size || 0; + + try { + result = await photoService.createInitiateMultipartUpload( + fileExtension, + fileName, + fileSize + ); + } catch (err) { + // To do later + } + + if (result && !result.ok) { + throw new Error(result.message); + } else return result.response; +}; + +async function fileToBuffer(file: any): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsArrayBuffer(file); + reader.onload = (event: any) => { + if (event.target.readyState !== FileReader.DONE) { + return reject(event?.error); + } + return resolve(event.target.result); + }; + }); +} + +export const pushImageToS3 = async ( + file: UploadFile, + callbackFn?: (value: number, xhr?: any) => void, + onFinish?: () => void +): Promise => { + /*const MAXIMUM_FILE_SUPPORTED_WEBVIEW = 250; + + if ( + file.size > MAXIMUM_FILE_SUPPORTED_WEBVIEW * 1024 * 1024 && + webviewMessage.isWebView() + ) { + webviewMessage.alert({ + subTitle: `Sorry, we cannot support file sizes larger than ${MAXIMUM_FILE_SUPPORTED_WEBVIEW}MB on mobile, please try again on desktop`, + button1: "Close", + type: ALERT_TYPE_WEBVIEW.UPLOAD_VIDEO_SIZE_BIG, + }); + + return; + }*/ + + if ((file.size || 0) > 50 * 1024 * 1024 && isVideo(file.name)) { + try { + const result = await handleCreateInitiateMultipartUpload(file); + + const uploadResult = await uploadParts( + file?.originFileObj, + result, + callbackFn + ); + + if (uploadResult.filter((value) => value.ETag).length === result.parts) { + const data: any = await photoService.getCompleteMultipartUpload( + result?.objectKey, + result.uploadId, + uploadResult + ); + + return data.ok && data?.response && data.response?.url; + } + } catch { + notification.error({ + message: + "Highly unstable network connection. Please check your network connect again.", + duration: 0, + isOffDuration: true, + }); + return; + } + } else { + try { + const presignedData = await handleCreatePresignedUrl(file); + + if (isVideo(file.name)) { + return await awsService.uploadVideoToS3( + presignedData, + file.originFileObj, + callbackFn, + onFinish + ); + } else { + return await awsService.uploadPhotoToS3( + presignedData, + file.originFileObj, + callbackFn, + onFinish + ); + } + } catch (e: any) { + notification.error({ + message: e.message, + description: "", + }); + } + } +}; + +export const uploadImageToS3 = async ( + file: File +): Promise => { + if (isVideo(file.name)) { + // only supports image + return; + } + + if ((file.size || 0) > 50 * 1024 * 1024) { + // above maximum size allowed + return; + } + + const presignedData = await handleCreatePresignedUrl(file); + + return await awsService.uploadPhotoToS3(presignedData, file); +}; + +export const uploadImageWithCrop = async ( + imageFile: File, + cropParams: Area +) => { + const imageUrl = await uploadImage(imageFile); + + const urlObj = new URL(imageUrl); + + // set crop params for imagekit api + urlObj.searchParams.set( + "tr", + `w-${cropParams.width},h-${cropParams.height},cm-extract,x-${cropParams.x},y-${cropParams.y}` + ); + + urlObj.searchParams.set("crp", JSON.stringify(cropParams)); + + return { + fileName: imageFile.name, + url: urlObj.toString(), + }; +}; + +/** + * Uploads an image to S3 + * This function will throw an error if the file is not an image or if the file size is greater than 50MB. + * This means `undefined` will never be returned, compared to the previous implementation. + * @param file + */ +export const uploadImage = async (file: File): Promise => { + if (!isImage(file.name)) { + throw new UnsupportedFileTypeError("Only image files are supported"); + } + + if ((file.size || 0) > 50 * 1024 * 1024) { + throw new FileTooLargeError("File size must be less than 50MB"); + } + + const presignedUrl = await handleCreatePresignedUrl(file); + + return awsService.uploadPhotoToS3(presignedUrl, file); +}; + +/** + * This functions uploads a video directly to the target, based on a returned signed upload url. + * + * @param file + * @returns Video object with thumbnail, and playback urls + */ +export const uploadVideo = async (file: File): Promise