summaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/auth/AuthLayout.js88
-rw-r--r--src/components/auth/Import.js168
-rw-r--r--src/components/auth/Invite.js111
-rw-r--r--src/components/auth/Login.js161
-rw-r--r--src/components/auth/Password.js135
-rw-r--r--src/components/auth/Pricing.js130
-rw-r--r--src/components/auth/Signup.js206
-rw-r--r--src/components/auth/Welcome.js69
-rw-r--r--src/components/layout/AppLayout.js148
-rw-r--r--src/components/layout/Sidebar.js75
-rw-r--r--src/components/services/content/ServiceWebview.js73
-rw-r--r--src/components/services/content/Services.js81
-rw-r--r--src/components/services/tabs/TabBarSortableList.js44
-rw-r--r--src/components/services/tabs/TabItem.js136
-rw-r--r--src/components/services/tabs/Tabbar.js77
-rw-r--r--src/components/settings/SettingsLayout.js56
-rw-r--r--src/components/settings/account/AccountDashboard.js286
-rw-r--r--src/components/settings/navigation/SettingsNavigation.js84
-rw-r--r--src/components/settings/recipes/RecipeItem.js34
-rw-r--r--src/components/settings/recipes/RecipesDashboard.js151
-rw-r--r--src/components/settings/services/EditServiceForm.js277
-rw-r--r--src/components/settings/services/ServiceError.js68
-rw-r--r--src/components/settings/services/ServiceItem.js98
-rw-r--r--src/components/settings/services/ServicesDashboard.js155
-rw-r--r--src/components/settings/settings/EditSettingsForm.js148
-rw-r--r--src/components/settings/user/EditUserForm.js145
-rw-r--r--src/components/ui/AppLoader.js15
-rw-r--r--src/components/ui/Button.js78
-rw-r--r--src/components/ui/InfoBar.js88
-rw-r--r--src/components/ui/Infobox.js87
-rw-r--r--src/components/ui/Input.js148
-rw-r--r--src/components/ui/Link.js78
-rw-r--r--src/components/ui/Loader.js41
-rw-r--r--src/components/ui/Radio.js89
-rw-r--r--src/components/ui/SearchInput.js124
-rw-r--r--src/components/ui/Select.js70
-rw-r--r--src/components/ui/Subscription.js265
-rw-r--r--src/components/ui/SubscriptionPopup.js84
-rw-r--r--src/components/ui/Tabs/TabItem.js17
-rw-r--r--src/components/ui/Tabs/Tabs.js69
-rw-r--r--src/components/ui/Tabs/index.js6
-rw-r--r--src/components/ui/Toggle.js67
-rw-r--r--src/components/ui/effects/Appear.js51
43 files changed, 4581 insertions, 0 deletions
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { RouteTransition } from 'react-router-transition';
5import { intlShape } from 'react-intl';
6
7import Link from '../ui/Link';
8import InfoBar from '../ui/InfoBar';
9
10import { oneOrManyChildElements, globalError as globalErrorPropType } from '../../prop-types';
11import globalMessages from '../../i18n/globalMessages';
12
13@observer
14export default class AuthLayout extends Component {
15 static propTypes = {
16 children: oneOrManyChildElements.isRequired,
17 pathname: PropTypes.string.isRequired,
18 error: globalErrorPropType.isRequired,
19 isOnline: PropTypes.bool.isRequired,
20 isAPIHealthy: PropTypes.bool.isRequired,
21 retryHealthCheck: PropTypes.func.isRequired,
22 isHealthCheckLoading: PropTypes.bool.isRequired,
23 };
24
25 static contextTypes = {
26 intl: intlShape,
27 };
28
29 render() {
30 const {
31 children,
32 pathname,
33 error,
34 isOnline,
35 isAPIHealthy,
36 retryHealthCheck,
37 isHealthCheckLoading,
38 } = this.props;
39 const { intl } = this.context;
40
41 return (
42 <div className="auth">
43 {!isOnline && (
44 <InfoBar
45 type="warning"
46 >
47 <span className="mdi mdi-flash" />
48 {intl.formatMessage(globalMessages.notConnectedToTheInternet)}
49 </InfoBar>
50 )}
51 {isOnline && !isAPIHealthy && (
52 <InfoBar
53 type="danger"
54 ctaLabel="Try again"
55 ctaLoading={isHealthCheckLoading}
56 sticky
57 onClick={retryHealthCheck}
58 >
59 <span className="mdi mdi-flash" />
60 {intl.formatMessage(globalMessages.APIUnhealthy)}
61 </InfoBar>
62 )}
63 <div className="auth__layout">
64 <RouteTransition
65 pathname={pathname}
66 atEnter={{ opacity: 0 }}
67 atLeave={{ opacity: 0 }}
68 atActive={{ opacity: 1 }}
69 mapStyles={styles => ({
70 transform: `translateX(${styles.translateX}%)`,
71 opacity: styles.opacity,
72 })}
73 component="span"
74 >
75 {/* Inject globalError into children */}
76 {React.cloneElement(children, {
77 error,
78 })}
79 </RouteTransition>
80 </div>
81 {/* </div> */}
82 <Link to="https://adlk.io" className="auth__adlk" target="_blank">
83 <img src="./assets/images/adlk.svg" alt="" />
84 </Link>
85 </div>
86 );
87 }
88}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6import classnames from 'classnames';
7
8import Form from '../../lib/Form';
9import Toggle from '../ui/Toggle';
10import Button from '../ui/Button';
11
12const messages = defineMessages({
13 headline: {
14 id: 'import.headline',
15 defaultMessage: '!!!Import your Franz 4 services',
16 },
17 notSupportedHeadline: {
18 id: 'import.notSupportedHeadline',
19 defaultMessage: '!!!Services not yet supported in Franz 5',
20 },
21 submitButtonLabel: {
22 id: 'import.submit.label',
23 defaultMessage: '!!!Import {count} services',
24 },
25 skipButtonLabel: {
26 id: 'import.skip.label',
27 defaultMessage: '!!!I want add services manually',
28 },
29});
30
31@observer
32export default class Import extends Component {
33 static propTypes = {
34 services: MobxPropTypes.arrayOrObservableArray.isRequired,
35 onSubmit: PropTypes.func.isRequired,
36 isSubmitting: PropTypes.bool.isRequired,
37 inviteRoute: PropTypes.string.isRequired,
38 };
39
40 static contextTypes = {
41 intl: intlShape,
42 };
43
44 prepareForm() {
45 const { services } = this.props;
46
47 const config = {
48 fields: {
49 import: [...services.filter(s => s.recipe).map(s => ({
50 add: {
51 default: true,
52 options: s,
53 },
54 }))],
55 },
56 };
57
58 return new Form(config, this.context.intl);
59 }
60
61 submit(e) {
62 const { services } = this.props;
63 e.preventDefault();
64 this.form.submit({
65 onSuccess: (form) => {
66 const servicesImport = form.values().import
67 .map((value, i) => !value.add || services.filter(s => s.recipe)[i])
68 .filter(s => typeof s !== 'boolean');
69
70 this.props.onSubmit({ services: servicesImport });
71 },
72 onError: () => {},
73 });
74 }
75
76 render() {
77 this.form = this.prepareForm();
78 const { intl } = this.context;
79 const { services, isSubmitting, inviteRoute } = this.props;
80
81 const availableServices = services.filter(s => s.recipe);
82 const unavailableServices = services.filter(s => !s.recipe);
83
84 return (
85 <div className="auth__scroll-container">
86 <div className="auth__container auth__container--signup">
87 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
88 <img
89 src="./assets/images/logo.svg"
90 className="auth__logo"
91 alt=""
92 />
93 <h1>
94 {intl.formatMessage(messages.headline)}
95 </h1>
96 <table className="service-table available-services">
97 <tbody>
98 {this.form.$('import').map((service, i) => (
99 <tr
100 key={service.id}
101 className="service-table__row"
102 onClick={() => service.$('add').set(!service.$('add').value)}
103 >
104 <td className="service-table__toggle">
105 <Toggle
106 field={service.$('add')}
107 showLabel={false}
108 />
109 </td>
110 <td className="service-table__column-icon">
111 <img
112 src={availableServices[i].custom_icon || availableServices[i].recipe.icons.svg}
113 className={classnames({
114 'service-table__icon': true,
115 'has-custom-icon': availableServices[i].custom_icon,
116 })}
117 alt=""
118 />
119 </td>
120 <td className="service-table__column-name">
121 {availableServices[i].name !== ''
122 ? availableServices[i].name
123 : availableServices[i].recipe.name}
124 </td>
125 </tr>
126 ))}
127 </tbody>
128 </table>
129 {unavailableServices.length > 0 && (
130 <div className="unavailable-services">
131 <strong>{intl.formatMessage(messages.notSupportedHeadline)}</strong>
132 <p>
133 {services.filter(s => !s.recipe).map((service, i) => (
134 <span key={service.id}>
135 {service.name !== '' ? service.name : service.service}
136 {unavailableServices.length > i + 1 ? ', ' : ''}
137 </span>
138 ))}
139 </p>
140 </div>
141 )}
142
143 {isSubmitting ? (
144 <Button
145 className="auth__button is-loading"
146 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
147 loaded={false}
148 disabled
149 />
150 ) : (
151 <Button
152 type="submit"
153 className="auth__button"
154 label={intl.formatMessage(messages.submitButtonLabel)}
155 />
156 )}
157 <Link
158 to={inviteRoute}
159 className="franz-form__button franz-form__button--secondary auth__button auth__button--skip"
160 >
161 {intl.formatMessage(messages.skipButtonLabel)}
162 </Link>
163 </form>
164 </div>
165 </div>
166 );
167 }
168}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6
7import Form from '../../lib/Form';
8import { email } from '../../helpers/validation-helpers';
9import Input from '../ui/Input';
10import Button from '../ui/Button';
11
12const messages = defineMessages({
13 headline: {
14 id: 'invite.headline.friends',
15 defaultMessage: '!!!Invite 3 of your friends or colleagues',
16 },
17 nameLabel: {
18 id: 'invite.name.label',
19 defaultMessage: '!!!Name',
20 },
21 emailLabel: {
22 id: 'invite.email.label',
23 defaultMessage: '!!!Email address',
24 },
25 submitButtonLabel: {
26 id: 'invite.submit.label',
27 defaultMessage: '!!!Send invites',
28 },
29 skipButtonLabel: {
30 id: 'invite.skip.label',
31 defaultMessage: '!!!I want to do this later',
32 },
33});
34
35@observer
36export default class Invite extends Component {
37 static propTypes = {
38 onSubmit: PropTypes.func.isRequired,
39 };
40
41 static contextTypes = {
42 intl: intlShape,
43 };
44
45 form = new Form({
46 fields: {
47 invite: [...Array(3).fill({
48 name: {
49 label: this.context.intl.formatMessage(messages.nameLabel),
50 // value: '',
51 placeholder: this.context.intl.formatMessage(messages.nameLabel),
52 },
53 email: {
54 label: this.context.intl.formatMessage(messages.emailLabel),
55 // value: '',
56 validate: [email],
57 placeholder: this.context.intl.formatMessage(messages.emailLabel),
58 },
59 })],
60 },
61 }, this.context.intl);
62
63 submit(e) {
64 e.preventDefault();
65 this.form.submit({
66 onSuccess: (form) => {
67 this.props.onSubmit({ invites: form.values().invite });
68 },
69 onError: () => {},
70 });
71 }
72
73 render() {
74 const { form } = this;
75 const { intl } = this.context;
76
77 return (
78 <div className="auth__container auth__container--signup">
79 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
80 <img
81 src="./assets/images/logo.svg"
82 className="auth__logo"
83 alt=""
84 />
85 <h1>
86 {intl.formatMessage(messages.headline)}
87 </h1>
88 {form.$('invite').map(invite => (
89 <div className="grid" key={invite.key}>
90 <div className="grid__row">
91 <Input field={invite.$('name')} showLabel={false} />
92 <Input field={invite.$('email')} showLabel={false} />
93 </div>
94 </div>
95 ))}
96 <Button
97 type="submit"
98 className="auth__button"
99 label={intl.formatMessage(messages.submitButtonLabel)}
100 />
101 <Link
102 to="/"
103 className="franz-form__button franz-form__button--secondary auth__button auth__button--skip"
104 >
105 {intl.formatMessage(messages.skipButtonLabel)}
106 </Link>
107 </form>
108 </div>
109 );
110 }
111}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import { required, email } from '../../helpers/validation-helpers';
8import Input from '../ui/Input';
9import Button from '../ui/Button';
10import Link from '../ui/Link';
11
12import { globalError as globalErrorPropType } from '../../prop-types';
13
14// import Appear from '../ui/effects/Appear';
15
16const messages = defineMessages({
17 headline: {
18 id: 'login.headline',
19 defaultMessage: '!!!Sign in',
20 },
21 emailLabel: {
22 id: 'login.email.label',
23 defaultMessage: '!!!Email address',
24 },
25 passwordLabel: {
26 id: 'login.password.label',
27 defaultMessage: '!!!Password',
28 },
29 submitButtonLabel: {
30 id: 'login.submit.label',
31 defaultMessage: '!!!Sign in',
32 },
33 invalidCredentials: {
34 id: 'login.invalidCredentials',
35 defaultMessage: '!!!Email or password not valid',
36 },
37 tokenExpired: {
38 id: 'login.tokenExpired',
39 defaultMessage: '!!!Your session expired, please login again.',
40 },
41 serverLogout: {
42 id: 'login.serverLogout',
43 defaultMessage: '!!!Your session expired, please login again.',
44 },
45 signupLink: {
46 id: 'login.link.signup',
47 defaultMessage: '!!!Create a free account',
48 },
49 passwordLink: {
50 id: 'login.link.password',
51 defaultMessage: '!!!Forgot password',
52 },
53});
54
55@observer
56export default class Login extends Component {
57 static propTypes = {
58 onSubmit: PropTypes.func.isRequired,
59 isSubmitting: PropTypes.bool.isRequired,
60 isTokenExpired: PropTypes.bool.isRequired,
61 isServerLogout: PropTypes.bool.isRequired,
62 signupRoute: PropTypes.string.isRequired,
63 passwordRoute: PropTypes.string.isRequired,
64 error: globalErrorPropType.isRequired,
65 };
66
67 static contextTypes = {
68 intl: intlShape,
69 };
70
71 form = new Form({
72 fields: {
73 email: {
74 label: this.context.intl.formatMessage(messages.emailLabel),
75 value: '',
76 validate: [required, email],
77 },
78 password: {
79 label: this.context.intl.formatMessage(messages.passwordLabel),
80 value: '',
81 validate: [required],
82 type: 'password',
83 },
84 },
85 }, this.context.intl);
86
87 submit(e) {
88 e.preventDefault();
89 this.form.submit({
90 onSuccess: (form) => {
91 this.props.onSubmit(form.values());
92 },
93 onError: () => {},
94 });
95 }
96
97 emailField = null;
98
99 render() {
100 const { form } = this;
101 const { intl } = this.context;
102 const {
103 isSubmitting,
104 isTokenExpired,
105 isServerLogout,
106 signupRoute,
107 passwordRoute,
108 error,
109 } = this.props;
110
111 return (
112 <div className="auth__container">
113 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
114 <img
115 src="./assets/images/logo.svg"
116 className="auth__logo"
117 alt=""
118 />
119 <h1>{intl.formatMessage(messages.headline)}</h1>
120 {isTokenExpired && (
121 <p className="error-message center">{intl.formatMessage(messages.tokenExpired)}</p>
122 )}
123 {isServerLogout && (
124 <p className="error-message center">{intl.formatMessage(messages.serverLogout)}</p>
125 )}
126 <Input
127 field={form.$('email')}
128 ref={(element) => { this.emailField = element; }}
129 focus
130 />
131 <Input
132 field={form.$('password')}
133 showPasswordToggle
134 />
135 {error.code === 'invalid-credentials' && (
136 <p className="error-message center">{intl.formatMessage(messages.invalidCredentials)}</p>
137 )}
138 {isSubmitting ? (
139 <Button
140 className="auth__button is-loading"
141 buttonType="secondary"
142 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
143 loaded={false}
144 disabled
145 />
146 ) : (
147 <Button
148 type="submit"
149 className="auth__button"
150 label={intl.formatMessage(messages.submitButtonLabel)}
151 />
152 )}
153 </form>
154 <div className="auth__links">
155 <Link to={signupRoute}>{intl.formatMessage(messages.signupLink)}</Link>
156 <Link to={passwordRoute}>{intl.formatMessage(messages.passwordLink)}</Link>
157 </div>
158 </div>
159 );
160 }
161}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import { required, email } from '../../helpers/validation-helpers';
8import Input from '../ui/Input';
9import Button from '../ui/Button';
10import Link from '../ui/Link';
11import Infobox from '../ui/Infobox';
12
13const messages = defineMessages({
14 headline: {
15 id: 'password.headline',
16 defaultMessage: '!!!Forgot password',
17 },
18 emailLabel: {
19 id: 'password.email.label',
20 defaultMessage: '!!!Email address',
21 },
22 submitButtonLabel: {
23 id: 'password.submit.label',
24 defaultMessage: '!!!Submit',
25 },
26 successInfo: {
27 id: 'password.successInfo',
28 defaultMessage: '!!!Your new password was sent to your email address',
29 },
30 noUser: {
31 id: 'password.noUser',
32 defaultMessage: '!!!No user affiliated with that email address',
33 },
34 signupLink: {
35 id: 'password.link.signup',
36 defaultMessage: '!!!Create a free account',
37 },
38 loginLink: {
39 id: 'password.link.login',
40 defaultMessage: '!!!Sign in to your account',
41 },
42});
43
44@observer
45export default class Password extends Component {
46 static propTypes = {
47 onSubmit: PropTypes.func.isRequired,
48 isSubmitting: PropTypes.bool.isRequired,
49 signupRoute: PropTypes.string.isRequired,
50 loginRoute: PropTypes.string.isRequired,
51 status: MobxPropTypes.arrayOrObservableArray.isRequired,
52 };
53
54 static contextTypes = {
55 intl: intlShape,
56 };
57
58 form = new Form({
59 fields: {
60 email: {
61 label: this.context.intl.formatMessage(messages.emailLabel),
62 value: '',
63 validate: [required, email],
64 },
65 },
66 }, this.context.intl);
67
68 submit(e) {
69 e.preventDefault();
70 this.form.submit({
71 onSuccess: (form) => {
72 this.props.onSubmit(form.values());
73 },
74 onError: () => {},
75 });
76 }
77
78 render() {
79 const { form } = this;
80 const { intl } = this.context;
81 const {
82 isSubmitting,
83 signupRoute,
84 loginRoute,
85 status,
86 } = this.props;
87
88 return (
89 <div className="auth__container">
90 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
91 <img
92 src="./assets/images/logo.svg"
93 className="auth__logo"
94 alt=""
95 />
96 <h1>{intl.formatMessage(messages.headline)}</h1>
97 {status.length > 0 && status.includes('sent') && (
98 <Infobox
99 type="success"
100 icon="checkbox-marked-circle-outline"
101 >
102 {intl.formatMessage(messages.successInfo)}
103 </Infobox>
104 )}
105 <Input
106 field={form.$('email')}
107 focus
108 />
109 {status.length > 0 && status.includes('no-user') && (
110 <p className="error-message center">{intl.formatMessage(messages.noUser)}</p>
111 )}
112 {isSubmitting ? (
113 <Button
114 className="auth__button is-loading"
115 buttonType="secondary"
116 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
117 loaded={false}
118 disabled
119 />
120 ) : (
121 <Button
122 type="submit"
123 className="auth__button"
124 label={intl.formatMessage(messages.submitButtonLabel)}
125 />
126 )}
127 </form>
128 <div className="auth__links">
129 <Link to={loginRoute}>{intl.formatMessage(messages.loginLink)}</Link>
130 <Link to={signupRoute}>{intl.formatMessage(messages.signupLink)}</Link>
131 </div>
132 </div>
133 );
134 }
135}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5// import { Link } from 'react-router';
6
7// import Button from '../ui/Button';
8import Loader from '../ui/Loader';
9import Appear from '../ui/effects/Appear';
10import SubscriptionForm from '../../containers/ui/SubscriptionFormScreen';
11
12const messages = defineMessages({
13 headline: {
14 id: 'pricing.headline',
15 defaultMessage: '!!!Support Franz',
16 },
17 monthlySupportLabel: {
18 id: 'pricing.support.label',
19 defaultMessage: '!!!Select your support plan',
20 },
21 submitButtonLabel: {
22 id: 'pricing.submit.label',
23 defaultMessage: '!!!Support the development of Franz',
24 },
25 skipPayment: {
26 id: 'pricing.link.skipPayment',
27 defaultMessage: '!!!I don\'t want to support the development of Franz.',
28 },
29});
30
31@observer
32export default class Signup extends Component {
33 static propTypes = {
34 donor: MobxPropTypes.objectOrObservableObject.isRequired,
35 isLoading: PropTypes.bool.isRequired,
36 isLoadingUser: PropTypes.bool.isRequired,
37 onCloseSubscriptionWindow: PropTypes.func.isRequired,
38 skipAction: PropTypes.func.isRequired,
39 };
40
41 static contextTypes = {
42 intl: intlShape,
43 };
44
45 render() {
46 const {
47 donor,
48 isLoading,
49 isLoadingUser,
50 onCloseSubscriptionWindow,
51 skipAction,
52 } = this.props;
53 const { intl } = this.context;
54
55 return (
56 <div className="auth__scroll-container">
57 <div className="auth__container auth__container--signup">
58 <form className="franz-form auth__form">
59 <img
60 src="./assets/images/sm.png"
61 className="auth__logo auth__logo--sm"
62 alt=""
63 />
64 <h1>{intl.formatMessage(messages.headline)}</h1>
65 <div className="auth__letter">
66 {isLoadingUser && (
67 <p>Loading</p>
68 )}
69 {!isLoadingUser && (
70 donor.amount ? (
71 <span>
72 <p>
73 Thank you so much for your previous donation of <strong>$ {donor.amount}</strong>.
74 <br />
75 Your support allowed us to get where we are today.
76 <br />
77 </p>
78 <p>
79 As an early supporter, you get <strong>a lifetime premium supporter license</strong> without any
80 additional charges.
81 </p>
82 <p>
83 However, If you want to keep supporting us, you are more than welcome to subscribe to a plan.
84 <br /><br />
85 </p>
86 </span>
87 ) : (
88 <span>
89 <p>
90 We built Franz with a lot of effort, manpower and love,
91 to bring you the best messaging experience.
92 <br />
93 </p>
94 <p>
95 Getting a Franz Premium Supporter License will allow us to keep improving Franz for you.
96 </p>
97 </span>
98 )
99 )}
100 <p>
101 Thanks for being a hero.
102 </p>
103 <p>
104 <strong>Stefan Malzner</strong>
105 </p>
106 </div>
107 <Loader loaded={!isLoading}>
108 <Appear transitionName="slideDown">
109 <span className="label">{intl.formatMessage(messages.monthlySupportLabel)}</span>
110 <SubscriptionForm
111 onCloseWindow={onCloseSubscriptionWindow}
112 showSkipOption
113 skipAction={skipAction}
114 hideInfo={Boolean(donor.amount)}
115 skipButtonLabel={intl.formatMessage(messages.skipPayment)}
116 />
117 {/* <Link
118 to={inviteRoute}
119 className="franz-form__button franz-form__button--secondary auth__button auth__button--skip"
120 >
121 {intl.formatMessage(messages.skipPayment)}
122 </Link> */}
123 </Appear>
124 </Loader>
125 </form>
126 </div>
127 </div>
128 );
129 }
130}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import { required, email, minLength } from '../../helpers/validation-helpers';
8import Input from '../ui/Input';
9import Radio from '../ui/Radio';
10import Button from '../ui/Button';
11import Link from '../ui/Link';
12
13import { globalError as globalErrorPropType } from '../../prop-types';
14
15const messages = defineMessages({
16 headline: {
17 id: 'signup.headline',
18 defaultMessage: '!!!Sign up',
19 },
20 firstnameLabel: {
21 id: 'signup.firstname.label',
22 defaultMessage: '!!!Firstname',
23 },
24 lastnameLabel: {
25 id: 'signup.lastname.label',
26 defaultMessage: '!!!Lastname',
27 },
28 emailLabel: {
29 id: 'signup.email.label',
30 defaultMessage: '!!!Email address',
31 },
32 companyLabel: {
33 id: 'signup.company.label',
34 defaultMessage: '!!!Company',
35 },
36 passwordLabel: {
37 id: 'signup.password.label',
38 defaultMessage: '!!!Password',
39 },
40 legalInfo: {
41 id: 'signup.legal.info',
42 defaultMessage: '!!!By creating a Franz account you accept the',
43 },
44 terms: {
45 id: 'signup.legal.terms',
46 defaultMessage: '!!!Terms of service',
47 },
48 privacy: {
49 id: 'signup.legal.privacy',
50 defaultMessage: '!!!Privacy Statement',
51 },
52 submitButtonLabel: {
53 id: 'signup.submit.label',
54 defaultMessage: '!!!Create account',
55 },
56 loginLink: {
57 id: 'signup.link.login',
58 defaultMessage: '!!!Already have an account, sign in?',
59 },
60 emailDuplicate: {
61 id: 'signup.emailDuplicate',
62 defaultMessage: '!!!A user with that email address already exists',
63 },
64});
65
66@observer
67export default class Signup extends Component {
68 static propTypes = {
69 onSubmit: PropTypes.func.isRequired,
70 isSubmitting: PropTypes.bool.isRequired,
71 loginRoute: PropTypes.string.isRequired,
72 error: globalErrorPropType.isRequired,
73 };
74
75 static contextTypes = {
76 intl: intlShape,
77 };
78
79 form = new Form({
80 fields: {
81 accountType: {
82 value: 'individual',
83 validate: [required],
84 options: [{
85 value: 'individual',
86 label: 'Individual',
87 }, {
88 value: 'non-profit',
89 label: 'Non-Profit',
90 }, {
91 value: 'company',
92 label: 'Company',
93 }],
94 },
95 firstname: {
96 label: this.context.intl.formatMessage(messages.firstnameLabel),
97 value: '',
98 validate: [required],
99 },
100 lastname: {
101 label: this.context.intl.formatMessage(messages.lastnameLabel),
102 value: '',
103 validate: [required],
104 },
105 email: {
106 label: this.context.intl.formatMessage(messages.emailLabel),
107 value: '',
108 validate: [required, email],
109 },
110 organization: {
111 label: this.context.intl.formatMessage(messages.companyLabel),
112 value: '', // TODO: make required when accountType: company
113 },
114 password: {
115 label: this.context.intl.formatMessage(messages.passwordLabel),
116 value: '',
117 validate: [required, minLength(6)],
118 type: 'password',
119 },
120 },
121 }, this.context.intl);
122
123 submit(e) {
124 e.preventDefault();
125 this.form.submit({
126 onSuccess: (form) => {
127 this.props.onSubmit(form.values());
128 },
129 onError: () => {},
130 });
131 }
132
133 render() {
134 const { form } = this;
135 const { intl } = this.context;
136 const { isSubmitting, loginRoute, error } = this.props;
137
138 return (
139 <div className="auth__scroll-container">
140 <div className="auth__container auth__container--signup">
141 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
142 <img
143 src="./assets/images/logo.svg"
144 className="auth__logo"
145 alt=""
146 />
147 <h1>{intl.formatMessage(messages.headline)}</h1>
148 <Radio field={form.$('accountType')} showLabel={false} />
149 <div className="grid__row">
150 <Input field={form.$('firstname')} focus />
151 <Input field={form.$('lastname')} />
152 </div>
153 <Input field={form.$('email')} />
154 <Input
155 field={form.$('password')}
156 showPasswordToggle
157 scorePassword
158 />
159 {form.$('accountType').value === 'company' && (
160 <Input field={form.$('organization')} />
161 )}
162 {error.code === 'email-duplicate' && (
163 <p className="error-message center">{intl.formatMessage(messages.emailDuplicate)}</p>
164 )}
165 {isSubmitting ? (
166 <Button
167 className="auth__button is-loading"
168 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
169 loaded={false}
170 disabled
171 />
172 ) : (
173 <Button
174 type="submit"
175 className="auth__button"
176 label={intl.formatMessage(messages.submitButtonLabel)}
177 />
178 )}
179 <p className="legal">
180 {intl.formatMessage(messages.legalInfo)}
181 <br />
182 <Link
183 to="http://meetfranz.com/terms"
184 target="_blank"
185 className="link"
186 >
187 {intl.formatMessage(messages.terms)}
188 </Link>
189 &nbsp;&amp;&nbsp;
190 <Link
191 to="http://meetfranz.com/privacy"
192 target="_blank"
193 className="link"
194 >
195 {intl.formatMessage(messages.privacy)}
196 </Link>.
197 </p>
198 </form>
199 <div className="auth__links">
200 <Link to={loginRoute}>{intl.formatMessage(messages.loginLink)}</Link>
201 </div>
202 </div>
203 </div>
204 );
205 }
206}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Link from '../ui/Link';
7
8const messages = defineMessages({
9 signupButton: {
10 id: 'welcome.signupButton',
11 defaultMessage: '!!!Create a free account',
12 },
13 loginButton: {
14 id: 'welcome.loginButton',
15 defaultMessage: '!!!Login to your account',
16 },
17});
18
19@observer
20export default class Login extends Component {
21 static propTypes = {
22 loginRoute: PropTypes.string.isRequired,
23 signupRoute: PropTypes.string.isRequired,
24 recipes: MobxPropTypes.arrayOrObservableArray.isRequired,
25 };
26
27 static contextTypes = {
28 intl: intlShape,
29 };
30
31 render() {
32 const { intl } = this.context;
33 const {
34 loginRoute,
35 signupRoute,
36 recipes,
37 } = this.props;
38
39 return (
40 <div className="welcome">
41 <div className="welcome__content">
42 <img src="./assets/images/logo.svg" className="welcome__logo" alt="" />
43 {/* <img src="./assets/images/welcome.png" className="welcome__services" alt="" /> */}
44 <div className="welcome__text">
45 <h1>Franz</h1>
46 </div>
47 </div>
48 <div className="welcome__buttons">
49 <Link to={signupRoute} className="button">
50 {intl.formatMessage(messages.signupButton)}
51 </Link>
52 <Link to={loginRoute} className="button">
53 {intl.formatMessage(messages.loginButton)}
54 </Link>
55 </div>
56 <div className="welcome__featured-services">
57 {recipes.map(recipe => (
58 <img
59 key={recipe.id}
60 src={recipe.icons.svg}
61 className="welcome__featured-service"
62 alt=""
63 />
64 ))}
65 </div>
66 </div>
67 );
68 }
69}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import InfoBar from '../ui/InfoBar';
7import globalMessages from '../../i18n/globalMessages';
8
9function createMarkup(HTMLString) {
10 return { __html: HTMLString };
11}
12
13const messages = defineMessages({
14 servicesUpdated: {
15 id: 'infobar.servicesUpdated',
16 defaultMessage: '!!!Your services have been updated.',
17 },
18 updateAvailable: {
19 id: 'infobar.updateAvailable',
20 defaultMessage: '!!!A new update for Franz is available.',
21 },
22 buttonReloadServices: {
23 id: 'infobar.buttonReloadServices',
24 defaultMessage: '!!!Reload services',
25 },
26 buttonInstallUpdate: {
27 id: 'infobar.buttonInstallUpdate',
28 defaultMessage: '!!!Restart & install update',
29 },
30 requiredRequestsFailed: {
31 id: 'infobar.requiredRequestsFailed',
32 defaultMessage: '!!!Could not load services and user information',
33 },
34});
35
36@observer
37export default class AppLayout extends Component {
38 static propTypes = {
39 sidebar: PropTypes.element.isRequired,
40 services: PropTypes.element.isRequired,
41 children: PropTypes.element,
42 news: MobxPropTypes.arrayOrObservableArray.isRequired,
43 isOnline: PropTypes.bool.isRequired,
44 showServicesUpdatedInfoBar: PropTypes.bool.isRequired,
45 appUpdateIsDownloaded: PropTypes.bool.isRequired,
46 removeNewsItem: PropTypes.func.isRequired,
47 reloadServicesAfterUpdate: PropTypes.func.isRequired,
48 installAppUpdate: PropTypes.func.isRequired,
49 showRequiredRequestsError: PropTypes.bool.isRequired,
50 areRequiredRequestsSuccessful: PropTypes.bool.isRequired,
51 retryRequiredRequests: PropTypes.func.isRequired,
52 areRequiredRequestsLoading: PropTypes.bool.isRequired,
53 };
54
55 static defaultProps = {
56 children: [],
57 };
58
59 static contextTypes = {
60 intl: intlShape,
61 };
62
63 render() {
64 const {
65 sidebar,
66 services,
67 children,
68 isOnline,
69 news,
70 showServicesUpdatedInfoBar,
71 appUpdateIsDownloaded,
72 removeNewsItem,
73 reloadServicesAfterUpdate,
74 installAppUpdate,
75 showRequiredRequestsError,
76 areRequiredRequestsSuccessful,
77 retryRequiredRequests,
78 areRequiredRequestsLoading,
79 } = this.props;
80
81 const { intl } = this.context;
82
83 return (
84 <div>
85 <div className="app">
86 {sidebar}
87 <div className="app__service">
88 {news.length > 0 && news.map(item => (
89 <InfoBar
90 key={item.id}
91 position="top"
92 type={item.type}
93 sticky={item.sticky}
94 onHide={() => removeNewsItem({ newsId: item.id })}
95 >
96 <span dangerouslySetInnerHTML={createMarkup(item.message)} />
97 </InfoBar>
98 ))}
99 {!isOnline && (
100 <InfoBar
101 type="danger"
102 >
103 <span className="mdi mdi-flash" />
104 {intl.formatMessage(globalMessages.notConnectedToTheInternet)}
105 </InfoBar>
106 )}
107 {!areRequiredRequestsSuccessful && showRequiredRequestsError && (
108 <InfoBar
109 type="danger"
110 ctaLabel="Try again"
111 ctaLoading={areRequiredRequestsLoading}
112 sticky
113 onClick={retryRequiredRequests}
114 >
115 <span className="mdi mdi-flash" />
116 {intl.formatMessage(messages.requiredRequestsFailed)}
117 </InfoBar>
118 )}
119 {showServicesUpdatedInfoBar && (
120 <InfoBar
121 type="primary"
122 ctaLabel={intl.formatMessage(messages.buttonReloadServices)}
123 onClick={reloadServicesAfterUpdate}
124 sticky
125 >
126 <span className="mdi mdi-power-plug" />
127 {intl.formatMessage(messages.servicesUpdated)}
128 </InfoBar>
129 )}
130 {appUpdateIsDownloaded && (
131 <InfoBar
132 type="primary"
133 ctaLabel={intl.formatMessage(messages.buttonInstallUpdate)}
134 onClick={installAppUpdate}
135 sticky
136 >
137 <span className="mdi mdi-information" />
138 {intl.formatMessage(messages.updateAvailable)}
139 </InfoBar>
140 )}
141 {services}
142 </div>
143 </div>
144 {children}
145 </div>
146 );
147 }
148}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import ReactTooltip from 'react-tooltip';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Tabbar from '../services/tabs/Tabbar';
7import { ctrlKey } from '../../environment';
8
9const messages = defineMessages({
10 settings: {
11 id: 'sidebar.settings',
12 defaultMessage: '!!!Settings',
13 },
14});
15
16export default class Sidebar extends Component {
17 static propTypes = {
18 openSettings: PropTypes.func.isRequired,
19 isPremiumUser: PropTypes.bool,
20 }
21
22 static defaultProps = {
23 isPremiumUser: false,
24 }
25
26 static contextTypes = {
27 intl: intlShape,
28 };
29
30 state = {
31 tooltipEnabled: true,
32 };
33
34 enableToolTip() {
35 this.setState({ tooltipEnabled: true });
36 }
37
38 disableToolTip() {
39 this.setState({ tooltipEnabled: false });
40 }
41
42 render() {
43 const { openSettings, isPremiumUser } = this.props;
44 const { intl } = this.context;
45 return (
46 <div className="sidebar">
47 <Tabbar
48 {...this.props}
49 enableToolTip={() => this.enableToolTip()}
50 disableToolTip={() => this.disableToolTip()}
51 />
52 <button
53 onClick={openSettings}
54 className="sidebar__settings-button"
55 data-tip={`Settings (${ctrlKey}+,)`}
56 >
57 {isPremiumUser && (
58 <span className="emoji">
59 <img src="./assets/images/emoji/star.png" alt="" />
60 </span>
61 )}
62 <img
63 src="./assets/images/logo.svg"
64 className="sidebar__logo"
65 alt=""
66 />
67 {intl.formatMessage(messages.settings)}
68 </button>
69 {this.state.tooltipEnabled && (
70 <ReactTooltip place="right" type="dark" effect="solid" />
71 )}
72 </div>
73 );
74 }
75}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { autorun } from 'mobx';
4import { observer } from 'mobx-react';
5import Webview from 'react-electron-web-view';
6import classnames from 'classnames';
7
8import ServiceModel from '../../../models/Service';
9
10@observer
11export default class ServiceWebview extends Component {
12 static propTypes = {
13 service: PropTypes.instanceOf(ServiceModel).isRequired,
14 setWebviewReference: PropTypes.func.isRequired,
15 };
16
17 static defaultProps = {
18 isActive: false,
19 };
20
21 state = {
22 forceRepaint: false,
23 };
24
25 componentDidMount() {
26 autorun(() => {
27 if (this.props.service.isActive) {
28 this.setState({ forceRepaint: true });
29 setTimeout(() => {
30 this.setState({ forceRepaint: false });
31 }, 100);
32 }
33 });
34 }
35
36 webview = null;
37
38 render() {
39 const {
40 service,
41 setWebviewReference,
42 } = this.props;
43
44 const webviewClasses = classnames({
45 services__webview: true,
46 'is-active': service.isActive,
47 'services__webview--force-repaint': this.state.forceRepaint,
48 });
49
50 return (
51 <div className={webviewClasses}>
52 <Webview
53 ref={(element) => { this.webview = element; }}
54
55 autosize
56 src={service.url}
57 preload="./webview/plugin.js"
58 partition={`persist:service-${service.id}`}
59
60 onDidAttach={() => setWebviewReference({
61 serviceId: service.id,
62 webview: this.webview.view,
63 })}
64
65 useragent={service.userAgent}
66
67 disablewebsecurity
68 allowpopups
69 />
70 </div>
71 );
72 }
73}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { Link } from 'react-router';
5import { defineMessages, intlShape } from 'react-intl';
6
7import Webview from './ServiceWebview';
8import Appear from '../../ui/effects/Appear';
9
10const messages = defineMessages({
11 welcome: {
12 id: 'services.welcome',
13 defaultMessage: '!!!Welcome to Franz',
14 },
15 getStarted: {
16 id: 'services.getStarted',
17 defaultMessage: '!!!Get started',
18 },
19});
20
21@observer
22export default class Services extends Component {
23 static propTypes = {
24 services: MobxPropTypes.arrayOrObservableArray.isRequired,
25 setWebviewReference: PropTypes.func.isRequired,
26 handleIPCMessage: PropTypes.func.isRequired,
27 openWindow: PropTypes.func.isRequired,
28 };
29
30 static defaultProps = {
31 services: [],
32 activeService: '',
33 };
34
35 static contextTypes = {
36 intl: intlShape,
37 };
38
39 render() {
40 const {
41 services,
42 handleIPCMessage,
43 setWebviewReference,
44 openWindow,
45 } = this.props;
46 const { intl } = this.context;
47
48 return (
49 <div className="services">
50 {services.length === 0 && (
51 <Appear
52 timeout={1500}
53 transitionName="slideUp"
54 >
55 <div className="services__no-service">
56 <img src="./assets/images/logo.svg" alt="" />
57 <h1>{intl.formatMessage(messages.welcome)}</h1>
58 <Appear
59 timeout={300}
60 transitionName="slideUp"
61 >
62 <Link to="/settings/recipes" className="button">
63 {intl.formatMessage(messages.getStarted)}
64 </Link>
65 </Appear>
66 </div>
67 </Appear>
68 )}
69 {services.map(service => (
70 <Webview
71 key={service.id}
72 service={service}
73 handleIPCMessage={handleIPCMessage}
74 setWebviewReference={setWebviewReference}
75 openWindow={openWindow}
76 />
77 ))}
78 </div>
79 );
80 }
81}
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 @@
1import React from 'react';
2import { observer } from 'mobx-react';
3import { SortableContainer } from 'react-sortable-hoc';
4
5import TabItem from './TabItem';
6import { ctrlKey } from '../../../environment';
7
8export default SortableContainer(observer(({
9 services,
10 setActive,
11 reload,
12 toggleNotifications,
13 deleteService,
14 disableService,
15 openSettings,
16}) => (
17 <ul
18 className="tabs"
19 >
20 {services.map((service, index) => (
21 <TabItem
22 key={service.id}
23 clickHandler={() => setActive({ serviceId: service.id })}
24 service={service}
25 index={index}
26 shortcutIndex={index + 1}
27 reload={() => reload({ serviceId: service.id })}
28 toggleNotifications={() => toggleNotifications({ serviceId: service.id })}
29 deleteService={() => deleteService({ serviceId: service.id })}
30 disableService={() => disableService({ serviceId: service.id })}
31 openSettings={openSettings}
32 />
33 ))}
34 <li>
35 <button
36 className="sidebar__add-service"
37 onClick={() => openSettings({ path: 'recipes' })}
38 data-tip={`Add new service (${ctrlKey}+N)`}
39 >
40 <span className="mdi mdi-plus" />
41 </button>
42 </li>
43 </ul>
44)));
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 @@
1import { remote } from 'electron';
2import React, { Component } from 'react';
3import { defineMessages, intlShape } from 'react-intl';
4import PropTypes from 'prop-types';
5import { observer } from 'mobx-react';
6import classnames from 'classnames';
7import { SortableElement } from 'react-sortable-hoc';
8
9import ServiceModel from '../../../models/Service';
10import { ctrlKey } from '../../../environment';
11
12const { Menu } = remote;
13
14const messages = defineMessages({
15 reload: {
16 id: 'tabs.item.reload',
17 defaultMessage: '!!!Reload',
18 },
19 edit: {
20 id: 'tabs.item.edit',
21 defaultMessage: '!!!Edit',
22 },
23 disableNotifications: {
24 id: 'tabs.item.disableNotifications',
25 defaultMessage: '!!!Disable notifications',
26 },
27 enableNotifications: {
28 id: 'tabs.item.enableNotification',
29 defaultMessage: '!!!Enable notifications',
30 },
31 disableService: {
32 id: 'tabs.item.disableService',
33 defaultMessage: '!!!Disable Service',
34 },
35 deleteService: {
36 id: 'tabs.item.deleteService',
37 defaultMessage: '!!!Delete Service',
38 },
39});
40
41@observer
42class TabItem extends Component {
43 static propTypes = {
44 service: PropTypes.instanceOf(ServiceModel).isRequired,
45 clickHandler: PropTypes.func.isRequired,
46 shortcutIndex: PropTypes.number.isRequired,
47 reload: PropTypes.func.isRequired,
48 toggleNotifications: PropTypes.func.isRequired,
49 openSettings: PropTypes.func.isRequired,
50 deleteService: PropTypes.func.isRequired,
51 disableService: PropTypes.func.isRequired,
52 };
53
54 static contextTypes = {
55 intl: intlShape,
56 };
57
58 render() {
59 const {
60 service,
61 clickHandler,
62 shortcutIndex,
63 reload,
64 toggleNotifications,
65 deleteService,
66 disableService,
67 openSettings,
68 } = this.props;
69 const { intl } = this.context;
70
71
72 const menuTemplate = [{
73 label: service.name || service.recipe.name,
74 enabled: false,
75 }, {
76 type: 'separator',
77 }, {
78 label: intl.formatMessage(messages.reload),
79 click: reload,
80 }, {
81 label: intl.formatMessage(messages.edit),
82 click: () => openSettings({
83 path: `services/edit/${service.id}`,
84 }),
85 }, {
86 type: 'separator',
87 }, {
88 label: service.isNotificationEnabled
89 ? intl.formatMessage(messages.disableNotifications)
90 : intl.formatMessage(messages.enableNotifications),
91 click: () => toggleNotifications(),
92 }, {
93 label: intl.formatMessage(messages.disableService),
94 click: () => disableService(),
95 }, {
96 type: 'separator',
97 }, {
98 label: intl.formatMessage(messages.deleteService),
99 click: () => deleteService(),
100 }];
101 const menu = Menu.buildFromTemplate(menuTemplate);
102
103 return (
104 <li
105 className={classnames({
106 'tab-item': true,
107 'is-active': service.isActive,
108 'has-custom-icon': service.hasCustomIcon,
109 })}
110 onClick={clickHandler}
111 onContextMenu={() => menu.popup(remote.getCurrentWindow())}
112 data-tip={`${service.name} ${shortcutIndex <= 9 ? `(${ctrlKey}+${shortcutIndex})` : ''}`}
113 >
114 <img
115 src={service.icon}
116 className="tab-item__icon"
117 alt=""
118 />
119 {service.unreadDirectMessageCount > 0 && (
120 <span className="tab-item__message-count">
121 {service.unreadDirectMessageCount}
122 </span>
123 )}
124 {service.unreadIndirectMessageCount > 0
125 && service.unreadDirectMessageCount === 0
126 && service.isIndirectMessageBadgeEnabled && (
127 <span className="tab-item__message-count is-indirect">
128
129 </span>
130 )}
131 </li>
132 );
133 }
134}
135
136export 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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4
5import TabBarSortableList from './TabBarSortableList';
6
7@observer
8export default class TabBar extends Component {
9 static propTypes = {
10 services: MobxPropTypes.arrayOrObservableArray.isRequired,
11 setActive: PropTypes.func.isRequired,
12 openSettings: PropTypes.func.isRequired,
13 enableToolTip: PropTypes.func.isRequired,
14 disableToolTip: PropTypes.func.isRequired,
15 reorder: PropTypes.func.isRequired,
16 reload: PropTypes.func.isRequired,
17 toggleNotifications: PropTypes.func.isRequired,
18 deleteService: PropTypes.func.isRequired,
19 updateService: PropTypes.func.isRequired,
20 }
21
22 onSortEnd = ({ oldIndex, newIndex }) => {
23 const {
24 enableToolTip,
25 reorder,
26 } = this.props;
27
28 enableToolTip();
29 reorder({ oldIndex, newIndex });
30 };
31
32 disableService = ({ serviceId }) => {
33 const { updateService } = this.props;
34
35 if (serviceId) {
36 updateService({
37 serviceId,
38 serviceData: {
39 isEnabled: false,
40 },
41 redirect: false,
42 });
43 }
44 }
45
46 render() {
47 const {
48 services,
49 setActive,
50 openSettings,
51 disableToolTip,
52 reload,
53 toggleNotifications,
54 deleteService,
55 } = this.props;
56
57 return (
58 <div>
59 <TabBarSortableList
60 services={services}
61 setActive={setActive}
62 onSortEnd={this.onSortEnd}
63 onSortStart={disableToolTip}
64 reload={reload}
65 toggleNotifications={toggleNotifications}
66 deleteService={deleteService}
67 disableService={this.disableService}
68 openSettings={openSettings}
69 distance={20}
70 axis="y"
71 lockAxis="y"
72 helperClass="is-reordering"
73 />
74 </div>
75 );
76 }
77}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4
5import { oneOrManyChildElements } from '../../prop-types';
6import Appear from '../ui/effects/Appear';
7
8@observer
9export default class SettingsLayout extends Component {
10 static propTypes = {
11 navigation: PropTypes.element.isRequired,
12 children: oneOrManyChildElements.isRequired,
13 closeSettings: PropTypes.func.isRequired,
14 };
15
16 componentWillMount() {
17 document.addEventListener('keydown', this.handleKeyDown.bind(this), false);
18 }
19
20 componentWillUnmount() {
21 document.removeEventListener('keydown', this.handleKeyDown.bind(this), false);
22 }
23
24 handleKeyDown(e) {
25 if (e.keyCode === 27) { // escape key
26 this.props.closeSettings();
27 }
28 }
29
30 render() {
31 const {
32 navigation,
33 children,
34 closeSettings,
35 } = this.props;
36
37 return (
38 <Appear transitionName="fadeIn-fast">
39 <div className="settings-wrapper">
40 <button
41 className="settings-wrapper__action"
42 onClick={closeSettings}
43 />
44 <div className="settings franz-form">
45 {navigation}
46 {children}
47 <button
48 className="settings__close mdi mdi-close"
49 onClick={closeSettings}
50 />
51 </div>
52 </div>
53 </Appear>
54 );
55 }
56}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape, FormattedMessage } from 'react-intl';
5import ReactTooltip from 'react-tooltip';
6import moment from 'moment';
7
8import Loader from '../../ui/Loader';
9import Button from '../../ui/Button';
10import Infobox from '../../ui/Infobox';
11import Link from '../../ui/Link';
12import SubscriptionForm from '../../../containers/ui/SubscriptionFormScreen';
13
14const messages = defineMessages({
15 headline: {
16 id: 'settings.account.headline',
17 defaultMessage: '!!!Account',
18 },
19 headlineSubscription: {
20 id: 'settings.account.headlineSubscription',
21 defaultMessage: '!!!Your Subscription',
22 },
23 headlineUpgrade: {
24 id: 'settings.account.headlineUpgrade',
25 defaultMessage: '!!!Upgrade your Account',
26 },
27 headlineInvoices: {
28 id: 'settings.account.headlineInvoices',
29 defaultMessage: '!!Invoices',
30 },
31 manageSubscriptionButtonLabel: {
32 id: 'settings.account.manageSubscription.label',
33 defaultMessage: '!!!Manage your subscription',
34 },
35 accountTypeBasic: {
36 id: 'settings.account.accountType.basic',
37 defaultMessage: '!!!Basic Account',
38 },
39 accountTypePremium: {
40 id: 'settings.account.accountType.premium',
41 defaultMessage: '!!!Premium Supporter Account',
42 },
43 accountEditButton: {
44 id: 'settings.account.account.editButton',
45 defaultMessage: '!!!Edit Account',
46 },
47 invoiceDownload: {
48 id: 'settings.account.invoiceDownload',
49 defaultMessage: '!!!Download',
50 },
51 userInfoRequestFailed: {
52 id: 'settings.account.userInfoRequestFailed',
53 defaultMessage: '!!!Could not load user information',
54 },
55 tryReloadUserInfoRequest: {
56 id: 'settings.account.tryReloadUserInfoRequest',
57 defaultMessage: '!!!Try again',
58 },
59 miningActive: {
60 id: 'settings.account.mining.active',
61 defaultMessage: '!!!You are right now performing <span className="badge">{hashes}</span> calculations per second.',
62 },
63 miningThankYou: {
64 id: 'settings.account.mining.thankyou',
65 defaultMessage: '!!!Thank you for supporting Franz with your processing power.',
66 },
67 miningMoreInfo: {
68 id: 'settings.account.mining.moreInformation',
69 defaultMessage: '!!!Get more information',
70 },
71 cancelMining: {
72 id: 'settings.account.mining.cancel',
73 defaultMessage: '!!!Cancel mining',
74 },
75});
76
77@observer
78export default class AccountDashboard extends Component {
79 static propTypes = {
80 user: MobxPropTypes.observableObject.isRequired,
81 orders: MobxPropTypes.arrayOrObservableArray.isRequired,
82 hashrate: PropTypes.number.isRequired,
83 isLoading: PropTypes.bool.isRequired,
84 isLoadingOrdersInfo: PropTypes.bool.isRequired,
85 isLoadingPlans: PropTypes.bool.isRequired,
86 isCreatingPaymentDashboardUrl: PropTypes.bool.isRequired,
87 userInfoRequestFailed: PropTypes.bool.isRequired,
88 retryUserInfoRequest: PropTypes.func.isRequired,
89 openDashboard: PropTypes.func.isRequired,
90 openExternalUrl: PropTypes.func.isRequired,
91 onCloseSubscriptionWindow: PropTypes.func.isRequired,
92 stopMiner: PropTypes.func.isRequired,
93 };
94
95 static contextTypes = {
96 intl: intlShape,
97 };
98
99 render() {
100 const {
101 user,
102 orders,
103 hashrate,
104 isLoading,
105 isCreatingPaymentDashboardUrl,
106 openDashboard,
107 openExternalUrl,
108 isLoadingOrdersInfo,
109 isLoadingPlans,
110 userInfoRequestFailed,
111 retryUserInfoRequest,
112 onCloseSubscriptionWindow,
113 stopMiner,
114 } = this.props;
115 const { intl } = this.context;
116
117 return (
118 <div className="settings__main">
119 <div className="settings__header">
120 <span className="settings__header-item">
121 {intl.formatMessage(messages.headline)}
122 </span>
123 </div>
124 <div className="settings__body">
125 {isLoading && (
126 <Loader />
127 )}
128
129 {!isLoading && userInfoRequestFailed && (
130 <div>
131 <Infobox
132 icon="alert"
133 type="danger"
134 ctaLabel={intl.formatMessage(messages.tryReloadUserInfoRequest)}
135 ctaLoading={isLoading}
136 ctaOnClick={retryUserInfoRequest}
137 >
138 {intl.formatMessage(messages.userInfoRequestFailed)}
139 </Infobox>
140 </div>
141 )}
142
143 {!userInfoRequestFailed && (
144 <div>
145 {!isLoading && (
146 <div className="account">
147 <div className="account__box account__box--flex">
148 <div className="account__avatar">
149 <img
150 src="./assets/images/logo.svg"
151 alt=""
152 />
153 {user.isPremium && (
154 <span
155 className="account__avatar-premium emoji"
156 data-tip="Premium Supporter Account"
157 >
158 <img src="./assets/images/emoji/star.png" alt="" />
159 </span>
160 )}
161 </div>
162 <div className="account__info">
163 <h2>
164 {`${user.firstname} ${user.lastname}`}
165 </h2>
166 {user.organization && `${user.organization}, `}
167 {user.email}<br />
168 {!user.isPremium && (
169 <span className="badge badge">{intl.formatMessage(messages.accountTypeBasic)}</span>
170 )}
171 {user.isPremium && (
172 <span className="badge badge--premium">{intl.formatMessage(messages.accountTypePremium)}</span>
173 )}
174 </div>
175 <Link to="/settings/user/edit" className="button">
176 {intl.formatMessage(messages.accountEditButton)}
177 </Link>
178
179 {user.emailValidated}
180 </div>
181 </div>
182 )}
183
184 {user.isSubscriptionOwner && (
185 isLoadingOrdersInfo ? (
186 <Loader />
187 ) : (
188 <div className="account franz-form">
189 {orders.length > 0 && (
190 <div>
191 <div className="account__box">
192 <h2>{intl.formatMessage(messages.headlineSubscription)}</h2>
193 <div className="account__subscription">
194 {orders[0].name}
195 <span className="badge">{orders[0].price}</span>
196 <Button
197 label={intl.formatMessage(messages.manageSubscriptionButtonLabel)}
198 className="account__subscription-button franz-form__button--inverted"
199 loaded={!isCreatingPaymentDashboardUrl}
200 onClick={() => openDashboard()}
201 />
202 </div>
203 </div>
204 <div className="account__box account__box--last">
205 <h2>{intl.formatMessage(messages.headlineInvoices)}</h2>
206 <table className="invoices">
207 <tbody>
208 {orders.map(order => (
209 <tr key={order.id}>
210 <td className="invoices__date">
211 {moment(order.date).format('DD.MM.YYYY')}
212 </td>
213 <td className="invoices__action">
214 <button
215 onClick={() => openExternalUrl(order.invoiceUrl)}
216 >
217 {intl.formatMessage(messages.invoiceDownload)}
218 </button>
219 </td>
220 </tr>
221 ))}
222 </tbody>
223 </table>
224 </div>
225 </div>
226 )}
227 </div>
228 )
229 )}
230
231 {user.isMiner && (
232 <div className="account franz-form">
233 <div className="account__box">
234 <h2>{intl.formatMessage(messages.headlineSubscription)}</h2>
235 <div className="account__subscription">
236 <div>
237 <p>{intl.formatMessage(messages.miningThankYou)}</p>
238 <FormattedMessage
239 {...messages.miningActive}
240 values={{
241 hashes: <span className="badge">{hashrate.toFixed(2)}</span>,
242 }}
243 tagName="p"
244 />
245 <p>
246 <Link
247 to="http://meetfranz.com/mining"
248 target="_blank"
249 className="link"
250 >
251 {intl.formatMessage(messages.miningMoreInfo)}
252 </Link>
253 </p>
254 </div>
255 <Button
256 label={intl.formatMessage(messages.cancelMining)}
257 className="account__subscription-button franz-form__button--inverted"
258 onClick={() => stopMiner()}
259 />
260 </div>
261 </div>
262 </div>
263 )}
264
265 {!user.isPremium && !user.isMiner && (
266 isLoadingPlans ? (
267 <Loader />
268 ) : (
269 <div className="account franz-form">
270 <div className="account__box account__box--last">
271 <h2>{intl.formatMessage(messages.headlineUpgrade)}</h2>
272 <SubscriptionForm
273 onCloseWindow={onCloseSubscriptionWindow}
274 />
275 </div>
276 </div>
277 )
278 )}
279 </div>
280 )}
281 </div>
282 <ReactTooltip place="right" type="dark" effect="solid" />
283 </div>
284 );
285 }
286}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { defineMessages, intlShape } from 'react-intl';
4
5import Link from '../../ui/Link';
6
7const messages = defineMessages({
8 availableServices: {
9 id: 'settings.navigation.availableServices',
10 defaultMessage: '!!!Available services',
11 },
12 yourServices: {
13 id: 'settings.navigation.yourServices',
14 defaultMessage: '!!!Your services',
15 },
16 account: {
17 id: 'settings.navigation.account',
18 defaultMessage: '!!!Account',
19 },
20 settings: {
21 id: 'settings.navigation.settings',
22 defaultMessage: '!!!Settings',
23 },
24 logout: {
25 id: 'settings.navigation.logout',
26 defaultMessage: '!!!Logout',
27 },
28});
29
30export default class SettingsNavigation extends Component {
31 static propTypes = {
32 serviceCount: PropTypes.number.isRequired,
33 };
34
35 static contextTypes = {
36 intl: intlShape,
37 };
38
39 render() {
40 const { serviceCount } = this.props;
41 const { intl } = this.context;
42
43 return (
44 <div className="settings-navigation">
45 <Link
46 to="/settings/recipes"
47 className="settings-navigation__link"
48 activeClassName="is-active"
49 >
50 {intl.formatMessage(messages.availableServices)}
51 </Link>
52 <Link
53 to="/settings/services"
54 className="settings-navigation__link"
55 activeClassName="is-active"
56 >
57 {intl.formatMessage(messages.yourServices)} <span className="badge">{serviceCount}</span>
58 </Link>
59 <Link
60 to="/settings/user"
61 className="settings-navigation__link"
62 activeClassName="is-active"
63 >
64 {intl.formatMessage(messages.account)}
65 </Link>
66 <Link
67 to="/settings/app"
68 className="settings-navigation__link"
69 activeClassName="is-active"
70 >
71 {intl.formatMessage(messages.settings)}
72 </Link>
73 <span className="settings-navigation__expander" />
74 <Link
75 to="/auth/logout"
76 className="settings-navigation__link"
77 activeClassName="is-active"
78 >
79 {intl.formatMessage(messages.logout)}
80 </Link>
81 </div>
82 );
83 }
84}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4
5import RecipePreviewModel from '../../../models/RecipePreview';
6
7@observer
8export default class RecipeItem extends Component {
9 static propTypes = {
10 recipe: PropTypes.instanceOf(RecipePreviewModel).isRequired,
11 onClick: PropTypes.func.isRequired,
12 };
13
14 render() {
15 const { recipe, onClick } = this.props;
16
17 return (
18 <button
19 className="recipe-teaser"
20 onClick={onClick}
21 >
22 {recipe.local && (
23 <span className="recipe-teaser__dev-badge">dev</span>
24 )}
25 <img
26 src={recipe.icons.svg}
27 className="recipe-teaser__icon"
28 alt=""
29 />
30 <span className="recipe-teaser__label">{recipe.name}</span>
31 </button>
32 );
33 }
34}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6
7import SearchInput from '../../ui/SearchInput';
8import Infobox from '../../ui/Infobox';
9import RecipeItem from './RecipeItem';
10import Loader from '../../ui/Loader';
11import Appear from '../../ui/effects/Appear';
12
13const messages = defineMessages({
14 headline: {
15 id: 'settings.recipes.headline',
16 defaultMessage: '!!!Available Services',
17 },
18 mostPopularRecipes: {
19 id: 'settings.recipes.mostPopular',
20 defaultMessage: '!!!Most popular',
21 },
22 allRecipes: {
23 id: 'settings.recipes.all',
24 defaultMessage: '!!!All services',
25 },
26 devRecipes: {
27 id: 'settings.recipes.dev',
28 defaultMessage: '!!!Development',
29 },
30 nothingFound: {
31 id: 'settings.recipes.nothingFound',
32 defaultMessage: '!!!Sorry, but no service matched your search term.',
33 },
34 servicesSuccessfulAddedInfo: {
35 id: 'settings.recipes.servicesSuccessfulAddedInfo',
36 defaultMessage: '!!!Service successfully added',
37 },
38});
39
40@observer
41export default class RecipesDashboard extends Component {
42 static propTypes = {
43 recipes: MobxPropTypes.arrayOrObservableArray.isRequired,
44 isLoading: PropTypes.bool.isRequired,
45 hasLoadedRecipes: PropTypes.bool.isRequired,
46 showAddServiceInterface: PropTypes.func.isRequired,
47 searchRecipes: PropTypes.func.isRequired,
48 resetSearch: PropTypes.func.isRequired,
49 serviceStatus: MobxPropTypes.arrayOrObservableArray.isRequired,
50 devRecipesCount: PropTypes.number.isRequired,
51 searchNeedle: PropTypes.string,
52 };
53
54 static defaultProps = {
55 searchNeedle: '',
56 }
57
58 static contextTypes = {
59 intl: intlShape,
60 };
61
62 render() {
63 const {
64 recipes,
65 isLoading,
66 hasLoadedRecipes,
67 showAddServiceInterface,
68 searchRecipes,
69 resetSearch,
70 serviceStatus,
71 devRecipesCount,
72 searchNeedle,
73 } = this.props;
74 const { intl } = this.context;
75
76 return (
77 <div className="settings__main">
78 <div className="settings__header">
79 <SearchInput
80 className="settings__search-header"
81 defaultValue={intl.formatMessage(messages.headline)}
82 onChange={e => searchRecipes(e)}
83 onReset={() => resetSearch()}
84 throttle
85 />
86 </div>
87 <div className="settings__body recipes">
88 {serviceStatus.length > 0 && serviceStatus.includes('created') && (
89 <Appear>
90 <Infobox
91 type="success"
92 icon="checkbox-marked-circle-outline"
93 dismissable
94 >
95 {intl.formatMessage(messages.servicesSuccessfulAddedInfo)}
96 </Infobox>
97 </Appear>
98 )}
99 {!searchNeedle && (
100 <div className="recipes__navigation">
101 <Link
102 to="/settings/recipes"
103 className="badge"
104 activeClassName="badge--primary"
105 >
106 {intl.formatMessage(messages.mostPopularRecipes)}
107 </Link>
108 <Link
109 to="/settings/recipes/all"
110 className="badge"
111 activeClassName="badge--primary"
112 >
113 {intl.formatMessage(messages.allRecipes)}
114 </Link>
115 {devRecipesCount > 0 && (
116 <Link
117 to="/settings/recipes/dev"
118 className="badge"
119 activeClassName="badge--primary"
120 >
121 {intl.formatMessage(messages.devRecipes)} ({devRecipesCount})
122 </Link>
123 )}
124 </div>
125 )}
126 {isLoading ? (
127 <Loader />
128 ) : (
129 <div className="recipes__list">
130 {hasLoadedRecipes && recipes.length === 0 && (
131 <p className="align-middle settings__empty-state">
132 <span className="emoji">
133 <img src="./assets/images/emoji/dontknow.png" alt="" />
134 </span>
135 {intl.formatMessage(messages.nothingFound)}
136 </p>
137 )}
138 {recipes.map(recipe => (
139 <RecipeItem
140 key={recipe.id}
141 recipe={recipe}
142 onClick={() => showAddServiceInterface({ recipeId: recipe.id })}
143 />
144 ))}
145 </div>
146 )}
147 </div>
148 </div>
149 );
150 }
151}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Link } from 'react-router';
5import { defineMessages, intlShape } from 'react-intl';
6import normalizeUrl from 'normalize-url';
7
8import Form from '../../../lib/Form';
9import User from '../../../models/User';
10import Recipe from '../../../models/Recipe';
11import Service from '../../../models/Service';
12import Tabs, { TabItem } from '../../ui/Tabs';
13import Input from '../../ui/Input';
14import Toggle from '../../ui/Toggle';
15import Button from '../../ui/Button';
16
17const messages = defineMessages({
18 saveService: {
19 id: 'settings.service.form.saveButton',
20 defaultMessage: '!!!Save service',
21 },
22 deleteService: {
23 id: 'settings.service.form.deleteButton',
24 defaultMessage: '!!!Delete Service',
25 },
26 availableServices: {
27 id: 'settings.service.form.availableServices',
28 defaultMessage: '!!!Available services',
29 },
30 yourServices: {
31 id: 'settings.service.form.yourServices',
32 defaultMessage: '!!!Your services',
33 },
34 addServiceHeadline: {
35 id: 'settings.service.form.addServiceHeadline',
36 defaultMessage: '!!!Add {name}',
37 },
38 editServiceHeadline: {
39 id: 'settings.service.form.editServiceHeadline',
40 defaultMessage: '!!!Edit {name}',
41 },
42 tabHosted: {
43 id: 'settings.service.form.tabHosted',
44 defaultMessage: '!!!Hosted',
45 },
46 tabOnPremise: {
47 id: 'settings.service.form.tabOnPremise',
48 defaultMessage: '!!!Self hosted ⭐️',
49 },
50 customUrlValidationError: {
51 id: 'settings.service.form.customUrlValidationError',
52 defaultMessage: '!!!Could not validate custom {name} server.',
53 },
54 customUrlPremiumInfo: {
55 id: 'settings.service.form.customUrlPremiumInfo',
56 defaultMessage: '!!!To add self hosted services, you need a Franz Premium Supporter Account.',
57 },
58 customUrlUpgradeAccount: {
59 id: 'settings.service.form.customUrlUpgradeAccount',
60 defaultMessage: '!!!Upgrade your account',
61 },
62 indirectMessageInfo: {
63 id: 'settings.service.form.indirectMessageInfo',
64 defaultMessage: '!!!You will be notified about all new messages in a channel, not just @username, @channel, @here, ...', // eslint-disable-line
65 },
66});
67
68@observer
69export default class EditServiceForm extends Component {
70 static propTypes = {
71 recipe: PropTypes.instanceOf(Recipe).isRequired,
72 // service: PropTypes.oneOfType([
73 // PropTypes.object,
74 // PropTypes.instanceOf(Service),
75 // ]),
76 service(props, propName) {
77 if (props.action === 'edit' && !(props[propName] instanceof Service)) {
78 return new Error(`'${propName}'' is expected to be of type 'Service'
79 when editing a Service`);
80 }
81
82 return null;
83 },
84 user: PropTypes.instanceOf(User).isRequired,
85 action: PropTypes.string.isRequired,
86 form: PropTypes.instanceOf(Form).isRequired,
87 onSubmit: PropTypes.func.isRequired,
88 onDelete: PropTypes.func.isRequired,
89 isSaving: PropTypes.bool.isRequired,
90 isDeleting: PropTypes.bool.isRequired,
91 };
92
93 static defaultProps = {
94 service: {},
95 };
96 static contextTypes = {
97 intl: intlShape,
98 };
99
100 state = {
101 isValidatingCustomUrl: false,
102 }
103
104 submit(e) {
105 const { recipe } = this.props;
106
107 e.preventDefault();
108 this.props.form.submit({
109 onSuccess: async (form) => {
110 const values = form.values();
111
112 let isValid = true;
113
114 if (recipe.validateUrl && values.customUrl) {
115 this.setState({ isValidatingCustomUrl: true });
116 try {
117 values.customUrl = normalizeUrl(values.customUrl);
118 isValid = await recipe.validateUrl(values.customUrl);
119 } catch (err) {
120 console.warn('ValidateURL', err);
121 isValid = false;
122 }
123 }
124
125 if (isValid) {
126 this.props.onSubmit(values);
127 } else {
128 form.invalidate('url-validation-error');
129 }
130
131 this.setState({ isValidatingCustomUrl: false });
132 },
133 onError: () => {},
134 });
135 }
136
137 render() {
138 const {
139 recipe,
140 service,
141 action,
142 user,
143 form,
144 isSaving,
145 isDeleting,
146 onDelete,
147 } = this.props;
148 const { intl } = this.context;
149
150 const { isValidatingCustomUrl } = this.state;
151
152 const deleteButton = isDeleting ? (
153 <Button
154 label={intl.formatMessage(messages.deleteService)}
155 loaded={false}
156 buttonType="secondary"
157 className="settings__delete-button"
158 disabled
159 />
160 ) : (
161 <Button
162 buttonType="danger"
163 label={intl.formatMessage(messages.deleteService)}
164 className="settings__delete-button"
165 onClick={onDelete}
166 />
167 );
168
169 return (
170 <div className="settings__main">
171 <div className="settings__header">
172 <span className="settings__header-item">
173 {action === 'add' ? (
174 <Link to="/settings/recipes">
175 {intl.formatMessage(messages.availableServices)}
176 </Link>
177 ) : (
178 <Link to="/settings/services">
179 {intl.formatMessage(messages.yourServices)}
180 </Link>
181 )}
182 </span>
183 <span className="separator" />
184 <span className="settings__header-item">
185 {action === 'add' ? (
186 intl.formatMessage(messages.addServiceHeadline, {
187 name: recipe.name,
188 })
189 ) : (
190 intl.formatMessage(messages.editServiceHeadline, {
191 name: service.name !== '' ? service.name : recipe.name,
192 })
193 )}
194 </span>
195 </div>
196 <div className="settings__body">
197 <form onSubmit={e => this.submit(e)} id="form">
198 <Input field={form.$('name')} focus />
199 {(recipe.hasTeamId || recipe.hasCustomUrl) && (
200 <Tabs
201 active={service.customUrl ? 1 : 0}
202 >
203 {recipe.hasTeamId && (
204 <TabItem title={intl.formatMessage(messages.tabHosted)}>
205 <Input field={form.$('team')} suffix={recipe.urlInputSuffix} />
206 </TabItem>
207 )}
208 {recipe.hasCustomUrl && (
209 <TabItem title={intl.formatMessage(messages.tabOnPremise)}>
210 {user.isPremium ? (
211 <div>
212 <Input field={form.$('customUrl')} />
213 {form.error === 'url-validation-error' && (
214 <p className="franz-form__error">
215 {intl.formatMessage(messages.customUrlValidationError, { name: recipe.name })}
216 </p>
217 )}
218 </div>
219 ) : (
220 <div className="center premium-info">
221 <p>{intl.formatMessage(messages.customUrlPremiumInfo)}</p>
222 <p>
223 <Link to="/settings/user" className="button">
224 {intl.formatMessage(messages.customUrlUpgradeAccount)}
225 </Link>
226 </p>
227 </div>
228 )}
229 </TabItem>
230 )}
231 </Tabs>
232 )}
233 <div className="settings__options">
234 <Toggle field={form.$('isNotificationEnabled')} />
235 {recipe.hasIndirectMessages && (
236 <div>
237 <Toggle field={form.$('isIndirectMessageBadgeEnabled')} />
238 <p className="settings__indirect-message-help">
239 {intl.formatMessage(messages.indirectMessageInfo)}
240 </p>
241 </div>
242 )}
243 <Toggle field={form.$('isEnabled')} />
244 </div>
245 {recipe.message && (
246 <p className="settings__message">
247 <span className="mdi mdi-information" />
248 {recipe.message}
249 </p>
250 )}
251 </form>
252 </div>
253 <div className="settings__controls">
254 {/* Delete Button */}
255 {action === 'edit' && deleteButton}
256
257 {/* Save Button */}
258 {isSaving || isValidatingCustomUrl ? (
259 <Button
260 type="submit"
261 label={intl.formatMessage(messages.saveService)}
262 loaded={false}
263 buttonType="secondary"
264 disabled
265 />
266 ) : (
267 <Button
268 type="submit"
269 label={intl.formatMessage(messages.saveService)}
270 htmlForm="form"
271 />
272 )}
273 </div>
274 </div>
275 );
276 }
277}
diff --git a/src/components/settings/services/ServiceError.js b/src/components/settings/services/ServiceError.js
new file mode 100644
index 000000000..923053296
--- /dev/null
+++ b/src/components/settings/services/ServiceError.js
@@ -0,0 +1,68 @@
1import React, { Component } from 'react';
2import { observer } from 'mobx-react';
3import { Link } from 'react-router';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Infobox from '../../ui/Infobox';
7import Button from '../../ui/Button';
8
9const messages = defineMessages({
10 headline: {
11 id: 'settings.service.error.headline',
12 defaultMessage: '!!!Error',
13 },
14 goBack: {
15 id: 'settings.service.error.goBack',
16 defaultMessage: '!!!Back to services',
17 },
18 availableServices: {
19 id: 'settings.service.form.availableServices',
20 defaultMessage: '!!!Available services',
21 },
22 errorMessage: {
23 id: 'settings.service.error.message',
24 defaultMessage: '!!!Could not load service recipe.',
25 },
26});
27
28@observer
29export default class EditServiceForm extends Component {
30 static contextTypes = {
31 intl: intlShape,
32 };
33
34 render() {
35 const { intl } = this.context;
36
37 return (
38 <div className="settings__main">
39 <div className="settings__header">
40 <span className="settings__header-item">
41 <Link to="/settings/recipes">
42 {intl.formatMessage(messages.availableServices)}
43 </Link>
44 </span>
45 <span className="separator" />
46 <span className="settings__header-item">
47 {intl.formatMessage(messages.headline)}
48 </span>
49 </div>
50 <div className="settings__body">
51 <Infobox
52 type="danger"
53 icon="alert"
54 >
55 {intl.formatMessage(messages.errorMessage)}
56 </Infobox>
57 </div>
58 <div className="settings__controls">
59 <Button
60 label={intl.formatMessage(messages.goBack)}
61 htmlForm="form"
62 onClick={() => window.history.back()}
63 />
64 </div>
65 </div>
66 );
67 }
68}
diff --git a/src/components/settings/services/ServiceItem.js b/src/components/settings/services/ServiceItem.js
new file mode 100644
index 000000000..20d8581d0
--- /dev/null
+++ b/src/components/settings/services/ServiceItem.js
@@ -0,0 +1,98 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { defineMessages, intlShape } from 'react-intl';
4import ReactTooltip from 'react-tooltip';
5import { observer } from 'mobx-react';
6import classnames from 'classnames';
7
8import ServiceModel from '../../../models/Service';
9
10const messages = defineMessages({
11 tooltipIsDisabled: {
12 id: 'settings.services.tooltip.isDisabled',
13 defaultMessage: '!!!Service is disabled',
14 },
15 tooltipNotificationsDisabled: {
16 id: 'settings.services.tooltip.notificationsDisabled',
17 defaultMessage: '!!!Notifications are disabled',
18 },
19});
20
21@observer
22export default class ServiceItem extends Component {
23 static propTypes = {
24 service: PropTypes.instanceOf(ServiceModel).isRequired,
25 goToServiceForm: PropTypes.func.isRequired,
26 };
27 static contextTypes = {
28 intl: intlShape,
29 };
30
31 render() {
32 const {
33 service,
34 // toggleAction,
35 goToServiceForm,
36 } = this.props;
37 const { intl } = this.context;
38
39 return (
40 <tr
41 className={classnames({
42 'service-table__row': true,
43 'service-table__row--disabled': !service.isEnabled,
44 })}
45 >
46 <td
47 className="service-table__column-icon"
48 onClick={goToServiceForm}
49 >
50 <img
51 src={service.icon}
52 className={classnames({
53 'service-table__icon': true,
54 'has-custom-icon': service.hasCustomIcon,
55 })}
56 alt=""
57 />
58 </td>
59 <td
60 className="service-table__column-name"
61 onClick={goToServiceForm}
62 >
63 {service.name !== '' ? service.name : service.recipe.name}
64 </td>
65 <td
66 className="service-table__column-info"
67 onClick={goToServiceForm}
68 >
69 {!service.isEnabled && (
70 <span
71 className="mdi mdi-power"
72 data-tip={intl.formatMessage(messages.tooltipIsDisabled)}
73 />
74 )}
75 </td>
76 <td
77 className="service-table__column-info"
78 onClick={goToServiceForm}
79 >
80 {!service.isNotificationEnabled && (
81 <span
82 className="mdi mdi-message-bulleted-off"
83 data-tip={intl.formatMessage(messages.tooltipNotificationsDisabled)}
84 />
85 )}
86 <ReactTooltip place="top" type="dark" effect="solid" />
87 </td>
88 {/* <td className="service-table__column-action">
89 <input
90 type="checkbox"
91 onChange={toggleAction}
92 checked={service.isEnabled}
93 />
94 </td> */}
95 </tr>
96 );
97 }
98}
diff --git a/src/components/settings/services/ServicesDashboard.js b/src/components/settings/services/ServicesDashboard.js
new file mode 100644
index 000000000..5f146b5f3
--- /dev/null
+++ b/src/components/settings/services/ServicesDashboard.js
@@ -0,0 +1,155 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { Link } from 'react-router';
5import { defineMessages, intlShape } from 'react-intl';
6
7import SearchInput from '../../ui/SearchInput';
8import Infobox from '../../ui/Infobox';
9import Loader from '../../ui/Loader';
10import ServiceItem from './ServiceItem';
11import Appear from '../../ui/effects/Appear';
12
13const messages = defineMessages({
14 headline: {
15 id: 'settings.services.headline',
16 defaultMessage: '!!!Your services',
17 },
18 noServicesAdded: {
19 id: 'settings.services.noServicesAdded',
20 defaultMessage: '!!!You haven\'t added any services yet.',
21 },
22 discoverServices: {
23 id: 'settings.services.discoverServices',
24 defaultMessage: '!!!Discover services',
25 },
26 servicesRequestFailed: {
27 id: 'settings.services.servicesRequestFailed',
28 defaultMessage: '!!!Could not load your services',
29 },
30 tryReloadServices: {
31 id: 'settings.account.tryReloadServices',
32 defaultMessage: '!!!Try again',
33 },
34 updatedInfo: {
35 id: 'settings.services.updatedInfo',
36 defaultMessage: '!!!Your changes have been saved',
37 },
38 deletedInfo: {
39 id: 'settings.services.deletedInfo',
40 defaultMessage: '!!!Service has been deleted',
41 },
42});
43
44@observer
45export default class ServicesDashboard extends Component {
46 static propTypes = {
47 services: MobxPropTypes.arrayOrObservableArray.isRequired,
48 isLoading: PropTypes.bool.isRequired,
49 toggleService: PropTypes.func.isRequired,
50 filterServices: PropTypes.func.isRequired,
51 resetFilter: PropTypes.func.isRequired,
52 goTo: PropTypes.func.isRequired,
53 servicesRequestFailed: PropTypes.bool.isRequired,
54 retryServicesRequest: PropTypes.func.isRequired,
55 status: MobxPropTypes.arrayOrObservableArray.isRequired,
56 };
57 static contextTypes = {
58 intl: intlShape,
59 };
60
61 render() {
62 const {
63 services,
64 isLoading,
65 toggleService,
66 filterServices,
67 resetFilter,
68 goTo,
69 servicesRequestFailed,
70 retryServicesRequest,
71 status,
72 } = this.props;
73 const { intl } = this.context;
74
75 return (
76 <div className="settings__main">
77 <div className="settings__header">
78 <SearchInput
79 className="settings__search-header"
80 defaultValue={intl.formatMessage(messages.headline)}
81 onChange={needle => filterServices({ needle })}
82 onReset={() => resetFilter()}
83 />
84 </div>
85 <div className="settings__body">
86 {!isLoading && servicesRequestFailed && (
87 <div>
88 <Infobox
89 icon="alert"
90 type="danger"
91 ctaLabel={intl.formatMessage(messages.tryReloadServices)}
92 ctaLoading={isLoading}
93 ctaOnClick={retryServicesRequest}
94 >
95 {intl.formatMessage(messages.servicesRequestFailed)}
96 </Infobox>
97 </div>
98 )}
99
100 {status.length > 0 && status.includes('updated') && (
101 <Appear>
102 <Infobox
103 type="success"
104 icon="checkbox-marked-circle-outline"
105 dismissable
106 >
107 {intl.formatMessage(messages.updatedInfo)}
108 </Infobox>
109 </Appear>
110 )}
111
112 {status.length > 0 && status.includes('service-deleted') && (
113 <Appear>
114 <Infobox
115 type="success"
116 icon="checkbox-marked-circle-outline"
117 dismissable
118 >
119 {intl.formatMessage(messages.deletedInfo)}
120 </Infobox>
121 </Appear>
122 )}
123
124 {!isLoading && services.length === 0 && (
125 <div className="align-middle settings__empty-state">
126 <p className="settings__empty-text">
127 <span className="emoji">
128 <img src="./assets/images/emoji/sad.png" alt="" />
129 </span>
130 {intl.formatMessage(messages.noServicesAdded)}
131 </p>
132 <Link to="/settings/recipes" className="button">{intl.formatMessage(messages.discoverServices)}</Link>
133 </div>
134 )}
135 {isLoading ? (
136 <Loader />
137 ) : (
138 <table className="service-table">
139 <tbody>
140 {services.map(service => (
141 <ServiceItem
142 key={service.id}
143 service={service}
144 toggleAction={() => toggleService({ serviceId: service.id })}
145 goToServiceForm={() => goTo(`/settings/services/edit/${service.id}`)}
146 />
147 ))}
148 </tbody>
149 </table>
150 )}
151 </div>
152 </div>
153 );
154 }
155}
diff --git a/src/components/settings/settings/EditSettingsForm.js b/src/components/settings/settings/EditSettingsForm.js
new file mode 100644
index 000000000..02736dbb9
--- /dev/null
+++ b/src/components/settings/settings/EditSettingsForm.js
@@ -0,0 +1,148 @@
1import { remote } from 'electron';
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import { observer } from 'mobx-react';
5import { defineMessages, intlShape } from 'react-intl';
6
7import Form from '../../../lib/Form';
8import Button from '../../ui/Button';
9import Toggle from '../../ui/Toggle';
10import Select from '../../ui/Select';
11
12const messages = defineMessages({
13 headline: {
14 id: 'settings.app.headline',
15 defaultMessage: '!!!Settings',
16 },
17 headlineGeneral: {
18 id: 'settings.app.headlineGeneral',
19 defaultMessage: '!!!General',
20 },
21 headlineLanguage: {
22 id: 'settings.app.headlineLanguage',
23 defaultMessage: '!!!Language',
24 },
25 headlineUpdates: {
26 id: 'settings.app.headlineUpdates',
27 defaultMessage: '!!!Updates',
28 },
29 buttonSearchForUpdate: {
30 id: 'settings.app.buttonSearchForUpdate',
31 defaultMessage: '!!!Check for updates',
32 },
33 buttonInstallUpdate: {
34 id: 'settings.app.buttonInstallUpdate',
35 defaultMessage: '!!!Restart & install update',
36 },
37 updateStatusSearching: {
38 id: 'settings.app.updateStatusSearching',
39 defaultMessage: '!!!Is searching for update',
40 },
41 updateStatusAvailable: {
42 id: 'settings.app.updateStatusAvailable',
43 defaultMessage: '!!!Update available, downloading...',
44 },
45 updateStatusUpToDate: {
46 id: 'settings.app.updateStatusUpToDate',
47 defaultMessage: '!!!You are using the latest version of Franz',
48 },
49 currentVersion: {
50 id: 'settings.app.currentVersion',
51 defaultMessage: '!!!Current version:',
52 },
53});
54
55@observer
56export default class EditSettingsForm extends Component {
57 static propTypes = {
58 checkForUpdates: PropTypes.func.isRequired,
59 installUpdate: PropTypes.func.isRequired,
60 form: PropTypes.instanceOf(Form).isRequired,
61 onSubmit: PropTypes.func.isRequired,
62 isCheckingForUpdates: PropTypes.bool.isRequired,
63 isUpdateAvailable: PropTypes.bool.isRequired,
64 noUpdateAvailable: PropTypes.bool.isRequired,
65 updateIsReadyToInstall: PropTypes.bool.isRequired,
66 };
67
68 static contextTypes = {
69 intl: intlShape,
70 };
71
72 submit(e) {
73 e.preventDefault();
74 this.props.form.submit({
75 onSuccess: (form) => {
76 const values = form.values();
77 this.props.onSubmit(values);
78 },
79 onError: () => {},
80 });
81 }
82
83 render() {
84 const {
85 checkForUpdates,
86 installUpdate,
87 form,
88 isCheckingForUpdates,
89 isUpdateAvailable,
90 noUpdateAvailable,
91 updateIsReadyToInstall,
92 } = this.props;
93 const { intl } = this.context;
94
95 let updateButtonLabelMessage = messages.buttonSearchForUpdate;
96 if (isCheckingForUpdates) {
97 updateButtonLabelMessage = messages.updateStatusSearching;
98 } else if (isUpdateAvailable) {
99 updateButtonLabelMessage = messages.updateStatusAvailable;
100 } else {
101 updateButtonLabelMessage = messages.buttonSearchForUpdate;
102 }
103
104 return (
105 <div className="settings__main">
106 <div className="settings__header">
107 <h1>{intl.formatMessage(messages.headline)}</h1>
108 </div>
109 <div className="settings__body">
110 <form
111 onSubmit={e => this.submit(e)}
112 onChange={e => this.submit(e)}
113 id="form"
114 >
115 <h2>{intl.formatMessage(messages.headlineGeneral)}</h2>
116 <Toggle field={form.$('autoLaunchOnStart')} />
117 <Toggle field={form.$('runInBackground')} />
118 {process.platform === 'win32' && (
119 <Toggle field={form.$('minimizeToSystemTray')} />
120 )}
121 <h2>{intl.formatMessage(messages.headlineLanguage)}</h2>
122 <Select field={form.$('locale')} showLabel={false} />
123 <h2>{intl.formatMessage(messages.headlineUpdates)}</h2>
124 {updateIsReadyToInstall ? (
125 <Button
126 label={intl.formatMessage(messages.buttonInstallUpdate)}
127 onClick={installUpdate}
128 />
129 ) : (
130 <Button
131 label={intl.formatMessage(updateButtonLabelMessage)}
132 onClick={checkForUpdates}
133 disabled={isCheckingForUpdates || isUpdateAvailable}
134 loaded={!isCheckingForUpdates || !isUpdateAvailable}
135 />
136 )}
137 {noUpdateAvailable && (
138 <p>{intl.formatMessage(messages.updateStatusUpToDate)}</p>
139 )}
140 <br />
141 <Toggle field={form.$('beta')} />
142 {intl.formatMessage(messages.currentVersion)} {remote.app.getVersion()}
143 </form>
144 </div>
145 </div>
146 );
147 }
148}
diff --git a/src/components/settings/user/EditUserForm.js b/src/components/settings/user/EditUserForm.js
new file mode 100644
index 000000000..f36887fc2
--- /dev/null
+++ b/src/components/settings/user/EditUserForm.js
@@ -0,0 +1,145 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6
7// import { Link } from 'react-router';
8
9import Form from '../../../lib/Form';
10import Input from '../../ui/Input';
11import Button from '../../ui/Button';
12import Radio from '../../ui/Radio';
13import Infobox from '../../ui/Infobox';
14
15const messages = defineMessages({
16 headline: {
17 id: 'settings.account.headline',
18 defaultMessage: '!!!Account',
19 },
20 headlineProfile: {
21 id: 'settings.account.headlineProfile',
22 defaultMessage: '!!!Update Profile',
23 },
24 headlineAccount: {
25 id: 'settings.account.headlineAccount',
26 defaultMessage: '!!!Account Information',
27 },
28 headlinePassword: {
29 id: 'settings.account.headlinePassword',
30 defaultMessage: '!!!Change Password',
31 },
32 successInfo: {
33 id: 'settings.account.successInfo',
34 defaultMessage: '!!!Your changes have been saved',
35 },
36 buttonSave: {
37 id: 'settings.account.buttonSave',
38 defaultMessage: '!!!Update profile',
39 },
40});
41
42@observer
43export default class EditServiceForm extends Component {
44 static propTypes = {
45 status: MobxPropTypes.observableArray.isRequired,
46 form: PropTypes.instanceOf(Form).isRequired,
47 onSubmit: PropTypes.func.isRequired,
48 isSaving: PropTypes.bool.isRequired,
49 };
50
51 static defaultProps = {
52 service: {},
53 };
54
55 static contextTypes = {
56 intl: intlShape,
57 };
58
59 submit(e) {
60 e.preventDefault();
61 this.props.form.submit({
62 onSuccess: (form) => {
63 const values = form.values();
64 this.props.onSubmit(values);
65 },
66 onError: () => {},
67 });
68 }
69
70 render() {
71 const {
72 // user,
73 status,
74 form,
75 isSaving,
76 } = this.props;
77 const { intl } = this.context;
78
79 return (
80 <div className="settings__main">
81 <div className="settings__header">
82 <span className="settings__header-item">
83 <Link to="/settings/user">
84 {intl.formatMessage(messages.headline)}
85 </Link>
86 </span>
87 <span className="separator" />
88 <span className="settings__header-item">
89 {intl.formatMessage(messages.headlineProfile)}
90 </span>
91 </div>
92 <div className="settings__body">
93 <form onSubmit={e => this.submit(e)} id="form">
94 {status.length > 0 && status.includes('data-updated') && (
95 <Infobox
96 type="success"
97 icon="checkbox-marked-circle-outline"
98 >
99 {intl.formatMessage(messages.successInfo)}
100 </Infobox>
101 )}
102 <h2>{intl.formatMessage(messages.headlineAccount)}</h2>
103 <div className="grid__row">
104 <Input field={form.$('firstname')} focus />
105 <Input field={form.$('lastname')} />
106 </div>
107 <Input field={form.$('email')} />
108 <Radio field={form.$('accountType')} />
109 {form.$('accountType').value === 'company' && (
110 <Input field={form.$('organization')} />
111 )}
112 <h2>{intl.formatMessage(messages.headlinePassword)}</h2>
113 <Input
114 field={form.$('oldPassword')}
115 showPasswordToggle
116 />
117 <Input
118 field={form.$('newPassword')}
119 showPasswordToggle
120 scorePassword
121 />
122 </form>
123 </div>
124 <div className="settings__controls">
125 {/* Save Button */}
126 {isSaving ? (
127 <Button
128 type="submit"
129 label={intl.formatMessage(messages.buttonSave)}
130 loaded={!isSaving}
131 buttonType="secondary"
132 disabled
133 />
134 ) : (
135 <Button
136 type="submit"
137 label={intl.formatMessage(messages.buttonSave)}
138 htmlForm="form"
139 />
140 )}
141 </div>
142 </div>
143 );
144 }
145}
diff --git a/src/components/ui/AppLoader.js b/src/components/ui/AppLoader.js
new file mode 100644
index 000000000..64a212969
--- /dev/null
+++ b/src/components/ui/AppLoader.js
@@ -0,0 +1,15 @@
1import React from 'react';
2
3import Appear from '../../components/ui/effects/Appear';
4import Loader from '../../components/ui/Loader';
5
6export default function () {
7 return (
8 <div className="app-loader">
9 <Appear>
10 <h1 className="app-loader__title">Franz</h1>
11 <Loader />
12 </Appear>
13 </div>
14 );
15}
diff --git a/src/components/ui/Button.js b/src/components/ui/Button.js
new file mode 100644
index 000000000..07e94192f
--- /dev/null
+++ b/src/components/ui/Button.js
@@ -0,0 +1,78 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import Loader from 'react-loader';
5import classnames from 'classnames';
6
7@observer
8export default class Button extends Component {
9 static propTypes = {
10 className: PropTypes.string,
11 label: PropTypes.string.isRequired,
12 disabled: PropTypes.bool,
13 onClick: PropTypes.func,
14 type: PropTypes.string,
15 buttonType: PropTypes.string,
16 loaded: PropTypes.bool,
17 htmlForm: PropTypes.string,
18 };
19
20 static defaultProps = {
21 className: null,
22 disabled: false,
23 onClick: () => {},
24 type: 'button',
25 buttonType: '',
26 loaded: true,
27 htmlForm: '',
28 };
29
30 element = null;
31
32 render() {
33 const {
34 label,
35 className,
36 disabled,
37 onClick,
38 type,
39 buttonType,
40 loaded,
41 htmlForm,
42 } = this.props;
43
44 const buttonProps = {
45 className: classnames({
46 'franz-form__button': true,
47 [`franz-form__button--${buttonType}`]: buttonType,
48 [`${className}`]: className,
49 }),
50 type,
51 };
52
53 if (disabled) {
54 buttonProps.disabled = true;
55 }
56
57 if (onClick) {
58 buttonProps.onClick = onClick;
59 }
60
61 if (htmlForm) {
62 buttonProps.form = htmlForm;
63 }
64
65 return (
66 <button {...buttonProps}>
67 <Loader
68 loaded={loaded}
69 lines={10}
70 scale={0.4}
71 color={buttonType === '' ? '#FFF' : '#373a3c'}
72 component="span"
73 />
74 {label}
75 </button>
76 );
77 }
78}
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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import Loader from 'react-loader';
6
7// import { oneOrManyChildElements } from '../../prop-types';
8import Appear from '../ui/effects/Appear';
9
10@observer
11export default class InfoBar extends Component {
12 static propTypes = {
13 // eslint-disable-next-line
14 children: PropTypes.any.isRequired,
15 onClick: PropTypes.func,
16 type: PropTypes.string,
17 className: PropTypes.string,
18 ctaLabel: PropTypes.string,
19 ctaLoading: PropTypes.bool,
20 position: PropTypes.string,
21 sticky: PropTypes.bool,
22 onHide: PropTypes.func,
23 };
24
25 static defaultProps = {
26 onClick: () => null,
27 type: 'primary',
28 className: '',
29 ctaLabel: '',
30 ctaLoading: false,
31 position: 'bottom',
32 sticky: false,
33 onHide: () => null,
34 };
35
36 render() {
37 const {
38 children,
39 type,
40 className,
41 ctaLabel,
42 ctaLoading,
43 onClick,
44 position,
45 sticky,
46 onHide,
47 } = this.props;
48
49 let transitionName = 'slideUp';
50 if (position === 'top') {
51 transitionName = 'slideDown';
52 }
53
54 return (
55 <Appear
56 transitionName={transitionName}
57 className={classnames({
58 'info-bar': true,
59 [`info-bar--${type}`]: true,
60 [`info-bar--${position}`]: true,
61 [`${className}`]: true,
62 })}
63 >
64 <div onClick={onClick} className="info-bar__content">
65 {children}
66 {ctaLabel && (
67 <button className="info-bar__cta">
68 <Loader
69 loaded={!ctaLoading}
70 lines={10}
71 scale={0.3}
72 color="#FFF"
73 component="span"
74 />
75 {ctaLabel}
76 </button>
77 )}
78 </div>
79 {!sticky && (
80 <button
81 className="info-bar__close mdi mdi-close"
82 onClick={onHide}
83 />
84 )}
85 </Appear>
86 );
87 }
88}
diff --git a/src/components/ui/Infobox.js b/src/components/ui/Infobox.js
new file mode 100644
index 000000000..2d063c7ef
--- /dev/null
+++ b/src/components/ui/Infobox.js
@@ -0,0 +1,87 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import Loader from 'react-loader';
6
7@observer
8export default class Infobox extends Component {
9 static propTypes = {
10 children: PropTypes.any.isRequired, // eslint-disable-line
11 icon: PropTypes.string,
12 type: PropTypes.string,
13 ctaOnClick: PropTypes.func,
14 ctaLabel: PropTypes.string,
15 ctaLoading: PropTypes.bool,
16 dismissable: PropTypes.bool,
17 };
18
19 static defaultProps = {
20 icon: '',
21 type: 'primary',
22 dismissable: false,
23 ctaOnClick: () => null,
24 ctaLabel: '',
25 ctaLoading: false,
26 };
27
28 state = {
29 dismissed: false,
30 };
31
32 render() {
33 const {
34 children,
35 icon,
36 type,
37 ctaLabel,
38 ctaLoading,
39 ctaOnClick,
40 dismissable,
41 } = this.props;
42
43 if (this.state.dismissed) {
44 return null;
45 }
46
47 return (
48 <div
49 className={classnames({
50 infobox: true,
51 [`infobox--${type}`]: type,
52 'infobox--default': !type,
53 })}
54 >
55 {icon && (
56 <i className={`mdi mdi-${icon}`} />
57 )}
58 <div className="infobox__content">
59 {children}
60 </div>
61 {ctaLabel && (
62 <button
63 className="infobox__cta"
64 onClick={ctaOnClick}
65 >
66 <Loader
67 loaded={!ctaLoading}
68 lines={10}
69 scale={0.3}
70 color="#FFF"
71 component="span"
72 />
73 {ctaLabel}
74 </button>
75 )}
76 {dismissable && (
77 <button
78 onClick={() => this.setState({
79 dismissed: true,
80 })}
81 className="infobox__delete mdi mdi-close"
82 />
83 )}
84 </div>
85 );
86 }
87}
diff --git a/src/components/ui/Input.js b/src/components/ui/Input.js
new file mode 100644
index 000000000..0bb9f23bf
--- /dev/null
+++ b/src/components/ui/Input.js
@@ -0,0 +1,148 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Field } from 'mobx-react-form';
5import classnames from 'classnames';
6
7import { scorePassword as scorePasswordFunc } from '../../helpers/password-helpers';
8
9@observer
10export default class Input extends Component {
11 static propTypes = {
12 field: PropTypes.instanceOf(Field).isRequired,
13 className: PropTypes.string,
14 focus: PropTypes.bool,
15 showPasswordToggle: PropTypes.bool,
16 showLabel: PropTypes.bool,
17 scorePassword: PropTypes.bool,
18 prefix: PropTypes.string,
19 suffix: PropTypes.string,
20 };
21
22 static defaultProps = {
23 className: null,
24 focus: false,
25 showPasswordToggle: false,
26 showLabel: true,
27 scorePassword: false,
28 prefix: '',
29 suffix: '',
30 };
31
32 state = {
33 showPassword: false,
34 passwordScore: 0,
35 }
36
37 componentDidMount() {
38 if (this.props.focus) {
39 this.focus();
40 }
41 }
42
43 onChange(e) {
44 const { field, scorePassword } = this.props;
45
46 field.onChange(e);
47
48 if (scorePassword) {
49 this.setState({ passwordScore: scorePasswordFunc(field.value) });
50 }
51 }
52
53 focus() {
54 this.inputElement.focus();
55 }
56
57 inputElement = null;
58
59 render() {
60 const {
61 field,
62 className,
63 showPasswordToggle,
64 showLabel,
65 scorePassword,
66 prefix,
67 suffix,
68 } = this.props;
69
70 const { passwordScore } = this.state;
71
72 let type = field.type;
73 if (type === 'password' && this.state.showPassword) {
74 type = 'text';
75 }
76
77 return (
78 <div
79 className={classnames({
80 'franz-form__field': true,
81 'has-error': field.error,
82 [`${className}`]: className,
83 })}
84 >
85 <div className="franz-form__input-wrapper">
86 {prefix && (
87 <span className="franz-form__input-prefix">{prefix}</span>
88 )}
89 <input
90 id={field.id}
91 type={type}
92 className="franz-form__input"
93 name={field.name}
94 value={field.value}
95 placeholder={field.placeholder}
96 onChange={e => this.onChange(e)}
97 onBlur={field.onBlur}
98 onFocus={field.onFocus}
99 ref={(element) => { this.inputElement = element; }}
100 />
101 {suffix && (
102 <span className="franz-form__input-suffix">{suffix}</span>
103 )}
104 {showPasswordToggle && (
105 <button
106 type="button"
107 className={classnames({
108 'franz-form__input-modifier': true,
109 mdi: true,
110 'mdi-eye': !this.state.showPassword,
111 'mdi-eye-off': this.state.showPassword,
112 })}
113 onClick={() => this.setState({ showPassword: !this.state.showPassword })}
114 tabIndex="-1"
115 />
116 )}
117 {scorePassword && (
118 <div className="franz-form__password-score">
119 {/* <progress value={this.state.passwordScore} max="100" /> */}
120 <meter
121 value={passwordScore < 5 ? 5 : passwordScore}
122 low="30"
123 high="75"
124 optimum="100"
125 max="100"
126 />
127 </div>
128 )}
129 </div>
130 {field.label && showLabel && (
131 <label
132 className="franz-form__label"
133 htmlFor={field.name}
134 >
135 {field.label}
136 </label>
137 )}
138 {field.error && (
139 <div
140 className="franz-form__error"
141 >
142 {field.error}
143 </div>
144 )}
145 </div>
146 );
147 }
148}
diff --git a/src/components/ui/Link.js b/src/components/ui/Link.js
new file mode 100644
index 000000000..f5da921fa
--- /dev/null
+++ b/src/components/ui/Link.js
@@ -0,0 +1,78 @@
1import { shell } from 'electron';
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import { inject, observer } from 'mobx-react';
5import { RouterStore } from 'mobx-react-router';
6import classnames from 'classnames';
7
8import { oneOrManyChildElements } from '../../prop-types';
9import { matchRoute } from '../../helpers/routing-helpers';
10
11// TODO: create container component for this component
12
13@inject('stores') @observer
14export default class Link extends Component {
15 onClick(e) {
16 if (this.props.target === '_blank') {
17 e.preventDefault();
18 shell.openExternal(this.props.to);
19 }
20 }
21
22 render() {
23 const {
24 children,
25 stores,
26 to,
27 className,
28 activeClassName,
29 strictFilter,
30 } = this.props;
31 const { router } = stores;
32
33 let filter = `${to}(*action)`;
34 if (strictFilter) {
35 filter = `${to}`;
36 }
37
38 const match = matchRoute(filter, router.location.pathname);
39
40 const linkClasses = classnames({
41 [`${className}`]: true,
42 [`${activeClassName}`]: match,
43 });
44
45 return (
46 <a
47 href={router.history.createHref(to)}
48 className={linkClasses}
49 onClick={e => this.onClick(e)}
50 >
51 {children}
52 </a>
53 );
54 }
55}
56
57Link.wrappedComponent.propTypes = {
58 stores: PropTypes.shape({
59 router: PropTypes.instanceOf(RouterStore).isRequired,
60 }).isRequired,
61 children: PropTypes.oneOfType([
62 oneOrManyChildElements,
63 PropTypes.string,
64 ]).isRequired,
65 to: PropTypes.string.isRequired,
66 className: PropTypes.string,
67 activeClassName: PropTypes.string,
68 strictFilter: PropTypes.bool,
69 target: PropTypes.string,
70};
71
72Link.wrappedComponent.defaultProps = {
73 className: '',
74 activeClassName: '',
75 strictFilter: false,
76 target: '',
77 openInBrowser: false,
78};
diff --git a/src/components/ui/Loader.js b/src/components/ui/Loader.js
new file mode 100644
index 000000000..e4fbd96a2
--- /dev/null
+++ b/src/components/ui/Loader.js
@@ -0,0 +1,41 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import Loader from 'react-loader';
4
5import { oneOrManyChildElements } from '../../prop-types';
6
7export default class LoaderComponent extends Component {
8 static propTypes = {
9 children: oneOrManyChildElements,
10 loaded: PropTypes.bool,
11 className: PropTypes.string,
12 };
13
14 static defaultProps = {
15 children: null,
16 loaded: false,
17 className: '',
18 };
19
20 render() {
21 const {
22 children,
23 loaded,
24 className,
25 } = this.props;
26
27 return (
28 <Loader
29 loaded={loaded}
30 // lines={10}
31 width={4}
32 scale={0.6}
33 color="#373a3c"
34 component="span"
35 className={className}
36 >
37 {children}
38 </Loader>
39 );
40 }
41}
diff --git a/src/components/ui/Radio.js b/src/components/ui/Radio.js
new file mode 100644
index 000000000..b54cfc820
--- /dev/null
+++ b/src/components/ui/Radio.js
@@ -0,0 +1,89 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Field } from 'mobx-react-form';
5import classnames from 'classnames';
6
7@observer
8export default class Radio extends Component {
9 static propTypes = {
10 field: PropTypes.instanceOf(Field).isRequired,
11 className: PropTypes.string,
12 focus: PropTypes.bool,
13 showLabel: PropTypes.bool,
14 };
15
16 static defaultProps = {
17 className: null,
18 focus: false,
19 showLabel: true,
20 };
21
22 componentDidMount() {
23 if (this.props.focus) {
24 this.focus();
25 }
26 }
27
28 focus() {
29 this.inputElement.focus();
30 }
31
32 inputElement = null;
33
34 render() {
35 const {
36 field,
37 className,
38 showLabel,
39 } = this.props;
40
41 return (
42 <div
43 className={classnames({
44 'franz-form__field': true,
45 'has-error': field.error,
46 [`${className}`]: className,
47 })}
48 >
49 {field.label && showLabel && (
50 <label
51 className="franz-form__label"
52 htmlFor={field.name}
53 >
54 {field.label}
55 </label>
56 )}
57 <div className="franz-form__radio-wrapper">
58 {field.options.map(type => (
59 <label
60 key={type.value}
61 htmlFor={`${field.id}-${type.value}`}
62 className={classnames({
63 'franz-form__radio': true,
64 'is-selected': field.value === type.value,
65 })}
66 >
67 <input
68 id={`${field.id}-${type.value}`}
69 type="radio"
70 name="type"
71 value={type.value}
72 onChange={field.onChange}
73 checked={field.value === type.value}
74 />
75 {type.label}
76 </label>
77 ))}
78 </div>
79 {field.error && (
80 <div
81 className="franz-form__error"
82 >
83 {field.error}
84 </div>
85 )}
86 </div>
87 );
88 }
89}
diff --git a/src/components/ui/SearchInput.js b/src/components/ui/SearchInput.js
new file mode 100644
index 000000000..bca412cef
--- /dev/null
+++ b/src/components/ui/SearchInput.js
@@ -0,0 +1,124 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import uuidv1 from 'uuid/v1';
6import { debounce } from 'lodash';
7
8@observer
9export default class SearchInput extends Component {
10 static propTypes = {
11 value: PropTypes.string,
12 defaultValue: PropTypes.string,
13 className: PropTypes.string,
14 onChange: PropTypes.func,
15 onReset: PropTypes.func,
16 name: PropTypes.string,
17 throttle: PropTypes.bool,
18 throttleDelay: PropTypes.number,
19 };
20
21 static defaultProps = {
22 value: '',
23 defaultValue: '',
24 className: '',
25 name: uuidv1(),
26 throttle: false,
27 throttleDelay: 250,
28 onChange: () => null,
29 onReset: () => null,
30 }
31
32 constructor(props) {
33 super(props);
34
35 this.state = {
36 value: props.value || props.defaultValue,
37 };
38
39 this.throttledOnChange = debounce(this.throttledOnChange, this.props.throttleDelay);
40 }
41
42 onChange(e) {
43 const { throttle, onChange } = this.props;
44 const { value } = e.target;
45 this.setState({ value });
46
47 if (throttle) {
48 e.persist();
49 this.throttledOnChange(value);
50 } else {
51 onChange(value);
52 }
53 }
54
55 onClick() {
56 const { defaultValue } = this.props;
57 const { value } = this.state;
58
59 if (value === defaultValue) {
60 this.setState({ value: '' });
61 }
62
63 this.input.focus();
64 }
65
66 onBlur() {
67 const { defaultValue } = this.props;
68 const { value } = this.state;
69
70 if (value === '') {
71 this.setState({ value: defaultValue });
72 }
73 }
74
75 throttledOnChange(e) {
76 const { onChange } = this.props;
77
78 onChange(e);
79 }
80
81 reset() {
82 const { defaultValue, onReset } = this.props;
83 this.setState({ value: defaultValue });
84
85 onReset();
86 }
87
88 input = null;
89
90 render() {
91 const { className, name, defaultValue } = this.props;
92 const { value } = this.state;
93
94 return (
95 <div
96 className={classnames([
97 className,
98 'search-input',
99 ])}
100 >
101 <label
102 htmlFor={name}
103 className="mdi mdi-magnify"
104 onClick={() => this.onClick()}
105 />
106 <input
107 name={name}
108 type="text"
109 value={value}
110 onChange={e => this.onChange(e)}
111 onClick={() => this.onClick()}
112 onBlur={() => this.onBlur()}
113 ref={(ref) => { this.input = ref; }}
114 />
115 {value !== defaultValue && value.length > 0 && (
116 <span
117 className="mdi mdi-close-circle-outline"
118 onClick={() => this.reset()}
119 />
120 )}
121 </div>
122 );
123 }
124}
diff --git a/src/components/ui/Select.js b/src/components/ui/Select.js
new file mode 100644
index 000000000..2a877af3e
--- /dev/null
+++ b/src/components/ui/Select.js
@@ -0,0 +1,70 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Field } from 'mobx-react-form';
5import classnames from 'classnames';
6
7@observer
8export default class Select extends Component {
9 static propTypes = {
10 field: PropTypes.instanceOf(Field).isRequired,
11 className: PropTypes.string,
12 showLabel: PropTypes.bool,
13 };
14
15 static defaultProps = {
16 className: null,
17 focus: false,
18 showLabel: true,
19 };
20
21 render() {
22 const {
23 field,
24 className,
25 showLabel,
26 } = this.props;
27
28 return (
29 <div
30 className={classnames({
31 'franz-form__field': true,
32 'has-error': field.error,
33 [`${className}`]: className,
34 })}
35 >
36 {field.label && showLabel && (
37 <label
38 className="franz-form__label"
39 htmlFor={field.name}
40 >
41 {field.label}
42 </label>
43 )}
44 <select
45 onChange={field.onChange}
46 id={field.id}
47 defaultValue={field.value}
48 className="franz-form__select"
49 >
50 {field.options.map(type => (
51 <option
52 key={type.value}
53 value={type.value}
54 // selected={field.value === }
55 >
56 {type.label}
57 </option>
58 ))}
59 </select>
60 {field.error && (
61 <div
62 className="franz-form__error"
63 >
64 {field.error}
65 </div>
66 )}
67 </div>
68 );
69 }
70}
diff --git a/src/components/ui/Subscription.js b/src/components/ui/Subscription.js
new file mode 100644
index 000000000..ada5cc3e0
--- /dev/null
+++ b/src/components/ui/Subscription.js
@@ -0,0 +1,265 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import Radio from '../ui/Radio';
8import Button from '../ui/Button';
9import Loader from '../ui/Loader';
10
11import { required } from '../../helpers/validation-helpers';
12
13const messages = defineMessages({
14 submitButtonLabel: {
15 id: 'subscription.submit.label',
16 defaultMessage: '!!!Support the development of Franz',
17 },
18 paymentSessionError: {
19 id: 'subscription.paymentSessionError',
20 defaultMessage: '!!!Could not initialize payment form',
21 },
22 typeFree: {
23 id: 'subscription.type.free',
24 defaultMessage: '!!!free',
25 },
26 typeMonthly: {
27 id: 'subscription.type.month',
28 defaultMessage: '!!!month',
29 },
30 typeYearly: {
31 id: 'subscription.type.year',
32 defaultMessage: '!!!year',
33 },
34 typeMining: {
35 id: 'subscription.type.mining',
36 defaultMessage: '!!!Support Franz with processing power',
37 },
38 includedFeatures: {
39 id: 'subscription.includedFeatures',
40 defaultMessage: '!!!The Franz Premium Supporter Account includes',
41 },
42 features: {
43 unlimitedServices: {
44 id: 'subscription.features.unlimitedServices',
45 defaultMessage: '!!!Add unlimited services',
46 },
47 onpremise: {
48 id: 'subscription.features.onpremise',
49 defaultMessage: '!!!Add on-premise/hosted services like HipChat',
50 },
51 customServices: {
52 id: 'subscription.features.customServices',
53 defaultMessage: '!!!Add your custom services',
54 },
55 encryptedSync: {
56 id: 'subscription.features.encryptedSync',
57 defaultMessage: '!!!Encrypted session synchronization',
58 },
59 vpn: {
60 id: 'subscription.features.vpn',
61 defaultMessage: '!!!Proxy & VPN support',
62 },
63 ads: {
64 id: 'subscription.features.ads',
65 defaultMessage: '!!!No ads, ever!',
66 },
67 comingSoon: {
68 id: 'subscription.features.comingSoon',
69 defaultMessage: '!!!coming soon',
70 },
71 },
72 miningHeadline: {
73 id: 'subscription.mining.headline',
74 defaultMessage: '!!!How does this work?',
75 },
76 experimental: {
77 id: 'subscription.mining.experimental',
78 defaultMessage: '!!!experimental',
79 },
80 miningDetail1: {
81 id: 'subscription.mining.line1',
82 defaultMessage: '!!!By enabling "Support with processing power", Franz will use about 20-50% of your CPU to mine cryptocurrency Monero which equals approximately $ 5/year.',
83 },
84 miningDetail2: {
85 id: 'subscription.mining.line2',
86 defaultMessage: '!!!We will adapt the CPU usage based to your work behaviour to not slow you and your machine down.',
87 },
88 miningDetail3: {
89 id: 'subscription.mining.line3',
90 defaultMessage: '!!!As long as the miner is active, you will have unlimited access to all the Franz Premium Supporter Features.',
91 },
92 miningMoreInfo: {
93 id: 'subscription.mining.moreInformation',
94 defaultMessage: '!!!Get more information about this plan',
95 },
96});
97
98@observer
99export default class SubscriptionForm extends Component {
100 static propTypes = {
101 plan: MobxPropTypes.objectOrObservableObject.isRequired,
102 isLoading: PropTypes.bool.isRequired,
103 handlePayment: PropTypes.func.isRequired,
104 retryPlanRequest: PropTypes.func.isRequired,
105 isCreatingHostedPage: PropTypes.bool.isRequired,
106 error: PropTypes.bool.isRequired,
107 showSkipOption: PropTypes.bool,
108 skipAction: PropTypes.func,
109 skipButtonLabel: PropTypes.string,
110 hideInfo: PropTypes.bool.isRequired,
111 openExternalUrl: PropTypes.func.isRequired,
112 };
113
114 static defaultProps ={
115 content: '',
116 showSkipOption: false,
117 skipAction: () => null,
118 skipButtonLabel: '',
119 }
120
121 static contextTypes = {
122 intl: intlShape,
123 };
124
125 componentWillMount() {
126 this.form = this.prepareForm();
127 }
128
129 prepareForm() {
130 const { intl } = this.context;
131
132 const form = {
133 fields: {
134 paymentTier: {
135 value: 'year',
136 validate: [required],
137 options: [{
138 value: 'month',
139 label: `$ ${Object.hasOwnProperty.call(this.props.plan, 'month')
140 ? `${this.props.plan.month.price} / ${intl.formatMessage(messages.typeMonthly)}`
141 : 'monthly'}`,
142 }, {
143 value: 'year',
144 label: `$ ${Object.hasOwnProperty.call(this.props.plan, 'year')
145 ? `${this.props.plan.year.price} / ${intl.formatMessage(messages.typeYearly)}`
146 : 'yearly'}`,
147 }, {
148 value: 'mining',
149 label: intl.formatMessage(messages.typeMining),
150 }],
151 },
152 },
153 };
154
155 if (this.props.showSkipOption) {
156 form.fields.paymentTier.options.unshift({
157 value: 'skip',
158 label: `$ 0 / ${intl.formatMessage(messages.typeFree)}`,
159 });
160 }
161
162 return new Form(form, this.context.intl);
163 }
164
165 render() {
166 const {
167 isLoading,
168 isCreatingHostedPage,
169 handlePayment,
170 retryPlanRequest,
171 error,
172 showSkipOption,
173 skipAction,
174 skipButtonLabel,
175 hideInfo,
176 openExternalUrl,
177 } = this.props;
178 const { intl } = this.context;
179
180 if (error) {
181 return (
182 <Button
183 label="Reload"
184 onClick={retryPlanRequest}
185 isLoaded={!isLoading}
186 />
187 );
188 }
189
190 return (
191 <Loader loaded={!isLoading}>
192 <Radio field={this.form.$('paymentTier')} showLabel={false} className="paymentTiers" />
193 {!hideInfo && (
194 <div className="subscription__premium-info">
195 {this.form.$('paymentTier').value !== 'mining' && (
196 <div>
197 <p>
198 <strong>{intl.formatMessage(messages.includedFeatures)}</strong>
199 </p>
200 <div className="subscription">
201 <ul className="subscription__premium-features">
202 <li>{intl.formatMessage(messages.features.onpremise)}</li>
203 <li>
204 {intl.formatMessage(messages.features.encryptedSync)}
205 <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span>
206 </li>
207 <li>
208 {intl.formatMessage(messages.features.customServices)}
209 <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span>
210 </li>
211 <li>
212 {intl.formatMessage(messages.features.vpn)}
213 <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span>
214 </li>
215 <li>
216 {intl.formatMessage(messages.features.ads)}
217 </li>
218 </ul>
219 </div>
220 </div>
221 )}
222 {this.form.$('paymentTier').value === 'mining' && (
223 <div className="subscription mining-details">
224 <p>
225 <strong>{intl.formatMessage(messages.miningHeadline)}</strong>
226 &nbsp;
227 <span className="badge">{intl.formatMessage(messages.experimental)}</span>
228 </p>
229 <p>{intl.formatMessage(messages.miningDetail1)}</p>
230 <p>{intl.formatMessage(messages.miningDetail2)}</p>
231 <p>{intl.formatMessage(messages.miningDetail3)}</p>
232 <p>
233 <button
234 onClick={() => openExternalUrl({ url: 'http://meetfranz.com/mining' })}
235 >
236 {intl.formatMessage(messages.miningMoreInfo)}
237 </button>
238 </p>
239 </div>
240 )}
241 </div>
242 )}
243 <div>
244 {error.code === 'no-payment-session' && (
245 <p className="error-message center">{intl.formatMessage(messages.paymentSessionError)}</p>
246 )}
247 </div>
248 {showSkipOption && this.form.$('paymentTier').value === 'skip' ? (
249 <Button
250 label={skipButtonLabel}
251 className="auth__button"
252 onClick={skipAction}
253 />
254 ) : (
255 <Button
256 label={intl.formatMessage(messages.submitButtonLabel)}
257 className="auth__button"
258 loaded={!isCreatingHostedPage}
259 onClick={() => handlePayment(this.form.$('paymentTier').value)}
260 />
261 )}
262 </Loader>
263 );
264 }
265}
diff --git a/src/components/ui/SubscriptionPopup.js b/src/components/ui/SubscriptionPopup.js
new file mode 100644
index 000000000..72b6ccd98
--- /dev/null
+++ b/src/components/ui/SubscriptionPopup.js
@@ -0,0 +1,84 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import Webview from 'react-electron-web-view';
6
7import Button from '../ui/Button';
8
9const messages = defineMessages({
10 buttonCancel: {
11 id: 'subscriptionPopup.buttonCancel',
12 defaultMessage: '!!!Cancel',
13 },
14 buttonDone: {
15 id: 'subscriptionPopup.buttonDone',
16 defaultMessage: '!!!Done',
17 },
18});
19
20@observer
21export default class SubscriptionPopup extends Component {
22 static propTypes = {
23 url: PropTypes.string.isRequired,
24 closeWindow: PropTypes.func.isRequired,
25 completeCheck: PropTypes.func.isRequired,
26 isCompleted: PropTypes.bool.isRequired,
27 };
28
29 static contextTypes = {
30 intl: intlShape,
31 };
32
33 state = {
34 isFakeLoading: false,
35 };
36
37 // We delay the window closing a bit in order to give
38 // the Recurly webhook a few seconds to do it's magic
39 delayedCloseWindow() {
40 this.setState({
41 isFakeLoading: true,
42 });
43
44 setTimeout(() => {
45 this.props.closeWindow();
46 }, 4000);
47 }
48
49 render() {
50 const { url, closeWindow, completeCheck, isCompleted } = this.props;
51 const { intl } = this.context;
52
53 return (
54 <div className="subscription-popup">
55 <div className="subscription-popup__content">
56 <Webview
57 className="subscription-popup__webview"
58
59 autosize
60 src={url}
61 disablewebsecurity
62 onDidNavigate={completeCheck}
63 // onNewWindow={(event, url, frameName, options) =>
64 // openWindow({ event, url, frameName, options })}
65 />
66 </div>
67 <div className="subscription-popup__toolbar franz-form">
68 <Button
69 label={intl.formatMessage(messages.buttonCancel)}
70 buttonType="secondary"
71 onClick={closeWindow}
72 disabled={isCompleted}
73 />
74 <Button
75 label={intl.formatMessage(messages.buttonDone)}
76 onClick={() => this.delayedCloseWindow()}
77 disabled={!isCompleted}
78 loaded={!this.state.isFakeLoading}
79 />
80 </div>
81 </div>
82 );
83 }
84}
diff --git a/src/components/ui/Tabs/TabItem.js b/src/components/ui/Tabs/TabItem.js
new file mode 100644
index 000000000..9ff9f009e
--- /dev/null
+++ b/src/components/ui/Tabs/TabItem.js
@@ -0,0 +1,17 @@
1import React, { Component } from 'react';
2
3import { oneOrManyChildElements } from '../../../prop-types';
4
5export default class TabItem extends Component {
6 static propTypes = {
7 children: oneOrManyChildElements.isRequired,
8 }
9
10 render() {
11 const { children } = this.props;
12
13 return (
14 <div>{children}</div>
15 );
16 }
17}
diff --git a/src/components/ui/Tabs/Tabs.js b/src/components/ui/Tabs/Tabs.js
new file mode 100644
index 000000000..50397f9bb
--- /dev/null
+++ b/src/components/ui/Tabs/Tabs.js
@@ -0,0 +1,69 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5
6import { oneOrManyChildElements } from '../../../prop-types';
7
8@observer
9export default class Tab extends Component {
10 static propTypes = {
11 children: oneOrManyChildElements.isRequired,
12 active: PropTypes.number,
13 };
14
15 static defaultProps = {
16 active: 0,
17 };
18
19 componentWillMount() {
20 this.setState({ active: this.props.active });
21 }
22
23 switchTab(index) {
24 this.setState({ active: index });
25 }
26
27 render() {
28 const { children: childElements } = this.props;
29 const children = childElements.filter(c => !!c);
30
31 if (children.length === 1) {
32 return <div>{children}</div>;
33 }
34
35 return (
36 <div className="content-tabs">
37 <div className="content-tabs__tabs">
38 {React.Children.map(children, (child, i) => (
39 <button
40 key={i}
41 className={classnames({
42 'content-tabs__item': true,
43 'is-active': this.state.active === i,
44 })}
45 onClick={() => this.switchTab(i)}
46 type="button"
47 >
48 {child.props.title}
49 </button>
50 ))}
51 </div>
52 <div className="content-tabs__content">
53 {React.Children.map(children, (child, i) => (
54 <div
55 key={i}
56 className={classnames({
57 'content-tabs__item': true,
58 'is-active': this.state.active === i,
59 })}
60 type="button"
61 >
62 {child}
63 </div>
64 ))}
65 </div>
66 </div>
67 );
68 }
69}
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 @@
1import Tabs from './Tabs';
2import TabItem from './TabItem';
3
4export default Tabs;
5
6export { 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 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import { Field } from 'mobx-react-form';
6
7@observer
8export default class Toggle extends Component {
9 static propTypes = {
10 field: PropTypes.instanceOf(Field).isRequired,
11 className: PropTypes.string,
12 showLabel: PropTypes.bool,
13 };
14
15 static defaultProps = {
16 className: '',
17 showLabel: true,
18 };
19
20 onChange(e) {
21 const { field } = this.props;
22
23 field.onChange(e);
24 }
25
26 render() {
27 const {
28 field,
29 className,
30 showLabel,
31 } = this.props;
32
33 if (field.value === '' && field.default !== '') {
34 field.value = field.default;
35 }
36
37 return (
38 <div
39 className={classnames([
40 'franz-form__field',
41 'franz-form__toggle-wrapper',
42 className,
43 ])}
44 >
45 <label
46 htmlFor={field.id}
47 className={classnames({
48 'franz-form__toggle': true,
49 'is-active': field.value,
50 })}
51 >
52 <div className="franz-form__toggle-button" />
53 <input
54 type="checkbox"
55 id={field.id}
56 name={field.name}
57 value={field.name}
58 checked={field.value}
59 onChange={e => this.onChange(e)}
60 />
61 </label>
62 {field.error && <div className={field.error}>{field.error}</div>}
63 {field.label && showLabel && <label className="franz-form__label" htmlFor={field.id}>{field.label}</label>}
64 </div>
65 );
66 }
67}
diff --git a/src/components/ui/effects/Appear.js b/src/components/ui/effects/Appear.js
new file mode 100644
index 000000000..1255fce2e
--- /dev/null
+++ b/src/components/ui/effects/Appear.js
@@ -0,0 +1,51 @@
1/* eslint-disable react/no-did-mount-set-state */
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
5
6export default class Appear extends Component {
7 static propTypes = {
8 children: PropTypes.any.isRequired, // eslint-disable-line
9 transitionName: PropTypes.string,
10 className: PropTypes.string,
11 };
12
13 static defaultProps = {
14 transitionName: 'fadeIn',
15 className: '',
16 };
17
18 state = {
19 mounted: false,
20 };
21
22 componentDidMount() {
23 this.setState({ mounted: true });
24 }
25
26 render() {
27 const {
28 children,
29 transitionName,
30 className,
31 } = this.props;
32
33 if (!this.state.mounted) {
34 return null;
35 }
36
37 return (
38 <ReactCSSTransitionGroup
39 transitionName={transitionName}
40 transitionAppear
41 transitionLeave
42 transitionAppearTimeout={1500}
43 transitionEnterTimeout={1500}
44 transitionLeaveTimeout={1500}
45 className={className}
46 >
47 {children}
48 </ReactCSSTransitionGroup>
49 );
50 }
51}