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/auth/AuthLayout.js | 88 +++++++ src/components/auth/Import.js | 168 ++++++++++++ src/components/auth/Invite.js | 111 ++++++++ src/components/auth/Login.js | 161 ++++++++++++ src/components/auth/Password.js | 135 ++++++++++ src/components/auth/Pricing.js | 130 ++++++++++ src/components/auth/Signup.js | 206 +++++++++++++++ src/components/auth/Welcome.js | 69 +++++ src/components/layout/AppLayout.js | 148 +++++++++++ src/components/layout/Sidebar.js | 75 ++++++ src/components/services/content/ServiceWebview.js | 73 ++++++ src/components/services/content/Services.js | 81 ++++++ src/components/services/tabs/TabBarSortableList.js | 44 ++++ src/components/services/tabs/TabItem.js | 136 ++++++++++ src/components/services/tabs/Tabbar.js | 77 ++++++ 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 +++++++++++ src/components/ui/AppLoader.js | 15 ++ src/components/ui/Button.js | 78 ++++++ src/components/ui/InfoBar.js | 88 +++++++ src/components/ui/Infobox.js | 87 +++++++ src/components/ui/Input.js | 148 +++++++++++ src/components/ui/Link.js | 78 ++++++ src/components/ui/Loader.js | 41 +++ src/components/ui/Radio.js | 89 +++++++ src/components/ui/SearchInput.js | 124 +++++++++ src/components/ui/Select.js | 70 +++++ src/components/ui/Subscription.js | 265 +++++++++++++++++++ src/components/ui/SubscriptionPopup.js | 84 ++++++ src/components/ui/Tabs/TabItem.js | 17 ++ src/components/ui/Tabs/Tabs.js | 69 +++++ src/components/ui/Tabs/index.js | 6 + src/components/ui/Toggle.js | 67 +++++ src/components/ui/effects/Appear.js | 51 ++++ 43 files changed, 4581 insertions(+) create mode 100644 src/components/auth/AuthLayout.js create mode 100644 src/components/auth/Import.js create mode 100644 src/components/auth/Invite.js create mode 100644 src/components/auth/Login.js create mode 100644 src/components/auth/Password.js create mode 100644 src/components/auth/Pricing.js create mode 100644 src/components/auth/Signup.js create mode 100644 src/components/auth/Welcome.js create mode 100644 src/components/layout/AppLayout.js create mode 100644 src/components/layout/Sidebar.js create mode 100644 src/components/services/content/ServiceWebview.js create mode 100644 src/components/services/content/Services.js create mode 100644 src/components/services/tabs/TabBarSortableList.js create mode 100644 src/components/services/tabs/TabItem.js create mode 100644 src/components/services/tabs/Tabbar.js 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 create mode 100644 src/components/ui/AppLoader.js create mode 100644 src/components/ui/Button.js create mode 100644 src/components/ui/InfoBar.js create mode 100644 src/components/ui/Infobox.js create mode 100644 src/components/ui/Input.js create mode 100644 src/components/ui/Link.js create mode 100644 src/components/ui/Loader.js create mode 100644 src/components/ui/Radio.js create mode 100644 src/components/ui/SearchInput.js create mode 100644 src/components/ui/Select.js create mode 100644 src/components/ui/Subscription.js create mode 100644 src/components/ui/SubscriptionPopup.js create mode 100644 src/components/ui/Tabs/TabItem.js create mode 100644 src/components/ui/Tabs/Tabs.js create mode 100644 src/components/ui/Tabs/index.js create mode 100644 src/components/ui/Toggle.js create mode 100644 src/components/ui/effects/Appear.js (limited to 'src/components') diff --git a/src/components/auth/AuthLayout.js b/src/components/auth/AuthLayout.js new file mode 100644 index 000000000..2741b8a15 --- /dev/null +++ b/src/components/auth/AuthLayout.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { RouteTransition } from 'react-router-transition'; +import { intlShape } from 'react-intl'; + +import Link from '../ui/Link'; +import InfoBar from '../ui/InfoBar'; + +import { oneOrManyChildElements, globalError as globalErrorPropType } from '../../prop-types'; +import globalMessages from '../../i18n/globalMessages'; + +@observer +export default class AuthLayout extends Component { + static propTypes = { + children: oneOrManyChildElements.isRequired, + pathname: PropTypes.string.isRequired, + error: globalErrorPropType.isRequired, + isOnline: PropTypes.bool.isRequired, + isAPIHealthy: PropTypes.bool.isRequired, + retryHealthCheck: PropTypes.func.isRequired, + isHealthCheckLoading: PropTypes.bool.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + children, + pathname, + error, + isOnline, + isAPIHealthy, + retryHealthCheck, + isHealthCheckLoading, + } = this.props; + const { intl } = this.context; + + return ( +
+ {!isOnline && ( + + + {intl.formatMessage(globalMessages.notConnectedToTheInternet)} + + )} + {isOnline && !isAPIHealthy && ( + + + {intl.formatMessage(globalMessages.APIUnhealthy)} + + )} +
+ ({ + transform: `translateX(${styles.translateX}%)`, + opacity: styles.opacity, + })} + component="span" + > + {/* Inject globalError into children */} + {React.cloneElement(children, { + error, + })} + +
+ {/*
*/} + + + + + ); + } +} diff --git a/src/components/auth/Import.js b/src/components/auth/Import.js new file mode 100644 index 000000000..cf83aa9c8 --- /dev/null +++ b/src/components/auth/Import.js @@ -0,0 +1,168 @@ +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 classnames from 'classnames'; + +import Form from '../../lib/Form'; +import Toggle from '../ui/Toggle'; +import Button from '../ui/Button'; + +const messages = defineMessages({ + headline: { + id: 'import.headline', + defaultMessage: '!!!Import your Franz 4 services', + }, + notSupportedHeadline: { + id: 'import.notSupportedHeadline', + defaultMessage: '!!!Services not yet supported in Franz 5', + }, + submitButtonLabel: { + id: 'import.submit.label', + defaultMessage: '!!!Import {count} services', + }, + skipButtonLabel: { + id: 'import.skip.label', + defaultMessage: '!!!I want add services manually', + }, +}); + +@observer +export default class Import extends Component { + static propTypes = { + services: MobxPropTypes.arrayOrObservableArray.isRequired, + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + inviteRoute: PropTypes.string.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + prepareForm() { + const { services } = this.props; + + const config = { + fields: { + import: [...services.filter(s => s.recipe).map(s => ({ + add: { + default: true, + options: s, + }, + }))], + }, + }; + + return new Form(config, this.context.intl); + } + + submit(e) { + const { services } = this.props; + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + const servicesImport = form.values().import + .map((value, i) => !value.add || services.filter(s => s.recipe)[i]) + .filter(s => typeof s !== 'boolean'); + + this.props.onSubmit({ services: servicesImport }); + }, + onError: () => {}, + }); + } + + render() { + this.form = this.prepareForm(); + const { intl } = this.context; + const { services, isSubmitting, inviteRoute } = this.props; + + const availableServices = services.filter(s => s.recipe); + const unavailableServices = services.filter(s => !s.recipe); + + return ( +
+
+
this.submit(e)}> + +

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

+ + + {this.form.$('import').map((service, i) => ( + service.$('add').set(!service.$('add').value)} + > + + + + + ))} + +
+ + + + + {availableServices[i].name !== '' + ? availableServices[i].name + : availableServices[i].recipe.name} +
+ {unavailableServices.length > 0 && ( +
+ {intl.formatMessage(messages.notSupportedHeadline)} +

+ {services.filter(s => !s.recipe).map((service, i) => ( + + {service.name !== '' ? service.name : service.service} + {unavailableServices.length > i + 1 ? ', ' : ''} + + ))} +

+
+ )} + + {isSubmitting ? ( +
+
+ ); + } +} diff --git a/src/components/auth/Invite.js b/src/components/auth/Invite.js new file mode 100644 index 000000000..c1d815dcd --- /dev/null +++ b/src/components/auth/Invite.js @@ -0,0 +1,111 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Link } from 'react-router'; + +import Form from '../../lib/Form'; +import { email } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Button from '../ui/Button'; + +const messages = defineMessages({ + headline: { + id: 'invite.headline.friends', + defaultMessage: '!!!Invite 3 of your friends or colleagues', + }, + nameLabel: { + id: 'invite.name.label', + defaultMessage: '!!!Name', + }, + emailLabel: { + id: 'invite.email.label', + defaultMessage: '!!!Email address', + }, + submitButtonLabel: { + id: 'invite.submit.label', + defaultMessage: '!!!Send invites', + }, + skipButtonLabel: { + id: 'invite.skip.label', + defaultMessage: '!!!I want to do this later', + }, +}); + +@observer +export default class Invite extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + invite: [...Array(3).fill({ + name: { + label: this.context.intl.formatMessage(messages.nameLabel), + // value: '', + placeholder: this.context.intl.formatMessage(messages.nameLabel), + }, + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + // value: '', + validate: [email], + placeholder: this.context.intl.formatMessage(messages.emailLabel), + }, + })], + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit({ invites: form.values().invite }); + }, + onError: () => {}, + }); + } + + render() { + const { form } = this; + const { intl } = this.context; + + return ( +
+
this.submit(e)}> + +

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

+ {form.$('invite').map(invite => ( +
+
+ + +
+
+ ))} +
+ ); + } +} diff --git a/src/components/auth/Login.js b/src/components/auth/Login.js new file mode 100644 index 000000000..015079f02 --- /dev/null +++ b/src/components/auth/Login.js @@ -0,0 +1,161 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; + +import Form from '../../lib/Form'; +import { required, email } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Button from '../ui/Button'; +import Link from '../ui/Link'; + +import { globalError as globalErrorPropType } from '../../prop-types'; + +// import Appear from '../ui/effects/Appear'; + +const messages = defineMessages({ + headline: { + id: 'login.headline', + defaultMessage: '!!!Sign in', + }, + emailLabel: { + id: 'login.email.label', + defaultMessage: '!!!Email address', + }, + passwordLabel: { + id: 'login.password.label', + defaultMessage: '!!!Password', + }, + submitButtonLabel: { + id: 'login.submit.label', + defaultMessage: '!!!Sign in', + }, + invalidCredentials: { + id: 'login.invalidCredentials', + defaultMessage: '!!!Email or password not valid', + }, + tokenExpired: { + id: 'login.tokenExpired', + defaultMessage: '!!!Your session expired, please login again.', + }, + serverLogout: { + id: 'login.serverLogout', + defaultMessage: '!!!Your session expired, please login again.', + }, + signupLink: { + id: 'login.link.signup', + defaultMessage: '!!!Create a free account', + }, + passwordLink: { + id: 'login.link.password', + defaultMessage: '!!!Forgot password', + }, +}); + +@observer +export default class Login extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + isTokenExpired: PropTypes.bool.isRequired, + isServerLogout: PropTypes.bool.isRequired, + signupRoute: PropTypes.string.isRequired, + passwordRoute: PropTypes.string.isRequired, + error: globalErrorPropType.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + value: '', + validate: [required, email], + }, + password: { + label: this.context.intl.formatMessage(messages.passwordLabel), + value: '', + validate: [required], + type: 'password', + }, + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit(form.values()); + }, + onError: () => {}, + }); + } + + emailField = null; + + render() { + const { form } = this; + const { intl } = this.context; + const { + isSubmitting, + isTokenExpired, + isServerLogout, + signupRoute, + passwordRoute, + error, + } = this.props; + + return ( +
+
this.submit(e)}> + +

{intl.formatMessage(messages.headline)}

+ {isTokenExpired && ( +

{intl.formatMessage(messages.tokenExpired)}

+ )} + {isServerLogout && ( +

{intl.formatMessage(messages.serverLogout)}

+ )} + { this.emailField = element; }} + focus + /> + + {error.code === 'invalid-credentials' && ( +

{intl.formatMessage(messages.invalidCredentials)}

+ )} + {isSubmitting ? ( +
+ ); + } +} diff --git a/src/components/auth/Password.js b/src/components/auth/Password.js new file mode 100644 index 000000000..d2b196853 --- /dev/null +++ b/src/components/auth/Password.js @@ -0,0 +1,135 @@ +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 Form from '../../lib/Form'; +import { required, email } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Button from '../ui/Button'; +import Link from '../ui/Link'; +import Infobox from '../ui/Infobox'; + +const messages = defineMessages({ + headline: { + id: 'password.headline', + defaultMessage: '!!!Forgot password', + }, + emailLabel: { + id: 'password.email.label', + defaultMessage: '!!!Email address', + }, + submitButtonLabel: { + id: 'password.submit.label', + defaultMessage: '!!!Submit', + }, + successInfo: { + id: 'password.successInfo', + defaultMessage: '!!!Your new password was sent to your email address', + }, + noUser: { + id: 'password.noUser', + defaultMessage: '!!!No user affiliated with that email address', + }, + signupLink: { + id: 'password.link.signup', + defaultMessage: '!!!Create a free account', + }, + loginLink: { + id: 'password.link.login', + defaultMessage: '!!!Sign in to your account', + }, +}); + +@observer +export default class Password extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + signupRoute: PropTypes.string.isRequired, + loginRoute: PropTypes.string.isRequired, + status: MobxPropTypes.arrayOrObservableArray.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + value: '', + validate: [required, email], + }, + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit(form.values()); + }, + onError: () => {}, + }); + } + + render() { + const { form } = this; + const { intl } = this.context; + const { + isSubmitting, + signupRoute, + loginRoute, + status, + } = this.props; + + return ( +
+
this.submit(e)}> + +

{intl.formatMessage(messages.headline)}

+ {status.length > 0 && status.includes('sent') && ( + + {intl.formatMessage(messages.successInfo)} + + )} + + {status.length > 0 && status.includes('no-user') && ( +

{intl.formatMessage(messages.noUser)}

+ )} + {isSubmitting ? ( +
+ ); + } +} diff --git a/src/components/auth/Pricing.js b/src/components/auth/Pricing.js new file mode 100644 index 000000000..761561a89 --- /dev/null +++ b/src/components/auth/Pricing.js @@ -0,0 +1,130 @@ +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 Button from '../ui/Button'; +import Loader from '../ui/Loader'; +import Appear from '../ui/effects/Appear'; +import SubscriptionForm from '../../containers/ui/SubscriptionFormScreen'; + +const messages = defineMessages({ + headline: { + id: 'pricing.headline', + defaultMessage: '!!!Support Franz', + }, + monthlySupportLabel: { + id: 'pricing.support.label', + defaultMessage: '!!!Select your support plan', + }, + submitButtonLabel: { + id: 'pricing.submit.label', + defaultMessage: '!!!Support the development of Franz', + }, + skipPayment: { + id: 'pricing.link.skipPayment', + defaultMessage: '!!!I don\'t want to support the development of Franz.', + }, +}); + +@observer +export default class Signup extends Component { + static propTypes = { + donor: MobxPropTypes.objectOrObservableObject.isRequired, + isLoading: PropTypes.bool.isRequired, + isLoadingUser: PropTypes.bool.isRequired, + onCloseSubscriptionWindow: PropTypes.func.isRequired, + skipAction: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + donor, + isLoading, + isLoadingUser, + onCloseSubscriptionWindow, + skipAction, + } = this.props; + const { intl } = this.context; + + return ( +
+
+
+ +

{intl.formatMessage(messages.headline)}

+
+ {isLoadingUser && ( +

Loading

+ )} + {!isLoadingUser && ( + donor.amount ? ( + +

+ Thank you so much for your previous donation of $ {donor.amount}. +
+ Your support allowed us to get where we are today. +
+

+

+ As an early supporter, you get a lifetime premium supporter license without any + additional charges. +

+

+ However, If you want to keep supporting us, you are more than welcome to subscribe to a plan. +

+

+
+ ) : ( + +

+ We built Franz with a lot of effort, manpower and love, + to bring you the best messaging experience. +
+

+

+ Getting a Franz Premium Supporter License will allow us to keep improving Franz for you. +

+
+ ) + )} +

+ Thanks for being a hero. +

+

+ Stefan Malzner +

+
+ + + {intl.formatMessage(messages.monthlySupportLabel)} + + {/* + {intl.formatMessage(messages.skipPayment)} + */} + + +
+
+
+ ); + } +} diff --git a/src/components/auth/Signup.js b/src/components/auth/Signup.js new file mode 100644 index 000000000..71ca16111 --- /dev/null +++ b/src/components/auth/Signup.js @@ -0,0 +1,206 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; + +import Form from '../../lib/Form'; +import { required, email, minLength } from '../../helpers/validation-helpers'; +import Input from '../ui/Input'; +import Radio from '../ui/Radio'; +import Button from '../ui/Button'; +import Link from '../ui/Link'; + +import { globalError as globalErrorPropType } from '../../prop-types'; + +const messages = defineMessages({ + headline: { + id: 'signup.headline', + defaultMessage: '!!!Sign up', + }, + firstnameLabel: { + id: 'signup.firstname.label', + defaultMessage: '!!!Firstname', + }, + lastnameLabel: { + id: 'signup.lastname.label', + defaultMessage: '!!!Lastname', + }, + emailLabel: { + id: 'signup.email.label', + defaultMessage: '!!!Email address', + }, + companyLabel: { + id: 'signup.company.label', + defaultMessage: '!!!Company', + }, + passwordLabel: { + id: 'signup.password.label', + defaultMessage: '!!!Password', + }, + legalInfo: { + id: 'signup.legal.info', + defaultMessage: '!!!By creating a Franz account you accept the', + }, + terms: { + id: 'signup.legal.terms', + defaultMessage: '!!!Terms of service', + }, + privacy: { + id: 'signup.legal.privacy', + defaultMessage: '!!!Privacy Statement', + }, + submitButtonLabel: { + id: 'signup.submit.label', + defaultMessage: '!!!Create account', + }, + loginLink: { + id: 'signup.link.login', + defaultMessage: '!!!Already have an account, sign in?', + }, + emailDuplicate: { + id: 'signup.emailDuplicate', + defaultMessage: '!!!A user with that email address already exists', + }, +}); + +@observer +export default class Signup extends Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + loginRoute: PropTypes.string.isRequired, + error: globalErrorPropType.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + form = new Form({ + fields: { + accountType: { + value: 'individual', + validate: [required], + options: [{ + value: 'individual', + label: 'Individual', + }, { + value: 'non-profit', + label: 'Non-Profit', + }, { + value: 'company', + label: 'Company', + }], + }, + firstname: { + label: this.context.intl.formatMessage(messages.firstnameLabel), + value: '', + validate: [required], + }, + lastname: { + label: this.context.intl.formatMessage(messages.lastnameLabel), + value: '', + validate: [required], + }, + email: { + label: this.context.intl.formatMessage(messages.emailLabel), + value: '', + validate: [required, email], + }, + organization: { + label: this.context.intl.formatMessage(messages.companyLabel), + value: '', // TODO: make required when accountType: company + }, + password: { + label: this.context.intl.formatMessage(messages.passwordLabel), + value: '', + validate: [required, minLength(6)], + type: 'password', + }, + }, + }, this.context.intl); + + submit(e) { + e.preventDefault(); + this.form.submit({ + onSuccess: (form) => { + this.props.onSubmit(form.values()); + }, + onError: () => {}, + }); + } + + render() { + const { form } = this; + const { intl } = this.context; + const { isSubmitting, loginRoute, error } = this.props; + + return ( +
+
+
this.submit(e)}> + +

{intl.formatMessage(messages.headline)}

+ +
+ + +
+ + + {form.$('accountType').value === 'company' && ( + + )} + {error.code === 'email-duplicate' && ( +

{intl.formatMessage(messages.emailDuplicate)}

+ )} + {isSubmitting ? ( +
+
+ ); + } +} diff --git a/src/components/auth/Welcome.js b/src/components/auth/Welcome.js new file mode 100644 index 000000000..06b10ecfe --- /dev/null +++ b/src/components/auth/Welcome.js @@ -0,0 +1,69 @@ +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 '../ui/Link'; + +const messages = defineMessages({ + signupButton: { + id: 'welcome.signupButton', + defaultMessage: '!!!Create a free account', + }, + loginButton: { + id: 'welcome.loginButton', + defaultMessage: '!!!Login to your account', + }, +}); + +@observer +export default class Login extends Component { + static propTypes = { + loginRoute: PropTypes.string.isRequired, + signupRoute: PropTypes.string.isRequired, + recipes: MobxPropTypes.arrayOrObservableArray.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { intl } = this.context; + const { + loginRoute, + signupRoute, + recipes, + } = this.props; + + return ( +
+
+ + {/* */} +
+

Franz

+
+
+
+ + {intl.formatMessage(messages.signupButton)} + + + {intl.formatMessage(messages.loginButton)} + +
+
+ {recipes.map(recipe => ( + + ))} +
+
+ ); + } +} diff --git a/src/components/layout/AppLayout.js b/src/components/layout/AppLayout.js new file mode 100644 index 000000000..f60c170a8 --- /dev/null +++ b/src/components/layout/AppLayout.js @@ -0,0 +1,148 @@ +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 InfoBar from '../ui/InfoBar'; +import globalMessages from '../../i18n/globalMessages'; + +function createMarkup(HTMLString) { + return { __html: HTMLString }; +} + +const messages = defineMessages({ + servicesUpdated: { + id: 'infobar.servicesUpdated', + defaultMessage: '!!!Your services have been updated.', + }, + updateAvailable: { + id: 'infobar.updateAvailable', + defaultMessage: '!!!A new update for Franz is available.', + }, + buttonReloadServices: { + id: 'infobar.buttonReloadServices', + defaultMessage: '!!!Reload services', + }, + buttonInstallUpdate: { + id: 'infobar.buttonInstallUpdate', + defaultMessage: '!!!Restart & install update', + }, + requiredRequestsFailed: { + id: 'infobar.requiredRequestsFailed', + defaultMessage: '!!!Could not load services and user information', + }, +}); + +@observer +export default class AppLayout extends Component { + static propTypes = { + sidebar: PropTypes.element.isRequired, + services: PropTypes.element.isRequired, + children: PropTypes.element, + news: MobxPropTypes.arrayOrObservableArray.isRequired, + isOnline: PropTypes.bool.isRequired, + showServicesUpdatedInfoBar: PropTypes.bool.isRequired, + appUpdateIsDownloaded: PropTypes.bool.isRequired, + removeNewsItem: PropTypes.func.isRequired, + reloadServicesAfterUpdate: PropTypes.func.isRequired, + installAppUpdate: PropTypes.func.isRequired, + showRequiredRequestsError: PropTypes.bool.isRequired, + areRequiredRequestsSuccessful: PropTypes.bool.isRequired, + retryRequiredRequests: PropTypes.func.isRequired, + areRequiredRequestsLoading: PropTypes.bool.isRequired, + }; + + static defaultProps = { + children: [], + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + sidebar, + services, + children, + isOnline, + news, + showServicesUpdatedInfoBar, + appUpdateIsDownloaded, + removeNewsItem, + reloadServicesAfterUpdate, + installAppUpdate, + showRequiredRequestsError, + areRequiredRequestsSuccessful, + retryRequiredRequests, + areRequiredRequestsLoading, + } = this.props; + + const { intl } = this.context; + + return ( +
+
+ {sidebar} +
+ {news.length > 0 && news.map(item => ( + removeNewsItem({ newsId: item.id })} + > + + + ))} + {!isOnline && ( + + + {intl.formatMessage(globalMessages.notConnectedToTheInternet)} + + )} + {!areRequiredRequestsSuccessful && showRequiredRequestsError && ( + + + {intl.formatMessage(messages.requiredRequestsFailed)} + + )} + {showServicesUpdatedInfoBar && ( + + + {intl.formatMessage(messages.servicesUpdated)} + + )} + {appUpdateIsDownloaded && ( + + + {intl.formatMessage(messages.updateAvailable)} + + )} + {services} +
+
+ {children} +
+ ); + } +} diff --git a/src/components/layout/Sidebar.js b/src/components/layout/Sidebar.js new file mode 100644 index 000000000..4aee1ec60 --- /dev/null +++ b/src/components/layout/Sidebar.js @@ -0,0 +1,75 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ReactTooltip from 'react-tooltip'; +import { defineMessages, intlShape } from 'react-intl'; + +import Tabbar from '../services/tabs/Tabbar'; +import { ctrlKey } from '../../environment'; + +const messages = defineMessages({ + settings: { + id: 'sidebar.settings', + defaultMessage: '!!!Settings', + }, +}); + +export default class Sidebar extends Component { + static propTypes = { + openSettings: PropTypes.func.isRequired, + isPremiumUser: PropTypes.bool, + } + + static defaultProps = { + isPremiumUser: false, + } + + static contextTypes = { + intl: intlShape, + }; + + state = { + tooltipEnabled: true, + }; + + enableToolTip() { + this.setState({ tooltipEnabled: true }); + } + + disableToolTip() { + this.setState({ tooltipEnabled: false }); + } + + render() { + const { openSettings, isPremiumUser } = this.props; + const { intl } = this.context; + return ( +
+ this.enableToolTip()} + disableToolTip={() => this.disableToolTip()} + /> + + {this.state.tooltipEnabled && ( + + )} +
+ ); + } +} diff --git a/src/components/services/content/ServiceWebview.js b/src/components/services/content/ServiceWebview.js new file mode 100644 index 000000000..043ff42ea --- /dev/null +++ b/src/components/services/content/ServiceWebview.js @@ -0,0 +1,73 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { autorun } from 'mobx'; +import { observer } from 'mobx-react'; +import Webview from 'react-electron-web-view'; +import classnames from 'classnames'; + +import ServiceModel from '../../../models/Service'; + +@observer +export default class ServiceWebview extends Component { + static propTypes = { + service: PropTypes.instanceOf(ServiceModel).isRequired, + setWebviewReference: PropTypes.func.isRequired, + }; + + static defaultProps = { + isActive: false, + }; + + state = { + forceRepaint: false, + }; + + componentDidMount() { + autorun(() => { + if (this.props.service.isActive) { + this.setState({ forceRepaint: true }); + setTimeout(() => { + this.setState({ forceRepaint: false }); + }, 100); + } + }); + } + + webview = null; + + render() { + const { + service, + setWebviewReference, + } = this.props; + + const webviewClasses = classnames({ + services__webview: true, + 'is-active': service.isActive, + 'services__webview--force-repaint': this.state.forceRepaint, + }); + + return ( +
+ { this.webview = element; }} + + autosize + src={service.url} + preload="./webview/plugin.js" + partition={`persist:service-${service.id}`} + + onDidAttach={() => setWebviewReference({ + serviceId: service.id, + webview: this.webview.view, + })} + + useragent={service.userAgent} + + disablewebsecurity + allowpopups + /> +
+ ); + } +} diff --git a/src/components/services/content/Services.js b/src/components/services/content/Services.js new file mode 100644 index 000000000..03c68b06f --- /dev/null +++ b/src/components/services/content/Services.js @@ -0,0 +1,81 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; +import { Link } from 'react-router'; +import { defineMessages, intlShape } from 'react-intl'; + +import Webview from './ServiceWebview'; +import Appear from '../../ui/effects/Appear'; + +const messages = defineMessages({ + welcome: { + id: 'services.welcome', + defaultMessage: '!!!Welcome to Franz', + }, + getStarted: { + id: 'services.getStarted', + defaultMessage: '!!!Get started', + }, +}); + +@observer +export default class Services extends Component { + static propTypes = { + services: MobxPropTypes.arrayOrObservableArray.isRequired, + setWebviewReference: PropTypes.func.isRequired, + handleIPCMessage: PropTypes.func.isRequired, + openWindow: PropTypes.func.isRequired, + }; + + static defaultProps = { + services: [], + activeService: '', + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + services, + handleIPCMessage, + setWebviewReference, + openWindow, + } = this.props; + const { intl } = this.context; + + return ( +
+ {services.length === 0 && ( + +
+ +

{intl.formatMessage(messages.welcome)}

+ + + {intl.formatMessage(messages.getStarted)} + + +
+
+ )} + {services.map(service => ( + + ))} +
+ ); + } +} diff --git a/src/components/services/tabs/TabBarSortableList.js b/src/components/services/tabs/TabBarSortableList.js new file mode 100644 index 000000000..c0a68d1a5 --- /dev/null +++ b/src/components/services/tabs/TabBarSortableList.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { observer } from 'mobx-react'; +import { SortableContainer } from 'react-sortable-hoc'; + +import TabItem from './TabItem'; +import { ctrlKey } from '../../../environment'; + +export default SortableContainer(observer(({ + services, + setActive, + reload, + toggleNotifications, + deleteService, + disableService, + openSettings, +}) => ( + +))); diff --git a/src/components/services/tabs/TabItem.js b/src/components/services/tabs/TabItem.js new file mode 100644 index 000000000..9e03d2e21 --- /dev/null +++ b/src/components/services/tabs/TabItem.js @@ -0,0 +1,136 @@ +import { remote } from 'electron'; +import React, { Component } from 'react'; +import { defineMessages, intlShape } from 'react-intl'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import classnames from 'classnames'; +import { SortableElement } from 'react-sortable-hoc'; + +import ServiceModel from '../../../models/Service'; +import { ctrlKey } from '../../../environment'; + +const { Menu } = remote; + +const messages = defineMessages({ + reload: { + id: 'tabs.item.reload', + defaultMessage: '!!!Reload', + }, + edit: { + id: 'tabs.item.edit', + defaultMessage: '!!!Edit', + }, + disableNotifications: { + id: 'tabs.item.disableNotifications', + defaultMessage: '!!!Disable notifications', + }, + enableNotifications: { + id: 'tabs.item.enableNotification', + defaultMessage: '!!!Enable notifications', + }, + disableService: { + id: 'tabs.item.disableService', + defaultMessage: '!!!Disable Service', + }, + deleteService: { + id: 'tabs.item.deleteService', + defaultMessage: '!!!Delete Service', + }, +}); + +@observer +class TabItem extends Component { + static propTypes = { + service: PropTypes.instanceOf(ServiceModel).isRequired, + clickHandler: PropTypes.func.isRequired, + shortcutIndex: PropTypes.number.isRequired, + reload: PropTypes.func.isRequired, + toggleNotifications: PropTypes.func.isRequired, + openSettings: PropTypes.func.isRequired, + deleteService: PropTypes.func.isRequired, + disableService: PropTypes.func.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + service, + clickHandler, + shortcutIndex, + reload, + toggleNotifications, + deleteService, + disableService, + openSettings, + } = this.props; + const { intl } = this.context; + + + const menuTemplate = [{ + label: service.name || service.recipe.name, + enabled: false, + }, { + type: 'separator', + }, { + label: intl.formatMessage(messages.reload), + click: reload, + }, { + label: intl.formatMessage(messages.edit), + click: () => openSettings({ + path: `services/edit/${service.id}`, + }), + }, { + type: 'separator', + }, { + label: service.isNotificationEnabled + ? intl.formatMessage(messages.disableNotifications) + : intl.formatMessage(messages.enableNotifications), + click: () => toggleNotifications(), + }, { + label: intl.formatMessage(messages.disableService), + click: () => disableService(), + }, { + type: 'separator', + }, { + label: intl.formatMessage(messages.deleteService), + click: () => deleteService(), + }]; + const menu = Menu.buildFromTemplate(menuTemplate); + + return ( +
  • menu.popup(remote.getCurrentWindow())} + data-tip={`${service.name} ${shortcutIndex <= 9 ? `(${ctrlKey}+${shortcutIndex})` : ''}`} + > + + {service.unreadDirectMessageCount > 0 && ( + + {service.unreadDirectMessageCount} + + )} + {service.unreadIndirectMessageCount > 0 + && service.unreadDirectMessageCount === 0 + && service.isIndirectMessageBadgeEnabled && ( + + • + + )} +
  • + ); + } +} + +export default SortableElement(TabItem); diff --git a/src/components/services/tabs/Tabbar.js b/src/components/services/tabs/Tabbar.js new file mode 100644 index 000000000..fdb2c0a59 --- /dev/null +++ b/src/components/services/tabs/Tabbar.js @@ -0,0 +1,77 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; + +import TabBarSortableList from './TabBarSortableList'; + +@observer +export default class TabBar extends Component { + static propTypes = { + services: MobxPropTypes.arrayOrObservableArray.isRequired, + setActive: PropTypes.func.isRequired, + openSettings: PropTypes.func.isRequired, + enableToolTip: PropTypes.func.isRequired, + disableToolTip: PropTypes.func.isRequired, + reorder: PropTypes.func.isRequired, + reload: PropTypes.func.isRequired, + toggleNotifications: PropTypes.func.isRequired, + deleteService: PropTypes.func.isRequired, + updateService: PropTypes.func.isRequired, + } + + onSortEnd = ({ oldIndex, newIndex }) => { + const { + enableToolTip, + reorder, + } = this.props; + + enableToolTip(); + reorder({ oldIndex, newIndex }); + }; + + disableService = ({ serviceId }) => { + const { updateService } = this.props; + + if (serviceId) { + updateService({ + serviceId, + serviceData: { + isEnabled: false, + }, + redirect: false, + }); + } + } + + render() { + const { + services, + setActive, + openSettings, + disableToolTip, + reload, + toggleNotifications, + deleteService, + } = this.props; + + return ( +
    + +
    + ); + } +} 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 ? ( + + ); + } +} diff --git a/src/components/ui/InfoBar.js b/src/components/ui/InfoBar.js new file mode 100644 index 000000000..aea2bd888 --- /dev/null +++ b/src/components/ui/InfoBar.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import classnames from 'classnames'; +import Loader from 'react-loader'; + +// import { oneOrManyChildElements } from '../../prop-types'; +import Appear from '../ui/effects/Appear'; + +@observer +export default class InfoBar extends Component { + static propTypes = { + // eslint-disable-next-line + children: PropTypes.any.isRequired, + onClick: PropTypes.func, + type: PropTypes.string, + className: PropTypes.string, + ctaLabel: PropTypes.string, + ctaLoading: PropTypes.bool, + position: PropTypes.string, + sticky: PropTypes.bool, + onHide: PropTypes.func, + }; + + static defaultProps = { + onClick: () => null, + type: 'primary', + className: '', + ctaLabel: '', + ctaLoading: false, + position: 'bottom', + sticky: false, + onHide: () => null, + }; + + render() { + const { + children, + type, + className, + ctaLabel, + ctaLoading, + onClick, + position, + sticky, + onHide, + } = this.props; + + let transitionName = 'slideUp'; + if (position === 'top') { + transitionName = 'slideDown'; + } + + return ( + +
    + {children} + {ctaLabel && ( + + )} +
    + {!sticky && ( + + )} + {dismissable && ( + +

    + + )} + + )} +
    + {error.code === 'no-payment-session' && ( +

    {intl.formatMessage(messages.paymentSessionError)}

    + )} +
    + {showSkipOption && this.form.$('paymentTier').value === 'skip' ? ( + + ))} + +
    + {React.Children.map(children, (child, i) => ( +
    + {child} +
    + ))} +
    + + ); + } +} diff --git a/src/components/ui/Tabs/index.js b/src/components/ui/Tabs/index.js new file mode 100644 index 000000000..e4adb62c7 --- /dev/null +++ b/src/components/ui/Tabs/index.js @@ -0,0 +1,6 @@ +import Tabs from './Tabs'; +import TabItem from './TabItem'; + +export default Tabs; + +export { TabItem }; diff --git a/src/components/ui/Toggle.js b/src/components/ui/Toggle.js new file mode 100644 index 000000000..62d46393e --- /dev/null +++ b/src/components/ui/Toggle.js @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import classnames from 'classnames'; +import { Field } from 'mobx-react-form'; + +@observer +export default class Toggle extends Component { + static propTypes = { + field: PropTypes.instanceOf(Field).isRequired, + className: PropTypes.string, + showLabel: PropTypes.bool, + }; + + static defaultProps = { + className: '', + showLabel: true, + }; + + onChange(e) { + const { field } = this.props; + + field.onChange(e); + } + + render() { + const { + field, + className, + showLabel, + } = this.props; + + if (field.value === '' && field.default !== '') { + field.value = field.default; + } + + return ( +
    +