diff --git a/.babelrc.js b/.babelrc.js new file mode 100644 index 0000000..1d52d1e --- /dev/null +++ b/.babelrc.js @@ -0,0 +1,59 @@ +"use strict"; + +module.exports = function(api) { + api.cache.using(() => process.env.NODE_ENV); + + const presets = [ + [ + "@babel/env", + { + modules: api.env("test") ? undefined : false, + useBuiltIns: "entry", + corejs: { + version: 3, + }, + debug: false, + }, + ], + "@babel/typescript", + "@babel/react", + ]; + + const plugins = [ + "@babel/plugin-transform-runtime", + [ + "@babel/plugin-proposal-decorators", + { + legacy: true, + }, + ], + [ + "@babel/plugin-proposal-class-properties", + { + loose: true, + }, + ], + [ + "import", + { + libraryName: "antd", + libraryDirectory: api.env("test") ? undefined : "es", + style: true, + }, + ], + "@babel/plugin-proposal-optional-catch-binding", + "@babel/plugin-proposal-optional-chaining", + "@babel/plugin-syntax-dynamic-import", + "lodash", + ]; + + return { + presets: presets, + plugins: plugins, + env: { + test: { + presets: presets, + }, + }, + }; +}; diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..05bdd2f --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,7 @@ +> 1% +last 2 versions +safari >= 6 +not ie > 0 +not ie_mob > 0 +not edge <= 15 +not op_mini all \ No newline at end of file diff --git a/.bundlesizerc b/.bundlesizerc new file mode 100644 index 0000000..a01cd89 --- /dev/null +++ b/.bundlesizerc @@ -0,0 +1,20 @@ +{ + "files": [ + { + "path": "./build/**/*.{js,ttf}", + "maxSize": "300 kB" + }, + { + "path": "./build/**/*.{png,svg,jpg,jpeg}", + "maxSize": "250 kB" + }, + { + "path": "./build/**/*.{css,scss}", + "maxSize": "50 kB" + }, + { + "path": "./build/**/*.html", + "maxSize": "50 kB" + } + ] +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b5ef85f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = false +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..42cc166 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +BUNDLE_NAME=IdeaSource +BROWSERSLIST_CONFIG=.browserslistrc +PORT=5000 \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..934767e --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +GENERATE_SOURCEMAP=true +REACT_APP_SERVER_BASE_URI=https://ideasource-staging.appspot.com/graphql \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..4f79a0f --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +GENERATE_SOURCEMAP=false \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1dd6ca8 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +build/* +.storybook_build/* +node_modules/* +typings/* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..35e6525 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,33 @@ +module.exports = { + extends: ["plugin:@typescript-eslint/recommended", "prettier/@typescript-eslint", "plugin:prettier/recommended"], // "plugin:jsx-a11y/recommended", "plugin:jest/recommended" + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2018, + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + rules: { + "prettier/prettier": "warn", + + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-member-accessibility": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-object-literal-type-assertion": "off", + "@typescript-eslint/no-parameter-properties": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/prefer-interface": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d6f09d --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +/.temp/ +/coverage + +# production +/.storybook_build/ +/build/ + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.release.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# webstorm +/.idea/ +/.idea/httpRequests +/.idea/inspectionProfiles/profiles_settings.xml +/.idea/usage.statistics.xml +/.idea/workspace.xml + +# others +.cache +.Spotlight-V100 +.Trashes +Thumbs.db +Desktop.ini +package-lock.json +/src/**/*.d.ts + +/.server/cloud_sql_proxy +/.server/*-client-key.json \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..bf4d038 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/services"] + path = src/services + url = https://github.com/orangeloops/phoenix-shared.git diff --git a/.graphql/ideasource.schema.graphql b/.graphql/ideasource.schema.graphql new file mode 100644 index 0000000..ab37b43 --- /dev/null +++ b/.graphql/ideasource.schema.graphql @@ -0,0 +1,228 @@ +# This file was generated based on ".graphqlconfig". Do not edit manually. + +schema { + query: Query + mutation: Mutation + subscription: Subscription +} + +type Challenge { + closeDate: DateTime + createdBy: User! + createdDate: DateTime! + deletedBy: User + deletedDate: DateTime + description: String! + endDate: DateTime + id: ID! + ideas(createdById: String, createdByMe: Boolean = false, first: Int = 5, orderBy: ReactionOrder): IdeaConnection! + imageUrl: String + modifiedBy: User! + modifiedDate: DateTime! + myReaction: Reaction + privacyData: String + privacyMode: ChallengePrivacyMode! + reactions(createdById: String, createdByMe: Boolean = false, first: Int = 5, orderBy: ReactionOrder): ReactionConnection! + reactionsSummary(value: String): [ReactionsSummaryItem!]! + title: String! + topIdea(reactionValue: String): Idea +} + +type ChallengeConnection { + edges: [ChallengeEdge!]! + pageInfo: ConnectionPageInfo! + totalCount: Int! +} + +type ChallengeEdge { + cursor: String! + node: Challenge! +} + +type CheckEmail { + isAvailable: Boolean + isBlacklisted: Boolean + isCorporate: Boolean +} + +type ConnectionPageInfo { + endCursor: String + hasNextPage: Boolean! +} + +type Idea { + challenge: Challenge + createdBy: User! + createdDate: DateTime! + deletedBy: User + deletedDate: DateTime + description: String + id: ID! + imageUrl: String + modifiedBy: User! + modifiedDate: DateTime! + myReaction: Reaction + reactions(createdById: String, createdByMe: Boolean = false, first: Int = 5, orderBy: ReactionOrder): ReactionConnection! + reactionsSummary(value: String): [ReactionsSummaryItem!]! + title: String! +} + +type IdeaConnection { + edges: [IdeaEdge!]! + pageInfo: ConnectionPageInfo! + totalCount: Int! +} + +type IdeaEdge { + cursor: String! + node: Idea! +} + +type Mutation { + _: Boolean + checkEmail(email: String!): CheckEmail + confirmEmail(token: String!): Void + createChallenge(closeDate: DateTime, description: String, endDate: DateTime, privacyMode: ChallengePrivacyMode, title: String!, upload: Upload): Challenge + createIdea(challengeId: String!, description: String, title: String!, upload: Upload): Idea + createReaction(objectId: ID!, objectType: ReactionObjectType!, value: String!): Reaction + deleteChallenge(id: ID!): Boolean! + deleteIdea(id: ID!): Boolean! + deleteReaction(id: ID!, objectType: ReactionObjectType!): Boolean! + deleteUser(id: ID!): Boolean! + refreshTokens(token: String!): Tokens + requestResetPassword(email: String!): Void + resendEmailConfirmation(email: String!): Void + resetPassword(password: String!, token: String!): Void + signIn(email: String!, generateRefreshToken: Boolean = false, password: String!): Tokens + signUp(email: String!, name: String!, password: String!, upload: Upload): Void + updateChallenge(closeDate: DateTime, description: String, endDate: DateTime, id: ID!, privacyMode: ChallengePrivacyMode, title: String!, upload: Upload): Challenge + updateIdea(description: String, id: ID!, title: String!, upload: Upload): Idea + updateUser(id: ID!, name: String, upload: Upload): User +} + +type Query { + _: Boolean + challenge(id: ID!): Challenge + challenges(after: String, createdById: String, createdByMe: Boolean = false, excludeClosed: Boolean = true, excludeEnded: Boolean = true, first: Int, orderBy: ChallengeOrder, withReactionByUserId: String): ChallengeConnection! + idea(id: ID!): Idea + ideas(after: String, challengeId: String, createdById: String, createdByMe: Boolean = false, first: Int, orderBy: IdeaOrder, withReactionByUserId: String): IdeaConnection! + me: User + reaction(id: ID!, objectType: ReactionObjectType!): Reaction + reactions(after: String, createdById: String, createdByMe: Boolean = false, first: Int, objectId: String, objectType: ReactionObjectType!, orderBy: ReactionOrder, value: String): ReactionConnection! + reactionsSummary(objectId: String!, objectType: ReactionObjectType!, value: String): [ReactionsSummaryItem!]! + reset: String + user(id: ID!): User + version: String +} + +type Reaction { + createdBy: User! + createdDate: DateTime! + deletedBy: User + deletedDate: DateTime + id: ID! + modifiedBy: User! + modifiedDate: DateTime! + objectId: ID! + value: String! +} + +type ReactionConnection { + edges: [ReactionEdge!]! + pageInfo: ConnectionPageInfo! + totalCount: Int! +} + +type ReactionEdge { + cursor: String! + node: Reaction! +} + +type ReactionsSummaryItem { + totalCount: Int! + value: String! +} + +type Subscription { + _: Boolean +} + +type Tokens { + refreshToken: String + token: String! +} + +type User { + challenges(after: String, first: Int, orderBy: ChallengeOrder): ChallengeConnection! + createdDate: DateTime! + deletedDate: DateTime + email: String! + id: ID! + ideas(after: String, first: Int, orderBy: IdeaOrder): IdeaConnection! + imageUrl: String + modifiedDate: DateTime! + name: String! + status: UserStatus! +} + +type Void { + _: Boolean +} + +enum ChallengeOrderField { + CREATED_DATE + TITLE + UPDATED_DATE +} + +enum ChallengePrivacyMode { + BYDOMAIN + PUBLIC +} + +enum ConnectionOrderDirection { + ASC + DESC +} + +enum IdeaOrderField { + CREATED_DATE + TITLE + UPDATED_DATE +} + +enum ReactionObjectType { + CHALLENGE + IDEA +} + +enum ReactionOrderField { + CREATED_DATE + UPDATED_DATE +} + +enum UserStatus { + ACTIVE + BLOCKED + PENDING +} + +input ChallengeOrder { + direction: ConnectionOrderDirection! + field: ChallengeOrderField! +} + +input IdeaOrder { + direction: ConnectionOrderDirection! + field: IdeaOrderField! +} + +input ReactionOrder { + direction: ConnectionOrderDirection! + field: ReactionOrderField! +} + +"A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar." +scalar DateTime + +scalar Upload diff --git a/.graphqlconfig b/.graphqlconfig new file mode 100644 index 0000000..e5dff10 --- /dev/null +++ b/.graphqlconfig @@ -0,0 +1,15 @@ +{ + "name": "IdeaSource v1 Schema", + "schemaPath": ".graphql/ideasource.schema.graphql", + "extensions": { + "endpoints": { + "production": { + "url": "https://ideasource.io/graphql", + "introspect": true + }, + "localhost": { + "url": "http://localhost:5000/graphql" + } + } + } +} \ No newline at end of file diff --git a/.huskyrc.js b/.huskyrc.js new file mode 100644 index 0000000..9edb3cb --- /dev/null +++ b/.huskyrc.js @@ -0,0 +1,5 @@ +module.exports = { + hooks: { + "pre-commit": "lint-staged", + }, +}; diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 0000000..4701074 --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,4 @@ +module.exports = { + "src/**/*.{js,jsx,ts,tsx}": ["prettier --write", "eslint --fix", "git add"], + "**/*.{css,scss}": ["prettier --write", "git add"], // "stylelint --fix" +}; \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1ab1b2a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +build +.storybook_build +node_modules/ +src/**/__snapshots__/ +package.json +package-lock.json \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..835b70e --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + printWidth: 240, + bracketSpacing: false, + jsxBracketSameLine: true, + trailingComma: "es5", +}; diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000..e89a005 --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,2 @@ +build/* +node_modules/* diff --git a/.stylelintrc.js b/.stylelintrc.js new file mode 100644 index 0000000..63de420 --- /dev/null +++ b/.stylelintrc.js @@ -0,0 +1,14 @@ +module.exports = { + extends: ["stylelint-config-recommended-scss"], // "stylelint-a11y/recommended" + plugins: ["stylelint-prettier"], // "stylelint-a11y" + rules: { + "prettier/prettier": null, + + "declaration-block-no-duplicate-properties": null, + "font-family-no-missing-generic-family-keyword": null, + "indentation": null, + "max-empty-lines": 1, + "no-descending-specificity": null, + "no-duplicate-selectors": null, + }, +}; \ No newline at end of file diff --git a/README.md b/README.md index f19d0a1..c00c7b7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ -# public-phoenix-web -OrangeLoops IdeaSource Project +# IdeaSource Web Project + +## What's IdeaSource? + +[IdeaSource](https://ideasource.io/) is an open-source platform to gather, manage and organize ideas for creative problem solving. We used it internally as a sandbox to research React Native development front to back. + +## Why IdeaSource? + +Coming up with good ideas can be hard, especially after a while when echo chambers tend to appear in teams, groupthink takes over and creative solutions are left out of the conversation. We wished to introduce an easy way to share and manage ideas generated by a larger pool of people than those directly tasked with coming up with a solution, providing mechanisms for everyone to vote to sort the proposed ideas. + +## How does it work? + +Ideas are gathered around challenges, which can be problems, aspects or topics a group or organization may need to fix or improve. In order to create a challenge you must enter a description, upload an image and set a deadline. Challenges can be either private or public. Private challenges are only available to users registered using the same corporate email domain, while open challenges are open for every user in IdeaSource. Once a challenge is submitted, everyone can start contributing ideas, and votes for ideas already shared. + +## Getting Started + +Run the following commands in your terminal + +```bash +git clone https://github.com/orangeloops/public-phoenix-web.git +cd public-phoenix-web +npm install +npm run start +``` + +Then open [http://localhost:5000/](http://localhost:5000/) on your web browser. + +### Testing + +Run `npm test` for test. + +## Deploying + +For deployment, run `npm run build` and upload `build/` to your server. + +## License +>You can check out the full license [here](https://github.com/orangeloops/public-phoenix-web/blob/develop/LICENSE) + +This project is licensed under the terms of the MIT license. + +--- + +[orangeloops.com](https://www.orangeloops.com/)  ·  +[twitter](https://twitter.com/orangeloopsinc/)  ·  +[blog](https://orangeloops.com/blog/)  ·  +[IdeaSource](https://ideasource.io/) diff --git a/config/env.js b/config/env.js new file mode 100644 index 0000000..42973a9 --- /dev/null +++ b/config/env.js @@ -0,0 +1,105 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const paths = require("./paths"); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve("./paths")]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error("The NODE_ENV environment variable is required but was not specified."); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +var dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== "test" && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require("dotenv-expand")( + require("dotenv").config({ + path: dotenvFile, + }) + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebook/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. +// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || "") + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in Webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +const appPackage = require(paths.appPackageJson); + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || "development", + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + + STORYBOOK_ENV: process.env.STORYBOOK_ENV, + + BUNDLE_TYPE: process.env.BUNDLE_TYPE || "app", + BUNDLE_NAME: process.env.BUNDLE_NAME || appPackage.name, + + PACKAGE_NAME: appPackage.name, + PACKAGE_VERSION: appPackage.version, + + WEBPACK_CONFIG: process.env.WEBPACK_CONFIG, + STYLESHEETS_CONFIG: process.env.STYLESHEETS_CONFIG, + BROWSERSLIST_CONFIG: process.env.BROWSERSLIST_CONFIG, + } + ); + // Stringify all values so we can feed into Webpack DefinePlugin + const stringified = { + "process.env": Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return {raw, stringified}; +} + +module.exports = getClientEnvironment; diff --git a/config/helper.js b/config/helper.js new file mode 100644 index 0000000..070221d --- /dev/null +++ b/config/helper.js @@ -0,0 +1,16 @@ +"use strict"; + +const ModuleDependencyWarning = require("webpack/lib/ModuleDependencyWarning"); + +module.exports = { + IgnoreNotFoundExportPlugin: class IgnoreNotFoundExportPlugin { + apply(compiler) { + const messageRegExp = /export '.*'( \(reexported as '.*'\))? was not found in/; + + const doneHook = stats => (stats.compilation.warnings = stats.compilation.warnings.filter(warning => !(warning instanceof ModuleDependencyWarning && messageRegExp.test(warning.message)))); + + if (compiler.hooks) compiler.hooks.done.tap("IgnoreNotFoundExportPlugin", doneHook); + else compiler.plugin("done", doneHook); + } + }, +}; diff --git a/config/modules.js b/config/modules.js new file mode 100644 index 0000000..8a5e300 --- /dev/null +++ b/config/modules.js @@ -0,0 +1,77 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const paths = require("./paths"); +const chalk = require("react-dev-utils/chalk"); + +/** + * Get the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePaths(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + // If there's no baseUrl set we respect NODE_PATH + // Note that NODE_PATH is deprecated and will be removed + // in the next major release of create-react-app. + + const nodePath = process.env.NODE_PATH || ""; + return nodePath.split(path.delimiter).filter(Boolean); + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === "") { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === "") { + return [paths.appSrc]; + } + + // Otherwise, throw an error. + throw new Error(chalk.red.bold("Your project's `baseUrl` can only be set to `src` or `node_modules`." + " Create React App does not support other values at this time.")); +} + +function getModules() { + // Check if TypeScript is setup + const hasTsConfig = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (hasTsConfig && hasJsConfig) { + throw new Error("You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file."); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // TypeScript project and set up the config + // based on tsconfig.json + if (hasTsConfig) { + config = require(paths.appTsConfig); + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const additionalModulePaths = getAdditionalModulePaths(options); + + return { + additionalModulePaths: additionalModulePaths, + hasTsConfig, + }; +} + +module.exports = getModules(); diff --git a/config/paths.js b/config/paths.js new file mode 100644 index 0000000..274098c --- /dev/null +++ b/config/paths.js @@ -0,0 +1,76 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const url = require("url"); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = relativePath => path.resolve(appDirectory, relativePath); + +const envPublicUrl = process.env.PUBLIC_URL; + +function ensureSlash(inputPath, needsSlash) { + const hasSlash = inputPath.endsWith("/"); + if (hasSlash && !needsSlash) { + return inputPath.substr(0, inputPath.length - 1); + } else if (!hasSlash && needsSlash) { + return `${inputPath}/`; + } else { + return inputPath; + } +} + +const getPublicUrl = appPackageJson => envPublicUrl || require(appPackageJson).homepage; + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// Webpack needs to know it to put the right + + diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..ed65beb --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "IdeaSource", + "short_name": "IdeaSource", + "icons": [ + { + "src": "./assets/google/android-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./assets/google/android-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..0453066 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,155 @@ +"use strict"; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = "production"; +process.env.NODE_ENV = "production"; + +const isBundleTypeLibrary = process.env.BUNDLE_TYPE === "library"; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on("unhandledRejection", err => { + throw err; +}); + +// Ensure environment variables are read. +require("../config/env"); + +const path = require("path"); +const chalk = require("chalk"); +const fs = require("fs-extra"); +const webpack = require("webpack"); +const bfj = require("bfj"); +const configFactory = require("../config/webpack.config"); +const paths = require("../config/paths"); +const checkRequiredFiles = require("react-dev-utils/checkRequiredFiles"); +const formatWebpackMessages = require("react-dev-utils/formatWebpackMessages"); +const printHostingInstructions = require("react-dev-utils/printHostingInstructions"); +const FileSizeReporter = require("react-dev-utils/FileSizeReporter"); +const printBuildError = require("react-dev-utils/printBuildError"); + +const measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild; +const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; +const useYarn = fs.existsSync(paths.yarnLockFile); + +// These sizes are pretty large. We'll warn for bundles exceeding them. +const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; +const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; + +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndex])) { + process.exit(1); +} + +// Process CLI arguments +const argv = process.argv.slice(2); +const writeStatsJson = argv.indexOf("--stats") !== -1; + +// Generate configuration +const config = configFactory("production"); + +// We require that you explictly set browsers and do not fall back to +// browserslist defaults. +const {checkBrowsers} = require("react-dev-utils/browsersHelper"); +checkBrowsers(paths.appPath) + .then(() => { + // First, read the current file sizes in build directory. + // This lets us display how much they changed later. + return measureFileSizesBeforeBuild(paths.appBuild); + }) + .then(previousFileSizes => { + // Remove all content but keep the directory so that + // if you're in it, you don't end up in Trash + fs.emptyDirSync(paths.appBuild); + + // Merge with the public folder + if (!isBundleTypeLibrary) copyPublicFolder(); + + // Start the webpack build + return build(previousFileSizes); + }) + .then( + ({stats, previousFileSizes, warnings}) => { + if (warnings.length) { + console.log(chalk.yellow("Compiled with warnings.\n")); + console.log(warnings.join("\n\n")); + console.log("\nSearch for the " + chalk.underline(chalk.yellow("keywords")) + " to learn more about each warning."); + console.log("To ignore, add " + chalk.cyan("// eslint-disable-next-line") + " to the line before.\n"); + } else { + console.log(chalk.green("Compiled successfully.\n")); + } + + const appPackage = require(paths.appPackageJson); + const publicUrl = paths.publicUrl; + const publicPath = config.output.publicPath; + const buildFolder = path.relative(process.cwd(), paths.appBuild); + + console.log("File sizes after gzip:\n"); + printFileSizesAfterBuild(stats, previousFileSizes, buildFolder, WARN_AFTER_BUNDLE_GZIP_SIZE, WARN_AFTER_CHUNK_GZIP_SIZE); + console.log(); + + printHostingInstructions(appPackage, publicUrl, publicPath, buildFolder, useYarn); + }, + err => { + console.log(chalk.red("Failed to compile.\n")); + printBuildError(err); + process.exit(1); + } + ) + .catch(err => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); + +// Create the production build and print the deployment instructions. +function build(previousFileSizes) { + console.log("Creating an optimized production build..."); + + let compiler = webpack(config); + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err) { + return reject(err); + } + const messages = formatWebpackMessages(stats.toJson({all: false, warnings: true, errors: true})); + if (messages.errors.length) { + // Only keep the first error. Others are often indicative + // of the same problem, but confuse the reader with noise. + if (messages.errors.length > 1) { + messages.errors.length = 1; + } + return reject(new Error(messages.errors.join("\n\n"))); + } + if (process.env.CI && (typeof process.env.CI !== "string" || process.env.CI.toLowerCase() !== "false") && messages.warnings.length) { + console.log(chalk.yellow("\nTreating warnings as errors because process.env.CI = true.\n" + "Most CI servers set it automatically.\n")); + return reject(new Error(messages.warnings.join("\n\n"))); + } + + const resolveArgs = { + stats, + previousFileSizes, + warnings: messages.warnings, + }; + if (writeStatsJson) { + return bfj + .write(paths.appBuild + "/bundle-stats.json", stats.toJson()) + .then(() => resolve(resolveArgs)) + .catch(error => reject(new Error(error))); + } + + return resolve(resolveArgs); + }); + }); +} + +function copyPublicFolder() { + fs.copySync(paths.appPublic, paths.appBuild, { + dereference: true, + filter: file => file !== paths.appHtml, + }); +} diff --git a/scripts/postbuild.js b/scripts/postbuild.js new file mode 100644 index 0000000..3918c74 --- /dev/null +++ b/scripts/postbuild.js @@ -0,0 +1 @@ +"use strict"; diff --git a/scripts/poststart.js b/scripts/poststart.js new file mode 100644 index 0000000..3918c74 --- /dev/null +++ b/scripts/poststart.js @@ -0,0 +1 @@ +"use strict"; diff --git a/scripts/prebuild.js b/scripts/prebuild.js new file mode 100644 index 0000000..3918c74 --- /dev/null +++ b/scripts/prebuild.js @@ -0,0 +1 @@ +"use strict"; diff --git a/scripts/prestart.js b/scripts/prestart.js new file mode 100644 index 0000000..3918c74 --- /dev/null +++ b/scripts/prestart.js @@ -0,0 +1 @@ +"use strict"; diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 0000000..62379c2 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,111 @@ +"use strict"; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = "development"; +process.env.NODE_ENV = "development"; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on("unhandledRejection", err => { + throw err; +}); + +// Ensure environment variables are read. +require("../config/env"); + +const fs = require("fs"); +const chalk = require("react-dev-utils/chalk"); +const webpack = require("webpack"); +const WebpackDevServer = require("webpack-dev-server"); +const clearConsole = require("react-dev-utils/clearConsole"); +const checkRequiredFiles = require("react-dev-utils/checkRequiredFiles"); +const {choosePort, createCompiler, prepareProxy, prepareUrls} = require("react-dev-utils/WebpackDevServerUtils"); +const openBrowser = require("react-dev-utils/openBrowser"); +const paths = require("../config/paths"); +const configFactory = require("../config/webpack.config"); +const createDevServerConfig = require("../config/webpackDevServer.config"); + +const useYarn = fs.existsSync(paths.yarnLockFile); +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndex])) { + process.exit(1); +} + +// Tools like Cloud9 rely on this. +const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; +const HOST = process.env.HOST || "0.0.0.0"; + +if (process.env.HOST) { + console.log(chalk.cyan(`Attempting to bind to HOST environment variable: ${chalk.yellow(chalk.bold(process.env.HOST))}`)); + console.log(`If this was unintentional, check that you haven't mistakenly set it in your shell.`); + console.log(`Learn more here: ${chalk.yellow("https://bit.ly/CRA-advanced-config")}`); + console.log(); +} + +// We require that you explictly set browsers and do not fall back to +// browserslist defaults. +const {checkBrowsers} = require("react-dev-utils/browsersHelper"); +checkBrowsers(paths.appPath, isInteractive) + .then(() => { + // We attempt to use the default port but if it is busy, we offer the user to + // run on a different port. `choosePort()` Promise resolves to the next free port. + return choosePort(HOST, DEFAULT_PORT); + }) + .then(port => { + if (port == null) { + // We have not found a port. + return; + } + const config = configFactory("development"); + const protocol = process.env.HTTPS === "true" ? "https" : "http"; + const appName = require(paths.appPackageJson).name; + const useTypeScript = fs.existsSync(paths.appTsConfig); + const urls = prepareUrls(protocol, HOST, port); + const devSocket = { + warnings: warnings => devServer.sockWrite(devServer.sockets, "warnings", warnings), + errors: errors => devServer.sockWrite(devServer.sockets, "errors", errors), + }; + // Create a webpack compiler that is configured with custom messages. + const compiler = createCompiler({ + appName, + config, + devSocket, + urls, + useYarn, + useTypeScript, + webpack, + }); + // Load proxy config + const proxySetting = require(paths.appPackageJson).proxy; + const proxyConfig = prepareProxy(proxySetting, paths.appPublic); + // Serve webpack assets generated by the compiler over a web server. + const serverConfig = createDevServerConfig(proxyConfig, urls.lanUrlForConfig); + const devServer = new WebpackDevServer(compiler, serverConfig); + // Launch WebpackDevServer. + devServer.listen(port, HOST, err => { + if (err) { + return console.log(err); + } + if (isInteractive) { + clearConsole(); + } + console.log(chalk.cyan("Starting the development server...\n")); + openBrowser(urls.localUrlForBrowser); + }); + + ["SIGINT", "SIGTERM"].forEach(function(sig) { + process.on(sig, function() { + devServer.close(); + process.exit(); + }); + }); + }) + .catch(err => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); diff --git a/src/assets/images/camera.svg b/src/assets/images/camera.svg new file mode 100644 index 0000000..3a4fa80 --- /dev/null +++ b/src/assets/images/camera.svg @@ -0,0 +1,15 @@ + + + + Fill 149 + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/cell_menu.png b/src/assets/images/cell_menu.png new file mode 100644 index 0000000..5053a98 Binary files /dev/null and b/src/assets/images/cell_menu.png differ diff --git a/src/assets/images/crss.png b/src/assets/images/crss.png new file mode 100644 index 0000000..1c9120a Binary files /dev/null and b/src/assets/images/crss.png differ diff --git a/src/assets/images/delete.png b/src/assets/images/delete.png new file mode 100644 index 0000000..74afe8e Binary files /dev/null and b/src/assets/images/delete.png differ diff --git a/src/assets/images/edit.png b/src/assets/images/edit.png new file mode 100644 index 0000000..1fa9a41 Binary files /dev/null and b/src/assets/images/edit.png differ diff --git a/src/assets/images/likes.svg b/src/assets/images/likes.svg new file mode 100644 index 0000000..3619086 --- /dev/null +++ b/src/assets/images/likes.svg @@ -0,0 +1,20 @@ + + + + Fill 72 + Created with Sketch. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/lock.png b/src/assets/images/lock.png new file mode 100644 index 0000000..ce286b1 Binary files /dev/null and b/src/assets/images/lock.png differ diff --git a/src/assets/images/login.svg b/src/assets/images/login.svg new file mode 100644 index 0000000..30b23c5 --- /dev/null +++ b/src/assets/images/login.svg @@ -0,0 +1,16 @@ + + + + photo-1483706571191-85c0c76b1947@2x + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/logo.png b/src/assets/images/logo.png new file mode 100644 index 0000000..fd4ab78 Binary files /dev/null and b/src/assets/images/logo.png differ diff --git a/src/assets/images/orangeloops_logo.png b/src/assets/images/orangeloops_logo.png new file mode 100644 index 0000000..68af896 Binary files /dev/null and b/src/assets/images/orangeloops_logo.png differ diff --git a/src/assets/images/signup.svg b/src/assets/images/signup.svg new file mode 100644 index 0000000..d4d9363 --- /dev/null +++ b/src/assets/images/signup.svg @@ -0,0 +1,15 @@ + + + + photo-1531379410502-63bfe8cdaf6f + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/validate.svg b/src/assets/images/validate.svg new file mode 100644 index 0000000..52c64fe --- /dev/null +++ b/src/assets/images/validate.svg @@ -0,0 +1,14 @@ + + + + photo-1518829579054-df4bf5d42da7 + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/src/assets/stylesheets/abstracts/README.md b/src/assets/stylesheets/abstracts/README.md new file mode 100755 index 0000000..19e5889 --- /dev/null +++ b/src/assets/stylesheets/abstracts/README.md @@ -0,0 +1,7 @@ +# Abstracts + +The `abstracts/` folder gathers all Sass tools and helpers used across the project. Every global variable, function, mixin and placeholder should be put in here. + +The rule of thumb for this folder is that it should not output a single line of CSS when compiled on its own. These are nothing but Sass helpers. + +Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Abstracts folder](http://sass-guidelin.es/#abstracts-folder) diff --git a/src/assets/stylesheets/abstracts/_functions.scss b/src/assets/stylesheets/abstracts/_functions.scss new file mode 100755 index 0000000..ab659f7 --- /dev/null +++ b/src/assets/stylesheets/abstracts/_functions.scss @@ -0,0 +1,3 @@ +// ----------------------------------------------------------------------------- +// This file contains all application-wide Sass functions. +// ----------------------------------------------------------------------------- diff --git a/src/assets/stylesheets/abstracts/_mixins.scss b/src/assets/stylesheets/abstracts/_mixins.scss new file mode 100755 index 0000000..d6be077 --- /dev/null +++ b/src/assets/stylesheets/abstracts/_mixins.scss @@ -0,0 +1,87 @@ +// ----------------------------------------------------------------------------- +// This file contains all application-wide Sass mixins. +// ----------------------------------------------------------------------------- + +/// Event wrapper +/// @author Harry Roberts +/// @param {Bool} $self [false] - Whether or not to include current selector +/// @link https://twitter.com/csswizardry/status/478938530342006784 Original tweet from Harry Roberts +@mixin on-event($self: false) { + @if $self { + &, + &:hover, + &:active, + &:focus { + @content; + } + } @else { + &:hover, + &:active, + &:focus { + @content; + } + } +} + +/// Make a context based selector a little more friendly +/// @author Hugo Giraudel +/// @param {String} $context +@mixin when-inside($context) { + #{$context} & { + @content; + } +} + +/// Responsive breakpoint manager +/// @access publicHome +/// @param {String} $breakpoint - Breakpoint +/// @requires $breakpoints +@mixin respond-to($breakpoint) { + $raw-query: map-get($breakpoints, $breakpoint); + + @if $raw-query { + $query: if(type-of($raw-query) == "string", unquote($raw-query), inspect($raw-query)); + + @media #{$query} { + @content; + } + } @else { + @error 'No value found for `#{$breakpoint}`. ' + + 'Please make sure it is defined in `$breakpoints` map.'; + } +} + +// http://aslanbakan.com/en/blog/browser-and-device-specific-css-styles-with-sass-and-less-mixins +@mixin browser($browsers) { + @each $browser in $browsers { + html[data-browser*="#{$browser}"] & { + @content; + } + } +} + +@mixin ellipsisMultiline($linesCount: 1) { + display: block; /* Fallback for non-webkit */ + display: -webkit-box; + -webkit-line-clamp: $linesCount; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + + @supports not (-webkit-line-clamp: $linesCount) { + height: 1em * 1.2 * $linesCount; /* Fallback for non-webkit */ + line-height: 1.2; + } + + @include browser(Trident) { + white-space: nowrap; + } +} + +@mixin ellipsis() { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + word-break: break-all; +} diff --git a/src/assets/stylesheets/abstracts/_variables.scss b/src/assets/stylesheets/abstracts/_variables.scss new file mode 100755 index 0000000..2c49072 --- /dev/null +++ b/src/assets/stylesheets/abstracts/_variables.scss @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------------- +// This file contains all application-wide Sass variables. +// ----------------------------------------------------------------------------- + +/// Colors +$ph-primary-color: #604f8b !default; +$ph-primary-color-darker: darken($ph-primary-color, 7); +$ph-primary-color-lighter: lighten($ph-primary-color, 7); +$ph-primary-color-half-opacity: rgba(96, 79, 140, 0.5) !default; +$ph-secondary-color: #ba9ce8 !default; +$ph-third-color: #efeafc !default; +$ph-black-color: #454545 !default; +$ph-white-color: #ffffff !default; +$ph-cloudy-gray-color: #7e7b7b !default; +$ph-light-gray-color: #aeaeae !default; +$ph-auxiliary-color: #edebf0 !default; + +/// Breakpoints +$breakpoints: ( + "xs": ( + max-width: 575px, + ), + "sm": ( + max-width: 768px, + ), + "md": ( + max-width: 992px, + ), + "lg": ( + max-width: 1200px, + ), + "xl": ( + max-width: 1600px, + ), + "xxl": ( + min-width: 1601px, + ), +) !default; diff --git a/src/assets/stylesheets/base/README.md b/src/assets/stylesheets/base/README.md new file mode 100755 index 0000000..c0bea08 --- /dev/null +++ b/src/assets/stylesheets/base/README.md @@ -0,0 +1,5 @@ +# Base + +The `base/` folder holds what we might call the boilerplate code for the project. In there, you might find some typographic rules, and probably a stylesheet (that I’m used to calling `_base.scss`), defining some standard styles for commonly used HTML elements. + +Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Base folder](http://sass-guidelin.es/#base-folder) diff --git a/src/assets/stylesheets/base/_base.scss b/src/assets/stylesheets/base/_base.scss new file mode 100755 index 0000000..fe8a836 --- /dev/null +++ b/src/assets/stylesheets/base/_base.scss @@ -0,0 +1,52 @@ +// ----------------------------------------------------------------------------- +// This file contains very basic styles. +// ----------------------------------------------------------------------------- + +/** + * Set up a decent box model on the root element + */ +html { + height: 100%; + box-sizing: border-box; +} + +/** + * Make all elements from the DOM inherit from the parent box-sizing + * Since `*` has a specificity of 0, it does not override the `html` value + * making all elements inheriting from the root box-sizing value + * See: https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ + */ + +* { + font: { + family: "Montserrat" !important; + } +} +*, +*::before, +*::after { + box-sizing: inherit; +} + +body { + height: 100%; + margin: 0; + padding: 0; + font: { + family: "Montserrat" !important; + } +} + +p { + margin: 0; + padding: 0; +} + +#root { + height: 100%; +} + +.app-container { + width: 100%; + height: 100%; +} diff --git a/src/assets/stylesheets/base/_fonts.scss b/src/assets/stylesheets/base/_fonts.scss new file mode 100755 index 0000000..f789910 --- /dev/null +++ b/src/assets/stylesheets/base/_fonts.scss @@ -0,0 +1,27 @@ +// ----------------------------------------------------------------------------- +// This file contains all @font-face declarations, if any. +// ----------------------------------------------------------------------------- + +@font-face { + font-family: Montserrat; + src: url("./fonts/Montserrat-Bold.ttf"); + font-weight: bold; +} + +@font-face { + font-family: Montserrat; + src: url("./fonts/Montserrat-Regular.ttf"); + font-weight: normal; +} + +@font-face { + font-family: Montserrat; + src: url("./fonts/Montserrat-Light.ttf"); + font-weight: 300; +} + +@font-face { + font-family: Montserrat; + src: url("./fonts/Montserrat-SemiBold.ttf"); + font-weight: 600; +} diff --git a/src/assets/stylesheets/base/_helpers.scss b/src/assets/stylesheets/base/_helpers.scss new file mode 100755 index 0000000..ed302e5 --- /dev/null +++ b/src/assets/stylesheets/base/_helpers.scss @@ -0,0 +1,3 @@ +// ----------------------------------------------------------------------------- +// This file contains CSS helper classes. +// ----------------------------------------------------------------------------- diff --git a/src/assets/stylesheets/components/README.md b/src/assets/stylesheets/components/README.md new file mode 100755 index 0000000..ba7e8c4 --- /dev/null +++ b/src/assets/stylesheets/components/README.md @@ -0,0 +1,5 @@ +# Components + +For small components, there is the `components/` folder. While `layout/` is macro (defining the global wireframe), `components/` is more focused on widgets. It contains all kind of specific modules like a slider, a loader, a widget, and basically anything along those lines. There are usually a lot of files in components/ since the whole site/application should be mostly composed of tiny modules. + +Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Components folder](http://sass-guidelin.es/#components-folder) diff --git a/src/assets/stylesheets/components/_aboutmodal.scss b/src/assets/stylesheets/components/_aboutmodal.scss new file mode 100644 index 0000000..ab3aaac --- /dev/null +++ b/src/assets/stylesheets/components/_aboutmodal.scss @@ -0,0 +1,132 @@ +.ant-modal.ph-modal.ph-about-modal { + max-width: 400px; + margin: 0 auto; + + .ant-modal-content { + margin: 0; + + @include respond-to("sm") { + height: auto; + } + } +} + +.ph-about-modal-content-container { + > *:not(:last-child) { + margin-bottom: 11px; + } +} + +.ph-about-modal-content-container { + width: 100%; + min-height: 100%; + background-color: $ph-auxiliary-color; + padding: { + top: 33px; + right: 44px; + bottom: 44px; + left: 44px; + } + display: flex; + flex-direction: column; + align-items: center; + + @include respond-to("sm") { + padding: 33px 46px 25px 46px; + } + @include respond-to("xs") { + padding: 44px 22px; + justify-content: center; + } +} + +.ph-about-modal-what-is { + color: $ph-primary-color; + font: { + weight: 600; + size: 26px; + } + padding: 22px 0; + + &:hover { + color: $ph-secondary-color; + } + + @include respond-to("xs") { + padding: 11px 0 33px; + } +} + +.ph-about-modal-separator { + width: 80%; + height: 1px; + background-color: #d8d8d8; +} + +.ph-about-modal-built-by { + color: $ph-black-color; + font: { + weight: 600; + size: 16px; + } +} + +.ph-about-modal-orangeloops-logo-wrapper { + width: 100%; +} + +.ph-about-modal-orangeloops-logo { + background-image: url("../../assets/images/orangeloops_logo.png"); + background-size: contain; + background-repeat: no-repeat; + height: 50px; + width: 40%; + max-width: 130px; + cursor: pointer; + margin: 0 auto; +} + +.ph-about-modal-follow-us { + color: $ph-black-color; + font: { + weight: normal; + size: 14px; + } + padding-top: 11px; +} + +.ph-about-modal-twitter-icon.anticon { + color: #55acee; + font-size: 34px; + margin-right: 20px; +} + +.ph-about-modal-facebook-icon.anticon { + color: #3b5998; + font-size: 34px; +} + +.ph-about-modal-terms-of-use { + color: $ph-primary-color; + font: { + weight: 600; + size: 14px; + } + padding-top: 11px; + + &:hover { + color: $ph-secondary-color; + } +} + +.ph-about-modal-privacy-policy { + color: $ph-primary-color; + font: { + weight: 600; + size: 14px; + } + + &:hover { + color: $ph-secondary-color; + } +} diff --git a/src/assets/stylesheets/components/_challenge.scss b/src/assets/stylesheets/components/_challenge.scss new file mode 100644 index 0000000..219e2e8 --- /dev/null +++ b/src/assets/stylesheets/components/_challenge.scss @@ -0,0 +1,301 @@ +.ph-challenge { + width: 100%; + height: 100%; + color: $ph-black-color; +} + +.ph-challenge-image-box { + margin-top: 54px; + width: 100%; + height: 500px; + position: fixed; + z-index: 0; + background-color: #edebf0; + + @include respond-to("xs") { + height: 300px; + } +} + +.ph-challenge-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ph-challenge-wrapper { + position: absolute; + top: 500px; + right: 0; + left: 0; + background: #fff; + z-index: 1; + + @include respond-to("xs") { + top: 300px; + } +} + +.ph-challenge-summary { + width: 100%; + height: 99px; + background-color: $ph-auxiliary-color; + padding: 0 33px; + display: flex; + justify-content: space-between; + + @include respond-to("xs") { + padding: 0 11px; + } +} + +.ph-challenge-summary-creation { + width: 100%; + height: 100%; + margin-right: 11px; + padding: 10px 0px; + display: flex; + align-items: center; + + @include respond-to("xs") { + width: 60%; + } + + @include respond-to("xs") { + width: auto; + } +} + +.ph-challenge-summary-creation-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + border: 1px solid $ph-primary-color; + background-color: $ph-primary-color; + object-fit: cover; + + cursor: pointer; + + @include respond-to("xs") { + width: 50px; + height: 50px; + } +} + +.ph-challenge-summary-creation-data { + display: flex; + flex-direction: column; + justify-content: center; + color: $ph-black-color; + margin-left: 11px; + + > :not(:last-child) { + margin-bottom: 1px; + } + + @include respond-to("xs") { + display: none; + } +} + +.ph-challenge-summary-creation-data-name { + font: { + family: Montserrat; + weight: 600; + size: 24px; + } + @include ellipsisMultiline(1); +} + +.ph-challenge-summary-creation-data-date { + font: { + family: Montserrat; + weight: 300; + size: 18px; + } + + @include respond-to("xs") { + font: { + size: 12px; + } + } +} + +.ph-challenge-summary-deadlines { + width: 100%; + margin: 22px 0px; + display: flex; + justify-content: flex-end; + + @include respond-to("sm") { + width: 100%; + } +} + +.ph-challenge-summary-deadlines-content { + width: auto; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + + &.ideas { + padding-right: 33px; + text-align: right; + } + + &.challenge { + padding-left: 33px; + text-align: left; + } + + @include respond-to("sm") { + justify-content: space-around; + + &.ideas { + padding-right: 11px; + } + + &.challenge { + padding-left: 11px; + } + } +} + +.ph-challenge-summary-deadlines-date { + color: $ph-secondary-color; + font: { + family: Montserrat; + weight: bold; + size: 22px; + } + + @include respond-to("xs") { + font: { + size: 15px; + } + } +} + +.ph-challenge-summary-deadlines-label { + font: { + family: Montserrat; + weight: bold; + size: 14px; + } + width: auto; + + @include respond-to("xs") { + font: { + size: 12px; + } + @include ellipsisMultiline(1); + word-break: break-all !important; + width: 100%; + } +} + +.ph-challenge-summary-deadlines-separator { + flex: 0 0 1px; + height: 100%; + background-color: $ph-light-gray-color; +} + +.ph-challenge-info-box { + width: 100%; + height: auto; + padding: 33px; + + @include respond-to("xs") { + padding: 22px 11px 33px; + } +} + +.ph-challenge-title { + font: { + size: 26px; + weight: 700; + } + margin-right: 20px; + + @include respond-to("xs") { + font-size: 18px; + } +} + +.ph-challenge-description-box { + display: flex; + flex-direction: column; +} + +.ph-challenge-description { + height: auto; + text-align: justify; + font: { + size: 18px; + weight: normal; + } + + @include respond-to("xs") { + text-align: start; + font-size: 14px; + } +} + +.ph-challenge-add-idea-button { + background: $ph-primary-color; + text-align: center; + font-size: 12px; + font-weight: 700; + color: $ph-white-color; + padding: 11px 30px; + cursor: pointer; + text-transform: uppercase; + height: 40px; + align-self: flex-end; + margin-top: 11px; + + &:hover { + background-color: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + @include respond-to("xs") { + padding: 11px; + width: 100%; + } +} + +.ph-challenge-ideas-wrapper { + width: 100%; + margin: 0px 0px 44px; + padding: 33px; + display: grid; + grid-template-columns: 100%; + grid-auto-rows: min-content; + grid-gap: 22px 0px; + + @include respond-to("md") { + padding: 0px 22px; + } + + @include respond-to("xs") { + padding: 0px 11px; + } +} + +.ph-challenge-private-label { + padding: 5px 33px; + display: flex; + justify-content: flex-end; + align-items: center; +} + +.ph-challenge-private-image { + width: 10px; + height: 15px; + margin-left: 5px; +} diff --git a/src/assets/stylesheets/components/_challengeideacell.scss b/src/assets/stylesheets/components/_challengeideacell.scss new file mode 100644 index 0000000..dacc951 --- /dev/null +++ b/src/assets/stylesheets/components/_challengeideacell.scss @@ -0,0 +1,181 @@ +.ph-challenge-idea-cell-wrapper { + border: 1px solid $ph-light-gray-color; + padding: 11px; + display: grid; + grid-template-columns: 1fr 2fr; + grid-auto-rows: 200px; + grid-gap: 0px 17px; + grid-template-areas: "image data"; + position: relative; + + @include respond-to("xs") { + padding: 11px; + grid-template-columns: 1fr; + grid-auto-rows: 200px auto; + grid-gap: 17px 0px; + grid-template-areas: "image" "data"; + } +} + +.ph-challenge-idea-cell-dropdown-text { + padding: 0 5px; +} + +.ph-challenge-idea-cell-image-wrapper { + position: relative; + grid-area: image; + width: 100%; + height: 100%; + min-width: 132px; + border: 1px solid #aeaeae; +} + +.ph-challenge-idea-cell-delete-button { + position: absolute; + right: 11px; + top: 11px; +} + +.ph-challenge-idea-cell-image-content { + width: 100%; + height: 100%; + object-fit: cover; + background-color: #edebf0; +} + +.ph-challenge-idea-cell-creation-wrapper { + width: 100%; + height: auto; + padding: 6px; + color: $ph-white-color; + background: linear-gradient(to bottom, rgba(0, -1, 1, 0) 0%, rgba(0, 0, 0, 0.1) 10%, #000 100%); + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + text-shadow: 2px 2px 3px #000; + font: { + size: 18px; + weight: 600; + } +} + +.ph-challenge-idea-cell-creation-user { + display: flex; + flex-direction: row; + align-items: center; + + > :not(:last-child) { + margin-right: 6px; + } +} + +.ph-challenge-idea-createdby-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid $ph-white-color; + background-color: $ph-primary-color; + object-fit: cover; + + cursor: pointer; +} + +.ph-challenge-idea-createdby-name { + font: { + family: Montserrat; + weight: 600; + size: 15px; + } + @include ellipsisMultiline(1); +} + +.ph-challenge-idea-createdby-date { + font: { + family: Montserrat; + weight: 600; + size: 15px; + } +} + +.ph-challenge-idea-cell-data-wrapper { + grid-area: data; + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 1fr; + grid-auto-rows: auto 1fr; + grid-template-areas: "title" "description" "reactions"; + grid-gap: 4px 11px; + overflow: hidden; + + @include respond-to("xs") { + grid-template-columns: 1fr; + grid-auto-rows: 25px 25px auto; + grid-template-areas: "reactions" "title" "description"; + grid-gap: 4px 11px; + } +} + +.ph-challenge-idea-cell-data-title { + grid-area: title; + font: { + family: Montserrat; + weight: 600; + size: 15px; + } + color: $ph-black-color; + align-self: center; + + @include ellipsis; +} + +.ph-challenge-idea-cell-data-idea-description { + grid-area: description; + padding-right: 17px; + text-align: justify; + font: { + family: Montserrat; + weight: normal; + size: 14px; + } + overflow-x: auto; + color: $ph-black-color; + + @include respond-to("xs") { + padding-right: 0px; + } +} + +.ph-challenge-idea-cell-data-reactions-wrapper { + grid-area: reactions; + display: flex; + flex-direction: row; + align-items: center; +} + +.ph-challenge-idea-cell-data-reactions-icon { + margin-right: 8px; + font-size: 21px; + color: $ph-primary-color; + + &:hover { + cursor: pointer; + } + + &.disabled { + cursor: not-allowed; + } +} + +.ph-challenge-idea-cell-data-reactions-message { + font: { + family: Montserrat; + weight: bold; + size: 12px; + } + color: $ph-primary-color; +} diff --git a/src/assets/stylesheets/components/_confirmemail.scss b/src/assets/stylesheets/components/_confirmemail.scss new file mode 100644 index 0000000..d745fb5 --- /dev/null +++ b/src/assets/stylesheets/components/_confirmemail.scss @@ -0,0 +1,110 @@ +.ph-confirm-email { + height: auto; + min-height: 100%; + display: flex; + flex-direction: column; + overflow-x: hidden; + + @include respond-to("sm") { + height: 100%; + } +} + +.ph-confirm-email-content { + display: flex; + flex-grow: 1; + overflow-y: auto; + + @include respond-to("sm") { + align-items: center; + flex-direction: column; + } +} + +.ph-confirm-email-image-wrapper { + width: 50%; + position: relative; + background-color: $ph-primary-color; + + @include respond-to("sm") { + position: absolute; + width: auto; + top: 0; + left: 0; + right: 0; + height: 100%; + } + + @include respond-to("xs") { + position: static; + display: none; + } +} + +.ph-confirm-email-image { + height: 100%; + width: 100%; + position: absolute; + opacity: 0.5; + background: { + image: url("../../assets/images/login.svg"); + size: cover; + position: center; + } +} + +.ph-confirm-email-data { + width: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 22px; + margin: { + top: 110px; + bottom: 22px; + } + + @include respond-to("sm") { + background: $ph-white-color; + display: block; + width: auto; + margin: { + left: 22px; + right: 22px; + } + z-index: 1; + } + + @include respond-to("xs") { + margin-top: 55px; + } +} + +.ph-confirm-email-data-container { + width: 100%; + max-width: 380px; + text-align: center; + + > * { + margin-bottom: 44px; + } + + > :last-child { + margin-bottom: 0; + } +} + +.ph-confirm-email-data-title { + font: { + weight: bold; + size: 24px; + } + color: $ph-primary-color; +} + +.ph-confirm-email-load-icon { + padding-top: 100px; + font-size: 200px; + color: $ph-primary-color-half-opacity; +} diff --git a/src/assets/stylesheets/components/_createchallenge.scss b/src/assets/stylesheets/components/_createchallenge.scss new file mode 100644 index 0000000..523ec8f --- /dev/null +++ b/src/assets/stylesheets/components/_createchallenge.scss @@ -0,0 +1,246 @@ +.ph-create-challenge-container { + height: 100%; + width: 100%; + background-color: $ph-auxiliary-color; + padding: { + top: 33px; + right: 77px; + bottom: 44px; + left: 77px; + } + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: center; + + @include respond-to("sm") { + padding: 33px 46px 25px 46px; + } + @include respond-to("xs") { + padding: 44px 22px; + } +} + +.ph-create-challenge-header { + font: { + weight: 600; + size: 22px; + } + text-transform: uppercase; + margin-bottom: 33px; + + @include respond-to("xs") { + grid-template-rows: 160px 44px 100px; + grid-template-columns: 1fr; + margin-bottom: 22px; + } +} + +.ph-create-challenge-box { + display: grid; + width: 100%; + grid-template-areas: "title title title title" "image description description description"; + grid-gap: 22px; + grid-template-rows: 1fr 3fr; + grid-template-columns: 1fr 1fr 1fr 1fr; + justify-content: center; + margin-bottom: 22px; + + @include respond-to("xs") { + grid-gap: 11px; + grid-template-rows: 160px 44px 100px; + grid-template-columns: 1fr; + grid-template-areas: "image" "title" "description"; + margin-bottom: 0; + } +} + +.ph-create-challenge-input { + padding: 11px; + font-size: 14px; + border: 0px; + border-bottom: 1px solid $ph-primary-color; + input::placeholder { + color: $ph-cloudy-gray-color; + } + + @include respond-to("xs") { + font-size: 12px; + } + + &.title { + grid-area: title; + } + + &.description { + grid-area: description; + outline: 0 none transparent; + resize: none; + @include respond-to("xs") { + margin-bottom: 11px; + } + } +} + +.ph-create-challenge-image { + grid-area: image; + width: 100%; + height: 100%; +} + +.ph-create-challenge-description { + grid-area: description; + resize: none; + padding: 11px; + border: 0px; + border-bottom: 1px solid $ph-primary-color; +} + +.ph-create-challenge-deadline-box { + display: grid; + width: 100%; + grid-gap: 11px; + grid-template-rows: 1fr; + grid-template-columns: 1fr 1fr; + grid-template-areas: "idea challenge"; +} + +.ph-create-challenge-deadline-title { + font-size: 14px; + @include respond-to("md") { + font-size: 10px; + } +} + +.ph-create-challenge-date-picker { + height: 100%; + border-bottom: 1px solid $ph-primary-color; + + input::placeholder { + font-size: 14px; + font-weight: 300; + @include respond-to("sm") { + font-size: 11px; + } + @include respond-to("xs") { + color: $ph-white-color; + } + } + + &.deadline { + grid-area: challenge; + } + + &.receipt-deadline { + grid-area: idea; + } + + .ant-calendar-picker-input { + font-size: 14px; + border-radius: 0; + border: none; + + &:hover { + border: none; + } + } +} + +.ph-date-picker-hint { + padding-left: 11px; + background-color: $ph-primary-color-half-opacity; + font-weight: 600; + color: $ph-white-color; + text-shadow: 1px 1px #000000; +} + +.ph-create-challenge-button { + margin-top: 33px; + background-color: $ph-primary-color; + outline: 0 none transparent; + color: $ph-white-color; + width: 300px; + height: 44px; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + font: { + weight: bold; + size: 18px; + } + text-transform: uppercase; + cursor: pointer; + + @include respond-to("xs") { + margin-top: 11px; + width: 100%; + } + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + &:focus { + outline: none; + } + + &:disabled { + background-color: $ph-primary-color-half-opacity; + cursor: not-allowed; + } +} + +.ph-load-image-wrapper { + width: 189px; + max-height: 189px; + object-fit: cover; + height: 100%; + padding: 0px; + @include respond-to("sm") { + width: 150px; + } + @include respond-to("xs") { + width: 100%; + } +} + +.ph-load-image-content { + display: flex; + justify-content: center; + align-items: center; + width: 189px; + height: 100%; + cursor: pointer; + + @include respond-to("sm") { + width: 150px; + } + @include respond-to("xs") { + width: 100%; + } +} + +.ph-load-image-camera-content { + height: 100%; + width: 100%; + display: flex; + justify-content: flex-end; + align-items: flex-end; + background-color: $ph-primary-color-half-opacity; +} + +.ph-load-image-camera { + width: 60px; + height: 60px; + margin: 11px; +} + +.ph-create-challenge-checkbox-container { + padding-top: 11px; + width: 100%; +} diff --git a/src/assets/stylesheets/components/_createidea.scss b/src/assets/stylesheets/components/_createidea.scss new file mode 100644 index 0000000..a16c5e0 --- /dev/null +++ b/src/assets/stylesheets/components/_createidea.scss @@ -0,0 +1,115 @@ +.ph-create-idea-content-wrapper { + height: 100%; + width: 100%; + background-color: $ph-auxiliary-color; + padding: { + top: 33px; + right: 77px; + bottom: 44px; + left: 77px; + } + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: center; + + @include respond-to("sm") { + padding: 33px 22px; + } +} + +.ph-create-idea-header { + font: { + weight: 600; + size: 22px; + } + text-transform: uppercase; + margin-bottom: 33px; + + @include respond-to("xs") { + margin-bottom: 0; + } +} + +.ph-create-idea-input { + padding: 11px; + font-size: 14px; + border: 0px; + border-bottom: 1px solid $ph-primary-color; + input::placeholder { + color: $ph-cloudy-gray-color; + } + + @include respond-to("xs") { + font-size: 12px; + } + + &.title { + grid-area: title; + } + + &.description { + grid-area: description; + outline: 0 none transparent; + resize: none; + } +} + +.ph-create-idea-box { + display: grid; + width: 100%; + grid-template-areas: "title title title title" "image description description description"; + grid-gap: 22px; + grid-template-rows: 1fr 4fr; + grid-template-columns: 1fr 1fr 1fr 1fr; + justify-content: center; + + @include respond-to("xs") { + grid-template-areas: "title" "image" "description"; + grid-template-columns: 1fr; + grid-gap: 11px; + } +} + +.ph-create-idea-button { + margin-top: 33px; + background-color: $ph-primary-color; + color: $ph-white-color; + width: 300px; + height: 44px; + display: flex; + justify-content: center; + align-items: center; + font: { + weight: bold; + size: 18px; + } + text-transform: uppercase; + cursor: pointer; + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + &:focus { + outline: none; + } + + &:disabled { + background-color: $ph-primary-color-half-opacity; + cursor: not-allowed; + } + + @include respond-to("xs") { + margin-top: 0; + width: 100%; + } +} + +.ph-create-idea-image { + grid-area: image; +} diff --git a/src/assets/stylesheets/components/_header.scss b/src/assets/stylesheets/components/_header.scss new file mode 100644 index 0000000..a644c2a --- /dev/null +++ b/src/assets/stylesheets/components/_header.scss @@ -0,0 +1,92 @@ +.ph-header { + background-color: $ph-primary-color; + height: 54px; + width: 100%; + padding: 0px 22px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + position: fixed; + z-index: 2; + + @include respond-to("xs") { + padding: 0px 11px; + } +} + +.ph-header-logo { + background: { + image: url("../../assets/images/logo.png"); + size: cover; + repeat: no-repeat; + } + height: 34px; + width: 160px; + cursor: pointer; +} + +.ph-header-avatar { + border-radius: 50%; + width: 28px; + height: 28px; + border: 1px solid $ph-white-color; + overflow: hidden; + background-color: $ph-primary-color; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.ph-header-content-wrapper { + display: flex; + flex-direction: row; +} + +.ph-header-divider { + background-color: $ph-white-color; + height: 37px; + width: 1px; + margin: 0px 22px; + + @include respond-to("xs") { + margin: 0px 11px; + } +} + +.ph-header-content { + display: flex; + align-items: center; + cursor: pointer; +} + +.ph-header-login { + display: flex; + align-items: center; + color: $ph-white-color; + font: { + family: Montserrat; + size: 16px; + weight: 300; + } + text-transform: capitalize; + margin-right: 5px; + + @include respond-to("xs") { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 12ch; + + &.authenticated { + display: none; + } + } +} + +.ph-header-more-icon { + font-size: 25px; +} diff --git a/src/assets/stylesheets/components/_home.scss b/src/assets/stylesheets/components/_home.scss new file mode 100644 index 0000000..2dda71e --- /dev/null +++ b/src/assets/stylesheets/components/_home.scss @@ -0,0 +1,546 @@ +.ph-challenge-load-icon-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +.ph-challenge-load-icon { + padding-top: 100px; + font-size: 200px; + color: $ph-primary-color-half-opacity; +} + +.ph-home { + @include respond-to("xs") { + overflow-x: hidden; + } +} + +.ph-home-new-challenge { + background-color: $ph-primary-color-half-opacity; + display: flex; + height: 162px; + flex-direction: row; + justify-content: center; + align-items: center; + padding-top: 54px; + color: $ph-white-color; + + @include respond-to("xs") { + flex-wrap: wrap; + justify-content: center; + padding: 70px 11px 0; + height: 180px; + } +} + +.ph-home-new-challenge-title { + font-size: 50px; + text-shadow: 1px 1px #000; + margin-right: 50px; + font-weight: 700; + + @include respond-to("xs") { + font-size: 35px; + line-height: 20px; + text-align: center; + width: 100%; + margin-right: 0; + } +} + +.ph-home-new-challenge-description { + margin-top: 5px; + font-size: 17px; + + @include respond-to("xs") { + margin-top: 0; + font-size: 16px; + } +} + +.ph-home-new-challenge-button { + font-size: 12px; + opacity: 1; + width: auto; + text-align: center; + text-transform: uppercase; + cursor: pointer; + padding: 11px 30px; + background: $ph-primary-color; + font-weight: 700; + color: $ph-white-color; + border: { + width: 1px; + style: solid; + color: $ph-primary-color; + } + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + @include respond-to("xs") { + width: auto; + } +} + +.ph-home-challenge { + display: grid; + grid-template-columns: minmax(350px, 40%) minmax(350px, 35%); + grid-column-gap: 33px; + justify-content: center; + grid-auto-rows: auto; + padding: 33px 22px; + color: $ph-black-color; + border-bottom: { + width: 1px; + style: solid; + color: $ph-black-color; + } + + @include respond-to("sm") { + grid-template-columns: 1fr; + grid-template-rows: 400px auto; + grid-gap: 22px; + } + @include respond-to("xs") { + padding: 33px 0; + grid-template-rows: 350px auto; + } +} + +.ph-home-challenge-container { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.ph-home-challenge-box { + position: relative; + height: 100%; + width: 100%; + min-width: 300px; + overflow: hidden; + cursor: pointer; +} + +.ph-home-challenge-image-box { + height: 520px; + width: 100%; + overflow: hidden; + background-color: #edebf0; + + @include respond-to("md") { + height: 330px; + } + + @include respond-to("sm") { + height: 100%; + } + + @include respond-to("xs") { + height: 300px; + max-width: 800px; + } + + img { + height: 100%; + width: 100%; + object-fit: cover; + } +} + +.ph-home-challenge-box-content { + width: 95%; + position: absolute; + left: 11px; + bottom: 11px; + display: flex; + flex-direction: column; + align-items: flex-start; + + @include respond-to("md") { + position: static; + padding: 11px 0 0; + width: 100%; + } + + @include respond-to("sm") { + position: absolute; + left: 11px; + bottom: 11px; + right: 11px; + width: auto; + } +} + +.ph-home-challenge-box-content-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 11px; + padding: 0px 5px; + @include ellipsisMultiline(2); + background: $ph-white-color; + + @include respond-to("md") { + font-size: 16px; + @include ellipsisMultiline(2); + display: block !important; + display: -webkit-box !important; + } + + @include respond-to("sm") { + font-size: 18px; + @include ellipsisMultiline(1); + } +} + +.ph-home-challenge-box-content-description-paragraph { + background-color: $ph-white-color; + font-size: 16px; + padding: 0px 5px; + @include ellipsisMultiline(3); + + @include respond-to("xs") { + @include ellipsisMultiline(2); + margin: 0; + } +} + +.ph-home-challenge-box-footer { + padding-top: 11px; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + + @include respond-to("xs") { + padding: 11px 11px 0; + } +} + +.ph-home-challenge-box-footer-info { + display: flex; + align-items: center; +} + +.ph-home-challenge-box-footer-avatar { + width: 45px; + height: 45px; + border: 1px solid $ph-primary-color; + background-color: $ph-primary-color; + border-radius: 50%; + overflow: hidden; + cursor: pointer; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + &:hover { + opacity: 0.9; + } +} + +.ph-home-challenge-box-footer-date { + padding-left: 11px; + font-size: 16px; +} + +.ph-home-challenge-box-footer-see-more { + font-weight: 300; + font-size: 14px; + color: $ph-primary-color; + cursor: pointer; + + &:hover { + font-weight: 700; + } +} + +.ph-home-challenge-ideas { + display: flex; + flex-direction: column; + width: 100%; + max-width: 600px; + min-width: 320px; + + @include respond-to("sm") { + max-width: 724px; + } + @include respond-to("xs") { + min-width: 320px; + padding: 0 10px; + } +} + +.ph-home-challenge-ideas-container { + display: grid; + grid-template-rows: 1fr 1fr 1fr; + grid-auto-columns: auto; + + @include respond-to("sm") { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: auto auto; + } + + @include respond-to("xs") { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + } +} + +.ph-home-challenge-ideas-single-idea { + border-bottom: 1px solid #efeafc; + padding: 22px 0; + + &:last-child { + border-bottom: 0; + } + + @include respond-to("sm") { + padding: 0 22px; + border-right: 1px solid #efeafc; + border-bottom: 0; + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + border-right: 0; + } + } +} + +.ph-home-challenge-ideas-idea { + width: 100%; + height: 100%; + display: flex; + cursor: pointer; + + @include respond-to("sm") { + display: flex; + flex-direction: column; + } +} + +.idea3 { + @include respond-to("xs") { + display: none; + } +} + +.ph-home-challenge-ideas-idea-image-box { + width: 120px; + height: 120px; + min-width: 120px; + margin-right: 11px; + background-color: #edebf0; + + @include respond-to("sm") { + width: 100%; + margin-right: 0; + margin-bottom: 11px; + } +} + +.ph-home-challenge-ideas-idea-image { + object-fit: cover; + height: 100%; + width: 100%; +} + +.ph-home-challenge-ideas-idea-box { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; +} + +.ph-home-challenge-ideas-idea-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ph-home-challenge-ideas-idea-content-title { + font-weight: 600; + font-size: 13px; + @include ellipsisMultiline(2); + + @include respond-to("sm") { + @include ellipsisMultiline(1); + } +} + +.ph-home-challenge-ideas-idea-content-likes { + width: 40px; + height: 40px; + min-width: 33px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + //deprecated hearth in likes + // background: { + // image: url("../../assets/images/likes.svg"); + // position: center; + // size: cover; + // repeat: no-repeat; + // } + //; +} + +.ph-home-challenge-ideas-idea-content-likes-number { + font-size: 22px; + font-weight: 600; + color: $ph-primary-color-half-opacity; + cursor: pointer; + display: inherit; +} + +.ph-home-challenge-ideas-idea-content-likes-text { + font-size: 11px; + font-weight: 600; +} + +.ph-home-challenge-ideas-idea-content-description { + font-size: 12px; + @include ellipsisMultiline(3); + + @include respond-to("sm") { + height: auto; + } +} + +.ph-home-challenge-ideas-idea-footer { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + + @include respond-to("sm") { + margin-top: 11px; + } +} + +.ph-home-challenge-ideas-idea-footer-info { + display: flex; + align-items: center; +} + +.ph-home-challenge-ideas-idea-footer-info-avatar { + border: { + radius: 50%; + color: $ph-primary-color; + style: solid; + width: 1px; + } + overflow: hidden; + width: 30px; + height: 30px; + background-color: $ph-primary-color; + cursor: pointer; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + &:hover { + opacity: 0.9; + } +} + +.ph-home-challenge-ideas-idea-footer-info-date { + padding-left: 11px; + font-size: 12px; + + @include respond-to("sm") { + padding-left: 4px; + font-size: 10px; + } + @include respond-to("xs") { + display: none; + } +} + +.ph-home-challenge-ideas-idea-footer-see-more { + font-weight: 300; + font-size: 12px; + color: $ph-primary-color; + cursor: pointer; + + &:hover { + font-weight: 700; + } +} + +.ph-home-challenge-ideas-footer { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 22px; + + @include respond-to("sm") { + justify-content: space-around; + } +} + +.ph-home-challenge-ideas-footer-view-all { + width: 125px; + text-align: center; + font-size: 12px; + color: $ph-cloudy-gray-color; + padding: 11px; + cursor: pointer; + border: { + width: 1px; + style: solid; + color: $ph-cloudy-gray-color; + } + text-transform: uppercase; + + &:hover { + font-weight: 700; + } +} + +.ph-home-challenge-ideas-footer-add-idea { + width: 125px; + text-align: center; + padding: 11px; + background: $ph-primary-color; + font-size: 12px; + font-weight: 700; + color: $ph-white-color; + cursor: pointer; + border: { + width: 1px; + style: solid; + color: $ph-primary-color; + } + text-transform: uppercase; + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } +} diff --git a/src/assets/stylesheets/components/_ideaDetail.scss b/src/assets/stylesheets/components/_ideaDetail.scss new file mode 100644 index 0000000..f4396c1 --- /dev/null +++ b/src/assets/stylesheets/components/_ideaDetail.scss @@ -0,0 +1,176 @@ +.ph-idea-detail { + height: 100%; + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + font: { + family: Montserrat; + } + overflow: hidden; + + @include respond-to("sm") { + grid-template-columns: 1fr; + grid-template-rows: 320px; + } +} + +.ph-idea-detail-top { + box-shadow: 4px 6px 15px 0px #7e7b7b; + display: grid; + grid-template-rows: 415px auto; + + @include respond-to("sm") { + grid-template-rows: 260px auto; + box-shadow: none; + } +} + +.ph-idea-detail-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ph-idea-detail-image-background { + width: 100%; + height: 100%; + background-color: #edebf0; +} + +.ph-idea-detail-image-box { + position: relative; +} + +.ph-idea-detail-user { + background: linear-gradient(to bottom, rgba(0, -1, 1, 0) 0%, rgba(0, 0, 0, 0.1) 10%, #000 100%); + position: absolute; + bottom: 0; + left: 0; + right: 0; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 11px; + color: #fff; + text-shadow: 2px 2px 3px #000; + font: { + size: 18px; + weight: 600; + } +} + +.ph-idea-detail-user-box { + display: flex; + align-items: center; +} + +.ph-idea-detail-user-avatar-box { + height: 60px; + width: 60px; + border-radius: 50%; + border: 1px solid #fff; + margin-right: 11px; + overflow: hidden; + cursor: pointer; + background-color: $ph-primary-color; + flex-shrink: 0; + flex-grow: 0; +} + +.ph-idea-detail-user-avatar { + width: 100%; + height: 100%; + object-fit: cover; + + &:hover { + opacity: 0.8; + } +} + +.ph-idea-detail-user-name { + @include ellipsisMultiline(1); +} + +.ph-idea-detail-info { + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px; +} + +.ph-idea-detail-info-reactions { + display: flex; + align-items: center; +} + +.ph-idea-detail-info-reactions-heart { + font-size: 30px; + margin-right: 11px; + color: $ph-primary-color; + + &:hover { + cursor: pointer; + } + + &.disabled { + cursor: not-allowed; + } +} + +.ph-idea-detail-info-reactions-quantity { + font: { + weight: 600; + } + color: $ph-primary-color; +} + +.ph-idea-detail-info-votes-deadline { + text-align: right; +} + +.ph-idea-detail-info-votes-deadline-label { + font: { + size: 12px; + weight: 300; + } + color: $ph-black-color; +} + +.ph-idea-detail-info-votes-deadline-date { + font: { + weight: 600; + } + color: $ph-primary-color; +} + +.ph-idea-detail-content { + padding: 0 33px; + margin: 33px 0; + overflow: auto; + height: 412px; + min-height: 0; + + @include respond-to("sm") { + padding: 0 11px; + margin: 0 0 11px; + height: auto; + } +} + +.ph-idea-detail-content-title { + font: { + weight: 600; + size: 18px; + } + color: $ph-black-color; + margin-bottom: 11px; +} + +.ph-idea-detail-content-description { + font: { + size: 15px; + } + color: $ph-black-color; + overflow-y: auto; +} diff --git a/src/assets/stylesheets/components/_loadimage.scss b/src/assets/stylesheets/components/_loadimage.scss new file mode 100644 index 0000000..f61c747 --- /dev/null +++ b/src/assets/stylesheets/components/_loadimage.scss @@ -0,0 +1,42 @@ +.ph-load-image-wrapper { + width: 189px; + max-height: 189px; + object-fit: cover; + height: 100%; + padding: 0px; + background-color: $ph-primary-color-half-opacity; + @include respond-to("sm") { + width: 150px; + } + @include respond-to("xs") { + width: 100%; + } +} + +.ph-load-image-content { + display: flex; + justify-content: center; + align-items: center; + width: 189px; + height: 100%; + @include respond-to("sm") { + width: 150px; + } + @include respond-to("xs") { + width: 100%; + } +} + +.ph-load-image-camera-content { + height: 100%; + width: 100%; + display: flex; + justify-content: flex-end; + align-items: flex-end; +} + +.ph-load-image-camera { + width: 60px; + height: 60px; + margin: 11px; +} diff --git a/src/assets/stylesheets/components/_login.scss b/src/assets/stylesheets/components/_login.scss new file mode 100644 index 0000000..f702445 --- /dev/null +++ b/src/assets/stylesheets/components/_login.scss @@ -0,0 +1,209 @@ +.ph-login { + height: auto; + min-height: 100%; + display: flex; + flex-direction: column; + overflow-x: hidden; + + @include respond-to("sm") { + height: 100%; + } +} + +.ph-login-content { + display: flex; + flex-grow: 1; + overflow-y: auto; + + @include respond-to("sm") { + align-items: center; + flex-direction: column; + } +} + +.ph-login-image-wrapper { + width: 50%; + position: relative; + background-color: $ph-primary-color; + + @include respond-to("sm") { + position: absolute; + width: auto; + top: 0px; + left: 0px; + right: 0px; + height: 100%; + } + + @include respond-to("xs") { + position: static; + display: none; + } +} + +.ph-login-image { + height: 100%; + width: 100%; + position: absolute; + opacity: 0.5; + background: { + image: url("../../assets/images/login.svg"); + size: cover; + position: center; + } +} + +.ph-login-data { + width: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 22px; + margin: { + top: 110px; + bottom: 22px; + } + + @include respond-to("sm") { + background: $ph-white-color; + display: block; + width: auto; + z-index: 1; + } + + @include respond-to("xs") { + margin-top: 55px; + } +} + +.ph-login-data-content { + width: 100%; + max-width: 380px; + text-align: center; + + @include respond-to("sm") { + width: 100%; + max-width: 380px; + } +} + +.ph-login-divider { + display: none; + + width: 100%; + //display: flex; + align-items: center; + margin: { + top: 44px; + bottom: 33px; + } + + & span { + margin: 0 10px; + color: $ph-cloudy-gray-color; + font-weight: 300; + } + + @include respond-to("xs") { + margin: { + top: 22px; + bottom: 11px; + } + } +} + +.ph-login-divider-line { + height: 2px; + background-color: $ph-auxiliary-color; + width: 100%; +} + +.ph-login-form { + display: flex; + flex-direction: column; + align-items: center; +} + +.ph-login-input { + height: 44px; + width: 100%; + padding: 11px 0px 0px 11px; + font-weight: 300; + font-size: 16px; + border: 0px; + border-bottom: 1px solid $ph-primary-color; + box-sizing: border-box; + display: block; + + &:not(:last-child) { + margin-bottom: 22px; + } +} + +input::placeholder { + color: $ph-cloudy-gray-color; +} + +input:focus { + outline: none; +} + +.ph-login-button { + margin-top: 33px; + background-color: $ph-primary-color; + color: $ph-white-color; + outline: 0 none transparent; + width: 100%; + height: 54px; + display: flex; + justify-content: center; + align-items: center; + font: { + weight: bold; + size: 18px; + } + cursor: pointer; + + @include respond-to("xs") { + width: 100%; + max-width: 320px; + } + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + &:disabled { + background-color: $ph-primary-color-half-opacity; + cursor: not-allowed; + } +} + +.ph-login-forgot-password { + margin-top: 33px; + text-align: center; + font-size: 16px; +} + +.ph-login-no-account { + margin-top: 33px; + text-align: center; + font-size: 16px; + color: $ph-black-color; + font-weight: 300; +} + +.ph-login-no-account span { + margin-right: 13px; +} + +.ph-link { + font-weight: 600; + text-decoration: none; + color: $ph-black-color; +} diff --git a/src/assets/stylesheets/components/_profile.scss b/src/assets/stylesheets/components/_profile.scss new file mode 100644 index 0000000..d482361 --- /dev/null +++ b/src/assets/stylesheets/components/_profile.scss @@ -0,0 +1,377 @@ +.ph-profile { + color: $ph-black-color; +} + +.ph-profile-header-space { + height: 54px; +} + +.ph-profile-title { + display: flex; + flex-direction: row; + justify-content: center; + align-content: center; + font: { + family: Montserrat; + weight: 600; + size: 24px; + } + margin-top: 40px; + + @include respond-to("xs") { + display: none; + } +} + +.ph-profile-user-info { + width: 80%; + height: 100%; + margin: 40px auto 0; + display: grid; + grid-template-rows: 200px; + grid-template-columns: 200px 1fr; + grid-template-areas: "picture user"; + grid-gap: 0px 45px; + justify-content: center; + align-items: center; + + @include respond-to("md") { + padding: 0px 22px; + grid-template-rows: 150px; + grid-template-columns: 150px 1fr; + } + + @include respond-to("sm") { + grid-template-columns: 150px 1fr; + grid-gap: 0px 22px; + } + + @include respond-to("xs") { + height: auto; + margin: 33px auto 0; + padding: 0; + grid-template-rows: 1fr auto; + grid-template-columns: 1fr; + grid-template-areas: "picture" "user"; + grid-gap: 11px; + } +} + +.ph-profile-user-info-picture { + border-radius: 50%; + width: 100%; + height: 100%; + grid-area: picture; + position: relative; + + @include respond-to("xs") { + height: 121px; + width: 121px; + margin: 0 auto; + } +} + +.ph-profile-name-editable { + display: flex; + flex-direction: row; + align-items: baseline; + + @include respond-to("xs") { + justify-content: center; + } +} + +.ph-profile-edit-icon { + width: 14px; + height: 14px; + margin-left: 14px; + cursor: pointer; + background: { + image: url("../../assets/images/edit.png"); + size: cover; + position: center; + } + grid-area: picture; +} + +.ph-load-icon { + font-size: 48px; + color: white; +} + +.ph-profile-user-image { + border-radius: 50%; + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + background-color: $ph-primary-color; + cursor: pointer; +} + +.ph-profile-image-camera-box { + width: 47px; + height: 47px; + overflow: hidden; + border-radius: 50%; + border: { + color: $ph-white-color; + style: solid; + width: 2px; + } + position: absolute; + bottom: 0px; + right: 11px; + background-color: $ph-secondary-color; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + @include respond-to("md") { + bottom: 0px; + right: 0px; + } + + @include respond-to("xs") { + width: 35px; + height: 35px; + bottom: -5px; + right: 0px; + } +} + +.ph-profile-image-camera { + width: 60%; + height: 60%; +} + +.ph-profile-button { + margin: 0px; + background-color: $ph-primary-color; + color: $ph-white-color; + height: 44px; + width: 301px; + display: flex; + justify-content: center; + align-items: center; + font-weight: bold; + font-size: 18px; + cursor: pointer; + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + &:focus { + outline: none; + } + + @include respond-to("md") { + width: 200px; + } + + @include respond-to("sm") { + width: 300px; + } + + @include respond-to("xs") { + width: 280px; + margin: 0; + } +} + +.ph-profile-user-info-box { + display: flex; + align-items: center; + justify-content: space-between; + + @include respond-to("sm") { + flex-direction: column; + justify-content: space-between; + height: 100%; + align-items: flex-end; + } + + @include respond-to("sm") { + height: auto; + } + + @include respond-to("xs") { + align-items: center; + } +} + +.ph-profile-user-info-data { + width: auto; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + grid-area: user; + + @include respond-to("xs") { + min-width: 160px; + text-align: center; + } +} + +.ph-profile-user-info-data-name { + max-width: 22ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font: { + family: Montserrat; + weight: bold; + size: 18px; + } + + @include ellipsis(); +} + +.ph-profile-input { + height: 44px; + width: 280px; + padding: 11px 0px 0px 11px; + font-weight: 300; + font-size: 16px; + border: 0px; + border-bottom: 1px solid $ph-primary-color; + box-sizing: border-box; + display: block; + + @include respond-to("sm") { + width: 300px; + } + + @include respond-to("xs") { + width: 280px; + } +} + +.ph-profile-user-info-data-text { + font: { + family: Montserrat; + weight: 300; + size: 14px; + } + padding-top: 7px; + + &.city { + padding-top: 7px; + } + + @include ellipsis(); + + @include respond-to("xs") { + height: inherit; + } +} + +.ph-profile-user-info-button { + width: auto; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + text-align: justify; + margin-left: 45px; + font: { + family: Montserrat; + weight: 300; + size: 14px; + } + + @include respond-to("sm") { + padding: 11px 0; + margin-left: 0; + } + + @include respond-to("xs") { + padding: 11px 0; + } +} + +.ph-profile-user-summary-wrapper { + width: 100%; + height: 111px; + background-color: $ph-auxiliary-color; + margin: 22px 0px 44px; + display: flex; + justify-content: center; + align-content: center; + + @include respond-to("xs") { + margin-bottom: 22px; + padding: 0 11px; + } +} + +.ph-profile-user-summary { + width: 500px; + height: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.ph-profile-user-summary-content { + width: 100px; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: $ph-black-color; + font: { + family: Montserrat; + weight: 300; + size: 16px; + } + + &:hover { + cursor: pointer; + font-weight: bold; + } + + &.clicked { + font-weight: bold; + } + + @include respond-to("xs") { + font-size: 14px; + } +} + +.ph-profile-user-summary-counter { + color: $ph-secondary-color; + font-size: 35px; + + @include respond-to("xs") { + font-size: 30px; + } +} + +.ph-profile-user-summary-label { +} + +.ph-profile-user-summary-list-wrapper { + padding: 0px 111px 22px; + + @include respond-to("md") { + padding: 0px 22px 22px; + } + + @include respond-to("xs") { + padding: 0px 11px 11px; + } +} + +.ph-profile-user-summary-list-content { + width: 100%; +} diff --git a/src/assets/stylesheets/components/_profilechallengecell.scss b/src/assets/stylesheets/components/_profilechallengecell.scss new file mode 100644 index 0000000..ffc2e6d --- /dev/null +++ b/src/assets/stylesheets/components/_profilechallengecell.scss @@ -0,0 +1,138 @@ +.ph-profile-challenge-cell-wrapper { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 1fr 2fr; + grid-auto-rows: 1fr; + grid-gap: 0px 11px; +} + +.ph-profile-challenge-cell-image-wrapper { + width: auto; + min-width: 130px; + cursor: pointer; + height: 110px !important; +} + +.ph-profile-challenge-cell-image-content { + width: 100%; + height: 100%; + object-fit: cover; + background-color: #edebf0; +} + +.ph-profile-challenge-cell-data-wrapper { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 1fr 50px; + grid-auto-rows: 1fr 3fr; + grid-gap: 5px 2px; + grid-template-areas: "title edit" "description-box description-box"; + + @include respond-to("xs") { + grid-template-columns: 1fr auto; + } +} + +.ph-profile-challenge-cell-data-title { + grid-area: title; + cursor: pointer; + font: { + family: Montserrat; + weight: 600; + size: 12px; + } + color: $ph-black-color; + + @include ellipsis; +} + +.ph-profile-challenge-cell-dropdown-text { + padding: 0 5px; +} + +.ph-profile-challenge-cell-data-icon { + grid-area: edit; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + + @include respond-to("xs") { + padding-left: 10px; + } +} + +.ph-profile-challenge-cell-data-idea-wrapper { + grid-area: description-box; + background-color: $ph-third-color; + margin: 5px 0px 0px; + padding: 5px 11px; + color: $ph-black-color; + display: grid; + grid-template-columns: 1fr 50px; + grid-auto-rows: 1fr 2fr; + grid-gap: 0px 2px; + grid-template-areas: "title votes" "description votes"; + cursor: pointer; + + @include respond-to("xs") { + padding: 11px 5px; + grid-gap: 0px 5px; + grid-template-columns: 1fr auto; + } +} + +.ph-profile-challenge-cell-data-idea-title { + grid-area: title; + + font: { + family: Montserrat; + weight: 300; + size: 11px; + } + + @include ellipsis; +} + +.ph-profile-challenge-cell-data-idea-description { + grid-area: description; + + font: { + family: Montserrat; + weight: normal; + size: 12px; + } + color: $ph-primary-color; + + @include ellipsisMultiline(2); +} + +.ph-profile-challenge-cell-data-idea-votes { + grid-area: votes; + + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + text-align: center; + line-height: 1; + + font: { + family: Montserrat; + weight: bold; + size: 12px; + } +} + +.ph-profile-challenge-cell-data-idea-votes-counter { + font: { + family: Montserrat; + weight: bold; + size: 18px; + } + color: $ph-secondary-color; +} diff --git a/src/assets/stylesheets/components/_profileideacell.scss b/src/assets/stylesheets/components/_profileideacell.scss new file mode 100644 index 0000000..dae82fe --- /dev/null +++ b/src/assets/stylesheets/components/_profileideacell.scss @@ -0,0 +1,111 @@ +.ph-profile-idea-cell-wrapper { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 1fr 2fr; + grid-auto-rows: 1fr; + grid-gap: 0px 11px; + cursor: pointer; + + color: $ph-black-color; +} + +.ph-profile-idea-cell-image-wrapper { + width: 100%; + height: 101px; + min-width: 132px; +} + +.ph-profile-idea-cell-image-content { + width: 100%; + height: 100%; + object-fit: cover; + background-color: #edebf0; +} + +.ph-profile-idea-cell-dropdown-text { + padding: 0 5px; +} + +.ph-profile-idea-cell-data-wrapper { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 1fr 50px; + grid-auto-rows: 1fr 3fr; + grid-gap: 5px 2px; + grid-template-areas: "title edit" "description votes"; +} + +.ph-profile-idea-cell-data-title-wrapper { +} + +.ph-profile-idea-cell-data-title { + grid-area: title; + + align-items: center; + + font: { + family: Montserrat; + weight: 600; + size: 12px; + } + color: $ph-black-color; + + @include ellipsis; +} + +.ph-profile-idea-cell-data-icon { + grid-area: edit; + + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.ph-profile-idea-cell-data-idea-wrapper { + padding: 5px 0px; +} + +.ph-profile-idea-cell-data-idea-description { + grid-area: description; + font: { + family: Montserrat; + weight: normal; + size: 12px; + } + color: $ph-primary-color; + height: fit-content; + @include ellipsisMultiline(4); +} + +.ph-profile-idea-cell-data-idea-votes { + grid-area: votes; + + margin: 5px 0px 0px; + background-color: $ph-third-color; + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + text-align: center; + line-height: 1; + + font: { + family: Montserrat; + weight: bold; + size: 12px; + } +} + +.ph-profile-idea-cell-data-idea-votes-counter { + font: { + family: Montserrat; + weight: bold; + size: 18px; + } + color: $ph-secondary-color; +} diff --git a/src/assets/stylesheets/components/_profilesummarylists.scss b/src/assets/stylesheets/components/_profilesummarylists.scss new file mode 100644 index 0000000..be3099f --- /dev/null +++ b/src/assets/stylesheets/components/_profilesummarylists.scss @@ -0,0 +1,15 @@ +.ph-summary-list { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + grid-auto-rows: 110px; + grid-gap: 22px 75px; + + @include respond-to("md") { + grid-gap: 22px 33px; + } + + @include respond-to("xs") { + grid-template-columns: 1fr; + } +} diff --git a/src/assets/stylesheets/components/_resetpassword.scss b/src/assets/stylesheets/components/_resetpassword.scss new file mode 100644 index 0000000..3dcc8b7 --- /dev/null +++ b/src/assets/stylesheets/components/_resetpassword.scss @@ -0,0 +1,140 @@ +.ph-reset-password { + height: auto; + min-height: 100%; + display: flex; + flex-direction: column; + overflow-x: hidden; + + @include respond-to("sm") { + height: 100%; + } +} + +.ph-reset-password-content { + display: flex; + flex-grow: 1; + overflow-y: auto; + + @include respond-to("sm") { + align-items: center; + flex-direction: column; + } +} + +.ph-reset-password-image-wrapper { + width: 50%; + position: relative; + background-color: $ph-primary-color; + + @include respond-to("sm") { + position: absolute; + width: auto; + top: 0; + left: 0; + right: 0; + height: 100%; + } + + @include respond-to("xs") { + position: static; + display: none; + } +} + +.ph-reset-password-image { + height: 100%; + width: 100%; + position: absolute; + opacity: 0.5; + background: { + image: url("../../assets/images/login.svg"); + size: cover; + position: center; + } +} + +.ph-reset-password-data { + width: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 22px; + margin: { + top: 110px; + bottom: 22px; + } + + @include respond-to("sm") { + width: 100%; + max-width: 380px; + background: $ph-white-color; + display: block; + margin: { + left: 22px; + right: 22px; + } + z-index: 1; + } + + @include respond-to("xs") { + margin-top: 55px; + } +} + +.ph-reset-password-data-content { + width: 100%; + max-width: 380px; +} + +.ph-reset-password-title { + text-align: center; + font: { + weight: bold; + size: 24px; + } + color: $ph-primary-color; + margin-bottom: 44px; +} + +.ph-reset-password-subtitle { + margin-bottom: 22px; +} + +.ph-reset-password-button { + margin-top: 33px; + background-color: $ph-primary-color; + color: $ph-white-color; + width: 100%; + height: 54px; + display: flex; + justify-content: center; + align-items: center; + font: { + weight: bold; + size: 18px; + } + cursor: pointer; + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + &:focus { + outline: none; + } + + &:disabled { + cursor: not-allowed; + background-color: $ph-primary-color-half-opacity; + } + + @include respond-to("xs") { + width: 100%; + max-width: 320px; + } +} diff --git a/src/assets/stylesheets/components/_signup.scss b/src/assets/stylesheets/components/_signup.scss new file mode 100644 index 0000000..844cdcc --- /dev/null +++ b/src/assets/stylesheets/components/_signup.scss @@ -0,0 +1,181 @@ +.ph-signup { + min-height: 100%; + height: auto; + display: flex; + flex-direction: column; + overflow-x: hidden; + @include respond-to("sm") { + height: 100%; + } +} + +.ph-signup-content { + display: flex; + flex-grow: 1; + + @include respond-to("sm") { + flex-direction: column; + align-items: center; + } +} + +.ph-signup-image-wrapper { + width: 50%; + position: relative; + background-color: $ph-primary-color; + + @include respond-to("sm") { + position: absolute; + width: auto; + top: 0; + left: 0; + right: 0; + height: 100%; + } + + @include respond-to("xs") { + position: static; + display: none; + } +} + +.ph-signup-image { + height: 100%; + width: 100%; + position: absolute; + opacity: 0.5; + background: { + image: url("../../assets/images/signup.svg"); + size: cover; + position: center; + } +} + +.ph-signup-data { + width: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + box-sizing: inherit; + padding: 22px; + margin: { + top: 110px; + bottom: 22px; + } + + @include respond-to("sm") { + background: $ph-white-color; + width: auto; + z-index: 1; + display: inline-block; + } + + @include respond-to("xs") { + display: flex; + width: 300px; + justify-content: initial; + } +} + +.ph-signup-form { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + + @include respond-to("xs") { + width: 280px; + } +} + +.ph-signup-input { + height: 44px; + width: 100%; + padding: 11px 0px 0px 11px; + font-weight: 300; + font-size: 16px; + border: 0px; + border-bottom: 1px solid $ph-primary-color; + box-sizing: border-box; + display: block; + + &:not(:last-child) { + margin-bottom: 22px; + } + + &.without-margin { + margin-bottom: 0px; + } + + > .ph-signup-input { + display: inline-block; + width: 123px; + height: 21px; + vertical-align: middle; + } +} + +input::placeholder { + color: $ph-cloudy-gray-color; +} + +input:focus { + outline: none; +} + +.ph-signup-button { + margin-top: 33px; + background-color: $ph-primary-color; + color: $ph-white-color; + outline: 0 none transparent; + height: 54px; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + font-weight: bold; + font-size: 18px; + cursor: pointer; + + @include respond-to("xs") { + width: 280px; + } + + &:hover { + background: $ph-primary-color-lighter; + } + + &:active { + background-color: $ph-primary-color-darker; + } + + &:focus { + outline: none; + } + + &:disabled { + background-color: $ph-primary-color-half-opacity; + cursor: not-allowed; + } +} + +.ph-signup-no-account { + margin-top: 33px; + text-align: center; + font-size: 16px; + color: $ph-black-color; + font-weight: 300; +} + +.ph-signup-no-account span { + margin-right: 10px; +} + +.ph-link { + font-weight: 600; + text-decoration: none; + color: $ph-black-color; +} diff --git a/src/assets/stylesheets/components/_validate.scss b/src/assets/stylesheets/components/_validate.scss new file mode 100644 index 0000000..b112c1f --- /dev/null +++ b/src/assets/stylesheets/components/_validate.scss @@ -0,0 +1,151 @@ +.ph-validate { + height: auto; + min-height: 100%; + display: flex; + flex-direction: column; + overflow-x: hidden; + + @include respond-to("sm") { + height: 100%; + } +} + +.ph-validate-content { + display: flex; + flex-grow: 1; + overflow-y: auto; + + @include respond-to("sm") { + align-items: center; + flex-direction: column; + } +} + +.ph-validate-image-wrapper { + width: 50%; + position: relative; + background-color: $ph-primary-color; + + @include respond-to("sm") { + position: absolute; + width: auto; + top: 0; + left: 0; + right: 0; + height: 100%; + } + + @include respond-to("xs") { + position: static; + display: none; + } +} + +.ph-validate-image { + height: 100%; + width: 100%; + position: absolute; + opacity: 0.5; + background: { + image: url("../../assets/images/validate.svg"); + size: cover; + position: center; + } +} + +.ph-validate-data { + width: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 22px; + margin: { + top: 110px; + bottom: 22px; + } + + @include respond-to("sm") { + background: $ph-white-color; + display: block; + width: auto; + z-index: 1; + } +} + +.ph-validate-data-content { + width: 100%; + max-width: 380px; + text-align: center; +} + +.ph-validate-title { + font: { + weight: bold; + size: 24px; + } + color: $ph-primary-color; + margin-bottom: 44px; +} + +.ph-validate-message { + font: { + weight: normal; + size: 16px; + } + + & .ph-validate-email { + font-weight: 600; + display: block; + padding: 22px 0px; + + @include ellipsis(); + } +} + +.ph-validate-button { + margin: 55px auto 0px; + background-color: $ph-primary-color; + color: $ph-white-color; + height: 54px; + width: 320px; + display: flex; + justify-content: center; + align-items: center; + font: { + weight: bold; + size: 18px; + } + cursor: pointer; + + @include respond-to("xs") { + width: 100%; + max-width: 320px; + } +} + +.ph-validate-resend-email { + margin-top: 33px; + text-align: center; + font-size: 16px; +} + +.ph-link { + font-weight: 600; + text-decoration: none; + cursor: pointer; + background-color: transparent; + border-width: 0; + outline: 0 none transparent; + + &, + &:hover, + &:focus { + text-decoration: none; + color: $ph-black-color; + } + + &:disabled { + cursor: not-allowed; + } +} diff --git a/src/assets/stylesheets/fonts/Montserrat-Bold.ttf b/src/assets/stylesheets/fonts/Montserrat-Bold.ttf new file mode 100755 index 0000000..9a425b9 Binary files /dev/null and b/src/assets/stylesheets/fonts/Montserrat-Bold.ttf differ diff --git a/src/assets/stylesheets/fonts/Montserrat-Light.ttf b/src/assets/stylesheets/fonts/Montserrat-Light.ttf new file mode 100755 index 0000000..a3cf5f5 Binary files /dev/null and b/src/assets/stylesheets/fonts/Montserrat-Light.ttf differ diff --git a/src/assets/stylesheets/fonts/Montserrat-Regular.ttf b/src/assets/stylesheets/fonts/Montserrat-Regular.ttf new file mode 100755 index 0000000..2a2b2aa Binary files /dev/null and b/src/assets/stylesheets/fonts/Montserrat-Regular.ttf differ diff --git a/src/assets/stylesheets/fonts/Montserrat-SemiBold.ttf b/src/assets/stylesheets/fonts/Montserrat-SemiBold.ttf new file mode 100755 index 0000000..0ecc667 Binary files /dev/null and b/src/assets/stylesheets/fonts/Montserrat-SemiBold.ttf differ diff --git a/src/assets/stylesheets/layout/README.md b/src/assets/stylesheets/layout/README.md new file mode 100755 index 0000000..3360301 --- /dev/null +++ b/src/assets/stylesheets/layout/README.md @@ -0,0 +1,5 @@ +# Layout + +The `layout/` folder contains everything that takes part in laying out the site or application. This folder could have stylesheets for the main parts of the site (header, footer, navigation, sidebar…), the grid system or even CSS styles for all the forms. + +Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Layout folder](http://sass-guidelin.es/#layout-folder) diff --git a/src/assets/stylesheets/layout/_footer.scss b/src/assets/stylesheets/layout/_footer.scss new file mode 100755 index 0000000..7f45c71 --- /dev/null +++ b/src/assets/stylesheets/layout/_footer.scss @@ -0,0 +1,3 @@ +// ----------------------------------------------------------------------------- +// This file contains all styles related to the footer of the site/application. +// ----------------------------------------------------------------------------- diff --git a/src/assets/stylesheets/layout/_header.scss b/src/assets/stylesheets/layout/_header.scss new file mode 100755 index 0000000..69c86bb --- /dev/null +++ b/src/assets/stylesheets/layout/_header.scss @@ -0,0 +1,3 @@ +// ----------------------------------------------------------------------------- +// This file contains all styles related to the header of the site/application. +// ----------------------------------------------------------------------------- diff --git a/src/assets/stylesheets/main.scss b/src/assets/stylesheets/main.scss new file mode 100755 index 0000000..019b53f --- /dev/null +++ b/src/assets/stylesheets/main.scss @@ -0,0 +1,36 @@ +@charset "UTF-8"; + +/* 1. Configuration and helpers */ +@import "abstracts/variables", "abstracts/functions", "abstracts/mixins"; + +/* 2. Vendors */ +@import "vendor/ant", "vendor/normalize"; + +/* 3. Base stuff */ +@import "base/base", "base/fonts", "base/helpers"; + +/* 4. Layout-related sections */ +@import "layout/header", "layout/footer"; + +/* 5. Components */ +@import "components/aboutmodal"; +@import "components/challenge"; +@import "components/challengeideacell"; +@import "components/confirmemail"; +@import "components/createchallenge"; +@import "components/createidea"; +@import "components/header"; +@import "components/home"; +@import "components/ideaDetail"; +@import "components/loadimage"; +@import "components/login"; +@import "components/profile"; +@import "components/profilechallengecell"; +@import "components/profileideacell"; +@import "components/profilesummarylists"; +@import "components/resetpassword"; +@import "components/signup"; +@import "components/validate"; + +/* 6. Themes */ +@import "themes/default"; diff --git a/src/assets/stylesheets/readme.md b/src/assets/stylesheets/readme.md new file mode 100755 index 0000000..aca2c99 --- /dev/null +++ b/src/assets/stylesheets/readme.md @@ -0,0 +1,7 @@ +# Main file + +The main file (usually labelled `main.scss`) should be the only Sass file from the whole code base not to begin with an underscore. This file should not contain anything but `@import` and comments. + +_Note: when using [Eyeglass](https://github.com/sass-eyeglass/eyeglass) for distribution, it might be a fine idea to name this file `index.scss` rather than `main.scss` in order to stick to [Eyeglass modules specifications](https://github.com/sass-eyeglass/eyeglass#writing-an-eyeglass-module-with-sass-files). See [#21](https://github.com/HugoGiraudel/sass-boilerplate/issues/21) for reference._ + +Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Main file](http://sass-guidelin.es/#main-file) diff --git a/src/assets/stylesheets/themes/README.md b/src/assets/stylesheets/themes/README.md new file mode 100755 index 0000000..65cf21a --- /dev/null +++ b/src/assets/stylesheets/themes/README.md @@ -0,0 +1,7 @@ +# Theme + +On large sites and applications, it is not unusual to have different themes. There are certainly different ways of dealing with themes but I personally like having them all in a `themes/` folder. + +_Note — This is very project-specific and is likely to be non-existent on many projects._ + +Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Themes folder](http://sass-guidelin.es/#themes-folder) diff --git a/src/assets/stylesheets/themes/_default.scss b/src/assets/stylesheets/themes/_default.scss new file mode 100755 index 0000000..4be9bba --- /dev/null +++ b/src/assets/stylesheets/themes/_default.scss @@ -0,0 +1,4 @@ +// ----------------------------------------------------------------------------- +// When having several themes, this file contains everything related to the +// default one. +// ----------------------------------------------------------------------------- diff --git a/src/assets/stylesheets/vendor/README.md b/src/assets/stylesheets/vendor/README.md new file mode 100755 index 0000000..546f2de --- /dev/null +++ b/src/assets/stylesheets/vendor/README.md @@ -0,0 +1,7 @@ +# Vendors + +Most projects will have a `vendors/` folder containing all the CSS files from external libraries and frameworks – Normalize, Bootstrap, jQueryUI, FancyCarouselSliderjQueryPowered, and so on. Putting those aside in the same folder is a good way to say “Hey, this is not from me, not my code, not my responsibility”. + +If you have to override a section of any vendor, I recommend you have an 8th folder called `vendors-extensions/` in which you may have files named exactly after the vendors they overwrite. For instance, `vendors-extensions/_bootstrap.scss` is a file containing all CSS rules intended to re-declare some of Bootstrap’s default CSS. This is to avoid editing the vendor files themselves, which is generally not a good idea. + +Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Vendors folder](http://sass-guidelin.es/#vendors-folder) diff --git a/src/assets/stylesheets/vendor/_ant.scss b/src/assets/stylesheets/vendor/_ant.scss new file mode 100644 index 0000000..b553bd4 --- /dev/null +++ b/src/assets/stylesheets/vendor/_ant.scss @@ -0,0 +1,96 @@ +.ant-modal.ph-modal { + width: 100%; + height: auto; + padding: 0px; + display: flex; + justify-content: center; + + @include respond-to("xs") { + width: 100%; + height: 100%; + margin: 0px; + top: 0px; + } + + > .ant-modal-content { + height: 100%; + width: 100%; + max-width: 831px; + margin: 0px 97px 20px; + border-radius: 0px; + + @include respond-to("sm") { + height: 490px; + margin: 0px 68px 20px; + } + + @include respond-to("xs") { + width: 100%; + height: 100%; + margin: 0px; + } + } + + .ant-modal-body { + width: 100%; + height: 100%; + padding: 0px; + } +} + +.ant-modal-close { + color: $ph-secondary-color !important; + + .ant-modal-close-x { + font-size: 25px !important; + } +} + +.ant-dropdown-menu-item { + &:hover { + background-color: $ph-third-color !important; + } +} + +.ant-calendar-date { + &:hover { + background-color: $ph-third-color !important; + } +} + +.ant-calendar-selected-day .ant-calendar-date { + background-color: $ph-third-color !important; +} + +.ant-calendar-today { + > .ant-calendar-date { + border-color: $ph-primary-color !important; + color: $ph-primary-color !important; + } +} + +.ant-calendar-today-btn { + color: $ph-primary-color !important; +} + +//Checkbox +.ant-checkbox-checked .ant-checkbox-inner { + background-color: $ph-primary-color !important; + border-color: $ph-primary-color !important; +} + +.ant-checkbox-checked:after { + border: 1px solid $ph-primary-color !important; +} + +.ant-checkbox-wrapper:hover .ant-checkbox-inner, +.ant-checkbox:hover .ant-checkbox-inner, +.ant-checkbox-input:focus + .ant-checkbox-inner { + border-color: $ph-primary-color !important; +} + +.ant-checkbox-wrapper { + @include respond-to("xs") { + font-size: 12px !important; + } +} diff --git a/src/assets/stylesheets/vendor/_normalize.scss b/src/assets/stylesheets/vendor/_normalize.scss new file mode 100755 index 0000000..223b4af --- /dev/null +++ b/src/assets/stylesheets/vendor/_normalize.scss @@ -0,0 +1,464 @@ +/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */ + +/** + * 1. Change the default font family in all browsers (opinionated). + * 2. Correct the line height in all browsers. + * 3. Prevent adjustments of font size after orientation changes in + * IE on Windows Phone and in iOS. + */ + +/* Document + ========================================================================== */ + +html { + font-family: sans-serif; /* 1 */ + line-height: 1.15; /* 2 */ + -ms-text-size-adjust: 100%; /* 3 */ + -webkit-text-size-adjust: 100%; /* 3 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers (opinionated). + */ + +body { + margin: 0; +} + +/** + * Add the correct display in IE 9-. + */ + +article, +aside, +footer, +header, +nav, +section { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + * 1. Add the correct display in IE. + */ + +figcaption, +figure, +main { + /* 1 */ + display: block; +} + +/** + * Add the correct margin in IE 8. + */ + +figure { + margin: 1em 40px; +} + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +//pre { +// font-family: monospace, monospace; /* 1 */ +// font-size: 1em; /* 2 */ +//} + +/* Text-level semantics + ========================================================================== */ + +/** + * 1. Remove the gray background on active links in IE 10. + * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. + */ + +a { + background-color: transparent; /* 1 */ + -webkit-text-decoration-skip: objects; /* 2 */ +} + +/** + * Remove the outline on focused links when they are also active or hovered + * in all browsers (opinionated). + */ + +a:active, +a:hover { + outline-width: 0; +} + +/** + * 1. Remove the bottom border in Firefox 39-. + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Prevent the duplicate application of `bolder` by the next rule in Safari 6. + */ + +b, +strong { + font-weight: inherit; +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +//code, +//kbd, +//samp { +// font-family: monospace, monospace; /* 1 */ +// font-size: 1em; /* 2 */ +//} + +/** + * Add the correct font style in Android 4.3-. + */ + +dfn { + font-style: italic; +} + +/** + * Add the correct background and color in IE 9-. + */ + +mark { + background-color: #ff0; + color: #000; +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +audio, +video { + display: inline-block; +} + +/** + * Add the correct display in iOS 4-7. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Remove the border on images inside links in IE 10-. + */ + +img { + border-style: none; +} + +/** + * Hide the overflow in IE. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers (opinionated). + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: sans-serif; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` + * controls in Android 4. + * 2. Correct the inability to style clickable types in iOS and Safari. + */ + +button, +html [type="button"], /* 1 */ +[type="reset"], +[type="submit"] { + -webkit-appearance: button; /* 2 */ +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Change the border, margin, and padding in all browsers (opinionated). + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * 1. Add the correct display in IE 9-. + * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Remove the default vertical scrollbar in IE. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10-. + * 2. Remove the padding in IE 10-. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in IE 9-. + * 1. Add the correct display in Edge, IE, and Firefox. + */ + +details, /* 1 */ +menu { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Scripting + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +canvas { + display: inline-block; +} + +/** + * Add the correct display in IE. + */ + +template { + display: none; +} + +/* Hidden + ========================================================================== */ + +/** + * Add the correct display in IE 10-. + */ + +[hidden] { + display: none; +} diff --git a/src/services/.gitignore b/src/services/.gitignore new file mode 100644 index 0000000..ad46b30 --- /dev/null +++ b/src/services/.gitignore @@ -0,0 +1,61 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/src/services/AppConfig.ts b/src/services/AppConfig.ts new file mode 100644 index 0000000..374a5e0 --- /dev/null +++ b/src/services/AppConfig.ts @@ -0,0 +1,3 @@ +export const AppConfig: any = { + Settings: {Server: {apiClient: {timeout: 30000}}}, +}; diff --git a/src/services/Errors.ts b/src/services/Errors.ts new file mode 100644 index 0000000..e1aa2c1 --- /dev/null +++ b/src/services/Errors.ts @@ -0,0 +1,38 @@ +// TODO interface => types + +export type GenericError = { + code: "GENERIC_ERROR"; + message: string; +}; + +export type NetworkError = { + code: "NETWORK_ERROR"; + message: string; +}; + +export interface BadUserInputError { + code: "BAD_USER_INPUT_ERROR"; + message: string; + extra: T; +} + +export interface ObjectNotFoundError { + code: "OBJECT_NOT_FOUND_ERROR"; + message?: string; +} + +export type PendingAccountError = { + code: "PENDING_ACCOUNT_ERROR"; + message: string; +}; + +export interface UserNotAuthenticatedError { + code: "USER_NOT_AUTHENTICATED_ERROR"; + message: string; +} + +export interface ValidationError { + code: "VALIDATION_ERROR"; + field: T; + message: string; +} diff --git a/src/services/Helper.ts b/src/services/Helper.ts new file mode 100644 index 0000000..c893d91 --- /dev/null +++ b/src/services/Helper.ts @@ -0,0 +1,8 @@ +import * as _ from "lodash"; +import {isObservableArray} from "mobx"; + +export class Helper { + static isArray(value: T[] | any): value is T[] { + return _.isArray(value) || isObservableArray(value); + } +} diff --git a/src/services/apiclient/APIClient.ts b/src/services/apiclient/APIClient.ts new file mode 100644 index 0000000..fc03ea1 --- /dev/null +++ b/src/services/apiclient/APIClient.ts @@ -0,0 +1,1804 @@ +import ApolloClient from "apollo-client"; +import {BaseAPIClient, Context, RequestOptions} from "./BaseAPIClient"; +import {AppConfig} from "../AppConfig"; +import {ApolloLink, execute} from "apollo-link"; +import {createUploadLink} from "apollo-upload-client"; +import {InMemoryCache} from "apollo-cache-inmemory"; +import {onError} from "apollo-link-error"; +import { + CheckEmailRequest, + CheckEmailResponse, + ConfirmEmailRequest, + ConfirmEmailResponse, + CreateChallengeRequest, + CreateChallengeResponse, + CreateIdeaReactionRequest, + CreateIdeaReactionResponse, + CreateIdeaRequest, + CreateIdeaResponse, + DeleteChallengeRequest, + DeleteChallengeResponse, + DeleteIdeaReactionRequest, + DeleteIdeaReactionResponse, + DeleteIdeaRequest, + DeleteIdeaResponse, + FetchChallengeIdeasRequest, + FetchChallengeIdeasResponse, + FetchChallengeListRequest, + FetchChallengeListResponse, + FetchChallengeRequest, + FetchChallengeResponse, + FetchIdeaRequest, + FetchIdeaResponse, + FetchIdeasWithUserReactionRequest, + FetchIdeasWithUserReactionResponse, + FetchMeRequest, + FetchMeResponse, + FetchMyChallengesRequest, + FetchMyChallengesResponse, + FetchMyIdeasRequest, + FetchMyIdeasResponse, + FetchUserChallengesRequest, + FetchUserChallengesResponse, + FetchUserIdeasRequest, + FetchUserIdeasResponse, + FetchUserRequest, + FetchUserResponse, + RefreshTokensRequest, + RefreshTokensResponse, + RequestResetPasswordRequest, + RequestResetPasswordResponse, + ResendEmailConfirmationRequest, + ResendEmailConfirmationResponse, + ResetPasswordRequest, + ResetPasswordResponse, + SignInRequest, + SignInResponse, + SignUpRequest, + SignUpResponse, + UpdateChallengeRequest, + UpdateChallengeResponse, + UpdateIdeaRequest, + UpdateIdeaResponse, + UpdateUserRequest, + UpdateUserResponse, + ValidateEmailRequest, + ValidateEmailResponse, +} from "./APIClient.types"; +import gql from "graphql-tag"; +import * as Models from "../models"; +import * as _ from "lodash"; + +export type ConfigureClientOptions = {}; + +export type GetHeadersOptions = { + authToken?: string; +}; + +export class APIClient extends BaseAPIClient { + static client: ApolloClient; + + static configureClient(options: ConfigureClientOptions) { + const defaultConfig = { + headers: { + Accept: "application/json", + }, + timeout: AppConfig.Settings.Server.apiClient.timeout, + }; + + this.client = new ApolloClient({ + link: ApolloLink.from([ + onError(({graphQLErrors, networkError}) => { + if (graphQLErrors) graphQLErrors.map(({message, locations, path}) => console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`)); + if (networkError) console.log(`[Network error]: ${networkError}`); + }), + new ApolloLink(operation => { + const context = operation.getContext() as Context; + + const requestConfig = { + ...context.requestConfig, + timeout: context.requestConfig.timeout || defaultConfig.timeout, + headers: context.requestConfig.headers, + }; + + return execute( + createUploadLink({ + uri: process.env.REACT_APP_SERVER_BASE_URI || "/graphql", + credentials: "same-origin", + fetch: this.getCustomFetch(requestConfig, context.requestOptions), + }), + operation + ); + }), + ]), + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: "no-cache", + }, + }, + }); + } + + static getHeaders(options: GetHeadersOptions): Record { + const headers: Record = {}; + + if (options.authToken) headers["x-token"] = options.authToken; + + return headers; + } + + static async refreshTokens(request: RefreshTokensRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation refreshTokens($token: String!) { + refreshTokens(token: $token) { + token + refreshToken + } + } + `, + variables: { + token: request.refreshToken, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.refreshTokens && typeof data.refreshTokens.token === "string" && typeof data.refreshTokens.refreshToken === "string") + return { + success: true, + authToken: data.refreshTokens.token, + refreshToken: data.refreshTokens.refreshToken, + }; + } else { + const {graphQLErrors} = response.rawResponse; + const badUserInputError = graphQLErrors.find(e => !_.isNil(e.extensions) && e.extensions.code === "BAD_USER_INPUT"); + + if (badUserInputError) + return { + success: false, + error: { + code: "BAD_USER_INPUT_ERROR", + message: badUserInputError.message, + extra: undefined, + }, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async signIn(request: SignInRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation signIn($email: String!, $password: String!) { + signIn(email: $email, password: $password, generateRefreshToken: true) { + token + refreshToken + } + } + `, + variables: { + email: request.email.toLowerCase(), + password: request.password, + }, + headers: this.getHeaders({}), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.signIn && typeof data.signIn.token === "string" && typeof data.signIn.refreshToken === "string") + return { + success: true, + authToken: data.signIn.token, + refreshToken: data.signIn.refreshToken, + }; + } else { + const {graphQLErrors} = response.rawResponse; + + const unauthenticatedError = graphQLErrors.find(error => !_.isNil(error.extensions) && error.extensions.code === "UNAUTHENTICATED"); // TODO: NotFound -> Unauthenticated? + const pendingError = graphQLErrors.find(error => !_.isNil(error.extensions) && error.extensions.code === "BAD_USER_INPUT" && !_.isNil(error.extensions.exception) && error.extensions.exception.statusCode === 409); + + if (unauthenticatedError) + return { + success: false, + error: { + code: "OBJECT_NOT_FOUND_ERROR", + message: unauthenticatedError.message, + }, + }; + else if (pendingError) + return { + success: false, + error: { + code: "PENDING_ACCOUNT_ERROR", + message: pendingError.message, + }, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async signUp(request: SignUpRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation signUp($name: String!, $email: String!, $password: String!) { + signUp(name: $name, email: $email, password: $password) { + _ + } + } + `, + variables: { + name: request.name, + email: request.email.toLowerCase(), + password: request.password, + }, + headers: this.getHeaders({}), + context: {}, + }, + options + ); + + if (!response.success) { + const {graphQLErrors} = response.rawResponse; + + const badUserInputError = graphQLErrors.find(error => !_.isNil(error.extensions) && error.extensions.code === "BAD_USER_INPUT" && !_.isNil(error.extensions.exception) && error.extensions.exception.statusCode === 409); + + return badUserInputError + ? { + success: false, + error: { + code: "BAD_USER_INPUT_ERROR", + message: badUserInputError.message, + extra: "EMAIL_IN_USE", + }, + } + : { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + return { + success: true, + }; + } + + static async resendEmailConfirmation(request: ResendEmailConfirmationRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation resendEmailConfirmation($email: String!) { + resendEmailConfirmation(email: $email) { + _ + } + } + `, + variables: { + email: request.email.toLowerCase(), + }, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) return {success: true}; + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async requestResetPassword(request: RequestResetPasswordRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation requestResetPassword($email: String!) { + requestResetPassword(email: $email) { + _ + } + } + `, + variables: { + email: request.email.toLowerCase(), + }, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) return {success: true}; + + const badUserInputError = response.rawResponse.graphQLErrors.find(e => !_.isNil(e.extensions) && e.extensions.code === "BAD_USER_INPUT"); + + if (badUserInputError) + return { + success: false, + error: { + code: "BAD_USER_INPUT_ERROR", + extra: undefined, + message: badUserInputError.message, + }, + }; + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async resetPassword(request: ResetPasswordRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation resetPassword($token: String!, $password: String!) { + resetPassword(token: $token, password: $password) { + _ + } + } + `, + variables: { + token: request.token, + password: request.password, + }, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) return {success: true}; + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async validateEmail(request: ValidateEmailRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation validateEmail { + checkEmail + } + `, + variables: {}, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && typeof data.validateEmail === "boolean" && data.validateEmail) return {success: true}; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchMe(request: FetchMeRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query me { + me { + id + name + imageUrl + email + + createdDate + modifiedDate + } + } + `, + variables: {}, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.me) return {success: true, user: Models.User.fromJSON(data.me)}; + } else { + const {graphQLErrors} = response.rawResponse; + + const forbiddenError = graphQLErrors.find(error => error.extensions && error.extensions!.code === "FORBIDDEN"); + + if (forbiddenError) + return { + success: false, + error: { + code: "BAD_USER_INPUT_ERROR", + message: "Invalid token", + extra: undefined, + }, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchUser(request: FetchUserRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query fetchUser($id: ID!) { + user(id: $id) { + id + name + imageUrl + + createdDate + modifiedDate + } + } + `, + variables: { + id: request.id, + }, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.user) return {success: true, user: Models.User.fromJSON(data.user)}; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async updateUser(request: UpdateUserRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation updateUser($id: ID!, $name: String, $image: Upload) { + updateUser(id: $id, name: $name, upload: $image) { + id + name + imageUrl + email + + createdDate + modifiedDate + } + } + `, + variables: { + id: request.id, + name: request.name, + image: request.image, + }, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.updateUser) return {success: true, user: Models.User.fromJSON(data.updateUser)}; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async confirmEmail(request: ConfirmEmailRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation confirmEmail($token: String!) { + confirmEmail(token: $token) { + _ + } + } + `, + variables: { + token: request.token, + }, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + return response.success + ? {success: true} + : { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async checkEmail(request: CheckEmailRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation checkEmail($email: String!) { + checkEmail(email: $email) { + isAvailable + isBlacklisted + isCorporate + } + } + `, + variables: { + email: request.email, + }, + headers: this.getHeaders({authToken: request.authToken}), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.checkEmail) { + return { + success: true, + ...data.checkEmail, + }; + } + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchChallengeList(request: FetchChallengeListRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + { + challenges { + edges { + node { + id + title + description + imageUrl + closeDate + endDate + privacyMode + privacyData + + createdDate + createdBy { + id + name + imageUrl + } + modifiedDate + modifiedBy { + id + name + imageUrl + } + + ideas { + totalCount + edges { + node { + id + title + description + imageUrl + + reactions { + totalCount + } + + reactionsSummary { + value + totalCount + } + } + } + } + } + } + } + } + `, + variables: {}, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.challenges && Array.isArray(data.challenges.edges)) + return { + success: true, + challenges: data.challenges.edges.map((edge: any) => Models.Challenge.fromJSON(edge.node)), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchMyChallenges(request: FetchMyChallengesRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + { + challenges(createdByMe: true, excludeClosed: false, excludeEnded: false) { + totalCount + edges { + node { + id + title + description + imageUrl + closeDate + endDate + privacyMode + privacyData + + createdDate + modifiedDate + + topIdea { + id + title + description + imageUrl + + reactionsSummary { + value + totalCount + } + } + + ideas { + totalCount + edges { + node { + id + title + description + imageUrl + } + } + } + } + } + } + } + `, + variables: {}, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.challenges && Array.isArray(data.challenges.edges)) + return { + success: true, + challenges: data.challenges.edges.map((edge: any) => Models.Challenge.fromJSON(edge.node)), + totalCount: data.challenges.totalCount, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchUserChallenges(request: FetchUserChallengesRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query fetchUserChallenges($userId: String!) { + challenges(createdById: $userId, excludeClosed: false, excludeEnded: false) { + totalCount + edges { + node { + id + title + description + imageUrl + closeDate + endDate + privacyMode + privacyData + + createdDate + modifiedDate + + topIdea { + id + title + description + imageUrl + + reactionsSummary { + value + totalCount + } + } + + ideas { + totalCount + edges { + node { + id + title + description + imageUrl + } + } + } + } + } + } + } + `, + variables: { + userId: request.userId, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.challenges && Array.isArray(data.challenges.edges)) + return { + success: true, + challenges: data.challenges.edges.map((edge: any) => Models.Challenge.fromJSON(edge.node)), + totalCount: data.challenges.totalCount, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchChallenge(request: FetchChallengeRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query challenge($id: ID!) { + challenge(id: $id) { + id + title + description + imageUrl + closeDate + endDate + privacyMode + privacyData + + createdDate + createdBy { + id + name + imageUrl + } + modifiedDate + modifiedBy { + id + name + imageUrl + } + + reactions { + totalCount + edges { + node { + id + objectId + value + + createdDate + createdBy { + id + name + } + } + } + } + reactionsSummary { + value + totalCount + } + + topIdea { + id + title + description + imageUrl + + reactionsSummary { + value + totalCount + } + } + + ideas { + totalCount + edges { + node { + id + title + description + imageUrl + createdDate + createdBy { + id + name + imageUrl + } + } + } + } + } + } + `, + variables: { + id: request.challengeId, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.challenge) + return { + success: true, + challenge: Models.Challenge.fromJSON(data.challenge), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async createChallenge(request: CreateChallengeRequest, options: RequestOptions = {}): Promise { + const {client} = this; + const {authToken, title, description, closeDate, endDate, image: upload, privacyMode} = request; + + if (closeDate) closeDate.endOf("day"); + if (!_.isNil(endDate)) endDate.endOf("day"); + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation createChallenge($title: String!, $description: String, $closeDate: DateTime, $endDate: DateTime, $upload: Upload, $privacyMode: ChallengePrivacyMode) { + createChallenge(title: $title, description: $description, closeDate: $closeDate, endDate: $endDate, upload: $upload, privacyMode: $privacyMode) { + id + title + description + imageUrl + closeDate + endDate + privacyData + privacyMode + + createdDate + createdBy { + id + name + imageUrl + } + modifiedDate + modifiedBy { + id + name + imageUrl + } + + ideas { + totalCount + edges { + node { + id + title + description + imageUrl + + reactionsSummary { + value + totalCount + } + } + } + } + } + } + `, + variables: { + title, + description, + closeDate: _.isNil(closeDate) ? undefined : closeDate.toISOString(), + endDate: _.isNil(endDate) ? undefined : endDate.toISOString(), + upload, + privacyMode, + }, + headers: this.getHeaders({ + authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.createChallenge) + return { + success: true, + challenge: Models.Challenge.fromJSON(data.createChallenge), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async updateChallenge(request: UpdateChallengeRequest, options: RequestOptions = {}): Promise { + const {client} = this; + const {authToken, id, title, description, closeDate, endDate, image: upload, privacyMode} = request; + + if (closeDate) closeDate.endOf("day"); + if (!_.isNil(endDate)) endDate.endOf("day"); + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation updateChallenge($id: ID!, $title: String!, $description: String, $closeDate: DateTime, $endDate: DateTime, $upload: Upload, $privacyMode: ChallengePrivacyMode) { + updateChallenge(id: $id, title: $title, description: $description, closeDate: $closeDate, endDate: $endDate, upload: $upload, privacyMode: $privacyMode) { + id + title + description + imageUrl + closeDate + endDate + privacyMode + privacyData + } + } + `, + variables: { + id, + title, + description, + closeDate: _.isNil(closeDate) ? undefined : closeDate.toISOString(), + endDate: _.isNil(endDate) ? undefined : endDate.toISOString(), + upload, + privacyMode, + }, + headers: this.getHeaders({ + authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.updateChallenge) + return { + success: true, + challenge: Models.Challenge.fromJSON(data.updateChallenge), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async deleteChallenge(request: DeleteChallengeRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation deleteChallenge($id: ID!) { + deleteChallenge(id: $id) + } + `, + variables: { + id: request.id, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && typeof data.deleteChallenge === "boolean" && data.deleteChallenge) + return { + success: true, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchChallengeIdeas(request: FetchChallengeIdeasRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query fetcChallengeIdeas($challengeId: String!) { + ideas(challengeId: $challengeId) { + totalCount + edges { + node { + id + title + description + imageUrl + challenge { + id + title + } + + reactions { + totalCount + } + + reactionsSummary { + value + totalCount + } + + myReaction { + id + value + } + + createdDate + createdBy { + id + name + imageUrl + } + modifiedDate + modifiedBy { + id + name + imageUrl + } + } + } + } + } + `, + variables: { + challengeId: request.challengeId, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.ideas && Array.isArray(data.ideas.edges) && typeof data.ideas.totalCount === "number") + return { + success: true, + ideas: data.ideas.edges.map((e: any) => Models.Idea.fromJSON(e.node)), + totalCount: data.ideas.totalCount, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchUserIdeas(request: FetchUserIdeasRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query fetchUserIdeas($userId: String!) { + ideas(createdById: $userId) { + totalCount + edges { + node { + id + title + description + imageUrl + + challenge { + id + title + closeDate + } + + createdDate + modifiedDate + + reactionsSummary { + value + totalCount + } + } + } + } + } + `, + variables: { + userId: request.userId, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.ideas && Array.isArray(data.ideas.edges) && typeof data.ideas.totalCount === "number") + return { + success: true, + ideas: data.ideas.edges.map((e: any) => Models.Idea.fromJSON(e.node)), + totalCount: data.ideas.totalCount, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchIdeasWithUserReaction(request: FetchIdeasWithUserReactionRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query fetchIdeasWithUserReaction($userId: String!) { + ideas(withReactionByUserId: $userId) { + totalCount + edges { + node { + id + title + description + imageUrl + challenge { + id + title + closeDate + } + + reactionsSummary { + value + totalCount + } + + myReaction { + id + value + } + + createdDate + modifiedDate + modifiedBy { + id + name + imageUrl + } + } + } + } + } + `, + variables: { + userId: request.userId, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.ideas && Array.isArray(data.ideas.edges) && typeof data.ideas.totalCount === "number") + return { + success: true, + ideas: data.ideas.edges.map((e: any) => Models.Idea.fromJSON(e.node)), + totalCount: data.ideas.totalCount, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchMyIdeas(request: FetchMyIdeasRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + { + ideas(createdByMe: true) { + totalCount + edges { + node { + id + title + description + imageUrl + + challenge { + id + title + closeDate + } + + reactionsSummary { + value + totalCount + } + + createdDate + modifiedDate + modifiedBy { + id + name + imageUrl + } + } + } + } + } + `, + variables: {}, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.ideas && Array.isArray(data.ideas.edges) && typeof data.ideas.totalCount === "number") + return { + success: true, + ideas: data.ideas.edges.map((e: any) => Models.Idea.fromJSON(e.node)), + totalCount: data.ideas.totalCount, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async fetchIdea(request: FetchIdeaRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "query", + gql: gql` + query idea($id: ID!) { + idea(id: $id) { + id + title + description + imageUrl + + myReaction { + id + value + } + + reactions { + totalCount + edges { + node { + id + value + + createdDate + createdBy { + id + name + } + } + } + } + reactionsSummary { + value + totalCount + } + + challenge { + id + title + closeDate + } + + createdBy { + id + name + imageUrl + } + createdDate + } + } + `, + variables: { + id: request.id, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.idea) + return { + success: true, + idea: Models.Idea.fromJSON(data.idea), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async createIdea(request: CreateIdeaRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation createIdea($challengeId: String!, $title: String!, $description: String, $upload: Upload) { + createIdea(challengeId: $challengeId, title: $title, description: $description, upload: $upload) { + id + title + description + imageUrl + + challenge { + id + closeDate + } + + createdBy { + id + name + imageUrl + } + } + } + `, + variables: { + challengeId: request.challengeId, + title: request.title, + description: request.description, + upload: request.image, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.createIdea) + return { + success: true, + idea: Models.Idea.fromJSON(data.createIdea), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async updateIdea(request: UpdateIdeaRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation updateIdea($id: ID!, $title: String!, $description: String, $upload: Upload) { + updateIdea(id: $id, title: $title, description: $description, upload: $upload) { + id + title + description + imageUrl + } + } + `, + variables: { + id: request.id, + title: request.title, + description: request.description, + upload: request.image, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.updateIdea) + return { + success: true, + idea: Models.Idea.fromJSON(data.updateIdea), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async deleteIdea(request: DeleteIdeaRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation deleteIdea($id: ID!) { + deleteIdea(id: $id) + } + `, + variables: { + id: request.id, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.deleteIdea) + return { + success: true, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async createIdeaReaction(request: CreateIdeaReactionRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation createReaction($ideaId: ID!) { + createReaction(objectType: IDEA, objectId: $ideaId, value: "LIKE") { + id + value + } + } + `, + variables: { + ideaId: request.ideaId, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.createReaction) + return { + success: true, + reaction: Models.Reaction.fromJSON(data.createReaction), + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } + + static async deleteIdeaReaction(request: DeleteIdeaReactionRequest, options: RequestOptions = {}): Promise { + const {client} = this; + + const response = await this.request( + client, + { + request, + requestMethod: "mutation", + gql: gql` + mutation deleteReaction($id: ID!) { + deleteReaction(objectType: IDEA, id: $id) + } + `, + variables: { + id: request.id, + }, + headers: this.getHeaders({ + authToken: request.authToken, + }), + context: {}, + }, + options + ); + + if (response.success) { + const {data} = response.rawResponse; + + if (data && data.deleteReaction) + return { + success: true, + }; + } + + return { + success: false, + error: !response.success && this.isNetworkError(response.rawResponse) ? this.networkError() : this.genericError(), + }; + } +} diff --git a/src/services/apiclient/APIClient.types.ts b/src/services/apiclient/APIClient.types.ts new file mode 100644 index 0000000..196f5f5 --- /dev/null +++ b/src/services/apiclient/APIClient.types.ts @@ -0,0 +1,447 @@ +import * as Models from "../models"; +import * as Errors from "../Errors"; +import moment from "moment"; +import {ReactNativeFile} from "extract-files"; + +export type APIRequest = { + authToken?: string; +}; + +export type RefreshTokensRequest = APIRequest & { + refreshToken: string; +}; +export type SuccessfulRefreshTokensResponse = { + success: true; + authToken: string; + refreshToken: string; +}; +export type FailedRefreshTokensResponse = { + success: false; + authToken?: undefined; + refreshToken?: undefined; + error: Errors.NetworkError | Errors.GenericError | Errors.BadUserInputError; +}; +export type RefreshTokensResponse = SuccessfulRefreshTokensResponse | FailedRefreshTokensResponse; + +export type SignInRequest = APIRequest & { + email: string; + password: string; +}; +export type SuccessfulSignInResponse = { + success: true; + authToken: string; + refreshToken: string; +}; +export type FailedSignInResponse = { + success: false; + authToken?: undefined; + refreshToken?: undefined; + error: Errors.NetworkError | Errors.GenericError | Errors.ObjectNotFoundError | Errors.PendingAccountError; +}; +export type SignInResponse = SuccessfulSignInResponse | FailedSignInResponse; + +export type SignUpRequest = APIRequest & { + name: string; + email: string; + password: string; +}; +export type SuccessfulSignUpResponse = { + success: true; +}; +export type FailedSignUpResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError | Errors.BadUserInputError<"EMAIL_IN_USE">; +}; +export type SignUpResponse = SuccessfulSignUpResponse | FailedSignUpResponse; + +export type ResendEmailConfirmationRequest = APIRequest & { + email: string; +}; +export type SuccessfulResendEmailConfirmationResponse = { + success: true; +}; +export type FailedResendEmailConfirmationResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError; +}; +export type ResendEmailConfirmationResponse = SuccessfulResendEmailConfirmationResponse | FailedResendEmailConfirmationResponse; + +export type RequestResetPasswordRequest = APIRequest & { + email: string; +}; +export type SuccessfulRequestResetPasswordResponse = { + success: true; +}; +export type FailedRequestResetPasswordResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError | Errors.BadUserInputError; +}; +export type RequestResetPasswordResponse = SuccessfulRequestResetPasswordResponse | FailedRequestResetPasswordResponse; + +export type ResetPasswordRequest = APIRequest & { + token: string; + password: string; +}; +export type SuccessfulResetPasswordResponse = { + success: true; +}; +export type FailedResetPasswordResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError; +}; +export type ResetPasswordResponse = SuccessfulResetPasswordResponse | FailedResetPasswordResponse; + +export type ValidateEmailRequest = APIRequest & { + authToken: string; +}; +export type SuccessfulValidateEmailResponse = { + success: true; +}; +export type FailedValidateEmailResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError; +}; +export type ValidateEmailResponse = SuccessfulValidateEmailResponse | FailedValidateEmailResponse; + +export type FetchMeRequest = APIRequest & { + authToken: string; +}; +export type SuccessfulFetchMeResponse = { + success: true; + user: Models.User; +}; +export type FailedFetchMeResponse = { + success: false; + user?: undefined; + error: Errors.NetworkError | Errors.GenericError | Errors.BadUserInputError; +}; +export type FetchMeResponse = SuccessfulFetchMeResponse | FailedFetchMeResponse; + +export type FetchUserRequest = APIRequest & { + id: string; +}; +export type SuccessfulFetchUserResponse = { + success: true; + user: Models.User; +}; +export type FailedFetchUserResponse = { + success: false; + user?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchUserResponse = SuccessfulFetchUserResponse | FailedFetchUserResponse; + +export type UpdateUserRequest = APIRequest & { + authToken: string; + id: string; + name?: string; + image?: File | ReactNativeFile; +}; +export type SuccessfulUpdateUserResponse = { + success: true; + user: Models.User; +}; +export type FailedUpdateUserResponse = { + success: false; + user?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type UpdateUserResponse = SuccessfulUpdateUserResponse | FailedUpdateUserResponse; + +export type ConfirmEmailRequest = APIRequest & { + token: string; +}; +export type SuccessfulConfirmEmailResponse = { + success: true; +}; +export type FailedConfirmEmailResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError; +}; +export type ConfirmEmailResponse = SuccessfulConfirmEmailResponse | FailedConfirmEmailResponse; + +export type CheckEmailRequest = APIRequest & { + email: string; +}; +export type SuccessfulCheckEmailResponse = { + success: true; + isAvailable: boolean; + isBlacklisted: boolean; + isCorporate: boolean; +}; +export type FailedCheckEmailResponse = { + success: false; + isAvailable?: undefined; + isBlacklisted?: undefined; + isCorporate?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type CheckEmailResponse = SuccessfulCheckEmailResponse | FailedCheckEmailResponse; + +export type FetchChallengeListRequest = APIRequest; +export type SuccessfulFetchChallengeListResponse = { + success: true; + challenges: Models.Challenge[]; +}; +export type FailedFetchChallengeListResponse = { + success: false; + challenges?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchChallengeListResponse = SuccessfulFetchChallengeListResponse | FailedFetchChallengeListResponse; + +export type FetchMyChallengesRequest = APIRequest & { + authToken: string; +}; +export type SuccessfulFetchMyChallengesResponse = { + success: true; + challenges: Models.Challenge[]; + totalCount: number; +}; +export type FailedFetchMyChallengesResponse = { + success: false; + challenges?: undefined; + totalCount?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchMyChallengesResponse = SuccessfulFetchMyChallengesResponse | FailedFetchMyChallengesResponse; + +export type FetchUserChallengesRequest = APIRequest & { + userId: string; +}; +export type SuccessfulFetchUserChallengesResponse = { + success: true; + challenges: Models.Challenge[]; + totalCount: number; +}; +export type FailedFetchUserChallengesResponse = { + success: false; + challenges?: undefined; + totalCount?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchUserChallengesResponse = SuccessfulFetchUserChallengesResponse | FailedFetchUserChallengesResponse; + +export type FetchChallengeRequest = APIRequest & { + challengeId: string; +}; +export type SuccessfulFetchChallengeResponse = { + success: true; + challenge: Models.Challenge; +}; +export type FailedFetchChallengeResponse = { + success: false; + challenge?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchChallengeResponse = SuccessfulFetchChallengeResponse | FailedFetchChallengeResponse; + +export type CreateChallengeRequest = APIRequest & { + authToken: string; + title: string; + description?: string; + closeDate?: moment.Moment; + endDate?: moment.Moment; + image?: File | ReactNativeFile; + privacyMode?: Models.ChallengePrivacyMode; +}; +export type SuccessfulCreateChallengeResponse = { + success: true; + challenge: Models.Challenge; +}; +export type FailedCreateChallengeResponse = { + success: false; + challenge?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type CreateChallengeResponse = SuccessfulCreateChallengeResponse | FailedCreateChallengeResponse; + +export type UpdateChallengeRequest = APIRequest & { + id: string; + authToken: string; + title: string; + description?: string; + closeDate?: moment.Moment; + endDate?: moment.Moment; + image?: File | ReactNativeFile; + privacyMode?: Models.ChallengePrivacyMode; +}; +export type SuccessfulUpdateChallengeResponse = { + success: true; + challenge: Models.Challenge; +}; +export type FailedUpdateChallengeResponse = { + success: false; + challenge?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type UpdateChallengeResponse = SuccessfulUpdateChallengeResponse | FailedUpdateChallengeResponse; + +export type DeleteChallengeRequest = APIRequest & { + id: string; + authToken: string; +}; +export type SuccessfulDeleteChallengeResponse = { + success: true; +}; +export type FailedDeleteChallengeResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError; +}; +export type DeleteChallengeResponse = SuccessfulDeleteChallengeResponse | FailedDeleteChallengeResponse; + +export type FetchChallengeIdeasRequest = APIRequest & { + authToken: string; + challengeId: string; +}; +export type SuccessfulFetchChallengeIdeasResponse = { + success: true; + ideas: Models.Idea[]; + totalCount: number; +}; +export type FailedFetchChallengeIdeasResponse = { + success: false; + ideas?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchChallengeIdeasResponse = SuccessfulFetchChallengeIdeasResponse | FailedFetchChallengeIdeasResponse; + +export type FetchUserIdeasRequest = APIRequest & { + userId: string; +}; +export type SuccessfulFetchUserIdeasResponse = { + success: true; + ideas: Models.Idea[]; + totalCount: number; +}; +export type FailedFetchUserIdeasResponse = { + success: false; + ideas?: undefined; + totalCount?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchUserIdeasResponse = SuccessfulFetchUserIdeasResponse | FailedFetchUserIdeasResponse; + +export type FetchIdeasWithUserReactionRequest = APIRequest & { + userId: string; +}; +export type SuccessfulFetchIdeasWithUserReactionResponse = { + success: true; + ideas: Models.Idea[]; + totalCount: number; +}; +export type FailedFetchIdeasWithUserReactionResponse = { + success: false; + ideas?: undefined; + totalCount?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchIdeasWithUserReactionResponse = SuccessfulFetchIdeasWithUserReactionResponse | FailedFetchIdeasWithUserReactionResponse; + +export type FetchMyIdeasRequest = APIRequest & { + authToken: string; +}; +export type SuccessfulFetchMyIdeasResponse = { + success: true; + ideas: Models.Idea[]; + totalCount: number; +}; +export type FailedFetchMyIdeasResponse = { + success: false; + ideas?: undefined; + totalCount?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchMyIdeasResponse = SuccessfulFetchMyIdeasResponse | FailedFetchMyIdeasResponse; + +export type FetchIdeaRequest = APIRequest & { + authToken: string | undefined; + id: string; +}; +export type SuccessfulFetchIdeaResponse = { + success: true; + idea: Models.Idea; +}; +export type FailedFetchIdeaResponse = { + success: false; + idea?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type FetchIdeaResponse = SuccessfulFetchIdeaResponse | FailedFetchIdeaResponse; + +export type CreateIdeaRequest = APIRequest & { + authToken: string; + challengeId: string; + title: string; + description?: string; + image?: File | ReactNativeFile; +}; +export type SuccessfulCreateIdeaResponse = { + success: true; + idea: Models.Idea; +}; +export type FailedCreateIdeaResponse = { + success: false; + idea?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type CreateIdeaResponse = SuccessfulCreateIdeaResponse | FailedCreateIdeaResponse; + +export type UpdateIdeaRequest = APIRequest & { + authToken: string; + id: string; + title: string; + description?: string; + image?: File | ReactNativeFile; +}; +export type SuccessfulUpdateIdeaResponse = { + success: true; + idea: Models.Idea; +}; +export type FailedUpdateIdeaResponse = { + success: false; + idea?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type UpdateIdeaResponse = SuccessfulUpdateIdeaResponse | FailedUpdateIdeaResponse; + +export type DeleteIdeaRequest = APIRequest & { + authToken: string; + id: string; +}; +export type SuccessfulDeleteIdeaResponse = { + success: true; +}; +export type FailedDeleteIdeaResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError; +}; +export type DeleteIdeaResponse = SuccessfulDeleteIdeaResponse | FailedDeleteIdeaResponse; + +export type CreateIdeaReactionRequest = APIRequest & { + authToken: string; + ideaId: string; +}; +export type SuccessfulCreateIdeaReactionResponse = { + success: true; + reaction: Models.Reaction; +}; +export type FailedCreateIdeaReactionResponse = { + success: false; + reaction?: undefined; + error: Errors.NetworkError | Errors.GenericError; +}; +export type CreateIdeaReactionResponse = SuccessfulCreateIdeaReactionResponse | FailedCreateIdeaReactionResponse; + +export type DeleteIdeaReactionRequest = APIRequest & { + authToken: string; + id: string; +}; +export type SuccessfulDeleteIdeaReactionResponse = { + success: true; +}; +export type FailedDeleteIdeaReactionResponse = { + success: false; + error: Errors.NetworkError | Errors.GenericError; +}; +export type DeleteIdeaReactionResponse = SuccessfulDeleteIdeaReactionResponse | FailedDeleteIdeaReactionResponse; diff --git a/src/services/apiclient/BaseAPIClient.ts b/src/services/apiclient/BaseAPIClient.ts new file mode 100644 index 0000000..ab11bc8 --- /dev/null +++ b/src/services/apiclient/BaseAPIClient.ts @@ -0,0 +1,173 @@ +import {ApolloClient, ApolloError, ApolloQueryResult} from "apollo-client"; +import {FetchResult} from "apollo-link"; +import * as Errors from "../Errors"; +import {CoreHelper} from "../utils/CoreHelper"; + +export type RequestMethod = "query" | "mutation"; + +export type RequestConfig = { + request: TRequest; + requestMethod: TRequestMethod; + gql: any; + variables: Record; + timeout?: number; + headers?: Record; + context: any; +}; + +export type Context = { + requestConfig: RequestConfig; + requestOptions: RequestOptions; +}; + +export type RequestOptions = { + cancel?: () => void; + onUploadProgress?: (loaded: number, total: number) => void; + onDownloadProgress?: (loaded: number, total: number) => void; +}; + +export type SuccessfulResponse = { + success: true; + rawResponse: TRequestMethod extends "query" ? ApolloQueryResult : FetchResult; +}; +export type FailedResponse = { + success: false; + rawResponse: ApolloError; +}; +export type APIResponse = (SuccessfulResponse | FailedResponse) & { + request: TRequest; +}; + +export class BaseAPIClient { + static parseHeaders(rawHeaders: any) { + // https://github.com/jaydenseric/apollo-upload-client/issues/88#issuecomment-468318261 + const headers = new Headers(); + // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space + // https://tools.ietf.org/html/rfc7230#section-3.2 + const preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, " "); + preProcessedHeaders.split(/\r?\n/).forEach((line: any) => { + const parts = line.split(":"); + const key = parts.shift().trim(); + if (key) { + const value = parts.join(":").trim(); + headers.append(key, value); + } + }); + return headers; + } + + static getCustomFetch(requestConfig: RequestConfig, requestOptions: RequestOptions) { + return (url: string, options: RequestInit) => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.timeout = requestConfig.timeout!; + + xhr.onload = () => { + const opts: any = { + status: xhr.status, + statusText: xhr.statusText, + headers: this.parseHeaders(xhr.getAllResponseHeaders() || ""), + }; + + opts.url = "responseURL" in xhr ? xhr.responseURL : opts.headers.get("X-Request-URL"); + const body = "response" in xhr ? xhr.response : (xhr as any).responseText; + + resolve(new Response(body, opts)); + }; + xhr.onerror = () => { + reject(new TypeError("Network request failed")); + }; + xhr.ontimeout = () => { + reject(new TypeError("Network request failed")); + }; + + xhr.open(options.method!, url, true); + + const headers: Record = { + ...options.headers, + ...requestConfig.headers, + }; + Object.keys(headers).forEach(key => { + xhr.setRequestHeader(key, headers[key]); + }); + + if (requestOptions.onDownloadProgress) { + const {onDownloadProgress} = requestOptions; + + xhr.onprogress = e => (e.lengthComputable ? onDownloadProgress(e.loaded, e.total) : onDownloadProgress(1, 1)); + } + + if (xhr.upload && requestOptions.onUploadProgress) { + const {onUploadProgress} = requestOptions; + + xhr.upload.onprogress = e => (e.lengthComputable ? onUploadProgress(e.loaded, e.total) : onUploadProgress(1, 1)); + } + + requestOptions.cancel = () => { + xhr.abort(); + }; + + xhr.send(options.body); + }); + } + + static async request(client: ApolloClient, config: RequestConfig, options: RequestOptions): Promise>; + static async request(client: ApolloClient, config: RequestConfig, options: RequestOptions): Promise>; + static async request(client: ApolloClient, config: RequestConfig, options: RequestOptions = {}): Promise> { + const {request, gql, requestMethod, variables, timeout, headers} = config; + + const context = { + ...config.context, + timeout, + headers, + requestOptions: options, + requestConfig: config, + }; + + console.log(config); + + if (requestMethod === "query") + return client + .query({ + context, + variables, + query: gql, + }) + .then(async rawResponse => ({success: true as true, request, rawResponse})) + .catch(async rawResponse => ({success: false as false, request, rawResponse})); + else + return client + .mutate({ + context, + variables, + mutation: gql, + }) + .then(async rawResponse => { + return rawResponse instanceof ApolloError + ? { + success: false as false, + rawResponse, + request, + } + : { + success: true as true, + rawResponse, + request, + }; + }) + .catch(async rawResponse => ({success: false as false, request, rawResponse})); + } + + static isNetworkError(error: ApolloError): boolean { + return !!error.networkError; + } + + static genericError(): Errors.GenericError { + return {code: "GENERIC_ERROR", message: CoreHelper.formatMessage("Common-genericError")}; + } + + static networkError(): Errors.NetworkError { + return {code: "NETWORK_ERROR", message: CoreHelper.formatMessage("Common-networkError")}; + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..5181abe --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,10 @@ +import * as Models from "./models"; +import * as Errors from "./Errors"; + +export {Models}; + +export {Errors}; + +export {DataStore} from "./store/DataStore"; + +export {Helper} from "./Helper"; diff --git a/src/services/locales/Locale.ts b/src/services/locales/Locale.ts new file mode 100644 index 0000000..ff79a33 --- /dev/null +++ b/src/services/locales/Locale.ts @@ -0,0 +1,38 @@ +export type LocaleParams = { + "Common-dateFormatterWithoutTimeZone": {}; + "Common-dateFormatterWithTimeZone": {}; + "Common-date": {}; + "Common-day": {}; + "Common-days": {}; + "Common-genericError": {}; + "Common-hour": {}; + "Common-hours": {}; + "Common-loadingText": {}; + "Common-miles": {}; + "Common-minute": {}; + "Common-minutes": {}; + "Common-month": {}; + "Common-months": {}; + "Common-more": {}; + "Common-networkError": {}; + "Common-ok": {}; + "Common-second": {}; + "Common-seconds": {}; + "Common-test": {}; + "Common-time": {}; + "Common-today": {}; + "Common-tomorrow": {}; + "Common-week": {}; + "Common-weeks": {}; + "Common-year": {}; + "Common-years": {}; + "Common-yesterday": {}; + "About-title": {}; + "About-builtBy": {}; +}; + +export type LocaleKey = keyof LocaleParams; + +export type Locale = Record & { + code: string; +}; diff --git a/src/services/models/BaseModel.ts b/src/services/models/BaseModel.ts new file mode 100644 index 0000000..29a9ac1 --- /dev/null +++ b/src/services/models/BaseModel.ts @@ -0,0 +1,14 @@ +import * as _ from "lodash"; + +export class BaseModel { + static fromJSON(json: any): any { + const object = new this(); + + _.assign(object, json); + this.fixObjectFromJSON(object, json); + + return object; + } + + static fixObjectFromJSON(object: any, json: any): any {} +} diff --git a/src/services/models/Challenge.ts b/src/services/models/Challenge.ts new file mode 100644 index 0000000..2901261 --- /dev/null +++ b/src/services/models/Challenge.ts @@ -0,0 +1,61 @@ +import * as _ from "lodash"; +import moment from "moment"; +import {BaseModel} from "./BaseModel"; +import {Idea} from "./Idea"; +import {User} from "./User"; +import {Reaction} from "./Reaction"; +import {observable} from "mobx"; + +export enum ChallengePrivacyMode { + PUBLIC = "PUBLIC", + BYDOMAIN = "BYDOMAIN", +} + +export class Challenge extends BaseModel { + @observable id: string; + @observable title: string; + @observable description: string; + @observable imageUrl: string; + @observable closeDate: moment.Moment; + @observable endDate: moment.Moment; + + @observable privacyMode: ChallengePrivacyMode; + @observable privacyData: string; + + @observable createdDate: moment.Moment; + @observable createdBy: User; + @observable modifiedDate: moment.Moment; + @observable modifiedBy: User; + @observable deletedDate: moment.Moment; + @observable deletedBy: User; + + @observable reactions: Reaction[]; + @observable reactionQuantity: number; + + @observable topIdea: Idea; + @observable ideas: Idea[]; + + static fixObjectFromJSON(object: Challenge, json: any) { + if (!_.isNil(json.closeDate)) object.closeDate = moment(json.closeDate); + if (!_.isNil(json.endDate)) object.endDate = moment(json.endDate); + + if (!_.isNil(json.createdDate)) object.createdDate = moment(json.createdDate); + if (!_.isNil(json.createdBy)) object.createdBy = User.fromJSON(json.createdBy); + + if (!_.isNil(json.modifiedDate)) object.modifiedDate = moment(json.modifiedDate); + if (!_.isNil(json.modifiedBy)) object.modifiedBy = User.fromJSON(json.modifiedBy); + + if (!_.isNil(json.deletedDate)) object.deletedDate = moment(json.deletedDate); + if (!_.isNil(json.deletedBy)) object.deletedBy = User.fromJSON(json.deletedBy); + + object.reactions = !_.isNil(json.reactions) && _.isArray(json.reactions.edges) ? json.reactions.edges.map((edge: any) => Reaction.fromJSON(edge.node)) : []; + + const likesSummary = _.isArray(json.reactionsSummary) ? json.reactionsSummary.find((reaction: any) => reaction.value === "LIKE") : undefined; + object.reactionQuantity = !_.isNil(json.reactions) && !_.isNil(json.reactions.totalCount) ? json.reactions.totalCount : !_.isNil(likesSummary) && !_.isNil(likesSummary.totalCount) ? likesSummary.totalCount : 0; + + object.topIdea = !_.isNil(json.topIdea) ? Idea.fromJSON(json.topIdea) : undefined; + object.ideas = !_.isNil(json.ideas) && _.isArray(json.ideas.edges) ? json.ideas.edges.map((edge: any) => Idea.fromJSON(edge.node)) : []; + + object.privacyData = !_.isNil(json.privacyData) ? json.privacyData.replace(/\["(.+)"]/, "$1") : undefined; + } +} diff --git a/src/services/models/Idea.ts b/src/services/models/Idea.ts new file mode 100644 index 0000000..40cc12a --- /dev/null +++ b/src/services/models/Idea.ts @@ -0,0 +1,66 @@ +import * as _ from "lodash"; +import moment from "moment"; +import {User} from "./User"; +import {Challenge} from "./Challenge"; +import {Reaction} from "./Reaction"; +import {BaseModel} from "./BaseModel"; +import {observable} from "mobx"; + +export class Idea extends BaseModel { + @observable id: string; + @observable title: string; + @observable description: string; + @observable imageUrl: string; + + @observable createdDate: moment.Moment; + @observable createdBy: User; + @observable modifiedDate: moment.Moment; + @observable modifiedBy: User; + @observable deletedDate: moment.Moment; + @observable deletedBy: User; + + @observable reactions: Reaction[]; + @observable reactionQuantity: number; + @observable myReaction: Reaction | undefined; + @observable challenge: Challenge; + + static deleteMyReaction(idea: Idea) { + const {myReaction} = idea; + + if (_.isNil(myReaction)) return; + + idea.myReaction = undefined; + if (!_.isNil(idea.reactionQuantity)) idea.reactionQuantity -= 1; + if (!_.isNil(idea.reactions)) { + const reactionIndex = idea.reactions.findIndex(reaction => reaction.id === myReaction.id); + + if (reactionIndex !== -1) idea.reactions.splice(reactionIndex, 1); + } + } + + static addReaction(idea: Idea, reaction: Reaction) { + idea.myReaction = reaction; + if (!_.isNil(idea.reactionQuantity)) idea.reactionQuantity += 1; + if (!_.isNil(idea.reactions)) idea.reactions.push(reaction); + } + + static fixObjectFromJSON(object: Idea, json: any) { + if (!_.isNil(json.createdDate)) object.createdDate = moment(json.createdDate); + if (!_.isNil(json.createdBy)) object.createdBy = User.fromJSON(json.createdBy); + + if (!_.isNil(json.modifiedDate)) object.modifiedDate = moment(json.modifiedDate); + if (!_.isNil(json.modifiedBy)) object.modifiedBy = User.fromJSON(json.modifiedBy); + + if (!_.isNil(json.deletedDate)) object.deletedDate = moment(json.deletedDate); + if (!_.isNil(json.deletedBy)) object.deletedBy = User.fromJSON(json.deletedBy); + + object.reactions = !_.isNil(json.reactions) && _.isArray(json.reactions.edges) ? json.reactions.edges.map((edge: any) => Reaction.fromJSON(edge.node)) : []; + + object.myReaction = !_.isNil(json.myReaction) ? Reaction.fromJSON(json.myReaction) : undefined; + + const likesSummary = _.isArray(json.reactionsSummary) ? json.reactionsSummary.find((reaction: any) => reaction.value === "LIKE") : undefined; + object.reactionQuantity = !_.isNil(json.reactions) && !_.isNil(json.reactions.totalCount) ? json.reactions.totalCount : !_.isNil(likesSummary) && !_.isNil(likesSummary.totalCount) ? likesSummary.totalCount : 0; + + if (!_.isNil(json.challenge)) object.challenge = Challenge.fromJSON(json.challenge); + } +} diff --git a/src/services/models/Reaction.ts b/src/services/models/Reaction.ts new file mode 100644 index 0000000..3c90b61 --- /dev/null +++ b/src/services/models/Reaction.ts @@ -0,0 +1,29 @@ +import * as _ from "lodash"; +import moment from "moment"; +import {User} from "./User"; +import {observable} from "mobx"; +import {BaseModel} from "./BaseModel"; + +export class Reaction extends BaseModel { + @observable id: string; + @observable objectId: string; + @observable value: string; + + @observable createdDate: moment.Moment; + @observable createdBy: User; + @observable modifiedDate: moment.Moment; + @observable modifiedBy: User; + @observable deletedDate: moment.Moment; + @observable deletedBy: User; + + static fixObjectFromJSON(object: any, json: any) { + if (!_.isNil(json.createdDate)) object.createdDate = moment(json.createdDate); + if (!_.isNil(json.createdBy)) object.createdBy = User.fromJSON(json.createdBy); + + if (!_.isNil(json.modifiedDate)) object.modifiedDate = moment(json.modifiedDate); + if (!_.isNil(json.modifiedBy)) object.modifiedBy = User.fromJSON(json.modifiedBy); + + if (!_.isNil(json.deletedDate)) object.deletedDate = moment(json.deletedDate); + if (!_.isNil(json.deletedBy)) object.deletedBy = User.fromJSON(json.deletedBy); + } +} diff --git a/src/services/models/User.ts b/src/services/models/User.ts new file mode 100644 index 0000000..b24b7e5 --- /dev/null +++ b/src/services/models/User.ts @@ -0,0 +1,32 @@ +import * as _ from "lodash"; +import {Challenge} from "./Challenge"; +import {Idea} from "./Idea"; +import {observable} from "mobx"; +import {BaseModel} from "./BaseModel"; + +export enum UserStatus { + Blocked = "BLOCKED", + Pending = "PENDING", + Active = "ACTIVE", +} + +export class User extends BaseModel { + @observable id: string; + @observable name: string; + @observable imageUrl: string; + @observable email: string; + @observable password: string; + @observable status: UserStatus; + + @observable createdDate: Date; + @observable deletedDate: Date; + + @observable challenges: Challenge[]; + @observable ideas: Idea[]; + @observable reactedIdeas: Idea[]; + + static fixObjectFromJSON(object: any, json: any) { + if (!_.isNil(json.createdDate)) object.createdDate = new Date(json.createdDate); + if (!_.isNil(json.deletedDate)) object.deletedDate = new Date(json.deletedDate); + } +} diff --git a/src/services/models/index.ts b/src/services/models/index.ts new file mode 100644 index 0000000..18f24f3 --- /dev/null +++ b/src/services/models/index.ts @@ -0,0 +1,5 @@ +export * from "./BaseModel"; +export * from "./Challenge"; +export * from "./Idea"; +export * from "./Reaction"; +export * from "./User"; diff --git a/src/services/store/ChallengeState.ts b/src/services/store/ChallengeState.ts new file mode 100644 index 0000000..636b70a --- /dev/null +++ b/src/services/store/ChallengeState.ts @@ -0,0 +1,537 @@ +import {ReactNativeFile} from "apollo-upload-client"; +import * as _ from "lodash"; +import {action, observable, runInAction} from "mobx"; +import moment from "moment"; +import {APIClient} from "../apiclient/APIClient"; +import * as Models from "../models"; +import * as Errors from "../Errors"; +import {State} from "./State"; +import {CreateChallengeResponse, DeleteChallengeResponse, FetchChallengeListResponse, FetchChallengeResponse, FetchMyChallengesResponse, FetchUserChallengesResponse, UpdateChallengeResponse} from "../apiclient/APIClient.types"; + +export interface FetchChallengeListRequest {} +export interface FetchChallengeListStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: FetchChallengeListResponse; + challenges?: Models.Challenge[]; +} + +export interface FetchMyChallengesRequest {} +export interface FetchMyChallengesStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.NetworkError | Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: FetchMyChallengesResponse; + challenges?: Models.Challenge[]; + totalCount?: number; +} + +export interface FetchUserChallengesRequest { + user: string | Models.User; +} +export interface FetchUserChallengesStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: FetchUserChallengesResponse; + challenges?: Models.Challenge[]; + totalCount?: number; +} + +export interface FetchChallengeRequest { + id: string; +} +export interface FetchChallengeStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.ObjectNotFoundError; + apiResponse?: FetchChallengeResponse; + challenge?: Models.Challenge; +} + +export interface UpdateChallengeRequest { + challenge: Models.Challenge; + title: string; + description?: string; + closeDate?: moment.Moment; + endDate?: moment.Moment; + image?: File | ReactNativeFile; + privacyMode?: Models.ChallengePrivacyMode; +} +export interface UpdateChallengeStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: UpdateChallengeResponse; + challenge?: Models.Challenge; +} + +export interface DeleteChallengeRequest { + id?: string; + challenge?: Models.Challenge; +} +export interface DeleteChallengeStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: DeleteChallengeResponse; +} + +export interface CreateChallengeRequest { + title: string; + description?: string; + closeDate?: moment.Moment; + endDate?: moment.Moment; + image?: File | ReactNativeFile; + privacyMode?: Models.ChallengePrivacyMode; +} +export interface CreateChallengeStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: CreateChallengeResponse; + challenge?: Models.Challenge; +} + +export class ChallengeState extends State { + initialFetchChallengeListStatus: FetchChallengeListStatus = { + isLoading: false, + }; + + @observable + fetchChallengeListStatus = _.cloneDeep(this.initialFetchChallengeListStatus); + + initialFetchMyChallengesStatus: FetchMyChallengesStatus = { + isLoading: false, + }; + + @observable + fetchMyChallengesStatus = _.cloneDeep(this.initialFetchMyChallengesStatus); + + initialFetchUserChallengesStatus: FetchUserChallengesStatus = { + isLoading: false, + }; + + @observable + fetchUserChallengesStatus = _.cloneDeep(this.initialFetchUserChallengesStatus); + + initialFetchChallengeStatus: FetchChallengeStatus = { + isLoading: false, + }; + + @observable + fetchChallengeStatus = _.cloneDeep(this.initialFetchChallengeStatus); + + initialUpdateChallengeStatus: UpdateChallengeStatus = { + isLoading: false, + }; + + @observable + updateChallengeStatus = _.cloneDeep(this.initialUpdateChallengeStatus); + + initialCreateChallengeStatus: CreateChallengeStatus = { + isLoading: false, + }; + + @observable + createChallengeStatus = _.cloneDeep(this.initialCreateChallengeStatus); + + initialDeleteChallengeStatus: DeleteChallengeStatus = { + isLoading: false, + }; + + @observable + deleteChallengeStatus = _.cloneDeep(this.initialDeleteChallengeStatus); + + @observable + challengeList: Models.Challenge[] | undefined = undefined; + + @observable + currentChallenge: Models.Challenge | undefined = undefined; + + @action + fetchChallengeList(request: FetchChallengeListRequest = {}): Promise { + const {authToken} = this.rootStore.userState; + this.fetchChallengeListStatus = { + isLoading: true, + }; + + return APIClient.fetchChallengeList({authToken}).then(apiResponse => { + const {success, challenges} = apiResponse; + + const newStatus: FetchChallengeListStatus = { + isLoading: false, + success, + apiResponse, + challenges, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user's ideas."}; + + runInAction(() => { + if (newStatus.success) { + newStatus.challenges = observable(newStatus.challenges!); + + newStatus.challenges!.forEach(challenge => { + const {ideas} = challenge; + + ideas.forEach(idea => { + idea.challenge = challenge; + }); + }); + + this.challengeList = newStatus.challenges; + } + + this.fetchChallengeListStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + async fetchMyChallenges(request: FetchMyChallengesRequest = {}): Promise { + const {authToken, currentUser} = this.rootStore.userState; + + let newStatus: FetchMyChallengesStatus = { + isLoading: true, + }; + this.fetchMyChallengesStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus = { + isLoading: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}, + }; + + this.fetchMyChallengesStatus = newStatus; + + return newStatus; + } + + const response = await APIClient.fetchMyChallenges({authToken}); + newStatus = { + isLoading: false, + success: response.success, + apiResponse: response, + challenges: response.challenges, + totalCount: response.success ? response.totalCount : undefined, + error: !response.success ? response.error : undefined, + }; + + runInAction(() => { + if (response.success && !_.isNil(currentUser)) { + currentUser.challenges = observable(response.challenges); + + for (let i = 0; i < response.challenges.length; i++) response.challenges[i].createdBy = currentUser; + } + + this.fetchMyChallengesStatus = newStatus; + }); + + return newStatus; + } + + @action + async fetchUserChallenges(request: FetchUserChallengesRequest): Promise { + const {authToken} = this.rootStore.userState; + const {user} = request; + + let newStatus: FetchUserChallengesStatus = { + isLoading: true, + }; + this.fetchUserChallengesStatus = newStatus; + + const userId = typeof user === "string" ? user : user.id; + + const response = await APIClient.fetchUserChallenges({authToken, userId}); + const {success, challenges, totalCount} = response; + + newStatus = { + isLoading: false, + success, + apiResponse: response, + challenges, + totalCount, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user's challenges."}; + + runInAction(() => { + if (typeof user !== "string" && !_.isNil(challenges)) { + user.challenges = observable(challenges); + + for (let i = 0; i < challenges.length; i++) challenges[i].createdBy = user; + } + + this.fetchUserChallengesStatus = newStatus; + }); + + return newStatus; + } + + @action + setChallengeList(challengeList: Models.Challenge[] | undefined) { + this.challengeList = challengeList; + } + + @action + async fetchChallenge(request: FetchChallengeRequest): Promise { + const {authToken} = this.rootStore.userState; + this.fetchChallengeListStatus = { + isLoading: true, + }; + + const response = await APIClient.fetchChallenge({...request, authToken, challengeId: request.id}); + const {success, challenge} = response; + + const newStatus: FetchChallengeStatus = { + isLoading: false, + success: success && !_.isNil(challenge), + apiResponse: response, + challenge, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get challenge."}; + else if (_.isNil(challenge)) newStatus.error = {code: "OBJECT_NOT_FOUND_ERROR", message: "Challenge not found."}; + + runInAction(() => { + if (newStatus.success) { + newStatus.challenge = observable(newStatus.challenge!); + + const {ideas} = newStatus.challenge!; + + ideas.forEach(idea => { + idea.challenge = newStatus.challenge!; + }); + + this.setCurrentChallenge(newStatus.challenge); + this.fetchChallengeStatus = newStatus; + } + }); + + return newStatus; + } + + @action + setCurrentChallenge(challenge: Models.Challenge | undefined) { + this.currentChallenge = challenge; + } + + @action + createChallenge(request: CreateChallengeRequest): Promise { + const {authToken} = this.rootStore.userState; + const {title, description, closeDate, endDate, image, privacyMode} = request; + + let newStatus: CreateChallengeStatus = { + isLoading: true, + }; + this.createChallengeStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus = { + isLoading: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}, + }; + + this.createChallengeStatus = newStatus; + + return Promise.resolve(newStatus); + } + + return APIClient.createChallenge({ + authToken, + title, + description, + closeDate, + endDate, + image, + privacyMode, + }).then(apiResponse => { + const {success, challenge} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + challenge, + }; + + if (!newStatus.success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not create challenge."}; + + runInAction(() => { + if (newStatus.success && !_.isNil(newStatus.challenge)) { + const {challengeList, fetchMyChallengesStatus} = this; + + if (!_.isNil(challengeList)) challengeList.unshift(newStatus.challenge); + + if (!_.isNil(fetchMyChallengesStatus.challenges)) { + fetchMyChallengesStatus.challenges.unshift(newStatus.challenge); + fetchMyChallengesStatus.totalCount!++; + } + } + + this.createChallengeStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + updateChallenge(request: UpdateChallengeRequest): Promise { + const {userState} = this.rootStore; + const {authToken} = userState; + const {title, description, closeDate, endDate, image, privacyMode} = request; + + let newStatus: UpdateChallengeStatus = { + isLoading: true, + }; + this.updateChallengeStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus.isLoading = false; + newStatus.error = {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}; + + this.updateChallengeStatus = newStatus; + + return Promise.resolve(newStatus); + } + + return APIClient.updateChallenge({ + authToken, + id: request.challenge.id, + title, + description, + closeDate, + endDate, + image, + privacyMode, + }).then(apiResponse => { + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + challenge: apiResponse.challenge, + }; + + if (!newStatus.success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not update challenge."}; + + runInAction(() => { + if (apiResponse.success) { + const updateChallenge = (c: Models.Challenge) => { + runInAction(() => { + const responseChallenge = apiResponse.challenge; + + c.id = responseChallenge.id; + c.title = responseChallenge.title; + c.description = responseChallenge.description; + c.endDate = responseChallenge.endDate; + c.closeDate = responseChallenge.closeDate; + c.imageUrl = responseChallenge.imageUrl; + c.privacyMode = responseChallenge.privacyMode; + }); + }; + + updateChallenge(request.challenge); + + const {challengeList} = this; + if (!_.isNil(challengeList)) { + const challengeToUpdate = challengeList.find(c => c.id === apiResponse.challenge.id); + + if (!_.isNil(challengeToUpdate)) updateChallenge(challengeToUpdate); + } + } + + this.updateChallengeStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + deleteChallenge(request: DeleteChallengeRequest): Promise { + const {authToken} = this.rootStore.userState; + const id = !_.isNil(request.challenge) ? request.challenge.id : request.id!; + + let newStatus: DeleteChallengeStatus = { + isLoading: true, + }; + this.deleteChallengeStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus.isLoading = false; + newStatus.error = {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}; + + this.deleteChallengeStatus = newStatus; + + return Promise.resolve(newStatus); + } + + return APIClient.deleteChallenge({ + authToken, + id, + }).then(apiResponse => { + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success: success, + apiResponse, + }; + + if (!newStatus.success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not delete challenge."}; + + runInAction(() => { + const {challengeList, fetchMyChallengesStatus} = this; + + if (success) { + const removeFromSource = (source: Models.Challenge[]) => { + const challengeIndex = source.findIndex(challenge => challenge.id === id); + + runInAction(() => { + if (challengeIndex !== -1) source.splice(challengeIndex, 1); + }); + + return challengeIndex !== -1; + }; + + if (!_.isNil(fetchMyChallengesStatus.challenges)) { + if (removeFromSource(fetchMyChallengesStatus.challenges)) fetchMyChallengesStatus.totalCount!--; + } + + if (!_.isNil(request.challenge)) { + const {createdBy} = request.challenge; + + if (!_.isNil(createdBy) && !_.isNil(createdBy.challenges)) removeFromSource(createdBy.challenges); + } + + if (!_.isNil(challengeList)) removeFromSource(challengeList); + } + + this.deleteChallengeStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + signOut() { + this.challengeList = undefined; + this.currentChallenge = undefined; + + this.fetchChallengeListStatus = _.cloneDeep(this.initialFetchChallengeListStatus); + this.fetchMyChallengesStatus = _.cloneDeep(this.initialFetchMyChallengesStatus); + this.fetchUserChallengesStatus = _.cloneDeep(this.initialFetchUserChallengesStatus); + this.fetchChallengeStatus = _.cloneDeep(this.initialFetchChallengeStatus); + this.createChallengeStatus = _.cloneDeep(this.initialCreateChallengeStatus); + this.updateChallengeStatus = _.cloneDeep(this.initialUpdateChallengeStatus); + this.deleteChallengeStatus = _.cloneDeep(this.initialDeleteChallengeStatus); + } +} diff --git a/src/services/store/DataStore.ts b/src/services/store/DataStore.ts new file mode 100644 index 0000000..83aa1cf --- /dev/null +++ b/src/services/store/DataStore.ts @@ -0,0 +1,31 @@ +import * as _ from "lodash"; +import {action, configure} from "mobx"; +import {ChallengeState} from "./ChallengeState"; +import {IdeaState} from "./IdeaState"; +import {ReactionState} from "./ReactionState"; +import {UserState} from "./UserState"; + +configure({enforceActions: "observed"}); + +export class DataStore { + challengeState = new ChallengeState(this); + ideaState = new IdeaState(this); + reactionState = new ReactionState(this); + userState = new UserState(this); + + private static INSTANCE: DataStore; + + constructor() { + if (_.isNil(DataStore.INSTANCE)) DataStore.INSTANCE = this; + + return DataStore.INSTANCE; + } + + @action + signOut() { + this.challengeState.signOut(); + this.ideaState.signOut(); + this.reactionState.signOut(); + this.userState.signOut(); + } +} diff --git a/src/services/store/IdeaState.ts b/src/services/store/IdeaState.ts new file mode 100644 index 0000000..45bc135 --- /dev/null +++ b/src/services/store/IdeaState.ts @@ -0,0 +1,642 @@ +import {ReactNativeFile} from "apollo-upload-client"; +import * as _ from "lodash"; +import {action, observable, runInAction} from "mobx"; +import * as Models from "../models"; +import * as Errors from "../Errors"; +import {State} from "./State"; +import {APIClient} from "../apiclient/APIClient"; +import {CreateIdeaResponse, DeleteIdeaResponse, FetchChallengeIdeasResponse, FetchIdeaResponse, FetchIdeasWithUserReactionResponse, FetchMyIdeasResponse, FetchUserIdeasResponse, UpdateIdeaResponse} from "../apiclient/APIClient.types"; + +export interface FetchChallengeIdeasRequest { + challenge?: Models.Challenge; +} + +export interface FetchChallengeIdeasStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: FetchChallengeIdeasResponse; + ideas?: Models.Idea[]; +} + +export interface FetchUserIdeasRequest { + user: string | Models.User; +} + +export interface FetchUserIdeasStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: FetchUserIdeasResponse; + ideas?: Models.Idea[]; + totalCount?: number; +} + +export interface FetchIdeasWithUserReactionRequest { + user: string | Models.User; +} + +export interface FetchIdeasWithUserReactionStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: FetchIdeasWithUserReactionResponse; + ideas?: Models.Idea[]; + totalCount?: number; +} + +export interface FetchIdeasWithMyReactionRequest {} + +export interface FetchIdeasWithMyReactionStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: FetchIdeasWithUserReactionResponse; + ideas?: Models.Idea[]; + totalCount?: number; +} + +export interface CreateIdeaRequest { + challenge: Models.Challenge; + title: string; + description?: string; + image?: File | ReactNativeFile; +} + +export interface CreateIdeaStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: CreateIdeaResponse; + idea?: Models.Idea; +} + +export interface UpdateIdeaRequest { + idea: Models.Idea; + title: string; + description?: string; + image?: File | ReactNativeFile; +} + +export interface UpdateIdeaStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: UpdateIdeaResponse; + idea?: Models.Idea; +} + +export interface DeleteIdeaRequest { + id?: string; + idea?: Models.Idea; +} + +export interface DeleteIdeaStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: DeleteIdeaResponse; +} + +export interface FetchMyIdeasRequest {} + +export interface FetchMyIdeasStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: FetchMyIdeasResponse; + ideas?: Models.Idea[]; + totalCount?: number; +} + +export interface FetchIdeaRequest { + id?: string; + idea?: Models.Idea; +} + +export interface FetchIdeaStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: FetchIdeaResponse; + idea?: Models.Idea; +} + +export class IdeaState extends State { + initialFetchChallengeIdeasStatus: FetchChallengeIdeasStatus = { + isLoading: false, + }; + + @observable + fetchChallengeIdeasStatus = _.cloneDeep(this.initialFetchChallengeIdeasStatus); + + initialFetchUserIdeasStatus: FetchUserIdeasStatus = { + isLoading: false, + }; + + @observable + fetchUserIdeasStatus = _.cloneDeep(this.initialFetchUserIdeasStatus); + + initialFetchIdeasWithUserReactionStatus: FetchIdeasWithUserReactionStatus = { + isLoading: false, + }; + + @observable + fetchIdeasWithUserReactionStatus = _.cloneDeep(this.initialFetchIdeasWithUserReactionStatus); + + initialFetchIdeasWithMyReactionStatus: FetchIdeasWithMyReactionStatus = { + isLoading: false, + }; + + @observable + fetchIdeasWithMyReactionStatus = _.cloneDeep(this.initialFetchIdeasWithMyReactionStatus); + + initialCreateIdeaStatus: CreateIdeaStatus = { + isLoading: false, + }; + + @observable + createIdeaStatus = _.cloneDeep(this.initialCreateIdeaStatus); + + initialUpdateIdeaStatus: UpdateIdeaStatus = { + isLoading: false, + }; + + @observable + updateIdeaStatus = _.cloneDeep(this.initialUpdateIdeaStatus); + + initialDeleteIdeaStatus: DeleteIdeaStatus = { + isLoading: false, + }; + + @observable + deleteIdeaStatus = _.cloneDeep(this.initialDeleteIdeaStatus); + + initialFetchMyIdeasStatus: FetchMyIdeasStatus = { + isLoading: false, + }; + + @observable + fetchMyIdeasStatus = _.cloneDeep(this.initialFetchMyIdeasStatus); + + initialFetchIdeaStatus: FetchIdeaStatus = { + isLoading: false, + }; + + @observable + fetchIdeaStatus = _.cloneDeep(this.initialFetchIdeaStatus); + + @action + fetchChallengeIdeas(request: FetchChallengeIdeasRequest = {}): Promise { + const {challengeState, userState} = this.rootStore; + const {currentChallenge} = challengeState; + const {authToken} = userState; + + const challenge = !_.isNil(request.challenge) ? request.challenge : currentChallenge!; + + let newStatus: FetchChallengeIdeasStatus = { + isLoading: true, + }; + this.fetchChallengeIdeasStatus = newStatus; + + return APIClient.fetchChallengeIdeas({authToken: authToken!, challengeId: challenge.id}).then(apiResponse => { + const {success, ideas} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + ideas, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get challenge ideas."}; + + runInAction(() => { + if (success && !_.isNil(ideas)) { + challenge.ideas = observable(ideas); + + challenge.ideas.forEach(idea => { + runInAction(() => (idea.challenge = challenge)); + }); + } + + this.fetchChallengeIdeasStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + fetchMyIdeas(request: FetchMyIdeasRequest = {}): Promise { + this.fetchMyIdeasStatus = { + isLoading: true, + }; + + const {authToken} = this.rootStore.userState; + + this.fetchMyIdeasStatus = { + isLoading: true, + }; + + if (_.isNil(authToken)) { + this.fetchMyIdeasStatus = { + isLoading: false, + success: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}, + }; + + return Promise.resolve(this.fetchMyIdeasStatus); + } + + return APIClient.fetchMyIdeas({...request, authToken}).then(apiResponse => { + const {success, ideas, totalCount} = apiResponse; + + const newStatus: FetchMyIdeasStatus = { + isLoading: false, + success, + apiResponse, + ideas, + totalCount, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user's ideas."}; + + runInAction(() => { + const {currentUser} = this.rootStore.userState; + + if (success && !_.isNil(currentUser) && !_.isNil(ideas)) { + currentUser.ideas = observable(ideas); + + ideas.forEach(idea => { + runInAction(() => (idea.createdBy = currentUser)); + }); + } + + this.fetchMyIdeasStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + fetchUserIdeas(request: FetchUserIdeasRequest): Promise { + const {authToken} = this.rootStore.userState; + const {user} = request; + + let newStatus: FetchUserIdeasStatus = { + isLoading: true, + }; + this.fetchUserIdeasStatus = newStatus; + + const userId = typeof user === "string" ? user : user.id; + + return APIClient.fetchUserIdeas({authToken, userId}).then(apiResponse => { + const {success, ideas, totalCount} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + ideas, + totalCount, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user ideas."}; + + runInAction(() => { + if (typeof user !== "string" && !_.isNil(ideas)) { + user.ideas = observable(ideas); + + ideas.forEach(idea => { + runInAction(() => (idea.createdBy = user)); + }); + } + + this.fetchUserIdeasStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + fetchIdeasWithMyReaction(request: FetchIdeasWithMyReactionRequest = {}): Promise { + const {authToken} = this.rootStore.userState; + const {currentUser} = this.rootStore.userState; + + let newStatus: FetchIdeasWithMyReactionStatus = { + isLoading: true, + }; + this.fetchIdeasWithMyReactionStatus = newStatus; + + if (_.isNil(authToken) || _.isNil(currentUser)) { + newStatus = { + isLoading: false, + success: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You must be signed in."}, + }; + + this.fetchIdeasWithMyReactionStatus = newStatus; + return Promise.resolve(newStatus); + } + + return APIClient.fetchIdeasWithUserReaction({authToken, userId: currentUser.id}).then(apiResponse => { + const {success, ideas, totalCount} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + ideas, + totalCount, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user's likes."}; + + runInAction(() => { + if (!_.isNil(ideas)) { + currentUser.reactedIdeas = observable(ideas); + + ideas.forEach(idea => { + runInAction(() => (idea.createdBy = currentUser)); + }); + } + + this.fetchIdeasWithMyReactionStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + fetchIdeasWithUserReaction(request: FetchIdeasWithUserReactionRequest): Promise { + const {user} = request; + const {authToken} = this.rootStore.userState; + + let newStatus: FetchIdeasWithUserReactionStatus = { + isLoading: true, + }; + this.fetchIdeasWithUserReactionStatus = newStatus; + + const userId = typeof user === "string" ? user : user.id; + + return APIClient.fetchIdeasWithUserReaction({authToken, userId}).then(apiResponse => { + const {success, ideas, totalCount} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + ideas, + totalCount, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user's likes."}; + + runInAction(() => { + if (typeof user !== "string" && !_.isNil(ideas)) { + user.reactedIdeas = observable(ideas); + + ideas.forEach(idea => { + runInAction(() => (idea.createdBy = user)); + }); + } + + this.fetchIdeasWithUserReactionStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + fetchIdea(request: FetchIdeaRequest): Promise { + const {authToken} = this.rootStore.userState; + const {id, idea} = request; + + return APIClient.fetchIdea({id: !_.isNil(id) ? id : idea!.id, authToken}).then(apiResponse => { + const {success} = apiResponse; + + const newStatus: FetchIdeaStatus = { + isLoading: false, + success, + apiResponse, + idea: apiResponse.idea, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get idea."}; + + runInAction(() => { + this.fetchIdeaStatus = newStatus; + + if (success && !_.isNil(idea)) _.merge(idea, apiResponse.idea); + }); + + return newStatus; + }); + } + + @action + createIdea(request: CreateIdeaRequest): Promise { + const {authToken} = this.rootStore.userState; + const {challenge, title, description, image} = request; + + let newStatus: CreateIdeaStatus = { + ...this.initialCreateIdeaStatus, + isLoading: true, + }; + this.createIdeaStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus = { + isLoading: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}, + }; + + this.createIdeaStatus = newStatus; + + return Promise.resolve(newStatus); + } + + return APIClient.createIdea({ + authToken, + title, + description, + image, + challengeId: challenge.id, + }).then(apiResponse => { + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + idea: apiResponse.idea, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not create idea."}; + + runInAction(() => { + const {fetchMyIdeasStatus} = this; + + if (apiResponse.success) { + const {idea} = apiResponse; + + if (!_.isNil(fetchMyIdeasStatus.ideas)) { + fetchMyIdeasStatus.ideas.unshift(idea); + fetchMyIdeasStatus.totalCount!++; + } + + if (!_.isNil(idea.createdBy) && !_.isNil(idea.createdBy.ideas)) idea.createdBy.ideas.unshift(idea); + idea.challenge = challenge; + + if (!_.isNil(challenge.ideas)) challenge.ideas.unshift(idea); + } + + this.createIdeaStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + updateIdea(request: UpdateIdeaRequest): Promise { + const {userState} = this.rootStore; + const {authToken} = userState; + const {title, description, image} = request; + + let newStatus: UpdateIdeaStatus = { + isLoading: true, + }; + this.updateIdeaStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus.isLoading = false; + newStatus.error = {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}; + + this.updateIdeaStatus = newStatus; + + return Promise.resolve(newStatus); + } + + return APIClient.updateIdea({ + authToken, + id: request.idea.id, + title, + description, + image, + }).then(apiResponse => { + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success, + apiResponse, + idea: apiResponse.idea, + }; + + if (!newStatus.success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not update idea."}; + + runInAction(() => { + if (apiResponse.success) { + const updateIdea = (c: Models.Idea) => { + runInAction(() => { + const responseIdea = apiResponse.idea; + + c.id = responseIdea.id; + c.title = responseIdea.title; + c.description = responseIdea.description; + c.imageUrl = responseIdea.imageUrl; + }); + }; + + updateIdea(request.idea); + } + + this.updateIdeaStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + deleteIdea(request: DeleteIdeaRequest): Promise { + const {authToken} = this.rootStore.userState; + const {id, idea} = request; + + let newStatus: DeleteIdeaStatus = { + isLoading: true, + }; + this.deleteIdeaStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus.isLoading = false; + newStatus.error = {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}; + + this.deleteIdeaStatus = newStatus; + + return Promise.resolve(newStatus); + } + + return APIClient.deleteIdea({ + authToken, + id: !_.isNil(id) ? id : idea!.id, + }).then(apiResponse => { + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success: success, + apiResponse, + }; + + if (!newStatus.success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not delete idea."}; + + runInAction(() => { + const {fetchMyIdeasStatus} = this; + + if (apiResponse.success) { + const removeFromSource = (source: Models.Idea[]) => { + const ideaIndex = source.findIndex(i => i.id === (!_.isNil(id) ? id : idea!.id)); + + runInAction(() => { + if (ideaIndex !== -1) source.splice(ideaIndex, 1); + }); + + return ideaIndex !== -1; + }; + + if (!_.isNil(fetchMyIdeasStatus.ideas) && removeFromSource(fetchMyIdeasStatus.ideas)) { + fetchMyIdeasStatus.totalCount!--; + } + + if (!_.isNil(idea) && !_.isNil(idea.createdBy) && !_.isNil(idea.createdBy.ideas)) removeFromSource(idea.createdBy.ideas); + + if (!_.isNil(idea) && !_.isNil(idea.challenge) && !_.isNil(idea.challenge.ideas)) removeFromSource(idea.challenge.ideas); + } + + this.deleteIdeaStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + signOut() { + this.fetchChallengeIdeasStatus = _.cloneDeep(this.initialFetchChallengeIdeasStatus); + this.fetchUserIdeasStatus = _.cloneDeep(this.initialFetchUserIdeasStatus); + this.fetchIdeasWithUserReactionStatus = _.cloneDeep(this.initialFetchIdeasWithUserReactionStatus); + this.fetchIdeasWithMyReactionStatus = _.cloneDeep(this.initialFetchIdeasWithMyReactionStatus); + this.createIdeaStatus = _.cloneDeep(this.initialCreateIdeaStatus); + this.updateIdeaStatus = _.cloneDeep(this.initialUpdateIdeaStatus); + this.deleteIdeaStatus = _.cloneDeep(this.initialDeleteIdeaStatus); + this.fetchMyIdeasStatus = _.cloneDeep(this.initialFetchMyIdeasStatus); + this.fetchIdeaStatus = _.cloneDeep(this.initialFetchIdeaStatus); + } +} diff --git a/src/services/store/ReactionState.ts b/src/services/store/ReactionState.ts new file mode 100644 index 0000000..1098799 --- /dev/null +++ b/src/services/store/ReactionState.ts @@ -0,0 +1,179 @@ +import * as _ from "lodash"; +import * as Models from "../models"; +import {action, observable, runInAction} from "mobx"; +import * as Errors from "../Errors"; +import {State} from "./State"; +import {APIClient} from "../apiclient/APIClient"; +import {CreateIdeaReactionResponse, DeleteIdeaReactionResponse} from "../apiclient/APIClient.types"; + +export interface CreateIdeaReactionRequest { + idea: Models.Idea; +} + +export interface CreateIdeaReactionStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: CreateIdeaReactionResponse; + reaction?: Models.Reaction; +} + +export interface DeleteIdeaReactionRequest { + idea: Models.Idea; +} + +export interface DeleteIdeaReactionStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError | Errors.ObjectNotFoundError; + apiResponse?: DeleteIdeaReactionResponse; +} + +export class ReactionState extends State { + initialCreateIdeaReactionStatus: CreateIdeaReactionStatus = { + isLoading: false, + }; + + @observable + createIdeaReactionStatus = _.cloneDeep(this.initialCreateIdeaReactionStatus); + + initialDeleteIdeaReactionStatus: DeleteIdeaReactionStatus = { + isLoading: false, + }; + + @observable + deleteIdeaReactionStatus = _.cloneDeep(this.initialDeleteIdeaReactionStatus); + + @action + createIdeaReaction(request: CreateIdeaReactionRequest): Promise { + const {authToken, currentUser} = this.rootStore.userState; + const {idea} = request; + + let newStatus: CreateIdeaReactionStatus = {isLoading: true}; + this.createIdeaReactionStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus = { + isLoading: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}, + }; + + this.createIdeaReactionStatus = newStatus; + + return Promise.resolve(newStatus); + } + + return APIClient.createIdeaReaction({ + authToken, + ideaId: idea.id, + }).then(apiResponse => { + const {success, reaction} = apiResponse; + + newStatus = { + isLoading: false, + success: success && !_.isNil(reaction), + apiResponse, + reaction, + }; + + if (!newStatus.success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not react to idea."}; + + runInAction(() => { + const {fetchIdeasWithMyReactionStatus} = this.rootStore.ideaState; + + if (newStatus.success) { + Models.Idea.addReaction(idea, reaction!); + + if (!_.isNil(currentUser) && !_.isNil(currentUser.reactedIdeas)) currentUser.reactedIdeas.unshift(idea); + + if (!_.isNil(fetchIdeasWithMyReactionStatus.ideas)) { + fetchIdeasWithMyReactionStatus.ideas.unshift(idea); + fetchIdeasWithMyReactionStatus.totalCount!++; + } + } + + this.createIdeaReactionStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + deleteIdeaReaction(request: DeleteIdeaReactionRequest): Promise { + const {authToken, currentUser} = this.rootStore.userState; + const {idea} = request; + + let newStatus: DeleteIdeaReactionStatus = { + isLoading: true, + }; + this.deleteIdeaReactionStatus = newStatus; + + if (_.isNil(authToken)) { + newStatus = { + isLoading: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}, + }; + + this.deleteIdeaReactionStatus = newStatus; + return Promise.resolve(newStatus); + } + + const {myReaction} = request.idea; + if (_.isNil(myReaction) || _.isNil(myReaction.id)) { + newStatus = { + isLoading: false, + error: {code: "OBJECT_NOT_FOUND_ERROR", message: "You don't have a reaction for this idea"}, + }; + + this.deleteIdeaReactionStatus = newStatus; + return Promise.resolve(newStatus); + } + + return APIClient.deleteIdeaReaction({ + id: myReaction.id, + authToken, + }).then(apiResponse => { + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success: success, + apiResponse, + }; + + if (!newStatus.success) newStatus.error = {code: "GENERIC_ERROR", message: "Could not delete reaction."}; + + runInAction(() => { + const {fetchIdeasWithMyReactionStatus} = this.rootStore.ideaState; + + if (newStatus.success) { + Models.Idea.deleteMyReaction(idea); + + const removeFromSource = (source: Models.Idea[]) => { + const ideaIndex = source.findIndex(i => i.id === idea.id); + + runInAction(() => { + if (ideaIndex !== -1) source.splice(ideaIndex, 1); + }); + + return ideaIndex !== -1; + }; + + if (!_.isNil(currentUser) && !_.isNil(currentUser.reactedIdeas)) removeFromSource(currentUser.reactedIdeas); + + if (!_.isNil(fetchIdeasWithMyReactionStatus.ideas) && removeFromSource(fetchIdeasWithMyReactionStatus.ideas)) fetchIdeasWithMyReactionStatus.totalCount!--; + } + this.deleteIdeaReactionStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + signOut() { + this.createIdeaReactionStatus = _.cloneDeep(this.initialCreateIdeaReactionStatus); + this.deleteIdeaReactionStatus = _.cloneDeep(this.initialDeleteIdeaReactionStatus); + } +} diff --git a/src/services/store/State.ts b/src/services/store/State.ts new file mode 100644 index 0000000..a59243e --- /dev/null +++ b/src/services/store/State.ts @@ -0,0 +1,5 @@ +import {DataStore} from "./DataStore"; + +export class State { + constructor(protected rootStore: DataStore) {} +} diff --git a/src/services/store/UserState.ts b/src/services/store/UserState.ts new file mode 100644 index 0000000..cae553b --- /dev/null +++ b/src/services/store/UserState.ts @@ -0,0 +1,703 @@ +import * as _ from "lodash"; +import {action, computed, observable, runInAction} from "mobx"; +import * as Models from "../models"; +import * as Errors from "../Errors"; +import {State} from "./State"; +import {ReactNativeFile} from "extract-files"; +import {APIClient} from "../apiclient/APIClient"; +import {FetchMeResponse, FetchUserResponse, SignInResponse, SignUpResponse, UpdateUserResponse} from "../apiclient/APIClient.types"; + +export interface RefreshTokensRequest {} + +export interface RefreshTokensResponse { + success: boolean; + authToken?: string; + refreshToken?: string; + error?: Errors.NetworkError | Errors.GenericError | Errors.BadUserInputError; +} + +export interface TestTokenRequest { + token: string; + refreshToken?: string | null; +} + +export interface TestTokenStatus { + success?: boolean; + error?: Errors.GenericError | Errors.BadUserInputError; +} + +export interface SignInRequest { + email: string; + password: string; +} + +export enum SignInFields { + Email = "email", + Password = "password", +} + +export interface SignInStatus { + isLoading: boolean; + success?: boolean; + errors?: Errors.NetworkError | Errors.GenericError | Errors.ValidationError[] | Errors.ObjectNotFoundError | Errors.PendingAccountError; + + apiResponse?: SignInResponse; +} + +export interface SignUpRequest { + name: string; + email: string; + password: string; +} + +export enum SignUpErrorCodes { + EmptyNameField = "empty_name_field", + EmptyEmailField = "empty_email_field", + EmptyPasswordField = "empty_password_field", +} + +export interface SignUpStatus { + isLoading: boolean; + success?: boolean; + errors?: Errors.NetworkError | Errors.ValidationError[] | Errors.GenericError | Errors.BadUserInputError<"EMAIL_IN_USE">; + apiResponse?: SignUpResponse; +} + +export interface ResendEmailConfirmationRequest { + email: string; +} + +export interface ResendEmailConfirmationStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; +} + +export interface RequestResetPasswordRequest { + email: string; +} + +export enum RequestResetPasswordFields { + Email = "email", +} + +export interface RequestResetPasswordStatus { + isLoading: boolean; + success?: boolean; + errors?: Errors.NetworkError | Errors.GenericError | Errors.ValidationError[] | Errors.BadUserInputError; +} + +export interface ResetPasswordRequest { + token: string; + password: string; +} + +export enum ResetPasswordFields { + Password = "password", +} + +export interface ResetPasswordStatus { + isLoading: boolean; + success?: boolean; + errors?: Errors.NetworkError | Errors.GenericError | Errors.ValidationError[]; +} + +export interface FetchMeRequest {} + +export interface FetchMeStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError | Errors.UserNotAuthenticatedError; + apiResponse?: FetchMeResponse; + user?: Models.User; +} + +export interface FetchUserRequest { + id: string; +} + +export interface FetchUserStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: FetchUserResponse; + user?: Models.User; +} + +export interface UpdateMeRequest { + name?: string; + image?: File | ReactNativeFile; +} + +export interface UpdateMeStatus { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; + apiResponse?: UpdateUserResponse; + user?: Models.User; +} + +export type ConfirmEmailRequest = { + token: string; +}; + +export type ConfirmEmailStatus = { + isLoading: boolean; + success?: boolean; + error?: Errors.GenericError; +}; + +export type CheckEmailRequest = { + email: string; +}; + +export type CheckEmailStatus = { + isLoading: boolean; + success?: boolean; + isAvailable?: boolean; + isBlacklisted?: boolean; + isCorporate?: boolean; +}; + +export class UserState extends State { + @observable + currentUser: Models.User | undefined; + + @observable + authToken: string | undefined; + + @observable + refreshToken: string | undefined; + + @computed + get currentDomain(): string | undefined { + const {currentUser} = this; + + const email = !_.isNil(currentUser) ? currentUser.email : undefined; + + return !_.isNil(email) ? email.split("@")[1] : undefined; + } + + initialSignInStatus: SignInStatus = { + isLoading: false, + }; + + @observable + signInStatus = _.cloneDeep(this.initialSignInStatus); + + initialSignUpStatus: SignUpStatus = { + isLoading: false, + }; + + @observable + signUpStatus = _.cloneDeep(this.initialSignUpStatus); + + initialResendEmailConfirmationStatus: ResendEmailConfirmationStatus = { + isLoading: false, + }; + + @observable + resendEmailConfirmationStatus = _.cloneDeep(this.initialResendEmailConfirmationStatus); + + initialRequestResetPasswordStatus: RequestResetPasswordStatus = { + isLoading: false, + }; + + @observable + requestResetPasswordStatus = _.cloneDeep(this.initialRequestResetPasswordStatus); + + initialResetPasswordStatus: ResetPasswordStatus = { + isLoading: false, + }; + + @observable + resetPasswordStatus = _.cloneDeep(this.initialResetPasswordStatus); + + initialFetchMeStatus: FetchMeStatus = { + isLoading: false, + }; + + @observable + fetchMeStatus = _.cloneDeep(this.initialFetchMeStatus); + + initialFetchUserStatus: FetchUserStatus = { + isLoading: false, + }; + + @observable + fetchUserStatus = _.cloneDeep(this.initialFetchUserStatus); + + initialUpdateMeStatus: UpdateMeStatus = { + isLoading: false, + }; + + @observable + updateMeStatus = _.cloneDeep(this.initialUpdateMeStatus); + + initialConfirmEmailStatus: ConfirmEmailStatus = { + isLoading: false, + }; + + @observable + confirmEmailStatus = _.cloneDeep(this.initialConfirmEmailStatus); + + initialCheckEmailStatus: CheckEmailStatus = { + isLoading: false, + }; + + @observable + checkEmailStatus = _.cloneDeep(this.initialCheckEmailStatus); + + protected refreshTokensInterval: any; + + @action + setAuthToken(authToken: string | undefined) { + this.authToken = authToken; + } + + async refreshTokens(request: RefreshTokensRequest = {}): Promise { + const {refreshToken, authToken} = this; + + const apiResponse = await APIClient.refreshTokens({ + refreshToken: refreshToken!, + authToken, + }); + + runInAction(() => { + if (apiResponse.success) { + if (apiResponse.authToken) this.authToken = apiResponse.authToken; + + if (apiResponse.refreshToken) this.refreshToken = apiResponse.refreshToken; + } + }); + + return apiResponse; + } + + async startRefreshTokensInterval(fireImmediately: boolean = true) { + clearInterval(this.refreshTokensInterval); + + if (fireImmediately && this.refreshToken) { + const refreshTokensResponse = await this.refreshTokens(); + + if (refreshTokensResponse.success && refreshTokensResponse.error && refreshTokensResponse.error.code === "BAD_USER_INPUT_ERROR") return; + } + + this.refreshTokensInterval = setInterval(async () => { + const {refreshToken} = this; + + if (refreshToken === undefined) return; + + const refreshTokensResponse = await this.refreshTokens(); + + if (!refreshTokensResponse.success && refreshTokensResponse.error && refreshTokensResponse.error.code === "BAD_USER_INPUT_ERROR") clearInterval(this.refreshTokensInterval); + }, 60000 * 15); + } + + @computed + get isAuthenticated(): boolean { + return !_.isNil(this.authToken); + } + + @action + async testToken(request: TestTokenRequest): Promise { + const {token} = request; + this.setAuthToken(token); + + const fetchMeResponse = await this.fetchMe(); + + let error: Errors.BadUserInputError | Errors.GenericError | undefined; + if (!fetchMeResponse.success) { + error = fetchMeResponse.error !== undefined && fetchMeResponse.error.code === "GENERIC_ERROR" ? fetchMeResponse.error : {code: "BAD_USER_INPUT_ERROR", message: "Invalid token", extra: undefined}; + this.setAuthToken(undefined); + } else { + if (request.refreshToken) { + runInAction(() => { + this.refreshToken = request.refreshToken!; + }); + + this.startRefreshTokensInterval(); + } + } + + return { + success: fetchMeResponse.success, + error, + }; + } + + @action + signIn(request: SignInRequest): Promise { + const {email, password} = request; + + this.signInStatus = { + isLoading: true, + }; + + const errors: Errors.ValidationError[] = []; + + if (email.trim().length === 0) errors.push({code: "VALIDATION_ERROR", message: "Email field is empty.", field: SignInFields.Email}); + if (password.trim().length === 0) errors.push({code: "VALIDATION_ERROR", message: "Password field is empty.", field: SignInFields.Password}); + + if (errors.length > 0) { + this.signInStatus = { + isLoading: false, + success: false, + errors, + }; + + return Promise.resolve(this.signInStatus); + } + + return APIClient.signIn({email, password}).then(apiResponse => { + const {success, authToken, refreshToken} = apiResponse; + + const newStatus: SignInStatus = { + isLoading: false, + success, + apiResponse, + }; + + if (!apiResponse.success && _.isNil(newStatus.errors)) newStatus.errors = apiResponse.error; + + runInAction(() => { + if (apiResponse.success) { + this.authToken = authToken; + + if (refreshToken) { + this.refreshToken = refreshToken; + this.startRefreshTokensInterval(false); + } + } + + this.signInStatus = newStatus; + }); + + return newStatus; + }); + } + + @action + async signUp(request: SignUpRequest): Promise { + const {name, email, password} = request; + + this.signUpStatus = { + isLoading: true, + }; + + const errors: Errors.ValidationError[] = []; + + if (name.trim().length === 0) errors.push({code: "VALIDATION_ERROR", message: "Name field is empty.", field: SignUpErrorCodes.EmptyNameField}); + if (email.trim().length === 0) errors.push({code: "VALIDATION_ERROR", message: "Email field is empty.", field: SignUpErrorCodes.EmptyEmailField}); + if (password.trim().length === 0) errors.push({code: "VALIDATION_ERROR", message: "Password field is empty.", field: SignUpErrorCodes.EmptyPasswordField}); + + if (errors.length > 0) { + _.assign(this.signUpStatus, { + isLoading: false, + success: false, + errors, + }); + + return this.signUpStatus; + } + + const apiResponse = await APIClient.signUp({name, email, password}); + + const {success} = apiResponse; + + const newStatus: SignUpStatus = { + isLoading: false, + success, + apiResponse, + errors: apiResponse.success ? undefined : apiResponse.error, + }; + + runInAction(() => { + this.signUpStatus = newStatus; + }); + + return newStatus; + } + + @action + async resendEmailConfirmation(request: ResendEmailConfirmationRequest): Promise { + const {email} = request; + + const setStatus = (status: ResendEmailConfirmationStatus) => runInAction(() => (this.resendEmailConfirmationStatus = status)); + + let newStatus: ResendEmailConfirmationStatus = {isLoading: true}; + setStatus(newStatus); + + const {success} = await APIClient.resendEmailConfirmation({email}); + + newStatus = { + isLoading: false, + success, + }; + + if (!success) newStatus.error = {code: "GENERIC_ERROR", message: "There was an unexpected problem. Please try again in a few minutes."}; + + setStatus(newStatus); + + return newStatus; + } + + @action + async requestResetPassword(request: RequestResetPasswordRequest): Promise { + const {email} = request; + + const setNewStatus = (status: RequestResetPasswordStatus) => { + runInAction(() => (this.requestResetPasswordStatus = status)); + }; + + let newStatus: RequestResetPasswordStatus = { + isLoading: true, + }; + setNewStatus(newStatus); + + const errors: Errors.ValidationError[] = []; + + if (email.trim().length === 0) errors.push({code: "VALIDATION_ERROR", message: "Email field is empty.", field: RequestResetPasswordFields.Email}); + + if (errors.length > 0) { + newStatus = { + isLoading: false, + success: false, + errors, + }; + setNewStatus(newStatus); + + return newStatus; + } + + const apiResponse = await APIClient.requestResetPassword({email}); + + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success, + }; + + if (!apiResponse.success) { + const {error} = apiResponse; + + newStatus.errors = error; + } + + runInAction(() => { + setNewStatus(newStatus); + }); + + return newStatus; + } + + @action + async resetPassword(request: ResetPasswordRequest): Promise { + const {token, password} = request; + + const setNewStatus = (status: ResetPasswordStatus) => { + runInAction(() => (this.resetPasswordStatus = status)); + }; + + let newStatus: ResetPasswordStatus = { + isLoading: true, + }; + setNewStatus(newStatus); + + const errors: Errors.ValidationError[] = []; + + if (password.trim().length === 0) errors.push({code: "VALIDATION_ERROR", message: "Password field is empty.", field: ResetPasswordFields.Password}); + + if (errors.length > 0) { + this.resetPasswordStatus = { + isLoading: false, + success: false, + errors, + }; + + return this.resetPasswordStatus; + } + + const apiResponse = await APIClient.resetPassword({token, password}); + + const {success} = apiResponse; + + newStatus = { + isLoading: false, + success, + }; + + if (!apiResponse.success) newStatus.errors = apiResponse.error; + + runInAction(() => { + this.resetPasswordStatus = newStatus; + }); + + return newStatus; + } + + @action + fetchMe(request: FetchMeRequest = {}): Promise { + const {authToken} = this; + + this.fetchMeStatus = { + isLoading: true, + }; + + if (_.isNil(authToken)) { + this.fetchMeStatus = { + isLoading: false, + success: false, + error: {code: "USER_NOT_AUTHENTICATED_ERROR", message: "You are not logged in."}, + }; + + return Promise.resolve(this.fetchMeStatus); + } + + return APIClient.fetchMe({authToken}).then(apiResponse => { + const {success, user} = apiResponse; + + const newStatus: FetchMeStatus = { + isLoading: false, + success: success && !_.isNil(user), + apiResponse, + user, + }; + + if (!success || _.isNil(user)) { + newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user data."}; + } + + runInAction(() => { + this.fetchMeStatus = newStatus; + this.currentUser = user; + }); + + return newStatus; + }); + } + + @action + fetchUser(request: FetchUserRequest): Promise { + const {authToken} = this; + this.fetchUserStatus = { + isLoading: true, + }; + + return APIClient.fetchUser({...request, authToken}).then(apiResponse => { + const {success, user} = apiResponse; + + const newStatus: FetchUserStatus = { + isLoading: false, + success: success && !_.isNil(user), + apiResponse, + user, + }; + + if (!success || _.isNil(user)) newStatus.error = {code: "GENERIC_ERROR", message: "Could not get user data."}; + + runInAction(() => (this.fetchUserStatus = newStatus)); + + return newStatus; + }); + } + + @action + updateMe(request: UpdateMeRequest): Promise { + const {authToken, currentUser} = this; + const {name, image} = request; + + this.updateMeStatus = { + isLoading: true, + }; + + return APIClient.updateUser({authToken: authToken!, id: currentUser!.id, name, image}).then(apiResponse => { + const {success, user} = apiResponse; + + const newStatus: UpdateMeStatus = { + isLoading: false, + success: success && !_.isNil(user), + apiResponse, + user, + }; + + if (!success || _.isNil(user)) newStatus.error = {code: "GENERIC_ERROR", message: "Could not update user."}; + + runInAction(() => { + this.updateMeStatus = newStatus; + + if (_.isNil(this.currentUser)) this.currentUser = user; + else _.assign(this.currentUser, user); + }); + + return newStatus; + }); + } + + @action + async confirmEmail(request: ConfirmEmailRequest): Promise { + const {token} = request; + + const setNewStatus = (status: ConfirmEmailStatus) => { + runInAction(() => (this.confirmEmailStatus = status)); + }; + + let newStatus: ConfirmEmailStatus = { + isLoading: true, + }; + setNewStatus(newStatus); + + const {success} = await APIClient.confirmEmail({token}); + + newStatus = { + isLoading: false, + success, + error: success ? undefined : {code: "GENERIC_ERROR", message: "Could not confirm email."}, + }; + setNewStatus(newStatus); + + return newStatus; + } + + async checkEmail(request: CheckEmailRequest): Promise { + const {email} = request; + + const setStatus = (status: CheckEmailStatus) => runInAction(() => (this.checkEmailStatus = status)); + + setStatus({isLoading: true}); + const {success, isAvailable, isBlacklisted, isCorporate} = await APIClient.checkEmail({email}); + + const newStatus: CheckEmailStatus = { + isLoading: false, + success, + isAvailable, + isBlacklisted, + isCorporate, + }; + + setStatus(newStatus); + + return newStatus; + } + + @action + signOut() { + this.currentUser = undefined; + this.authToken = undefined; + this.refreshToken = undefined; + + if (this.refreshTokensInterval) clearInterval(this.refreshTokensInterval); + + this.signInStatus = _.cloneDeep(this.initialSignInStatus); + this.signUpStatus = _.cloneDeep(this.initialSignUpStatus); + this.resendEmailConfirmationStatus = _.cloneDeep(this.initialResendEmailConfirmationStatus); + this.requestResetPasswordStatus = _.cloneDeep(this.initialRequestResetPasswordStatus); + this.resetPasswordStatus = _.cloneDeep(this.initialResetPasswordStatus); + this.fetchMeStatus = _.cloneDeep(this.initialFetchMeStatus); + this.fetchUserStatus = _.cloneDeep(this.initialFetchUserStatus); + this.updateMeStatus = _.cloneDeep(this.initialUpdateMeStatus); + this.confirmEmailStatus = _.cloneDeep(this.initialConfirmEmailStatus); + this.checkEmailStatus = _.cloneDeep(this.initialCheckEmailStatus); + } +} diff --git a/src/services/utils/CoreHelper.ts b/src/services/utils/CoreHelper.ts new file mode 100644 index 0000000..8e1b73b --- /dev/null +++ b/src/services/utils/CoreHelper.ts @@ -0,0 +1,84 @@ +import * as _ from "lodash"; +import * as uuid from "uuid"; +import intl from "react-intl-universal"; +import {LocaleKey, LocaleParams} from "../locales/Locale"; + +// AppConfig, Models, Stores can not be imported! + +const base64 = require("base-x")("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"); + +(String.prototype as any).replaceAll = function(search: string, replacement: string) { + const target = this; + + return target.replace(new RegExp(search, "g"), replacement); +}; + +(String.prototype as any).trimAll = function() { + const target = this; + + return target.replaceAll(" ", ""); +}; + +export class CoreHelper { + static getUUID(short: boolean = true): string { + if (short) { + const buf = new Buffer(16); + const uuidLong = uuid.v4(null, buf); + + let result = base64.encode(uuidLong); + result = result.replace(/\//g, "_"); + result = result.replace(/\+/g, "-"); + result = result.replace(/=/g, ""); + + return result; + } else return uuid.v4(); + } + + static formatMessage(messageId: TLocaleKey, variables: LocaleParams[TLocaleKey] | undefined = undefined, defaultMessage: string | undefined = undefined, parseLineBreaks: boolean = false): string { + let result = intl.formatMessage( + { + id: messageId, + defaultMessage: !_.isNil(defaultMessage) ? defaultMessage : messageId, + }, + variables + ); + + if (parseLineBreaks) { + const lines: string[] = result.split("|"); + + result = ""; + lines.forEach(line => (result.length === 0 ? (result = line) : (result = `${result}\n${line}`))); + } + return result; + } + + static validateEmail(email: string): boolean { + const pattern = new RegExp(/^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i); + return pattern.test(email); + } + + static mergeWith(object: any, values: any, updateObject: boolean = true, customizer?: (value: any, sourceValue: any, key: any) => any): any { + if (updateObject) { + return _.mergeWith(object, values, !_.isNil(customizer) ? customizer : this.mergeWithCustomizer); + } else { + const clone = _.cloneDeep(object); + return _.mergeWith(clone, values, !_.isNil(customizer) ? customizer : this.mergeWithCustomizer); + } + } + + static mergeWithCustomizer(value: any, sourceValue: any, key: any): any { + return _.isArray(value) ? sourceValue : undefined; + } + + static get isDevelopment(): boolean { + return process.env.NODE_ENV === "development"; + } + + static get isProduction(): boolean { + return process.env.NODE_ENV === "production"; + } + + static get isStorybook(): boolean { + return !_.isNil(process.env.STORYBOOK_ENV); + } +} diff --git a/src/web/App.tsx b/src/web/App.tsx new file mode 100644 index 0000000..2f07891 --- /dev/null +++ b/src/web/App.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; +import * as _ from "lodash"; +import {Redirect, Route, Switch} from "react-router-dom"; +import "../assets/stylesheets/main.scss"; + +import {BaseComponent} from "./BaseComponent"; +import {Challenge} from "./pages/challenge/Challenge"; +import {ConfirmEmail} from "./pages/confirmemail/ConfirmEmail"; +import {Home} from "./pages/home/Home"; +import {LogIn} from "./pages/login/LogIn"; +import {Profile} from "./pages/profile/Profile"; +import {ResetPassword} from "./pages/resetpassword/ResetPassword"; +import {SignUp} from "./pages/signup/SignUp"; +import {Validate} from "./pages/validate/Validate"; +import {PendingAccount} from "./pages/pending_account/PendingAccount"; +import {Header} from "./components/header/Header"; +import {ForgotPassword} from "./pages/forgotpassword/ForgotPassword"; +import {autorun} from "mobx"; + +class App extends BaseComponent { + state = { + isLoading: true, + }; + + async componentDidMount() { + const {userState} = this.store; + const token = this.appStore.getToken(); + const refreshToken = this.appStore.getRefreshToken(); + + if (!_.isNil(token)) await userState.testToken({token, refreshToken}); + + this.setState({ + isLoading: false, + }); + + autorun(() => this.handleTokensChange()); + } + + private handleTokensChange() { + const {store, appStore} = this; + const {authToken, refreshToken} = store.userState; + + if (authToken) appStore.setToken(authToken); + else appStore.deleteToken(); + + if (refreshToken) appStore.setRefreshToken(refreshToken); + else appStore.deleteRefreshToken(); + } + + render() { + const {isLoading} = this.state; + + return ( +
+ + {!isLoading && ( + + + + + + + + + + + + + + )} +
+ ); + } +} + +export default App; diff --git a/src/web/BaseComponent.ts b/src/web/BaseComponent.ts new file mode 100644 index 0000000..0640ff4 --- /dev/null +++ b/src/web/BaseComponent.ts @@ -0,0 +1,14 @@ +import * as React from "react"; +import {DataStore} from "../services"; +import {AppStore} from "./store/AppStore"; + +export interface BaseComponentProps {} + +export interface BaseComponentState {} + +export abstract class BaseComponent

extends React.Component { + state = {} as S; + + protected store = new DataStore(); + protected appStore = new AppStore(); +} diff --git a/src/web/Index.tsx b/src/web/Index.tsx new file mode 100644 index 0000000..e90e749 --- /dev/null +++ b/src/web/Index.tsx @@ -0,0 +1,26 @@ +import "core-js/stable"; +import "regenerator-runtime/runtime"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import {BrowserRouter} from "react-router-dom"; +import {APIClient} from "../services/apiclient/APIClient"; +import * as serviceWorker from "./serviceWorker"; +import App from "./App"; + +// import "antd/dist/antd.less"; + +APIClient.configureClient({}); + +ReactDOM.render( + + + , + document.getElementById("root") as HTMLElement +); + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +// serviceWorker.register(); +serviceWorker.unregister(); diff --git a/src/web/components/about_modal/AboutModal.tsx b/src/web/components/about_modal/AboutModal.tsx new file mode 100644 index 0000000..9031c9b --- /dev/null +++ b/src/web/components/about_modal/AboutModal.tsx @@ -0,0 +1,62 @@ +import {BaseComponent} from "../../BaseComponent"; +import * as React from "react"; +import {Icon, Modal} from "antd"; +import {boundMethod} from "autobind-decorator"; + +export interface AboutModalProps { + onClose: () => void; +} + +export interface AboutModalState { + showAbout: boolean; +} + +export class AboutModal extends BaseComponent { + state = { + showAbout: false, + }; + + @boundMethod + private handleClose() { + const {props} = this; + + props.onClose(); + } + + render() { + return ( + +

+ + What is IdeaSource? + + +
+ +
IdeaSource is built by
+ +
+ +
Follow us
+ + + + + ); + } +} diff --git a/src/web/components/challenge_idea_cell/ChallengeIdeaCell.tsx b/src/web/components/challenge_idea_cell/ChallengeIdeaCell.tsx new file mode 100644 index 0000000..2ef4dad --- /dev/null +++ b/src/web/components/challenge_idea_cell/ChallengeIdeaCell.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; +import {RouteComponentProps} from "react-router"; +import * as _ from "lodash"; +import classNames from "classnames"; +import {observer} from "mobx-react"; +import {Dropdown, Icon, Menu} from "antd"; +import {Models} from "../../../services"; +import {BaseComponent} from "../../BaseComponent"; +import {boundMethod} from "autobind-decorator"; + +export interface ChallengeIdeaCellProps extends RouteComponentProps<{}> { + idea: Models.Idea; + refreshChallenge?: () => void; +} + +export interface ChallengeIdeaCellState { + isChangingValue: boolean; +} + +@observer +export class ChallengeIdeaCell extends BaseComponent { + state = { + idea: new Models.Idea(), + isChangingValue: false, + }; + + componentDidMount() { + const {idea} = this.props; + const {ideaState} = this.store; + + ideaState.fetchIdea({idea}).then(response => { + if (!response.success) { + alert(!_.isNil(response.error) ? response.error.message : "A problem has occurred."); + } + }); + } + + @boundMethod + private onDeleteIdea() { + const {idea} = this.props; + const {userState, ideaState} = this.store; + + if (userState.isAuthenticated && this.userConfirmedDeletion()) { + ideaState.deleteIdea({idea}).then(response => { + if (!response.success) { + alert(!_.isNil(response.error) ? response.error.message : "A problem has occurred trying to delete this Idea."); + } else this.props.refreshChallenge!(); + }); + } + } + + private userConfirmedDeletion(): Boolean { + const message = "Are you sure you want to delete this Idea?"; + return confirm(message); + } + + @boundMethod + private handleGoToProfile(userId: string) { + this.props.history.push({pathname: "/profile", state: {userId}}); + } + + @boundMethod + private async handleReactionPress() { + const {idea} = this.props; + const {isChangingValue} = this.state; + const {userState, reactionState} = this.store; + const {isAuthenticated} = userState; + + if (!isAuthenticated) { + this.props.history.push("/login"); + return; + } + + if (isChangingValue) return; + + this.setState({isChangingValue: true}); + + if (_.isNil(idea!.myReaction)) await reactionState.createIdeaReaction({idea: idea}); + else await reactionState.deleteIdeaReaction({idea: idea}); + + this.setState({isChangingValue: false}); + } + + render() { + const {idea} = this.props; + const {isChangingValue} = this.state; + const menuImage = require("../../../assets/images/cell_menu.png"); + const deleteImage = require("../../../assets/images/delete.png"); + const likedByMe = _.isNil(idea.myReaction) === isChangingValue; + const totalReactions = !isChangingValue ? idea.reactionQuantity : likedByMe ? idea.reactionQuantity + 1 : idea.reactionQuantity - 1; + const render = !_.isNil(idea.createdBy); + + const challenge = this.store.challengeState.currentChallenge; + const showDropdown: boolean = !_.isNil(challenge) && !_.isNil(this.store.userState.currentUser) && challenge.createdBy.id === this.store.userState.currentUser.id; + const menu = showDropdown ? ( + + + menu + Delete + + + ) : ( +
+ ); + return render ? ( +
+ {showDropdown && ( +
+ +
+ menu +
+
+
+ )} +
+ + + +
+
+ User avatar this.handleGoToProfile(idea.createdBy.id)} /> +
{idea!.createdBy.name}
+
+
{!_.isNil(idea!.createdDate) ? idea!.createdDate.format("DD/MM/YYYY") : ""}
+
+
+
+
+ {idea!.title} +
+ {idea!.description} +
+ this.handleReactionPress()} + /> + {totalReactions + (totalReactions === 1 ? " Like" : " Likes")} +
+
+
+ ) : ( +
+ ); + } +} diff --git a/src/web/components/create_challenge/CreateChallenge.tsx b/src/web/components/create_challenge/CreateChallenge.tsx new file mode 100644 index 0000000..95e8a68 --- /dev/null +++ b/src/web/components/create_challenge/CreateChallenge.tsx @@ -0,0 +1,297 @@ +import moment from "moment"; +import * as React from "react"; +import {ChangeEvent} from "react"; +import {Checkbox, DatePicker} from "antd"; +import {BaseComponent} from "../../BaseComponent"; +import * as _ from "lodash"; +import {CreateChallengeRequest, UpdateChallengeRequest} from "../../../services/store/ChallengeState"; +import {Models} from "../../../services"; +import {boundMethod} from "autobind-decorator"; + +const camera = require("../../../assets/images/camera.svg"); + +export interface CreateChallengeProps { + challenge?: Models.Challenge; + + onClose: () => void; +} + +export interface CreateChallengeState { + title: string; + description: string; + deadLine?: moment.Moment; + ideasDeadLine?: moment.Moment; + editImage?: File; + titlePlaceholder: string; + challenge?: Models.Challenge; + editImageUrl: string; + isLoading: boolean; + privacy: Models.ChallengePrivacyMode; + checkValue: boolean; + domain: string; +} + +export class CreateChallenge extends BaseComponent { + constructor(props: CreateChallengeProps) { + super(props); + + this.state = { + title: "", + description: "", + deadLine: moment().add(1, "M"), + ideasDeadLine: undefined, + editImage: undefined, + titlePlaceholder: "", + challenge: undefined, + editImageUrl: "", + isLoading: false, + privacy: Models.ChallengePrivacyMode.PUBLIC, + checkValue: false, + domain: "", + }; + } + + componentDidMount() { + const {userState} = this.store; + const {challenge} = this.props; + + this.setState({ + challenge: challenge, + editImageUrl: "", + }); + + if (challenge) { + this.setState({ + title: challenge.title, + description: challenge.description, + ideasDeadLine: challenge.closeDate, + deadLine: challenge.endDate, + editImageUrl: challenge.imageUrl, + privacy: challenge.privacyMode, + domain: challenge.privacyData, + }); + + if (challenge.privacyMode === Models.ChallengePrivacyMode.BYDOMAIN) { + this.setState({ + checkValue: true, + }); + } + } + + if (userState.currentUser) { + this.setState({ + domain: userState.currentUser.email.split("@")[1], + titlePlaceholder: "What is the title of the new challenge, " + userState.currentUser.name + "?", + }); + } + } + + @boundMethod + private handleImageChallengeChange(event: ChangeEvent) { + const files = event.target.files; + if (files !== null && files.length === 1) { + const file = files[0]; + + this.setState({ + editImage: file, + editImageUrl: "", + }); + } + } + + @boundMethod + private handleTitleChange(val: string) { + this.setState({title: val}); + } + + @boundMethod + private handleDescriptionChange(val: string) { + this.setState({description: val}); + } + + @boundMethod + private handleDeadline(val: moment.Moment) { + this.setState({deadLine: val}); + } + + @boundMethod + private handleIdeasDeadline(val: moment.Moment) { + this.setState({ideasDeadLine: val}); + } + + @boundMethod + private canCreate(): boolean { + const {isLoading, description, title, editImage, challenge} = this.state; + + return !isLoading && description !== "" && title !== "" && (editImage !== undefined || !_.isNil(challenge)); + } + + @boundMethod + private checkChange(event: any) { + if (event.target.checked) { + this.setState({ + privacy: Models.ChallengePrivacyMode.BYDOMAIN, + checkValue: true, + }); + } else { + this.setState({ + privacy: Models.ChallengePrivacyMode.PUBLIC, + checkValue: false, + }); + } + } + + @boundMethod + private handleCreate() { + const {challengeState} = this.store; + const {challenge} = this.state; + + this.setState({isLoading: true}); + + if (challenge) { + const request: UpdateChallengeRequest = { + challenge: challenge, + title: this.state.title, + description: this.state.description, + closeDate: this.state.ideasDeadLine, + endDate: this.state.deadLine, + image: this.state.editImage, + privacyMode: this.state.privacy, + }; + + challengeState.updateChallenge(request).then(response => { + if (response.success) { + this.setState({ + challenge: undefined, + title: "", + description: "", + editImage: undefined, + ideasDeadLine: moment(), + deadLine: moment(), + isLoading: false, + }); + + this.props.onClose(); + } else { + alert("A problem has occurred trying to edit the Challenge"); + } + }); + } else { + const request: CreateChallengeRequest = { + title: this.state.title, + description: this.state.description, + closeDate: this.state.ideasDeadLine, + endDate: this.state.deadLine, + image: this.state.editImage, + privacyMode: this.state.privacy, + }; + + challengeState.createChallenge(request).then(response => { + if (response.success) { + this.setState({ + title: "", + description: "", + editImage: undefined, + ideasDeadLine: moment(), + deadLine: moment(), + isLoading: false, + }); + + this.props.onClose(); + } else { + alert("A problem has occurred trying to create the Challenge"); + } + }); + } + } + + @boundMethod + private disableDateChallenge(m: moment.Moment | undefined): boolean { + const {ideasDeadLine} = this.state; + + return (m && + m + .clone() + .endOf("day") + .isBefore(ideasDeadLine ? ideasDeadLine : moment()))!; + } + + @boundMethod + private disableDateIdeas(m: moment.Moment | undefined): boolean { + const {deadLine} = this.state; + + return (m && + (m + .clone() + .endOf("day") + .isBefore(moment()) || + (!_.isNil(deadLine) && + m + .clone() + .startOf("day") + .isAfter(deadLine))))!; + } + + render() { + const {deadLine, ideasDeadLine, titlePlaceholder, editImage, editImageUrl, challenge, checkValue, domain} = this.state; + + const displayCameraIcon = ( +
+ +
+ ); + const uploadButton = ( +
+ {editImageUrl !== "" ? avatar : editImage ? avatar : displayCameraIcon} +
+ ); + return ( +
+
{challenge ? "Edit Challenge" : "New Challenge"}
+
+ this.handleTitleChange(event.target.value)} type="text" placeholder={titlePlaceholder} maxLength={200} /> +
+ + +
+