diff --git a/.babelrc.json b/.babelrc.json new file mode 100644 index 000000000..1edc76172 --- /dev/null +++ b/.babelrc.json @@ -0,0 +1,7 @@ +{ + "sourceType": "unambiguous", + "presets": ["@babel/preset-env", "@babel/preset-typescript", [ + "@babel/preset-react", {"runtime": "automatic"} + ]], + "plugins": ["@babel/plugin-transform-runtime"] +} \ No newline at end of file diff --git a/.circleci/config.yml b/.circleci/config.yml index 271484925..2d3e1638d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,6 +54,12 @@ running_yarn_build: &running_yarn_build yarn install yarn build +running_yarn_sb_build: &running_yarn_sb_build + name: Running Yarn Storybook Build + command: | + source buildenvvar + yarn sb:build + running_yarn_test: &running_yarn_test name: Running Yarn Test Build command: | @@ -91,6 +97,7 @@ build_steps: &build_steps - run: *install_deploysuite - run: *build_configuration_fetch - run: *running_yarn_build + - run: *running_yarn_sb_build - persist_to_workspace: *workspace_persist test_steps: &test_steps @@ -129,13 +136,13 @@ deploy_steps: &deploy_steps ./master_deploy.sh -d CFRONT -e $DEPLOY_ENV -c $ENABLE_CACHE jobs: - lint-dev: - <<: *defaults - environment: - DEPLOY_ENV: "DEV" - LOGICAL_ENV: "dev" - APPNAME: "platform-ui-mvp" - steps: *lint_steps + # lint-dev: + # <<: *defaults + # environment: + # DEPLOY_ENV: "DEV" + # LOGICAL_ENV: "dev" + # APPNAME: "platform-ui-mvp" + # steps: *lint_steps # lint-prod: # <<: *defaults @@ -153,6 +160,14 @@ jobs: APPNAME: "platform-ui-mvp" steps: *build_steps + build-qa: + <<: *defaults + environment: + DEPLOY_ENV: "QA" + LOGICAL_ENV: "qa" + APPNAME: "platform-ui-mvp" + steps: *build_steps + build-prod: <<: *defaults environment: @@ -179,6 +194,15 @@ jobs: APPNAME: "platform-ui-mvp" steps: *deploy_steps + deployQa: + <<: *deploy_defaults + environment: + DEPLOY_ENV: "QA" + LOGICAL_ENV: "qa" + ENABLE_CACHE: true + APPNAME: "platform-ui-mvp" + steps: *deploy_steps + deployProd: <<: *deploy_defaults environment: @@ -192,12 +216,12 @@ workflows: version: 2 build: jobs: - - lint-dev: - context : org-global - filters: - branches: - ignore: - - master + # - lint-dev: + # context : org-global + # filters: + # branches: + # ignore: + # - master # - lint-prod: # context : org-global @@ -212,6 +236,14 @@ workflows: branches: ignore: - master + - qa + + - build-qa: + context : org-global + filters: + branches: + only: + - qa - build-prod: context : org-global @@ -229,6 +261,15 @@ workflows: only: - dev + - deployQa: + context : org-global + requires: + - build-qa + filters: + branches: + only: + - qa + - deployProd: context : org-global requires: diff --git a/.environments/.env.dev b/.environments/.env.dev new file mode 100644 index 000000000..1c2c3148b --- /dev/null +++ b/.environments/.env.dev @@ -0,0 +1,25 @@ +REACT_APP_HOST_ENV=dev + +REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false + +# Stripe configs +REACT_APP_STRIPE_API_KEY=pk_test_rfcS49MHRVUKomQ9JgSH7Xqz +REACT_APP_STRIPE_API_VERSION=2020-08-27 +# not really used anywhere +REACT_APP_STRIPE_ADMIN_TOKEN= +REACT_APP_STRIPE_CUSTOMER_TOKEN= + +# Vanilla Forums +REACT_APP_VANILLA_ACCESS_TOKEN=va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV + +# DataDogLogging +REACT_APP_DATADOG_PUBLIC_TOKEN=puba0825671e469d16f940c5a30dc738f11 + +REACT_APP_MEMBER_VERIFY_LOOKER=3322 + +REACT_APP_SPRIG_ENV_ID=bUcousVQ0-yF + +# Filestack configuration for uploading Submissions +REACT_APP_FILESTACK_API_KEY='AzFINuQoqTmqw0QEoaw9az' +REACT_APP_FILESTACK_REGION='us-east-1' +REACT_APP_FILESTACK_SUBMISSION_CONTAINER='topcoder-dev-submissions-dmz' diff --git a/.environments/.env.prod b/.environments/.env.prod new file mode 100644 index 000000000..29368ec95 --- /dev/null +++ b/.environments/.env.prod @@ -0,0 +1,25 @@ +REACT_APP_HOST_ENV=prod + +REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false + +# Stripe configs +REACT_APP_STRIPE_API_KEY=pk_live_m3bCBVSfkfMOEp3unZFRsHXi +REACT_APP_STRIPE_API_VERSION=2020-08-27 +# not really used anywhere +REACT_APP_STRIPE_ADMIN_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw +REACT_APP_STRIPE_CUSTOMER_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg + +# Vanilla Forums +REACT_APP_VANILLA_ACCESS_TOKEN=va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV + +# DataDogLogging +REACT_APP_DATADOG_PUBLIC_TOKEN=puba0825671e469d16f940c5a30dc738f11 + +REACT_APP_MEMBER_VERIFY_LOOKER=3322 + +REACT_APP_SPRIG_ENV_ID=bUcousVQ0-yF + +# Filestack configuration for uploading Submissions +REACT_APP_FILESTACK_API_KEY= +REACT_APP_FILESTACK_REGION= +REACT_APP_FILESTACK_SUBMISSION_CONTAINER= diff --git a/.environments/.env.qa b/.environments/.env.qa new file mode 100644 index 000000000..24db1f9ac --- /dev/null +++ b/.environments/.env.qa @@ -0,0 +1,25 @@ +REACT_APP_HOST_ENV=qa + +REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false + +# Stripe configs +REACT_APP_STRIPE_API_KEY=pk_test_rfcS49MHRVUKomQ9JgSH7Xqz +REACT_APP_STRIPE_API_VERSION=2020-08-27 +# not really used anywhere +REACT_APP_STRIPE_ADMIN_TOKEN= +REACT_APP_STRIPE_CUSTOMER_TOKEN= + +# Vanilla Forums +REACT_APP_VANILLA_ACCESS_TOKEN=va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV + +# DataDogLogging +REACT_APP_DATADOG_PUBLIC_TOKEN=puba0825671e469d16f940c5a30dc738f11 + +REACT_APP_MEMBER_VERIFY_LOOKER=3322 + +REACT_APP_SPRIG_ENV_ID=bUcousVQ0-yF + +# Filestack configuration for uploading Submissions +REACT_APP_FILESTACK_API_KEY= +REACT_APP_FILESTACK_REGION= +REACT_APP_FILESTACK_SUBMISSION_CONTAINER= diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..2b7a30cb0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +Related JIRA Ticket: +https://topcoder.atlassian.net/browse/ + +# What's in this PR? + + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6a24ee936..272d0ee0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +node_modules /.pnp .pnp.js @@ -18,11 +18,11 @@ # misc .DS_Store .env.local -.env.development.local -.env.test.local -.env.production.local +.env.*.local .env npm-debug.log* yarn-debug.log* yarn-error.log* + +storybook-static diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..26a4cb686 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,38 @@ +import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin"; +import type { StorybookConfig } from "@storybook/react-webpack5"; + +import cracoConfig from '../craco.config'; + +const config: StorybookConfig = { + stories: ["../src/**/*.docs.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/preset-create-react-app", + "@storybook/addon-interactions", + ], + framework: { + name: "@storybook/react-webpack5", + options: {}, + }, + docs: { + autodocs: "tag", + }, + staticDirs: ["../public"], + webpackFinal: async (config, { configType }) => { + + if (config.resolve) { + config.resolve.plugins = [ + ...(config.resolve.plugins ?? []), + new TsconfigPathsPlugin() + ]; + config.resolve.alias = { + ...config.resolve.alias, + ...cracoConfig.webpack.alias, + }; + } + + return config; + } +}; +export default config; diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html new file mode 100644 index 000000000..34389bd54 --- /dev/null +++ b/.storybook/manager-head.html @@ -0,0 +1,3 @@ + diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 000000000..95062d7cf --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,6 @@ + diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 000000000..1c372b694 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,15 @@ +import type { Preview } from "@storybook/react"; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/.vscode/components.code-snippets b/.vscode/components.code-snippets new file mode 100644 index 000000000..da4851e5f --- /dev/null +++ b/.vscode/components.code-snippets @@ -0,0 +1,91 @@ +{ + // Place your mfe-customer-work workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "[MFE] React component": { + "scope": "typescript,typescriptreact", + "prefix": "rfc", + "body": [ + "import { FC } from 'react'", + "", + "import styles from './${1:ComponentName}.module.scss'", + "", + "interface ${1:ComponentName}Props {", + "}", + "", + "const ${1:ComponentName}: FC<${1:ComponentName}Props> = (props: ${1:ComponentName}Props) => {", + "", + " return (", + "
", + "
", + " )", + "}", + "", + "export default ${1:ComponentName}", + "" + ], + "description": "Create a react functional component" + }, + "[MFE] export comp": { + "scope": "typescript,typescriptreact", + "prefix": "exp", + "body": [ + "export { default as ${1:ComponentName} } from './${1:ComponentName}'", + "" + ], + "description": "Export module" + }, + "[MFE] use state": { + "scope": "typescript,typescriptreact", + "prefix": "usest", + "body": [ + "const [$1, set$2]: [$3, Dispatch>] = useState($4)$0", + ] + }, + "[MFE] Storybook Template": { + "scope": "typescript,typescriptreact", + "prefix": "sb", + "body": [ + "/* eslint-disable no-underscore-dangle */", + "/* eslint-disable camelcase */", + "", + "import { Meta, StoryObj } from '@storybook/react'", + "", + "import '../../styles/index.scss'", + "", + "import { ${1:ComponentName} } from '.'", + "", + "const meta: Meta = {", + " argTypes: {", + " },", + " component: ${1:ComponentName},", + " excludeStories: /.*Decorator$/,", + " tags: ['autodocs'],", + " title: 'Components/${1:ComponentName}',", + "}", + "", + "export default meta", + "", + "type Story = StoryObj;", + "", + "export const Primary: Story = {", + " args: {", + " },", + "}", + "" + ] + } + } diff --git a/README.md b/README.md index 044bb5e22..0d864b00b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ All future user interfaces at Topcoder will be implemented here. Pre-existing us - [Local Development](#local-development) - [Application Structure](#application-structure) - [Coding Practices](#coding-practices) -- [Tools](#tools) +- [All Apps](#applications-hosted-under-platform-ui) --- @@ -73,7 +73,7 @@ e.g.: `PROD-001 #comment adding readme notes #time 45m` # Local Development - [Local Environment Setup](#local-environment-setup) -- [Tool-specific Setup](#tool-specific-setup) +- [App specific Setup](#app-specific-setup) - [Yarn Commands](#yarn-commands) ## Local Environment Setup @@ -93,6 +93,8 @@ Use the [VS Code](https://code.visualstudio.com/download) IDE for MFE developmen ### nvm Use the node version manager [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) to easily and safely manage the required version of NodeJS (aka, node). Download and install it per the instructions for your development operating system. Installing a version of node via `nvm` will also install `npm`. +>**NOTE:** If the nvm command is not working it might be because the installation failed to update your paths variable properly. To try and fix this try installing nvm again using sudo. + Once nvm is installed, run: >% nvm install @@ -128,7 +130,7 @@ You will need to add the following line to your hosts file. The hosts file is no ``` ### Serving -1. Open a bash shell +1. Open a terminal 2. Run the following commands >% git clone https://github.com/topcoder-platform/platform-ui.git @@ -143,6 +145,12 @@ You will need to add the following line to your hosts file. The hosts file is no >**NOTE**: The site must run on port 443 in order for auth0 to work and for the site to load properly. Mac users will need to run the app with elevated permissions. +Run following command to allow node to run apps on ports lowert than 500: + +``` +sudo setcap 'cap_net_bind_service=+ep' `which node` +``` + ### Local SSL SSL is required for authentication to work properly. @@ -163,172 +171,113 @@ For easier development, it is recommended that you add this certificate to your Otherwise, you will need to override the exception each time you load the site. Firefox users may need to user an incognito browser in order to override the exception. -### Personal Config -1. Add [hostname] to [`/src-ts/config/environments/app-host-environment.type.ts`](/src-ts/config/environments/app-host-environment.type.ts) -2. Copy an existing config from [`/src-ts/config/environments/environment.*.config.ts`](/src-ts/config/environments/environment.brooke.config.ts) -3. Rename new config `environment.[hostname].config.ts` -4. Rename config variable to `EnvironmentConfig[HostName]` -5. Set the `ENV` variable to `[hostname]` -6. Add the switch case for the host name to [`/src-ts/config/environments/environment.config.ts`](/src-ts/config/environments/environment.config.ts) -7. Prior to starting the server, set your host name: -```% export REACT_APP_HOST_ENV=[hostname]``` +## App specific setup ->**NOTE:** Individual tools (e.g. [Learn tool](/src-ts/tools/learn/README.md)) can have their own configuration, which can be configured the same way as the global config. - -#### For further convenience - -1. Copy start-ssl-*.sh -2. Rename to start-ssl-[hostname].sh -3. Set the REACT_APP_HOST_ENV=[hostname] -4. Add "start:[hostname]": "sh start-ssl-[hostname].sh" to scripts in package.json - - -## Tool-specific setup - -Each [Tool](#tools) can have its own setup requirements. Please see each tool's [README](#tools) for further information. +Each [Application](#applications-hosted-under-platform-ui) can have its own setup requirements. Please see each apps's [README](#applications-hosted-under-platform-ui) for further information. ## yarn Commands -| Command | Description | -| --------------------- | ------------------------------------------------------------------------------------- | -| `yarn start` | Serve dev mode build with the default config | -| `yarn start:` | Serve dev mode build with dev's personal config | -| `yarn build` | Build dev mode build with the default config and outputs static files in /builds | -| `yarn build:prod` | Build prod mode build with the prod config and outputs static files in /builds | -| `yarn lint:ts` | Run eslint against ts/x files and outputs report | -| `yarn lint:ts:fix` | Run eslint against ts/x files, fixes auto-fixable issues, and outputs report | -| `yarn lint:js` | Run eslint against js/x files and outputs report | -| `yarn lint:js:fix` | Run eslint against js/x files, fixes auto-fixable issues, and outputs report | -| `yarn lint` | Run eslint against js/x and ts/x files and outputs report | -| `yarn lint:fix` | Run eslint against js/x and ts/x files, fixes auto-fixable issues, and outputs report | -| `yarn test` | Run unit tests, watching for changes and re-running per your specifications | -| `yarn test:no-watch` | Run unit tests once, without watching for changes or re-running | -| `yarn cy:run` | Run e2e tests once in local command with the site is running | -| `yarn cy:ci` | Run e2e tests once by circle ci | -| `yarn report:coverage`| Generate e2e coverage report in html format | -| `yarn report:coverage:text` | Generate e2e coverage report in text format | - - - - +| Command | Description | +| --------------------- | ------------------------------------------------------------------------------------- | +| `yarn start` | Serve dev mode build with the default config | +| `yarn build` | Build dev mode build with the default config and outputs static files in /build | +| `yarn build:prod` | Build prod mode build with the prod config and outputs static files in /build | +| `yarn demo` | Serves the built files (by running yarn:build) for local testing | +| `yarn lint` | Run eslint against js/x and ts/x files and outputs report | +| `yarn lint:fix` | Run eslint against js/x and ts/x files, fixes auto-fixable issues, and outputs report | +| `yarn test` | Run unit tests, watching for changes and re-running per your specifications | +| `yarn test:no-watch` | Run unit tests once, without watching for changes or re-running | +| `yarn cy:run` | Run e2e tests once in local command with the site is running | +| `yarn cy:ci` | Run e2e tests once by circle ci | +| `yarn report:coverage` | Generate e2e coverage report in html format | +| `yarn report:coverage:text` | Generate e2e coverage report in text format | # Application Structure +- [The Platform App](#the-platform-app) - [Folder Structure](#folder-structure) -- [Adding a Tool or Util](#adding-a-tool-or-util) +- [Typescript Versus Javascript](#ts-versus-js) +- [Adding a new Platform UI application](#adding-a-new-platform-ui-application) -## Folder Structure +# The Platform App -The folder structure of the app has the following goals: +Under "src/apps/platform" is to be found the "mainframe" of the platform application. +This application only loads and serves all the other applications, and serves as the main router of the whole platform UI. +It also renders the [Universal Navigation](https://github.com/topcoder-platform/universal-navigation)'s header & footer. -- Hierarchy represents dependence -- Limit nesting -- Limit the number of folders w/in a given parent -- Easy to find items (familiar to React engineers) -- Short names -- Ubiquitous language +## Folder Structure -### /src & /src-ts +The goal for the PlatformUI is to eventually host as many apps from the Topcoder environment as possible. +To accomodate this, each individual app has it's own "workspace" that is to be found under "src/apps". +They can share components and common utilities by using libraries created under "src/libs", eg. "src/libs/ui". -The Work Tool is currently migrating from javascript to typescript. That's why in the root of the repository there are two source folders(`src` and `src-ts`). +The global (common) configuration files of the applications are to be found under "src/config/environments". ->**NOTE:** All work should be done in the /src-ts directory unless expressly instructed otherwise. +Note that we have some aliases defined in `craco.confg.js` and `tsconfig.paths.json`. These are defined for easier imports: +- `~` refers to 'src', so imports can be much cleaner: `import { Button } from '~/libs/ui'` +- in scss, you can point to the global ui styles, mixins & variables by using `@import '@libs/ui/styles/includes';` +- you can define a new allias for a new app, eg. the earn app has it's own alias, and it can be used as `@import '@earn/styles/variables';` -### /src-ts/config +### TS versus JS +At the moment, a few applications are imported from different codebases as they are, only a few updates have been made to them, hence they are written in javascript rather than typescript. +**The goal** is to have all applications transitioned to typescript eventuall. +So, if you write any new component/any new application, please use typescript, as we'll eventually deprecate the JS code. -Definitions of configurations for a specific host environment. See the [Personal Config](#personal-config) section for further info. +### /src/apps -### /src-ts/header +This is where all the applications under platform-ui will be created and live. Each application can have it's own configuration & setup. -Defines the entire header panel of the UI: +### /src/config -- Logo -- Tool Selectors -- Utility Selectors +Global (common) configurations shared between all apps under platform-ui +Import with `~/config`; -### /src-ts/lib +### /src-ts/libs Shared code that should be stable and should not be modified unless expressly intending to modify the *entire* Platform UI. -When using items in this directory, there are only 3 permissable locations -from which to import: - -.ts or tsx: -- /src-ts/lib - -.tsx -- /src-ts/lib/styles/index.scss - -.scss -- /src-ts/lib/styles/includes -- /src-ts/lib/styles/typography -- /src-ts/lib/styles/variables +As obvious as it may sound, but within the libraries themselves, we should **not**, import anything from the apps fodlers. +The libraries should be standalone, at most they should rely on other libraries (eg. libs/core will import from libs/ui pages related to Auth). See the [Styling](#styling) section for more details about stylesheets -### /src-ts/tools - -The majority of development should happen in subdirectories of the tools directory. +>**NOTE:** Apps should not import modules from anywhere other than libs. If it is necessary to import from outside the libs, the shared code should generally be moved to a lib under libs. -The Tool Selectors on the site [Header](#src-tsheader) correlate 1:1 to directories within the tools directory. +## Adding a new Platform UI application -The name of a tool's directory should correlate w/the name of the tool and its url. +All of the routes for a given app (including root, section, and subsection routes) should be +defined in a top-level file in it's own app folder. ``` -i.e. /src-ts/tools/[tool-name] == platform-ui.topcoder.com/[tool-name] -e.g. /src-ts/tools/work == platform-ui.topcoder.com/work -``` - ->**NOTE:** Tools should not import modules from any directories other than lib. If it is necessary to import from outside the lib, the shared code should generally be moved to lib. - -### /src-ts/utils - -This directory includes shared utilities that are not specific to a tool (e.g. Profile Settings.) - -The Utility Selectors in the site [Header](#src-tsheader) correlate 1:1 to directories within the utils directory. - -The name of a util's directory should correlate w/the name of the util and its url. +i.e. [appName]Routes in /src/apps/[app-name]/src/[app-name].routes.ts +e.g. learnRoutes in /src/apps/learn/src/learn.routes.tsx +e.g. selfServiceRoutes in src/apps/self-service/src/self-service.routes.tsx ``` -i.e. /src-ts/utils/[util-name] == platform-ui.topcoder.com/[util-name] -e.g. /src-ts/utils/profile == platform-ui.topcoder.com/profile -``` - ->**NOTE:** Utils should not import modules from any directories other than lib. If it is necessary to import from outside the lib, the shared code should generally be moved to lib. - -## Adding a Tool or Util -All of the routes for a given tool or util (including root, section, and subsection routes) should be -defined in a top-level file in the tool/util folder. +These routes then need to be imported in the Plaform App's [platformRoutes](./src/apps/platform/src/platform.routes.tsx): ``` -i.e. [toolName]Routes in /src-ts/tools/[tool-name]/[tool-name].routes.ts -i.e. [utilName]Routes in src-ts/utils/[util-name]/[util-name].routes.ts +import { learnRoutes } from '~/apps/learn' +import { selfServiceRoutes } from '~/apps/self-service' -e.g. workRoutes in /src-ts/tools/work/work.routes.tsx -e.g. settingsRoutes in src-ts/tools/settings/settings.routes.tsx -``` - -These routes then need to be added to the routes file for the parent tools/utils: +export const appRoutes: Array = [ + ...selfServiceRoutes, + ...learnRoutes, +] ``` -/src-ts/tools/tools.routes.ts -/src-ts/utils/utils.routes.ts -``` - -Simply adding the routes to one of thes files above will register the tool/util -with the application and will display the new component. ### Lazy loading and code splitting -When loading a route component, please use the `lazyLoad()` method defined in the application lib. +When loading a route component, please use the `lazyLoad()` method defined in `~/libs/core`. -| param | description| -| ----- | ---------- | -| `moduleImport: () => Promise` | Function which imports the desired module | -| `namedExport?: string` | The name of the exported module (if the module has named exports) | +| param | description | +| ---------------------------------- | --------------------------------------------------------------------- | +| `moduleImport: () => Promise` | Function which imports the desired module | +| `namedExport?: string` | The name of the exported module (if the module has named exports) | Eg: ``` @@ -355,18 +304,15 @@ export const learnRoutes: Array = [ The PlatformRoute model has several useful options: -| property | description | -| ---------------- | ---------------------------------------- | -| `children: Array` | The children property defines subsections that will inherit the url path from the parent. | -| `element: JSX.Element` | The element property is the JSX element that should appear at the specified URL. | -| `disabled?: boolean` | When a route is marked as disabled, it will not be registered and will the URL will return a 404. | -| `hide?: boolean` | When a route is hidden, it will be registered and the URL will be available through deep-linking but will not be visible in either the Tools or Utils Selectors. This is useful for handling redirects for obsolete routes. | -| `authRequired?: boolean` | Requiring authentication for a route means that users who are not logged in will be redirected to the Login Form when they try to access the route. | -| `route: string` | The route property is the path to the route, relative to its parent(s). | -| `title: string` | The title property is the text that will appear in the Tools or Utils Selectors (this is irrelevant on hidden routes). | -| `rolesRequired: Array` | Requiring roles for a route means that users who do not own the roles will be presented with restricted page when they try to access the route. | - - +| property | description | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `children: Array` | The children property defines subsections that will inherit the url path from the parent. | +| `element: JSX.Element` | The element property is the JSX element that should appear at the specified URL. | +| `disabled?: boolean` | When a route is marked as disabled, it will not be registered and will the URL will return a 404. | +| `authRequired?: boolean` | Requiring authentication for a route means that users who are not logged in will be redirected to the Login Form when they try to access the route. | +| `route: string` | The route property is the path to the route, relative to its parent(s). | +| `title: string` | The title property is the used for routes identification | +| `rolesRequired: Array` | Requiring roles for a route means that users who do not own the roles will be presented with restricted page when they try to access the route. | @@ -379,9 +325,8 @@ The PlatformRoute model has several useful options: ### Rules -Javascript rules: [src/.eslintrc.js](src/.eslintrc.js) - -Typescript rules: [src-ts/.eslintrc.js](src-ts/.eslintrc.js) +Typescript rules: [src/.eslintrc.js](src/.eslintrc.js) +Javascript rules are set as "overrides" under the same linting rules: [src/.eslintrc.js](src/.eslintrc.js) ### Command Line @@ -460,27 +405,29 @@ e.g. styles.scss } ``` -Shared stylesheets are located in `src-ts/lib/styles/`. We use variables and mixins for handling padding, colors and breakpoints in the application, among others. To reference these in your SCSS files, simply add the following line at the top of your file. +Shared stylesheets are located in `src/libs/ui/lib/styles/`. +We use variables and mixins for handling padding, colors and breakpoints in the application, among others. To reference these in your SCSS files, simply add the needed lines at the top of your file. ``` -@import '[path to]/lib/styles/includes'; -@import '[path to]/lib/styles/typography'; -@import '[path to]/lib/styles/variables'; +@import '@libs/ui/styles/includes'; +@import '@libs/ui/styles/typography'; +@import '@libs/ui/styles/variables'; ``` ### Colors & Gradients -Colors and Gradients are defined as variables in `src-ts/lib/styles/_palette.scss`. +Colors and Gradients are defined as variables in `src/libs/ui/lib/styles/_palette.scss`. >**WARNING:** Do not use any colors that are not already defined in the palette. If a mockup you are working from has a different color, find the color in the palette that is closest. ### Padding -Padding for various screen sizes are defined as variables in `src-ts/lib/styles/_layout.scss`. This file also contains a mixin called `pagePaddings` that determines the correct padding to use for the current screen size based on breakpoints. +Padding for various screen sizes are defined as variables in `src/libs/ui/lib/styles/_layout.scss`. +This file also contains a mixin called `pagePaddings` that determines the correct padding to use for the current screen size based on breakpoints. ### Breakpoints -Breakpoint mixins are defined in `src-ts/lib/styles/_breakpoints.scss` and can be used to apply different styling based on the screen width. +Breakpoint mixins are defined in `src/libs/ui/lib/styles/_breakpoints.scss` and can be used to apply different styling based on the screen width. Here is an example that applies a different height value than the default to a css class selector if the screen is considered small (376px - 464px). @@ -531,7 +478,7 @@ For specifying mobile CSS, you can use @include ltemd: ### Heroicons We use the SVG icons library [Heroicons](https://heroicons.com/), where each icon is available in an `outline` or `solid` version. -We import both sets of icons in the file `src-ts/lib/svgs/index.ts`. +We import both sets of icons in the file `src/libs/ui/lib/components/svgs/index.ts`. ``` import * as IconOutline from '@heroicons/react/outline' import * as IconSolid from '@heroicons/react/solid' @@ -541,27 +488,31 @@ Then, to use an icon from either of these sets, you would import the correspondi e.g.: ``` +import { IconOutline } from '~/libs/ui' + +... + ``` ### Custom SVGs -Custom SVGs can also be imported and used directly as a React component. Save your SVG in its own index (i.e. "barrel" file within your tool (e.g. /src-ts/tools/my-tool/my-tool/lib/svgs), and then import the SVG into the barrel file as a component: +Custom SVGs can also be imported and used directly as a React component. Save your SVG in its own index (i.e. "barrel" file within your app (e.g. /src/apps/my-app/src/lib/svgs), and then import the SVG into the barrel file as a component: ``` import { ReactComponent as CustomSVG } from './customSvg.svg' ``` -The export the svg from the barrel file to be used w/in your tool: +The export the svg from the barrel file to be used w/in your app: ``` export { CustomSVG } ``` -See the /src-ts/lib/svgs for an example. +See the /src/libs/ui/lib/components/svgs for an example. ->**NOTE:** Custom SVGs should be saved w/in a given tool. Only global SVGs should be in the main /lib/svgs directory. +>**NOTE:** Custom SVGs should be saved w/in a given app. Only global SVGs should be in the main /src/libs/ui/lib/components/svgs directory. ### Styling Icons You can style an SVG icon by overwritting its properties through CSS (height, width, fill, etc.). -There are also existing mixins located in `src-ts/lib/styles/_icons.scss` with pre-defined widths and heights for various icon sizes. +There are also existing mixins located in `src/libs/ui/lib/styles/_icons.scss` with pre-defined widths and heights for various icon sizes. e.g.: ``` @@ -593,39 +544,56 @@ e.g.: -# Tools +# Applications hosted under Platform UI -The following summarizes the various [tools](#adding-a-tool-or-util) in the Platform UI. +The following summarizes the various [apps](#adding-a-new-platform-ui-application) in the Platform UI. +- [Platform App](#platform-app) - [Dev Center](#dev-center) +- [Earn](#earn) - [Gamification Admin](#gamification-admin) - [Learn](#learn) -- [Work](#work) +- [Self Service](#self-service) + +## Platform App + +This is the "router" app under the whole sum of all Platform UI applications. It will just load all applications and serve one based on the specific route +It also renders the [Universal Navigation](https://github.com/topcoder-platform/universal-navigation)'s header & footer. + +[Platform README](./src/apps/platform/README.md) +[Platform Routes](./src/apps/platform/src/platform.routes.tsx) ## Dev Center A community-led project to document how to work with Topcoder internal applications. -[Dev Center README](./src-ts/tools/dev-center/README.md) -[Dev Center Routes](./src-ts/tools/dev-center/dev-center.routes.tsx) +[Dev Center README](./src/apps/dev-center/README.md) +[Dev Center Routes](./src/apps/dev-center/src/dev-center.routes.tsx) + +## Earn + +The application that displays the list of challenges & gigs: opportunity feed + +[Earn README](./src/apps/earn/README.md) +[Earn Routes](./src/apps/earn/src/earn.routes.tsx) ## Gamification Admin Application that allows administrators to CRUD badges and de/assign them to specific users. -[Gamification Admin README TBD](./src-ts/tools/gamification-admin/README.md) -[Gamification Admin Routes](./src-ts/tools/gamification-admin/gamification-admin.routes.tsx) +[Gamification Admin README TBD](./src/apps/gamification-admin/README.md) +[Gamification Admin Routes](./src/apps/gamification-admin/src/gamification-admin.routes.tsx) ## Learn Application that serves 3rd-party educational content. -[Learn README](./src-ts/tools/learn/README.md) -[Learn Routes](./src-ts/tools/learn/learn.routes.tsx) +[Learn README](./src/apps/learn/README.md) +[Learn Routes](./src/apps/learn/src/learn.routes.tsx) -## Work +## Self Service Application that allows customers to submit/start challenges self-service. -[Work README TBD](./src-ts/tools/work/README.md) -[Work Routes](./src-ts/tools/work/work.routes.tsx) +[Work README TBD](./src/apps/self-service/README.md) +[Work Routes](./src/apps/self-service/src/self-service.routes.tsx) diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 457f10d59..000000000 --- a/babel.config.js +++ /dev/null @@ -1,71 +0,0 @@ -module.exports = function (api) { - const isProd = process.env.APPMODE === "production"; - api.cache(!isProd); - - const generateScopedName = isProd - ? "[hash:base64:6]" - : "self_service_[path][name]___[local]___[hash:base64:6]"; - return { - presets: ["@babel/preset-env", "@babel/preset-react"], - plugins: [ - [ - "@babel/plugin-transform-runtime", - { - useESModules: true, - regenerator: false, - }, - ], - [ - "react-css-modules", - { - filetypes: { - ".scss": { - syntax: "postcss-scss", - }, - }, - generateScopedName, - }, - ], - [ - "inline-react-svg", - { - "svgo": { - "plugins": [ - { - "cleanupIDs": false - } - ] - } - } - ], - ], - env: { - test: { - presets: [ - [ - "@babel/preset-env", - { - targets: "current node", - }, - ], - ], - plugins: [ - "istanbul", - [ - "module-resolver", - { - alias: { - styles: "./src/styles", - components: "./src/components", - hooks: "./src/hooks", - utils: "./src/utils", - constants: "./src/constants", - services: "./src/services", - }, - }, - ], - ], - }, - }, - }; -}; diff --git a/config-overrides.js b/config-overrides.js deleted file mode 100644 index 75f62850f..000000000 --- a/config-overrides.js +++ /dev/null @@ -1,3 +0,0 @@ -const { removeModuleScopePlugin } = require('customize-cra') - -module.exports = removeModuleScopePlugin() diff --git a/config/dev.js b/config/dev.js deleted file mode 100644 index c0a6063ed..000000000 --- a/config/dev.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = { - TC_DOMAIN: 'topcoder-dev.com', - /** - * URL of Topcoder Community Website - */ - TOPCODER_COMMUNITY_WEBSITE_URL: "https://topcoder-dev.com", - TERMS_URL: - "https://www.topcoder-dev.com/challenges/terms/detail/317cd8f9-d66c-4f2a-8774-63c612d99cd4", - PRIVACY_POLICY_URL: "https://www.topcoder-dev.com/policy", - SIGN_IN_URL: `https://accounts-auth0.topcoder-dev.com/?retUrl=https%3A%2F%2Fwork.topcoder-dev.com%2Fself-service%2Fwizard®Source=selfService`, - SIGN_UP_URL: `https://accounts-auth0.topcoder-dev.com/?retUrl=https%3A%2F%2Fwork.topcoder-dev.com%2Fself-service%2Fwizard®Source=selfService&mode=signUp`, - /** - * URL of Topcoder Connect Website - */ - CONNECT_WEBSITE_URL: "https://connect.topcoder-dev.com", - VANILLA_EMBED_JS: "https://vanilla.topcoder-dev.com/js/embed.js", - VANILLA_EMBED_TYPE: "mfe", - VANILLA_FORUM_API: "https://vanilla.topcoder-dev.com/api/v2", - VANILLA_ACCESS_TOKEN: "va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV", - - API: { - V5: "https://api.topcoder-dev.com/v5", - V3: "https://api.topcoder-dev.com/v3", - }, - - STRIPE: { - API_KEY: "pk_test_rfcS49MHRVUKomQ9JgSH7Xqz", - API_VERSION: "2020-08-27", - CUSTOMER_TOKEN: - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg", - ADMIN_TOKEN: - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - }, - /** - * Expire time period of auto saved intake form: 24 hours - */ - AUTO_SAVED_COOKIE_EXPIRED_IN: 24 * 60, - TIME_ZONE: "Europe/London", -}; diff --git a/config/index.js b/config/index.js deleted file mode 100644 index 85a7013e3..000000000 --- a/config/index.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global process */ - -module.exports = (() => { - const env = process.env.REACT_APP_HOST_ENV || "dev"; - - console.info(`REACT_APP_HOST_ENV: "${process.env.REACT_APP_HOST_ENV}"`); - console.info(`env: "${env}"`); - - // for security reason don't let to require any arbitrary file defined in process.env - if (["prod", "dev"].indexOf(env) < 0) { - return require("./dev"); - } - - return require("./" + env); -})(); diff --git a/config/local.js b/config/local.js deleted file mode 100644 index 7af0bba50..000000000 --- a/config/local.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - COMMUNITY_ADMIN_URL: "", -}; diff --git a/config/prod.js b/config/prod.js deleted file mode 100644 index f49e3aea1..000000000 --- a/config/prod.js +++ /dev/null @@ -1,40 +0,0 @@ -module.exports = { - TC_DOMAIN: 'topcoder.com', - /** - * URL of Topcoder Community Website - */ - TOPCODER_COMMUNITY_WEBSITE_URL: "https://topcoder.com", - TERMS_URL: - "https://www.topcoder.com/challenges/terms/detail/564a981e-6840-4a5c-894e-d5ad22e9cd6f", - PRIVACY_POLICY_URL: "https://www.topcoder.com/policy", - SIGN_IN_URL: `https://accounts-auth0.topcoder.com/?retUrl=https%3A%2F%2Fwork.topcoder.com%2Fself-service%2Fwizard®Source=selfService`, - SIGN_UP_URL: `https://accounts-auth0.topcoder.com/?retUrl=https%3A%2F%2Fwork.topcoder.com%2Fself-service%2Fwizard®Source=selfService&mode=signUp`, - - /** - * URL of Topcoder Connect Website - */ - CONNECT_WEBSITE_URL: "https://connect.topcoder.com", - VANILLA_EMBED_JS: "https://discussions.topcoder.com/js/embed.js", - VANILLA_EMBED_TYPE: "standard", - VANILLA_FORUM_API: "https://vanilla.topcoder.com/api/v2", - VANILLA_ACCESS_TOKEN: "va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV", - - API: { - V5: "https://api.topcoder.com/v5", - V3: "https://api.topcoder.com/v3", - }, - - STRIPE: { - API_KEY: "pk_live_m3bCBVSfkfMOEp3unZFRsHXi", - API_VERSION: "2020-08-27", - CUSTOMER_TOKEN: - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg", - ADMIN_TOKEN: - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - }, - /** - * Expire time period of auto saved intake form: 24 hours - */ - AUTO_SAVED_COOKIE_EXPIRED_IN: 24 * 60, - TIME_ZONE: "Europe/London", -}; diff --git a/craco.config.js b/craco.config.js new file mode 100644 index 000000000..6427a1b55 --- /dev/null +++ b/craco.config.js @@ -0,0 +1,43 @@ +const path = require('path'); +const CracoCSSModules = require('craco-css-modules'); +const CracoEnvPlugin = require('craco-plugin-env') +const BabelRcPlugin = require('@jackwilsdon/craco-use-babelrc'); + +const isProd = process.env.APPMODE === "production"; + +const localIdentName = isProd + ? "[hash:base64:6]" + : "[name]_[local]__[hash:base64:6]"; + +const resolve = dir => path.resolve(__dirname, dir); + +module.exports = { + style: { + modules: { + localIdentName, + }, + }, + + plugins: [ + { plugin: BabelRcPlugin }, + { plugin: CracoCSSModules }, + { plugin: CracoEnvPlugin, options: { + envDir: './.environments', + } }, + ], + + webpack: { + alias: { + // aliases used in JS/TS + '~': resolve('src'), + '@earn': resolve('src/apps/earn/src'), + '@learn': resolve('src/apps/learn/src'), + '@devCenter': resolve('src/apps/dev-center/src'), + '@gamificationAdmin': resolve('src/apps/gamification-admin/src'), + + '@platform': resolve('src/apps/platform/src'), + // aliases used in SCSS files + '@libs/ui/styles': resolve('src/libs/ui/lib/styles'), + }, + } +} diff --git a/package.json b/package.json index bdae33cd8..2e78a5de4 100644 --- a/package.json +++ b/package.json @@ -1,173 +1,242 @@ { - "name": "@topcoder-platform/platform-ui", - "version": "3.0.0", - "private": true, - "scripts": { - "dev": "yarn react-app-rewired start", - "start": "sh start-ssl.sh", - "start:brooke": "sudo sh start-ssl-brooke.sh", - "build": "yarn react-app-rewired build", - "lint:ts": "eslint -c ./src-ts/.eslintrc.js 'src-ts/**/*.{ts,tsx}'", - "lint:ts:fix": "eslint -c ./src-ts/.eslintrc.js 'src-ts/**/*.{ts,tsx}' --fix", - "lint:js": "eslint -c ./src/.eslintrc.js 'src/**/*.{js,jsx}'", - "lint:js:fix": "eslint -c ./src/.eslintrc.js 'src/**/*.{js,jsx}' --fix", - "lint": "npm run lint:js && npm run lint:ts", - "lint:fix": "npm run lint:js:fix && npm run lint:ts:fix", - "test": "react-scripts test --watchAll", - "test:no-watch": "react-scripts test --watchAll=false --passWithNoTests", - "cy:run": "cypress run --reporter junit", - "cy:ci": "start-server-and-test 'serve -s build -n -p 3000' http://localhost:3000 'cy:run'", - "report:coverage": "nyc report --reporter=html", - "report:coverage:text": "nyc report --reporter=text" - }, - "dependencies": { - "@datadog/browser-logs": "^4.21.2", - "@heroicons/react": "^1.0.6", - "@sprig-technologies/sprig-browser": "^2.20.1", - "@stripe/react-stripe-js": "1.13.0", - "@stripe/stripe-js": "1.41.0", - "@types/testing-library__jest-dom": "^5.14.5", - "apexcharts": "^3.36.0", - "axios": "^1.1.2", - "browser-cookies": "^1.2.0", - "classnames": "^2.3.2", - "contentful": "^9.2.5", - "crypto-js": "^4.1.1", - "customize-cra": "^1.0.0", - "dompurify": "^2.4.0", - "highlight.js": "^11.6.0", - "html2canvas": "^1.4.1", - "lodash": "^4.17.21", - "markdown-it": "^13.0.1", - "marked": "4.1.1", - "moment": "^2.29.4", - "moment-timezone": "^0.5.37", - "prop-types": "^15.8.1", - "qrcode.react": "^3.1.0", - "qs": "^6.11.0", - "rc-checkbox": "^2.3.2", - "react": "^18.2.0", - "react-apexcharts": "^1.4.0", - "react-app-rewired": "^2.2.1", - "react-contenteditable": "^3.3.6", - "react-dom": "^18.2.0", - "react-elastic-carousel": "^0.11.5", - "react-gtm-module": "^2.0.11", - "react-helmet": "^6.1.0", - "react-redux": "^8.0.4", - "react-redux-toastr": "^7.6.10", - "react-responsive-modal": "^6.2.0", - "react-router-dom": "^6.4.2", - "react-scripts": "5.0.1", - "react-select": "^5.5.0", - "react-spinners": "^0.13.6", - "react-toastify": "^9.0.8", - "react-tooltip": "^4.4.0", - "redux": "^4.2.0", - "redux-logger": "^3.0.6", - "redux-promise-middleware": "^6.1.3", - "redux-thunk": "^2.4.1", - "sanitize-html": "^2.7.2", - "sass": "^1.55.0", - "styled-components": "^5.3.6", - "swr": "^1.3.0", - "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.26", - "typescript": "^4.8.4", - "universal-navigation": "https://github.com/topcoder-platform/universal-navigation", - "uuid": "^9.0.0" - }, - "devDependencies": { - "@babel/core": "^7.19.3", - "@babel/plugin-syntax-jsx": "^7.18.6", - "@babel/plugin-transform-runtime": "^7.19.1", - "@babel/preset-env": "^7.19.4", - "@babel/preset-react": "^7.18.6", - "@babel/preset-typescript": "^7.18.6", - "@babel/runtime": "^7.19.4", - "@cypress/code-coverage": "^3.10.0", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^14.4.3", - "@types/axios": "^0.14.0", - "@types/cypress": "^1.1.3", - "@types/dompurify": "^2.3.4", - "@types/highlightjs": "^9.12.2", - "@types/jest": "^29.1.2", - "@types/lodash": "^4.14.186", - "@types/markdown-it": "^12.2.3", - "@types/marked": "4.0.7", - "@types/node": "^18.8.5", - "@types/reach__router": "^1.3.11", - "@types/react": "^18.0.21", - "@types/react-dom": "^18.0.6", - "@types/react-gtm-module": "^2.0.1", - "@types/react-helmet": "^6.1.6", - "@types/react-redux-toastr": "^7.6.2", - "@types/react-router-dom": "^5.3.3", - "@types/sanitize-html": "^2.6.2", - "@types/segment-analytics": "^0.0.34", - "@types/systemjs": "^6.1.1", - "@types/uuid": "^8.3.4", - "@typescript-eslint/eslint-plugin": "^5.30.6", - "@typescript-eslint/parser": "^5.30.6", - "@wdio/junit-reporter": "^7.25.1", - "autoprefixer": "^10.4.12", - "babel-eslint": "^11.0.0-beta.2", - "babel-jest": "^29.2.0", - "babel-plugin-inline-react-svg": "^2.0.1", - "babel-plugin-module-resolver": "^4.1.0", - "babel-plugin-react-css-modules": "^5.2.6", - "concurrently": "^7.4.0", - "config": "^3.3.8", - "cross-env": "^7.0.3", - "cypress": "^10.10.0", - "eslint": "^8.25.0", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-react-app": "^7.0.1", - "eslint-config-react-important-stuff": "^3.0.0", - "eslint-import-resolver-typescript": "^3.2.5", - "eslint-plugin-cypress": "^2.12.1", - "eslint-plugin-import": "^2.25.3", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-ordered-imports": "^0.6.0", - "eslint-plugin-react": "^7.28.0", - "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-unicorn": "^46.0.0", - "file-loader": "^6.2.0", - "husky": "^8.0.1", - "identity-obj-proxy": "^3.0.0", - "istanbul-lib-coverage": "^3.2.0", - "jest": "^29.2.0", - "jest-cli": "^29.2.0", - "lint-staged": "^13.0.3", - "nyc": "^15.1.0", - "postcss-loader": "^4.0.4", - "postcss-scss": "^3.0.2", - "pretty-quick": "^3.1.3", - "resolve-url-loader": "^5.0.0", - "sass-loader": "^13.1.0", - "serve": "^14.0.1", - "start-server-and-test": "^1.14.0", - "style-loader": "^3.3.1", - "systemjs-webpack-interop": "^2.3.7", - "typed-scss-modules": "^7.0.1", - "webpack": "^4.41.2", - "webpack-cli": "^4.10.0", - "webpack-config-single-spa-react": "^4.0.3", - "webpack-dev-server": "^4.11.1", - "webpack-merge": "^5.8.0" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" + "name": "@topcoder-platform/platform-ui", + "version": "3.0.0", + "private": true, + "license": "ISC", + "scripts": { + "dev": "craco start --mode ${REACT_APP_HOST_ENV:-dev}", + "start": "bash start.sh", + "build": "export CI=false && craco build --mode ${REACT_APP_HOST_ENV:-prod}", + "build:dev": "craco build --mode ${REACT_APP_HOST_ENV:-dev}", + "demo": "npx http-server --port 443 -a 0.0.0.0 -S -C ./ssl/rootCA.crt -K ./ssl/rootCA.key -P https://local.topcoder-dev.com? --proxy-options.secure false ./build", + "lint": "eslint -c ./src/.eslintrc.js 'src/**/*.{ts,tsx}'", + "lint:fix": "eslint -c ./src/.eslintrc.js 'src/**/*.{ts,tsx}' --fix", + "test": "craco test --watchAll", + "test:no-watch": "craco test --watchAll=false --passWithNoTests", + "cy:run": "cypress run --reporter junit", + "cy:ci": "start-server-and-test 'serve -s build -n -p 3000' http://localhost:3000 'cy:run'", + "report:coverage": "nyc report --reporter=html", + "report:coverage:text": "nyc report --reporter=text", + "sb": "storybook dev -p 6006", + "sb:build": "storybook build -o build/storybook" + }, + "dependencies": { + "@datadog/browser-logs": "^4.21.2", + "@heroicons/react": "^1.0.6", + "@sprig-technologies/sprig-browser": "^2.20.1", + "@storybook/addon-actions": "^7.0.5", + "@storybook/react": "^7.0.5", + "@stripe/react-stripe-js": "1.13.0", + "@stripe/stripe-js": "1.41.0", + "@types/testing-library__jest-dom": "^5.14.5", + "apexcharts": "^3.36.0", + "axios": "^1.1.2", + "browser-cookies": "^1.2.0", + "classnames": "^2.3.2", + "contentful": "^9.2.5", + "country-calling-code": "0.0.3", + "crypto-js": "^4.1.1", + "customize-cra": "^1.0.0", + "dompurify": "^2.4.0", + "draft-js": "^0.10.4", + "draft-js-export-html": "^1.2.0", + "draft-js-markdown-shortcuts-plugin": "^0.3.0", + "draft-js-plugins-editor": "^2.0.3", + "express": "^4.18.2", + "express-fileupload": "^1.4.0", + "express-interceptor": "^1.2.0", + "fflate": "^0.7.4", + "filestack-react": "^2.0.0", + "focus-trap-react": "^6.0.0", + "get-parameter-names": "^0.3.0", + "highcharts": "^10.3.3", + "highcharts-react-official": "^3.2.0", + "highlight.js": "^11.6.0", + "html2canvas": "^1.4.1", + "isomorphic-fetch": "^2.2.1", + "joi": "^17.4.0", + "katex": "^0.16.4", + "lodash": "^4.17.21", + "markdown-it": "^13.0.1", + "marked": "4.1.1", + "moment": "^2.29.4", + "moment-duration-format": "^2.3.2", + "moment-timezone": "^0.5.37", + "money": "^0.2.0", + "prop-types": "^15.8.1", + "qrcode.react": "^3.1.0", + "qs": "^6.11.0", + "rc-checkbox": "^2.3.2", + "react": "^18.2.0", + "react-apexcharts": "^1.4.0", + "react-color": "^2.13.8", + "react-contenteditable": "^3.3.6", + "react-css-super-themr": "^2.2.0", + "react-date-range": "^1.1.3", + "react-dom": "^18.2.0", + "react-dropzone": "^11.3.2", + "react-elastic-carousel": "^0.11.5", + "react-gtm-module": "^2.0.11", + "react-helmet": "^6.1.0", + "react-html-parser": "^2.0.2", + "react-markdown": "8.0.6", + "react-redux": "^8.0.4", + "react-redux-toastr": "^7.6.10", + "react-responsive": "^9.0.0-beta.5", + "react-responsive-modal": "^6.2.0", + "react-router-dom": "^6.4.2", + "react-scripts": "5.0.1", + "react-select": "^5.5.0", + "react-spinners": "^0.13.6", + "react-stickynode": "^1.4.1", + "react-toastify": "^9.0.8", + "react-tooltip": "5.11.1", + "redux": "^4.2.0", + "redux-actions": "^2.6.5", + "redux-logger": "^3.0.6", + "redux-promise": "^0.6.0", + "redux-promise-middleware": "^6.1.3", + "redux-thunk": "^2.4.1", + "rehype-katex": "^6.0.2", + "rehype-raw": "^6.1.1", + "rehype-stringify": "^9.0.3", + "remark-breaks": "^3.0.2", + "remark-frontmatter": "^4.0.1", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "remove": "^0.1.5", + "sanitize-html": "^2.7.2", + "sass": "^1.55.0", + "shortid": "^2.2.16", + "styled-components": "^5.3.6", + "swr": "^1.3.0", + "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.26", + "turndown": "^4.0.2", + "typescript": "^4.8.4", + "universal-navigation": "https://github.com/topcoder-platform/universal-navigation", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@babel/core": "^7.19.3", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/plugin-transform-runtime": "^7.19.1", + "@babel/preset-env": "^7.19.4", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.18.6", + "@babel/runtime": "^7.19.4", + "@craco/craco": "^7.1.0", + "@cypress/code-coverage": "^3.10.0", + "@jackwilsdon/craco-use-babelrc": "1.0.0", + "@storybook/addon-essentials": "^7.0.5", + "@storybook/addon-interactions": "^7.0.5", + "@storybook/addon-links": "^7.0.5", + "@storybook/blocks": "^7.0.5", + "@storybook/preset-create-react-app": "^7.0.5", + "@storybook/react-webpack5": "^7.0.5", + "@storybook/testing-library": "^0.0.14-next.2", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", + "@types/axios": "^0.14.0", + "@types/cypress": "^1.1.3", + "@types/dompurify": "^2.3.4", + "@types/highlightjs": "^9.12.2", + "@types/jest": "^29.1.2", + "@types/lodash": "^4.14.186", + "@types/markdown-it": "^12.2.3", + "@types/marked": "4.0.7", + "@types/node": "^18.8.5", + "@types/reach__router": "^1.3.11", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "@types/react-gtm-module": "^2.0.1", + "@types/react-helmet": "^6.1.6", + "@types/react-redux-toastr": "^7.6.2", + "@types/react-router-dom": "^5.3.3", + "@types/redux-actions": "2.6.2", + "@types/sanitize-html": "^2.6.2", + "@types/segment-analytics": "^0.0.34", + "@types/systemjs": "^6.1.1", + "@types/uuid": "^8.3.4", + "@typescript-eslint/eslint-plugin": "^5.30.6", + "@typescript-eslint/parser": "^5.30.6", + "@wdio/junit-reporter": "^7.25.1", + "autoprefixer": "^10.4.12", + "babel-eslint": "^11.0.0-beta.2", + "babel-jest": "^29.2.0", + "babel-plugin-inline-react-svg": "^2.0.1", + "babel-plugin-module-resolver": "^4.1.0", + "babel-plugin-named-exports-order": "^0.0.2", + "babel-plugin-react-css-modules": "^5.2.6", + "concurrently": "^7.4.0", + "craco-css-modules": "^1.0.5", + "craco-plugin-env": "^1.0.5", + "craco-resolve-url-loader": "^1.0.0", + "cross-env": "^7.0.3", + "css-loader": "3.5.3", + "cypress": "^10.10.0", + "eslint": "^8.25.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-react-app": "^7.0.1", + "eslint-config-react-important-stuff": "^3.0.0", + "eslint-import-resolver-typescript": "^3.2.5", + "eslint-plugin-cypress": "^2.12.1", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-ordered-imports": "^0.6.0", + "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-unicorn": "^46.0.0", + "file-loader": "^6.2.0", + "husky": "^8.0.1", + "identity-obj-proxy": "^3.0.0", + "istanbul-lib-coverage": "^3.2.0", + "jest": "^29.2.0", + "jest-cli": "^29.2.0", + "lint-staged": "^13.0.3", + "nyc": "^15.1.0", + "postcss-loader": "^4.0.4", + "postcss-scss": "^3.0.2", + "pretty-quick": "^3.1.3", + "react-docgen-typescript": "^2.2.2", + "react-hot-loader": "^4.3.3", + "resolve-url-loader": "^5.0.0", + "sass-loader": "^13.1.0", + "serve": "^14.0.1", + "start-server-and-test": "^1.14.0", + "storybook": "^7.0.5", + "style-loader": "^3.3.1", + "systemjs-webpack-interop": "^2.3.7", + "tsconfig-paths-webpack-plugin": "^4.0.1", + "typed-scss-modules": "^7.0.1", + "webpack": "^5.79.0", + "webpack-cli": "^4.10.0", + "webpack-config-single-spa-react": "^4.0.3", + "webpack-dev-server": "^4.11.1", + "webpack-merge": "^5.8.0" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "types": "./types/index.d.ts", + "eslintConfig": { + "overrides": [ + { + "files": [ + "**/*.stories.*" ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "types": "./types/index.d.ts" + "rules": { + "import/no-anonymous-default-export": "off" + } + } + ] + } } diff --git a/public/index.html b/public/index.html index 374c41cfc..46591736c 100644 --- a/public/index.html +++ b/public/index.html @@ -11,9 +11,9 @@ + href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Barlow+Condensed:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Roboto+Mono:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" /> + href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Barlow+Condensed:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Roboto+Mono:ital,wght@0,100;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" /> + + + + diff --git a/src/apps/earn/src/assets/icons/IconMinimalLeft.svg b/src/apps/earn/src/assets/icons/IconMinimalLeft.svg new file mode 100644 index 000000000..287bf690e --- /dev/null +++ b/src/apps/earn/src/assets/icons/IconMinimalLeft.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/apps/earn/src/assets/icons/IconMinimalRight.svg b/src/apps/earn/src/assets/icons/IconMinimalRight.svg new file mode 100644 index 000000000..d2905c94b --- /dev/null +++ b/src/apps/earn/src/assets/icons/IconMinimalRight.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/apps/earn/src/assets/icons/IconMinimalUp.svg b/src/apps/earn/src/assets/icons/IconMinimalUp.svg new file mode 100644 index 000000000..9bf3e2d5a --- /dev/null +++ b/src/apps/earn/src/assets/icons/IconMinimalUp.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/apps/earn/src/assets/icons/IconShare.svg b/src/apps/earn/src/assets/icons/IconShare.svg new file mode 100644 index 000000000..2c731ab66 --- /dev/null +++ b/src/apps/earn/src/assets/icons/IconShare.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/src/apps/earn/src/assets/icons/IconSquareDownload.svg b/src/apps/earn/src/assets/icons/IconSquareDownload.svg new file mode 100644 index 000000000..9ef5ceed7 --- /dev/null +++ b/src/apps/earn/src/assets/icons/IconSquareDownload.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/IconTrashSimple.svg b/src/apps/earn/src/assets/icons/IconTrashSimple.svg new file mode 100644 index 000000000..2b1e18cf4 --- /dev/null +++ b/src/apps/earn/src/assets/icons/IconTrashSimple.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/apps/earn/src/assets/icons/arrow-down.svg b/src/apps/earn/src/assets/icons/arrow-down.svg new file mode 100644 index 000000000..bcfb1072d --- /dev/null +++ b/src/apps/earn/src/assets/icons/arrow-down.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/arrow-prev.svg b/src/apps/earn/src/assets/icons/arrow-prev.svg new file mode 100644 index 000000000..0cfb59fda --- /dev/null +++ b/src/apps/earn/src/assets/icons/arrow-prev.svg @@ -0,0 +1,9 @@ + + + + diff --git a/src/apps/earn/src/assets/icons/arrow-right.svg b/src/apps/earn/src/assets/icons/arrow-right.svg new file mode 100644 index 000000000..a75d8d921 --- /dev/null +++ b/src/apps/earn/src/assets/icons/arrow-right.svg @@ -0,0 +1 @@ + diff --git a/src/apps/earn/src/assets/icons/arrow.svg b/src/apps/earn/src/assets/icons/arrow.svg new file mode 100644 index 000000000..5a6667ff4 --- /dev/null +++ b/src/apps/earn/src/assets/icons/arrow.svg @@ -0,0 +1,13 @@ + + + 2F48C66D-6E88-40BD-8F0F-1ABB211BC6D0 + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/banner-chevron-up.svg b/src/apps/earn/src/assets/icons/banner-chevron-up.svg new file mode 100644 index 000000000..96c5c3d89 --- /dev/null +++ b/src/apps/earn/src/assets/icons/banner-chevron-up.svg @@ -0,0 +1,17 @@ + + + banner chevron + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/card-view.svg b/src/apps/earn/src/assets/icons/card-view.svg new file mode 100644 index 000000000..a25ef8005 --- /dev/null +++ b/src/apps/earn/src/assets/icons/card-view.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/checkmark-large.png b/src/apps/earn/src/assets/icons/checkmark-large.png new file mode 100644 index 000000000..d3c90d8c6 Binary files /dev/null and b/src/apps/earn/src/assets/icons/checkmark-large.png differ diff --git a/src/apps/earn/src/assets/icons/checkmark-medium.png b/src/apps/earn/src/assets/icons/checkmark-medium.png new file mode 100644 index 000000000..454fd20e7 Binary files /dev/null and b/src/apps/earn/src/assets/icons/checkmark-medium.png differ diff --git a/src/apps/earn/src/assets/icons/checkmark-small.png b/src/apps/earn/src/assets/icons/checkmark-small.png new file mode 100644 index 000000000..fdcd176f2 Binary files /dev/null and b/src/apps/earn/src/assets/icons/checkmark-small.png differ diff --git a/src/apps/earn/src/assets/icons/checkpoint-small.svg b/src/apps/earn/src/assets/icons/checkpoint-small.svg new file mode 100644 index 000000000..81f1073b6 --- /dev/null +++ b/src/apps/earn/src/assets/icons/checkpoint-small.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/checkpoint.svg b/src/apps/earn/src/assets/icons/checkpoint.svg new file mode 100644 index 000000000..5f748e847 --- /dev/null +++ b/src/apps/earn/src/assets/icons/checkpoint.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/close-gray.svg b/src/apps/earn/src/assets/icons/close-gray.svg new file mode 100644 index 000000000..d773daa00 --- /dev/null +++ b/src/apps/earn/src/assets/icons/close-gray.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/close.svg b/src/apps/earn/src/assets/icons/close.svg new file mode 100644 index 000000000..26c8dfbb8 --- /dev/null +++ b/src/apps/earn/src/assets/icons/close.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/dropdown-arrow.png b/src/apps/earn/src/assets/icons/dropdown-arrow.png new file mode 100644 index 000000000..40edda675 Binary files /dev/null and b/src/apps/earn/src/assets/icons/dropdown-arrow.png differ diff --git a/src/apps/earn/src/assets/icons/find-work-green.svg b/src/apps/earn/src/assets/icons/find-work-green.svg new file mode 100644 index 000000000..fb4743f1a --- /dev/null +++ b/src/apps/earn/src/assets/icons/find-work-green.svg @@ -0,0 +1,16 @@ + + + 1D5681BC-C33F-4401-A8C9-A5A6F99E8CA7 + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/find-work.svg b/src/apps/earn/src/assets/icons/find-work.svg new file mode 100644 index 000000000..1af6abbaa --- /dev/null +++ b/src/apps/earn/src/assets/icons/find-work.svg @@ -0,0 +1,12 @@ + + + DAD5564D-C4AF-448B-9138-756872CA4CB8 + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/icon-calendar-blue.svg b/src/apps/earn/src/assets/icons/icon-calendar-blue.svg new file mode 100644 index 000000000..84fbbc9dd --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-calendar-blue.svg @@ -0,0 +1,13 @@ + + + + Fill 81 Copy + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/icon-calendar-medium.svg b/src/apps/earn/src/assets/icons/icon-calendar-medium.svg new file mode 100644 index 000000000..26c0a56e8 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-calendar-medium.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/apps/earn/src/assets/icons/icon-calendar.svg b/src/apps/earn/src/assets/icons/icon-calendar.svg new file mode 100644 index 000000000..b75a6fb03 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-calendar.svg @@ -0,0 +1,13 @@ + + + + Fill 81 Copy + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/icon-corner-left-green.svg b/src/apps/earn/src/assets/icons/icon-corner-left-green.svg new file mode 100644 index 000000000..0b69acfa2 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-corner-left-green.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/src/apps/earn/src/assets/icons/icon-cross.png b/src/apps/earn/src/assets/icons/icon-cross.png new file mode 100644 index 000000000..75604cde4 Binary files /dev/null and b/src/apps/earn/src/assets/icons/icon-cross.png differ diff --git a/src/apps/earn/src/assets/icons/icon-facebook-gray.svg b/src/apps/earn/src/assets/icons/icon-facebook-gray.svg new file mode 100644 index 000000000..fd976e999 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-facebook-gray.svg @@ -0,0 +1,7 @@ + + + + diff --git a/src/apps/earn/src/assets/icons/icon-gear-blue.svg b/src/apps/earn/src/assets/icons/icon-gear-blue.svg new file mode 100644 index 000000000..6705fa5ba --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-gear-blue.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/src/apps/earn/src/assets/icons/icon-globe-simple.svg b/src/apps/earn/src/assets/icons/icon-globe-simple.svg new file mode 100644 index 000000000..c726e199b --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-globe-simple.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/apps/earn/src/assets/icons/icon-globe.png b/src/apps/earn/src/assets/icons/icon-globe.png new file mode 100644 index 000000000..d324710cd Binary files /dev/null and b/src/apps/earn/src/assets/icons/icon-globe.png differ diff --git a/src/apps/earn/src/assets/icons/icon-hourglass.svg b/src/apps/earn/src/assets/icons/icon-hourglass.svg new file mode 100644 index 000000000..9a154d2d0 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-hourglass.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/src/apps/earn/src/assets/icons/icon-linkedin-gray.svg b/src/apps/earn/src/assets/icons/icon-linkedin-gray.svg new file mode 100644 index 000000000..6653b4d77 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-linkedin-gray.svg @@ -0,0 +1,9 @@ + + + + diff --git a/src/apps/earn/src/assets/icons/icon-location-crimson.svg b/src/apps/earn/src/assets/icons/icon-location-crimson.svg new file mode 100644 index 000000000..d993f5f6f --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-location-crimson.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/apps/earn/src/assets/icons/icon-location-mark.svg b/src/apps/earn/src/assets/icons/icon-location-mark.svg new file mode 100644 index 000000000..19c17a8a9 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-location-mark.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/apps/earn/src/assets/icons/icon-magnifier.svg b/src/apps/earn/src/assets/icons/icon-magnifier.svg new file mode 100644 index 000000000..657f19399 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-magnifier.svg @@ -0,0 +1,8 @@ + + + + diff --git a/src/apps/earn/src/assets/icons/icon-mark.png b/src/apps/earn/src/assets/icons/icon-mark.png new file mode 100644 index 000000000..8b7b43c57 Binary files /dev/null and b/src/apps/earn/src/assets/icons/icon-mark.png differ diff --git a/src/apps/earn/src/assets/icons/icon-next.svg b/src/apps/earn/src/assets/icons/icon-next.svg new file mode 100644 index 000000000..467efa406 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-next.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/icon-number-one.png b/src/apps/earn/src/assets/icons/icon-number-one.png new file mode 100644 index 000000000..29c75b631 Binary files /dev/null and b/src/apps/earn/src/assets/icons/icon-number-one.png differ diff --git a/src/apps/earn/src/assets/icons/icon-number-three.png b/src/apps/earn/src/assets/icons/icon-number-three.png new file mode 100644 index 000000000..9b3eb12c3 Binary files /dev/null and b/src/apps/earn/src/assets/icons/icon-number-three.png differ diff --git a/src/apps/earn/src/assets/icons/icon-number-two.png b/src/apps/earn/src/assets/icons/icon-number-two.png new file mode 100644 index 000000000..4947bef6a Binary files /dev/null and b/src/apps/earn/src/assets/icons/icon-number-two.png differ diff --git a/src/apps/earn/src/assets/icons/icon-payment.svg b/src/apps/earn/src/assets/icons/icon-payment.svg new file mode 100644 index 000000000..cdc79ef20 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-payment.svg @@ -0,0 +1,6 @@ + + + +$ + diff --git a/src/apps/earn/src/assets/icons/icon-prev.svg b/src/apps/earn/src/assets/icons/icon-prev.svg new file mode 100644 index 000000000..3e90c4708 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-prev.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/icon-sad-face.svg b/src/apps/earn/src/assets/icons/icon-sad-face.svg new file mode 100644 index 000000000..b9b685c03 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-sad-face.svg @@ -0,0 +1,20 @@ + + + + 838C3DF4-2CF1-42A3-BC49-21142AF2003C@2x + Created with sketchtool. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/icon-tick-circled.svg b/src/apps/earn/src/assets/icons/icon-tick-circled.svg new file mode 100644 index 000000000..5d80b5f34 --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-tick-circled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/icon-twitter-gray.svg b/src/apps/earn/src/assets/icons/icon-twitter-gray.svg new file mode 100644 index 000000000..ca0065e2b --- /dev/null +++ b/src/apps/earn/src/assets/icons/icon-twitter-gray.svg @@ -0,0 +1,11 @@ + + + + diff --git a/src/apps/earn/src/assets/icons/info.svg b/src/apps/earn/src/assets/icons/info.svg new file mode 100644 index 000000000..937844107 --- /dev/null +++ b/src/apps/earn/src/assets/icons/info.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/list-view.svg b/src/apps/earn/src/assets/icons/list-view.svg new file mode 100644 index 000000000..ea5bd2186 --- /dev/null +++ b/src/apps/earn/src/assets/icons/list-view.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/logo_topcoder.svg b/src/apps/earn/src/assets/icons/logo_topcoder.svg new file mode 100644 index 000000000..3fa9d3aa6 --- /dev/null +++ b/src/apps/earn/src/assets/icons/logo_topcoder.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/long-arrow-next.svg b/src/apps/earn/src/assets/icons/long-arrow-next.svg new file mode 100644 index 000000000..05de31982 --- /dev/null +++ b/src/apps/earn/src/assets/icons/long-arrow-next.svg @@ -0,0 +1 @@ + diff --git a/src/apps/earn/src/assets/icons/menu-chevron-up.svg b/src/apps/earn/src/assets/icons/menu-chevron-up.svg new file mode 100644 index 000000000..cfdb74002 --- /dev/null +++ b/src/apps/earn/src/assets/icons/menu-chevron-up.svg @@ -0,0 +1,17 @@ + + + CE319284-3A67-4756-B6AD-2D4164352632 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/my-work-green.svg b/src/apps/earn/src/assets/icons/my-work-green.svg new file mode 100644 index 000000000..3b944366f --- /dev/null +++ b/src/apps/earn/src/assets/icons/my-work-green.svg @@ -0,0 +1,11 @@ + + + 9178F574-02BA-4DFF-A4C4-93CE92BDE405 + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/my-work.svg b/src/apps/earn/src/assets/icons/my-work.svg new file mode 100644 index 000000000..b679bbba3 --- /dev/null +++ b/src/apps/earn/src/assets/icons/my-work.svg @@ -0,0 +1,15 @@ + + + C087C319-6EC3-4D78-A758-D1B398901B5D + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/not-found-recommended.png b/src/apps/earn/src/assets/icons/not-found-recommended.png new file mode 100644 index 000000000..728a77b96 Binary files /dev/null and b/src/apps/earn/src/assets/icons/not-found-recommended.png differ diff --git a/src/apps/earn/src/assets/icons/not-found.png b/src/apps/earn/src/assets/icons/not-found.png new file mode 100644 index 000000000..a11d6a87b Binary files /dev/null and b/src/apps/earn/src/assets/icons/not-found.png differ diff --git a/src/apps/earn/src/assets/icons/note.svg b/src/apps/earn/src/assets/icons/note.svg new file mode 100644 index 000000000..5edeea9af --- /dev/null +++ b/src/apps/earn/src/assets/icons/note.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/registrant.svg b/src/apps/earn/src/assets/icons/registrant.svg new file mode 100644 index 000000000..1e337b068 --- /dev/null +++ b/src/apps/earn/src/assets/icons/registrant.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/ribbon-icon.svg b/src/apps/earn/src/assets/icons/ribbon-icon.svg new file mode 100644 index 000000000..92bef26e0 --- /dev/null +++ b/src/apps/earn/src/assets/icons/ribbon-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/robot-embarassed.svg b/src/apps/earn/src/assets/icons/robot-embarassed.svg new file mode 100644 index 000000000..0d974364a --- /dev/null +++ b/src/apps/earn/src/assets/icons/robot-embarassed.svg @@ -0,0 +1,20 @@ + + + robot-embarresed + Created with Sketch. + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/robot-happy.svg b/src/apps/earn/src/assets/icons/robot-happy.svg new file mode 100644 index 000000000..8567e7369 --- /dev/null +++ b/src/apps/earn/src/assets/icons/robot-happy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/search.svg b/src/apps/earn/src/assets/icons/search.svg new file mode 100644 index 000000000..2ae8f711a --- /dev/null +++ b/src/apps/earn/src/assets/icons/search.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/submission.svg b/src/apps/earn/src/assets/icons/submission.svg new file mode 100644 index 000000000..2ddc02eb2 --- /dev/null +++ b/src/apps/earn/src/assets/icons/submission.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/track-des.svg b/src/apps/earn/src/assets/icons/track-des.svg new file mode 100644 index 000000000..fee5c04d2 --- /dev/null +++ b/src/apps/earn/src/assets/icons/track-des.svg @@ -0,0 +1,18 @@ + + + AC4D4B5E-93F9-4F74-A2B8-9AE81B345347 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/track-dev.svg b/src/apps/earn/src/assets/icons/track-dev.svg new file mode 100644 index 000000000..f30f2f2d1 --- /dev/null +++ b/src/apps/earn/src/assets/icons/track-dev.svg @@ -0,0 +1,13 @@ + + + 3D983778-B2BF-4CB8-B636-1C9EDCAA0EFD + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/track-ds.svg b/src/apps/earn/src/assets/icons/track-ds.svg new file mode 100644 index 000000000..e676bfd80 --- /dev/null +++ b/src/apps/earn/src/assets/icons/track-ds.svg @@ -0,0 +1,13 @@ + + + DE0551FC-97AC-4CB0-9254-C3C58445CA86 + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/track-qa.svg b/src/apps/earn/src/assets/icons/track-qa.svg new file mode 100644 index 000000000..18db8e841 --- /dev/null +++ b/src/apps/earn/src/assets/icons/track-qa.svg @@ -0,0 +1,13 @@ + + + 37873904-C546-401A-A25C-FFDA30980031 + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/icons/update-success.svg b/src/apps/earn/src/assets/icons/update-success.svg new file mode 100644 index 000000000..a721c4f61 --- /dev/null +++ b/src/apps/earn/src/assets/icons/update-success.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/IconSquareDownload.svg b/src/apps/earn/src/assets/images/IconSquareDownload.svg new file mode 100644 index 000000000..9ef5ceed7 --- /dev/null +++ b/src/apps/earn/src/assets/images/IconSquareDownload.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/arrow-down.svg b/src/apps/earn/src/assets/images/arrow-down.svg new file mode 100644 index 000000000..d53eedd1a --- /dev/null +++ b/src/apps/earn/src/assets/images/arrow-down.svg @@ -0,0 +1,19 @@ + + + + 5D558F9B-43EB-41DF-905D-8862EFEE8959 + Created with sketchtool. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/arrow-left.svg b/src/apps/earn/src/assets/images/arrow-left.svg new file mode 100644 index 000000000..29f5eee2f --- /dev/null +++ b/src/apps/earn/src/assets/images/arrow-left.svg @@ -0,0 +1,16 @@ + + + + AB093E0F-1C19-4224-9B2F-A8D7A5A76AD0 + Created with sketchtool. + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/arrow-next.svg b/src/apps/earn/src/assets/images/arrow-next.svg new file mode 100644 index 000000000..288a45180 --- /dev/null +++ b/src/apps/earn/src/assets/images/arrow-next.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/arrow-prev-green.svg b/src/apps/earn/src/assets/images/arrow-prev-green.svg new file mode 100644 index 000000000..c0c10c4df --- /dev/null +++ b/src/apps/earn/src/assets/images/arrow-prev-green.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/arrow-prev.svg b/src/apps/earn/src/assets/images/arrow-prev.svg new file mode 100644 index 000000000..4c17ae49f --- /dev/null +++ b/src/apps/earn/src/assets/images/arrow-prev.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/arrow-right-green.svg b/src/apps/earn/src/assets/images/arrow-right-green.svg new file mode 100644 index 000000000..2c28fcca4 --- /dev/null +++ b/src/apps/earn/src/assets/images/arrow-right-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/blob-purple.svg b/src/apps/earn/src/assets/images/blob-purple.svg new file mode 100644 index 000000000..cd1d5d93c --- /dev/null +++ b/src/apps/earn/src/assets/images/blob-purple.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/blob-yellow.svg b/src/apps/earn/src/assets/images/blob-yellow.svg new file mode 100644 index 000000000..98ff027a5 --- /dev/null +++ b/src/apps/earn/src/assets/images/blob-yellow.svg @@ -0,0 +1,17 @@ + + + 4E19AD87-834F-4DB6-AFFB-EA530C3ACC5D + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/calendar.svg b/src/apps/earn/src/assets/images/calendar.svg new file mode 100644 index 000000000..d14c5596e --- /dev/null +++ b/src/apps/earn/src/assets/images/calendar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/celebrate.svg b/src/apps/earn/src/assets/images/celebrate.svg new file mode 100644 index 000000000..be86c1da7 --- /dev/null +++ b/src/apps/earn/src/assets/images/celebrate.svg @@ -0,0 +1,9 @@ + + + Combined Shape + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/completed.svg b/src/apps/earn/src/assets/images/completed.svg new file mode 100644 index 000000000..8b638020e --- /dev/null +++ b/src/apps/earn/src/assets/images/completed.svg @@ -0,0 +1,11 @@ + + + C23ADC25-9150-4E3C-9043-97A704EBE690 + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/default-user-avatar.svg b/src/apps/earn/src/assets/images/default-user-avatar.svg new file mode 100644 index 000000000..4a80a0465 --- /dev/null +++ b/src/apps/earn/src/assets/images/default-user-avatar.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/ico-user-default.png b/src/apps/earn/src/assets/images/ico-user-default.png new file mode 100644 index 000000000..6454881d6 Binary files /dev/null and b/src/apps/earn/src/assets/images/ico-user-default.png differ diff --git a/src/apps/earn/src/assets/images/ico-user-default.svg b/src/apps/earn/src/assets/images/ico-user-default.svg new file mode 100644 index 000000000..ed6ce1a7b --- /dev/null +++ b/src/apps/earn/src/assets/images/ico-user-default.svg @@ -0,0 +1,20 @@ + + + + ico-user-default + Created with Sketch. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/icon-arrow-down-black.svg b/src/apps/earn/src/assets/images/icon-arrow-down-black.svg new file mode 100644 index 000000000..869b8991c --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-arrow-down-black.svg @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/icon-arrow-down.svg b/src/apps/earn/src/assets/images/icon-arrow-down.svg new file mode 100644 index 000000000..b8814cfb9 --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-arrow-down.svg @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/icon-arrow-up-black.svg b/src/apps/earn/src/assets/images/icon-arrow-up-black.svg new file mode 100644 index 000000000..3b6328bbb --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-arrow-up-black.svg @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/icon-arrow-up.svg b/src/apps/earn/src/assets/images/icon-arrow-up.svg new file mode 100644 index 000000000..cae45cce0 --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-arrow-up.svg @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/icon-calendar-2-active.svg b/src/apps/earn/src/assets/images/icon-calendar-2-active.svg new file mode 100644 index 000000000..e03bcbecf --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-calendar-2-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/icon-calendar-2.svg b/src/apps/earn/src/assets/images/icon-calendar-2.svg new file mode 100644 index 000000000..c8f954975 --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-calendar-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/icon-close-green.svg b/src/apps/earn/src/assets/images/icon-close-green.svg new file mode 100644 index 000000000..101c548ea --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-close-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/icon-date-sort.svg b/src/apps/earn/src/assets/images/icon-date-sort.svg new file mode 100644 index 000000000..1defb57d7 --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-date-sort.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/icon-sort-mobile.svg b/src/apps/earn/src/assets/images/icon-sort-mobile.svg new file mode 100644 index 000000000..d5c9466c8 --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-sort-mobile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/icon-sort.svg b/src/apps/earn/src/assets/images/icon-sort.svg new file mode 100644 index 000000000..8919cf0ed --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-sort.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/icon-time-active.svg b/src/apps/earn/src/assets/images/icon-time-active.svg new file mode 100644 index 000000000..cf1202494 --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-time-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/icon-time.svg b/src/apps/earn/src/assets/images/icon-time.svg new file mode 100644 index 000000000..5e535625f --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-time.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/icon-verified.svg b/src/apps/earn/src/assets/images/icon-verified.svg new file mode 100644 index 000000000..ac4a28ac7 --- /dev/null +++ b/src/apps/earn/src/assets/images/icon-verified.svg @@ -0,0 +1,37 @@ + + + Group 6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/apps/earn/src/assets/images/nav-active-item-blue.svg b/src/apps/earn/src/assets/images/nav-active-item-blue.svg new file mode 100644 index 000000000..459058553 --- /dev/null +++ b/src/apps/earn/src/assets/images/nav-active-item-blue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/nav-active-item.svg b/src/apps/earn/src/assets/images/nav-active-item.svg new file mode 100644 index 000000000..5ff4ba65b --- /dev/null +++ b/src/apps/earn/src/assets/images/nav-active-item.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/._06-Big-Prize.png b/src/apps/earn/src/assets/images/open-graph/challenges/._06-Big-Prize.png new file mode 100644 index 000000000..127aed183 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/._06-Big-Prize.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/._09-First2Finish.png b/src/apps/earn/src/assets/images/open-graph/challenges/._09-First2Finish.png new file mode 100644 index 000000000..8a66ae8d6 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/._09-First2Finish.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/02-Design-Preview.png b/src/apps/earn/src/assets/images/open-graph/challenges/02-Design-Preview.png new file mode 100644 index 000000000..18ddf6c07 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/02-Design-Preview.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/03-Development.png b/src/apps/earn/src/assets/images/open-graph/challenges/03-Development.png new file mode 100644 index 000000000..11d31928d Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/03-Development.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/04-Data-Science.png b/src/apps/earn/src/assets/images/open-graph/challenges/04-Data-Science.png new file mode 100644 index 000000000..5f58f3e14 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/04-Data-Science.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/05-QA.png b/src/apps/earn/src/assets/images/open-graph/challenges/05-QA.png new file mode 100644 index 000000000..a476e36c8 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/05-QA.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/06-Big-Prize.png b/src/apps/earn/src/assets/images/open-graph/challenges/06-Big-Prize.png new file mode 100644 index 000000000..8fedf92a1 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/06-Big-Prize.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/09-First2Finish.png b/src/apps/earn/src/assets/images/open-graph/challenges/09-First2Finish.png new file mode 100644 index 000000000..07993a41a Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/09-First2Finish.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/Design-First2Finish.png b/src/apps/earn/src/assets/images/open-graph/challenges/Design-First2Finish.png new file mode 100644 index 000000000..887800120 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/Design-First2Finish.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/Design-Task.png b/src/apps/earn/src/assets/images/open-graph/challenges/Design-Task.png new file mode 100644 index 000000000..f2e2fb18e Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/Design-Task.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/Development-First2Finish.png b/src/apps/earn/src/assets/images/open-graph/challenges/Development-First2Finish.png new file mode 100644 index 000000000..f3f2ceee8 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/Development-First2Finish.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/Development-Task.png b/src/apps/earn/src/assets/images/open-graph/challenges/Development-Task.png new file mode 100644 index 000000000..20cbac13f Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/Development-Task.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/MM-Challenge.png b/src/apps/earn/src/assets/images/open-graph/challenges/MM-Challenge.png new file mode 100644 index 000000000..b747546df Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/MM-Challenge.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/QA-First2Finish.png b/src/apps/earn/src/assets/images/open-graph/challenges/QA-First2Finish.png new file mode 100644 index 000000000..e3d439288 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/QA-First2Finish.png differ diff --git a/src/apps/earn/src/assets/images/open-graph/challenges/QA-Task.png b/src/apps/earn/src/assets/images/open-graph/challenges/QA-Task.png new file mode 100644 index 000000000..0c94fdc20 Binary files /dev/null and b/src/apps/earn/src/assets/images/open-graph/challenges/QA-Task.png differ diff --git a/src/apps/earn/src/assets/images/progress-bar-mid.svg b/src/apps/earn/src/assets/images/progress-bar-mid.svg new file mode 100644 index 000000000..89bb89a9e --- /dev/null +++ b/src/apps/earn/src/assets/images/progress-bar-mid.svg @@ -0,0 +1,35 @@ + + + 950E0BBC-2C06-4F3E-A318-363E03868587@2x + + + + + + + + + + + + + + + + + + + + + + You are here + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/progress-bar-mobile.svg b/src/apps/earn/src/assets/images/progress-bar-mobile.svg new file mode 100644 index 000000000..f3bc32823 --- /dev/null +++ b/src/apps/earn/src/assets/images/progress-bar-mobile.svg @@ -0,0 +1,37 @@ + + + 4C8194C6-558E-4D9A-B419-CA20B73B5E59@2x + + + + + + + + + + + + + + + + + + + + + + + + You are here + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/progress-bar.svg b/src/apps/earn/src/assets/images/progress-bar.svg new file mode 100644 index 000000000..31d0152d6 --- /dev/null +++ b/src/apps/earn/src/assets/images/progress-bar.svg @@ -0,0 +1,35 @@ + + + E3BD476A-D75E-46B7-B760-74F655D7902F + + + + + + + + + + + + + + + + + + + + + + You are here + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/social.png b/src/apps/earn/src/assets/images/social.png new file mode 100644 index 000000000..f64e59923 Binary files /dev/null and b/src/apps/earn/src/assets/images/social.png differ diff --git a/src/apps/earn/src/assets/images/social/icon_email.svg b/src/apps/earn/src/assets/images/social/icon_email.svg new file mode 100644 index 000000000..e03bb5c54 --- /dev/null +++ b/src/apps/earn/src/assets/images/social/icon_email.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/earn/src/assets/images/social/icon_facebook.svg b/src/apps/earn/src/assets/images/social/icon_facebook.svg new file mode 100644 index 000000000..548ec26a8 --- /dev/null +++ b/src/apps/earn/src/assets/images/social/icon_facebook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/social/icon_plus.svg b/src/apps/earn/src/assets/images/social/icon_plus.svg new file mode 100644 index 000000000..dbb6e5ce5 --- /dev/null +++ b/src/apps/earn/src/assets/images/social/icon_plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/social/icon_print.svg b/src/apps/earn/src/assets/images/social/icon_print.svg new file mode 100644 index 000000000..aa0b1e8ea --- /dev/null +++ b/src/apps/earn/src/assets/images/social/icon_print.svg @@ -0,0 +1,29 @@ + + + + icon print + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/social/icon_twitter.svg b/src/apps/earn/src/assets/images/social/icon_twitter.svg new file mode 100644 index 000000000..d08c276e0 --- /dev/null +++ b/src/apps/earn/src/assets/images/social/icon_twitter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/tco19_logo_black.svg b/src/apps/earn/src/assets/images/tco19_logo_black.svg new file mode 100644 index 000000000..02102ed27 --- /dev/null +++ b/src/apps/earn/src/assets/images/tco19_logo_black.svg @@ -0,0 +1,39 @@ + + + + Topcoder Open 2019 + Created with sketchtool. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/thinking-face-laptop-tablet.svg b/src/apps/earn/src/assets/images/thinking-face-laptop-tablet.svg new file mode 100644 index 000000000..809839b5e --- /dev/null +++ b/src/apps/earn/src/assets/images/thinking-face-laptop-tablet.svg @@ -0,0 +1,433 @@ + + + + + + + + + + + diff --git a/src/apps/earn/src/assets/images/thinking-face-mobile.svg b/src/apps/earn/src/assets/images/thinking-face-mobile.svg new file mode 100644 index 000000000..cb818ced0 --- /dev/null +++ b/src/apps/earn/src/assets/images/thinking-face-mobile.svg @@ -0,0 +1,433 @@ + + + + + + + + + + + diff --git a/src/apps/earn/src/assets/images/tick-big.png b/src/apps/earn/src/assets/images/tick-big.png new file mode 100644 index 000000000..8bd83bc41 Binary files /dev/null and b/src/apps/earn/src/assets/images/tick-big.png differ diff --git a/src/apps/earn/src/assets/images/tooltip-arrow.svg b/src/apps/earn/src/assets/images/tooltip-arrow.svg new file mode 100644 index 000000000..a6bda5162 --- /dev/null +++ b/src/apps/earn/src/assets/images/tooltip-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/tooltip-info.svg b/src/apps/earn/src/assets/images/tooltip-info.svg new file mode 100644 index 000000000..bb9b227af --- /dev/null +++ b/src/apps/earn/src/assets/images/tooltip-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/update-success-big.svg b/src/apps/earn/src/assets/images/update-success-big.svg new file mode 100644 index 000000000..ba202e104 --- /dev/null +++ b/src/apps/earn/src/assets/images/update-success-big.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/apps/earn/src/assets/images/upload-fail.svg b/src/apps/earn/src/assets/images/upload-fail.svg new file mode 100644 index 000000000..e402a5bb4 --- /dev/null +++ b/src/apps/earn/src/assets/images/upload-fail.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/assets/images/upload-loading.svg b/src/apps/earn/src/assets/images/upload-loading.svg new file mode 100644 index 000000000..f96a787eb --- /dev/null +++ b/src/apps/earn/src/assets/images/upload-loading.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/earn/src/assets/images/upload-success.svg b/src/apps/earn/src/assets/images/upload-success.svg new file mode 100644 index 000000000..088cb2148 --- /dev/null +++ b/src/apps/earn/src/assets/images/upload-success.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/components/AccessDenied/index.jsx b/src/apps/earn/src/components/AccessDenied/index.jsx new file mode 100644 index 000000000..103530bd2 --- /dev/null +++ b/src/apps/earn/src/components/AccessDenied/index.jsx @@ -0,0 +1,69 @@ +import PT from "prop-types"; +import _ from "lodash"; + +import { EnvironmentConfig } from "~/config"; +import { LinkButton } from "~/libs/ui"; +import { ReactComponent as TopcoderLogo } from "@earn/assets/icons/logo_topcoder.svg"; + +import { ACCESS_DENIED_REASON } from "../../constants"; +import { styled as styledCss } from "../../utils"; + +import styles from "./styles.module.scss"; +const styled = styledCss(styles) + +const AccessDenied = ({ cause, redirectLink, children }) => { + const retUrl = encodeURIComponent(window.location.href); + switch (cause) { + case ACCESS_DENIED_REASON.NOT_AUTHENTICATED: { + return ( +
+ +
+ You must be authenticated to access this page. +
+
+ + Log In Here + +
+
+ ); + } + case ACCESS_DENIED_REASON.NOT_AUTHORIZED: + return ( +
+ +
You are not authorized to access this page.
+ {children} +
+ ); + case ACCESS_DENIED_REASON.HAVE_NOT_SUBMITTED_TO_THE_CHALLENGE: + return ( +
+ +
You have not submitted to this challenge
+ Back to the challenge +
+ ); + default: + return
; + } +}; + +AccessDenied.defaultProps = { + cause: ACCESS_DENIED_REASON.NOT_AUTHENTICATED, + redirectLink: "", + children: null, +}; + +AccessDenied.propTypes = { + cause: PT.oneOf(_.toArray(ACCESS_DENIED_REASON)), + redirectLink: PT.string, + children: PT.node, +}; + +export default AccessDenied; diff --git a/src/apps/earn/src/components/AccessDenied/styles.module.scss b/src/apps/earn/src/components/AccessDenied/styles.module.scss new file mode 100644 index 000000000..41e351b8f --- /dev/null +++ b/src/apps/earn/src/components/AccessDenied/styles.module.scss @@ -0,0 +1,48 @@ +@import '@earn/styles/variables'; +@import '@earn/styles/mixins'; + +.access-denied { + @include tc-body-lg; + + padding-top: 100px; + text-align: center; + width: 100%; +} + +.msg { + padding-top: 48px; + + a { + cursor: pointer; + } +} + +.joinNow { + &, + &:hover, + &:visited { + color: $tc-light-blue; + text-decoration: underline; + } +} + +.policy { + font-weight: bold; + margin-right: 24px; + + &, + &:hover, + &:visited { + color: $tc-light-blue; + text-decoration: underline; + } +} + +.copyright { + @include roboto-regular; + + color: $tc-gray-60; + font-size: 12pt; + margin-top: 128px; + text-transform: uppercase; +} diff --git a/src/apps/earn/src/components/Avatar/index.jsx b/src/apps/earn/src/components/Avatar/index.jsx new file mode 100644 index 000000000..9e74f6467 --- /dev/null +++ b/src/apps/earn/src/components/Avatar/index.jsx @@ -0,0 +1,12 @@ +import { ReactComponent as DefaultUserAvatar } from '@earn/assets/images/default-user-avatar.svg'; +import React from 'react'; +import { themr } from 'react-css-super-themr'; +import ProtoAvatar from '../ProtoAvatar'; + +import theme from './style.scss'; + +function Avatar(props) { + return ; +} + +export default themr('Avatar', theme)(Avatar); \ No newline at end of file diff --git a/src/apps/earn/src/components/Avatar/style.scss b/src/apps/earn/src/components/Avatar/style.scss new file mode 100644 index 000000000..843c97d37 --- /dev/null +++ b/src/apps/earn/src/components/Avatar/style.scss @@ -0,0 +1,5 @@ +.avatar { + border-radius: 16px; + height: 32px; + width: 32px; + } \ No newline at end of file diff --git a/src/apps/earn/src/components/Banner/index.jsx b/src/apps/earn/src/components/Banner/index.jsx new file mode 100644 index 000000000..96ac9b552 --- /dev/null +++ b/src/apps/earn/src/components/Banner/index.jsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { ReactComponent as BannerChevronUp} from "../../assets/icons/banner-chevron-up.svg"; +import classNames from 'classnames' + +import styles from "./styles.scss"; + +export const Banner = () => { + const [isExpanded, setIsExpanded] = useState(false); + const header = + "Welcome to our BETA work listings site - Tell us what you think!"; + + return ( +
+

+ {header} + + setIsExpanded(!isExpanded)} + > + + +

+ + {isExpanded && ( +
+

+ Welcome to the Beta version of the new Challenge Listings. During + this Beta phase, we will be fine-tuning the platform based on + feedback we receive from you, our community members. +

+

NOTE THAT THIS IS NOT THE FINAL VERSION OF THE SITE.

+

+ You may encounter occasional broken links or error messages. If so, + please let us know! This is what the Beta phase is intended for, and + your feedback will enable us to greatly improve the new site.{" "} +

+

You can click on the Feedback button on page.

+

Thank you!

+
+ )} +
+ ); +}; + +export default Banner; diff --git a/src/apps/earn/src/components/Banner/styles.scss b/src/apps/earn/src/components/Banner/styles.scss new file mode 100644 index 000000000..45711f32c --- /dev/null +++ b/src/apps/earn/src/components/Banner/styles.scss @@ -0,0 +1,65 @@ +@import "@earn/styles/variables"; +@import "@earn/styles/mixins"; + +.banner { + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + + width: 100%; + background: linear-gradient(90deg, $tc-blue 0%, $tc-turquoise 100%); + border-radius: 10px; + margin-bottom: 22px; + color: $tc-white; + padding-left: 28px; + + .header { + display: flex; + width: 100%; + justify-content: space-between; + flex-direction: row; + align-items: center; + @include roboto-bold; + line-height: 25px; + min-height: 50px; + font-size: 20px; + text-transform: uppercase; + } + + .chevron { + margin-right: 20px; + margin-top: 5px; + + &.expanded { + transform: rotate(180deg); + } + + &:hover { + cursor: pointer; + } + } + + .content { + display: flex; + flex-direction: column; + justify-content: flex-start; + font-size: 16px; + margin-bottom: 24px; + width: 85%; + + h3 { + font-size: 15px; + font-weight: bold; + margin-top: 15px; + } + + p { + margin-top: 15px; + } + + a { + text-decoration: underline; + } + + } +} diff --git a/src/apps/earn/src/components/Checkbox/index.jsx b/src/apps/earn/src/components/Checkbox/index.jsx new file mode 100644 index 000000000..6325007b5 --- /dev/null +++ b/src/apps/earn/src/components/Checkbox/index.jsx @@ -0,0 +1,65 @@ +/** + * Checkbox component. + */ +import React, { useRef, useState, useEffect } from "react"; +import PT from "prop-types"; +import _ from "lodash"; + +import config from "../../config"; +import iconCheckL from "../../assets/icons/checkmark-large.png"; +import iconCheckM from "../../assets/icons/checkmark-medium.png"; +import iconCheckS from "../../assets/icons/checkmark-small.png"; + +import styles from "./styles.scss"; +import { styled as styledCss } from "@earn/utils"; +const styled = styledCss(styles) + +function Checkbox({ checked, onChange, size, errorMsg }) { + const [checkedInternal, setCheckedInternal] = useState(checked); + let sizeStyle = size === "lg" ? "lgSize" : null; + const imgSrc = + size === "xs" ? iconCheckS : size === "sm" ? iconCheckM : iconCheckL; + if (!sizeStyle) { + sizeStyle = size === "xs" ? "xsSize" : "smSize"; + } + const delayedOnChange = useRef( + _.debounce((q, cb) => cb(q), config.GUIKIT.DEBOUNCE_ON_CHANGE_TIME) // eslint-disable-line no-undef + ).current; + + useEffect(() => { + setCheckedInternal(checked); + }, [checked]); + + return ( + + ); +} + +Checkbox.defaultProps = { + checked: false, + onChange: () => {}, + size: "sm", + errorMsg: "", +}; + +Checkbox.propTypes = { + checked: PT.bool, + onChange: PT.func, + size: PT.oneOf(["xs", "sm", "lg"]), + errorMsg: PT.string, +}; + +export default Checkbox; diff --git a/src/apps/earn/src/components/Checkbox/styles.scss b/src/apps/earn/src/components/Checkbox/styles.scss new file mode 100644 index 000000000..1f78c4dcf --- /dev/null +++ b/src/apps/earn/src/components/Checkbox/styles.scss @@ -0,0 +1,117 @@ +@import "@earn/styles/variables"; +@import "@earn/styles/GUIKit/default"; + +/* Create a custom checkbox */ +.checkmark { + position: absolute; + top: 0; + left: 0; + background-color: $tc-white; + border: 1px solid $gui-kit-gray-30; + + &.haveError { + border: 2px solid #ef476f; + } + + /* Create the checkmark/indicator (hidden when not checked) */ + .after { + position: absolute; + display: none; + left: 50%; + top: 50%; + } +} + +/* The container */ +.container { + display: block; + position: relative; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + // lg size + &.lgSize { + width: 25px; + height: 25px; + + .checkmark { + width: 25px; + height: 25px; + border-radius: 4px; + + .after { + margin-left: -9px; + margin-top: -7px; + } + } + } + + // sm size + &.smSize { + width: 20px; + height: 20px; + + .checkmark { + width: 20px; + height: 20px; + border-radius: 3px; + + .after { + margin-left: -7px; + margin-top: -6px; + } + } + } + + // xs size + &.xsSize { + width: 15px; + height: 15px; + + .checkmark { + width: 15px; + height: 15px; + border-radius: 2px; + + .after { + margin-left: -6px; + margin-top: -4px; + } + } + } + + /* Hide the browser's default checkbox */ + input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + + /* When the checkbox is checked, add a blue background */ + &:checked ~ .checkmark { + background-color: $gui-kit-level-2; + border: none; + box-shadow: inset 0 1px 2px 0 rgba(0, 0, 0, 0.29); + + /* Show the checkmark when checked */ + .after { + display: block; + } + } + } +} + +.errorMessage { + display: block; + + @include errorMessage; + + position: absolute; + white-space: nowrap; + margin: 35px 0 0 0; + color: #ef476f; +} diff --git a/src/apps/earn/src/components/CurrencyField/index.jsx b/src/apps/earn/src/components/CurrencyField/index.jsx new file mode 100644 index 000000000..f5779ec3c --- /dev/null +++ b/src/apps/earn/src/components/CurrencyField/index.jsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from "react"; +import PT from "prop-types"; +import debounce from "lodash/debounce"; +import { + convertNumberStringToNumber, + integerFormatter, + isValidNumberString, +} from "../../utils/gigs/formatting"; +import { DEBOUNCE_ON_CHANGE_TIME } from "../../constants/index.js"; +import { PAYMENT_MAX_VALUE } from "../../constants/gigs"; +import { isNumber } from "lodash"; + +import styles from "./styles.scss"; + +/** + * Displays currency input field. + * + * @param {Object} props component properties + * @param {string} [props.className] class name added to root element + * @param {string} props.currency currency abbreviation + * @param {string} props.id id for input element + * @param {string} props.label field label + * @param {number} [props.maxValue] maximum value + * @param {number} [props.minValue] minimum value + * @param {string} props.name name for input element + * @param {(v: string) => void} props.onChange function called when input value changes + * @param {(v: number) => void} props.onCommit function called after some delay + * when input value changes and it is valid + * @param {boolean} [props.required] whether the field required non-empty value + * @param {*} props.value input value + * @returns {JSX.Element} + */ +const CurrencyField = ({ + className, + currency, + id, + label, + maxValue, + minValue, + name, + rangeError, + onError, + onChange, + onCommit, + required = false, + value, +}) => { + const [error, setError] = useState(""); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const checkValue = useCallback( + debounce( + (value) => { + if (!value) { + if (required) { + setError("Required"); + } + return; + } + let num = convertNumberStringToNumber(value); + let min = isNumber(minValue) + ? minValue + : convertNumberStringToNumber(minValue); + let max = isNumber(maxValue) + ? maxValue + : convertNumberStringToNumber(maxValue); + if (isValidNumberString(value) && !isNaN(num)) { + setError(""); + if (num < min || num > max) { + onError("Please put in a valid range."); + } else { + onError(""); + onCommit(num); + } + } else { + onError(""); + setError("Invalid format"); + } + }, + DEBOUNCE_ON_CHANGE_TIME, + { leading: false } + ), + [maxValue, minValue, onCommit, required] + ); + + const onChangeValue = useCallback( + (event) => { + let value = event.target.value; + setError(""); + if (value && isValidNumberString(value)) { + let num = convertNumberStringToNumber(value); + if (!isNaN(num)) { + value = integerFormatter.format(num); + } + } + onChange(value); + checkValue(value); + }, + [checkValue, onChange] + ); + + useEffect(() => { + checkValue(value); + }, [checkValue, value]); + + return ( +
+ {label && ( + + )} +
+ + {currency} + {error && {error}} +
+
+ ); +}; + +CurrencyField.defaultProps = { + maxValue: PAYMENT_MAX_VALUE, + minValue: 0, +}; + +CurrencyField.propTypes = { + className: PT.string, + currency: PT.string.isRequired, + id: PT.string.isRequired, + label: PT.string, + rangeError: PT.string, + maxValue: PT.oneOfType([PT.number, PT.string]), + minValue: PT.oneOfType([PT.number, PT.string]), + name: PT.string.isRequired, + onError: PT.func.isRequired, + onChange: PT.func.isRequired, + onCommit: PT.func.isRequired, + required: PT.bool, + value: PT.oneOfType([PT.number, PT.string]).isRequired, +}; + +export default CurrencyField; diff --git a/src/apps/earn/src/components/CurrencyField/styles.scss b/src/apps/earn/src/components/CurrencyField/styles.scss new file mode 100644 index 000000000..fed55f9d9 --- /dev/null +++ b/src/apps/earn/src/components/CurrencyField/styles.scss @@ -0,0 +1,70 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.container { + position: relative; +} + +.label { + z-index: 2; + position: absolute; + left: 9px; + top: 0; + padding: 0 7px; + font-size: 12px; + line-height: 12px; + transform: translateY(-50%); + color: $control-border-color; + background-color: #fff; +} + +.field { + z-index: 1; + position: relative; + display: flex; + align-items: center; + border: 1px solid $control-border-color; + border-radius: $control-border-radius; + height: 40px; + background-color: #fff; + + &.has-errors { + border-color: #EF476F; + } +} + +input.input { + flex: 1 1 0; + margin: 0; + border: none !important; + padding: 8px 0 8px 15px; + height: 22px; + font-size: 14px; + line-height: 22px; + background: none; + outline: none !important; + box-shadow: none !important; + width: 60px; +} + +.currency { + flex: 0 0 auto; + margin: 0 10px 0 6px; + font-size: 13px; + line-height: 22px; + text-transform: uppercase; + color: $control-border-color; +} + +.error { + display: block; + position: absolute; + left: 15px; + top: 100%; + margin-top: 2px; + font-size: 12px; + line-height: 17px; + white-space: nowrap; + color: $body-color; + background-color: #fff; +} diff --git a/src/apps/earn/src/components/DateRangePicker/DateInput/index.jsx b/src/apps/earn/src/components/DateRangePicker/DateInput/index.jsx new file mode 100644 index 000000000..881715340 --- /dev/null +++ b/src/apps/earn/src/components/DateRangePicker/DateInput/index.jsx @@ -0,0 +1,167 @@ +import React, { useRef, useEffect, useState } from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import TextInput from "../../TextInput"; +import { ReactComponent as CalendarIcon} from "../../../assets/icons/icon-calendar.svg"; + +import styles from "./styles.scss"; +import { styled as styledCss } from "../../../utils"; +const styled = styledCss(styles) + +const DateInput = ({ + id, + isStartDateActive, + startDateString, + onStartDateChange, + onStartDateFocus, + isEndDateActive, + endDateString, + onEndDateChange, + onEndDateFocus, + error, + onClickCalendarIcon, + onStartEndDateChange, + placeholder, + enterToSubmit, +}) => { + const ref = useRef(null); + const [focused, setFocused] = useState(false); + + let rangeText; + if (startDateString && endDateString) { + rangeText = `${startDateString} - ${endDateString}`; + } else { + rangeText = `${startDateString}${endDateString}`; + } + + useEffect(() => { + const inputElement = ref.current.querySelector("input"); + const onFocus = () => setFocused(true); + const onBlur = () => setFocused(false); + + inputElement.addEventListener("focus", onFocus); + inputElement.addEventListener("blur", onBlur); + + return () => { + inputElement.removeEventListener("focus", onFocus); + inputElement.removeEventListener("blur", onBlur); + }; + }, []); + + const latestPropsRef = useRef(null); + latestPropsRef.current = { onStartDateFocus, onEndDateFocus }; + + useEffect(() => { + const inputElement = ref.current.querySelector("input"); + + let caretPosition; + if (inputElement.selectionDirection === "forward") { + caretPosition = inputElement.selectionEnd; + } else { + caretPosition = inputElement.selectionStart; + } + + if (caretPosition < 14) { + latestPropsRef.current.onStartDateFocus(); + } else { + latestPropsRef.current.onEndDateFocus(); + } + }, [focused]); + + const onChangeRangeText = (value) => { + let [newStartDateString = "", newEndDateString = ""] = value + .trim() + .split("-"); + newStartDateString = newStartDateString.trim(); + newEndDateString = newEndDateString.trim(); + + if ( + newStartDateString !== startDateString && + newEndDateString !== endDateString + ) { + const event = { + startDateString: newStartDateString, + endDateString: newEndDateString, + }; + onStartEndDateChange(event); + onStartDateFocus(); + } else if (newStartDateString !== startDateString) { + onStartDateFocus(); + onStartDateChange(newStartDateString); + } else if (newEndDateString !== endDateString) { + onEndDateFocus(); + onEndDateChange(newEndDateString); + if (newEndDateString === "") { + onStartDateFocus(); + } + } + }; + + const onChangeRangeTextDebounced = useRef(_.debounce((f) => f(), 1000)); + + const onClickIcon = (event) => { + event.stopPropagation(); + + const inputElement = ref.current.querySelector("input"); + + let caretPosition; + if (inputElement.selectionDirection === "forward") { + caretPosition = inputElement.selectionEnd; + } else { + caretPosition = inputElement.selectionStart; + } + + if (caretPosition < 14 || caretPosition === rangeText.length) { + onClickCalendarIcon("start"); + } else { + onClickCalendarIcon("end"); + } + }; + + const label = startDateString ? "From" : endDateString ? "To" : "From"; + + return ( +
+
+ { + if (!enterToSubmit) { + onChangeRangeTextDebounced.current(() => + onChangeRangeText(value) + ); + } + }} + onEnterKey={(value) => { + onChangeRangeText(value); + }} + placeholder={placeholder} + /> + +
+
{error}
+
+ ); +}; + +DateInput.propTypes = { + id: PT.string, + isStartDateActive: PT.bool, + startDateString: PT.string, + onStartDateChange: PT.func, + onStartDateFocus: PT.func, + isEndDateActive: PT.bool, + endDateString: PT.string, + onEndDateChange: PT.func, + onEndDateFocus: PT.func, + error: PT.string, + onClickCalendarIcon: PT.func, + onStartEndDateChange: PT.func, + placeholder: PT.string, +}; + +export default DateInput; diff --git a/src/apps/earn/src/components/DateRangePicker/DateInput/styles.scss b/src/apps/earn/src/components/DateRangePicker/DateInput/styles.scss new file mode 100644 index 000000000..8b637437b --- /dev/null +++ b/src/apps/earn/src/components/DateRangePicker/DateInput/styles.scss @@ -0,0 +1,49 @@ +@import "@earn/styles/variables"; +@import "@earn/styles/mixins"; + +.container { + &.isError { + input { + border: 1px solid $tc-level-5; + } + + .errorHint { + display: block; + color: $tc-level-5; + font-size: 12px; + padding: 4px 0; + height: 20px; + } + } +} + +.date-range-input { + margin-top: -12px; + font-size: $font-size-sm; + + @include textinput-show-label; +} + +.input-group { + position: relative; + + .icon { + position: absolute; + top: 22px; + right: 14px; + display: block; + cursor: pointer; + appearance: none; + background: none; + border: none; + padding: 0; + } + + input { + padding-right: 46px !important; + } +} + +.errorHint { + display: none; +} diff --git a/src/apps/earn/src/components/DateRangePicker/helpers.js b/src/apps/earn/src/components/DateRangePicker/helpers.js new file mode 100644 index 000000000..df745c982 --- /dev/null +++ b/src/apps/earn/src/components/DateRangePicker/helpers.js @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; +import moment from "moment"; + +/** + * Check whether the dates are same + * @param {Date} date1. + * @param {Date} date2. + * @return {boolean} + */ +export function isSameDay(date1, date2) { + if (!date1 || !date2) return false; + return moment(date1).isSame(moment(date2), "day"); +} + +/** + * Check whether the date1 is occur before date2 + * @param {Date} date1. + * @param {Date} date2. + * @return {boolean} + */ +export function isBeforeDay(date1, date2) { + if (!date1 || !date2) return false; + return moment(date1).isBefore(moment(date2), "day"); +} + +/** + * Check whether the date1 is occur after date2 + * @param {Date} date1. + * @param {Date} date2. + * @return {boolean} + */ +export function isAfterDay(date1, date2) { + if (!date1 || !date2) return false; + return moment(date1).isAfter(moment(date2), "day"); +} + +const staticRangeHandler = { + range: {}, + isSelected(range) { + const definedRange = this.range(); + return ( + isSameDay(range.startDate, definedRange.startDate) && + isSameDay(range.endDate, definedRange.endDate) + ); + }, +}; + +/** + * Create defined date ranges + * @return {object[]} list of defined ranges + */ +export function createStaticRanges() { + const today = moment(); + const endOfToday = today.set({ + hour: 23, + minute: 59, + second: 59, + millisecond: 999, + }); + const pastWeek = endOfToday.clone().subtract(1, "week"); + const pastMonth = endOfToday.clone().subtract(1, "month"); + const past6Months = endOfToday.clone().subtract(6, "month"); + const pastYear = endOfToday.clone().subtract(1, "year"); + + const ranges = [ + { + label: "Past Week", + range: () => ({ + startDate: pastWeek.startOf("day").toDate(), + endDate: endOfToday.toDate(), + }), + }, + { + label: "Past Month", + range: () => ({ + startDate: pastMonth.startOf("day").toDate(), + endDate: endOfToday.toDate(), + }), + }, + { + label: "Past 6 Months", + range: () => ({ + startDate: past6Months.startOf("day").toDate(), + endDate: endOfToday.toDate(), + }), + }, + { + label: "Past Year", + range: () => ({ + startDate: pastYear.startOf("day").toDate(), + endDate: endOfToday.toDate(), + }), + }, + ]; + + return ranges.map((range) => ({ ...staticRangeHandler, ...range })); +} + +/** + * React hook for checking if the click is from outside the reference + * @param {boolean} initialIsVisible true if visible and false if hidden + */ +export function useComponentVisible(initialIsVisible) { + const [isComponentVisible, setIsComponentVisible] = useState( + initialIsVisible + ); + const ref = useRef(null); + + const handleHideDropdown = (event) => { + if (event.key === "Escape") { + setIsComponentVisible(false); + } + }; + + const handleClickOutside = (event) => { + if (ref.current && !ref.current.contains(event.target)) { + setIsComponentVisible(false); + } + }; + + useEffect(() => { + document.addEventListener("keydown", handleHideDropdown, true); + document.addEventListener("click", handleClickOutside, true); + return () => { + document.removeEventListener("keydown", handleHideDropdown, true); + document.removeEventListener("click", handleClickOutside, true); + }; + }); + + return { ref, isComponentVisible, setIsComponentVisible }; +} + +export default { + useComponentVisible, + createStaticRanges, +}; diff --git a/src/apps/earn/src/components/DateRangePicker/index.jsx b/src/apps/earn/src/components/DateRangePicker/index.jsx new file mode 100644 index 000000000..fbd840320 --- /dev/null +++ b/src/apps/earn/src/components/DateRangePicker/index.jsx @@ -0,0 +1,651 @@ +import React, { useState, useEffect } from "react"; +import moment from "moment"; +import { DateRangePicker as ReactDateRangePicker } from "react-date-range"; +import PropTypes from "prop-types"; + +import styles from "./style.scss"; +import { styled as styledCss } from "@earn/utils"; + +import DateInput from "./DateInput"; + +import { + useComponentVisible, + createStaticRanges, + isSameDay, + isAfterDay, + isBeforeDay, +} from "./helpers"; + +const styled = styledCss(styles); + +function DateRangePicker(props) { + const { id, range, onChange, placeholder, enterToSubmit = false } = props; + + const [rangeString, setRangeString] = useState({ + startDateString: "", + endDateString: "", + }); + const [activeDate, setActiveDate] = useState(null); + const [preview, setPreview] = useState(null); + const [focusedRange, setFocusedRange] = useState([0, 0]); + const [errors, setErrors] = useState({ + startDate: undefined, + endDate: undefined, + }); + + const { + ref: calendarRef, + isComponentVisible, + setIsComponentVisible, + } = useComponentVisible(false); + + const isStartDateFocused = focusedRange[1] === 0; + const isEndDateFocused = focusedRange[1] === 1; + + useEffect(() => { + setRangeString({ + startDateString: range.startDate + ? moment(range.startDate).format("MMM D, YYYY") + : "", + endDateString: range.endDate + ? moment(range.endDate).format("MMM D, YYYY") + : "", + }); + }, [range]); + + /** + * Handle end date change on user input + * After user input the end date via keyboard, validate it then update the range state + * @param {Object} e Input Event. + */ + const onEndDateChange = (value) => { + const endDateString = value; + const endDate = moment(endDateString, "MMM D, YYYY", true); + const startDate = moment(rangeString.startDateString, "MMM D, YYYY", true); + + if (endDate.isValid() && isBeforeDay(endDate, startDate)) { + setErrors({ + ...errors, + endDate: "Range Error", + }); + } else if (endDate.isValid()) { + onChange({ + endDate: endDate.toDate(), + startDate: range.startDate, + }); + + setErrors({ + ...errors, + endDate: "", + }); + + setRangeString({ + ...rangeString, + endDateString: endDate.format("MMM D, YYYY"), + }); + } else if (endDateString === "") { + onChange({ + endDate: null, + startDate: range.startDate, + }); + + setErrors({ + ...errors, + endDate: "", + }); + } else { + setErrors({ + ...errors, + endDate: "Invalid End Date Format", + }); + + setRangeString({ + ...rangeString, + endDateString, + }); + } + }; + + /** + * Handle start date change on user input + * After user input the start date via keyboard, validate it then update the range state + * @param {Object} e Input Event. + */ + const onStartDateChange = (value) => { + const startDateString = value; + const startDate = moment(startDateString, "MMM D, YYYY", true); + const endDate = moment(rangeString.endDateString, "MMM D, YYYY", true); + + if ( + startDate.isValid() && + endDate.isValid() && + isAfterDay(startDate, endDate) + ) { + setErrors({ + ...errors, + startDate: "Range Error", + }); + } else if (startDate.isValid()) { + onChange({ + endDate: range.endDate, + startDate: startDate.toDate(), + }); + + setErrors({ + ...errors, + startDate: "", + }); + + setRangeString({ + ...rangeString, + startDateString: startDate.format("MMM D, YYYY"), + }); + } else if (startDateString === "") { + onChange({ + endDate: range.endDate, + startDate: null, + }); + + setErrors({ + ...errors, + startDate: "", + }); + } else { + setErrors({ + ...errors, + startDate: "Invalid Start Date Format", + }); + + setRangeString({ + ...rangeString, + startDateString, + }); + } + }; + + const onStartEndDateChange = ({ startDateString, endDateString }) => { + const startDate = moment(startDateString, "MMM D, YYYY", true); + const endDate = moment(endDateString, "MMM D, YYYY", true); + + if ( + startDate.isValid() && + endDate.isValid() && + isBeforeDay(endDate, startDate) + ) { + setErrors({ + ...errors, + endDate: "Range Error", + }); + } else if (startDate.isValid() && endDate.isValid()) { + onChange({ + endDate: endDate.toDate(), + startDate: startDate.toDate(), + }); + setErrors({ + startDate: "", + endDate: "", + }); + } else if (startDate.isValid()) { + onChange({ + endDate: null, + startDate: startDate.toDate(), + }); + setErrors({ + ...errors, + endDate: "Invalid End Date Format", + }); + } else if (endDate.isValid()) { + onChange({ + endDate: endDate.toDate(), + startDate: null, + }); + setErrors({ + ...errors, + startDate: "Invalid Start Date Format", + }); + } else if (startDateString === "" && endDateString === "") { + onChange({ + endDate: null, + startDate: null, + }); + setErrors({ + startDate: "", + endDate: "", + }); + } else if (startDateString === "") { + onChange({ + endDate: endDate.toDate(), + startDate: null, + }); + + setErrors({ + ...errors, + startDate: "", + }); + } else if (endDateString === "") { + onChange({ + endDate: null, + startDate: startDate.toDate(), + }); + + setErrors({ + ...errors, + endDate: "", + }); + } else { + onChange({ + endDate: null, + startDate: null, + }); + setErrors({ + startDate: "Invalid Start Date Format", + endDate: "Invalid End Date Format", + }); + } + }; + + /** + * Trigger to open calendar modal on calendar icon in start date input + */ + const onIconClickStartDate = () => { + const calendarIcon = document.querySelector(id); + if (calendarIcon) { + calendarIcon.blur(); + } + setFocusedRange([0, 0]); // set current focused input to start date + setActiveDate(null); + setIsComponentVisible(true); + setPreview(null); + }; + + /** + * Trigger to open calendar modal on calendar icon in end date input + */ + const onIconClickEndDate = () => { + const calendarIcon = document.querySelector(id); + if (calendarIcon) { + calendarIcon.blur(); + } + setFocusedRange([0, 1]); // set current focused input to end date + setActiveDate(null); + setIsComponentVisible(true); + setPreview(null); + }; + + const onReset = (presetRange) => { + let newStartDate; + let newEndDate; + + if (presetRange) { + newStartDate = presetRange.startDate; + newEndDate = presetRange.endDate; + } + + setFocusedRange([0, 0]); + + setErrors({ + startDate: "", + endDate: "", + }); + + setRangeString({ + startDateString: newStartDate + ? moment(newStartDate).format("MMM D, YYYY") + : "", + endDateString: newEndDate ? moment(newEndDate).format("MMM D, YYYY") : "", + }); + + onChange({ + startDate: newStartDate ? moment(newStartDate) : null, + endDate: newEndDate ? moment(newEndDate) : null, + }); + + setIsComponentVisible(false); + }; + + /** + * Event handler on date selection changes + * @param {Object} newRange nnew range that has endDate and startDate data + */ + const onDateRangePickerChange = (newRange) => { + let newEndDate = newRange.endDate; + let newStartDate = newRange.startDate; + const isUseKeyPress = focusedRange[0] !== 0; + + if (isUseKeyPress) { + setFocusedRange([0, focusedRange[1]]); + } + + let shouldCloseCalendar = true; + let shouldOpenNextCalendar = false; + + // User is active on start date calendar modal + if ( + isStartDateFocused && + (isUseKeyPress || isSameDay(newStartDate, newEndDate)) + ) { + if (range.endDate && isAfterDay(newStartDate, range.endDate)) return; + newEndDate = range.endDate; + shouldCloseCalendar = false; + shouldOpenNextCalendar = true; + setErrors({ + ...errors, + startDate: "", + }); + } else if ( + isEndDateFocused && + (isUseKeyPress || isSameDay(newEndDate, newStartDate)) + ) { + if (range.startDate && isBeforeDay(newEndDate, range.startDate)) return; + newStartDate = range.startDate; + setErrors({ + ...errors, + endDate: "", + }); + } else { + setErrors({ + startDate: "", + endDate: "", + }); + } + + // Emit the payload + + setRangeString({ + startDateString: newStartDate + ? moment(newStartDate).format("MMM D, YYYY") + : "", + endDateString: newEndDate ? moment(newEndDate).format("MMM D, YYYY") : "", + }); + + onChange({ + startDate: newStartDate, + endDate: newEndDate ? moment(newEndDate).endOf("day").toDate() : null, + }); + + if (shouldOpenNextCalendar) { + setFocusedRange([0, 1]); + } + if (shouldCloseCalendar) { + setIsComponentVisible(false); + } + }; + + /** + * Event handler on preview change + * @param {Date} date current date which user hover + */ + const onPreviewChange = (date) => { + if (!(date instanceof Date)) { + setPreview(null); + + // --- + // workaround for fixing issue 132: + // - set the active range's background to transparent color + // to prevent the calendar auto focusing on the day of today by default when no + // start date nor end date are set. + // - does not set focus on the selection range when mouse leaves. + // --- + + // setActiveDate(null); + // if (range.startDate || range.endDate) { + // setFocusedRange([0, focusedRange[1]]); + // } + return; + } + + if (isStartDateFocused && date) { + setPreview({ + startDate: date, + endDate: range.endDate || date, + }); + } else if (isEndDateFocused && date) { + setPreview({ + startDate: range.startDate || date, + endDate: date, + }); + } + + setActiveDate(date); + setFocusedRange([1, focusedRange[1]]); + }; + + /** + * Event handler for user keypress + * @param {Event} e Keyboard event + */ + const handleKeyDown = (e) => { + let currentActiveDate = activeDate; + if (!currentActiveDate) { + currentActiveDate = moment().startOf("month").toDate(); + + if (isStartDateFocused && range.startDate) { + currentActiveDate = range.startDate; + } else if (isEndDateFocused && (range.startDate || range.endDate)) { + currentActiveDate = range.endDate || range.startDate; + } + } + + switch (e.key) { + case "Down": + case "ArrowDown": + currentActiveDate = moment(currentActiveDate).add(7, "days").toDate(); + onPreviewChange(currentActiveDate); + break; + case "Up": + case "ArrowUp": + currentActiveDate = moment(currentActiveDate) + .subtract(7, "days") + .toDate(); + onPreviewChange(currentActiveDate); + break; + case "Left": + case "ArrowLeft": + currentActiveDate = moment(currentActiveDate) + .subtract(1, "days") + .toDate(); + onPreviewChange(currentActiveDate); + break; + case "Right": + case "ArrowRight": + currentActiveDate = moment(currentActiveDate).add(1, "days").toDate(); + onPreviewChange(currentActiveDate); + break; + case "Enter": + if (activeDate) { + onDateRangePickerChange({ + startDate: isStartDateFocused ? activeDate : range.startDate, + endDate: isEndDateFocused ? activeDate : range.endDate, + }); + } + break; + case "Esc": + case "Escape": + setIsComponentVisible(false); + break; + default: + return; // Quit when this doesn't handle the key event. + } + + e.preventDefault(); + }; + + /** + * User Effect for listening to keypress event + */ + useEffect(() => { + if (isComponentVisible) { + document.addEventListener("keydown", handleKeyDown, true); + } else { + document.removeEventListener("keydown", handleKeyDown, true); + } + + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + }; + }); + + /** + * Focus the calendar to the given date, + * so for example, if the user click menu for end date it will open the calendar + * and focus it to current end date + */ + const getShownDate = () => { + if (activeDate) { + return activeDate; + } + + if (isStartDateFocused) { + if (preview) return preview.startDate; + return range.startDate || moment().toDate(); + } + if (preview) return preview.endDate; + return range.endDate || moment().toDate(); + }; + + /** + * Disable the days that cannot be selected + */ + const disabledDay = (date) => { + if (isStartDateFocused) { + return range.endDate ? moment(date).isAfter(range.endDate, "day") : false; + } + return range.startDate + ? moment(date).isBefore(range.startDate, "day") + : false; + }; + + /** + * Get Date Ranges + */ + const getRanges = () => { + if (activeDate) { + return [ + { + ...range, + key: "selection", + color: "#0AB88A", + }, + { + startDate: activeDate, + endDate: activeDate, + key: "active", + color: preview ? "#D8FDD8" : "#D8FDD800", + }, + ]; + } + return [ + { + ...range, + key: "selection", + color: "#0AB88A", + }, + ]; + }; + + /** + * Check whether the preview invalid + */ + const isInvalidPreview = () => { + if (!preview) return false; + if (isStartDateFocused) { + return isAfterDay(preview.startDate, range.endDate); + } + return isBeforeDay(preview.endDate, range.startDate); + }; + + const className = `${(focusedRange[1] === 1 && styles.endDate) || ""} ${ + (range.startDate && range.endDate && styles.isRange) || "" + } ${(isInvalidPreview() && styles.isInvalidPreview) || ""} ${ + ((errors.startDate || errors.endDate) && styles.isErrorInput) || "" + }`; + + return ( +
+
+ setFocusedRange([0, 0])} + isEndDateActive={focusedRange[1] === 1 && isComponentVisible} + endDateString={rangeString.endDateString} + onEndDateChange={onEndDateChange} + onEndDateFocus={() => setFocusedRange([0, 1])} + error={errors.startDate || errors.endDate} + onClickCalendarIcon={(event) => { + event === "start" ? onIconClickStartDate() : onIconClickEndDate(); + }} + onStartEndDateChange={onStartEndDateChange} + placeholder={placeholder} + enterToSubmit={enterToSubmit} + /> +
+
+ {isComponentVisible && ( +
+ { + if (!preview) { + onReset(item.selection || item.active); + } else { + onDateRangePickerChange(item.selection || item.active); + } + }} + dateDisplayFormat="MM/dd/yyyy" + showDateDisplay={false} + staticRanges={createStaticRanges()} + inputRanges={[]} + moveRangeOnFirstSelection={false} + initialFocusedRange={[0, 1]} + showMonthArrow={false} + ranges={getRanges()} + disabledDay={disabledDay} + shownDate={getShownDate()} + preview={preview} + onPreviewChange={onPreviewChange} + /> +
+ + +
+
+ )} +
+
+ ); +} + +// It use https://www.npmjs.com/package/react-date-range internally +// Check the docs for further options + +DateRangePicker.propTypes = { + id: PropTypes.string, + range: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.string, +}; + +DateRangePicker.defaultProps = { + id: "input-date-range-calendar-icon", + placeholder: "Date range", +}; + +export default DateRangePicker; diff --git a/src/apps/earn/src/components/DateRangePicker/style.scss b/src/apps/earn/src/components/DateRangePicker/style.scss new file mode 100644 index 000000000..b00f99541 --- /dev/null +++ b/src/apps/earn/src/components/DateRangePicker/style.scss @@ -0,0 +1,463 @@ +@import "@earn/styles/variables"; +@import "@earn/styles/mixins"; + +$green: #D8FDD8; +$darkGreen: #0AB88A;; + +@mixin phone { + @media (max-width: #{$screen-sm - 1px}) { + @content; + } +} + +@mixin tablet { + @media (min-width: #{$screen-sm}) and (max-width: #{$screen-lg - 1px}) { + @content; + } +} + +.isErrorInput { + :global .rdrDateRangePickerWrapper { + margin-top: -20px !important; + } +} + +.isRange { + :global { + .rdrDay { + .rdrInRange { + background: $green !important; + } + + .rdrStartEdge, + .rdrEndEdge { + background: $green !important; + } + } + } +} + +.isInvalidPreview { + :global { + .rdrDayInPreview, + .rdrDayStartPreview, + .rdrDayEndPreview, + .rdrStartEdge.rdrEndEdge { + border: none !important; + } + } +} + +.dateRangePicker { + display: block; + position: relative; + color: $tc-black; + + :global { + @include phone { + .rdrDateRangePickerWrapper { + position: relative !important; + width: 100% !important; + flex-direction: column-reverse; + align-items: center; + justify-content: flex-end; + padding: 0 20px; + border-radius: 0 !important; + + .rdrDefinedRangesWrapper { + .rdrStaticRanges { + display: inline-flex; + flex-direction: row; + justify-content: space-around; + flex-wrap: wrap; + margin-top: 10px !important; + width: calc(100% - 40px); + + .rdrStaticRange { + width: 50%; + } + + .rdrStaticRangeLabel { + font-size: 14px; + } + + > button:hover .rdrStaticRangeLabel { + background-color: $green; + } + } + } + + .rdrDateRangeWrapper { + width: 100%; + + .rdrMonthAndYearWrapper { + padding-top: 0; + + .rdrMonthAndYearPickers select { + font-size: 16px; + } + } + + .rdrMonth { + width: 100%; + } + + .rdrDayNumber { + font-size: 15px; + } + + .rdrDayToday .rdrDayNumber span:after { + bottom: -4px; + left: calc(50% + 2px); + } + + .rdrMonths { + .rdrWeekDay { + font-size: 15px; + margin-bottom: 10px; + } + + .rdrDay { + .rdrDayStartPreview { + left: 17%; + } + + .rdrDayEndPreview { + right: 17%; + } + } + } + } + } + } + + .rdrDateRangePickerWrapper { + z-index: 15; + position: relative; + // background: $tc-white; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + overflow: hidden; + width: 455px; + + .rdrDefinedRangesWrapper { + width: 100%; + border: none; + + .rdrStaticRanges { + margin-top: 60px; + + .rdrStaticRange { + border: none; + } + + button { + color: $tc-gray-80; + + @include roboto-regular; + } + + button:hover .rdrStaticRangeLabel { + background-color: $green; + } + } + + .rdrInputRanges { + display: none; + } + } + } + + .rdrMonthAndYearPickers select { + background: url("data:image/svg+xml;utf8,") no-repeat right 8px center; + + option { + background: $white; + } + + &:hover { + background-color: transparent; + } + } + + .rdrMonths { + border-top: 1px solid $tc-gray-20; + border-bottom: 1px solid $tc-gray-20; + + .rdrWeekDay { + font-weight: bold; + color: #484848; + } + + .rdrDayDisabled { + background: transparent; + + .rdrDayInPreview, + .rdrDayStartPreview, + .rdrDayEndPreview, + .rdrStartEdge.rdrEndEdge { + filter: none !important; + -webkit-filter: none !important; + } + } + + .rdrDayPassive { + .rdrInRange, + .rdrStartEdge, + .rdrEndEdge, + .rdrSelected, + .rdrDayStartPreview, + .rdrDayInPreview, + .rdrDayEndPreview { + display: block; + } + + .rdrInRange ~ .rdrDayNumber span { + opacity: 0.5; + } + } + + .rdrDayEndOfMonth .rdrInRange, + .rdrDayEndOfMonth .rdrStartEdge, + .rdrDayEndOfWeek .rdrInRange, + .rdrDayEndOfWeek .rdrStartEdge { + right: 0; + } + + .rdrDayStartOfMonth .rdrInRange, + .rdrDayStartOfMonth .rdrEndEdge, + .rdrDayStartOfWeek .rdrInRange, + .rdrDayStartOfWeek .rdrEndEdge { + left: 0; + } + + .rdrDayStartOfWeek { + .rdrDayInPreview { + left: 0; + border-radius: 0 !important; + border-left: none; + } + } + + .rdrDayEndOfWeek { + .rdrDayInPreview { + right: 0; + border-radius: 0 !important; + border-right: none; + } + } + + .rdrDayStartOfMonth, + .rdrDayEndOfMonth { + .rdrDayInPreview { + border-radius: 0; + border-left: 0; + border-right: 0; + } + } + + .rdrDay { + margin-bottom: 6px; + + .rdrInRange { + color: transparent; + background: transparent; + + & ~ .rdrDayNumber span { + color: $tc-black; + } + } + + .rdrDayStartPreview { + border-radius: 18px 0 0 18px; + height: 36px; + left: 4px; + border-right: none; + } + + .rdrDayEndPreview { + border-radius: 0 18px 18px 0; + height: 36px; + right: 4px; + border-left: none; + } + + .rdrDayStartPreview.rdrDayEndPreview, + .rdrDayHovered { + border-radius: 50%; + width: 36px; + height: 36px; + left: 4px; + border: 1px solid $green; + } + + .rdrSelected, + .rdrInRange, + .rdrStartEdge, + .rdrEndEdge { + top: 0; + bottom: 0; + border-radius: 0; + } + + .rdrDayInPreview, + .rdrDayStartPreview, + .rdrDayEndPreview { + border-color: $green; + top: 0; + bottom: 0; + } + + .rdrDayStartPreview, + .rdrDayEndPreview { + & ~ .rdrDayNumber span { + color: $tc-black; + } + } + + .rdrStartEdge { + background: transparent; + left: 50%; + + &::after { + content: ""; + position: absolute; + top: 0; + left: -18px; + border-radius: 50%; + width: 36px; + height: 36px; + background: currentColor; + z-index: 0; + } + } + + .rdrEndEdge { + background: transparent; + right: 50%; + + &::after { + content: ""; + position: absolute; + top: 0; + right: -18.2px; + border-radius: 50%; + width: 36px; + height: 36px; + background: currentColor; + z-index: 0; + } + } + + .rdrStartEdge.rdrEndEdge ~ .rdrDayNumber span { + color: $tc-black; + } + + .rdrDayNumber { + top: 0; + bottom: 0; + } + } + + .rdrDayStartOfWeek, + .rdrDayEndOfWeek { + border-radius: 0; + } + + .rdrDayToday .rdrDayNumber span:after { + bottom: -4px; + left: calc(50% + 2px); + background: $darkGreen !important; + } + } + } +} + +.dateInputWrapper { + position: relative; + text-align: left; +} + +.calendar-container { + @include roboto-regular; + + position: absolute; + top: calc(100% + 8px); +} + +.calendar-inner-container { + position: absolute; + padding: 0 18px 0 0; + text-align: right; + border: 1px solid $tc-gray-30; + border-radius: 4px; + background: $tc-white; + z-index: 10; + + @include phone { + width: 100%; + position: fixed; + top: 0; + left: 0; + right: 20px; + bottom: 0; + z-index: 15; + padding: 187px 0 0; + border: 0; + border-radius: 0; + background: rgba(#2A2A2A, 0.6); + + @include down(320px) { + padding: 40px 0 0; + } + + .calendar-footer { + margin: 0 20px; + padding: 20px 0; + } + } + + .calendar-footer { + background: $tc-white; + } + + .calendar-button { + @include roboto-bold; + + width: 71px; + height: 24px; + line-height: 22px; + margin: 9px 4px 16px; + padding: 0; + font-size: 10.5px; + text-align: center; + background: transparent; + border: 1px solid #ccc; + border-radius: 2px; + + @include phone { + width: 79px; + height: 26px; + line-height: 27px; + font-size: 12px; + margin: 0 12px 0; + } + } +} + +@include tablet { + .calendar-container, + .calendar-inner-container { + right: 0; + } + + :global { + .rdrDateRangePickerWrapper { + .calendar-container, + .calendar-inner-container { + right: 62px; + left: auto; + } + } + } +} diff --git a/src/apps/earn/src/components/Dropdown/index.jsx b/src/apps/earn/src/components/Dropdown/index.jsx new file mode 100644 index 000000000..f4b9d2d6b --- /dev/null +++ b/src/apps/earn/src/components/Dropdown/index.jsx @@ -0,0 +1,199 @@ +/** + * Dropdown component. + */ +import React, { useState, useRef, useEffect } from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import ReactSelect, { components } from "react-select"; + +import config from "../../config"; + +import styles from "./styles.scss"; +import { styled as styledCss } from "@earn/utils"; +const styled = styledCss(styles) + +const Menu = (props) => { + return ( + + {props.children} + + ); +}; +const MenuList = (props) => { + return ( + + {props.children} + + ); +}; + +const CustomOption = (props) => { + return ( + + {props.children} + + ); +}; + +const ValueContainer = ({ children, ...props }) => ( + + {children} + +); + +const Input = (props) => { + return ( + + ); +}; + +const SingleValue = ({ children, ...props }) => ( + + {children} + +); + +const ControlComponent = (props) => ( + +); +const IndicatorSeparator = () => { + return null; +}; + +function Dropdown({ + className, + options, + label, + required, + placeholder, + onChange, + errorMsg, + searchable, + size, +}) { + const [internalOptions, setInternalOptions] = useState(options); + const selectedOption = _.find(internalOptions, { selected: true }); + const [focused, setFocused] = useState(false); + const delayedOnChange = useRef( + _.debounce((q, cb) => cb(q), config.GUIKIT.DEBOUNCE_ON_CHANGE_TIME) // eslint-disable-line no-undef + ).current; + const sizeStyle = size === "lg" ? "lgSize" : "xsSize"; + useEffect(() => { + setInternalOptions(options); + }, [options]); + return ( +
setFocused(true)} + onBlurCapture={() => setFocused(false)} + className={ + styled( + className, + 'dropdownContainer', + 'container', + sizeStyle, + selectedOption && "haveValue", + errorMsg && "haveError", + focused && "isFocused" + ) + } + > +
+ ({ + value: o.label, + label: o.label, + }))} + value={selectedOption} + components={{ + Menu, + MenuList, + Option: CustomOption, + ValueContainer, + SingleValue, + IndicatorSeparator, + Input, + Control: ControlComponent, + }} + onChange={(value) => { + if (value) { + const newOptions = internalOptions.map((o) => ({ + selected: value.label === o.label, + label: o.label, + value: o.value, + })); + setInternalOptions(newOptions); + delayedOnChange(_.cloneDeep(newOptions), onChange); + } + }} + placeholder={`${placeholder}${placeholder && required ? " *" : ""}`} + clearable={false} + /> +
+ {label ? ( + + {label} + {required ?  * : null} + + ) : null} + {errorMsg ? ( + + {errorMsg} + + ) : null} +
+ ); +} + +Dropdown.defaultProps = { + placeholder: '', + label: '', + required: false, + onChange: () => {}, + errorMsg: '', + searchable: true, + size: 'lg', +}; + +Dropdown.propTypes = { + options: PT.arrayOf( + PT.shape({ + label: PT.string, + selected: PT.bool, + }) + ).isRequired, + placeholder: PT.string, + label: PT.string, + required: PT.bool, + onChange: PT.func, + errorMsg: PT.string, + size: PT.oneOf(["xs", "lg"]), +}; + +export default Dropdown; diff --git a/src/apps/earn/src/components/Dropdown/styles.scss b/src/apps/earn/src/components/Dropdown/styles.scss new file mode 100644 index 000000000..c844913b8 --- /dev/null +++ b/src/apps/earn/src/components/Dropdown/styles.scss @@ -0,0 +1,260 @@ +@import "@earn/styles/variables"; +@import "@earn/styles/GUIKit/default"; + +.label { + @include textInputLabel; +} + +.relative { + position: relative; +} + +.errorMessage { + @include errorMessage; +} + +.iconDropdown { + position: absolute; + top: 50%; + right: 16px; + pointer-events: none; + margin-top: -4px; +} + +.container { + position: relative; + display: flex; + flex-direction: column; + padding-top: 12px; + + &.haveValue .label, + &.haveError .label, + &.isFocused .label { + display: flex; + } + + &.isFocused { + .label { + color: $gui-kit-level-2; + } + + .iconDropdown { + transform: scale(1, -1); + } + } + + &.haveError .label, + &.haveError.isFocused .label { + color: $gui-kit-level-5; + } + + :global { + // @import '~react-select/dist/react-select'; + + width: 100%; + + .Select-control { + margin: 0; + padding: 0; + border: none; + border-radius: 6px !important; + height: 52px; + outline: none !important; + box-shadow: inset 0 0 0 1px $gui-kit-gray-30 !important; + } + + .Select-input { + padding: 0; + margin: 0; + display: flex; + + input { + font-size: 16px; + padding: 0; + height: 22px; + line-height: 22px; + + &:focus { + border: none; + box-shadow: none; + } + } + } + + .Select-value { + .Select-value-label { + height: 22px; + line-height: 22px; + font-size: 16px; + color: $gui-kit-gray-90 !important; + position: absolute; + right: 0; + top: 0; + bottom: 0; + left: 15px; + height: 100%; + display: flex; + align-items: center; + margin: 0; + } + } + + .Select-placeholder, + .Select-value, + .Select-input { + padding: 0 15px !important; + height: 100% !important; + display: flex !important; + align-items: center !important; + } + + .Select-placeholder { + color: $gui-kit-gray-30; + opacity: 1; + text-transform: none; + font-size: 16px; + } + + .Select-multi-value-wrapper { + width: 100% !important; + height: 100% !important; + } + + .Select-arrow-zone { + padding-right: 15px !important; + + .Select-arrow { + background-image: none; + border: none; + width: 15px; + height: 9px; + background-size: 15px 9px; + top: 0 !important; + border-width: 0 !important; + opacity: 0; + pointer-events: none; + } + } + + .Select { + &.is-open { + .Select-arrow-zone { + .Select-arrow { + transform: scale(1, -1); + } + } + } + + &:not(.is-searchable) { + .Select-input { + display: none !important; + } + } + } + + .Select-menu-outer { + top: calc(100% + 2px) !important; + border: 1px solid $gui-kit-gray-30 !important; + border-radius: 0 !important; + max-height: 269px; + z-index: 7; + margin: 0; + + .Select-menu { + padding: 9px 0; + max-height: 269px; + + .Select-option { + padding: 0 15px !important; + font-size: 16px !important; + line-height: 30px !important; + color: $gui-kit-gray-90 !important; + background-color: transparent !important; + text-decoration: none !important; + + &.is-selected { + font-weight: bold !important; + } + + &:hover { + background-color: #229173 !important; + color: $tc-white !important; + } + } + } + } + } + + &.haveError { + :global { + .Select-control { + box-shadow: inset 0 0 0 2px $gui-kit-level-5 !important; + } + } + } + + // lg size + &.lgSize { + :global { + .Select-control { + height: 52px; + } + + .Select-input { + input { + height: 52px; + } + } + } + } + + // xs size + &.xsSize { + padding-top: 0; + + &.haveValue .label, + &.isFocused .label { + margin-top: -12px; + } + + :global { + .Select-control { + height: 40px; + } + + .Select-input { + input { + font-size: 14px; + height: 40px; + } + } + + .Select-value { + .Select-value-label { + font-size: 14px; + color: $gui-kit-gray-90 !important; + } + } + + .Select-placeholder { + font-size: 14px; + } + + .Select-menu-outer { + * { + font-size: 14px !important; + } + + .Select-menu { + .Select-option { + font-size: 14px !important; + } + } + } + } + + .errorMessage { + @include errorMessageXs; + } + } +} diff --git a/src/apps/earn/src/components/DropdownTerms/index.jsx b/src/apps/earn/src/components/DropdownTerms/index.jsx new file mode 100644 index 000000000..d47070913 --- /dev/null +++ b/src/apps/earn/src/components/DropdownTerms/index.jsx @@ -0,0 +1,214 @@ +/** + * Dropdown terms component. + */ +import React, { useState, useRef, useEffect } from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import Select from "react-select"; + +import config from "../../config"; +import iconDown from "../../assets/icons/dropdown-arrow.png"; + +import styles from "./styles.scss"; +import { styled as styledCss } from "@earn/utils"; +const styled = styledCss(styles) + +function DropdownTerms({ + terms, + placeholder, + label, + required, + onChange, + errorMsg, + addNewOptionPlaceholder, + size, +}) { + const [internalTerms, setInternalTerms] = useState(terms); + const selectedOption = _.filter(internalTerms, { selected: true }).map( + (o) => ({ + value: o.label, + label: o.label, + }) + ); + const [focused, setFocused] = useState(false); + const delayedOnChange = useRef( + _.debounce((q, cb) => cb(q), config.GUIKIT.DEBOUNCE_ON_CHANGE_TIME) // eslint-disable-line no-undef + ).current; + + const containerRef = useRef(null); + let inputFieldRef = useRef(null); + const latestPropsRef = useRef(null); + latestPropsRef.current = { addNewOptionPlaceholder }; + + useEffect(() => { + const selectInput = containerRef.current.getElementsByClassName( + "Select-input" + ); + if (selectInput && selectInput.length) { + inputFieldRef.current = selectInput[0].getElementsByTagName("input"); + inputFieldRef.current[0].placeholder = focused + ? latestPropsRef.current.addNewOptionPlaceholder + : ""; + inputFieldRef.current[0].style.border = "none"; + inputFieldRef.current[0].style.boxShadow = "none"; + selectInput[0].style.borderTop = "none"; + } + }, [focused, selectedOption]); + useEffect(() => { + setInternalTerms(terms); // eslint-disable-next-line react-hooks/exhaustive-deps + }, [terms && terms.length]); + + const CustomReactSelectRow = React.forwardRef( + ({ className, option, children, onSelect }, ref) => + children ? ( + { + event.preventDefault(); + event.stopPropagation(); + onSelect(option, event); + }} + title={option.title} + tabIndex={-1} + > + {children} + + ) : null + ); + + CustomReactSelectRow.defaultProps = { + children: null, + className: "", + onSelect: () => {}, + }; + + CustomReactSelectRow.propTypes = { + children: PT.node, + className: PT.string, + onSelect: PT.func, + option: PT.object.isRequired, + }; + + return ( +
+
+ this.setState({ editURL: this.inputURL.value })} + ref={(node) => { + this.inputURL = node; + }} + className={styled("url")} + tabIndex="0" + value={st.editURL} + /> +
+
+ Size%: + + this.setState({ size: _.clamp(this.inputSize.value, 0, 100) }) + } + ref={(node) => { + this.inputSize = node; + }} + className={styled("size")} + tabIndex="-1" + value={st.size} + /> +
+
+
+ + +
+ {st.previewURL ? ( +
+
+ {st.description} +
+ ) : null} + +
+ ); + } +} + +EditModal.defaultProps = { + description: "", + onSave: _.noop, + onCancel: _.noop, + size: 100, + src: "http://", +}; + +EditModal.propTypes = { + description: PT.string, + onSave: PT.func, + onCancel: PT.func, + size: PT.number, + src: PT.string, +}; diff --git a/src/apps/earn/src/components/Editor/Image/EditModal/style.scss b/src/apps/earn/src/components/Editor/Image/EditModal/style.scss new file mode 100644 index 000000000..c3a1aa5ac --- /dev/null +++ b/src/apps/earn/src/components/Editor/Image/EditModal/style.scss @@ -0,0 +1,65 @@ +@import "@earn/styles/mixins"; + +.buttons-container { + display: inline-block; + margin-left: 31px; + margin-top: $base-unit * 2; +} + +// Competing against some globals +.container { + @include tc-body-md; + + input { + display: inline-block; + height: $base-unit * 6; + margin: 0; + padding: 0; + margin-left: $base-unit * 1; + vertical-align: middle; + + &.size { + width: 50px; + } + + &.url { + width: 500px; + } + } + + z-index: 1000; +} + +.modalContainer { + top: 30%; + left: 50%; + transform: translate(-50%); + height: auto; + width: 720px; +} + +.modalOverlay { + pointer-events: bounding-box; +} + +.fields-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.field { + display: inline-block; +} + +.preview { + display: block; +} + +.size { + width: 50px; +} + +.url { + width: 100%; +} diff --git a/src/apps/earn/src/components/Editor/Image/Popup/index.jsx b/src/apps/earn/src/components/Editor/Image/Popup/index.jsx new file mode 100644 index 000000000..e01b842d3 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Image/Popup/index.jsx @@ -0,0 +1,70 @@ +/** + * Popup Component for Link Decorators + */ +import _ from "lodash"; +import PT from "prop-types"; +import React from "react"; + + +import EditModal from "../EditModal"; + +import styles from "./style.scss"; +import { styled as styledCss } from "@earn/utils"; +import { Button } from "~/libs/ui"; +const styled = styledCss(styles) + +export default class Popup extends React.Component { + constructor(props) { + super(props); + this.state = { + editing: props.triggerModal, + }; + } + + render() { + const { onEdit, size, src } = this.props; + const { editing } = this.state; + const renderDisplay = () => ( +
+ +
+ ); + + const renderEdit = () => ( +
+ this.setState({ editing: false })} + onSave={(newSrc, newSize) => { + this.setState({ editing: false }); + onEdit(newSrc, newSize); + }} + /> +
+ ); + + return
{editing ? renderEdit() : renderDisplay()}
; + } +} + +Popup.defaultProps = { + size: 100, + src: "http://", + onEdit: _.noop, + triggerModal: false, +}; + +Popup.propTypes = { + onEdit: PT.func, + size: PT.number, + src: PT.string, + triggerModal: PT.bool, +}; diff --git a/src/apps/earn/src/components/Editor/Image/Popup/style.scss b/src/apps/earn/src/components/Editor/Image/Popup/style.scss new file mode 100644 index 000000000..7fb680869 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Image/Popup/style.scss @@ -0,0 +1,6 @@ +@import "@earn/styles/mixins"; + +.edit { + height: $base-unit * 4; + width: $base-unit * 4; +} diff --git a/src/apps/earn/src/components/Editor/Image/index.jsx b/src/apps/earn/src/components/Editor/Image/index.jsx new file mode 100644 index 000000000..5fefc0d78 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Image/index.jsx @@ -0,0 +1,52 @@ +/** + * Draft Decorator Component + * Renders images within the draft component including a popup edit button and modal + */ +import PT from "prop-types"; + +import { Tooltip } from "~/libs/ui"; + +import Popup from "./Popup"; + +const Image = ({ children, contentState, entityKey, updateEntityData }) => { + const { description, size, src, triggerModal } = contentState + .getEntity(entityKey) + .getData(); + + const popup = ( + { + updateEntityData(entityKey, { src: newSrc, size: newSize }); + }} + size={size} + src={src} + triggerModal={triggerModal} + /> + ); + + return ( + + + {description} + + {children} + + ); +}; + +Image.propTypes = { + contentState: PT.shape().isRequired, + children: PT.node.isRequired, + entityKey: PT.string.isRequired, + updateEntityData: PT.func.isRequired, +}; + +export default Image; diff --git a/src/apps/earn/src/components/Editor/Link/Popup/index.jsx b/src/apps/earn/src/components/Editor/Link/Popup/index.jsx new file mode 100644 index 000000000..6f6f77a50 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Link/Popup/index.jsx @@ -0,0 +1,89 @@ +/** + * Popup Component for Link Decorators + */ +import _ from "lodash"; +import PT from "prop-types"; +import React from "react"; + +import styles from "./style.scss"; +import { styled as styledCss } from "@earn/utils"; +import { Button } from "~/libs/ui"; +const styled = styledCss(styles) + +export default class Popup extends React.Component { + constructor(props) { + super(props); + this.state = { + href: props.href, + editing: false, + }; + } + + handleDone() { + const { onEdit } = this.props; + const { href } = this.state; + onEdit(href); + this.setState({ editing: false }); + } + + render() { + const { editing, href } = this.state; + const renderDisplay = () => ( +
+ + {href} + + +
+ ); + + const renderEdit = () => ( +
+ this.setState({ href: this.node.value })} + onKeyUp={(e) => { + if (e.keyCode === 13) { + this.handleDone(); + } + }} + ref={(node) => { + this.node = node; + }} + /> + +
+ ); + + return ( +
+ {editing ? renderEdit() : renderDisplay()} +
+ ); + } +} + +Popup.defaultProps = { + href: "", + onEdit: _.noop, +}; + +Popup.propTypes = { + href: PT.string, + onEdit: PT.func, +}; diff --git a/src/apps/earn/src/components/Editor/Link/Popup/style.scss b/src/apps/earn/src/components/Editor/Link/Popup/style.scss new file mode 100644 index 000000000..8fb4ddd90 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Link/Popup/style.scss @@ -0,0 +1,26 @@ +@import "@earn/styles/mixins"; + +.edit { + height: $base-unit * 4; + width: $base-unit * 4; +} + +// Competing against some globals +.container { + a { + display: inline-block; + min-width: $base-unit * 24; + padding-left: $base-unit * 4; + vertical-align: middle; + } + + input[type="text"] { + display: inline-block; + height: $base-unit * 6; + margin: 0; + padding: 0; + margin-left: $base-unit * 1; + width: $base-unit * 48; + vertical-align: middle; + } +} diff --git a/src/apps/earn/src/components/Editor/Link/index.jsx b/src/apps/earn/src/components/Editor/Link/index.jsx new file mode 100644 index 000000000..d36a082cf --- /dev/null +++ b/src/apps/earn/src/components/Editor/Link/index.jsx @@ -0,0 +1,46 @@ +/** + * Draft Decorator Component + * Renders links within the draft component including a popup Tooltip to edit + */ +import PT from "prop-types"; + +import { Tooltip } from "~/libs/ui"; + +import Popup from "./Popup"; + +const Link = ({ children, contentState, entityKey, updateEntityData }) => { + const { href, triggerPopup } = contentState.getEntity(entityKey).getData(); + + const popup = ( + updateEntityData(entityKey, { href: updated })} + /> + ); + + return ( + + + + {children} + + + + ); +}; + +Link.defaultProps = { + children: null, +}; + +Link.propTypes = { + children: PT.node, + contentState: PT.shape().isRequired, + entityKey: PT.string.isRequired, + updateEntityData: PT.func.isRequired, +}; + +export default Link; diff --git a/src/apps/earn/src/components/Editor/MarkdownEditor/BlockWrapper.jsx b/src/apps/earn/src/components/Editor/MarkdownEditor/BlockWrapper.jsx new file mode 100644 index 000000000..257fa80c6 --- /dev/null +++ b/src/apps/earn/src/components/Editor/MarkdownEditor/BlockWrapper.jsx @@ -0,0 +1,37 @@ +import PT from "prop-types"; +import React from "react"; + +import { EditorBlock } from "draft-js"; + +export default function BlockWrapper(props) { + const { blockProps } = props; + const { type } = blockProps; + const types = type.split("-"); + const thisType = (types[0] || "unstyled").split(":"); + + const leadingLi = + thisType[0] === "li" && !types.slice(1).some((x) => x === "li"); + + if (thisType[0] !== "unstyled") { + const child = BlockWrapper({ + ...props, + blockProps: { + type: types.slice(1).join("-"), + }, + }); + let className = thisType[0]; + if (thisType[1]) className += ` md-syntax-level-${thisType[1]}`; + if (leadingLi) className += " leadingLi"; + if (thisType[0] === "hr") { + return
{child}
; + } + return React.createElement(thisType[0], { className }, child); + } + return ; +} + +BlockWrapper.propTypes = { + blockProps: PT.shape({ + type: PT.string.isRequired, + }).isRequired, +}; diff --git a/src/apps/earn/src/components/Editor/MarkdownEditor/index.jsx b/src/apps/earn/src/components/Editor/MarkdownEditor/index.jsx new file mode 100644 index 000000000..015d46a62 --- /dev/null +++ b/src/apps/earn/src/components/Editor/MarkdownEditor/index.jsx @@ -0,0 +1,127 @@ +/** + * Markdown editor. + */ + +import PT from "prop-types"; +import React from "react"; +import Turndown from "turndown"; + +import { ContentState, convertFromHTML, EditorState } from "draft-js"; + +import BlockWrapper from "./BlockWrapper"; +import Connector from "../Connector"; +import GenericEditor from "../GenericEditor"; +import MdUtils from "./md-utils"; + +import ImageEditor from "../Image/EditModal"; + +import styles from "./style.scss"; +import { styled as styledCss } from "../../../utils"; +const styled = styledCss(styles) + +const GUI_STATES = { + IMAGE_EDIT_MODAL: "IMAGE_EDIT_MODAL", + REGULAR: "REGULAR", +}; + +export default class MarkdownEditor extends React.Component { + constructor(props) { + super(props); + this.mdUtils = new MdUtils(); + this.state = { + editor: EditorState.createEmpty(this.mdUtils), + gui: GUI_STATES.REGULAR, + }; + this.turndown = new Turndown(); + } + + componentDidMount() { + const { initialContent } = this.props; + if (initialContent) { + let state = initialContent.replace(/\n/g, "
"); + state = convertFromHTML(state); + state = ContentState.createFromBlockArray( + state.contentBlocks, + state.entityMap + ); + state = EditorState.createWithContent(state, this.mdUtils); + this.onChange(state); + } + } + + onChange(newState) { + const { connector } = this.props; + this.mdUtils.parse(newState.getCurrentContent()); + if (connector && connector.previewer) { + connector.previewer.setVisible(true); + connector.previewer.setContent(this.mdUtils.getHtml()); + } + if (this.editor) { + const selfState = this.mdUtils.highlight(newState); + if (selfState !== newState) { + this.setState({ editor: selfState }); + } + } + } + + getHtml() { + return this.mdUtils.getHtml(); + } + + setHtml(html) { + let state = this.turndown.turndown(html); + state = state.replace(/\n/g, "
"); + state = convertFromHTML(state); + state = ContentState.createFromBlockArray( + state.contentBlocks, + state.entityMap + ); + state = EditorState.createWithContent(state, this.mdUtils); + this.onChange(state); + } + + insertImage() { + setImmediate(() => this.setState({ gui: GUI_STATES.IMAGE_EDIT_MODAL })); + } + + render() { + const { connector, id } = this.props; + const st = this.state; + return ( +
+ {st.gui === GUI_STATES.IMAGE_EDIT_MODAL ? ( + this.setState({ gui: GUI_STATES.REGULAR })} + /> + ) : null} + ({ + component: BlockWrapper, + editable: true, + props: { type: block.getType() }, + })} + connector={connector} + decorator={this.mdUtils} + editorState={st.editor} + id={id} + onChange={(state) => this.onChange(state)} + ref={(node) => { + this.editor = node; + }} + /> +
+ ); + } +} + +MarkdownEditor.defaultProps = { + connector: new Connector(), + id: null, + initialContent: null, +}; + +MarkdownEditor.propTypes = { + connector: PT.shape(), + id: PT.string, + initialContent: PT.string, +}; diff --git a/src/apps/earn/src/components/Editor/MarkdownEditor/inlineWrapperFactory.jsx b/src/apps/earn/src/components/Editor/MarkdownEditor/inlineWrapperFactory.jsx new file mode 100644 index 000000000..fe0684052 --- /dev/null +++ b/src/apps/earn/src/components/Editor/MarkdownEditor/inlineWrapperFactory.jsx @@ -0,0 +1,35 @@ +import PT from "prop-types"; +import React from "react"; + +function InlineWrapper({ children, hrefs, key }) { + if (!key || key === "text") { + return {children}; + } + if (key === "mdSyntax") { + return {children}; + } + if (key === "inlineCode") { + return {children}; + } + const keys = key.split("-"); + const child = InlineWrapper({ + children, + key: keys.slice(1).join("-"), + }); + + if (keys[0].startsWith("a:")) { + return {children}; + } + + return React.createElement(keys[0], {}, child); +} + +InlineWrapper.propTypes = { + children: PT.node.isRequired, + hrefs: PT.shape.isRequired, + key: PT.string.isRequired, +}; + +export default function inlineWrapperFactory(key, hrefs) { + return ({ children }) => InlineWrapper({ children, hrefs, key }); +} diff --git a/src/apps/earn/src/components/Editor/MarkdownEditor/md-utils.js b/src/apps/earn/src/components/Editor/MarkdownEditor/md-utils.js new file mode 100644 index 000000000..23689f398 --- /dev/null +++ b/src/apps/earn/src/components/Editor/MarkdownEditor/md-utils.js @@ -0,0 +1,456 @@ +/** + * Markdown utilities for DraftJS editor. + * + * There are plenty of Markdown-related plugins for DraftJS in Internet, but it + * looks like nobody made it right so far; thus here we go with the correct + * solution. + */ + +import _ from "lodash"; +import Markdown from "markdown-it"; + +import { EditorState, SelectionState, Modifier } from "draft-js"; + +import { List } from "immutable"; + +import shortId from "shortid"; + +import inlineWrapperFactory from "./inlineWrapperFactory"; + +import "./style.scss"; + +/** + * Counts the specified characters in the given string. + * @param {String} string + * @param {String} char + * @return {Number} + */ +function count(string, char) { + let pos = -1; + let res = 0; + for (;;) { + const c = string[(pos += 1)]; + if (c === char) res += 1; + else if (!c) return res; + } +} + +/** + * Finds position of the n-th occurance of the specified character in the given + * string, and returns it. Returns -1, if not found. + * @param {String} string + * @param {String} char + * @param {Number} n + * @return {Number} + */ +function findNth(string, char, n) { + let pos = -1; + let res = n; + for (;;) { + const c = string[(pos += 1)]; + if (c === char) res -= 1; + if (!res) return pos; + if (!c) return -1; + } +} + +/* Internal. */ +function newSelector(key, pos = 0, end, endKey) { + return SelectionState.createEmpty(key).merge({ + anchorOffset: pos, + focusKey: endKey === undefined ? key : endKey, + focusOffset: end === undefined ? pos : end, + }); +} + +/* Internal. */ +function insertChar(content, key, pos, char) { + return Modifier.insertText(content, newSelector(key, pos), char); +} + +/* Internal. */ +function mergeBlockData(content, key, data) { + return Modifier.mergeBlockData(content, newSelector(key), data); +} + +/* Internal. */ +function removeChar(content, key, pos) { + return Modifier.removeRange(content, newSelector(key, pos, pos + 1)); +} + +/* Internal. */ +function removeLastChar(content, key, pos) { + const s = newSelector(key, pos, 0, content.getKeyAfter(key)); + return Modifier.removeRange(content, s); +} + +/* Internal. */ +function splitBlock(content, key, pos) { + return Modifier.splitBlock(content, newSelector(key, pos)); +} + +/* Internal. */ +function setBlockType(content, key, type) { + const block = content.getBlockForKey(key); + if (block.getType() === type) return content; + return Modifier.setBlockType(content, newSelector(key, 0), type); +} + +export default class MdUtils { + /** + * Constructs a new MdUtils instance. + * @param {ContentState} contentState Optional. If provided, constructor will + * automatically call parse(contentState) in the end of initialization. + */ + constructor(contentState) { + this.markdown = new Markdown(); + this.markdown.disable(["table"]); + this.tokens = []; + if (contentState) this.parse(contentState); + } + + /** + * Private. + * + * Merges and/or splits DraftJS blocks to ensure that the current block + * contains exactly the specified number of lines. + * + * @param {Number} numLines + */ + alignLines(numLines) { + for (;;) { + const text = this.content.getBlockForKey(this.key).getText(); + const numLinesInBlock = 1 + count(text, "\n"); + if (numLinesInBlock < numLines) { + const nextKey = this.content.getKeyAfter(this.key); + if (!nextKey) return; + this.content = removeLastChar(this.content, this.key, text.length); + this.content = insertChar(this.content, this.key, text.length, "\n"); + if (this.selection.getAnchorKey() === nextKey) { + this.selection = this.selection.merge({ + anchorKey: this.key, + anchorOffset: this.selection.getAnchorOffset() + text.length + 1, + }); + } + if (this.selection.getFocusKey() === nextKey) { + this.selection = this.selection.merge({ + focusKey: this.key, + focusOffset: this.selection.getFocusOffset() + text.length + 1, + }); + } + } else if (numLinesInBlock > numLines) { + const splitPoint = findNth(text, "\n", numLines); + this.content = removeChar(this.content, this.key, splitPoint); + this.content = splitBlock(this.content, this.key, splitPoint); + if ( + this.selection.getAnchorKey() === this.key && + this.selection.getAnchorOffset() > splitPoint + ) { + this.selection = this.selection.merge({ + anchorKey: this.content.getKeyAfter(this.key), + anchorOffset: this.selection.getAnchorOffset() - splitPoint - 1, + }); + } + if ( + this.selection.getFocusKey() === this.key && + this.selection.getFocusOffset() > splitPoint + ) { + this.selection = this.selection.merge({ + focusKey: this.content.getKeyAfter(this.key), + focusOffset: this.selection.getFocusOffset() - splitPoint - 1, + }); + } + } else return; + } + } + + getDecorations(block) { + _.noop(this); + const res = block.getData().get("decorations") || List(); + return res.setSize(block.getLength()); + } + + getComponentForKey(key) { + _.noop(this); + return inlineWrapperFactory(key, this.hrefs); + } + + getPropsForKey() { + _.noop(this); + return {}; + } + + /** + * Highlights Markdown syntax in the given DraftJS state. + * @param {EditorState} state DraftJS EditorState that holds a plain text with + * a valid Markdown markup, previously loaded into this MdUtils instance via + * parse(..) method. + * @return {EditorState} Resulting state with the proper formatting and + * styling of the markup. + */ + highlight(state) { + this.blockTypes = []; + this.content = state.getCurrentContent(); + this.decorations = {}; + this.decoreLevel = 0; + this.endLines = []; + this.hrefs = {}; + this.key = this.content.getFirstBlock().getKey(); + this.selection = state.getSelection(); + this.styleLine = 0; + this.tokenId = 0; + this.tokens.forEach(() => this.highlightNextToken()); + while (this.key) { + this.content = setBlockType(this.content, this.key, "unstyled"); + this.highlightInline(); + this.key = this.content.getKeyAfter(this.key); + } + const res = EditorState.push(state, this.content, "custom"); + return EditorState.acceptSelection(res, this.selection); + } + + /** + * Private. + * + * Highlights inline Markdown syntax in the current DraftJS block. + */ + highlightInline(subTokens) { + let pos = 0; + const text = this.content.getBlockForKey(this.key).getText(); + const res = new Array(text.length); + res.fill("mdSyntax"); + + if (subTokens) { + const styles = []; + subTokens.forEach((st) => { + switch (st.type) { + case "link_open": { + const id = shortId().replace(/-/g, ":"); + [[, this.hrefs[id]]] = st.attrs; + styles.push(`a:${id}`); + break; + } + + case "em_open": + case "strong_open": + styles.push(st.tag); + break; + + case "s_open": + styles.push("strike"); + break; + + case "em_close": + case "link_close": + case "s_close": + case "strong_close": + styles.pop(); + break; + + case "code_inline": { + if (!st.content.length) break; + let style = styles.join("-"); + if (!style) style = "inlineCode"; + else style = `${style}-inlineCode`; + pos = text.indexOf(st.content, pos); + const end = pos + st.content.length; + while (pos < end) { + res[pos] = style; + pos += 1; + } + break; + } + + case "text": { + if (!st.content.length) break; + pos = text.indexOf(st.content, pos); + const end = pos + st.content.length; + const style = styles.join("-") || "text"; + while (pos < end) { + res[pos] = style; + pos += 1; + } + if (styles.length && _.last(styles).startsWith("a:")) { + pos += 3 + _.last(styles).slice(2).length; + } + break; + } + + default: + } + }); + } + + let i = text.length - 1; + while (i >= 0 && text[i] === " ") { + res[i] = "text"; + i -= 1; + } + while (i >= 0 && res[i] === "mdSyntax") i -= 1; + if (i < 0) res.fill("mdSyntax"); + + const decorations = List(res); + this.content = mergeBlockData(this.content, this.key, { decorations }); + } + + /** + * Private. + * + * Highlights Markdown syntax in the specified number of lines, starting from + * the first non-styled line. + * + * @param {Number} numLines + */ + highlightLines(numLines) { + this.alignLines(numLines); + const type = this.blockTypes.join("-") || "unstyled"; + this.content = setBlockType(this.content, this.key, type); + } + + /** + * Private. + * + * Highlights all Markdown syntax between the last highlighted DraftJS block + * and the current MarkdownIt token. + */ + highlightNextToken() { + const token = this.tokens[this.tokenId]; + + /* If token opens a new range, we: + * - Style any block before this token line; + * - Remember the range of this token, and the line of the first non-styled + * block. */ + if (token.map) { + const linesBefore = token.map[0] - this.styleLine; + if (linesBefore) { + this.highlightLines(linesBefore); + this.highlightInline(); + [this.styleLine] = token.map; + this.key = this.content.getKeyAfter(this.key); + } + } + + switch (token.type) { + case "blockquote_open": + case "bullet_list_open": + this.decoreLevel += 1; + this.blockTypes.push(`${token.tag}:${this.decoreLevel}`); + this.endLines.push(token.map[1]); + break; + + case "ordered_list_open": + this.decoreLevel += 2; + this.blockTypes.push(`${token.tag}:${this.decoreLevel}`); + this.endLines.push(token.map[1]); + break; + + case "heading_open": + case "list_item_open": + case "paragraph_open": + this.blockTypes.push(token.tag); + this.endLines.push(token.map[1]); + break; + + case "blockquote_close": + case "bullet_list_close": + case "heading_close": + case "list_item_close": + case "ordered_list_close": + case "paragraph_close": { + const linesBefore = _.last(this.endLines) - this.styleLine; + if (linesBefore) { + this.highlightLines(linesBefore); + this.highlightInline(); + this.key = this.content.getKeyAfter(this.key); + this.styleLine = _.last(this.endLines); + } + this.blockTypes.pop(); + this.endLines.pop(); + + switch (token.type) { + case "blockquote_close": + case "bullet_list_close": + this.decoreLevel -= 1; + break; + case "ordered_list_close": + this.decoreLevel -= 2; + break; + default: + } + + break; + } + + case "fence": { + this.blockTypes.push(token.tag); + let numLines = token.map[1] - token.map[0]; + const subTokens = [{ type: "text", content: token.content }]; + const isClosed = 1 + count(token.content, "\n") < numLines; + if (!isClosed) { + let i = 1 + this.tokenId; + while (i < this.tokens.length && !this.tokens[i].map) i += 1; + if ( + i === this.tokens.length || + this.tokens[i].map[0] > token.map[1] + ) { + numLines += 1; + } + } + this.highlightLines(numLines); + this.highlightInline(subTokens); + this.key = this.content.getKeyAfter(this.key); + [, this.styleLine] = token.map; + this.blockTypes.pop(); + break; + } + + case "code_block": + case "hr": { + this.blockTypes.push(token.tag); + this.highlightLines(token.map[1] - token.map[0]); + this.highlightInline(token.children); + this.key = this.content.getKeyAfter(this.key); + [, this.styleLine] = token.map; + this.blockTypes.pop(); + break; + } + + case "inline": + this.highlightLines(token.map[1] - token.map[0]); + this.highlightInline(token.children); + this.key = this.content.getKeyAfter(this.key); + [, this.styleLine] = token.map; + break; + + default: + } + + this.tokenId += 1; + } + + /** + * Returns HTML representation of the Markdown markup previously loaded by + * parse(..) method of MdUtils. + * @return {String} + */ + getHtml() { + if (!this.html) { + this.html = this.markdown.renderer.render(this.tokens, this.env); + } + return this.html; + } + + /** + * Parses the given DraftJS state. The state should contain a plain text with + * Markdown markup. After the parse you can call other methods of MdUtils to + * generate corresponding HTML markup, or DraftJS state for rendered Markdown + * representation, or DraftJS decorator for Markdown syntax highlighting in + * the original state. + * @param {ContentState} state + */ + parse(contentState) { + delete this.html; + this.env = {}; + this.tokens = this.markdown.parse(contentState.getPlainText(), this.env); + } +} diff --git a/src/apps/earn/src/components/Editor/MarkdownEditor/style.scss b/src/apps/earn/src/components/Editor/MarkdownEditor/style.scss new file mode 100644 index 000000000..24c5e4fe3 --- /dev/null +++ b/src/apps/earn/src/components/Editor/MarkdownEditor/style.scss @@ -0,0 +1,148 @@ +@import "@earn/styles/mixins"; + +/* Styling of Markdown syntax highlighting. */ +.container { + @include tc-body-md; + + overflow: hidden; + + a { + color: $tc-dark-blue-110; + text-decoration: underline; + } + + code { + background: $tc-gray-neutral-light; + border: 1px solid $tc-gray-20; + border-radius: 6px; + display: block; + font-family: "Roboto Mono", monospace; + padding: 15px 20px; + white-space: pre-wrap; + + &:global.inline { + background: $tc-gray-10; + border: none; + border-radius: 0; + display: inline; + padding: 0 5px; + } + } + + em { + font-style: italic; + } + strong { + font-weight: bold; + } + + /* This styling leads to some artefacts :( */ + :global .hr { + width: 100%; + + > div > span > span { + border-top: 1px solid firebrick; + display: inline-block; + height: 0; + line-height: 0; + width: 100%; + } + } + + :global { + li { + display: inline-block; + position: relative; + + &.leadingLi { + margin-top: 10px; + } + + &::before { + border-left: 3px solid $tc-gray-40; + content: ""; + height: 100%; + position: absolute; + } + } + + $offset: -15; + + @for $level from 0 to 10 { + ol.md-syntax-level-#{$level} > li::before { + left: #{$offset}px; + } + $offset: $offset + 18; + } + + $offset: -5; + + @for $level from 0 to 10 { + ul.md-syntax-level-#{$level} > li::before { + left: #{$offset}px; + } + $offset: $offset + 18; + } + + .blockquote { + position: relative; + + &::before { + border-left: 3px solid $tc-light-blue; + content: ""; + height: 100%; + position: absolute; + } + } + + $offset: -15; + + @for $level from 0 to 10 { + .blockquote.md-syntax-level-#{$level}::before { + left: #{$offset}px; + } + $offset: $offset + 18; + } + + div, + .h1, + .h2, + .h3, + .h4, + .h5, + .h6, + p { + @include tc-body-md; + + margin: 0 !important; + text-transform: none; + } + + .h1 .text { + @include tc-heading-xl; + } + .h2 .text { + @include tc-heading-lg; + } + .h3 .text { + @include tc-heading-md; + } + .h4 .text { + @include tc-heading-sm; + } + .h5 .text { + @include tc-heading-xs; + } + .h6 .text { + @include tc-heading-xs; + } + + .mdSyntax { + @include tc-body-md; + + color: firebrick; + font-family: "Roboto Mono", monospace; + font-weight: bold; + } + } +} diff --git a/src/apps/earn/src/components/Editor/MultiEditor.jsx b/src/apps/earn/src/components/Editor/MultiEditor.jsx new file mode 100644 index 000000000..d5b236874 --- /dev/null +++ b/src/apps/earn/src/components/Editor/MultiEditor.jsx @@ -0,0 +1,164 @@ +/** + * The MultiEditor component combines together WysiwygEditor and Markdown editor + * allowing to easily switch between them. + */ + +import PT from "prop-types"; +import React from "react"; +import Turndown from "turndown"; + +import { OrderedSet } from "immutable"; + +import Connector from "./Connector"; +import MarkdownEditor from "./MarkdownEditor"; +import WysiwygEditor from "."; + +export const MODES = { + MARKDOWN: "MARKDOWN", + WYSIWYG: "WYSIWYG", +}; + +export default class MultiEditor extends React.Component { + constructor(props) { + super(props); + this.fakeConnector = new Connector(); + this.fakeConnector.setToolbar(this); + this.id = props.id; + this.state = { + mode: props.initialMode, + }; + this.turndown = new Turndown(); + } + + componentDidMount() { + const { connector } = this.props; + if (connector) { + connector.addEditor(this); + this.fakeConnector.setPreviewer(connector.previewer); + } + } + + componentWillReceiveProps({ connector, id }) { + const { connector: prevConnector } = this.props; + this.id = id; + if (connector !== prevConnector) { + if (prevConnector) prevConnector.removeEditor(this); + if (connector) { + connector.addEditor(this); + this.fakeConnector.setPreviewer(connector.previewer); + } + } + } + + componentWillUnmount() { + const { connector } = this.props; + if (connector) connector.removeEditor(this); + } + + onFocusedEditorChanged(state) { + const { connector } = this.props; + if (connector) connector.setFocusedEditor(this, state); + } + + getHtml() { + return this.editor.getHtml(); + } + + setHtml(html) { + this.editor.setHtml(html); + } + + setMode(value) { + const { mode } = this.state; + if (value === mode) return; + const { connector } = this.props; + const state = this.editor.getHtml(); + this.setState({ mode: value }, () => { + this.editor.setHtml(state); + if (connector) connector.setFocusedEditor(this, this.editor.state.editor); + }); + } + + applyBlockStyle(type) { + const { mode } = this.state; + if (mode === MODES.WYSIWYG) this.editor.applyBlockStyle(type); + } + + applyColorStyle(type, color) { + const { mode } = this.state; + if (mode === MODES.WYSIWYG) { + this.editor.applyColorStyle(type, color); + } + } + + focus() { + const { mode } = this.state; + if (mode === MODES.WYSIWYG) this.editor.focus(); + } + + insertImage(src, triggerModal) { + const { mode } = this.state; + switch (mode) { + case MODES.WYSIWYG: + return this.editor.insertImage(src, triggerModal); + case MODES.MARKDOWN: + return this.editor.insertImage(); + default: + return undefined; + } + } + + insertLink(title, href, triggerPopup) { + const { mode } = this.state; + if (mode === MODES.WYSIWYG) { + this.editor.insertLink(title, href, triggerPopup); + } + } + + toggleInlineStyle(styleName) { + const { mode } = this.state; + if (mode === MODES.WYSIWYG) { + return this.editor.toggleInlineStyle(styleName); + } + return OrderedSet(); + } + + render() { + const { mode } = this.state; + switch (mode) { + case MODES.MARKDOWN: + return ( + { + if (node) this.editor = node; + }} + /> + ); + case MODES.WYSIWYG: { + return ( + { + if (node) this.editor = node; + }} + /> + ); + } + default: + throw new Error("Unknown mode"); + } + } +} + +MultiEditor.defaultProps = { + connector: null, + id: null, + initialMode: MODES.WYSIWYG, +}; + +MultiEditor.propTypes = { + connector: PT.shape(), + id: PT.string, + initialMode: PT.oneOf(Object.values(MODES)), +}; diff --git a/src/apps/earn/src/components/Editor/Previewer/index.jsx b/src/apps/earn/src/components/Editor/Previewer/index.jsx new file mode 100644 index 000000000..526885fb4 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Previewer/index.jsx @@ -0,0 +1,54 @@ +import PT from "prop-types"; +import React from "react"; + +import styles from "./style.scss"; +import { styled as styledCss } from "../../../utils"; +const styled = styledCss(styles) + +export default class Previewer extends React.Component { + constructor(props) { + super(props); + if (props.connector) props.connector.setPreviewer(this); + this.state = { + content: props.initialContent, + visible: false, + }; + } + + setContent(content) { + setImmediate(() => this.setState({ content })); + } + + setVisible(newVisible) { + const { visible } = this.state; + if (newVisible === visible) return; + setImmediate(() => this.setState({ visible: newVisible })); + } + + render() { + const { content, visible } = this.state; + return ( +
+ {visible ? ( +
+
Rendering Preview
+
+
+ ) : null} +
+ ); + } +} + +Previewer.defaultProps = { + connector: null, + initialContent: "", +}; + +Previewer.propTypes = { + connector: PT.shape(), + initialContent: PT.string, +}; diff --git a/src/apps/earn/src/components/Editor/Previewer/style.scss b/src/apps/earn/src/components/Editor/Previewer/style.scss new file mode 100644 index 000000000..d1776dade --- /dev/null +++ b/src/apps/earn/src/components/Editor/Previewer/style.scss @@ -0,0 +1,25 @@ +@import "@earn/styles/mixins"; + +.container { + background: white; + overflow: hidden; + position: relative; +} + +.content { + border: 1px solid $tc-gray-40; + height: 33vh; + margin: 0 0 10px; + padding-top: 25px; +} + +.title { + @include tc-label-xs; + + background: $tc-gray-neutral-dark; + color: $tc-gray-40; + left: 1px; + position: absolute; + padding: 5px 10px; + top: 1px; +} diff --git a/src/apps/earn/src/components/Editor/Toolbar/ColorPicker/index.jsx b/src/apps/earn/src/components/Editor/Toolbar/ColorPicker/index.jsx new file mode 100644 index 000000000..4bc508922 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Toolbar/ColorPicker/index.jsx @@ -0,0 +1,43 @@ +/** + * Component + * Implements a color picker dropdown populated with the colors found in utils/editor + */ +import _ from "lodash"; +import PT from "prop-types"; +import React from "react"; +import * as Colors from "react-color"; + +import { EDITOR_COLOR_MAP } from "../../../../utils/editor"; + +import styles from "./style.scss"; +import { styled as styledCss } from "@earn/utils"; +const styled = styledCss(styles) + +const ColorPicker = ({ onChange, style, visible }) => ( +
+ {visible ? ( + { + e.preventDefault(); + onChange(_.findKey(EDITOR_COLOR_MAP, (value) => value === hex)); + }} + className={styled("color-picker")} + /> + ) : null} +
+); + +ColorPicker.defaultProps = { + onChange: _.noop, + style: "", + visible: false, +}; + +ColorPicker.propTypes = { + onChange: PT.func, + style: PT.string, + visible: PT.bool, +}; + +export default ColorPicker; diff --git a/src/apps/earn/src/components/Editor/Toolbar/ColorPicker/style.scss b/src/apps/earn/src/components/Editor/Toolbar/ColorPicker/style.scss new file mode 100644 index 000000000..a3272ea00 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Toolbar/ColorPicker/style.scss @@ -0,0 +1,7 @@ +// The react-color component has inline styles which we need to overwrite +.color-picker { + display: inline-flex !important; + flex-wrap: nowrap !important; + position: absolute !important; + width: auto !important; +} diff --git a/src/apps/earn/src/components/Editor/Toolbar/index.jsx b/src/apps/earn/src/components/Editor/Toolbar/index.jsx new file mode 100644 index 000000000..587f11dd5 --- /dev/null +++ b/src/apps/earn/src/components/Editor/Toolbar/index.jsx @@ -0,0 +1,298 @@ +/** + * Component + * Implements a Toolbar that can control multiple components + */ +import _ from "lodash"; +import PT from "prop-types"; +import React from "react"; +import Sticky from "react-stickynode"; + +import { Button } from "~/libs/ui"; +import Select from "../../Select"; +import { EDITOR_BLOCK_STYLE_MAP } from "../../../utils/editor"; + +import { RichUtils } from "draft-js"; + +import ColorPicker from "./ColorPicker"; +import Connector from "../Connector"; +import MultiEditor, { MODES } from "../MultiEditor"; + +import styles from "./style.scss"; +import { styled as styledCss } from "../../../utils"; +const styled = styledCss(styles) + +/** + * Component class, provides a Toolbar that can control multiple Editor components + * connected to it via the Connector class + */ +export default class Toolbar extends React.Component { + constructor(props) { + super(props); + this.state = { + block: null, + editor: null, + // markdown: false, + pickingTextColor: false, + // pickingHighlightColor: false, + + BOLD: false, + ITALIC: false, + }; + } + + componentDidMount() { + const { connector } = this.props; + connector.setToolbar(this); + } + + componentWillReceiveProps({ connector: newConnector }) { + const { connector } = this.props; + const prevConnector = connector; + if (newConnector !== prevConnector) { + if (prevConnector) prevConnector.setToolbar(null); + if (newConnector) newConnector.setToolbar(this); + } + } + + componentWillUnmount() { + const { connector } = this.props; + connector.setToolbar(null); + } + + onFocusedEditorChanged(newState) { + const { connector } = this.props; + const editor = connector.focusedEditor; + if (editor) { + const inlineStyle = newState.getCurrentInlineStyle(); + const block = RichUtils.getCurrentBlockType(newState); + this.setState({ + editor, + block, + BOLD: inlineStyle.has("BOLD"), + // INLINE_CODE: inlineStyle.has('CODE'), + ITALIC: inlineStyle.has("ITALIC"), + UNDERLINE: inlineStyle.has("UNDERLINE"), + STRIKETHROUGH: inlineStyle.has("STRIKETHROUGH"), + }); + } else { + this.setState({ + block: "unstyled", + editor: null, + BOLD: false, + // INLINE_CODE: false, + ITALIC: false, + UNDERLINE: false, + STRIKETHROUGH: false, + }); + } + } + + render() { + const st = this.state; + const disableStyling = !st.editor; + const { connector, nodeId, onSave } = this.props; + + const createStyleButton = (label, name, active, className) => ( + + ); + + return ( + +
+ {connector.focusedEditor instanceof MultiEditor ? ( +
+ { + st.editor.applyBlockStyle(value); + this.setState({ block: value }); + }} + onFocus={(e) => e.preventDefault()} + options={_.map(EDITOR_BLOCK_STYLE_MAP, (label, value) => ({ + label, + value, + }))} + placeholder="Block Style" + value={st.editor ? st.block : null} + /> +
+ + {/* I guess, we gonna drop the inline Markdown option. Just for + * a case, let's keep the button code around for a bit longer. */ + /* + + */} +
+
+ ); + } +} + +Toolbar.defaultProps = { + connector: new Connector(), + onSave: _.noop, + nodeId: null, +}; + +Toolbar.propTypes = { + connector: PT.instanceOf(Connector), + onSave: PT.func, + nodeId: PT.string, +}; diff --git a/src/apps/earn/src/components/Editor/Toolbar/style.scss b/src/apps/earn/src/components/Editor/Toolbar/style.scss new file mode 100644 index 000000000..07db930cc --- /dev/null +++ b/src/apps/earn/src/components/Editor/Toolbar/style.scss @@ -0,0 +1,81 @@ +@import "@earn/styles/mixins"; + +@mixin button { + margin: 0 1px; + padding: 0 10px; +} + +.basic { + @include button; +} + +.bold { + @include button; + + font-weight: bold; +} + +.container { + background: $tc-gray-neutral-light; + padding: 10px; + width: 100%; +} + +.highlight-color-picker { + display: inline; + position: absolute; + transform: translate(-56px, 44px); +} + +.inlineCode { + @include button; + @include roboto-mono-regular; +} + +.italic { + @include button; + + font-style: italic; +} + +.select { + @include tc-label-md; +} + +.select-wrapper { + @include button; + + display: inline-block; + vertical-align: middle; + width: 250px; +} + +.save { + @include button; +} + +.separator { + display: inline-block; + height: 30px; + margin: 0 5px; + vertical-align: middle; + width: 1px; +} + +.strikethrough { + @include button; + + text-decoration: line-through; +} + +.text-color-picker { + display: inline; + position: absolute; + transform: translate(-44px, 44px); +} + +.underline { + @include button; + + text-decoration: underline; +} diff --git a/src/apps/earn/src/components/Editor/index.jsx b/src/apps/earn/src/components/Editor/index.jsx new file mode 100644 index 000000000..bf12218ea --- /dev/null +++ b/src/apps/earn/src/components/Editor/index.jsx @@ -0,0 +1,306 @@ +/** + * Content editor based on DraftJS. + * + * DraftJS is not Redux-friendly, thus our editor uses local state, unlike most + * of our code. Technically, it is possible to keep its state in Redux store, + * but it will have performance drawback, as it will demand constant conversions + * between the Redux state segment and the internal state of the editor. + */ +import _ from "lodash"; +import PT from "prop-types"; +import React from "react"; + +import { + ContentState, + convertFromHTML, + EditorState, + Modifier, + RichUtils, +} from "draft-js"; +import "draft-js/dist/Draft.css"; + +import Editor from "draft-js-plugins-editor"; +import createMarkdownShortcutsPlugin from "draft-js-markdown-shortcuts-plugin"; + +import { EDITOR_COLOR_MAP, editorStateToHTML } from "../../utils/editor"; + +import Connector from "./Connector"; +import createCustomPlugin from "./plugin"; + +import styles from "./style.scss"; + +export default class EditorWrapper extends React.Component { + constructor(props) { + super(props); + this.id = props.id; + + this.state = { + editor: EditorState.createEmpty(), + markdown: false, + }; + + // Each Editor needs its own instance of plugins + this.markdownPlugin = createMarkdownShortcutsPlugin(); + // We need to inject the EditorWrapper into the plugin so that it can + // modify state.editorState + this.customPlugin = createCustomPlugin({ + editor: this, + }); + } + + componentDidMount() { + const { connector, initialContent } = this.props; + connector.addEditor(this); + if (initialContent) { + let editorState = convertFromHTML(initialContent); + editorState = ContentState.createFromBlockArray( + editorState.contentBlocks, + editorState.entityMap + ); + editorState = EditorState.createWithContent(editorState); + this.initialContent = editorState.getCurrentContent(); + setImmediate(() => this.setState({ editor: editorState })); + } + } + + componentWillReceiveProps({ connector, id }) { + const { connector: prevConnector } = this.props; + this.id = id; + if (connector !== prevConnector) { + if (prevConnector) prevConnector.removeEditor(this); + if (connector) connector.addEditor(this); + } + } + + componentWillUnmount() { + const { connector } = this.props; + connector.removeEditor(this); + } + + getHtml() { + const { editor } = this.state; + return editorStateToHTML(editor.getCurrentContent()); + } + + setHtml(html) { + let state = convertFromHTML(html); + state = ContentState.createFromBlockArray( + state.contentBlocks, + state.entityMap + ); + state = EditorState.createWithContent(state); + setImmediate(() => this.setState({ editor: state })); + } + + /** + * Sets the block type/style at the current selection. Type map can be found in utils/editor. + * Only one block type/style can be applied, this will replace the previous. + * @param {String} type The new block style + */ + applyBlockStyle(type) { + const { editor } = this.state; + let editorState = editor; + editorState = RichUtils.toggleBlockType(editorState, type); + this.setState({ editorState }); // eslint-disable-line + } + + /** + * Sets the color at the current selection for the specified category. + * Type map can be found in utils/editor. + * @param {String} type Category, TEXT or HIGHLIGHT + * @param {String} color The new color name + */ + applyColorStyle(type, color) { + let { editor: editorState } = this.state; + let contentState = editorState.getCurrentContent(); + + const sel = editorState.getSelection(); + + // Clear any existing colors + contentState = _.reduce( + EDITOR_COLOR_MAP, + (state, value, name) => + Modifier.removeInlineStyle(state, sel, `${type}_${name}`), + contentState + ); + + editorState = EditorState.push( + editorState, + contentState, + "change-inline-style" + ); + + // Apply new color + editorState = RichUtils.toggleInlineStyle(editorState, `${type}_${color}`); + + this.setState({ editor: editorState }); + } + + focus() { + if (this.node) this.node.focus(); + } + + /** + * Inserts a new image at current cursor selection. + * @param {String} src The default src + * @param {Boolean} triggerModal Whether to trigger the img selection/resize modal on creation + */ + insertImage(src, triggerModal) { + let { editor: editorState } = this.state; + let contentState = editorState.getCurrentContent(); + + // If the user has a range selected, it needs to be collapsed before insertText will work + // This sets the starting and ending range to the same position, + // which is equivalent to just a cursor/caret + let sel = editorState.getSelection(); + const startKey = sel.getStartKey(); + const startOffset = sel.getStartOffset(); + sel = sel.merge({ + anchorKey: startKey, + anchorOffset: startOffset, + focusKey: startKey, + focusOffset: startOffset, + }); + + contentState = contentState.createEntity("IMG", "SEGMENTED", { + src, + triggerModal, + }); + const key = contentState.getLastCreatedEntityKey(); + + // Using insertText so that images behave in an inline fashion + contentState = Modifier.insertText(contentState, sel, " ", null, key); + + editorState = EditorState.push( + editorState, + contentState, + "insert-characters" + ); + + this.setState({ editor: editorState }); + } + + /** + * Inserts a new link at current cursor, or applies to selected text + * @param {String} title Default title to display for the link, if no text is selected in range + * @param {String} href The href + * @param {Boolean} triggerPopup Whether to trigger the popup on creation + */ + insertLink(title, href, triggerPopup) { + let { editor: editorState } = this.state; + let contentState = editorState.getCurrentContent(); + + const sel = editorState.getSelection(); + + contentState = contentState.createEntity("LINK", "MUTABLE", { + href, + triggerPopup, + }); + const key = contentState.getLastCreatedEntityKey(); + + // Selection is a just the cursor, insert new link + if (sel.isCollapsed()) { + // Inserts a space at the cursor, needed so that the user can 'escape' + // from the link entity by clicking after the link, or pressing right arrow + contentState = Modifier.insertText(contentState, sel, " ", null, null); + // Because selection hasn't been updated, this will insert the link *before* + // the newly created space. + contentState = Modifier.insertText(contentState, sel, title, null, key); + + editorState = EditorState.push( + editorState, + contentState, + "insert-characters" + ); + } else { + // Selection is a range, keep the text but make it a link + editorState = RichUtils.toggleLink(editorState, sel, key); + } + + this.setState({ editor: editorState }); + } + + /** + * Toggle an inline text style on/off + * @param {String} styleName Name of the style + * @return {String} The resulting style of the selection + */ + toggleInlineStyle(styleName) { + const { editor } = this.state; + const editorState = RichUtils.toggleInlineStyle(editor, styleName); + this.setState({ editor: editorState }); + return editorState.getCurrentInlineStyle(); + } + + render() { + const { connector, theme } = this.props; + + const st = this.state; + + let containerStyles = styles.container; + if (st.editor.getSelection().getHasFocus()) { + containerStyles += ` ${styles.focused}`; + } + if (theme.container) { + containerStyles += ` ${theme.container}`; + } + + return ( +
this.focus()} + onKeyPress={() => this.focus()} + onFocus={() => this.focus()} + role="button" + tabIndex={0} + > + { + const editorState = RichUtils.handleKeyCommand(state, command); + if (editorState) { + connector.setFocusedEditor(this, editorState); + this.setState({ editor: editorState }); + return true; + } + return false; + }} + onChange={(newState) => { + const p = _.get(this, "props.connector.previewer"); + if (p) p.setVisible(false); + + const hasFocus = newState.getSelection().getHasFocus(); + if ( + !connector.modified && + this.initialContent && + this.initialContent !== newState.getCurrentContent() + ) { + connector.modified = true; + } + connector.setFocusedEditor(hasFocus ? this : null, newState); + this.setState({ editor: newState }); + }} + plugins={[st.markdown ? this.markdownPlugin : {}, this.customPlugin]} + ref={(node) => { + this.node = node; + }} + spellCheck + /> +
+ ); + } +} + +EditorWrapper.defaultProps = { + connector: new Connector(), + id: null, + initialContent: null, + theme: {}, +}; + +EditorWrapper.propTypes = { + connector: PT.instanceOf(Connector), + id: PT.string, + initialContent: PT.string, + theme: PT.shape(), +}; diff --git a/src/apps/earn/src/components/Editor/plugin.jsx b/src/apps/earn/src/components/Editor/plugin.jsx new file mode 100644 index 000000000..2941806cd --- /dev/null +++ b/src/apps/earn/src/components/Editor/plugin.jsx @@ -0,0 +1,102 @@ +/** + * Custom DraftJS plugin. + * + * Facilitates the extra functionality required for component. + * Including: Images, Links, and the Note block. + * + * In the future, it should also be possible to validate allowed block elements here, + * and also substitute them for the nearest available option when HTML is rendered. + */ +import _ from "lodash"; +import React from "react"; +import { Map } from "immutable"; +import { EditorState } from "draft-js"; +import { EDITOR_COLOR_MAP } from "../../utils/editor"; + +import Image from "./Image"; +import Link from "./Link"; + +/** + * This is based on the strategy that draft-js-markdown-shortcuts-plugin uses + * so it will work on images and links created with markdown + * + * @param {String} type The block type to create the strategy for, ex. 'IMG', 'LINK' + * @return {Function} The strategy function + */ +const createStrategy = (type) => (contentBlock, callback, contentState) => { + contentBlock.findEntityRanges((metadata) => { + const key = metadata.getEntity(); + return key !== null && contentState.getEntity(key).getType() === type; + }, callback); +}; + +/** + * Creates a custom plugin instance + * + * @param {Object} config Config object, standard interface for Draft JS plugins + * @return {Object} Object representing the Custom Plugin instance, is passed to the + */ +export default ({ editor }) => { + // Store the editor in the closure + const updateEntityData = (key, data) => { + let editorState = editor.state.editor; + + editorState = EditorState.push( + editorState, + editorState.getCurrentContent().replaceEntityData(key, data), + "change-block-data" + ); + + // Force re-render for new data + editorState = EditorState.forceSelection( + editorState, + editorState.getSelection() + ); + + editor.setState({ editorState }); + }; + + const inlineStyles = {}; + + _.forIn(EDITOR_COLOR_MAP, (value, name) => { + inlineStyles[`TEXT_${name}`] = { color: value }; + inlineStyles[`HIGHLIGHT_${name}`] = { background: value }; + }); + + return { + // Provides custom html element rendering for block types + blockRenderMap: Map({ + // draft-js and draft-js-markdown-shortcuts-plugin use inconsistent rendering of + // code-blocks, so we override both of them + "code-block": { + element: "span", + wrapper: , + }, + note: { + element: "span", + wrapper:
, + }, + }), + // Provides custom styling for inline elements (mainly text) + customStyleMap: { + CODE: { + background: "#fafafb", + fontFamily: '"Roboto Mono", monospace', + }, + ...inlineStyles, + }, + // Provides custom component rendering for images and links + decorators: [ + { + strategy: createStrategy("LINK"), + component: Link, + props: { updateEntityData }, + }, + { + strategy: createStrategy("IMG"), + component: Image, + props: { updateEntityData }, + }, + ], + }; +}; diff --git a/src/apps/earn/src/components/Editor/style.scss b/src/apps/earn/src/components/Editor/style.scss new file mode 100644 index 000000000..d3dea4cb3 --- /dev/null +++ b/src/apps/earn/src/components/Editor/style.scss @@ -0,0 +1,71 @@ +@import "@earn/styles/mixins"; + +:global { + // Has a default z-index of 0 which interferes with component + .DraftEditor-editorContainer { + z-index: auto; + } + + // Draft.js sets some list-related globals that need to be undone, + // and these can't be changed using the blockStyleFn mechanism + .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listLTR::before { + width: auto; + position: relative; + left: 0; + } + + .public-DraftStyleDefault-depth0.public-DraftStyleDefault-listLTR { + margin-left: 0; + } +} + +.container { + @include tc-body-md; + + border: 1px solid $tc-gray-10; + margin: 10px 0; + padding: 10px; + + &:hover { + border: 1px solid $tc-gray-40; + } + + // Draft.js will render code-blocks like
+ // This will prevent the code/pre styles from being applied twice + pre { + code { + background: none; + border: 0; + border-radius: none; + margin: 0; + padding: 0; + } + } +} + +.focused { + border: 1px solid $tc-dark-blue; + + &:hover { + border: 1px solid $tc-dark-blue-110; + } +} + +.note { + @include tc-body-sm; + + background: $tc-yellow-30; + border: 1px solid $tc-yellow-70; + border-radius: 6px; + font-style: italic; + color: $tc-black; + line-height: 20px; + padding: 15px 20px; + margin: 25px 0; + + a, + p, + ul { + font-size: 13px; + } +} diff --git a/src/apps/earn/src/components/ErrorMessage/index.jsx b/src/apps/earn/src/components/ErrorMessage/index.jsx new file mode 100644 index 000000000..6c1eddaba --- /dev/null +++ b/src/apps/earn/src/components/ErrorMessage/index.jsx @@ -0,0 +1,58 @@ +import { useEffect } from "react"; +import PT from "prop-types"; +import { BaseModal, Button } from "~/libs/ui"; + +const ErrorMessage = ({ title, details, onOk }) => { + useEffect(() => { + document.body.classList.add("scrolling-disabled-by-modal"); + + return () => { + document.body.classList.remove("scrolling-disabled-by-modal"); + }; + }, []); + + return ( + { + e.preventDefault(); + onOk(); + }} + > + OK + + )} + > +
+ + ); +}; + +ErrorMessage.defaultProps = { + details: "", +}; + +ErrorMessage.propTypes = { + title: PT.string.isRequired, + details: PT.string, + onOk: PT.func.isRequired, +}; + +export default ErrorMessage; diff --git a/src/apps/earn/src/components/ErrorMessage/styles.scss b/src/apps/earn/src/components/ErrorMessage/styles.scss new file mode 100644 index 000000000..5c7328798 --- /dev/null +++ b/src/apps/earn/src/components/ErrorMessage/styles.scss @@ -0,0 +1,49 @@ +@import '@earn/styles/variables'; +@import '@earn/styles/mixins'; + +$sm-space-10: $base-unit * 2; +$sm-space-15: $base-unit * 3; +$sm-space-25: $base-unit * 5; +$sm-space-40: $base-unit * 8; +$button-space-32: 6 * $base-unit; + +.container { + @include roboto-regular; + + overflow: hidden; + padding: 8 * $base-unit; + text-align: center; + + @include xs-to-sm { + padding: 40px 10px; + } +} + +.details { + font-weight: 400; + font-size: 13px; + color: $tc-gray-60; + line-height: $sm-space-25; + padding: 0 $sm-space-15; + margin-bottom: $sm-space-10; + text-align: justify; + + a { + color: $tc-dark-blue; + text-decoration: underline; + } +} + +.title { + color: $tc-red; + font-size: 15px; + font-weight: bold; + line-height: $sm-space-25; + margin-bottom: $sm-space-10; + padding: 0 $sm-space-15; + + .id { + color: #000; + font-weight: 500; + } +} diff --git a/src/apps/earn/src/components/GigsRadioButton/index.jsx b/src/apps/earn/src/components/GigsRadioButton/index.jsx new file mode 100644 index 000000000..e7997d2fe --- /dev/null +++ b/src/apps/earn/src/components/GigsRadioButton/index.jsx @@ -0,0 +1,85 @@ +/** + * Radio button component. + */ +import { Fragment, useEffect, useRef, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import _ from "lodash"; +import styles from "./styles.scss"; +import config from "../../config"; + +function RadioButton({ + className, + layout = "", + options, + onChange, + size, + errorMsg, +}) { + const [internalOptions, setInternalOptions] = useState(options); + const optionsWithKey = internalOptions.map((o, oIndex) => ({ + ...o, + key: oIndex, + })); + let sizeStyle = size === "lg" ? "lgSize" : null; + if (!sizeStyle) { + sizeStyle = size === "xs" ? "xsSize" : "smSize"; + } + const delayedOnChange = useRef( + _.debounce((q, cb) => cb(q), config.GUIKIT.DEBOUNCE_ON_CHANGE_TIME) // eslint-disable-line no-undef + ).current; + + useEffect(() => { + setInternalOptions(options); + }, [options]); + + return ( + +
+ {optionsWithKey.map((o) => ( +
+ +
+ ))} +
+ {errorMsg ? {errorMsg} : null} +
+ ); +} + +RadioButton.defaultProps = { + onChange: () => {}, + size: "sm", + errorMsg: "", +}; + +RadioButton.propTypes = { + options: PT.arrayOf( + PT.shape({ + label: PT.string, + value: PT.bool.isRequired, + }) + ).isRequired, + onChange: PT.func, + size: PT.oneOf(["xs", "sm", "lg"]), + errorMsg: PT.string, +}; + +export default RadioButton; diff --git a/src/apps/earn/src/components/GigsRadioButton/styles.scss b/src/apps/earn/src/components/GigsRadioButton/styles.scss new file mode 100644 index 000000000..9d1daa675 --- /dev/null +++ b/src/apps/earn/src/components/GigsRadioButton/styles.scss @@ -0,0 +1,178 @@ +@import "../../styles/variables"; +@import '../../styles/GUIKit/default'; + +/* Create a custom radio button */ +.checkmark { + position: absolute; + top: 0; + left: 0; + background-color: $tc-white; + border-radius: 50%; + border: 1px solid $gui-kit-gray-30; + + /* Create the indicator (the dot/circle - hidden when not checked) */ + &::after { + content: ''; + position: absolute; + display: none; + top: 50%; + left: 50%; + margin-top: -6px; + margin-left: -6px; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: $tc-white; + box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.35); + } + + &.hasError { + border: 2px solid $gui-kit-level-5; + } +} + +.radioButton { + display: flex; + align-items: center; +} + +.label { + font-size: 14px; + cursor: pointer; +} + +/* The container */ +.container { + display: block; + position: relative; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + color: $gui-kit-gray-90; + + /* Hide the browser's default radio button */ + input { + position: absolute; + opacity: 0; + cursor: pointer; + + /* When the radio button is checked, add a blue background */ + &:checked ~ .checkmark { + background-color: $gui-kit-level-2; + box-shadow: inset 0 1px 2px 0 rgba(0, 0, 0, 0.29); + border: none; + + /* Show the indicator (dot/circle) when checked */ + &::after { + display: block; + } + } + } +} + +.radioButtonContainer { + display: flex; + flex-direction: column; + + &.horizontal { + flex-direction: row; + + .radioButton { + margin-right: 28px; + + &:last-child { + margin-right: 0; + } + } + } + + .label { + color: $gui-kit-gray-90; + } + + // lg size + &.lgSize { + .container { + padding-left: 24px; + line-height: 24px; + height: 24px; + + .checkmark { + height: 24px; + width: 24px; + + &::after { + margin-top: -6px; + margin-left: -6px; + width: 12px; + height: 12px; + } + } + } + + .label { + margin-left: 8px; + } + } + + // sm size + &.smSize { + .container { + padding-left: 20px; + line-height: 20px; + height: 20px; + + .checkmark { + height: 20px; + width: 20px; + + &::after { + margin-top: -5px; + margin-left: -5px; + width: 10px; + height: 10px; + } + } + } + + .label { + margin-left: 8px; + } + } + + // xs size + &.xsSize { + .container { + padding-left: 15px; + line-height: 15px; + height: 15px; + + .checkmark { + height: 16px; + width: 16px; + + &::after { + margin-top: -4px; + margin-left: -4px; + width: 8px; + height: 8px; + } + } + } + + .label { + margin-left: 8px; + } + } +} + +.errorMessage { + display: block; + + @include errorMessage; + + color: #ef476f; + margin-left: 0; +} diff --git a/src/apps/earn/src/components/LoginModal/index.jsx b/src/apps/earn/src/components/LoginModal/index.jsx new file mode 100644 index 000000000..6c6557bd0 --- /dev/null +++ b/src/apps/earn/src/components/LoginModal/index.jsx @@ -0,0 +1,82 @@ +import { useCallback } from "react"; +import MediaQuery from "react-responsive"; +import PT from "prop-types"; +import cn from "classnames"; + +import { Button } from "~/libs/ui"; + +import blobYellow from "../../assets/images/blob-yellow.svg"; +import blobPurple from "../../assets/images/blob-purple.svg"; +import progressBar from "../../assets/images/progress-bar.svg"; +import progressBarMid from "../../assets/images/progress-bar-mid.svg"; +import ProgressBarXS from "../../assets/images/progress-bar-mobile.svg"; +import thinkingFaceMobile from "../../assets/images/thinking-face-mobile.svg"; +import thinkingFace from "../../assets/images/thinking-face-laptop-tablet.svg"; +import { makeLoginUrl, makeRegisterUrl } from "../../utils/url"; +import modalStyles from "../../styles/_modal.scss"; + +import styles from "./styles.scss"; +import BaseModal from "../../../../../libs/ui/lib/components/modals/base-modal/BaseModal"; + +function LoginModal({ onClose, open }) { + const onClickBtnRegister = useCallback(() => { + window.open(makeRegisterUrl(window.location.href)); + }, []); + + return ( + +
+ + +

YAY! You are almost done!

+

+ Looks like you're not a Topcoder member yet. Or maybe you're + not logged in? + + + + + + + It's quick to register and it's free! +

+ + + + + + + + + +
+ +
+

+ Already a member? Login here +

+
+
+ ); +} + +LoginModal.defaultProps = { + utmSource: "gig_listing", +}; + +LoginModal.propTypes = { + utmSource: PT.string, +}; + +export default LoginModal; diff --git a/src/apps/earn/src/components/LoginModal/styles.scss b/src/apps/earn/src/components/LoginModal/styles.scss new file mode 100644 index 000000000..9e0533fad --- /dev/null +++ b/src/apps/earn/src/components/LoginModal/styles.scss @@ -0,0 +1,156 @@ +@import "../../styles/mixins"; + +.title { + position: relative; + color: #9d41c9; + @include barlow-condensed-medium; + font-size: 60px; + line-height: 58px; + text-transform: uppercase; + margin: 0; + margin-bottom: 30px; + + @include xs-to-sm { + max-width: 400px; + margin: auto; + font-size: 60px !important; + line-height: 58px !important; + margin-bottom: 30px; + } + + @media (max-width: 425px) { + max-width: 230px; + margin: auto; + font-size: 36px !important; + line-height: 34px !important; + margin-bottom: 30px; + } +} + +.loginMsg { + color: #2a2a2a; + font-size: 24px; + line-height: 36px; + margin-bottom: 40px; + + img { + display: inline; + } + + @media (max-width: 425px) { + text-align: left !important; + font-size: 20px; + line-height: 30px; + margin-bottom: 30px; + } +} + +.controls { + display: flex; + align-content: center; + justify-content: center; + margin: 0; + + & > button:first-child { + margin-right: 10px !important; + } + + & > a:first-child { + margin-right: 10px !important; + } +} + +.referrals { + display: flex; + overflow: auto; + + .sucessMsg { + font-size: 24px; + line-height: 36px; + margin-bottom: 40px; + } + + .rightAlign { + justify-content: flex-end; + } +} + +.loginRequired { + display: flex; + flex-direction: column; + padding: 80px 55px 40px 60px; + text-align: center; + position: relative; + overflow: hidden; + + @include xs-to-sm { + padding: 50px 35px 40px; + } + + @media (max-width: 425px) { + padding: 50px 35px 70px; + } + + .progressBar { + display: block; + width: 100%; + max-width: 100%; + + @include phone-only { + margin-bottom: 40px; + } + } + + .blobYellow, + .blobPurple { + display: block; + position: absolute; + max-width: 100%; + } + + .blobYellow { + top: 0; + right: 0; + + @media (max-width: 425px) { + max-width: 62px; + } + } + + .blobPurple { + bottom: -5px; + left: 0; + + @media (max-width: 425px) { + max-width: 84px; + bottom: -40px; + } + } + + .thinkingFace { + width: 23px; + margin: 0 5px; + transform: translateY(4px); + + @media (max-width: 425px) { + width: 21px; + transform: translateY(2px); + } + } + + .regTxt { + position: relative; + font-size: 16px; + margin: 30px 0 0; + + a { + color: #0D61BF; + text-decoration: underline; + } + } +} + +.overlay { + background-color: #2a2a2a; + opacity: 0.95; +} diff --git a/src/apps/earn/src/components/Menu/index.jsx b/src/apps/earn/src/components/Menu/index.jsx new file mode 100644 index 000000000..60fca660b --- /dev/null +++ b/src/apps/earn/src/components/Menu/index.jsx @@ -0,0 +1,182 @@ +import React, { useRef, useEffect } from "react"; +import { navigate } from "react-router-dom"; +import PT from "prop-types"; +import _ from "lodash"; + +import IconChevronUp from "../../assets/icons/menu-chevron-up.svg"; +import { MenuSelection, getMenuIcon } from "../../utils"; + +import styles from "./styles.scss"; +import { styled as styledCss } from "@earn/utils"; +const styled = styledCss(styles) + +const Menu = ({ menu, selected, onSelect, isLoggedIn, onUpdateMenu }) => { + const selectionRef = useRef(); + if (!selectionRef.current) { + selectionRef.current = new MenuSelection( + _.cloneDeep(menu), + selected, + onSelect, + onUpdateMenu + ); + } + + useEffect(() => { + selectionRef.current.setMenu(menu); + }, [menu]); + + useEffect(() => { + selectionRef.current.select(selected); + }, [selected]); + + // useEffect(() => { + // if (selectionRef.current.isAuth(selected) && isLoggedIn === false) { + // utils.auth.logIn(); + // } + // }, [selected, isLoggedIn]); + + const onSelectMenuItem = (name, path) => { + selectionRef.current.select(name); + if (path) { + navigate(path); + } + }; + + const getIcon = (menuItem, active) => { + const name = active ? menuItem.iconActive : menuItem.icon; + return getMenuIcon(name); + }; + + const isExpandable = (menuItem) => + selectionRef.current.isExpandable(menuItem); + const isSelected = (menuItem) => selectionRef.current.isSelected(menuItem); + const isExpanded = (menuItem) => selectionRef.current.isExpanded(menuItem); + const isActive = (menuItem) => selectionRef.current.isActive(menuItem); + + const renderSubSubmenu = (subMenuItem) => { + return ( +
    + {subMenuItem.children.map((subSubmenuItem) => ( +
  • + { + onSelectMenuItem(subSubmenuItem.name, subSubmenuItem.path); + }} + > + {subSubmenuItem.name} + +
  • + ))} +
+ ); + }; + + const renderSubmenu = (menuItem) => { + if (!menuItem.children) { + return null; + } + + return ( +
    + {menuItem.children.map((subMenuItem) => ( +
  • + { + onSelectMenuItem( + subMenuItem.name, + isExpandable(subMenuItem) ? null : subMenuItem.path + ); + }} + > + {subMenuItem.name} + + {isExpandable(subMenuItem) && renderSubSubmenu(subMenuItem)} +
  • + ))} +
+ ); + }; + + return ( + + ); +}; + +Menu.propTypes = { + menu: PT.shape(), + selected: PT.string, + onSelect: PT.func, + isLoggedIn: PT.oneOf([null, true, false]), +}; + +export default Menu; diff --git a/src/apps/earn/src/components/Menu/styles.scss b/src/apps/earn/src/components/Menu/styles.scss new file mode 100644 index 000000000..3782a14a4 --- /dev/null +++ b/src/apps/earn/src/components/Menu/styles.scss @@ -0,0 +1,102 @@ +@import "@earn/styles/variables"; + +$menu-padding-x: 4 * $base-unit; +$menu-padding-y: 20px; + +.menu { + padding: $menu-padding-y $menu-padding-x (3 * $base-unit); + + &.logged-in {} + + &.logged-out { + .menu-item-auth { + display: none; + } + } +} + +.menu-item { + padding: 4px 0; + cursor: pointer; + + .link { + display: flex; + align-items: center; + line-height: 26px; + outline: none; + } + + .icon { + width: 24px; + height: 24px; + margin-right: 16px; + text-align: left; + } + + .text {} + + .arrow { + width: 21px; + height: 21px; + margin-left: 8px; + line-height: 1; + text-align: center; + vertical-align: middle; + + &.up {} + &.down { + transform: rotate(180deg); + } + } + + &.active > .link { + font-weight: 500; + } + + &.selected > .link { + color: $tc-turquoise-dark1; + } +} + +.menu-item-main > .link { + margin-left: -20px; + margin-right: -20px; + padding-left: 20px; + padding-right: 20px; +} + +.menu-item-main.active > .link { + box-shadow: inset 4px 0 $tc-turquoise; +} + +.menu-item-main > .link + ul, +.menu-item-secondary > .link + ul { + display: none; +} + +.menu-item-main.expanded > .link + ul, +.menu-item-secondary.expanded > .link + ul { + display: block; + cursor: default; +} + +.menu-item-secondary.active.collapsed { + color: $tc-turquoise-dark1; +} + +.sub-menu { + padding-left: 24px + 16px; + + .menu-item { + cursor: default; + .link { + cursor: pointer; + display: inline-block; + } + } + +} + +.sub-submenu { + padding-left: 20px; +} diff --git a/src/apps/earn/src/components/MetaTags/index.jsx b/src/apps/earn/src/components/MetaTags/index.jsx new file mode 100644 index 000000000..fd60501f3 --- /dev/null +++ b/src/apps/earn/src/components/MetaTags/index.jsx @@ -0,0 +1,74 @@ +/** + * Auxiliary wrapper around React Helmet that helps to generate meta tags for + * generic use cases. + * + * NOTE: This component relies on `domain` path of Redux store to hold + * the current app domain, which will serve as the base path for the bundled + * images. + */ + +import React from "react"; +import PT from "prop-types"; +import { connect } from "react-redux"; +import { Helmet } from "react-helmet"; + +function MetaTags({ + description, + domain, + image, + siteName, + socialDescription, + socialTitle, + title, + url, +}) { + const img = `${domain}${image}`; + const socTitle = socialTitle || title; + const socDesc = socialDescription || description; + return ( + + {/* General tags. */} + {title} + + + {/* Twitter cards. */} + + + + {image ? : null} + {siteName ? : null} + + {/* Open Graph data. */} + + {image ? : null} + {image ? : null} + + {siteName ? : null} + {url ? : null} + + ); +} + +MetaTags.defaultProps = { + image: null, + siteName: null, + socialDescription: null, + socialTitle: null, + url: null, +}; + +MetaTags.propTypes = { + description: PT.string.isRequired, + domain: PT.string, + image: PT.string, + siteName: PT.string, + socialDescription: PT.string, + socialTitle: PT.string, + title: PT.string.isRequired, + url: PT.string, +}; + +/* TODO: It is not good to depend on the domain written into redux state here, + * better pass it via the renderer context at the server side, and get it from + * the location at the frontend side, or something similar? */ +export default connect((state) => ({ domain: state.domain }))(MetaTags); diff --git a/src/apps/earn/src/components/MultiSelect/index.jsx b/src/apps/earn/src/components/MultiSelect/index.jsx new file mode 100644 index 000000000..161147612 --- /dev/null +++ b/src/apps/earn/src/components/MultiSelect/index.jsx @@ -0,0 +1,237 @@ +//import "react-select/dist/react-select.css"; +import styles from './styles.scss'; +import React, { useState } from 'react'; +import PT from 'prop-types'; +import Select, { components } from 'react-select'; +import cn from 'classnames'; +import iconDown from '../../assets/icons/dropdown-arrow.png'; + +const Arrow = () => ( + icon down +); + +const Menu = (props) => { + return ( + + {props.children} + + ); +}; +const MenuList = (props) => { + return ( + + {props.children} + + ); +}; + +const CustomOption = (props) => { + return ( + + {props.children} + + ); +}; + +const ValueContainer = ({ children, ...props }) => ( + + {children} + +); + +const ControlComponent = (props) => { + return ( + + ); +}; +const IndicatorsContainer = (props) => { + return ( + + ); +}; +const SelectContainer = (props) => { + return ( + + ); +}; + +const MultiValueContainer = (props) => { + return ( + + ); +}; + +const MultiValueLabel = (props) => { + return ( + + ); +}; + +const MultiValueRemove = ({ children, ...props }) => ( + + {children} + +); + +const Input = (props) => { + return ( + + ); +}; + +const Placeholder = (props) => { + return ( + + ); +}; + +const IndicatorSeparator = () => { + return null; +}; +const DropdownIndicator = () => { + return null; +}; + +/** + * Displays a multi-select field. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const MultiSelect = ({ + className, + clearable, + label, + isRequired = false, + onChange, + onFocus, + options, + optLabelKey, + optValueKey, + placeholder, + showArrow = false, + size, + value, + error, +}) => { + const [focused, setFocused] = useState(false); + return ( + <> +
setFocused(true)} + onBlurCapture={() => setFocused(false)} + className={[ + styles.container, + styles[size], + className, + !!error ? styles['hasError'] : '', + ].join(' ')} + > + {label && ( + + {label + (isRequired ? ' *' : '')} + + )} + { + const newOptions = optionsWithKey.map((oWithKeyTmp) => ({ + label: oWithKeyTmp.label, + value: o.key === oWithKeyTmp.key, + })); + setInternalOptions(newOptions); + delayedOnChange(_.cloneDeep(newOptions), onChange); + }} + /> + + {o.label ? {o.label} : null} + +
+ ))} +
+ {errorMsg ? {errorMsg} : null} + + ); +} + +RadioButton.defaultProps = { + onChange: () => {}, + size: "sm", + errorMsg: "", +}; + +RadioButton.propTypes = { + options: PT.arrayOf( + PT.shape({ + label: PT.string, + value: PT.bool.isRequired, + }) + ).isRequired, + onChange: PT.func, + size: PT.oneOf(["xs", "sm", "lg"]), + errorMsg: PT.string, +}; + +export default RadioButton; diff --git a/src/apps/earn/src/components/RadioButton/styles.module.scss b/src/apps/earn/src/components/RadioButton/styles.module.scss new file mode 100644 index 000000000..0f8fa0bd5 --- /dev/null +++ b/src/apps/earn/src/components/RadioButton/styles.module.scss @@ -0,0 +1,166 @@ +@import "@earn/styles/variables"; +@import '@earn/styles/GUIKit/default'; + +/* Create a custom radio button */ +.checkmark { + position: absolute; + top: 0; + left: 0; + background-color: $tc-white; + border-radius: 50%; + border: 1px solid $gui-kit-gray-30; + + /* Create the indicator (the dot/circle - hidden when not checked) */ + &::after { + content: ''; + position: absolute; + display: none; + top: 50%; + left: 50%; + margin-top: -6px; + margin-left: -6px; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: $tc-white; + box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.35); + } + + &.hasError { + border: 2px solid $gui-kit-level-5; + } +} + +.radioButton { + display: flex; + align-items: center; +} + +.label { + font-size: 14px; + cursor: pointer; +} + +/* The container */ +.container { + display: block; + position: relative; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + color: $gui-kit-gray-90; + + /* Hide the browser's default radio button */ + input { + position: absolute; + opacity: 0; + cursor: pointer; + + /* When the radio button is checked, add a blue background */ + &:checked ~ .checkmark { + background-color: $gui-kit-level-2; + box-shadow: inset 0 1px 2px 0 rgba(0, 0, 0, 0.29); + border: none; + + /* Show the indicator (dot/circle) when checked */ + &::after { + display: block; + } + } + } +} + +.radioButtonContainer { + display: flex; + flex-direction: column; + + .label { + color: $gui-kit-gray-90; + } + + // lg size + &.lgSize { + .container { + padding-left: 24px; + line-height: 24px; + height: 24px; + + .checkmark { + height: 24px; + width: 24px; + + &::after { + margin-top: -6px; + margin-left: -6px; + width: 12px; + height: 12px; + } + } + } + + .label { + margin-left: 8px; + } + } + + // sm size + &.smSize { + .container { + padding-left: 20px; + line-height: 20px; + height: 20px; + + .checkmark { + height: 20px; + width: 20px; + + &::after { + margin-top: -5px; + margin-left: -5px; + width: 10px; + height: 10px; + } + } + } + + .label { + margin-left: 8px; + } + } + + // xs size + &.xsSize { + .container { + padding-left: 15px; + line-height: 15px; + height: 15px; + + .checkmark { + height: 16px; + width: 16px; + + &::after { + margin-top: -4px; + margin-left: -4px; + width: 8px; + height: 8px; + } + } + } + + .label { + margin-left: 8px; + } + } +} + +.errorMessage { + display: block; + + @include errorMessage; + + color: #ef476f; + margin-left: 0; +} diff --git a/src/apps/earn/src/components/ReferralAuthModal/index.jsx b/src/apps/earn/src/components/ReferralAuthModal/index.jsx new file mode 100644 index 000000000..063726d7e --- /dev/null +++ b/src/apps/earn/src/components/ReferralAuthModal/index.jsx @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import PT from "prop-types"; + +import { BaseModal, Button } from "~/libs/ui"; + +import { REFERRAL_PROGRAM_URL } from "../../constants"; +import { makeLoginUrl, makeRegisterUrl } from "../../utils/url"; + +import styles from "./styles.scss"; + +/** + * Displays a modal with "Login" and "Register" buttons which is displayed after + * the user clicks the button in the ReferralBanner. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const ReferralAuthModal = ({ onClose, open }) => { + const onClickBtnLogin = useCallback(() => { + window.location = makeLoginUrl(window.location.href); + }, []); + + const onClickBtnRegister = useCallback(() => { + window.open(makeRegisterUrl(window.location.href)); + }, []); + + return ( + +
Referral Program
+
Please login to receive your referral code.
+
+ + +
+
+ Find out how the referral program works{" "} + + here + + . +
+
+ ); +}; + +ReferralAuthModal.propTypes = { + onClose: PT.func.isRequired, + open: PT.bool.isRequired, +}; + +export default ReferralAuthModal; diff --git a/src/apps/earn/src/components/ReferralAuthModal/styles.scss b/src/apps/earn/src/components/ReferralAuthModal/styles.scss new file mode 100644 index 000000000..a9c23b93b --- /dev/null +++ b/src/apps/earn/src/components/ReferralAuthModal/styles.scss @@ -0,0 +1,48 @@ +@import '@libs/ui/styles/includes'; +@import "../../styles/mixins"; +@import "../../styles/variables"; + +.content { + display: block; +} + +.title { + @include barlow-condensed-medium; + font-size: 34px; + line-height: 38px; + text-transform: uppercase; + color: #1e94a3; +} + +.message { + margin: 20px 0 0; + font-size: 20px; + line-height: 30px; + text-align: center; + + @include tablet { + font-size: 24px; + line-height: 36px; + } +} + +.controls { + display: flex; + justify-content: center; + margin: 40px 0 0; + gap: $sp-3; +} + +.hint { + margin: 10px 0 0; + @include roboto-regular; + font-size: 14px; + line-height: 26px; + + a { + font-size: 16px; + line-height: 24px; + color: #0d61bf; + text-decoration: underline; + } +} diff --git a/src/apps/earn/src/components/ReferralEmailModal/index.jsx b/src/apps/earn/src/components/ReferralEmailModal/index.jsx new file mode 100644 index 000000000..065c75aa4 --- /dev/null +++ b/src/apps/earn/src/components/ReferralEmailModal/index.jsx @@ -0,0 +1,68 @@ +import modalStyles from "../../styles/_modal.scss"; +import cn from "classnames"; + +import { BaseModal, Button, LinkButton } from "~/libs/ui"; + +import { LoadingCircles } from "~/libs/ui"; +import { GIG_LIST_ROUTE } from "../../constants"; + +import styles from "./styles.scss"; + +const ReferralEmailModal = ({ error, isBusy, isUserError, onClose, open }) => ( + + {isBusy ? ( + <> +
Sending your referral...
+ + + ) : ( + <> +
+ {error ? "Oops!" : "Congratulations!"} +
+
+ {error ? error : "Your referral has been sent."} +
+ {!!error && + (isUserError ? ( +
+ If you think this is an error please contact +
+ support@topcoder.com. +
+ ) : ( +
+ Looks like there is a problem on our end. Please try again. +
+ If this persists please contact{" "} + support@topcoder.com. +
+ ))} +
+ + + FIND ANOTHER GIG + +
+ + )} +
+); + +export default ReferralEmailModal; diff --git a/src/apps/earn/src/components/ReferralEmailModal/styles.scss b/src/apps/earn/src/components/ReferralEmailModal/styles.scss new file mode 100644 index 000000000..c3df4a43b --- /dev/null +++ b/src/apps/earn/src/components/ReferralEmailModal/styles.scss @@ -0,0 +1,24 @@ +.sendLoadingIndicator { + display: block; + margin: 20px auto 0; +} + +.hint { + margin: 40px 0 20px; + font-size: 14px; + line-height: 26px; + text-align: center; + + a { + color: #0d61bf; + text-decoration: underline; + } + + + .controls { + margin: 0; + } +} + +.error { + color: #f00; +} diff --git a/src/apps/earn/src/components/SearchField/index.jsx b/src/apps/earn/src/components/SearchField/index.jsx new file mode 100644 index 000000000..7f58ca0bc --- /dev/null +++ b/src/apps/earn/src/components/SearchField/index.jsx @@ -0,0 +1,63 @@ +import styles from "./styles.scss"; +import React, { useCallback } from "react"; +import PT from "prop-types"; +import cn from "classnames"; + +import { ReactComponent as IconMagnifier } from "../../assets/icons/icon-magnifier.svg"; + +/** + * Displays search input field. + * + * @param {Object} props component properties + * @param {string} [props.className] class name added to root element + * @param {string} props.id id for input element + * @param {string} props.placeholder placeholder text + * @param {string} props.name name for input element + * @param {'medium'|'small'} [props.size] field size + * @param {function} props.onChange function called when input value changes + * @param {string} props.value input value + * @returns {JSX.Element} + */ +const SearchField = ({ + className, + id, + name, + size = "medium", + onChange, + placeholder, + value, +}) => { + const onInputChange = useCallback( + (event) => { + onChange(event.target.value); + }, + [onChange] + ); + + return ( +
+ + +
+ ); +}; + +SearchField.propTypes = { + className: PT.string, + id: PT.string.isRequired, + size: PT.oneOf(["medium", "small"]), + name: PT.string.isRequired, + onChange: PT.func.isRequired, + placeholder: PT.string, + value: PT.oneOfType([PT.number, PT.string]), +}; + +export default SearchField; diff --git a/src/apps/earn/src/components/SearchField/styles.scss b/src/apps/earn/src/components/SearchField/styles.scss new file mode 100644 index 000000000..8460e83c1 --- /dev/null +++ b/src/apps/earn/src/components/SearchField/styles.scss @@ -0,0 +1,38 @@ +.container { + display: flex; + align-items: center; + border: 1px solid #aaa; + border-radius: 6px; + background-color: #fff; + + &.medium { + height: 40px; + } + + &.small { + height: 30px; + } +} + +.icon { + margin: auto 10px; + width: 16px; + height: 16px; +} + +input.input { + flex: 1 1 0; + margin: 0; + border: none !important; + padding: 8px 16px 8px 0; + height: 22px; + line-height: 22px; + background: none; + outline: none !important; + box-shadow: none !important; + + &::placeholder { + text-transform: none; + color: #aaa; + } +} diff --git a/src/apps/earn/src/components/Select/index.jsx b/src/apps/earn/src/components/Select/index.jsx new file mode 100644 index 000000000..c0e8a5e25 --- /dev/null +++ b/src/apps/earn/src/components/Select/index.jsx @@ -0,0 +1,21 @@ +import _ from "lodash"; +import ReactSelect from "react-select"; +import PT from "prop-types"; +import styles from "./style.scss"; + +export default function Select(props) { + const { selectRef } = props; + return ( +
+ +
+ ); +} + +Select.defaultProps = { + selectRef: _.noop, +}; + +Select.propTypes = { + selectRef: PT.func, +}; diff --git a/src/apps/earn/src/components/Select/style.scss b/src/apps/earn/src/components/Select/style.scss new file mode 100644 index 000000000..f823c926c --- /dev/null +++ b/src/apps/earn/src/components/Select/style.scss @@ -0,0 +1,18 @@ +.select { + :global { + // @import "react-select/dist/react-select"; + + width: 100%; + + input.Select-input, + input.Select-input:focus { + background-color: transparent !important; + margin-left: 0 !important; + padding-right: 6px !important; + } + + .Select-multi-value-wrapper { + width: 100% !important; + } + } +} diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconCloudDownload.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconCloudDownload.svg new file mode 100644 index 000000000..b17e3bc52 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconCloudDownload.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalDown.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalDown.svg new file mode 100644 index 000000000..96fdc951e --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalLeft.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalLeft.svg new file mode 100644 index 000000000..287bf690e --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalLeft.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalRight.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalRight.svg new file mode 100644 index 000000000..d2905c94b --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalRight.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalUp.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalUp.svg new file mode 100644 index 000000000..9bf3e2d5a --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconMinimalUp.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconSearch.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconSearch.svg new file mode 100644 index 000000000..30f678532 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconSearch.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconShare.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconShare.svg new file mode 100644 index 000000000..2c731ab66 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconShare.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconSquareDownload.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconSquareDownload.svg new file mode 100644 index 000000000..e56fda655 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconSquareDownload.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconTrashSimple.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconTrashSimple.svg new file mode 100644 index 000000000..5b430a5e3 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconTrashSimple.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/Icons/IconZoom.svg b/src/apps/earn/src/components/SubmissionManagement/Icons/IconZoom.svg new file mode 100644 index 000000000..30f678532 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Icons/IconZoom.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/earn/src/components/SubmissionManagement/ScreeningDetails/index.jsx b/src/apps/earn/src/components/SubmissionManagement/ScreeningDetails/index.jsx new file mode 100644 index 000000000..706ff51f9 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/ScreeningDetails/index.jsx @@ -0,0 +1,133 @@ +/** + * This compnent receives via props the screening object and nicely renders them. + * Note that this component both has the text coming directly from the screening object, + * and the text generated based on the screening status + * + */ + +import PT from 'prop-types'; +import shortid from 'shortid'; +import { styled as styledCss } from '@earn/utils'; + +import styles from './styles.scss'; +const styled = styledCss(styles); + +export default function ScreeningDetails(props) { + const { + screeningObject, + helpPageUrl, + } = props; + + const hasWarnings = screeningObject.warnings; + const hasStatus = screeningObject.status; + const hasStatusPassed = hasStatus === 'passed'; + const hasStatusFailed = hasStatus === 'failed'; + const hasPending = screeningObject.status === 'pending'; + const warnLength = screeningObject.warnings && hasWarnings.length; + + const setStatusInfo = () => { + if (hasPending) { + return { + title: 'Pending', + classname: 'pending', + message: 'Your submission has been received, and will be screened after the end of the phase', + }; + } if (hasStatusPassed && !hasWarnings) { + return { + title: 'Passed Screening', + classname: 'passed', + message: 'You have passed screening.', + }; + } if (hasStatusFailed && !hasWarnings) { + return { + title: 'Failed Screening', + classname: 'failed', + message: 'You have failed screening', + }; + } if (hasStatusPassed && hasWarnings) { + return { + title: 'Passed Screening with Warnings', + classname: 'passed', + message: `You have passed screening, but the screener has given you ${warnLength} warnings that you must fix in round 2.`, + }; + } if (hasStatusFailed && hasWarnings) { + return { + title: 'Failed Screening with Warnings', + classname: 'failed', + message: 'You have failed screening and the screener has given you the following warning.', + }; + } + return { + title: '', + classname: '', + message: 'Your submission has been received, and will be evaluated during Review phase.', + }; + }; + + let warnings = []; + if (screeningObject.warnings) { + warnings = screeningObject.warnings.map((warning, i) => ( +
+
+ + Warning + + {' '} + {`${1 + i} : ${warning.brief}`} +
+

+ {warning.details} +

+
+ )); + } + return ( +
+
+

+ {setStatusInfo().title} +

+ {/* + NOTE: TonyJ asked to remove the OR links from the page to keep + users within the new Topcoder site as much as we can. Not wiping + out the code just in case we decide to bring it back later. + + Online Review + + */} +
+

+ {setStatusInfo().message} + + {' '}Need help? + +

+
+ {warnings} + {((hasStatusFailed) || (hasStatusPassed && hasWarnings)) + && ( +

+ Need more info on how to pass screening? + Go to help to read Rules & Policies. +

+ )} +
+
+ ); +} + +ScreeningDetails.defaultProps = { + screeningObject: {}, + helpPageUrl: '', +}; + +ScreeningDetails.propTypes = { + screeningObject: PT.shape({ + status: PT.string, + warnings: PT.arrayOf(PT.shape({ + brief: PT.string.isRequired, + details: PT.string.isRequired, + })), + }), + helpPageUrl: PT.string, +}; diff --git a/src/apps/earn/src/components/SubmissionManagement/ScreeningDetails/styles.scss b/src/apps/earn/src/components/SubmissionManagement/ScreeningDetails/styles.scss new file mode 100644 index 000000000..2310fef9b --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/ScreeningDetails/styles.scss @@ -0,0 +1,88 @@ +@import '@earn/styles/mixins'; +$status-space-10: $base-unit * 2; +$status-space-15: $base-unit * 3; +$status-space-20: $base-unit * 4; +$status-space-25: $base-unit * 5; +$gray-color: $tc-gray-80; +$green-color: $tc-green; +$red-color: $tc-red; + +.online-review-link { + background: none; +} + +.screening-details { + font-weight: 500; + font-size: 14px; + color: $tc-black; + letter-spacing: 0; + line-height: 22px; + + .screening-details-head { + display: flex; + margin-bottom: $base-unit; + + .status-title { + font-weight: 700; + color: $tc-orange; + letter-spacing: 0; + line-height: $status-space-20; + + .passed { + color: $green-color; + } + + &.failed { + color: $red-color; + } + + &.pending { + color: $gray-color; + } + } + + .online-review-link { + display: block; + margin-left: auto; + line-height: $status-space-20; + font-weight: 400; + font-size: 13px; + color: $tc-dark-blue-110; + text-decoration: underline; + padding: 0; + border: none; + text-transform: capitalize; + + &:hover { + opacity: 0.7; + } + + &:focus { + outline: none; + } + } + } + + .screening-warning { + margin-top: $status-space-15; + } + + .more-info { + margin-top: $status-space-15; + margin-bottom: $base-unit; + } + + .warning-bold { + font-weight: 700; + line-height: $status-space-20; + } + + .help-btn { + text-align: right; + } + + .help-link { + color: $tc-dark-blue-110; + cursor: pointer; + } +} diff --git a/src/apps/earn/src/components/SubmissionManagement/ScreeningStatus/index.jsx b/src/apps/earn/src/components/SubmissionManagement/ScreeningStatus/index.jsx new file mode 100644 index 000000000..3110f371e --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/ScreeningStatus/index.jsx @@ -0,0 +1,97 @@ +/** + * This component receives the screening object via props. + * Depending on status, presence and count of warnings, + * it renders itself in one of the ways shown in specs. + * When status is pending it renders just 'Not yet performed' text. + * When no screening object received, nothing is rendered. + * + * When hovered this object should update mouse cursor to pointer, + * and on click it should call the onClick() callback passed from parent. + * Parent code will use that signal to show/hide the panel with + * screening details for this submission. + * + */ + +import _ from 'lodash'; +import React from 'react'; +import PT from 'prop-types'; + +import { styled as styledCss } from '@earn/utils'; + +import styles from './styles.scss'; +const styled = styledCss(styles); + +export default function ScreeningStatus(props) { + const { + screeningObject, + onShowDetails, + submissionId, + } = props; + + const hasWarnings = screeningObject.warnings; + const hasStatus = screeningObject.status; + const hasStatusPassed = hasStatus === 'passed'; + const hasStatusFailed = hasStatus === 'failed'; + const hasPending = screeningObject.status === 'pending'; + const warnLength = screeningObject.warnings && hasWarnings.length; + + const setClassName = () => { + if (hasPending) { + return 'pending'; + } if (hasStatusPassed && !hasWarnings) { + return 'pass-with-no-warn'; + } if (hasStatusFailed && !hasWarnings) { + return 'fail-with-no-warn'; + } + return 'has-warn'; + }; + const setStatusClassName = () => { + if (hasStatusPassed && hasWarnings) { + return 'passed'; + } if (hasStatusFailed && hasWarnings) { + return 'failed'; + } + return ''; + }; + return ( + + ); +} + +ScreeningStatus.defaultProps = { + onShowDetails: _.noop, +}; + +ScreeningStatus.propTypes = { + screeningObject: PT.shape({ + status: PT.string, + warnings: PT.array, + }).isRequired, + onShowDetails: PT.func, + submissionId: PT.number.isRequired, +}; diff --git a/src/apps/earn/src/components/SubmissionManagement/ScreeningStatus/styles.scss b/src/apps/earn/src/components/SubmissionManagement/ScreeningStatus/styles.scss new file mode 100644 index 000000000..80fa75a06 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/ScreeningStatus/styles.scss @@ -0,0 +1,68 @@ +@import '@earn/styles/mixins'; +$status-space-10: $base-unit * 2; +$status-space-20: $base-unit * 4; +$status-space-40: $base-unit * 8; +$gray-color: #a3a3ad; +$green-color: #60c700; +$red-color: #f22f24; + +.status { + font-weight: 700; + display: inline-block; + padding: $base-unit $status-space-10; + border-radius: $status-space-40 0 0 $status-space-40; + text-transform: capitalize; + + &.passed { + background: $tc-orange; + } + + &.failed { + background: $red-color; + } +} + +.screening-status { + background: $tc-gray-50; + border-radius: $status-space-40; + font-weight: 400; + font-size: 13px; + line-height: $status-space-20; + color: $tc-white; + padding: $base-unit $status-space-10; + display: inline-block; + text-transform: initial; + cursor: pointer; + + &.has-warn { + padding: 0 $status-space-10 0 0; + } + + &.pass-with-no-warn { + background: $green-color; + + .status { + padding: 0 $base-unit; + } + } + + &.fail-with-no-warn { + background: $red-color; + + .status { + padding: 0 $base-unit; + } + } + + &.pending { + background: transparent; + font-size: 15px; + color: $gray-color; + font-style: italic; + } +} + +.warning { + padding-left: $status-space-10; + display: inline-block; +} diff --git a/src/apps/earn/src/components/SubmissionManagement/Submission/index.jsx b/src/apps/earn/src/components/SubmissionManagement/Submission/index.jsx new file mode 100644 index 000000000..367db65b9 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Submission/index.jsx @@ -0,0 +1,145 @@ +/** + * This component receives via props a single submission data object, + * and showScreeningDetails boolean property, which should tell whether + * the Screening Details component should be rendered or not + * (and also to choose the proper orientation of arrow icon). + * + * Also, this component will receive the following callbacks to be triggered + * when user clicks on buttons/icons/links: + * onDelete() (to be triggered by delete icon), + * onDownload() (to be triggered by download icon), + * onShowDetails() (to be triggered by details arrow icon, and also by screening status component). + */ + +import _ from 'lodash'; +import moment from 'moment'; +import { COMPETITION_TRACKS, CHALLENGE_STATUS, safeForDownload } from '@earn/utils/tc'; + +import PT from 'prop-types'; + +import { ReactComponent as DeleteIcon } from '../Icons/IconTrashSimple.svg'; +import { ReactComponent as DownloadIcon } from '../Icons/IconSquareDownload.svg'; +import { ReactComponent as ExpandIcon } from '../Icons/IconMinimalDown.svg'; +import ScreeningStatus from '../ScreeningStatus'; +import { styled as styledCss } from '@earn/utils'; + +import styles from './styles.scss'; +const styled = styledCss(styles); + +export default function Submission(props) { + const { + submissionObject, + showScreeningDetails, + track, + onDownload, + onDelete, + onShowDetails, + status, + allowDelete, + } = props; + const formatDate = date => moment(+new Date(date)).format('MMM DD, YYYY hh:mm A'); + const onDownloadSubmission = onDownload.bind(1, submissionObject.id); + const safeForDownloadCheck = safeForDownload(submissionObject.url); + + return ( + + + ID + {submissionObject.legacySubmissionId} +
{submissionObject.id}
+ + + TYPE + {submissionObject.type} + + + Submission Date + {formatDate(submissionObject.created)} + + { + track === COMPETITION_TRACKS.DES && ( + + Screening Status + {safeForDownloadCheck !== true ? safeForDownloadCheck : submissionObject.screening + && ( + + )} + + ) + } + +
+ + { /* + TODO: At the moment we just fetch downloads from the legacy + Topcoder Studio API, and we don't need any JS code to this. + It may change soon, as we move to the new TC API for + downloads. Then we'll use this commented out code or + remove it for good. + + */ } + {status !== CHALLENGE_STATUS.COMPLETED + && track === COMPETITION_TRACKS.DES + && safeForDownloadCheck === true && ( + + ) + } + +
+ + + ); +} + +Submission.defaultProps = { + submissionObject: {}, + showScreeningDetails: false, + onShowDetails: _.noop, +}; + +Submission.propTypes = { + submissionObject: PT.shape({ + id: PT.string, + legacySubmissionId: PT.string, + warpreviewnings: PT.string, + screening: PT.shape({ + status: PT.string, + }), + submitted: PT.string, + type: PT.string, + created: PT.any, + download: PT.any, + url: PT.string, + }), + showScreeningDetails: PT.bool, + track: PT.string.isRequired, + onDownload: PT.func.isRequired, + onDelete: PT.func.isRequired, + onShowDetails: PT.func, + status: PT.string.isRequired, + allowDelete: PT.bool.isRequired, +}; diff --git a/src/apps/earn/src/components/SubmissionManagement/Submission/styles.scss b/src/apps/earn/src/components/SubmissionManagement/Submission/styles.scss new file mode 100644 index 000000000..3511c9e26 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/Submission/styles.scss @@ -0,0 +1,202 @@ +@import '@earn/styles/mixins'; +$submission-space-10: $base-unit * 2; +$submission-space-20: $base-unit * 4; +$submission-space-25: $base-unit * 5; +$submission-space-50: $base-unit * 10; + +.submission-row { + width: 100%; + font-size: 15px; + color: $tc-black; + font-weight: 400; + + @include xs-to-sm { + display: block; + position: relative; + padding: 10px 0; + } + + td { + // vertical-align: middle; + padding: 16px; + background: $tc-white; + border-top: 1px solid $tc-gray-10; + color: $tc-black; + font-weight: 500; + font-size: 14px; + line-height: 22px; + + .mobile-header { + display: none; + + @include xs-to-md { + display: block; + color: #767676; + font-size: 11px; + line-height: 14px; + text-transform: uppercase; + text-align: left; + justify-content: flex-start; + } + } + + @include xs-to-lg { + padding: $submission-space-10; + } + + @include xs-to-sm { + display: block; + border: none; + } + + &.no-submission { + line-height: $submission-space-20; + padding: $submission-space-50 $submission-space-20; + text-align: center; + } + + &.dev-details { + padding: 16px; + + @include xs-to-md { + padding: 0 10px; + } + } + } + + .preview-col { + @include xs-to-sm { + float: left; + } + + .design-img { + width: 90px; + height: 90px; + + @include xs-to-sm { + width: 80px; + height: 80px; + } + } + + .dev-img { + width: 40px; + height: 40px; + } + } + + .id-col { + font-weight: 700; + width: 32%; + + @include xs-to-sm { + width: 80%; + } + } + + .type-col { + width: 21%; + + @include xs-to-sm { + width: 80%; + } + } + + .date-col { + width: 20%; + color: $tc-black; + font-weight: 500; + font-size: 14px; + line-height: 22px; + + @include xs-to-sm { + padding: 0 10px; + width: 80%; + } + } + + .v5-id { + font-weight: 400; + line-height: 22px; + } + + .status-col { + text-align: center; + + button { + background: none; + border: none; + padding: 0; + + .pending { + text-transform: initial; + font-size: 15px; + color: $tc-gray-40; + line-height: $submission-space-20; + } + } + } + + .action-col { + text-align: center; + min-width: 120px; + + @include xs-to-sm { + position: absolute; + right: 0; + top: 10px; + padding: 10px 0; + min-width: 100px; + text-align: right; + + svg { + width: 14px; + height: 14px; + } + } + + .delete-icon { + margin: 0 0 0 24px; + + @include xs-to-sm { + margin-left: 15px; + } + } + + button { + margin-left: 15px; + margin-top: 3px; + background: none; + border: 0; + font-size: 0; + padding: 0; + line-height: 0; + display: inline-block; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + .expand-icon { + transition: all 1.5s; + margin-left: 24px; + + @include xs-to-sm { + margin-left: 15px; + } + + &.expanded { + transform: rotate(180deg); + } + } + } + + .status-col button:focus { + outline: none; + } +} diff --git a/src/apps/earn/src/components/SubmissionManagement/SubmissionManagement/index.jsx b/src/apps/earn/src/components/SubmissionManagement/SubmissionManagement/index.jsx new file mode 100644 index 000000000..897e45451 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/SubmissionManagement/index.jsx @@ -0,0 +1,223 @@ +/** + * This component should render the entire page assembly, + * but not yet implement the logic behind user actions. + * It still receives submissions and challenge data, all callbacks, etc. from its parent container + * + * Namely, it receives via props: the mock data object (provided along with this specs), + * the showDetails set, and config object holding all necessary callbacks: + * onBack() - to trigger when user clicks Back button under the challenge name; + * onDelete(submissionId); + * onDownload() (to be triggered by download icon) + * onOpenOnlineReview(submissionId); onHelp(submissionId); + * onShowDetails(submissionId); + * onSubmit() - to trigger when user clicks Add Submission button. + */ + +import _ from 'lodash'; +import PT from 'prop-types'; +import moment from 'moment'; + +import { IconOutline, LinkButton, LoadingCircles } from '~/libs/ui'; +import { phaseEndDate } from '@earn/utils/challenge-listing/helper'; + +import SubmissionsTable from '../SubmissionsTable'; + +import styles from './styles.scss'; + +export default function SubmissionManagement(props) { + const { + challenge, + submissions, + loadingSubmissions, + showDetails, + onDelete, + helpPageUrl, + onDownload, + onShowDetails, + challengeUrl, + onlineReviewUrl, + submissionPhaseStartDate, + } = props; + + const { track } = challenge; + + const challengeType = track.toLowerCase(); + + const isDesign = challengeType === 'design'; + const isDevelop = challengeType === 'development'; + const currentPhase = challenge.phases + .filter(p => p.name !== 'Registration' && p.isOpen) + .sort((a, b) => moment(a.scheduledEndDate).diff(b.scheduledEndDate))[0]; + const submissionPhase = challenge.phases.filter(p => p.name === 'Submission')[0]; + const submissionEndDate = submissionPhase && phaseEndDate(submissionPhase); + + const now = moment(); + const end = moment(currentPhase && currentPhase.scheduledEndDate); + const diff = end.isAfter(now) ? end.diff(now) : 0; + const timeLeft = moment.duration(diff); + + const [days, hours, minutes] = [ + timeLeft.get('days'), + timeLeft.get('hours'), + timeLeft.get('minutes'), + ]; + + const componentConfig = { + helpPageUrl, + onDelete, + onDownload: onDownload.bind(0, challengeType), + onlineReviewUrl, + onShowDetails, + }; + return ( +
+
+
+ + +

+ {challenge.name} +

+
+
+
+
+

+ Manage your submissions +

+ + {/* { + isDesign && currentPhase && ( +

+ + {currentPhase.name} + {' '} + Ends: + + {' '} + {end.format('dddd MM/DD/YY hh:mm A')} +

+ ) + } */} +
+
+ { + currentPhase && ( +

+ Current Deadline:{' '} + {currentPhase.name} +

+ ) + } + + { + challenge.status !== 'Completed' ? ( +
+

+ Current Deadline Ends: {' '} + + {days > 0 && (`${days}D`)} + {' '} + {hours} + H + {' '} + {minutes} + M + +

+
+ ) : ( +

+ The challenge has ended +

+ ) + } +
+ { + isDesign && ( +

+ {/* eslint-disable-next-line max-len */} + We always recommend to download your submission to check you uploaded the correct .zip files  + {/* eslint-disable-next-line max-len */} + and also to verify your declarations file is accurate. If you don’t want to see a submission  + {/* eslint-disable-next-line max-len */} + simply delete it. If you have a new submissions, use the “Add Submission” button to add one  + to the top of the list. +

+ ) + } + { + isDevelop && ( +

+ {/* eslint-disable-next-line max-len */} + We always recommend to download your submission to check you uploaded the correct .zip files  + {/* eslint-disable-next-line max-len */} + and also to verify your declarations file is accurate. If you don’t want to see a submission  + {/* eslint-disable-next-line max-len */} + simply delete it. If you have a new submissions, use the “Add Submission” button to add one  + to the top of the list. +

+ ) + } + {loadingSubmissions && } + {!loadingSubmissions + && ( + + ) + } +
+ {now.isBefore(submissionEndDate) && ( +
+ + { + (!isDevelop || !submissions || submissions.length === 0) + ? 'Add Submission' : 'Update Submission' + } + +
+ )} +
+ ); +} + +SubmissionManagement.defaultProps = { + onDelete: _.noop, + onShowDetails: _.noop, + onDownload: _.noop, + onlineReviewUrl: '', + helpPageUrl: '', + loadingSubmissions: false, + challengeUrl: '', + submissions: [], +}; + +SubmissionManagement.propTypes = { + showDetails: PT.shape().isRequired, + onDelete: PT.func, + onlineReviewUrl: PT.string, + helpPageUrl: PT.string, + onDownload: PT.func, + onShowDetails: PT.func, + challenge: PT.shape().isRequired, + submissions: PT.arrayOf(PT.shape()), + loadingSubmissions: PT.bool, + challengeUrl: PT.string, + submissionPhaseStartDate: PT.string.isRequired, +}; diff --git a/src/apps/earn/src/components/SubmissionManagement/SubmissionManagement/styles.scss b/src/apps/earn/src/components/SubmissionManagement/SubmissionManagement/styles.scss new file mode 100644 index 000000000..8c884499f --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/SubmissionManagement/styles.scss @@ -0,0 +1,286 @@ +@import '@earn/styles/mixins'; +$submang-space-140: $base-unit * 28; +$submang-space-50: $base-unit * 10; +$submang-space-35: $base-unit * 7; +$submang-space-30: $base-unit * 6; +$submang-space-25: $base-unit * 5; +$submang-space-20: $base-unit * 4; +$gray-color: $tc-gray-40; +$light-gray-color: $tc-gray-neutral-light; + +.btn-wrap { + display: flex; + justify-content: flex-end; + margin-right: 32px; + margin-top: 34px; + color: white; + + @include xs-to-md { + margin-right: 16px; + margin-top: 32px; + } +} + + +.add-sub-btn-warning { + @include roboto-bold; + + font-weight: 700; + font-size: 16px; + line-height: 24px; + border-radius: 50px !important; + background: $tc-red-70 !important; + color: #fff !important; + text-transform: uppercase; + + @include xs-to-md { + font-size: 14px; + line-height: 20px; + } +} + +.submission-management { + padding-bottom: 40px; +} + +.submission-management-content { + padding: 0 32px; + + @include md { + padding: $submang-space-20 $submang-space-35; + } + + @include xs-to-sm { + padding: 0; + } + + .content-head { + display: flex; + justify-content: space-between; + font-weight: 400; + font-size: 15px; + color: $tc-gray-50; + line-height: $submang-space-25; + margin-top: 36px; + margin-bottom: $base-unit; + + @include xs-to-sm { + flex-direction: column; + margin-top: $submang-space-20; + padding: 0 15px; + } + + .title { + @include barlow-bold; + + font-weight: 600; + font-size: 24px; + color: $tc-black; + line-height: 28px; + text-transform: uppercase; + + @include xs-to-md { + font-size: 20px; + line-height: 22px; + } + } + + .round-ends { + font-size: 15px; + color: $tc-black; + line-height: $submang-space-30; + + @include xs-to-sm { + margin-top: 10px; + margin-bottom: 15px; + } + + span.ends-label { + color: $tc-gray-50; + + @include xs-to-sm { + display: block; + margin-bottom: -10px; + } + } + } + } + + .subTitle { + display: flex; + margin-top: 12px; + + @include xs-to-md { + margin: 17px 16px 0 16px; + flex-direction: column; + } + + .round { + @include roboto-regular; + + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: $tc-black; + + span { + @include roboto-bold; + + font-weight: 700; + } + + @include xs-to-md { + font-size: 14px; + line-height: 20px; + } + } + + .seperator { + width: 2px; + height: 24px; + left: 228px; + top: 0; + background: #d4d4d4; + border-radius: 1px; + margin: 0 16px; + + @include xs-to-md { + display: none; + } + } + } + + .recommend-info { + @include roboto-regular; + + font-size: 16px; + font-weight: 400; + color: $tc-black; + line-height: 24px; + margin-bottom: $submang-space-30; + margin-top: 40px; + + @include xs-to-sm { + padding: 0 15px; + margin-top: 32px; + font-size: 14px; + line-height: 20px; + } + } +} + +.submission-management-header { + display: flex; + justify-content: space-between; + width: 100%; + font-weight: 400; + padding: $submang-space-25 32px 0 32px; + background: #fff; + border-bottom: 1px solid $tc-gray-neutral-dark; + + @include md { + padding: $submang-space-25 $submang-space-35; + } + + @include xs-to-sm { + flex-direction: column; + padding: 9px 16px; + } + + .left-col { + display: flex; + padding-right: 200px; + margin-top: 15px; + margin-bottom: 40px; + + @include xs-to-sm { + padding-right: 0; + display: flex; + flex-direction: column; + } + + .name { + @include barlow-bold; + + font-size: 34px; + color: $tc-black; + line-height: 32px; + font-weight: 600; + margin-left: 16px; + margin-top: -6px; + + @include xs-to-sm { + font-size: 28px; + line-height: 32px; + margin-top: 0; + margin-left: 0; + } + } + + .back-btn { + @include roboto-bold; + + font-weight: 700; + font-size: 16px; + line-height: 24px; + border: 0; + color: #137d60 !important; + width: 32px; + height: 24px; + position: relative; + + @include xs-to-md { + margin-left: 0; + margin-bottom: 8px; + } + + svg { + border: 1.5px solid #137d60; + border-radius: 24px; + + @include xs-to-md { + margin-left: 0; + margin-bottom: 15px; + } + } + } + } + + .right-col { + @include roboto-regular; + + min-width: 100px; + + @include xs-to-sm { + margin-top: 10px; + + p, + div { + display: inline-block; + } + } + + .time-left { + font-size: 20px; + color: $tc-gray-80; + line-height: $submang-space-30; + + @include xs-to-sm { + font-size: 15px; + font-weight: bold; + line-height: $submang-space-25; + margin-left: 5px; + } + } + + .left-label { + font-size: 13px; + color: $tc-gray-50; + line-height: $submang-space-20; + + @include xs-to-sm { + margin-left: 5px; + } + } + } +} diff --git a/src/apps/earn/src/components/SubmissionManagement/SubmissionsTable/index.jsx b/src/apps/earn/src/components/SubmissionManagement/SubmissionsTable/index.jsx new file mode 100644 index 000000000..d55aeb8b9 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/SubmissionsTable/index.jsx @@ -0,0 +1,153 @@ +/** + * This component will render the entire assemly of submissions table. + * It receives via props the array of submission data objects; + * a showDetails set with submission IDs for which details panel + * should be shown; and type property (design or develop) to know + * whether the screening status column should be rendered. + * + * Also it will receive a bunch of callbacks which should be properly + * wired to the children components: + * onDelete(submissionId) – to delete specified submission; + * onDownload(submissionId) – to download the specified submission; + * onOpenOnlineReview(submissionId); + * onHelp(submissionId); + * onShowDetails(submissionId). + */ + +import _ from 'lodash'; +import React from 'react'; +import PT from 'prop-types'; +import shortid from 'shortid'; +import moment from 'moment'; +import { COMPETITION_TRACKS } from '@earn/utils/tc'; +import Submission from '../Submission'; +import ScreeningDetails from '../ScreeningDetails'; +import styles from './styles.scss'; + +export default function SubmissionsTable(props) { + const { + submissionObjects, + showDetails, + track, + onDelete, + onlineReviewUrl, + helpPageUrl, + onDownload, + onShowDetails, + status, + submissionPhaseStartDate, + } = props; + + const submissionsWithDetails = []; + if (!submissionObjects || submissionObjects.length === 0) { + submissionsWithDetails.push(( + + + You have no submission uploaded so far. + + + )); + } else { + submissionObjects.forEach((subObject) => { + // submissionPhaseStartDate will be the start date of + // the current submission/checkpoint or empty string if any other phase + const allowDelete = submissionPhaseStartDate + && moment(subObject.submissionDate).isAfter(submissionPhaseStartDate); + + const submission = ( + + ); + submissionsWithDetails.push(submission); + + const submissionDetail = ( + + {showDetails[subObject.id] + && ( + + + + )} + + ); + submissionsWithDetails.push(submissionDetail); + }); + } + + return ( +
+ + + + + + + {track === COMPETITION_TRACKS.DES && ( + + )} + + + + + {submissionsWithDetails} + +
+ ID + + Type + + Submission Date + + Screening Status + + Actions +
+
+ ); +} + +const SubShape = PT.shape({ + id: PT.string, + warpreviewnings: PT.string, + screening: PT.shape({ + status: PT.string, + }), + submitted: PT.string, + track: PT.string, +}); + +SubmissionsTable.defaultProps = { + submissionObjects: [], + onDelete: _.noop, + onDownload: _.noop, + onShowDetails: _.noop, + onlineReviewUrl: '', + helpPageUrl: '', +}; + +SubmissionsTable.propTypes = { + submissionObjects: PT.arrayOf(SubShape), + showDetails: PT.shape().isRequired, + track: PT.string.isRequired, + onDelete: PT.func, + onlineReviewUrl: PT.string, + helpPageUrl: PT.string, + onDownload: PT.func, + onShowDetails: PT.func, + status: PT.string.isRequired, + submissionPhaseStartDate: PT.string.isRequired, +}; diff --git a/src/apps/earn/src/components/SubmissionManagement/SubmissionsTable/styles.scss b/src/apps/earn/src/components/SubmissionManagement/SubmissionsTable/styles.scss new file mode 100644 index 000000000..498a0fac7 --- /dev/null +++ b/src/apps/earn/src/components/SubmissionManagement/SubmissionsTable/styles.scss @@ -0,0 +1,164 @@ +@import '@earn/styles/mixins'; +$status-space-10: $base-unit * 2; +$status-space-20: $base-unit * 4; +$status-space-25: $base-unit * 5; +$submission-space-10: $base-unit * 2; +$submission-space-20: $base-unit * 4; +$submission-space-25: $base-unit * 5; +$submission-space-50: $base-unit * 10; + +.submissions-table { + // border: 1px solid $tc-gray-10; + overflow: hidden; + // border-radius: 4px 4px 0 0; + + @include xs-to-sm { + border: none; + border-radius: 0; + border-top: 1px solid $tc-gray-10; + border-bottom: 1px solid $tc-gray-10; + margin: 0 16px; + } + + table { + width: 100%; + + th { + @include barlow-bold; + + font-size: 11px; + color: #767676; + font-weight: 600; + line-height: $status-space-20; + text-align: left; + padding: 0 15px 11px 15px; + text-transform: uppercase; + + &.status, + &.actions { + text-align: center; + } + + @include xs-to-sm { + display: none; + } + } + + .no-submission { + line-height: $submission-space-20; + padding: $submission-space-50 $submission-space-20; + text-align: center; + } + } + + .status-col { + text-align: center; + } + + .action-col { + text-align: center; + } +} + +.submission-row { + width: 100%; + font-size: 15px; + color: $tc-black; + font-weight: 400; + + td { + vertical-align: middle; + padding: 16px 16px 2px 16px; + background: $tc-white; + font-size: 14px; + color: $tc-black; + font-weight: 500; + border-top: 1px solid $tc-gray-10; + line-height: 12px; + + @include xs-to-md { + padding: 16px 16px 2px 8px; + } + + &.no-submission { + line-height: $submission-space-20; + padding: $submission-space-50 $submission-space-20; + text-align: center; + } + + &.dev-details { + padding-right: 60px; + border-top: 0; + padding-top: 0; + } + } + + .id-col { + font-weight: 700; + width: 32%; + } + + .type-col { + width: 21%; + } + + .date-col { + width: 20%; + color: $tc-black; + font-weight: 500; + line-height: 22px; + font-size: 14px; + } + + .status-col { + text-align: center; + + button { + background: none; + border: none; + padding: 0; + + .pending { + text-transform: initial; + font-size: 15px; + color: $tc-gray-40; + line-height: $submission-space-20; + } + } + } + + .action-col { + text-align: center; + + .delete-icon { + margin: 0 $submission-space-25; + } + + button { + background: none; + border: 0; + font-size: 0; + padding: 0; + line-height: 0; + display: inline-block; + margin-left: 15px; + margin-top: 3px; + + &:focus { + outline: none; + } + } + + .expand-icon { + transition: all 1.5s; + + &.expanded { + transform: rotate(180deg); + } + } + } + + .status-col button:focus { + outline: none; + } +} diff --git a/src/apps/earn/src/components/Tag/index.jsx b/src/apps/earn/src/components/Tag/index.jsx new file mode 100644 index 000000000..bdbf35d55 --- /dev/null +++ b/src/apps/earn/src/components/Tag/index.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import PT from "prop-types"; + +import styles from "./styles.scss"; + +const Tag = ({ tag, onClick }) => ( + { + onClick(tag); + }} + > + {tag} + +); + +Tag.propTypes = { + tag: PT.string, + onClick: PT.func, +}; + +export default Tag; diff --git a/src/apps/earn/src/components/Tag/styles.scss b/src/apps/earn/src/components/Tag/styles.scss new file mode 100644 index 000000000..81e4dc9f0 --- /dev/null +++ b/src/apps/earn/src/components/Tag/styles.scss @@ -0,0 +1,13 @@ +@import "@earn/styles/variables"; + +.tag { + display: inline-block; + padding: 3px 4px 3px 5px; + font-size: $font-size-xs; + line-height: 15px; + letter-spacing: 0.55px; + white-space: nowrap; + border: 1px solid $body-color; + border-radius: $border-radius-sm; + cursor: pointer; +} diff --git a/src/apps/earn/src/components/TagList/index.jsx b/src/apps/earn/src/components/TagList/index.jsx new file mode 100644 index 000000000..4db83b3eb --- /dev/null +++ b/src/apps/earn/src/components/TagList/index.jsx @@ -0,0 +1,148 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import PT from "prop-types"; + +import { Tooltip } from "~/libs/ui"; + +import styles from "./styles.scss"; + +/** + * Displays a tooltip with tags that are not initially shown. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const TagsTooltip = ({ children, onClickTag, renderTag, tags }) => { + const overlay = useMemo( + () => ( +
+ {tags + .map((tag) => ({ + tag, + onClickTag, + className: styles.tooltipTag, + })) + .map(renderTag)} +
+ ), + [onClickTag, renderTag, tags] + ); + return ( + + {children} + + ); +}; + +/** + * Displays a tag list. Initially displays only the first line of tags. + * + * @param {Object} props component properties + * @param {string} [props.className] class name added to root element + * @param {number} [props.maxTagCount] maximum number of tags to show + * @param {(t: any) => void} [props.onClickTag] function called when tag is clicked + * @param {(t: any) => JSX.Element} props.renderTag function that renders a tag + * @param {Array} props.tags array of tags + * @returns {JSX.Element} + */ +const TagList = ({ + className, + maxTagCount = 5, + onClickTag, + renderTag, + tags, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [showAll, setShowAll] = useState(false); + const [tagsVisible, setTagsVisible] = useState(tags); + + const containerRef = useRef(); + + const onClickBtnMore = useCallback( + (event) => { + event.preventDefault(); + setTagsVisible(tags); + setShowAll(true); + }, + [tags] + ); + + useEffect(() => { + let index = getFirstLineLastElemIndex(containerRef.current); + index = index < 0 ? maxTagCount : Math.min(index, maxTagCount); + if (tags.length < index + 1) { + setShowAll(true); + setTagsVisible(tags); + } else { + setTagsVisible(tags.slice(0, index)); + } + setIsVisible(true); + }, [tags, maxTagCount]); + + return ( +
+ {tagsVisible + .map((tag) => ({ className: styles.tag, onClickTag, tag })) + .map(renderTag)} + {!showAll && ( + + + {tags.length - tagsVisible.length}+ + + + )} +
+ ); +}; + +TagList.propTypes = { + className: PT.string, + maxTagCount: PT.number, + onClickTag: PT.func.isRequired, + renderTag: PT.func.isRequired, + tags: PT.arrayOf(PT.object).isRequired, +}; + +export default TagList; + +/** + * Searches for the index of the last element on the first line. + * + * @param {Element} container container element + * @returns {number} first line's last element's index + */ +function getFirstLineLastElemIndex(container) { + const firstElement = container.firstElementChild; + let offsetTop = firstElement.offsetTop; + for ( + let elem = firstElement.nextElementSibling, index = 1; + elem; + elem = elem.nextElementSibling, index++ + ) { + if (elem.offsetTop > offsetTop) { + return index - 1; + } + } + return -1; +} diff --git a/src/apps/earn/src/components/TagList/styles.scss b/src/apps/earn/src/components/TagList/styles.scss new file mode 100644 index 000000000..5fd9114c1 --- /dev/null +++ b/src/apps/earn/src/components/TagList/styles.scss @@ -0,0 +1,56 @@ +@import "../../styles/mixins"; +@import "../../styles/variables"; + +.container { + position: relative; + display: flex; + flex-wrap: wrap; + height: 27px; + opacity: 0; + + &.visible { + height: auto; + opacity: 1; + } +} + +.btn-more, +.tag { + margin-top: 4px; + margin-right: 4px; + border: 1px solid $body-color; + border-radius: 5px; + padding: 4px 6px 2px; + font-size: 12px; + line-height: 15px; + cursor: pointer; + + &:last-child { + margin-right: 0; + } +} + +.tooltip-tag-list { + display: flex; + flex-wrap: wrap; + background-color: transparent; +} + +.tooltipTag { + flex: 0 0 auto; + margin-top: 4px; + margin-right: 4px; + border-radius: 5px; + padding: 4px 5px 4px 6px; + @include roboto-regular; + font-size: 11px; + letter-spacing: 0.5px; + line-height: 15px; + color: #fff; + background-color: #555; + cursor: pointer; + + &:last-child { + margin-right: 0; + } +} diff --git a/src/apps/earn/src/components/Terms/TermDetails.jsx b/src/apps/earn/src/components/Terms/TermDetails.jsx new file mode 100644 index 000000000..8aaff868c --- /dev/null +++ b/src/apps/earn/src/components/Terms/TermDetails.jsx @@ -0,0 +1,91 @@ +/** + * Terms details component which display text of an agreement + */ + +import React from "react"; +import PT from "prop-types"; + +import { styled as styledCss } from "@earn/utils"; +import { LoadingCircles } from "~/libs/ui"; + +import styles from "./TermDetails.module.scss"; + +const styled = styledCss(styles); + +export default class TermDetails extends React.Component { + constructor(props) { + super(props); + this.state = { + loadingFrame: false, + }; + this.frameLoaded = this.frameLoaded.bind(this); + } + + componentWillMount() { + const { details, getDocuSignUrl } = this.props; + if ( + details.agreeabilityType !== "Electronically-agreeable" && + details.docusignTemplateId + ) { + getDocuSignUrl(details.docusignTemplateId); + this.setState({ loadingFrame: true }); + } + } + + frameLoaded() { + this.setState({ + loadingFrame: false, + }); + } + + render() { + const { details, docuSignUrl, loadingDocuSignUrl } = this.props; + const { loadingFrame } = this.state; + + return ( +
+ {details.agreeabilityType === "Electronically-agreeable" && ( +
+
+
+ )} + {details.agreeabilityType !== "Electronically-agreeable" && + details.docusignTemplateId === loadingDocuSignUrl && ( + + )} + {details.agreeabilityType !== "Electronically-agreeable" && + details.docusignTemplateId && + !loadingDocuSignUrl && + docuSignUrl && ( +
+ {loadingFrame && } +