From 58cda9cc7fb79ca9df6746de7f9662bc08dc156a Mon Sep 17 00:00:00 2001 From: Stefan Malzner Date: Fri, 13 Oct 2017 12:29:40 +0200 Subject: initial commit --- src/components/settings/SettingsLayout.js | 56 ++++ .../settings/account/AccountDashboard.js | 286 +++++++++++++++++++++ .../settings/navigation/SettingsNavigation.js | 84 ++++++ src/components/settings/recipes/RecipeItem.js | 34 +++ .../settings/recipes/RecipesDashboard.js | 151 +++++++++++ .../settings/services/EditServiceForm.js | 277 ++++++++++++++++++++ src/components/settings/services/ServiceError.js | 68 +++++ src/components/settings/services/ServiceItem.js | 98 +++++++ .../settings/services/ServicesDashboard.js | 155 +++++++++++ .../settings/settings/EditSettingsForm.js | 148 +++++++++++ src/components/settings/user/EditUserForm.js | 145 +++++++++++ 11 files changed, 1502 insertions(+) create mode 100644 src/components/settings/SettingsLayout.js create mode 100644 src/components/settings/account/AccountDashboard.js create mode 100644 src/components/settings/navigation/SettingsNavigation.js create mode 100644 src/components/settings/recipes/RecipeItem.js create mode 100644 src/components/settings/recipes/RecipesDashboard.js create mode 100644 src/components/settings/services/EditServiceForm.js create mode 100644 src/components/settings/services/ServiceError.js create mode 100644 src/components/settings/services/ServiceItem.js create mode 100644 src/components/settings/services/ServicesDashboard.js create mode 100644 src/components/settings/settings/EditSettingsForm.js create mode 100644 src/components/settings/user/EditUserForm.js (limited to 'src/components/settings') diff --git a/src/components/settings/SettingsLayout.js b/src/components/settings/SettingsLayout.js new file mode 100644 index 000000000..d5392ddba --- /dev/null +++ b/src/components/settings/SettingsLayout.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; + +import { oneOrManyChildElements } from '../../prop-types'; +import Appear from '../ui/effects/Appear'; + +@observer +export default class SettingsLayout extends Component { + static propTypes = { + navigation: PropTypes.element.isRequired, + children: oneOrManyChildElements.isRequired, + closeSettings: PropTypes.func.isRequired, + }; + + componentWillMount() { + document.addEventListener('keydown', this.handleKeyDown.bind(this), false); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleKeyDown.bind(this), false); + } + + handleKeyDown(e) { + if (e.keyCode === 27) { // escape key + this.props.closeSettings(); + } + } + + render() { + const { + navigation, + children, + closeSettings, + } = this.props; + + return ( + +
+
+ +
+ ); + } +} diff --git a/src/components/settings/account/AccountDashboard.js b/src/components/settings/account/AccountDashboard.js new file mode 100644 index 000000000..75dbdef49 --- /dev/null +++ b/src/components/settings/account/AccountDashboard.js @@ -0,0 +1,286 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape, FormattedMessage } from 'react-intl'; +import ReactTooltip from 'react-tooltip'; +import moment from 'moment'; + +import Loader from '../../ui/Loader'; +import Button from '../../ui/Button'; +import Infobox from '../../ui/Infobox'; +import Link from '../../ui/Link'; +import SubscriptionForm from '../../../containers/ui/SubscriptionFormScreen'; + +const messages = defineMessages({ + headline: { + id: 'settings.account.headline', + defaultMessage: '!!!Account', + }, + headlineSubscription: { + id: 'settings.account.headlineSubscription', + defaultMessage: '!!!Your Subscription', + }, + headlineUpgrade: { + id: 'settings.account.headlineUpgrade', + defaultMessage: '!!!Upgrade your Account', + }, + headlineInvoices: { + id: 'settings.account.headlineInvoices', + defaultMessage: '!!Invoices', + }, + manageSubscriptionButtonLabel: { + id: 'settings.account.manageSubscription.label', + defaultMessage: '!!!Manage your subscription', + }, + accountTypeBasic: { + id: 'settings.account.accountType.basic', + defaultMessage: '!!!Basic Account', + }, + accountTypePremium: { + id: 'settings.account.accountType.premium', + defaultMessage: '!!!Premium Supporter Account', + }, + accountEditButton: { + id: 'settings.account.account.editButton', + defaultMessage: '!!!Edit Account', + }, + invoiceDownload: { + id: 'settings.account.invoiceDownload', + defaultMessage: '!!!Download', + }, + userInfoRequestFailed: { + id: 'settings.account.userInfoRequestFailed', + defaultMessage: '!!!Could not load user information', + }, + tryReloadUserInfoRequest: { + id: 'settings.account.tryReloadUserInfoRequest', + defaultMessage: '!!!Try again', + }, + miningActive: { + id: 'settings.account.mining.active', + defaultMessage: '!!!You are right now performing {hashes} calculations per second.', + }, + miningThankYou: { + id: 'settings.account.mining.thankyou', + defaultMessage: '!!!Thank you for supporting Franz with your processing power.', + }, + miningMoreInfo: { + id: 'settings.account.mining.moreInformation', + defaultMessage: '!!!Get more information', + }, + cancelMining: { + id: 'settings.account.mining.cancel', + defaultMessage: '!!!Cancel mining', + }, +}); + +@observer +export default class AccountDashboard extends Component { + static propTypes = { + user: MobxPropTypes.observableObject.isRequired, + orders: MobxPropTypes.arrayOrObservableArray.isRequired, + hashrate: PropTypes.number.isRequired, + isLoading: PropTypes.bool.isRequired, + isLoadingOrdersInfo: PropTypes.bool.isRequired, + isLoadingPlans: PropTypes.bool.isRequired, + isCreatingPaymentDashboardUrl: PropTypes.bool.isRequired, + userInfoRequestFailed: PropTypes.bool.isRequired, + retryUserInfoRequest: PropTypes.func.isRequired, + openDashboard: PropTypes.func.isRequired, + openExternalUrl: PropTypes.func.isRequired, + onCloseSubscriptionWindow: PropTypes.func.isRequired, + stopMiner: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + user, + orders, + hashrate, + isLoading, + isCreatingPaymentDashboardUrl, + openDashboard, + openExternalUrl, + isLoadingOrdersInfo, + isLoadingPlans, + userInfoRequestFailed, + retryUserInfoRequest, + onCloseSubscriptionWindow, + stopMiner, + } = this.props; + const { intl } = this.context; + + return ( +
+
+ + {intl.formatMessage(messages.headline)} + +
+
+ {isLoading && ( + + )} + + {!isLoading && userInfoRequestFailed && ( +
+ + {intl.formatMessage(messages.userInfoRequestFailed)} + +
+ )} + + {!userInfoRequestFailed && ( +
+ {!isLoading && ( +
+
+
+ + {user.isPremium && ( + + + + )} +
+
+

+ {`${user.firstname} ${user.lastname}`} +

+ {user.organization && `${user.organization}, `} + {user.email}
+ {!user.isPremium && ( + {intl.formatMessage(messages.accountTypeBasic)} + )} + {user.isPremium && ( + {intl.formatMessage(messages.accountTypePremium)} + )} +
+ + {intl.formatMessage(messages.accountEditButton)} + + + {user.emailValidated} +
+
+ )} + + {user.isSubscriptionOwner && ( + isLoadingOrdersInfo ? ( + + ) : ( +
+ {orders.length > 0 && ( +
+
+

{intl.formatMessage(messages.headlineSubscription)}

+
+ {orders[0].name} + {orders[0].price} +
+
+
+

{intl.formatMessage(messages.headlineInvoices)}

+ + + {orders.map(order => ( + + + + + ))} + +
+ {moment(order.date).format('DD.MM.YYYY')} + + +
+
+
+ )} +
+ ) + )} + + {user.isMiner && ( +
+
+

{intl.formatMessage(messages.headlineSubscription)}

+
+
+

{intl.formatMessage(messages.miningThankYou)}

+ {hashrate.toFixed(2)}, + }} + tagName="p" + /> +

+ + {intl.formatMessage(messages.miningMoreInfo)} + +

+
+
+
+
+ )} + + {!user.isPremium && !user.isMiner && ( + isLoadingPlans ? ( + + ) : ( +
+
+

{intl.formatMessage(messages.headlineUpgrade)}

+ +
+
+ ) + )} +
+ )} +
+ +
+ ); + } +} diff --git a/src/components/settings/navigation/SettingsNavigation.js b/src/components/settings/navigation/SettingsNavigation.js new file mode 100644 index 000000000..3b21a7765 --- /dev/null +++ b/src/components/settings/navigation/SettingsNavigation.js @@ -0,0 +1,84 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, intlShape } from 'react-intl'; + +import Link from '../../ui/Link'; + +const messages = defineMessages({ + availableServices: { + id: 'settings.navigation.availableServices', + defaultMessage: '!!!Available services', + }, + yourServices: { + id: 'settings.navigation.yourServices', + defaultMessage: '!!!Your services', + }, + account: { + id: 'settings.navigation.account', + defaultMessage: '!!!Account', + }, + settings: { + id: 'settings.navigation.settings', + defaultMessage: '!!!Settings', + }, + logout: { + id: 'settings.navigation.logout', + defaultMessage: '!!!Logout', + }, +}); + +export default class SettingsNavigation extends Component { + static propTypes = { + serviceCount: PropTypes.number.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { serviceCount } = this.props; + const { intl } = this.context; + + return ( +
+ + {intl.formatMessage(messages.availableServices)} + + + {intl.formatMessage(messages.yourServices)} {serviceCount} + + + {intl.formatMessage(messages.account)} + + + {intl.formatMessage(messages.settings)} + + + + {intl.formatMessage(messages.logout)} + +
+ ); + } +} diff --git a/src/components/settings/recipes/RecipeItem.js b/src/components/settings/recipes/RecipeItem.js new file mode 100644 index 000000000..7b2f64d26 --- /dev/null +++ b/src/components/settings/recipes/RecipeItem.js @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; + +import RecipePreviewModel from '../../../models/RecipePreview'; + +@observer +export default class RecipeItem extends Component { + static propTypes = { + recipe: PropTypes.instanceOf(RecipePreviewModel).isRequired, + onClick: PropTypes.func.isRequired, + }; + + render() { + const { recipe, onClick } = this.props; + + return ( + + ); + } +} diff --git a/src/components/settings/recipes/RecipesDashboard.js b/src/components/settings/recipes/RecipesDashboard.js new file mode 100644 index 000000000..02ea04e35 --- /dev/null +++ b/src/components/settings/recipes/RecipesDashboard.js @@ -0,0 +1,151 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Link } from 'react-router'; + +import SearchInput from '../../ui/SearchInput'; +import Infobox from '../../ui/Infobox'; +import RecipeItem from './RecipeItem'; +import Loader from '../../ui/Loader'; +import Appear from '../../ui/effects/Appear'; + +const messages = defineMessages({ + headline: { + id: 'settings.recipes.headline', + defaultMessage: '!!!Available Services', + }, + mostPopularRecipes: { + id: 'settings.recipes.mostPopular', + defaultMessage: '!!!Most popular', + }, + allRecipes: { + id: 'settings.recipes.all', + defaultMessage: '!!!All services', + }, + devRecipes: { + id: 'settings.recipes.dev', + defaultMessage: '!!!Development', + }, + nothingFound: { + id: 'settings.recipes.nothingFound', + defaultMessage: '!!!Sorry, but no service matched your search term.', + }, + servicesSuccessfulAddedInfo: { + id: 'settings.recipes.servicesSuccessfulAddedInfo', + defaultMessage: '!!!Service successfully added', + }, +}); + +@observer +export default class RecipesDashboard extends Component { + static propTypes = { + recipes: MobxPropTypes.arrayOrObservableArray.isRequired, + isLoading: PropTypes.bool.isRequired, + hasLoadedRecipes: PropTypes.bool.isRequired, + showAddServiceInterface: PropTypes.func.isRequired, + searchRecipes: PropTypes.func.isRequired, + resetSearch: PropTypes.func.isRequired, + serviceStatus: MobxPropTypes.arrayOrObservableArray.isRequired, + devRecipesCount: PropTypes.number.isRequired, + searchNeedle: PropTypes.string, + }; + + static defaultProps = { + searchNeedle: '', + } + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + recipes, + isLoading, + hasLoadedRecipes, + showAddServiceInterface, + searchRecipes, + resetSearch, + serviceStatus, + devRecipesCount, + searchNeedle, + } = this.props; + const { intl } = this.context; + + return ( +
+
+ searchRecipes(e)} + onReset={() => resetSearch()} + throttle + /> +
+
+ {serviceStatus.length > 0 && serviceStatus.includes('created') && ( + + + {intl.formatMessage(messages.servicesSuccessfulAddedInfo)} + + + )} + {!searchNeedle && ( +
+ + {intl.formatMessage(messages.mostPopularRecipes)} + + + {intl.formatMessage(messages.allRecipes)} + + {devRecipesCount > 0 && ( + + {intl.formatMessage(messages.devRecipes)} ({devRecipesCount}) + + )} +
+ )} + {isLoading ? ( + + ) : ( +
+ {hasLoadedRecipes && recipes.length === 0 && ( +

+ + + + {intl.formatMessage(messages.nothingFound)} +

+ )} + {recipes.map(recipe => ( + showAddServiceInterface({ recipeId: recipe.id })} + /> + ))} +
+ )} +
+
+ ); + } +} diff --git a/src/components/settings/services/EditServiceForm.js b/src/components/settings/services/EditServiceForm.js new file mode 100644 index 000000000..fac0f6b9a --- /dev/null +++ b/src/components/settings/services/EditServiceForm.js @@ -0,0 +1,277 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { Link } from 'react-router'; +import { defineMessages, intlShape } from 'react-intl'; +import normalizeUrl from 'normalize-url'; + +import Form from '../../../lib/Form'; +import User from '../../../models/User'; +import Recipe from '../../../models/Recipe'; +import Service from '../../../models/Service'; +import Tabs, { TabItem } from '../../ui/Tabs'; +import Input from '../../ui/Input'; +import Toggle from '../../ui/Toggle'; +import Button from '../../ui/Button'; + +const messages = defineMessages({ + saveService: { + id: 'settings.service.form.saveButton', + defaultMessage: '!!!Save service', + }, + deleteService: { + id: 'settings.service.form.deleteButton', + defaultMessage: '!!!Delete Service', + }, + availableServices: { + id: 'settings.service.form.availableServices', + defaultMessage: '!!!Available services', + }, + yourServices: { + id: 'settings.service.form.yourServices', + defaultMessage: '!!!Your services', + }, + addServiceHeadline: { + id: 'settings.service.form.addServiceHeadline', + defaultMessage: '!!!Add {name}', + }, + editServiceHeadline: { + id: 'settings.service.form.editServiceHeadline', + defaultMessage: '!!!Edit {name}', + }, + tabHosted: { + id: 'settings.service.form.tabHosted', + defaultMessage: '!!!Hosted', + }, + tabOnPremise: { + id: 'settings.service.form.tabOnPremise', + defaultMessage: '!!!Self hosted ⭐️', + }, + customUrlValidationError: { + id: 'settings.service.form.customUrlValidationError', + defaultMessage: '!!!Could not validate custom {name} server.', + }, + customUrlPremiumInfo: { + id: 'settings.service.form.customUrlPremiumInfo', + defaultMessage: '!!!To add self hosted services, you need a Franz Premium Supporter Account.', + }, + customUrlUpgradeAccount: { + id: 'settings.service.form.customUrlUpgradeAccount', + defaultMessage: '!!!Upgrade your account', + }, + indirectMessageInfo: { + id: 'settings.service.form.indirectMessageInfo', + defaultMessage: '!!!You will be notified about all new messages in a channel, not just @username, @channel, @here, ...', // eslint-disable-line + }, +}); + +@observer +export default class EditServiceForm extends Component { + static propTypes = { + recipe: PropTypes.instanceOf(Recipe).isRequired, + // service: PropTypes.oneOfType([ + // PropTypes.object, + // PropTypes.instanceOf(Service), + // ]), + service(props, propName) { + if (props.action === 'edit' && !(props[propName] instanceof Service)) { + return new Error(`'${propName}'' is expected to be of type 'Service' + when editing a Service`); + } + + return null; + }, + user: PropTypes.instanceOf(User).isRequired, + action: PropTypes.string.isRequired, + form: PropTypes.instanceOf(Form).isRequired, + onSubmit: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + isSaving: PropTypes.bool.isRequired, + isDeleting: PropTypes.bool.isRequired, + }; + + static defaultProps = { + service: {}, + }; + static contextTypes = { + intl: intlShape, + }; + + state = { + isValidatingCustomUrl: false, + } + + submit(e) { + const { recipe } = this.props; + + e.preventDefault(); + this.props.form.submit({ + onSuccess: async (form) => { + const values = form.values(); + + let isValid = true; + + if (recipe.validateUrl && values.customUrl) { + this.setState({ isValidatingCustomUrl: true }); + try { + values.customUrl = normalizeUrl(values.customUrl); + isValid = await recipe.validateUrl(values.customUrl); + } catch (err) { + console.warn('ValidateURL', err); + isValid = false; + } + } + + if (isValid) { + this.props.onSubmit(values); + } else { + form.invalidate('url-validation-error'); + } + + this.setState({ isValidatingCustomUrl: false }); + }, + onError: () => {}, + }); + } + + render() { + const { + recipe, + service, + action, + user, + form, + isSaving, + isDeleting, + onDelete, + } = this.props; + const { intl } = this.context; + + const { isValidatingCustomUrl } = this.state; + + const deleteButton = isDeleting ? ( +