diff options
author | Stefan Malzner <stefan@adlk.io> | 2017-10-13 12:29:40 +0200 |
---|---|---|
committer | Stefan Malzner <stefan@adlk.io> | 2017-10-13 12:29:40 +0200 |
commit | 58cda9cc7fb79ca9df6746de7f9662bc08dc156a (patch) | |
tree | 1211600c2a5d3b5f81c435c6896618111a611720 /src/components | |
download | ferdium-app-58cda9cc7fb79ca9df6746de7f9662bc08dc156a.tar.gz ferdium-app-58cda9cc7fb79ca9df6746de7f9662bc08dc156a.tar.zst ferdium-app-58cda9cc7fb79ca9df6746de7f9662bc08dc156a.zip |
initial commit
Diffstat (limited to 'src/components')
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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { RouteTransition } from 'react-router-transition'; | ||
5 | import { intlShape } from 'react-intl'; | ||
6 | |||
7 | import Link from '../ui/Link'; | ||
8 | import InfoBar from '../ui/InfoBar'; | ||
9 | |||
10 | import { oneOrManyChildElements, globalError as globalErrorPropType } from '../../prop-types'; | ||
11 | import globalMessages from '../../i18n/globalMessages'; | ||
12 | |||
13 | @observer | ||
14 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import Form from '../../lib/Form'; | ||
9 | import Toggle from '../ui/Toggle'; | ||
10 | import Button from '../ui/Button'; | ||
11 | |||
12 | const 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 | ||
32 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | |||
7 | import Form from '../../lib/Form'; | ||
8 | import { email } from '../../helpers/validation-helpers'; | ||
9 | import Input from '../ui/Input'; | ||
10 | import Button from '../ui/Button'; | ||
11 | |||
12 | const 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 | ||
36 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import { required, email } from '../../helpers/validation-helpers'; | ||
8 | import Input from '../ui/Input'; | ||
9 | import Button from '../ui/Button'; | ||
10 | import Link from '../ui/Link'; | ||
11 | |||
12 | import { globalError as globalErrorPropType } from '../../prop-types'; | ||
13 | |||
14 | // import Appear from '../ui/effects/Appear'; | ||
15 | |||
16 | const 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 | ||
56 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import { required, email } from '../../helpers/validation-helpers'; | ||
8 | import Input from '../ui/Input'; | ||
9 | import Button from '../ui/Button'; | ||
10 | import Link from '../ui/Link'; | ||
11 | import Infobox from '../ui/Infobox'; | ||
12 | |||
13 | const 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 | ||
45 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | // import { Link } from 'react-router'; | ||
6 | |||
7 | // import Button from '../ui/Button'; | ||
8 | import Loader from '../ui/Loader'; | ||
9 | import Appear from '../ui/effects/Appear'; | ||
10 | import SubscriptionForm from '../../containers/ui/SubscriptionFormScreen'; | ||
11 | |||
12 | const 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 | ||
32 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import { required, email, minLength } from '../../helpers/validation-helpers'; | ||
8 | import Input from '../ui/Input'; | ||
9 | import Radio from '../ui/Radio'; | ||
10 | import Button from '../ui/Button'; | ||
11 | import Link from '../ui/Link'; | ||
12 | |||
13 | import { globalError as globalErrorPropType } from '../../prop-types'; | ||
14 | |||
15 | const 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 | ||
67 | export 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 | & | ||
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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Link from '../ui/Link'; | ||
7 | |||
8 | const 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 | ||
20 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import InfoBar from '../ui/InfoBar'; | ||
7 | import globalMessages from '../../i18n/globalMessages'; | ||
8 | |||
9 | function createMarkup(HTMLString) { | ||
10 | return { __html: HTMLString }; | ||
11 | } | ||
12 | |||
13 | const 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 | ||
37 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import ReactTooltip from 'react-tooltip'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Tabbar from '../services/tabs/Tabbar'; | ||
7 | import { ctrlKey } from '../../environment'; | ||
8 | |||
9 | const messages = defineMessages({ | ||
10 | settings: { | ||
11 | id: 'sidebar.settings', | ||
12 | defaultMessage: '!!!Settings', | ||
13 | }, | ||
14 | }); | ||
15 | |||
16 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { autorun } from 'mobx'; | ||
4 | import { observer } from 'mobx-react'; | ||
5 | import Webview from 'react-electron-web-view'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import ServiceModel from '../../../models/Service'; | ||
9 | |||
10 | @observer | ||
11 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { Link } from 'react-router'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | |||
7 | import Webview from './ServiceWebview'; | ||
8 | import Appear from '../../ui/effects/Appear'; | ||
9 | |||
10 | const 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 | ||
22 | export 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 @@ | |||
1 | import React from 'react'; | ||
2 | import { observer } from 'mobx-react'; | ||
3 | import { SortableContainer } from 'react-sortable-hoc'; | ||
4 | |||
5 | import TabItem from './TabItem'; | ||
6 | import { ctrlKey } from '../../../environment'; | ||
7 | |||
8 | export 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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import { defineMessages, intlShape } from 'react-intl'; | ||
4 | import PropTypes from 'prop-types'; | ||
5 | import { observer } from 'mobx-react'; | ||
6 | import classnames from 'classnames'; | ||
7 | import { SortableElement } from 'react-sortable-hoc'; | ||
8 | |||
9 | import ServiceModel from '../../../models/Service'; | ||
10 | import { ctrlKey } from '../../../environment'; | ||
11 | |||
12 | const { Menu } = remote; | ||
13 | |||
14 | const 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 | ||
42 | class 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 | |||
136 | export default SortableElement(TabItem); | ||
diff --git a/src/components/services/tabs/Tabbar.js b/src/components/services/tabs/Tabbar.js new file mode 100644 index 000000000..fdb2c0a59 --- /dev/null +++ b/src/components/services/tabs/Tabbar.js | |||
@@ -0,0 +1,77 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | |||
5 | import TabBarSortableList from './TabBarSortableList'; | ||
6 | |||
7 | @observer | ||
8 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | |||
5 | import { oneOrManyChildElements } from '../../prop-types'; | ||
6 | import Appear from '../ui/effects/Appear'; | ||
7 | |||
8 | @observer | ||
9 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape, FormattedMessage } from 'react-intl'; | ||
5 | import ReactTooltip from 'react-tooltip'; | ||
6 | import moment from 'moment'; | ||
7 | |||
8 | import Loader from '../../ui/Loader'; | ||
9 | import Button from '../../ui/Button'; | ||
10 | import Infobox from '../../ui/Infobox'; | ||
11 | import Link from '../../ui/Link'; | ||
12 | import SubscriptionForm from '../../../containers/ui/SubscriptionFormScreen'; | ||
13 | |||
14 | const 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 | ||
78 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { defineMessages, intlShape } from 'react-intl'; | ||
4 | |||
5 | import Link from '../../ui/Link'; | ||
6 | |||
7 | const 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 | |||
30 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | |||
5 | import RecipePreviewModel from '../../../models/RecipePreview'; | ||
6 | |||
7 | @observer | ||
8 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | |||
7 | import SearchInput from '../../ui/SearchInput'; | ||
8 | import Infobox from '../../ui/Infobox'; | ||
9 | import RecipeItem from './RecipeItem'; | ||
10 | import Loader from '../../ui/Loader'; | ||
11 | import Appear from '../../ui/effects/Appear'; | ||
12 | |||
13 | const 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 | ||
41 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Link } from 'react-router'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | import normalizeUrl from 'normalize-url'; | ||
7 | |||
8 | import Form from '../../../lib/Form'; | ||
9 | import User from '../../../models/User'; | ||
10 | import Recipe from '../../../models/Recipe'; | ||
11 | import Service from '../../../models/Service'; | ||
12 | import Tabs, { TabItem } from '../../ui/Tabs'; | ||
13 | import Input from '../../ui/Input'; | ||
14 | import Toggle from '../../ui/Toggle'; | ||
15 | import Button from '../../ui/Button'; | ||
16 | |||
17 | const 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 | ||
69 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import { observer } from 'mobx-react'; | ||
3 | import { Link } from 'react-router'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Infobox from '../../ui/Infobox'; | ||
7 | import Button from '../../ui/Button'; | ||
8 | |||
9 | const 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 | ||
29 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { defineMessages, intlShape } from 'react-intl'; | ||
4 | import ReactTooltip from 'react-tooltip'; | ||
5 | import { observer } from 'mobx-react'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import ServiceModel from '../../../models/Service'; | ||
9 | |||
10 | const 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 | ||
22 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { Link } from 'react-router'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | |||
7 | import SearchInput from '../../ui/SearchInput'; | ||
8 | import Infobox from '../../ui/Infobox'; | ||
9 | import Loader from '../../ui/Loader'; | ||
10 | import ServiceItem from './ServiceItem'; | ||
11 | import Appear from '../../ui/effects/Appear'; | ||
12 | |||
13 | const 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 | ||
45 | export 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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { observer } from 'mobx-react'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | |||
7 | import Form from '../../../lib/Form'; | ||
8 | import Button from '../../ui/Button'; | ||
9 | import Toggle from '../../ui/Toggle'; | ||
10 | import Select from '../../ui/Select'; | ||
11 | |||
12 | const 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 | ||
56 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | |||
7 | // import { Link } from 'react-router'; | ||
8 | |||
9 | import Form from '../../../lib/Form'; | ||
10 | import Input from '../../ui/Input'; | ||
11 | import Button from '../../ui/Button'; | ||
12 | import Radio from '../../ui/Radio'; | ||
13 | import Infobox from '../../ui/Infobox'; | ||
14 | |||
15 | const 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 | ||
43 | export 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 @@ | |||
1 | import React from 'react'; | ||
2 | |||
3 | import Appear from '../../components/ui/effects/Appear'; | ||
4 | import Loader from '../../components/ui/Loader'; | ||
5 | |||
6 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import Loader from 'react-loader'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | @observer | ||
8 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import Loader from 'react-loader'; | ||
6 | |||
7 | // import { oneOrManyChildElements } from '../../prop-types'; | ||
8 | import Appear from '../ui/effects/Appear'; | ||
9 | |||
10 | @observer | ||
11 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import Loader from 'react-loader'; | ||
6 | |||
7 | @observer | ||
8 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Field } from 'mobx-react-form'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | import { scorePassword as scorePasswordFunc } from '../../helpers/password-helpers'; | ||
8 | |||
9 | @observer | ||
10 | export 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 @@ | |||
1 | import { shell } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { inject, observer } from 'mobx-react'; | ||
5 | import { RouterStore } from 'mobx-react-router'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import { oneOrManyChildElements } from '../../prop-types'; | ||
9 | import { matchRoute } from '../../helpers/routing-helpers'; | ||
10 | |||
11 | // TODO: create container component for this component | ||
12 | |||
13 | @inject('stores') @observer | ||
14 | export 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 | |||
57 | Link.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 | |||
72 | Link.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import Loader from 'react-loader'; | ||
4 | |||
5 | import { oneOrManyChildElements } from '../../prop-types'; | ||
6 | |||
7 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Field } from 'mobx-react-form'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | @observer | ||
8 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import uuidv1 from 'uuid/v1'; | ||
6 | import { debounce } from 'lodash'; | ||
7 | |||
8 | @observer | ||
9 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Field } from 'mobx-react-form'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | @observer | ||
8 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import Radio from '../ui/Radio'; | ||
8 | import Button from '../ui/Button'; | ||
9 | import Loader from '../ui/Loader'; | ||
10 | |||
11 | import { required } from '../../helpers/validation-helpers'; | ||
12 | |||
13 | const 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 | ||
99 | export 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 | | ||
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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import Webview from 'react-electron-web-view'; | ||
6 | |||
7 | import Button from '../ui/Button'; | ||
8 | |||
9 | const 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 | ||
21 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | |||
3 | import { oneOrManyChildElements } from '../../../prop-types'; | ||
4 | |||
5 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | |||
6 | import { oneOrManyChildElements } from '../../../prop-types'; | ||
7 | |||
8 | @observer | ||
9 | export 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 @@ | |||
1 | import Tabs from './Tabs'; | ||
2 | import TabItem from './TabItem'; | ||
3 | |||
4 | export default Tabs; | ||
5 | |||
6 | export { TabItem }; | ||
diff --git a/src/components/ui/Toggle.js b/src/components/ui/Toggle.js new file mode 100644 index 000000000..62d46393e --- /dev/null +++ b/src/components/ui/Toggle.js | |||
@@ -0,0 +1,67 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import { Field } from 'mobx-react-form'; | ||
6 | |||
7 | @observer | ||
8 | export 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 */ | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; | ||
5 | |||
6 | export 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 | } | ||