aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/I18n.js28
-rw-r--r--src/actions/app.js23
-rw-r--r--src/actions/index.js28
-rw-r--r--src/actions/lib/actions.js18
-rw-r--r--src/actions/news.js7
-rw-r--r--src/actions/payment.js8
-rw-r--r--src/actions/recipe.js9
-rw-r--r--src/actions/recipePreview.js7
-rw-r--r--src/actions/requests.js3
-rw-r--r--src/actions/service.js75
-rw-r--r--src/actions/settings.js10
-rw-r--r--src/actions/ui.js11
-rw-r--r--src/actions/user.js30
-rw-r--r--src/api/AppApi.js9
-rw-r--r--src/api/LocalApi.js18
-rw-r--r--src/api/NewsApi.js14
-rw-r--r--src/api/PaymentApi.js22
-rw-r--r--src/api/RecipePreviewsApi.js17
-rw-r--r--src/api/RecipesApi.js17
-rw-r--r--src/api/ServicesApi.js33
-rw-r--r--src/api/UserApi.js49
-rw-r--r--src/api/index.js19
-rw-r--r--src/api/server/LocalApi.js33
-rw-r--r--src/api/server/ServerApi.js574
-rw-r--r--src/app.js103
-rwxr-xr-xsrc/assets/fonts/OpenSans-Bold.ttfbin0 -> 224592 bytes
-rwxr-xr-xsrc/assets/fonts/OpenSans-BoldItalic.ttfbin0 -> 213292 bytes
-rwxr-xr-xsrc/assets/fonts/OpenSans-ExtraBold.ttfbin0 -> 222584 bytes
-rwxr-xr-xsrc/assets/fonts/OpenSans-ExtraBoldItalic.ttfbin0 -> 213420 bytes
-rwxr-xr-xsrc/assets/fonts/OpenSans-Light.ttfbin0 -> 222412 bytes
-rwxr-xr-xsrc/assets/fonts/OpenSans-Regular.ttfbin0 -> 217360 bytes
-rw-r--r--src/assets/images/adlk.svg53
-rw-r--r--src/assets/images/emoji/dontknow.pngbin0 -> 29336 bytes
-rw-r--r--src/assets/images/emoji/sad.pngbin0 -> 25270 bytes
-rwxr-xr-xsrc/assets/images/emoji/star.pngbin0 -> 16093 bytes
-rw-r--r--src/assets/images/logo.svg35
-rw-r--r--src/assets/images/sm.pngbin0 -> 751417 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-1.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-10.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-2.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-3.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-4.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-5.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-6.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-7.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-8.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-9.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/taskbar/win32/taskbar-alert.icobin0 -> 32038 bytes
-rw-r--r--src/assets/images/tray/darwin/tray-active.pngbin0 -> 396 bytes
-rw-r--r--src/assets/images/tray/darwin/tray-active@2x.pngbin0 -> 1291 bytes
-rw-r--r--src/assets/images/tray/darwin/tray-unread-active.pngbin0 -> 424 bytes
-rw-r--r--src/assets/images/tray/darwin/tray-unread-active@2x.pngbin0 -> 1359 bytes
-rw-r--r--src/assets/images/tray/darwin/tray-unread.pngbin0 -> 1264 bytes
-rw-r--r--src/assets/images/tray/darwin/tray-unread@2x.pngbin0 -> 2026 bytes
-rw-r--r--src/assets/images/tray/darwin/tray.pngbin0 -> 1230 bytes
-rw-r--r--src/assets/images/tray/darwin/tray@2x.pngbin0 -> 1545 bytes
-rw-r--r--src/assets/images/tray/linux/tray-unread.pngbin0 -> 1264 bytes
-rw-r--r--src/assets/images/tray/linux/tray-unread@2x.pngbin0 -> 2026 bytes
-rw-r--r--src/assets/images/tray/linux/tray.pngbin0 -> 1230 bytes
-rw-r--r--src/assets/images/tray/linux/tray@2x.pngbin0 -> 1545 bytes
-rw-r--r--src/assets/images/tray/win32/tray-unread.icobin0 -> 1150 bytes
-rw-r--r--src/assets/images/tray/win32/tray-unread@2x.icobin0 -> 5430 bytes
-rw-r--r--src/assets/images/tray/win32/tray.icobin0 -> 1150 bytes
-rw-r--r--src/assets/images/tray/win32/tray@2x.icobin0 -> 5430 bytes
-rw-r--r--src/components/auth/AuthLayout.js88
-rw-r--r--src/components/auth/Import.js168
-rw-r--r--src/components/auth/Invite.js111
-rw-r--r--src/components/auth/Login.js161
-rw-r--r--src/components/auth/Password.js135
-rw-r--r--src/components/auth/Pricing.js130
-rw-r--r--src/components/auth/Signup.js206
-rw-r--r--src/components/auth/Welcome.js69
-rw-r--r--src/components/layout/AppLayout.js148
-rw-r--r--src/components/layout/Sidebar.js75
-rw-r--r--src/components/services/content/ServiceWebview.js73
-rw-r--r--src/components/services/content/Services.js81
-rw-r--r--src/components/services/tabs/TabBarSortableList.js44
-rw-r--r--src/components/services/tabs/TabItem.js136
-rw-r--r--src/components/services/tabs/Tabbar.js77
-rw-r--r--src/components/settings/SettingsLayout.js56
-rw-r--r--src/components/settings/account/AccountDashboard.js286
-rw-r--r--src/components/settings/navigation/SettingsNavigation.js84
-rw-r--r--src/components/settings/recipes/RecipeItem.js34
-rw-r--r--src/components/settings/recipes/RecipesDashboard.js151
-rw-r--r--src/components/settings/services/EditServiceForm.js277
-rw-r--r--src/components/settings/services/ServiceError.js68
-rw-r--r--src/components/settings/services/ServiceItem.js98
-rw-r--r--src/components/settings/services/ServicesDashboard.js155
-rw-r--r--src/components/settings/settings/EditSettingsForm.js148
-rw-r--r--src/components/settings/user/EditUserForm.js145
-rw-r--r--src/components/ui/AppLoader.js15
-rw-r--r--src/components/ui/Button.js78
-rw-r--r--src/components/ui/InfoBar.js88
-rw-r--r--src/components/ui/Infobox.js87
-rw-r--r--src/components/ui/Input.js148
-rw-r--r--src/components/ui/Link.js78
-rw-r--r--src/components/ui/Loader.js41
-rw-r--r--src/components/ui/Radio.js89
-rw-r--r--src/components/ui/SearchInput.js124
-rw-r--r--src/components/ui/Select.js70
-rw-r--r--src/components/ui/Subscription.js265
-rw-r--r--src/components/ui/SubscriptionPopup.js84
-rw-r--r--src/components/ui/Tabs/TabItem.js17
-rw-r--r--src/components/ui/Tabs/Tabs.js69
-rw-r--r--src/components/ui/Tabs/index.js6
-rw-r--r--src/components/ui/Toggle.js67
-rw-r--r--src/components/ui/effects/Appear.js51
-rw-r--r--src/config.js5
-rw-r--r--src/containers/auth/AuthLayoutContainer.js47
-rw-r--r--src/containers/auth/ImportScreen.js41
-rw-r--r--src/containers/auth/InviteScreen.js29
-rw-r--r--src/containers/auth/LoginScreen.js45
-rw-r--r--src/containers/auth/PasswordScreen.js38
-rw-r--r--src/containers/auth/PricingScreen.js53
-rw-r--r--src/containers/auth/SignupScreen.js43
-rw-r--r--src/containers/auth/WelcomeScreen.js34
-rw-r--r--src/containers/layout/AppLayoutContainer.js166
-rw-r--r--src/containers/settings/AccountScreen.js114
-rw-r--r--src/containers/settings/EditServiceScreen.js208
-rw-r--r--src/containers/settings/EditSettingsScreen.js167
-rw-r--r--src/containers/settings/EditUserScreen.js165
-rw-r--r--src/containers/settings/RecipesScreen.js126
-rw-r--r--src/containers/settings/ServicesScreen.js75
-rw-r--r--src/containers/settings/SettingsWindow.js43
-rw-r--r--src/containers/ui/SubscriptionFormScreen.js126
-rw-r--r--src/containers/ui/SubscriptionPopupScreen.js43
-rw-r--r--src/electron/Settings.js15
-rw-r--r--src/electron/exception.js4
-rw-r--r--src/electron/ipc-api/appIndicator.js80
-rw-r--r--src/electron/ipc-api/autoUpdate.js54
-rw-r--r--src/electron/ipc-api/index.js9
-rw-r--r--src/electron/ipc-api/settings.js10
-rw-r--r--src/electron/ipc-api/tray.js48
-rw-r--r--src/electron/webview-ime-focus.js40
-rw-r--r--src/environment.js22
-rw-r--r--src/helpers/password-helpers.js36
-rw-r--r--src/helpers/recipe-helpers.js39
-rw-r--r--src/helpers/routing-helpers.js4
-rw-r--r--src/helpers/validation-helpers.js48
-rw-r--r--src/helpers/webview-ime-focus-helpers.js38
-rw-r--r--src/i18n/globalMessages.js16
-rw-r--r--src/i18n/languages.js4
-rw-r--r--src/i18n/locales/en-US.json167
-rw-r--r--src/i18n/translations.js13
-rw-r--r--src/index.html30
-rw-r--r--src/index.js147
-rw-r--r--src/lib/Form.js31
-rw-r--r--src/lib/Menu.js259
-rw-r--r--src/lib/Miner.js72
-rw-r--r--src/lib/TouchBar.js45
-rw-r--r--src/lib/analytics.js42
-rw-r--r--src/models/News.js19
-rw-r--r--src/models/Order.js17
-rw-r--r--src/models/Plan.js16
-rw-r--r--src/models/Recipe.js52
-rw-r--r--src/models/RecipePreview.js16
-rw-r--r--src/models/Service.js132
-rw-r--r--src/models/User.js41
-rw-r--r--src/prop-types.js14
-rw-r--r--src/stores/AppStore.js309
-rw-r--r--src/stores/GlobalErrorStore.js28
-rw-r--r--src/stores/NewsStore.js42
-rw-r--r--src/stores/PaymentStore.js47
-rw-r--r--src/stores/RecipePreviewsStore.js50
-rw-r--r--src/stores/RecipesStore.js96
-rw-r--r--src/stores/RequestStore.js59
-rw-r--r--src/stores/ServicesStore.js503
-rw-r--r--src/stores/SettingsStore.js55
-rw-r--r--src/stores/UIStore.js34
-rw-r--r--src/stores/UserStore.js272
-rw-r--r--src/stores/index.js34
-rw-r--r--src/stores/lib/CachedRequest.js106
-rw-r--r--src/stores/lib/Reaction.js22
-rw-r--r--src/stores/lib/Request.js112
-rw-r--r--src/stores/lib/Store.js44
-rw-r--r--src/styles/animations.scss90
-rw-r--r--src/styles/auth.scss144
-rw-r--r--src/styles/badge.scss15
-rw-r--r--src/styles/button.scss74
-rw-r--r--src/styles/colors.scss22
-rw-r--r--src/styles/config.scss1
-rw-r--r--src/styles/content-tabs.scss52
-rw-r--r--src/styles/fonts.scss44
-rw-r--r--src/styles/info-bar.scss79
-rw-r--r--src/styles/infobox.scss61
-rw-r--r--src/styles/input.scss99
-rw-r--r--src/styles/layout.scss141
-rw-r--r--src/styles/main.scss36
-rw-r--r--src/styles/mixins.scss9
-rw-r--r--src/styles/radio.scss34
-rw-r--r--src/styles/recipes.scss72
-rw-r--r--src/styles/reset.scss95
-rw-r--r--src/styles/searchInput.scss4
-rw-r--r--src/styles/select.scss19
-rw-r--r--src/styles/service-table.scss62
-rw-r--r--src/styles/services.scss60
-rw-r--r--src/styles/settings.scss392
-rw-r--r--src/styles/subscription-popup.scss20
-rw-r--r--src/styles/subscription.scss72
-rw-r--r--src/styles/tabs.scss72
-rw-r--r--src/styles/toggle.scss47
-rw-r--r--src/styles/tooltip.scss4
-rw-r--r--src/styles/type.scss73
-rw-r--r--src/styles/util.scss20
-rw-r--r--src/styles/welcome.scss75
-rw-r--r--src/webview/ime.js10
-rw-r--r--src/webview/lib/RecipeWebview.js74
-rw-r--r--src/webview/notifications.js45
-rw-r--r--src/webview/plugin.js24
-rw-r--r--src/webview/spellchecker.js14
-rw-r--r--src/webview/zoom.js37
211 files changed, 12987 insertions, 0 deletions
diff --git a/src/I18n.js b/src/I18n.js
new file mode 100644
index 000000000..ae3ba2fa9
--- /dev/null
+++ b/src/I18n.js
@@ -0,0 +1,28 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { IntlProvider } from 'react-intl';
5
6import { oneOrManyChildElements } from './prop-types';
7import translations from './i18n/translations';
8import UserStore from './stores/UserStore';
9
10@inject('stores') @observer
11export default class I18N extends Component {
12 render() {
13 const { stores, children } = this.props;
14 const { locale } = stores.app;
15 return (
16 <IntlProvider {...{ locale, key: locale, messages: translations[locale] }}>
17 {children}
18 </IntlProvider>
19 );
20 }
21}
22
23I18N.wrappedComponent.propTypes = {
24 stores: PropTypes.shape({
25 user: PropTypes.instanceOf(UserStore).isRequired,
26 }).isRequired,
27 children: oneOrManyChildElements.isRequired,
28};
diff --git a/src/actions/app.js b/src/actions/app.js
new file mode 100644
index 000000000..5db4b739e
--- /dev/null
+++ b/src/actions/app.js
@@ -0,0 +1,23 @@
1import PropTypes from 'prop-types';
2
3export default {
4 setBadge: {
5 unreadDirectMessageCount: PropTypes.number.isRequired,
6 unreadIndirectMessageCount: PropTypes.number,
7 },
8 notify: {
9 title: PropTypes.string.isRequired,
10 options: PropTypes.object.isRequired,
11 serviceId: PropTypes.string,
12 },
13 launchOnStartup: {
14 enable: PropTypes.bool.isRequired,
15 },
16 openExternalUrl: {
17 url: PropTypes.string.isRequired,
18 },
19 checkForUpdates: {},
20 resetUpdateStatus: {},
21 installUpdate: {},
22 healthCheck: {},
23};
diff --git a/src/actions/index.js b/src/actions/index.js
new file mode 100644
index 000000000..59acabb0b
--- /dev/null
+++ b/src/actions/index.js
@@ -0,0 +1,28 @@
1import PropTypes from 'prop-types';
2
3import defineActions from './lib/actions';
4import service from './service';
5import recipe from './recipe';
6import recipePreview from './recipePreview';
7import ui from './ui';
8import app from './app';
9import user from './user';
10import payment from './payment';
11import news from './news';
12import settings from './settings';
13import requests from './requests';
14
15const actions = Object.assign({}, {
16 service,
17 recipe,
18 recipePreview,
19 ui,
20 app,
21 user,
22 payment,
23 news,
24 settings,
25 requests,
26});
27
28export default defineActions(actions, PropTypes.checkPropTypes);
diff --git a/src/actions/lib/actions.js b/src/actions/lib/actions.js
new file mode 100644
index 000000000..499018d70
--- /dev/null
+++ b/src/actions/lib/actions.js
@@ -0,0 +1,18 @@
1export default (definitions, validate) => {
2 const newActions = {};
3 Object.keys(definitions).forEach((scopeName) => {
4 newActions[scopeName] = {};
5 Object.keys(definitions[scopeName]).forEach((actionName) => {
6 const action = (params) => {
7 const schema = definitions[scopeName][actionName];
8 validate(schema, params, actionName);
9 action.notify(params);
10 };
11 newActions[scopeName][actionName] = action;
12 action.listeners = [];
13 action.listen = listener => action.listeners.push(listener);
14 action.notify = params => action.listeners.forEach(listener => listener(params));
15 });
16 });
17 return newActions;
18};
diff --git a/src/actions/news.js b/src/actions/news.js
new file mode 100644
index 000000000..db106e84f
--- /dev/null
+++ b/src/actions/news.js
@@ -0,0 +1,7 @@
1import PropTypes from 'prop-types';
2
3export default {
4 hide: {
5 newsId: PropTypes.string.isRequired,
6 },
7};
diff --git a/src/actions/payment.js b/src/actions/payment.js
new file mode 100644
index 000000000..2aaefc025
--- /dev/null
+++ b/src/actions/payment.js
@@ -0,0 +1,8 @@
1import PropTypes from 'prop-types';
2
3export default {
4 createHostedPage: {
5 planId: PropTypes.string.isRequired,
6 },
7 createDashboardUrl: {},
8};
diff --git a/src/actions/recipe.js b/src/actions/recipe.js
new file mode 100644
index 000000000..29b0a151f
--- /dev/null
+++ b/src/actions/recipe.js
@@ -0,0 +1,9 @@
1import PropTypes from 'prop-types';
2
3export default {
4 install: {
5 recipeId: PropTypes.string.isRequired,
6 update: PropTypes.bool,
7 },
8 update: {},
9};
diff --git a/src/actions/recipePreview.js b/src/actions/recipePreview.js
new file mode 100644
index 000000000..36de3d844
--- /dev/null
+++ b/src/actions/recipePreview.js
@@ -0,0 +1,7 @@
1import PropTypes from 'prop-types';
2
3export default {
4 search: {
5 needle: PropTypes.string.isRequired,
6 },
7};
diff --git a/src/actions/requests.js b/src/actions/requests.js
new file mode 100644
index 000000000..89296e7ec
--- /dev/null
+++ b/src/actions/requests.js
@@ -0,0 +1,3 @@
1export default {
2 retryRequiredRequests: {},
3};
diff --git a/src/actions/service.js b/src/actions/service.js
new file mode 100644
index 000000000..7f429ca32
--- /dev/null
+++ b/src/actions/service.js
@@ -0,0 +1,75 @@
1import PropTypes from 'prop-types';
2
3export default {
4 setActive: {
5 serviceId: PropTypes.string.isRequired,
6 },
7 showAddServiceInterface: {
8 recipeId: PropTypes.string.isRequired,
9 },
10 createService: {
11 recipeId: PropTypes.string.isRequired,
12 serviceData: PropTypes.object.isRequired,
13 },
14 createFromLegacyService: {
15 data: PropTypes.object.isRequired,
16 },
17 updateService: {
18 serviceId: PropTypes.string.isRequired,
19 serviceData: PropTypes.object.isRequired,
20 redirect: PropTypes.bool,
21 },
22 deleteService: {
23 serviceId: PropTypes.string.isRequired,
24 redirect: PropTypes.string,
25 },
26 setUnreadMessageCount: {
27 serviceId: PropTypes.string.isRequired,
28 count: PropTypes.object.isRequired,
29 },
30 setWebviewReference: {
31 serviceId: PropTypes.string.isRequired,
32 webview: PropTypes.object.isRequired,
33 },
34 focusService: {
35 serviceId: PropTypes.string.isRequired,
36 },
37 focusActiveService: {},
38 toggleService: {
39 serviceId: PropTypes.string.isRequired,
40 },
41 handleIPCMessage: {
42 serviceId: PropTypes.string.isRequired,
43 channel: PropTypes.string.isRequired,
44 args: PropTypes.array.isRequired,
45 },
46 sendIPCMessage: {
47 serviceId: PropTypes.string.isRequired,
48 channel: PropTypes.string.isRequired,
49 args: PropTypes.object.isRequired,
50 },
51 openWindow: {
52 event: PropTypes.object.isRequired,
53 },
54 reload: {
55 serviceId: PropTypes.string.isRequired,
56 },
57 reloadActive: {},
58 reloadAll: {},
59 reloadUpdatedServices: {},
60 filter: {
61 needle: PropTypes.string.isRequired,
62 },
63 resetFilter: {},
64 reorder: {
65 oldIndex: PropTypes.number.isRequired,
66 newIndex: PropTypes.number.isRequired,
67 },
68 toggleNotifications: {
69 serviceId: PropTypes.string.isRequired,
70 },
71 openDevTools: {
72 serviceId: PropTypes.string.isRequired,
73 },
74 openDevToolsForActiveService: {},
75};
diff --git a/src/actions/settings.js b/src/actions/settings.js
new file mode 100644
index 000000000..3d53cd674
--- /dev/null
+++ b/src/actions/settings.js
@@ -0,0 +1,10 @@
1import PropTypes from 'prop-types';
2
3export default {
4 update: {
5 settings: PropTypes.object.isRequired,
6 },
7 remove: {
8 key: PropTypes.string.isRequired,
9 },
10};
diff --git a/src/actions/ui.js b/src/actions/ui.js
new file mode 100644
index 000000000..b913b430b
--- /dev/null
+++ b/src/actions/ui.js
@@ -0,0 +1,11 @@
1import PropTypes from 'prop-types';
2
3export default {
4 openSettings: {
5 path: PropTypes.string,
6 },
7 closeSettings: {},
8 toggleServiceUpdatedInfoBar: {
9 visible: PropTypes.bool,
10 },
11};
diff --git a/src/actions/user.js b/src/actions/user.js
new file mode 100644
index 000000000..fe32b8a05
--- /dev/null
+++ b/src/actions/user.js
@@ -0,0 +1,30 @@
1import PropTypes from 'prop-types';
2
3export default {
4 login: {
5 email: PropTypes.string.isRequired,
6 password: PropTypes.string.isRequired,
7 },
8 logout: {},
9 signup: {
10 firstname: PropTypes.string.isRequired,
11 lastname: PropTypes.string.isRequired,
12 email: PropTypes.string.isRequired,
13 password: PropTypes.string.isRequired,
14 accountType: PropTypes.string.isRequired,
15 company: PropTypes.string,
16 },
17 retrievePassword: {
18 email: PropTypes.string.isRequired,
19 },
20 invite: {
21 invites: PropTypes.array.isRequired,
22 },
23 update: {
24 userData: PropTypes.object.isRequired,
25 },
26 resetStatus: {},
27 importLegacyServices: PropTypes.arrayOf(PropTypes.shape({
28 recipe: PropTypes.string.isRequired,
29 })).isRequired,
30};
diff --git a/src/api/AppApi.js b/src/api/AppApi.js
new file mode 100644
index 000000000..411c187f4
--- /dev/null
+++ b/src/api/AppApi.js
@@ -0,0 +1,9 @@
1export default class AppApi {
2 constructor(server) {
3 this.server = server;
4 }
5
6 health() {
7 return this.server.healthCheck();
8 }
9}
diff --git a/src/api/LocalApi.js b/src/api/LocalApi.js
new file mode 100644
index 000000000..6f2b049d6
--- /dev/null
+++ b/src/api/LocalApi.js
@@ -0,0 +1,18 @@
1export default class LocalApi {
2 constructor(server, local) {
3 this.server = server;
4 this.local = local;
5 }
6
7 getSettings() {
8 return this.local.getAppSettings();
9 }
10
11 updateSettings(data) {
12 return this.local.updateAppSettings(data);
13 }
14
15 removeKey(key) {
16 return this.local.removeKey(key);
17 }
18}
diff --git a/src/api/NewsApi.js b/src/api/NewsApi.js
new file mode 100644
index 000000000..294957511
--- /dev/null
+++ b/src/api/NewsApi.js
@@ -0,0 +1,14 @@
1export default class NewsApi {
2 constructor(server, local) {
3 this.server = server;
4 this.local = local;
5 }
6
7 latest() {
8 return this.server.getLatestNews();
9 }
10
11 hide(id) {
12 return this.server.hideNews(id);
13 }
14}
diff --git a/src/api/PaymentApi.js b/src/api/PaymentApi.js
new file mode 100644
index 000000000..3f6bb442e
--- /dev/null
+++ b/src/api/PaymentApi.js
@@ -0,0 +1,22 @@
1export default class PaymentApi {
2 constructor(server, local) {
3 this.server = server;
4 this.local = local;
5 }
6
7 plans() {
8 return this.server.getPlans();
9 }
10
11 getHostedPage(planId) {
12 return this.server.getHostedPage(planId);
13 }
14
15 getDashboardUrl() {
16 return this.server.getPaymentDashboardUrl();
17 }
18
19 getOrders() {
20 return this.server.getSubscriptionOrders();
21 }
22}
diff --git a/src/api/RecipePreviewsApi.js b/src/api/RecipePreviewsApi.js
new file mode 100644
index 000000000..d9c675d76
--- /dev/null
+++ b/src/api/RecipePreviewsApi.js
@@ -0,0 +1,17 @@
1export default class ServicesApi {
2 constructor(server) {
3 this.server = server;
4 }
5
6 all() {
7 return this.server.getRecipePreviews();
8 }
9
10 featured() {
11 return this.server.getFeaturedRecipePreviews();
12 }
13
14 search(needle) {
15 return this.server.searchRecipePreviews(needle);
16 }
17}
diff --git a/src/api/RecipesApi.js b/src/api/RecipesApi.js
new file mode 100644
index 000000000..0573dacaf
--- /dev/null
+++ b/src/api/RecipesApi.js
@@ -0,0 +1,17 @@
1export default class ServicesApi {
2 constructor(server) {
3 this.server = server;
4 }
5
6 all() {
7 return this.server.getInstalledRecipes();
8 }
9
10 install(recipeId) {
11 return this.server.getRecipePackage(recipeId);
12 }
13
14 update(recipes) {
15 return this.server.getRecipeUpdates(recipes);
16 }
17}
diff --git a/src/api/ServicesApi.js b/src/api/ServicesApi.js
new file mode 100644
index 000000000..3cb40ba0d
--- /dev/null
+++ b/src/api/ServicesApi.js
@@ -0,0 +1,33 @@
1export default class ServicesApi {
2 constructor(server) {
3 this.server = server;
4 }
5
6 all() {
7 return this.server.getServices();
8 }
9
10 // one(customerId) {
11 // return this.server.getCustomer(customerId);
12 // }
13 //
14 // search(needle) {
15 // return this.server.searchCustomers(needle);
16 // }
17 //
18 create(recipeId, data) {
19 return this.server.createService(recipeId, data);
20 }
21
22 delete(serviceId) {
23 return this.server.deleteService(serviceId);
24 }
25
26 update(serviceId, data) {
27 return this.server.updateService(serviceId, data);
28 }
29
30 reorder(data) {
31 return this.server.reorderService(data);
32 }
33}
diff --git a/src/api/UserApi.js b/src/api/UserApi.js
new file mode 100644
index 000000000..e8fd75bed
--- /dev/null
+++ b/src/api/UserApi.js
@@ -0,0 +1,49 @@
1import { hash } from '../helpers/password-helpers';
2
3export default class UserApi {
4 constructor(server, local) {
5 this.server = server;
6 this.local = local;
7 }
8
9 login(email, password) {
10 return this.server.login(email, hash(password));
11 }
12
13 logout() {
14 return this;
15 }
16
17 signup(data) {
18 Object.assign(data, {
19 password: hash(data.password),
20 });
21 return this.server.signup(data);
22 }
23
24 password(email) {
25 return this.server.retrievePassword(email);
26 }
27
28 invite(data) {
29 return this.server.inviteUser(data);
30 }
31
32 getInfo() {
33 return this.server.userInfo();
34 }
35
36 updateInfo(data) {
37 const userData = data;
38 if (userData.oldPassword && userData.newPassword) {
39 userData.oldPassword = hash(userData.oldPassword);
40 userData.newPassword = hash(userData.newPassword);
41 }
42
43 return this.server.updateUserInfo(userData);
44 }
45
46 getLegacyServices() {
47 return this.server.getLegacyServices();
48 }
49}
diff --git a/src/api/index.js b/src/api/index.js
new file mode 100644
index 000000000..3fc18c4b5
--- /dev/null
+++ b/src/api/index.js
@@ -0,0 +1,19 @@
1import AppApi from './AppApi';
2import ServicesApi from './ServicesApi';
3import RecipePreviewsApi from './RecipePreviewsApi';
4import RecipesApi from './RecipesApi';
5import UserApi from './UserApi';
6import LocalApi from './LocalApi';
7import PaymentApi from './PaymentApi';
8import NewsApi from './NewsApi';
9
10export default (server, local) => ({
11 app: new AppApi(server, local),
12 services: new ServicesApi(server, local),
13 recipePreviews: new RecipePreviewsApi(server, local),
14 recipes: new RecipesApi(server, local),
15 user: new UserApi(server, local),
16 local: new LocalApi(server, local),
17 payment: new PaymentApi(server, local),
18 news: new NewsApi(server, local),
19});
diff --git a/src/api/server/LocalApi.js b/src/api/server/LocalApi.js
new file mode 100644
index 000000000..79ac6e12f
--- /dev/null
+++ b/src/api/server/LocalApi.js
@@ -0,0 +1,33 @@
1export default class LocalApi {
2 // App
3 async updateAppSettings(data) {
4 const currentSettings = await this.getAppSettings();
5 const settings = Object.assign(currentSettings, data);
6
7 localStorage.setItem('app', JSON.stringify(settings));
8 console.debug('LocalApi::updateAppSettings resolves', settings);
9
10 return settings;
11 }
12
13 async getAppSettings() {
14 const settingsString = localStorage.getItem('app');
15 try {
16 const settings = JSON.parse(settingsString) || {};
17 console.debug('LocalApi::getAppSettings resolves', settings);
18
19 return settings;
20 } catch (err) {
21 return {};
22 }
23 }
24
25 async removeKey(key) {
26 const settings = await this.getAppSettings();
27
28 if (Object.hasOwnProperty.call(settings, key)) {
29 delete settings[key];
30 localStorage.setItem('app', JSON.stringify(settings));
31 }
32 }
33}
diff --git a/src/api/server/ServerApi.js b/src/api/server/ServerApi.js
new file mode 100644
index 000000000..b369796e8
--- /dev/null
+++ b/src/api/server/ServerApi.js
@@ -0,0 +1,574 @@
1import os from 'os';
2import path from 'path';
3import targz from 'tar.gz';
4import fs from 'fs-extra';
5import { remote } from 'electron';
6
7import ServiceModel from '../../models/Service';
8import RecipePreviewModel from '../../models/RecipePreview';
9import RecipeModel from '../../models/Recipe';
10import PlanModel from '../../models/Plan';
11import NewsModel from '../../models/News';
12import UserModel from '../../models/User';
13import OrderModel from '../../models/Order';
14
15import { API } from '../../environment';
16
17import {
18 getRecipeDirectory,
19 getDevRecipeDirectory,
20 loadRecipeConfig,
21} from '../../helpers/recipe-helpers';
22
23module.paths.unshift(
24 getDevRecipeDirectory(),
25 getRecipeDirectory(),
26);
27
28const { app } = remote;
29const fetch = remote.require('electron-fetch');
30
31const SERVER_URL = API;
32const API_VERSION = 'v1';
33
34export default class ServerApi {
35 recipePreviews = [];
36 recipes = [];
37
38 // User
39 async login(email, passwordHash) {
40 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/auth/login`, this._prepareAuthRequest({
41 method: 'POST',
42 headers: {
43 Authorization: `Basic ${window.btoa(`${email}:${passwordHash}`)}`,
44 },
45 }, false));
46 if (!request.ok) {
47 throw request;
48 }
49 const u = await request.json();
50
51 console.debug('ServerApi::login resolves', u);
52 return u.token;
53 }
54
55 async signup(data) {
56 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/auth/signup`, this._prepareAuthRequest({
57 method: 'POST',
58 body: JSON.stringify(data),
59 }, false));
60 if (!request.ok) {
61 throw request;
62 }
63 const u = await request.json();
64
65 console.debug('ServerApi::signup resolves', u);
66 return u.token;
67 }
68
69 async inviteUser(data) {
70 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/invite`, this._prepareAuthRequest({
71 method: 'POST',
72 body: JSON.stringify(data),
73 }));
74 if (!request.ok) {
75 throw request;
76 }
77
78 console.debug('ServerApi::inviteUser');
79 return true;
80 }
81
82 async retrievePassword(email) {
83 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/auth/password`, this._prepareAuthRequest({
84 method: 'POST',
85 body: JSON.stringify({
86 email,
87 }),
88 }, false));
89 if (!request.ok) {
90 throw request;
91 }
92 const r = await request.json();
93
94 console.debug('ServerApi::retrievePassword');
95 return r;
96 }
97
98 async userInfo() {
99 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me`, this._prepareAuthRequest({
100 method: 'GET',
101 }));
102 if (!request.ok) {
103 throw request;
104 }
105 const data = await request.json();
106
107 const user = new UserModel(data);
108 console.debug('ServerApi::userInfo resolves', user);
109
110 return user;
111 }
112
113 async updateUserInfo(data) {
114 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me`, this._prepareAuthRequest({
115 method: 'PUT',
116 body: JSON.stringify(data),
117 }));
118 if (!request.ok) {
119 throw request;
120 }
121 const updatedData = await request.json();
122
123 const user = Object.assign(updatedData, { data: new UserModel(updatedData.data) });
124 console.debug('ServerApi::updateUserInfo resolves', user);
125 return user;
126 }
127
128 // Services
129 async getServices() {
130 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me/services`, this._prepareAuthRequest({
131 method: 'GET',
132 }));
133 if (!request.ok) {
134 throw request;
135 }
136 const data = await request.json();
137
138 let services = await this._mapServiceModels(data);
139 services = services.filter(service => service !== null);
140 console.debug('ServerApi::getServices resolves', services);
141 return services;
142 }
143
144 async createService(recipeId, data) {
145 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service`, this._prepareAuthRequest({
146 method: 'POST',
147 body: JSON.stringify(Object.assign({
148 recipeId,
149 }, data)),
150 }));
151 if (!request.ok) {
152 throw request;
153 }
154 const serviceData = await request.json();
155 const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) });
156
157 console.debug('ServerApi::createService resolves', service);
158 return service;
159 }
160
161 async updateService(recipeId, data) {
162 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/${recipeId}`, this._prepareAuthRequest({
163 method: 'PUT',
164 body: JSON.stringify(data),
165 }));
166 if (!request.ok) {
167 throw request;
168 }
169 const serviceData = await request.json();
170 const service = Object.assign(serviceData, { data: await this._prepareServiceModel(serviceData.data) });
171
172 console.debug('ServerApi::updateService resolves', service);
173 return service;
174 }
175
176 async reorderService(data) {
177 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/reorder`, this._prepareAuthRequest({
178 method: 'PUT',
179 body: JSON.stringify(data),
180 }));
181 if (!request.ok) {
182 throw request;
183 }
184 const serviceData = await request.json();
185 console.debug('ServerApi::reorderService resolves', serviceData);
186 return serviceData;
187 }
188
189 async deleteService(id) {
190 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/service/${id}`, this._prepareAuthRequest({
191 method: 'DELETE',
192 }));
193 if (!request.ok) {
194 throw request;
195 }
196 const data = await request.json();
197
198 console.debug('ServerApi::deleteService resolves', data);
199 return data;
200 }
201
202 // Recipes
203 async getInstalledRecipes() {
204 const recipesDirectory = getRecipeDirectory();
205 const paths = fs.readdirSync(recipesDirectory)
206 .filter(file => (
207 fs.statSync(path.join(recipesDirectory, file)).isDirectory()
208 && file !== 'temp'
209 && file !== 'dev'
210 ));
211
212 this.recipes = paths.map((id) => {
213 // eslint-disable-next-line
214 const Recipe = require(id)(RecipeModel);
215 return new Recipe(loadRecipeConfig(id));
216 }).filter(recipe => recipe.id);
217
218 this.recipes = this.recipes.concat(this._getDevRecipes());
219
220 console.debug('StubServerApi::getInstalledRecipes resolves', this.recipes);
221 return this.recipes;
222 }
223
224 async getRecipeUpdates(recipeVersions) {
225 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/update`, this._prepareAuthRequest({
226 method: 'POST',
227 body: JSON.stringify(recipeVersions),
228 }));
229 if (!request.ok) {
230 throw request;
231 }
232 const recipes = await request.json();
233 console.debug('ServerApi::getRecipeUpdates resolves', recipes);
234 return recipes;
235 }
236
237 // Recipes Previews
238 async getRecipePreviews() {
239 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes`, this._prepareAuthRequest({
240 method: 'GET',
241 }));
242 if (!request.ok) {
243 throw request;
244 }
245 const data = await request.json();
246
247 const recipePreviews = this._mapRecipePreviewModel(data);
248 console.debug('ServerApi::getRecipes resolves', recipePreviews);
249
250 return recipePreviews;
251 }
252
253 async getFeaturedRecipePreviews() {
254 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/popular`, this._prepareAuthRequest({
255 method: 'GET',
256 }));
257 if (!request.ok) {
258 throw request;
259 }
260 const data = await request.json();
261
262 // data = this._addLocalRecipesToPreviews(data);
263
264 const recipePreviews = this._mapRecipePreviewModel(data);
265 console.debug('ServerApi::getFeaturedRecipes resolves', recipePreviews);
266 return recipePreviews;
267 }
268
269 async searchRecipePreviews(needle) {
270 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/search?needle=${needle}`, this._prepareAuthRequest({
271 method: 'GET',
272 }));
273 if (!request.ok) {
274 throw request;
275 }
276 const data = await request.json();
277
278 const recipePreviews = this._mapRecipePreviewModel(data);
279 console.debug('ServerApi::searchRecipePreviews resolves', recipePreviews);
280 return recipePreviews;
281 }
282
283 async getRecipePackage(recipeId) {
284 try {
285 const recipesDirectory = path.join(app.getPath('userData'), 'recipes');
286
287 const recipeTempDirectory = path.join(recipesDirectory, 'temp', recipeId);
288 const archivePath = path.join(recipeTempDirectory, 'recipe.tar.gz');
289 const packageUrl = `${SERVER_URL}/${API_VERSION}/recipes/download/${recipeId}`;
290
291 fs.ensureDirSync(recipeTempDirectory);
292 const res = await fetch(packageUrl);
293 const buffer = await res.buffer();
294 fs.writeFileSync(archivePath, buffer);
295
296 await targz().extract(archivePath, recipeTempDirectory);
297
298 const { id } = fs.readJsonSync(path.join(recipeTempDirectory, 'package.json'));
299 const recipeDirectory = path.join(recipesDirectory, id);
300
301 fs.copySync(recipeTempDirectory, recipeDirectory);
302 fs.remove(recipeTempDirectory);
303 fs.remove(path.join(recipesDirectory, recipeId, 'recipe.tar.gz'));
304
305 return id;
306 } catch (err) {
307 console.error(err);
308
309 return false;
310 }
311 }
312
313 // Payment
314 async getPlans() {
315 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/payment/plans`, this._prepareAuthRequest({
316 method: 'GET',
317 }));
318 if (!request.ok) {
319 throw request;
320 }
321 const data = await request.json();
322
323 const plan = new PlanModel(data);
324 console.debug('ServerApi::getPlans resolves', plan);
325 return plan;
326 }
327
328 async getHostedPage(planId) {
329 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/payment/init`, this._prepareAuthRequest({
330 method: 'POST',
331 body: JSON.stringify({
332 planId,
333 }),
334 }));
335 if (!request.ok) {
336 throw request;
337 }
338 const data = await request.json();
339
340 console.debug('ServerApi::getHostedPage resolves', data);
341 return data;
342 }
343
344 async getPaymentDashboardUrl() {
345 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me/billing`, this._prepareAuthRequest({
346 method: 'GET',
347 }));
348 if (!request.ok) {
349 throw request;
350 }
351 const data = await request.json();
352
353 console.debug('ServerApi::getPaymentDashboardUrl resolves', data);
354 return data;
355 }
356
357 async getSubscriptionOrders() {
358 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/me/subscription`, this._prepareAuthRequest({
359 method: 'GET',
360 }));
361 if (!request.ok) {
362 throw request;
363 }
364 const data = await request.json();
365 const orders = this._mapOrderModels(data);
366 console.debug('ServerApi::getSubscriptionOrders resolves', orders);
367 return orders;
368 }
369
370 // News
371 async getLatestNews() {
372 // eslint-disable-next-line
373 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/news?platform=${os.platform()}&arch=${os.arch()}version=${app.getVersion()}`,
374 this._prepareAuthRequest({
375 method: 'GET',
376 }));
377
378 if (!request.ok) {
379 throw request;
380 }
381 const data = await request.json();
382 const news = this._mapNewsModels(data);
383 console.debug('ServerApi::getLatestNews resolves', news);
384 return news;
385 }
386
387 async hideNews(id) {
388 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/news/${id}/read`,
389 this._prepareAuthRequest({
390 method: 'GET',
391 }));
392
393 if (!request.ok) {
394 throw request;
395 }
396
397 console.debug('ServerApi::hideNews resolves', id);
398 }
399
400 // Health Check
401 async healthCheck() {
402 const request = await window.fetch(`${SERVER_URL}/health`, this._prepareAuthRequest({
403 method: 'GET',
404 }, false));
405 if (!request.ok) {
406 throw request;
407 }
408 console.debug('ServerApi::healthCheck resolves');
409 }
410
411 async getLegacyServices() {
412 const file = path.join(app.getPath('userData'), 'settings', 'services.json');
413
414 try {
415 const config = fs.readJsonSync(file);
416
417 if (Object.prototype.hasOwnProperty.call(config, 'services')) {
418 const services = await Promise.all(config.services.map(async (s) => {
419 const service = s;
420 const request = await window.fetch(`${SERVER_URL}/${API_VERSION}/recipes/${s.service}`,
421 this._prepareAuthRequest({
422 method: 'GET',
423 }),
424 );
425
426 if (request.status === 200) {
427 const data = await request.json();
428 service.recipe = new RecipePreviewModel(data);
429 }
430
431 return service;
432 }));
433
434 console.debug('ServerApi::getLegacyServices resolves', services);
435 return services;
436 }
437 } catch (err) {
438 throw (new Error('ServerApi::getLegacyServices no config found'));
439 }
440
441 return [];
442 }
443
444 // Helper
445 async _mapServiceModels(services) {
446 return Promise.all(services
447 .map(async service => await this._prepareServiceModel(service)) // eslint-disable-line
448 );
449 }
450
451 async _prepareServiceModel(service) {
452 let recipe;
453 try {
454 recipe = this.recipes.find(r => r.id === service.recipeId);
455
456 if (!recipe) {
457 console.warn(`Recipe '${service.recipeId}' not installed, trying to fetch from server`);
458
459 await this.getRecipePackage(service.recipeId);
460
461 console.debug('Rerun ServerAPI::getInstalledRecipes');
462 await this.getInstalledRecipes();
463
464 recipe = this.recipes.find(r => r.id === service.recipeId);
465
466 if (!recipe) {
467 console.warn(`Could not load recipe ${service.recipeId}`);
468 return null;
469 }
470 }
471
472 return new ServiceModel(service, recipe);
473 } catch (e) {
474 console.debug(e);
475 return null;
476 }
477 }
478
479 _mapRecipePreviewModel(recipes) {
480 return recipes.map((recipe) => {
481 try {
482 return new RecipePreviewModel(recipe);
483 } catch (e) {
484 console.error(e);
485 return null;
486 }
487 }).filter(recipe => recipe !== null);
488 }
489
490 _mapNewsModels(news) {
491 return news.map((newsItem) => {
492 try {
493 return new NewsModel(newsItem);
494 } catch (e) {
495 console.error(e);
496 return null;
497 }
498 }).filter(newsItem => newsItem !== null);
499 }
500
501 _mapOrderModels(orders) {
502 return orders.map((orderItem) => {
503 try {
504 return new OrderModel(orderItem);
505 } catch (e) {
506 console.error(e);
507 return null;
508 }
509 }).filter(orderItem => orderItem !== null);
510 }
511
512 _prepareAuthRequest(options, auth = true) {
513 const request = Object.assign(options, {
514 mode: 'cors',
515 headers: {
516 'Content-Type': 'application/json',
517 'X-Franz-Source': 'desktop',
518 'X-Franz-Version': app.getVersion(),
519 'X-Franz-platform': process.platform,
520 'X-Franz-Timezone-Offset': new Date().getTimezoneOffset(),
521 'X-Franz-System-Locale': app.getLocale(),
522 },
523 });
524
525 // const headers = new window.Headers();
526 // headers.append('foo', 'bar');
527 // console.log(headers, request.headers);
528 //
529 //
530 // // request.headers.map((value, header) => headers.append(header, value));
531 // Object.keys(request.headers).map((key) => {
532 // console.log(key);
533 // return headers.append(key, request.headers[key]);
534 // });
535 // request.headers = headers;
536
537 // console.log(request);
538
539 if (auth) {
540 request.headers.Authorization = `Bearer ${localStorage.getItem('authToken')}`;
541 }
542
543 return request;
544 }
545
546 _getDevRecipes() {
547 const recipesDirectory = getDevRecipeDirectory();
548 try {
549 const paths = fs.readdirSync(recipesDirectory)
550 .filter(file => fs.statSync(path.join(recipesDirectory, file)).isDirectory() && file !== 'temp');
551
552 const recipes = paths.map((id) => {
553 // eslint-disable-next-line
554 const Recipe = require(id)(RecipeModel);
555 return new Recipe(loadRecipeConfig(id));
556 }).filter(recipe => recipe.id).map((data) => {
557 const recipe = data;
558
559 recipe.icons = {
560 svg: `${recipe.path}/icon.svg`,
561 png: `${recipe.path}/icon.png`,
562 };
563 recipe.local = true;
564
565 return data;
566 });
567
568 return recipes;
569 } catch (err) {
570 console.debug('Folder `recipe/dev` does not exist');
571 return false;
572 }
573 }
574}
diff --git a/src/app.js b/src/app.js
new file mode 100644
index 000000000..b539ea494
--- /dev/null
+++ b/src/app.js
@@ -0,0 +1,103 @@
1import { webFrame } from 'electron';
2
3import React from 'react';
4import { render } from 'react-dom';
5import { Provider } from 'mobx-react';
6import { syncHistoryWithStore, RouterStore } from 'mobx-react-router';
7import { Router, Route, hashHistory, IndexRedirect } from 'react-router';
8
9import 'babel-polyfill';
10import smoothScroll from 'smoothscroll-polyfill';
11
12import ServerApi from './api/server/ServerApi';
13import LocalApi from './api/server/LocalApi';
14import storeFactory from './stores';
15import apiFactory from './api';
16import actions from './actions';
17import MenuFactory from './lib/Menu';
18import TouchBarFactory from './lib/TouchBar';
19import * as analytics from './lib/analytics';
20
21import I18N from './I18n';
22import AppLayoutContainer from './containers/layout/AppLayoutContainer';
23import SettingsWindow from './containers/settings/SettingsWindow';
24import RecipesScreen from './containers/settings/RecipesScreen';
25import ServicesScreen from './containers/settings/ServicesScreen';
26import EditServiceScreen from './containers/settings/EditServiceScreen';
27import AccountScreen from './containers/settings/AccountScreen';
28import EditUserScreen from './containers/settings/EditUserScreen';
29import EditSettingsScreen from './containers/settings/EditSettingsScreen';
30import WelcomeScreen from './containers/auth/WelcomeScreen';
31import LoginScreen from './containers/auth/LoginScreen';
32import PasswordScreen from './containers/auth/PasswordScreen';
33import SignupScreen from './containers/auth/SignupScreen';
34import ImportScreen from './containers/auth/ImportScreen';
35import PricingScreen from './containers/auth/PricingScreen';
36import InviteScreen from './containers/auth/InviteScreen';
37import AuthLayoutContainer from './containers/auth/AuthLayoutContainer';
38import SubscriptionPopupScreen from './containers/ui/SubscriptionPopupScreen';
39
40// Add Polyfills
41smoothScroll.polyfill();
42
43// Basic electron Setup
44webFrame.setVisualZoomLevelLimits(1, 1);
45webFrame.setLayoutZoomLevelLimits(0, 0);
46
47window.addEventListener('load', () => {
48 const api = apiFactory(new ServerApi(), new LocalApi());
49 const router = new RouterStore();
50 const history = syncHistoryWithStore(hashHistory, router);
51 const stores = storeFactory(api, actions, router);
52 const menu = new MenuFactory(stores, actions);
53 const touchBar = new TouchBarFactory(stores, actions);
54
55 window.franz = {
56 stores,
57 actions,
58 api,
59 menu,
60 touchBar,
61 analytics,
62 render() {
63 const preparedApp = (
64 <Provider stores={stores} actions={actions}>
65 <I18N>
66 <Router history={history}>
67 <Route path="/" component={AppLayoutContainer}>
68 <Route path="/settings" component={SettingsWindow}>
69 <IndexRedirect to="/settings/recipes" />
70 <Route path="/settings/recipes" component={RecipesScreen} />
71 <Route path="/settings/recipes/:filter" component={RecipesScreen} />
72 <Route path="/settings/services" component={ServicesScreen} />
73 <Route path="/settings/services/:action/:id" component={EditServiceScreen} />
74 <Route path="/settings/user" component={AccountScreen} />
75 <Route path="/settings/user/edit" component={EditUserScreen} />
76 <Route path="/settings/app" component={EditSettingsScreen} />
77 </Route>
78 </Route>
79 <Route path="/auth" component={AuthLayoutContainer}>
80 <IndexRedirect to="/auth/welcome" />
81 <Route path="/auth/welcome" component={WelcomeScreen} />
82 <Route path="/auth/login" component={LoginScreen} />
83 <Route path="/auth/signup">
84 <IndexRedirect to="/auth/signup/form" />
85 <Route path="/auth/signup/form" component={SignupScreen} />
86 <Route path="/auth/signup/pricing" component={PricingScreen} />
87 <Route path="/auth/signup/import" component={ImportScreen} />
88 <Route path="/auth/signup/invite" component={InviteScreen} />
89 </Route>
90 <Route path="/auth/password" component={PasswordScreen} />
91 <Route path="/auth/logout" component={LoginScreen} />
92 </Route>
93 <Route path="/payment/:url" component={SubscriptionPopupScreen} />
94 <Route path="*" component={AppLayoutContainer} />
95 </Router>
96 </I18N>
97 </Provider>
98 );
99 render(preparedApp, document.getElementById('root'));
100 },
101 };
102 window.franz.render();
103});
diff --git a/src/assets/fonts/OpenSans-Bold.ttf b/src/assets/fonts/OpenSans-Bold.ttf
new file mode 100755
index 000000000..fd79d43be
--- /dev/null
+++ b/src/assets/fonts/OpenSans-Bold.ttf
Binary files differ
diff --git a/src/assets/fonts/OpenSans-BoldItalic.ttf b/src/assets/fonts/OpenSans-BoldItalic.ttf
new file mode 100755
index 000000000..9bc800958
--- /dev/null
+++ b/src/assets/fonts/OpenSans-BoldItalic.ttf
Binary files differ
diff --git a/src/assets/fonts/OpenSans-ExtraBold.ttf b/src/assets/fonts/OpenSans-ExtraBold.ttf
new file mode 100755
index 000000000..21f6f84a0
--- /dev/null
+++ b/src/assets/fonts/OpenSans-ExtraBold.ttf
Binary files differ
diff --git a/src/assets/fonts/OpenSans-ExtraBoldItalic.ttf b/src/assets/fonts/OpenSans-ExtraBoldItalic.ttf
new file mode 100755
index 000000000..31cb68834
--- /dev/null
+++ b/src/assets/fonts/OpenSans-ExtraBoldItalic.ttf
Binary files differ
diff --git a/src/assets/fonts/OpenSans-Light.ttf b/src/assets/fonts/OpenSans-Light.ttf
new file mode 100755
index 000000000..0d381897d
--- /dev/null
+++ b/src/assets/fonts/OpenSans-Light.ttf
Binary files differ
diff --git a/src/assets/fonts/OpenSans-Regular.ttf b/src/assets/fonts/OpenSans-Regular.ttf
new file mode 100755
index 000000000..db433349b
--- /dev/null
+++ b/src/assets/fonts/OpenSans-Regular.ttf
Binary files differ
diff --git a/src/assets/images/adlk.svg b/src/assets/images/adlk.svg
new file mode 100644
index 000000000..eb50f345a
--- /dev/null
+++ b/src/assets/images/adlk.svg
@@ -0,0 +1,53 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<svg width="120px" height="65px" viewBox="0 0 120 65" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3 <!-- Generator: Sketch 42 (36781) - http://www.bohemiancoding.com/sketch -->
4 <title>adlk-group</title>
5 <desc>Created with Sketch.</desc>
6 <defs>
7 <filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
8 <feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
9 <feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
10 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
11 <feMerge>
12 <feMergeNode in="shadowMatrixOuter1"></feMergeNode>
13 <feMergeNode in="SourceGraphic"></feMergeNode>
14 </feMerge>
15 </filter>
16 <linearGradient x1="30.4719662%" y1="95.2408507%" x2="68.063052%" y2="3.5648771%" id="linearGradient-2">
17 <stop stop-color="#318CC8" offset="0%"></stop>
18 <stop stop-color="#5EC0FF" offset="100%"></stop>
19 </linearGradient>
20 <path d="M29.5671186,59.9675705 C13.237647,59.9675705 0,46.7299236 0,30.4004519 C0,14.0709803 13.237647,0.833333333 29.5671186,0.833333333 C45.8965902,0.833333333 59.1342372,14.0709803 59.1342372,30.4004519 C59.1342372,46.7299236 45.8965902,59.9675705 29.5671186,59.9675705 Z" id="path-3"></path>
21 <filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-4">
22 <feMorphology radius="1.25" operator="dilate" in="SourceAlpha" result="shadowSpreadOuter1"></feMorphology>
23 <feOffset dx="0" dy="1" in="shadowSpreadOuter1" result="shadowOffsetOuter1"></feOffset>
24 <feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
25 <feComposite in="shadowBlurOuter1" in2="SourceAlpha" operator="out" result="shadowBlurOuter1"></feComposite>
26 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
27 </filter>
28 </defs>
29 <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
30 <g id="logo" transform="translate(-1.000000, 0.000000)">
31 <g id="adlk-group" transform="translate(4.000000, 1.000000)">
32 <g id="Page-1" filter="url(#filter-1)" transform="translate(53.333333, 0.000000)">
33 <path d="M29.951734,59.6153846 C13.6222624,59.6153846 0.384615385,46.3777376 0.384615385,30.048266 C0.384615385,13.7187944 13.6222624,0.481147424 29.951734,0.481147424 C46.2812056,0.481147424 59.5188526,13.7187944 59.5188526,30.048266 C59.5188526,46.3777376 46.2812056,59.6153846 29.951734,59.6153846 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="2.5" fill="url(#linearGradient-2)"></path>
34 <path d="M40.8193218,39.7759515 C40.2991334,39.7759515 39.777752,39.7461242 39.2560451,39.6885303 L39.2560451,43.1660699 C39.2560451,44.2684875 40.4320006,44.8564652 42.0489665,44.8564652 C43.6659324,44.8564652 47.3407661,45.1505083 48.8106562,42.5780922 C49.5666431,41.2550609 50.049954,39.777253 50.341611,38.5380637 C47.7706048,39.3483905 44.5464347,39.7759515 40.8193218,39.7759515" id="Fill-3" fill="#FEFEFE"></path>
35 <path d="M50.721253,36.0215602 C50.7213615,36.014727 50.7215784,36.0067007 50.7215784,36.0001929 C50.7215784,35.7047398 50.6497759,34.0216115 50.5562809,31.8479052 C50.5492308,31.8480137 50.5428315,31.8482306 50.5356729,31.8482306 C48.6476581,31.8482306 45.8548452,30.9165338 44.4370173,29.8136823 C44.0945997,29.5474057 43.7123762,29.1917557 43.3041216,28.7844772 C41.4215299,31.2340049 39.6248409,34.689418 39.3072613,37.4257217 C39.8033709,37.4891725 40.3081576,37.5235553 40.8193435,37.5235553 C44.8576366,37.5235553 48.2724847,36.9929545 50.721253,36.0215602" id="Fill-5" fill="#FEFEFE"></path>
36 <path d="M45.8200177,28.0358333 C46.832411,28.8233829 49.0605657,29.5681981 50.4596295,29.5945546 C50.3027921,25.9124538 50.1335898,21.76201 50.1335898,20.3820357 C50.1335898,17.809728 50.2079954,15.494586 49.3986448,15.494586 C47.3407769,15.494586 47.8552168,24.4244504 45.2828006,26.6292856 C45.1214077,26.7676843 44.956544,26.9196409 44.7892941,27.0838539 C45.1781339,27.4760559 45.5348686,27.8140265 45.8200177,28.0358333" id="Fill-7" fill="#FEFEFE"></path>
37 <path d="M10.0610578,29.5501499 C11.4368021,29.3757414 13.2421681,28.7220351 14.1242974,28.0358984 C14.475609,27.7625717 14.9354919,27.3128843 15.4306254,26.8023491 C15.3653307,26.7431283 15.3002529,26.6843414 15.2360428,26.6293506 C12.6636267,24.424407 13.1780665,15.4945426 11.1201987,15.4945426 C10.3109565,15.4945426 10.3852536,17.8096846 10.3852536,20.3821008 C10.3852536,21.756435 10.2173529,25.8792208 10.0610578,29.5501499" id="Fill-9" fill="#FEFEFE"></path>
38 <path d="M21.2005731,37.3395804 C20.8402592,34.4934037 18.9129807,30.8970972 16.9604304,28.4603682 C16.4311312,29.0030084 15.9377331,29.4788355 15.5071352,29.8137691 C14.2229336,30.812713 11.8100665,31.6713058 9.96359298,31.8240217 C9.86966405,34.0092251 9.79721079,35.7036335 9.79721079,36.0001713 C9.79721079,36.0653575 9.80024775,36.1457286 9.80577936,36.2371628 C12.2015093,37.0708092 15.3970452,37.5235336 19.1249174,37.5235336 C19.8311197,37.5235336 20.5248489,37.4573712 21.2005731,37.3395804" id="Fill-11" fill="#FEFEFE"></path>
39 <path d="M19.1249499,39.7759515 C15.6917717,39.7759515 12.6843105,39.4137936 10.2218758,38.7234269 C10.517329,39.9228103 10.9894682,41.3204641 11.7081439,42.5780922 C13.1781424,45.1505083 16.8529762,44.8564652 18.4698336,44.8564652 C20.0867995,44.8564652 21.262755,44.2684875 21.262755,43.1660699 L21.262755,39.6133655 C20.549394,39.7209608 19.8361416,39.7759515 19.1249499,39.7759515" id="Fill-13" fill="#FEFEFE"></path>
40 <path d="M52.954538,31.2252844 C52.5192762,31.2252844 52.057007,31.3746379 51.5802038,31.6691149 C51.5121975,31.7110901 51.2357254,31.8481872 50.5357054,31.8481872 C48.6475821,31.8481872 45.8548777,30.9165988 44.4370498,29.8137474 C43.7290036,29.2630809 42.8517551,28.3324687 41.9229867,27.3472996 C39.9696772,25.2753315 38.1247221,23.3183342 36.5281473,23.3183342 C35.3931908,23.3183342 34.0862121,23.8471997 32.8221847,24.3588195 C31.7693347,24.784862 30.6805834,25.2254386 29.9721033,25.2254386 C29.2636232,25.2254386 28.1749804,24.784862 27.1221304,24.3588195 C25.858103,23.8471997 24.5510158,23.3183342 23.4161678,23.3183342 C21.8194845,23.3183342 19.974421,25.27544 18.0212199,27.347408 C17.0924515,28.3325771 16.2152031,29.2630809 15.5071568,29.8137474 C14.0895459,30.9164904 11.296733,31.8480788 9.40850121,31.8481872 C8.7085897,31.8481872 8.4321176,31.7110901 8.36411132,31.6691149 C7.88730812,31.3746379 7.4249305,31.2252844 6.98977708,31.2252844 C6.3738159,31.2252844 5.85416983,31.547311 5.63366462,32.0656555 C5.36185643,32.7047193 5.58821864,33.4896658 6.23899643,34.1654985 C8.29393578,36.2996158 12.9907076,37.5235119 19.1249391,37.5235119 C23.1527113,37.5235119 26.7888237,35.4106533 28.8485355,33.4333735 C29.2664433,33.0322774 29.8115781,33.0118864 29.9165703,33.0118864 L29.9199326,33.0114525 L29.9396729,33.0186111 L30.0136446,33.0121033 L30.0276364,33.0118864 C30.1326285,33.0118864 30.6778718,33.0322774 31.0957796,33.4333735 C33.1554914,35.4106533 36.7914954,37.5235119 40.8192676,37.5235119 C46.9534991,37.5235119 51.6502709,36.2996158 53.7053187,34.1654985 C54.3560965,33.4897742 54.5822418,32.7047193 54.310542,32.0656555 C54.0900368,31.547311 53.5703908,31.2252844 52.954538,31.2252844" id="Fill-15" fill="#FEFEFE"></path>
41 </g>
42 <g id="Fill-1">
43 <use fill="black" fill-opacity="1" filter="url(#filter-4)" xlink:href="#path-3"></use>
44 <use stroke="#FFFFFF" stroke-width="2.5" fill="#E51F5B" fill-rule="evenodd" xlink:href="#path-3"></use>
45 </g>
46 <path d="M18.53613,36.1383525 C18.402105,36.1383525 18.134205,36.1215525 17.882955,36.0378525 C17.531205,35.9373525 17.045505,35.6693775 16.44258,34.1787525 C15.42093,31.6330275 12.79143,24.7158525 12.54018,24.0794775 C12.339255,23.5602525 12.272205,23.4764775 12.171705,23.4764775 C12.071205,23.4764775 12.00423,23.5769775 11.803305,24.1297275 L8.001405,34.5305025 C7.699905,35.3679525 7.364955,36.0043275 6.59448,36.1048275 C6.46053,36.1215525 6.30978,36.1383525 6.192555,36.1383525 C6.041805,36.1383525 5.95803,36.1718025 5.95803,36.2388525 C5.95803,36.3393525 6.05853,36.3728025 6.276255,36.3728025 C7.063455,36.3728025 7.984605,36.3225525 8.16888,36.3225525 C8.35308,36.3225525 9.023055,36.3728025 9.42498,36.3728025 C9.57573,36.3728025 9.72648,36.3393525 9.72648,36.2388525 C9.72648,36.1718025 9.67623,36.1383525 9.508755,36.1383525 L9.358005,36.1383525 C9.073305,36.1383525 8.755005,35.9876025 8.755005,35.6525775 C8.755005,35.3511525 8.83878,34.9492275 8.98953,34.5305025 L11.76978,26.1897525 C11.85348,25.9385025 11.937255,25.9385025 12.020955,26.1897525 L15.47118,35.6525775 C15.57168,35.9206275 15.47118,36.0713025 15.37068,36.1048275 C15.303705,36.1215525 15.253455,36.1550775 15.253455,36.2220525 C15.253455,36.3225525 15.437655,36.3225525 15.75588,36.3393525 C16.911555,36.3728025 18.134205,36.3728025 18.38538,36.3728025 C18.569655,36.3728025 18.720405,36.3393525 18.720405,36.2388525 C18.720405,36.1550775 18.63663,36.1383525 18.53613,36.1383525" id="Fill-3" fill="#FEFEFE"></path>
47 <path d="M22.0194975,30.37683 C22.0194975,32.15223 22.0362225,33.693105 22.0530225,34.011255 C22.0697475,34.42998 22.1032725,35.099955 22.2372225,35.284155 C22.4549475,35.60238 23.1081225,35.954055 25.0844475,35.954055 C26.6420475,35.954055 28.0824225,35.384655 29.0706225,34.39653 C29.9415225,33.54228 30.4104225,31.934505 30.4104225,30.39363 C30.4104225,28.266555 29.4892725,26.893155 28.8025725,26.173005 C27.2282475,24.514905 25.3189725,24.280455 23.3258475,24.280455 C22.9908975,24.280455 22.3711725,24.330705 22.2372225,24.397605 C22.0864725,24.464655 22.0362225,24.548355 22.0362225,24.73263 C22.0194975,25.302105 22.0194975,27.01038 22.0194975,28.30008 L22.0194975,30.37683 Z M20.6126475,28.568055 C20.6126475,25.95528 20.6126475,25.486305 20.5791225,24.950355 C20.5456725,24.380955 20.4618975,24.112905 19.9092225,23.99568 C19.7751975,23.96223 19.4904975,23.94543 19.3397475,23.94543 C19.2225225,23.94543 19.1554725,23.91198 19.1554725,23.84493 C19.1554725,23.74443 19.2392475,23.71098 19.4235225,23.71098 C20.1771975,23.71098 21.2322975,23.76123 21.3160725,23.76123 C21.5170725,23.76123 22.5721725,23.71098 23.5770975,23.71098 C25.2351975,23.71098 28.3001475,23.56023 30.2932725,25.620255 C31.1307225,26.491155 31.9177725,27.881355 31.9177725,29.874405 C31.9177725,31.98468 31.0469475,33.60933 30.1089975,34.597455 C29.3887725,35.35113 27.8814225,36.52353 25.0341975,36.52353 C24.3140475,36.52353 23.4263475,36.47328 22.7061975,36.42303 C21.9692475,36.37278 21.3997725,36.32253 21.3160725,36.32253 C21.2825475,36.32253 20.9978475,36.32253 20.6460975,36.33933 C20.3111475,36.33933 19.9259475,36.37278 19.6746975,36.37278 C19.4904975,36.37278 19.4067225,36.33933 19.4067225,36.23883 C19.4067225,36.18858 19.4402475,36.13833 19.5741975,36.13833 C19.7584725,36.121605 19.8924225,36.104805 20.0431725,36.071355 C20.3781225,36.004305 20.4618975,35.63583 20.5288725,35.150205 C20.6126475,34.44678 20.6126475,33.12363 20.6126475,31.51578 L20.6126475,28.568055 Z" id="Fill-5" fill="#FEFEFE"></path>
48 <path d="M35.61885,31.54929 C35.61885,34.01124 35.685825,35.08314 35.98725,35.38464 C36.2553,35.652615 36.690675,35.76984 37.997025,35.76984 C38.884725,35.76984 39.621675,35.753115 40.0236,35.267415 C40.241325,34.99944 40.408875,34.580715 40.459125,34.26249 C40.47585,34.12854 40.509375,34.044765 40.609875,34.044765 C40.69365,34.044765 40.710375,34.11174 40.710375,34.296015 C40.710375,34.480215 40.59315,35.48514 40.459125,35.97084 C40.341975,36.35604 40.291575,36.423015 39.404025,36.423015 C37.96365,36.423015 36.539925,36.32259 34.915425,36.32259 C34.396125,36.32259 33.8937,36.372765 33.27405,36.372765 C33.089775,36.372765 33.006,36.339315 33.006,36.238815 C33.006,36.188565 33.039525,36.13839 33.140025,36.13839 C33.290775,36.13839 33.491775,36.10479 33.64245,36.07134 C33.977475,36.00429 34.06125,35.635815 34.128225,35.15019 C34.212,34.446765 34.212,33.123615 34.212,31.515765 L34.212,28.56804 C34.212,25.955265 34.212,25.48629 34.178475,24.950415 C34.14495,24.38094 34.011075,24.112965 33.458175,23.995665 C33.3243,23.962215 33.039525,23.945415 32.88885,23.945415 C32.821875,23.945415 32.75475,23.911965 32.75475,23.844915 C32.75475,23.74449 32.8386,23.710965 33.022875,23.710965 C33.77655,23.710965 34.83165,23.761215 34.915425,23.761215 C34.99905,23.761215 36.221775,23.710965 36.7242,23.710965 C36.908475,23.710965 36.992175,23.74449 36.992175,23.844915 C36.992175,23.911965 36.9252,23.945415 36.858225,23.945415 C36.740925,23.945415 36.50655,23.962215 36.30555,23.995665 C35.81985,24.07944 35.685825,24.36414 35.652225,24.950415 C35.61885,25.48629 35.61885,25.955265 35.61885,28.56804 L35.61885,31.54929 Z" id="Fill-7" fill="#FEFEFE"></path>
49 <path d="M44.428275,29.63994 C44.947575,29.137515 47.2254,26.74239 48.246975,25.68729 C49.251825,24.64884 49.31895,24.48144 49.31895,24.28044 C49.31895,24.146415 49.235175,24.012465 49.10115,23.962215 C48.983925,23.911965 48.9504,23.878515 48.9504,23.811465 C48.9504,23.74449 49.0509,23.710965 49.20165,23.710965 C49.68735,23.710965 49.6371,23.761215 50.2065,23.761215 C50.7258,23.761215 51.71385,23.710965 51.9819,23.710965 C52.216425,23.710965 52.266675,23.761215 52.266675,23.828265 C52.266675,23.895165 52.233075,23.928765 52.0824,23.945415 C51.76425,23.979015 51.46275,24.045915 51.228225,24.146415 C50.809425,24.31389 50.491275,24.51489 49.48635,25.48629 C48.01245,26.90994 46.103175,28.80249 45.66765,29.288265 C46.722825,30.443865 49.771125,33.542265 50.558175,34.279215 C51.93165,35.568915 52.51785,35.93739 53.321775,36.08814 C53.472525,36.12159 53.639925,36.13839 53.80755,36.13839 C53.941425,36.13839 54.041925,36.17184 54.041925,36.25554 C54.041925,36.339315 53.97495,36.372765 53.790675,36.372765 L52.5513,36.372765 C51.0942,36.372765 50.708925,36.188565 49.921875,35.568915 C48.916875,34.781715 45.902175,31.54929 44.428275,29.79069 L44.428275,31.515765 C44.428275,33.123615 44.428275,34.446765 44.51205,35.15019 C44.5623,35.635815 44.6628,36.00429 45.165225,36.07134 C45.39975,36.10479 45.734775,36.13839 45.835275,36.13839 C45.986025,36.13839 46.0362,36.20529 46.0362,36.25554 C46.0362,36.339315 45.96915,36.372765 45.785025,36.372765 C44.8638,36.372765 43.808625,36.32259 43.72485,36.32259 C43.641225,36.32259 42.653025,36.372765 42.1506,36.372765 C41.966325,36.372765 41.88255,36.35604 41.88255,36.25554 C41.88255,36.20529 41.916075,36.13839 42.066825,36.13839 C42.167325,36.13839 42.3516,36.12159 42.502275,36.08814 C42.8373,36.02109 42.9378,35.635815 43.004775,35.15019 C43.08855,34.446765 43.08855,33.123615 43.08855,31.515765 L43.08855,28.56804 C43.08855,25.955265 43.08855,25.48629 43.05495,24.950415 C43.021425,24.38094 42.854025,24.096165 42.485475,24.012465 C42.30135,23.962215 41.99985,23.945415 41.88255,23.945415 C41.7486,23.945415 41.698425,23.911965 41.698425,23.844915 C41.698425,23.74449 41.78205,23.710965 41.966325,23.710965 C42.519,23.710965 43.641225,23.761215 43.72485,23.761215 C43.808625,23.761215 44.8638,23.710965 45.366225,23.710965 C45.5505,23.710965 45.634275,23.74449 45.634275,23.828265 C45.634275,23.895165 45.60075,23.928765 45.45,23.945415 C45.265725,23.962215 45.249,23.962215 45.0816,23.979015 C44.629275,24.02919 44.4954,24.36414 44.4618,24.950415 C44.428275,25.48629 44.428275,25.955265 44.428275,28.56804 L44.428275,29.63994 Z" id="Fill-9" fill="#FEFEFE"></path>
50 </g>
51 </g>
52 </g>
53</svg> \ No newline at end of file
diff --git a/src/assets/images/emoji/dontknow.png b/src/assets/images/emoji/dontknow.png
new file mode 100644
index 000000000..a4473d862
--- /dev/null
+++ b/src/assets/images/emoji/dontknow.png
Binary files differ
diff --git a/src/assets/images/emoji/sad.png b/src/assets/images/emoji/sad.png
new file mode 100644
index 000000000..b8b6ff69b
--- /dev/null
+++ b/src/assets/images/emoji/sad.png
Binary files differ
diff --git a/src/assets/images/emoji/star.png b/src/assets/images/emoji/star.png
new file mode 100755
index 000000000..0b9aa67da
--- /dev/null
+++ b/src/assets/images/emoji/star.png
Binary files differ
diff --git a/src/assets/images/logo.svg b/src/assets/images/logo.svg
new file mode 100644
index 000000000..87188f4aa
--- /dev/null
+++ b/src/assets/images/logo.svg
@@ -0,0 +1,35 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
3 <!-- Generator: Sketch 42 (36781) - http://www.bohemiancoding.com/sketch -->
4 <title>franz</title>
5 <desc>Created with Sketch.</desc>
6 <defs>
7 <filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
8 <feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
9 <feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
10 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
11 <feMerge>
12 <feMergeNode in="shadowMatrixOuter1"></feMergeNode>
13 <feMergeNode in="SourceGraphic"></feMergeNode>
14 </feMerge>
15 </filter>
16 <linearGradient x1="30.4719662%" y1="95.2408507%" x2="68.063052%" y2="3.5648771%" id="linearGradient-2">
17 <stop stop-color="#318CC8" offset="0%"></stop>
18 <stop stop-color="#5EC0FF" offset="100%"></stop>
19 </linearGradient>
20 </defs>
21 <g id="icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
22 <g id="franz">
23 <g id="Page-1" filter="url(#filter-1)" transform="translate(4.000000, 4.000000)">
24 <path d="M35.9420808,71.5384615 C16.3467148,71.5384615 0.461538462,55.6532852 0.461538462,36.0579192 C0.461538462,16.4625533 16.3467148,0.577376909 35.9420808,0.577376909 C55.5374467,0.577376909 71.4226231,16.4625533 71.4226231,36.0579192 C71.4226231,55.6532852 55.5374467,71.5384615 35.9420808,71.5384615 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="2.5" fill="url(#linearGradient-2)"></path>
25 <path d="M48.9831862,47.7311418 C48.3589601,47.7311418 47.7333024,47.695349 47.1072541,47.6262364 L47.1072541,51.7992839 C47.1072541,53.122185 48.5184007,53.8277583 50.4587598,53.8277583 C52.3991188,53.8277583 56.8089193,54.18061 58.5727874,51.0937106 C59.4799717,49.5060731 60.0599449,47.7327036 60.4099332,46.2456764 C57.3247258,47.2180686 53.4557216,47.7311418 48.9831862,47.7311418" id="Fill-3" fill="#FEFEFE"></path>
26 <path d="M60.8655036,43.2258722 C60.8656338,43.2176724 60.8658941,43.2080409 60.8658941,43.2002315 C60.8658941,42.8456878 60.7797311,40.8259338 60.667537,38.2174863 C60.6590769,38.2176164 60.6513977,38.2178768 60.6428075,38.2178768 C58.3771897,38.2178768 55.0258142,37.0998405 53.3244207,35.7764188 C52.9135196,35.4568869 52.4548514,35.0301068 51.9649459,34.5413727 C49.7058359,37.4808058 47.549809,41.6273016 47.1687136,44.910866 C47.7640451,44.9870071 48.3697891,45.0282664 48.9832122,45.0282664 C53.829164,45.0282664 57.9269816,44.3915454 60.8655036,43.2258722" id="Fill-5" fill="#FEFEFE"></path>
27 <path d="M54.9840212,33.643 C56.1988932,34.5880595 58.8726789,35.4818377 60.5515554,35.5134655 C60.3633505,31.0949446 60.1603078,26.114412 60.1603078,24.4584428 C60.1603078,21.3716736 60.2495945,18.5935032 59.2783737,18.5935032 C56.8089323,18.5935032 57.4262601,29.3093405 54.3393608,31.9551427 C54.1456893,32.1212212 53.9478528,32.3035691 53.747153,32.5006246 C54.2137606,32.9712671 54.6418423,33.3768319 54.9840212,33.643" id="Fill-7" fill="#FEFEFE"></path>
28 <path d="M12.0732694,35.4601798 C13.7241625,35.2508897 15.8906017,34.4664421 16.9491569,33.6430781 C17.3707308,33.3150861 17.9225903,32.7754611 18.5167504,32.1628189 C18.4383968,32.091754 18.3603035,32.0212096 18.2832514,31.9552208 C15.196352,29.3092884 15.8136798,18.5934511 13.3442384,18.5934511 C12.3731478,18.5934511 12.4623043,21.3716215 12.4623043,24.4585209 C12.4623043,26.107722 12.2608235,31.0550649 12.0732694,35.4601798" id="Fill-9" fill="#FEFEFE"></path>
29 <path d="M25.4406878,44.8074965 C25.008311,41.3920845 22.6955769,37.0765166 20.3525165,34.1524418 C19.7173574,34.8036101 19.1252797,35.3746025 18.6085622,35.7765229 C17.0675204,36.9752556 14.1720798,38.005567 11.9563116,38.188826 C11.8435969,40.8110701 11.7566529,42.8443602 11.7566529,43.2002055 C11.7566529,43.278429 11.7602973,43.3748743 11.7669352,43.4845954 C14.6418112,44.484971 18.4764543,45.0282403 22.9499008,45.0282403 C23.7973437,45.0282403 24.6298186,44.9488454 25.4406878,44.8074965" id="Fill-11" fill="#FEFEFE"></path>
30 <path d="M22.9499399,47.7311418 C18.830126,47.7311418 15.2211727,47.2965524 12.266251,46.4681122 C12.6207947,47.9073724 13.1873619,49.5845569 14.0497726,51.0937106 C15.8137709,54.18061 20.2235714,53.8277583 22.1638003,53.8277583 C24.1041594,53.8277583 25.515306,53.122185 25.515306,51.7992839 L25.515306,47.5360386 C24.6592728,47.6651529 23.8033699,47.7311418 22.9499399,47.7311418" id="Fill-13" fill="#FEFEFE"></path>
31 <path d="M63.5454456,37.4703413 C63.0231314,37.4703413 62.4684084,37.6495655 61.8962446,38.0029379 C61.814637,38.0533081 61.4828705,38.2178247 60.6428465,38.2178247 C58.3770986,38.2178247 55.0258532,37.0999186 53.3244598,35.7764969 C52.4748043,35.1156971 51.4221061,33.9989624 50.3075841,32.8167595 C47.9636126,30.3303978 45.7496666,27.9820011 43.8337767,27.9820011 C42.471829,27.9820011 40.9034545,28.6166396 39.3866216,29.2305834 C38.1232016,29.7418344 36.8167001,30.2705263 35.966524,30.2705263 C35.1163479,30.2705263 33.8099765,29.7418344 32.5465565,29.2305834 C31.0297236,28.6166396 29.461219,27.9820011 28.0994014,27.9820011 C26.1833814,27.9820011 23.9693052,30.330528 21.6254639,32.8168896 C20.5109419,33.9990926 19.4582437,35.1156971 18.6085882,35.7764969 C16.9074551,37.0997884 13.5560796,38.2176945 11.2902015,38.2178247 C10.4503076,38.2178247 10.1185411,38.0533081 10.0369336,38.0029379 C9.46476974,37.6495655 8.9099166,37.4703413 8.3877325,37.4703413 C7.64857908,37.4703413 7.0250038,37.8567732 6.76039755,38.4787866 C6.43422772,39.2456631 6.70586237,40.1875989 7.48679572,40.9985982 C9.95272293,43.559539 15.5888491,45.0282143 22.9499269,45.0282143 C27.7832535,45.0282143 32.1465885,42.492784 34.6182426,40.1200482 C35.1197319,39.6387329 35.7738938,39.6142637 35.8998843,39.6142637 L35.9039192,39.6137431 L35.9276075,39.6223333 L36.0163736,39.614524 L36.0331636,39.6142637 C36.1591542,39.6142637 36.8134462,39.6387329 37.3149356,40.1200482 C39.7865896,42.492784 44.1497944,45.0282143 48.9831211,45.0282143 C56.3441989,45.0282143 61.980325,43.559539 64.4463824,40.9985982 C65.2273158,40.1877291 65.4986901,39.2456631 65.1726504,38.4787866 C64.9080442,37.8567732 64.2844689,37.4703413 63.5454456,37.4703413" id="Fill-15" fill="#FEFEFE"></path>
32 </g>
33 </g>
34 </g>
35</svg> \ No newline at end of file
diff --git a/src/assets/images/sm.png b/src/assets/images/sm.png
new file mode 100644
index 000000000..2bf169bee
--- /dev/null
+++ b/src/assets/images/sm.png
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-1.ico b/src/assets/images/taskbar/win32/taskbar-1.ico
new file mode 100644
index 000000000..32ba334f3
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-1.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-10.ico b/src/assets/images/taskbar/win32/taskbar-10.ico
new file mode 100644
index 000000000..815a02ef7
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-10.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-2.ico b/src/assets/images/taskbar/win32/taskbar-2.ico
new file mode 100644
index 000000000..975295f3c
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-2.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-3.ico b/src/assets/images/taskbar/win32/taskbar-3.ico
new file mode 100644
index 000000000..a3798e486
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-3.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-4.ico b/src/assets/images/taskbar/win32/taskbar-4.ico
new file mode 100644
index 000000000..cd01ce0bf
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-4.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-5.ico b/src/assets/images/taskbar/win32/taskbar-5.ico
new file mode 100644
index 000000000..7119a5544
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-5.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-6.ico b/src/assets/images/taskbar/win32/taskbar-6.ico
new file mode 100644
index 000000000..17f41f04b
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-6.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-7.ico b/src/assets/images/taskbar/win32/taskbar-7.ico
new file mode 100644
index 000000000..c4aa16200
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-7.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-8.ico b/src/assets/images/taskbar/win32/taskbar-8.ico
new file mode 100644
index 000000000..896ff7b4b
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-8.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-9.ico b/src/assets/images/taskbar/win32/taskbar-9.ico
new file mode 100644
index 000000000..e3613e344
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-9.ico
Binary files differ
diff --git a/src/assets/images/taskbar/win32/taskbar-alert.ico b/src/assets/images/taskbar/win32/taskbar-alert.ico
new file mode 100644
index 000000000..5b349c2b6
--- /dev/null
+++ b/src/assets/images/taskbar/win32/taskbar-alert.ico
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray-active.png b/src/assets/images/tray/darwin/tray-active.png
new file mode 100644
index 000000000..489533dbf
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray-active.png
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray-active@2x.png b/src/assets/images/tray/darwin/tray-active@2x.png
new file mode 100644
index 000000000..76f212b52
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray-active@2x.png
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray-unread-active.png b/src/assets/images/tray/darwin/tray-unread-active.png
new file mode 100644
index 000000000..e2fd1a822
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray-unread-active.png
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray-unread-active@2x.png b/src/assets/images/tray/darwin/tray-unread-active@2x.png
new file mode 100644
index 000000000..9a64b3ef8
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray-unread-active@2x.png
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray-unread.png b/src/assets/images/tray/darwin/tray-unread.png
new file mode 100644
index 000000000..a94ad81fb
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray-unread.png
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray-unread@2x.png b/src/assets/images/tray/darwin/tray-unread@2x.png
new file mode 100644
index 000000000..56e74b16a
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray-unread@2x.png
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray.png b/src/assets/images/tray/darwin/tray.png
new file mode 100644
index 000000000..583f34df8
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray.png
Binary files differ
diff --git a/src/assets/images/tray/darwin/tray@2x.png b/src/assets/images/tray/darwin/tray@2x.png
new file mode 100644
index 000000000..479a2cf95
--- /dev/null
+++ b/src/assets/images/tray/darwin/tray@2x.png
Binary files differ
diff --git a/src/assets/images/tray/linux/tray-unread.png b/src/assets/images/tray/linux/tray-unread.png
new file mode 100644
index 000000000..a94ad81fb
--- /dev/null
+++ b/src/assets/images/tray/linux/tray-unread.png
Binary files differ
diff --git a/src/assets/images/tray/linux/tray-unread@2x.png b/src/assets/images/tray/linux/tray-unread@2x.png
new file mode 100644
index 000000000..56e74b16a
--- /dev/null
+++ b/src/assets/images/tray/linux/tray-unread@2x.png
Binary files differ
diff --git a/src/assets/images/tray/linux/tray.png b/src/assets/images/tray/linux/tray.png
new file mode 100644
index 000000000..583f34df8
--- /dev/null
+++ b/src/assets/images/tray/linux/tray.png
Binary files differ
diff --git a/src/assets/images/tray/linux/tray@2x.png b/src/assets/images/tray/linux/tray@2x.png
new file mode 100644
index 000000000..479a2cf95
--- /dev/null
+++ b/src/assets/images/tray/linux/tray@2x.png
Binary files differ
diff --git a/src/assets/images/tray/win32/tray-unread.ico b/src/assets/images/tray/win32/tray-unread.ico
new file mode 100644
index 000000000..a59428cfb
--- /dev/null
+++ b/src/assets/images/tray/win32/tray-unread.ico
Binary files differ
diff --git a/src/assets/images/tray/win32/tray-unread@2x.ico b/src/assets/images/tray/win32/tray-unread@2x.ico
new file mode 100644
index 000000000..f6fe65093
--- /dev/null
+++ b/src/assets/images/tray/win32/tray-unread@2x.ico
Binary files differ
diff --git a/src/assets/images/tray/win32/tray.ico b/src/assets/images/tray/win32/tray.ico
new file mode 100644
index 000000000..d3aa25d68
--- /dev/null
+++ b/src/assets/images/tray/win32/tray.ico
Binary files differ
diff --git a/src/assets/images/tray/win32/tray@2x.ico b/src/assets/images/tray/win32/tray@2x.ico
new file mode 100644
index 000000000..c1b7a73a5
--- /dev/null
+++ b/src/assets/images/tray/win32/tray@2x.ico
Binary files differ
diff --git a/src/components/auth/AuthLayout.js b/src/components/auth/AuthLayout.js
new file mode 100644
index 000000000..2741b8a15
--- /dev/null
+++ b/src/components/auth/AuthLayout.js
@@ -0,0 +1,88 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { RouteTransition } from 'react-router-transition';
5import { intlShape } from 'react-intl';
6
7import Link from '../ui/Link';
8import InfoBar from '../ui/InfoBar';
9
10import { oneOrManyChildElements, globalError as globalErrorPropType } from '../../prop-types';
11import globalMessages from '../../i18n/globalMessages';
12
13@observer
14export default class AuthLayout extends Component {
15 static propTypes = {
16 children: oneOrManyChildElements.isRequired,
17 pathname: PropTypes.string.isRequired,
18 error: globalErrorPropType.isRequired,
19 isOnline: PropTypes.bool.isRequired,
20 isAPIHealthy: PropTypes.bool.isRequired,
21 retryHealthCheck: PropTypes.func.isRequired,
22 isHealthCheckLoading: PropTypes.bool.isRequired,
23 };
24
25 static contextTypes = {
26 intl: intlShape,
27 };
28
29 render() {
30 const {
31 children,
32 pathname,
33 error,
34 isOnline,
35 isAPIHealthy,
36 retryHealthCheck,
37 isHealthCheckLoading,
38 } = this.props;
39 const { intl } = this.context;
40
41 return (
42 <div className="auth">
43 {!isOnline && (
44 <InfoBar
45 type="warning"
46 >
47 <span className="mdi mdi-flash" />
48 {intl.formatMessage(globalMessages.notConnectedToTheInternet)}
49 </InfoBar>
50 )}
51 {isOnline && !isAPIHealthy && (
52 <InfoBar
53 type="danger"
54 ctaLabel="Try again"
55 ctaLoading={isHealthCheckLoading}
56 sticky
57 onClick={retryHealthCheck}
58 >
59 <span className="mdi mdi-flash" />
60 {intl.formatMessage(globalMessages.APIUnhealthy)}
61 </InfoBar>
62 )}
63 <div className="auth__layout">
64 <RouteTransition
65 pathname={pathname}
66 atEnter={{ opacity: 0 }}
67 atLeave={{ opacity: 0 }}
68 atActive={{ opacity: 1 }}
69 mapStyles={styles => ({
70 transform: `translateX(${styles.translateX}%)`,
71 opacity: styles.opacity,
72 })}
73 component="span"
74 >
75 {/* Inject globalError into children */}
76 {React.cloneElement(children, {
77 error,
78 })}
79 </RouteTransition>
80 </div>
81 {/* </div> */}
82 <Link to="https://adlk.io" className="auth__adlk" target="_blank">
83 <img src="./assets/images/adlk.svg" alt="" />
84 </Link>
85 </div>
86 );
87 }
88}
diff --git a/src/components/auth/Import.js b/src/components/auth/Import.js
new file mode 100644
index 000000000..cf83aa9c8
--- /dev/null
+++ b/src/components/auth/Import.js
@@ -0,0 +1,168 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6import classnames from 'classnames';
7
8import Form from '../../lib/Form';
9import Toggle from '../ui/Toggle';
10import Button from '../ui/Button';
11
12const messages = defineMessages({
13 headline: {
14 id: 'import.headline',
15 defaultMessage: '!!!Import your Franz 4 services',
16 },
17 notSupportedHeadline: {
18 id: 'import.notSupportedHeadline',
19 defaultMessage: '!!!Services not yet supported in Franz 5',
20 },
21 submitButtonLabel: {
22 id: 'import.submit.label',
23 defaultMessage: '!!!Import {count} services',
24 },
25 skipButtonLabel: {
26 id: 'import.skip.label',
27 defaultMessage: '!!!I want add services manually',
28 },
29});
30
31@observer
32export default class Import extends Component {
33 static propTypes = {
34 services: MobxPropTypes.arrayOrObservableArray.isRequired,
35 onSubmit: PropTypes.func.isRequired,
36 isSubmitting: PropTypes.bool.isRequired,
37 inviteRoute: PropTypes.string.isRequired,
38 };
39
40 static contextTypes = {
41 intl: intlShape,
42 };
43
44 prepareForm() {
45 const { services } = this.props;
46
47 const config = {
48 fields: {
49 import: [...services.filter(s => s.recipe).map(s => ({
50 add: {
51 default: true,
52 options: s,
53 },
54 }))],
55 },
56 };
57
58 return new Form(config, this.context.intl);
59 }
60
61 submit(e) {
62 const { services } = this.props;
63 e.preventDefault();
64 this.form.submit({
65 onSuccess: (form) => {
66 const servicesImport = form.values().import
67 .map((value, i) => !value.add || services.filter(s => s.recipe)[i])
68 .filter(s => typeof s !== 'boolean');
69
70 this.props.onSubmit({ services: servicesImport });
71 },
72 onError: () => {},
73 });
74 }
75
76 render() {
77 this.form = this.prepareForm();
78 const { intl } = this.context;
79 const { services, isSubmitting, inviteRoute } = this.props;
80
81 const availableServices = services.filter(s => s.recipe);
82 const unavailableServices = services.filter(s => !s.recipe);
83
84 return (
85 <div className="auth__scroll-container">
86 <div className="auth__container auth__container--signup">
87 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
88 <img
89 src="./assets/images/logo.svg"
90 className="auth__logo"
91 alt=""
92 />
93 <h1>
94 {intl.formatMessage(messages.headline)}
95 </h1>
96 <table className="service-table available-services">
97 <tbody>
98 {this.form.$('import').map((service, i) => (
99 <tr
100 key={service.id}
101 className="service-table__row"
102 onClick={() => service.$('add').set(!service.$('add').value)}
103 >
104 <td className="service-table__toggle">
105 <Toggle
106 field={service.$('add')}
107 showLabel={false}
108 />
109 </td>
110 <td className="service-table__column-icon">
111 <img
112 src={availableServices[i].custom_icon || availableServices[i].recipe.icons.svg}
113 className={classnames({
114 'service-table__icon': true,
115 'has-custom-icon': availableServices[i].custom_icon,
116 })}
117 alt=""
118 />
119 </td>
120 <td className="service-table__column-name">
121 {availableServices[i].name !== ''
122 ? availableServices[i].name
123 : availableServices[i].recipe.name}
124 </td>
125 </tr>
126 ))}
127 </tbody>
128 </table>
129 {unavailableServices.length > 0 && (
130 <div className="unavailable-services">
131 <strong>{intl.formatMessage(messages.notSupportedHeadline)}</strong>
132 <p>
133 {services.filter(s => !s.recipe).map((service, i) => (
134 <span key={service.id}>
135 {service.name !== '' ? service.name : service.service}
136 {unavailableServices.length > i + 1 ? ', ' : ''}
137 </span>
138 ))}
139 </p>
140 </div>
141 )}
142
143 {isSubmitting ? (
144 <Button
145 className="auth__button is-loading"
146 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
147 loaded={false}
148 disabled
149 />
150 ) : (
151 <Button
152 type="submit"
153 className="auth__button"
154 label={intl.formatMessage(messages.submitButtonLabel)}
155 />
156 )}
157 <Link
158 to={inviteRoute}
159 className="franz-form__button franz-form__button--secondary auth__button auth__button--skip"
160 >
161 {intl.formatMessage(messages.skipButtonLabel)}
162 </Link>
163 </form>
164 </div>
165 </div>
166 );
167 }
168}
diff --git a/src/components/auth/Invite.js b/src/components/auth/Invite.js
new file mode 100644
index 000000000..c1d815dcd
--- /dev/null
+++ b/src/components/auth/Invite.js
@@ -0,0 +1,111 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6
7import Form from '../../lib/Form';
8import { email } from '../../helpers/validation-helpers';
9import Input from '../ui/Input';
10import Button from '../ui/Button';
11
12const messages = defineMessages({
13 headline: {
14 id: 'invite.headline.friends',
15 defaultMessage: '!!!Invite 3 of your friends or colleagues',
16 },
17 nameLabel: {
18 id: 'invite.name.label',
19 defaultMessage: '!!!Name',
20 },
21 emailLabel: {
22 id: 'invite.email.label',
23 defaultMessage: '!!!Email address',
24 },
25 submitButtonLabel: {
26 id: 'invite.submit.label',
27 defaultMessage: '!!!Send invites',
28 },
29 skipButtonLabel: {
30 id: 'invite.skip.label',
31 defaultMessage: '!!!I want to do this later',
32 },
33});
34
35@observer
36export default class Invite extends Component {
37 static propTypes = {
38 onSubmit: PropTypes.func.isRequired,
39 };
40
41 static contextTypes = {
42 intl: intlShape,
43 };
44
45 form = new Form({
46 fields: {
47 invite: [...Array(3).fill({
48 name: {
49 label: this.context.intl.formatMessage(messages.nameLabel),
50 // value: '',
51 placeholder: this.context.intl.formatMessage(messages.nameLabel),
52 },
53 email: {
54 label: this.context.intl.formatMessage(messages.emailLabel),
55 // value: '',
56 validate: [email],
57 placeholder: this.context.intl.formatMessage(messages.emailLabel),
58 },
59 })],
60 },
61 }, this.context.intl);
62
63 submit(e) {
64 e.preventDefault();
65 this.form.submit({
66 onSuccess: (form) => {
67 this.props.onSubmit({ invites: form.values().invite });
68 },
69 onError: () => {},
70 });
71 }
72
73 render() {
74 const { form } = this;
75 const { intl } = this.context;
76
77 return (
78 <div className="auth__container auth__container--signup">
79 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
80 <img
81 src="./assets/images/logo.svg"
82 className="auth__logo"
83 alt=""
84 />
85 <h1>
86 {intl.formatMessage(messages.headline)}
87 </h1>
88 {form.$('invite').map(invite => (
89 <div className="grid" key={invite.key}>
90 <div className="grid__row">
91 <Input field={invite.$('name')} showLabel={false} />
92 <Input field={invite.$('email')} showLabel={false} />
93 </div>
94 </div>
95 ))}
96 <Button
97 type="submit"
98 className="auth__button"
99 label={intl.formatMessage(messages.submitButtonLabel)}
100 />
101 <Link
102 to="/"
103 className="franz-form__button franz-form__button--secondary auth__button auth__button--skip"
104 >
105 {intl.formatMessage(messages.skipButtonLabel)}
106 </Link>
107 </form>
108 </div>
109 );
110 }
111}
diff --git a/src/components/auth/Login.js b/src/components/auth/Login.js
new file mode 100644
index 000000000..015079f02
--- /dev/null
+++ b/src/components/auth/Login.js
@@ -0,0 +1,161 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import { required, email } from '../../helpers/validation-helpers';
8import Input from '../ui/Input';
9import Button from '../ui/Button';
10import Link from '../ui/Link';
11
12import { globalError as globalErrorPropType } from '../../prop-types';
13
14// import Appear from '../ui/effects/Appear';
15
16const messages = defineMessages({
17 headline: {
18 id: 'login.headline',
19 defaultMessage: '!!!Sign in',
20 },
21 emailLabel: {
22 id: 'login.email.label',
23 defaultMessage: '!!!Email address',
24 },
25 passwordLabel: {
26 id: 'login.password.label',
27 defaultMessage: '!!!Password',
28 },
29 submitButtonLabel: {
30 id: 'login.submit.label',
31 defaultMessage: '!!!Sign in',
32 },
33 invalidCredentials: {
34 id: 'login.invalidCredentials',
35 defaultMessage: '!!!Email or password not valid',
36 },
37 tokenExpired: {
38 id: 'login.tokenExpired',
39 defaultMessage: '!!!Your session expired, please login again.',
40 },
41 serverLogout: {
42 id: 'login.serverLogout',
43 defaultMessage: '!!!Your session expired, please login again.',
44 },
45 signupLink: {
46 id: 'login.link.signup',
47 defaultMessage: '!!!Create a free account',
48 },
49 passwordLink: {
50 id: 'login.link.password',
51 defaultMessage: '!!!Forgot password',
52 },
53});
54
55@observer
56export default class Login extends Component {
57 static propTypes = {
58 onSubmit: PropTypes.func.isRequired,
59 isSubmitting: PropTypes.bool.isRequired,
60 isTokenExpired: PropTypes.bool.isRequired,
61 isServerLogout: PropTypes.bool.isRequired,
62 signupRoute: PropTypes.string.isRequired,
63 passwordRoute: PropTypes.string.isRequired,
64 error: globalErrorPropType.isRequired,
65 };
66
67 static contextTypes = {
68 intl: intlShape,
69 };
70
71 form = new Form({
72 fields: {
73 email: {
74 label: this.context.intl.formatMessage(messages.emailLabel),
75 value: '',
76 validate: [required, email],
77 },
78 password: {
79 label: this.context.intl.formatMessage(messages.passwordLabel),
80 value: '',
81 validate: [required],
82 type: 'password',
83 },
84 },
85 }, this.context.intl);
86
87 submit(e) {
88 e.preventDefault();
89 this.form.submit({
90 onSuccess: (form) => {
91 this.props.onSubmit(form.values());
92 },
93 onError: () => {},
94 });
95 }
96
97 emailField = null;
98
99 render() {
100 const { form } = this;
101 const { intl } = this.context;
102 const {
103 isSubmitting,
104 isTokenExpired,
105 isServerLogout,
106 signupRoute,
107 passwordRoute,
108 error,
109 } = this.props;
110
111 return (
112 <div className="auth__container">
113 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
114 <img
115 src="./assets/images/logo.svg"
116 className="auth__logo"
117 alt=""
118 />
119 <h1>{intl.formatMessage(messages.headline)}</h1>
120 {isTokenExpired && (
121 <p className="error-message center">{intl.formatMessage(messages.tokenExpired)}</p>
122 )}
123 {isServerLogout && (
124 <p className="error-message center">{intl.formatMessage(messages.serverLogout)}</p>
125 )}
126 <Input
127 field={form.$('email')}
128 ref={(element) => { this.emailField = element; }}
129 focus
130 />
131 <Input
132 field={form.$('password')}
133 showPasswordToggle
134 />
135 {error.code === 'invalid-credentials' && (
136 <p className="error-message center">{intl.formatMessage(messages.invalidCredentials)}</p>
137 )}
138 {isSubmitting ? (
139 <Button
140 className="auth__button is-loading"
141 buttonType="secondary"
142 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
143 loaded={false}
144 disabled
145 />
146 ) : (
147 <Button
148 type="submit"
149 className="auth__button"
150 label={intl.formatMessage(messages.submitButtonLabel)}
151 />
152 )}
153 </form>
154 <div className="auth__links">
155 <Link to={signupRoute}>{intl.formatMessage(messages.signupLink)}</Link>
156 <Link to={passwordRoute}>{intl.formatMessage(messages.passwordLink)}</Link>
157 </div>
158 </div>
159 );
160 }
161}
diff --git a/src/components/auth/Password.js b/src/components/auth/Password.js
new file mode 100644
index 000000000..d2b196853
--- /dev/null
+++ b/src/components/auth/Password.js
@@ -0,0 +1,135 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import { required, email } from '../../helpers/validation-helpers';
8import Input from '../ui/Input';
9import Button from '../ui/Button';
10import Link from '../ui/Link';
11import Infobox from '../ui/Infobox';
12
13const messages = defineMessages({
14 headline: {
15 id: 'password.headline',
16 defaultMessage: '!!!Forgot password',
17 },
18 emailLabel: {
19 id: 'password.email.label',
20 defaultMessage: '!!!Email address',
21 },
22 submitButtonLabel: {
23 id: 'password.submit.label',
24 defaultMessage: '!!!Submit',
25 },
26 successInfo: {
27 id: 'password.successInfo',
28 defaultMessage: '!!!Your new password was sent to your email address',
29 },
30 noUser: {
31 id: 'password.noUser',
32 defaultMessage: '!!!No user affiliated with that email address',
33 },
34 signupLink: {
35 id: 'password.link.signup',
36 defaultMessage: '!!!Create a free account',
37 },
38 loginLink: {
39 id: 'password.link.login',
40 defaultMessage: '!!!Sign in to your account',
41 },
42});
43
44@observer
45export default class Password extends Component {
46 static propTypes = {
47 onSubmit: PropTypes.func.isRequired,
48 isSubmitting: PropTypes.bool.isRequired,
49 signupRoute: PropTypes.string.isRequired,
50 loginRoute: PropTypes.string.isRequired,
51 status: MobxPropTypes.arrayOrObservableArray.isRequired,
52 };
53
54 static contextTypes = {
55 intl: intlShape,
56 };
57
58 form = new Form({
59 fields: {
60 email: {
61 label: this.context.intl.formatMessage(messages.emailLabel),
62 value: '',
63 validate: [required, email],
64 },
65 },
66 }, this.context.intl);
67
68 submit(e) {
69 e.preventDefault();
70 this.form.submit({
71 onSuccess: (form) => {
72 this.props.onSubmit(form.values());
73 },
74 onError: () => {},
75 });
76 }
77
78 render() {
79 const { form } = this;
80 const { intl } = this.context;
81 const {
82 isSubmitting,
83 signupRoute,
84 loginRoute,
85 status,
86 } = this.props;
87
88 return (
89 <div className="auth__container">
90 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
91 <img
92 src="./assets/images/logo.svg"
93 className="auth__logo"
94 alt=""
95 />
96 <h1>{intl.formatMessage(messages.headline)}</h1>
97 {status.length > 0 && status.includes('sent') && (
98 <Infobox
99 type="success"
100 icon="checkbox-marked-circle-outline"
101 >
102 {intl.formatMessage(messages.successInfo)}
103 </Infobox>
104 )}
105 <Input
106 field={form.$('email')}
107 focus
108 />
109 {status.length > 0 && status.includes('no-user') && (
110 <p className="error-message center">{intl.formatMessage(messages.noUser)}</p>
111 )}
112 {isSubmitting ? (
113 <Button
114 className="auth__button is-loading"
115 buttonType="secondary"
116 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
117 loaded={false}
118 disabled
119 />
120 ) : (
121 <Button
122 type="submit"
123 className="auth__button"
124 label={intl.formatMessage(messages.submitButtonLabel)}
125 />
126 )}
127 </form>
128 <div className="auth__links">
129 <Link to={loginRoute}>{intl.formatMessage(messages.loginLink)}</Link>
130 <Link to={signupRoute}>{intl.formatMessage(messages.signupLink)}</Link>
131 </div>
132 </div>
133 );
134 }
135}
diff --git a/src/components/auth/Pricing.js b/src/components/auth/Pricing.js
new file mode 100644
index 000000000..761561a89
--- /dev/null
+++ b/src/components/auth/Pricing.js
@@ -0,0 +1,130 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5// import { Link } from 'react-router';
6
7// import Button from '../ui/Button';
8import Loader from '../ui/Loader';
9import Appear from '../ui/effects/Appear';
10import SubscriptionForm from '../../containers/ui/SubscriptionFormScreen';
11
12const messages = defineMessages({
13 headline: {
14 id: 'pricing.headline',
15 defaultMessage: '!!!Support Franz',
16 },
17 monthlySupportLabel: {
18 id: 'pricing.support.label',
19 defaultMessage: '!!!Select your support plan',
20 },
21 submitButtonLabel: {
22 id: 'pricing.submit.label',
23 defaultMessage: '!!!Support the development of Franz',
24 },
25 skipPayment: {
26 id: 'pricing.link.skipPayment',
27 defaultMessage: '!!!I don\'t want to support the development of Franz.',
28 },
29});
30
31@observer
32export default class Signup extends Component {
33 static propTypes = {
34 donor: MobxPropTypes.objectOrObservableObject.isRequired,
35 isLoading: PropTypes.bool.isRequired,
36 isLoadingUser: PropTypes.bool.isRequired,
37 onCloseSubscriptionWindow: PropTypes.func.isRequired,
38 skipAction: PropTypes.func.isRequired,
39 };
40
41 static contextTypes = {
42 intl: intlShape,
43 };
44
45 render() {
46 const {
47 donor,
48 isLoading,
49 isLoadingUser,
50 onCloseSubscriptionWindow,
51 skipAction,
52 } = this.props;
53 const { intl } = this.context;
54
55 return (
56 <div className="auth__scroll-container">
57 <div className="auth__container auth__container--signup">
58 <form className="franz-form auth__form">
59 <img
60 src="./assets/images/sm.png"
61 className="auth__logo auth__logo--sm"
62 alt=""
63 />
64 <h1>{intl.formatMessage(messages.headline)}</h1>
65 <div className="auth__letter">
66 {isLoadingUser && (
67 <p>Loading</p>
68 )}
69 {!isLoadingUser && (
70 donor.amount ? (
71 <span>
72 <p>
73 Thank you so much for your previous donation of <strong>$ {donor.amount}</strong>.
74 <br />
75 Your support allowed us to get where we are today.
76 <br />
77 </p>
78 <p>
79 As an early supporter, you get <strong>a lifetime premium supporter license</strong> without any
80 additional charges.
81 </p>
82 <p>
83 However, If you want to keep supporting us, you are more than welcome to subscribe to a plan.
84 <br /><br />
85 </p>
86 </span>
87 ) : (
88 <span>
89 <p>
90 We built Franz with a lot of effort, manpower and love,
91 to bring you the best messaging experience.
92 <br />
93 </p>
94 <p>
95 Getting a Franz Premium Supporter License will allow us to keep improving Franz for you.
96 </p>
97 </span>
98 )
99 )}
100 <p>
101 Thanks for being a hero.
102 </p>
103 <p>
104 <strong>Stefan Malzner</strong>
105 </p>
106 </div>
107 <Loader loaded={!isLoading}>
108 <Appear transitionName="slideDown">
109 <span className="label">{intl.formatMessage(messages.monthlySupportLabel)}</span>
110 <SubscriptionForm
111 onCloseWindow={onCloseSubscriptionWindow}
112 showSkipOption
113 skipAction={skipAction}
114 hideInfo={Boolean(donor.amount)}
115 skipButtonLabel={intl.formatMessage(messages.skipPayment)}
116 />
117 {/* <Link
118 to={inviteRoute}
119 className="franz-form__button franz-form__button--secondary auth__button auth__button--skip"
120 >
121 {intl.formatMessage(messages.skipPayment)}
122 </Link> */}
123 </Appear>
124 </Loader>
125 </form>
126 </div>
127 </div>
128 );
129 }
130}
diff --git a/src/components/auth/Signup.js b/src/components/auth/Signup.js
new file mode 100644
index 000000000..71ca16111
--- /dev/null
+++ b/src/components/auth/Signup.js
@@ -0,0 +1,206 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import { required, email, minLength } from '../../helpers/validation-helpers';
8import Input from '../ui/Input';
9import Radio from '../ui/Radio';
10import Button from '../ui/Button';
11import Link from '../ui/Link';
12
13import { globalError as globalErrorPropType } from '../../prop-types';
14
15const messages = defineMessages({
16 headline: {
17 id: 'signup.headline',
18 defaultMessage: '!!!Sign up',
19 },
20 firstnameLabel: {
21 id: 'signup.firstname.label',
22 defaultMessage: '!!!Firstname',
23 },
24 lastnameLabel: {
25 id: 'signup.lastname.label',
26 defaultMessage: '!!!Lastname',
27 },
28 emailLabel: {
29 id: 'signup.email.label',
30 defaultMessage: '!!!Email address',
31 },
32 companyLabel: {
33 id: 'signup.company.label',
34 defaultMessage: '!!!Company',
35 },
36 passwordLabel: {
37 id: 'signup.password.label',
38 defaultMessage: '!!!Password',
39 },
40 legalInfo: {
41 id: 'signup.legal.info',
42 defaultMessage: '!!!By creating a Franz account you accept the',
43 },
44 terms: {
45 id: 'signup.legal.terms',
46 defaultMessage: '!!!Terms of service',
47 },
48 privacy: {
49 id: 'signup.legal.privacy',
50 defaultMessage: '!!!Privacy Statement',
51 },
52 submitButtonLabel: {
53 id: 'signup.submit.label',
54 defaultMessage: '!!!Create account',
55 },
56 loginLink: {
57 id: 'signup.link.login',
58 defaultMessage: '!!!Already have an account, sign in?',
59 },
60 emailDuplicate: {
61 id: 'signup.emailDuplicate',
62 defaultMessage: '!!!A user with that email address already exists',
63 },
64});
65
66@observer
67export default class Signup extends Component {
68 static propTypes = {
69 onSubmit: PropTypes.func.isRequired,
70 isSubmitting: PropTypes.bool.isRequired,
71 loginRoute: PropTypes.string.isRequired,
72 error: globalErrorPropType.isRequired,
73 };
74
75 static contextTypes = {
76 intl: intlShape,
77 };
78
79 form = new Form({
80 fields: {
81 accountType: {
82 value: 'individual',
83 validate: [required],
84 options: [{
85 value: 'individual',
86 label: 'Individual',
87 }, {
88 value: 'non-profit',
89 label: 'Non-Profit',
90 }, {
91 value: 'company',
92 label: 'Company',
93 }],
94 },
95 firstname: {
96 label: this.context.intl.formatMessage(messages.firstnameLabel),
97 value: '',
98 validate: [required],
99 },
100 lastname: {
101 label: this.context.intl.formatMessage(messages.lastnameLabel),
102 value: '',
103 validate: [required],
104 },
105 email: {
106 label: this.context.intl.formatMessage(messages.emailLabel),
107 value: '',
108 validate: [required, email],
109 },
110 organization: {
111 label: this.context.intl.formatMessage(messages.companyLabel),
112 value: '', // TODO: make required when accountType: company
113 },
114 password: {
115 label: this.context.intl.formatMessage(messages.passwordLabel),
116 value: '',
117 validate: [required, minLength(6)],
118 type: 'password',
119 },
120 },
121 }, this.context.intl);
122
123 submit(e) {
124 e.preventDefault();
125 this.form.submit({
126 onSuccess: (form) => {
127 this.props.onSubmit(form.values());
128 },
129 onError: () => {},
130 });
131 }
132
133 render() {
134 const { form } = this;
135 const { intl } = this.context;
136 const { isSubmitting, loginRoute, error } = this.props;
137
138 return (
139 <div className="auth__scroll-container">
140 <div className="auth__container auth__container--signup">
141 <form className="franz-form auth__form" onSubmit={e => this.submit(e)}>
142 <img
143 src="./assets/images/logo.svg"
144 className="auth__logo"
145 alt=""
146 />
147 <h1>{intl.formatMessage(messages.headline)}</h1>
148 <Radio field={form.$('accountType')} showLabel={false} />
149 <div className="grid__row">
150 <Input field={form.$('firstname')} focus />
151 <Input field={form.$('lastname')} />
152 </div>
153 <Input field={form.$('email')} />
154 <Input
155 field={form.$('password')}
156 showPasswordToggle
157 scorePassword
158 />
159 {form.$('accountType').value === 'company' && (
160 <Input field={form.$('organization')} />
161 )}
162 {error.code === 'email-duplicate' && (
163 <p className="error-message center">{intl.formatMessage(messages.emailDuplicate)}</p>
164 )}
165 {isSubmitting ? (
166 <Button
167 className="auth__button is-loading"
168 label={`${intl.formatMessage(messages.submitButtonLabel)} ...`}
169 loaded={false}
170 disabled
171 />
172 ) : (
173 <Button
174 type="submit"
175 className="auth__button"
176 label={intl.formatMessage(messages.submitButtonLabel)}
177 />
178 )}
179 <p className="legal">
180 {intl.formatMessage(messages.legalInfo)}
181 <br />
182 <Link
183 to="http://meetfranz.com/terms"
184 target="_blank"
185 className="link"
186 >
187 {intl.formatMessage(messages.terms)}
188 </Link>
189 &nbsp;&amp;&nbsp;
190 <Link
191 to="http://meetfranz.com/privacy"
192 target="_blank"
193 className="link"
194 >
195 {intl.formatMessage(messages.privacy)}
196 </Link>.
197 </p>
198 </form>
199 <div className="auth__links">
200 <Link to={loginRoute}>{intl.formatMessage(messages.loginLink)}</Link>
201 </div>
202 </div>
203 </div>
204 );
205 }
206}
diff --git a/src/components/auth/Welcome.js b/src/components/auth/Welcome.js
new file mode 100644
index 000000000..06b10ecfe
--- /dev/null
+++ b/src/components/auth/Welcome.js
@@ -0,0 +1,69 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Link from '../ui/Link';
7
8const messages = defineMessages({
9 signupButton: {
10 id: 'welcome.signupButton',
11 defaultMessage: '!!!Create a free account',
12 },
13 loginButton: {
14 id: 'welcome.loginButton',
15 defaultMessage: '!!!Login to your account',
16 },
17});
18
19@observer
20export default class Login extends Component {
21 static propTypes = {
22 loginRoute: PropTypes.string.isRequired,
23 signupRoute: PropTypes.string.isRequired,
24 recipes: MobxPropTypes.arrayOrObservableArray.isRequired,
25 };
26
27 static contextTypes = {
28 intl: intlShape,
29 };
30
31 render() {
32 const { intl } = this.context;
33 const {
34 loginRoute,
35 signupRoute,
36 recipes,
37 } = this.props;
38
39 return (
40 <div className="welcome">
41 <div className="welcome__content">
42 <img src="./assets/images/logo.svg" className="welcome__logo" alt="" />
43 {/* <img src="./assets/images/welcome.png" className="welcome__services" alt="" /> */}
44 <div className="welcome__text">
45 <h1>Franz</h1>
46 </div>
47 </div>
48 <div className="welcome__buttons">
49 <Link to={signupRoute} className="button">
50 {intl.formatMessage(messages.signupButton)}
51 </Link>
52 <Link to={loginRoute} className="button">
53 {intl.formatMessage(messages.loginButton)}
54 </Link>
55 </div>
56 <div className="welcome__featured-services">
57 {recipes.map(recipe => (
58 <img
59 key={recipe.id}
60 src={recipe.icons.svg}
61 className="welcome__featured-service"
62 alt=""
63 />
64 ))}
65 </div>
66 </div>
67 );
68 }
69}
diff --git a/src/components/layout/AppLayout.js b/src/components/layout/AppLayout.js
new file mode 100644
index 000000000..f60c170a8
--- /dev/null
+++ b/src/components/layout/AppLayout.js
@@ -0,0 +1,148 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import InfoBar from '../ui/InfoBar';
7import globalMessages from '../../i18n/globalMessages';
8
9function createMarkup(HTMLString) {
10 return { __html: HTMLString };
11}
12
13const messages = defineMessages({
14 servicesUpdated: {
15 id: 'infobar.servicesUpdated',
16 defaultMessage: '!!!Your services have been updated.',
17 },
18 updateAvailable: {
19 id: 'infobar.updateAvailable',
20 defaultMessage: '!!!A new update for Franz is available.',
21 },
22 buttonReloadServices: {
23 id: 'infobar.buttonReloadServices',
24 defaultMessage: '!!!Reload services',
25 },
26 buttonInstallUpdate: {
27 id: 'infobar.buttonInstallUpdate',
28 defaultMessage: '!!!Restart & install update',
29 },
30 requiredRequestsFailed: {
31 id: 'infobar.requiredRequestsFailed',
32 defaultMessage: '!!!Could not load services and user information',
33 },
34});
35
36@observer
37export default class AppLayout extends Component {
38 static propTypes = {
39 sidebar: PropTypes.element.isRequired,
40 services: PropTypes.element.isRequired,
41 children: PropTypes.element,
42 news: MobxPropTypes.arrayOrObservableArray.isRequired,
43 isOnline: PropTypes.bool.isRequired,
44 showServicesUpdatedInfoBar: PropTypes.bool.isRequired,
45 appUpdateIsDownloaded: PropTypes.bool.isRequired,
46 removeNewsItem: PropTypes.func.isRequired,
47 reloadServicesAfterUpdate: PropTypes.func.isRequired,
48 installAppUpdate: PropTypes.func.isRequired,
49 showRequiredRequestsError: PropTypes.bool.isRequired,
50 areRequiredRequestsSuccessful: PropTypes.bool.isRequired,
51 retryRequiredRequests: PropTypes.func.isRequired,
52 areRequiredRequestsLoading: PropTypes.bool.isRequired,
53 };
54
55 static defaultProps = {
56 children: [],
57 };
58
59 static contextTypes = {
60 intl: intlShape,
61 };
62
63 render() {
64 const {
65 sidebar,
66 services,
67 children,
68 isOnline,
69 news,
70 showServicesUpdatedInfoBar,
71 appUpdateIsDownloaded,
72 removeNewsItem,
73 reloadServicesAfterUpdate,
74 installAppUpdate,
75 showRequiredRequestsError,
76 areRequiredRequestsSuccessful,
77 retryRequiredRequests,
78 areRequiredRequestsLoading,
79 } = this.props;
80
81 const { intl } = this.context;
82
83 return (
84 <div>
85 <div className="app">
86 {sidebar}
87 <div className="app__service">
88 {news.length > 0 && news.map(item => (
89 <InfoBar
90 key={item.id}
91 position="top"
92 type={item.type}
93 sticky={item.sticky}
94 onHide={() => removeNewsItem({ newsId: item.id })}
95 >
96 <span dangerouslySetInnerHTML={createMarkup(item.message)} />
97 </InfoBar>
98 ))}
99 {!isOnline && (
100 <InfoBar
101 type="danger"
102 >
103 <span className="mdi mdi-flash" />
104 {intl.formatMessage(globalMessages.notConnectedToTheInternet)}
105 </InfoBar>
106 )}
107 {!areRequiredRequestsSuccessful && showRequiredRequestsError && (
108 <InfoBar
109 type="danger"
110 ctaLabel="Try again"
111 ctaLoading={areRequiredRequestsLoading}
112 sticky
113 onClick={retryRequiredRequests}
114 >
115 <span className="mdi mdi-flash" />
116 {intl.formatMessage(messages.requiredRequestsFailed)}
117 </InfoBar>
118 )}
119 {showServicesUpdatedInfoBar && (
120 <InfoBar
121 type="primary"
122 ctaLabel={intl.formatMessage(messages.buttonReloadServices)}
123 onClick={reloadServicesAfterUpdate}
124 sticky
125 >
126 <span className="mdi mdi-power-plug" />
127 {intl.formatMessage(messages.servicesUpdated)}
128 </InfoBar>
129 )}
130 {appUpdateIsDownloaded && (
131 <InfoBar
132 type="primary"
133 ctaLabel={intl.formatMessage(messages.buttonInstallUpdate)}
134 onClick={installAppUpdate}
135 sticky
136 >
137 <span className="mdi mdi-information" />
138 {intl.formatMessage(messages.updateAvailable)}
139 </InfoBar>
140 )}
141 {services}
142 </div>
143 </div>
144 {children}
145 </div>
146 );
147 }
148}
diff --git a/src/components/layout/Sidebar.js b/src/components/layout/Sidebar.js
new file mode 100644
index 000000000..4aee1ec60
--- /dev/null
+++ b/src/components/layout/Sidebar.js
@@ -0,0 +1,75 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import ReactTooltip from 'react-tooltip';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Tabbar from '../services/tabs/Tabbar';
7import { ctrlKey } from '../../environment';
8
9const messages = defineMessages({
10 settings: {
11 id: 'sidebar.settings',
12 defaultMessage: '!!!Settings',
13 },
14});
15
16export default class Sidebar extends Component {
17 static propTypes = {
18 openSettings: PropTypes.func.isRequired,
19 isPremiumUser: PropTypes.bool,
20 }
21
22 static defaultProps = {
23 isPremiumUser: false,
24 }
25
26 static contextTypes = {
27 intl: intlShape,
28 };
29
30 state = {
31 tooltipEnabled: true,
32 };
33
34 enableToolTip() {
35 this.setState({ tooltipEnabled: true });
36 }
37
38 disableToolTip() {
39 this.setState({ tooltipEnabled: false });
40 }
41
42 render() {
43 const { openSettings, isPremiumUser } = this.props;
44 const { intl } = this.context;
45 return (
46 <div className="sidebar">
47 <Tabbar
48 {...this.props}
49 enableToolTip={() => this.enableToolTip()}
50 disableToolTip={() => this.disableToolTip()}
51 />
52 <button
53 onClick={openSettings}
54 className="sidebar__settings-button"
55 data-tip={`Settings (${ctrlKey}+,)`}
56 >
57 {isPremiumUser && (
58 <span className="emoji">
59 <img src="./assets/images/emoji/star.png" alt="" />
60 </span>
61 )}
62 <img
63 src="./assets/images/logo.svg"
64 className="sidebar__logo"
65 alt=""
66 />
67 {intl.formatMessage(messages.settings)}
68 </button>
69 {this.state.tooltipEnabled && (
70 <ReactTooltip place="right" type="dark" effect="solid" />
71 )}
72 </div>
73 );
74 }
75}
diff --git a/src/components/services/content/ServiceWebview.js b/src/components/services/content/ServiceWebview.js
new file mode 100644
index 000000000..043ff42ea
--- /dev/null
+++ b/src/components/services/content/ServiceWebview.js
@@ -0,0 +1,73 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { autorun } from 'mobx';
4import { observer } from 'mobx-react';
5import Webview from 'react-electron-web-view';
6import classnames from 'classnames';
7
8import ServiceModel from '../../../models/Service';
9
10@observer
11export default class ServiceWebview extends Component {
12 static propTypes = {
13 service: PropTypes.instanceOf(ServiceModel).isRequired,
14 setWebviewReference: PropTypes.func.isRequired,
15 };
16
17 static defaultProps = {
18 isActive: false,
19 };
20
21 state = {
22 forceRepaint: false,
23 };
24
25 componentDidMount() {
26 autorun(() => {
27 if (this.props.service.isActive) {
28 this.setState({ forceRepaint: true });
29 setTimeout(() => {
30 this.setState({ forceRepaint: false });
31 }, 100);
32 }
33 });
34 }
35
36 webview = null;
37
38 render() {
39 const {
40 service,
41 setWebviewReference,
42 } = this.props;
43
44 const webviewClasses = classnames({
45 services__webview: true,
46 'is-active': service.isActive,
47 'services__webview--force-repaint': this.state.forceRepaint,
48 });
49
50 return (
51 <div className={webviewClasses}>
52 <Webview
53 ref={(element) => { this.webview = element; }}
54
55 autosize
56 src={service.url}
57 preload="./webview/plugin.js"
58 partition={`persist:service-${service.id}`}
59
60 onDidAttach={() => setWebviewReference({
61 serviceId: service.id,
62 webview: this.webview.view,
63 })}
64
65 useragent={service.userAgent}
66
67 disablewebsecurity
68 allowpopups
69 />
70 </div>
71 );
72 }
73}
diff --git a/src/components/services/content/Services.js b/src/components/services/content/Services.js
new file mode 100644
index 000000000..03c68b06f
--- /dev/null
+++ b/src/components/services/content/Services.js
@@ -0,0 +1,81 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { Link } from 'react-router';
5import { defineMessages, intlShape } from 'react-intl';
6
7import Webview from './ServiceWebview';
8import Appear from '../../ui/effects/Appear';
9
10const messages = defineMessages({
11 welcome: {
12 id: 'services.welcome',
13 defaultMessage: '!!!Welcome to Franz',
14 },
15 getStarted: {
16 id: 'services.getStarted',
17 defaultMessage: '!!!Get started',
18 },
19});
20
21@observer
22export default class Services extends Component {
23 static propTypes = {
24 services: MobxPropTypes.arrayOrObservableArray.isRequired,
25 setWebviewReference: PropTypes.func.isRequired,
26 handleIPCMessage: PropTypes.func.isRequired,
27 openWindow: PropTypes.func.isRequired,
28 };
29
30 static defaultProps = {
31 services: [],
32 activeService: '',
33 };
34
35 static contextTypes = {
36 intl: intlShape,
37 };
38
39 render() {
40 const {
41 services,
42 handleIPCMessage,
43 setWebviewReference,
44 openWindow,
45 } = this.props;
46 const { intl } = this.context;
47
48 return (
49 <div className="services">
50 {services.length === 0 && (
51 <Appear
52 timeout={1500}
53 transitionName="slideUp"
54 >
55 <div className="services__no-service">
56 <img src="./assets/images/logo.svg" alt="" />
57 <h1>{intl.formatMessage(messages.welcome)}</h1>
58 <Appear
59 timeout={300}
60 transitionName="slideUp"
61 >
62 <Link to="/settings/recipes" className="button">
63 {intl.formatMessage(messages.getStarted)}
64 </Link>
65 </Appear>
66 </div>
67 </Appear>
68 )}
69 {services.map(service => (
70 <Webview
71 key={service.id}
72 service={service}
73 handleIPCMessage={handleIPCMessage}
74 setWebviewReference={setWebviewReference}
75 openWindow={openWindow}
76 />
77 ))}
78 </div>
79 );
80 }
81}
diff --git a/src/components/services/tabs/TabBarSortableList.js b/src/components/services/tabs/TabBarSortableList.js
new file mode 100644
index 000000000..c0a68d1a5
--- /dev/null
+++ b/src/components/services/tabs/TabBarSortableList.js
@@ -0,0 +1,44 @@
1import React from 'react';
2import { observer } from 'mobx-react';
3import { SortableContainer } from 'react-sortable-hoc';
4
5import TabItem from './TabItem';
6import { ctrlKey } from '../../../environment';
7
8export default SortableContainer(observer(({
9 services,
10 setActive,
11 reload,
12 toggleNotifications,
13 deleteService,
14 disableService,
15 openSettings,
16}) => (
17 <ul
18 className="tabs"
19 >
20 {services.map((service, index) => (
21 <TabItem
22 key={service.id}
23 clickHandler={() => setActive({ serviceId: service.id })}
24 service={service}
25 index={index}
26 shortcutIndex={index + 1}
27 reload={() => reload({ serviceId: service.id })}
28 toggleNotifications={() => toggleNotifications({ serviceId: service.id })}
29 deleteService={() => deleteService({ serviceId: service.id })}
30 disableService={() => disableService({ serviceId: service.id })}
31 openSettings={openSettings}
32 />
33 ))}
34 <li>
35 <button
36 className="sidebar__add-service"
37 onClick={() => openSettings({ path: 'recipes' })}
38 data-tip={`Add new service (${ctrlKey}+N)`}
39 >
40 <span className="mdi mdi-plus" />
41 </button>
42 </li>
43 </ul>
44)));
diff --git a/src/components/services/tabs/TabItem.js b/src/components/services/tabs/TabItem.js
new file mode 100644
index 000000000..9e03d2e21
--- /dev/null
+++ b/src/components/services/tabs/TabItem.js
@@ -0,0 +1,136 @@
1import { remote } from 'electron';
2import React, { Component } from 'react';
3import { defineMessages, intlShape } from 'react-intl';
4import PropTypes from 'prop-types';
5import { observer } from 'mobx-react';
6import classnames from 'classnames';
7import { SortableElement } from 'react-sortable-hoc';
8
9import ServiceModel from '../../../models/Service';
10import { ctrlKey } from '../../../environment';
11
12const { Menu } = remote;
13
14const messages = defineMessages({
15 reload: {
16 id: 'tabs.item.reload',
17 defaultMessage: '!!!Reload',
18 },
19 edit: {
20 id: 'tabs.item.edit',
21 defaultMessage: '!!!Edit',
22 },
23 disableNotifications: {
24 id: 'tabs.item.disableNotifications',
25 defaultMessage: '!!!Disable notifications',
26 },
27 enableNotifications: {
28 id: 'tabs.item.enableNotification',
29 defaultMessage: '!!!Enable notifications',
30 },
31 disableService: {
32 id: 'tabs.item.disableService',
33 defaultMessage: '!!!Disable Service',
34 },
35 deleteService: {
36 id: 'tabs.item.deleteService',
37 defaultMessage: '!!!Delete Service',
38 },
39});
40
41@observer
42class TabItem extends Component {
43 static propTypes = {
44 service: PropTypes.instanceOf(ServiceModel).isRequired,
45 clickHandler: PropTypes.func.isRequired,
46 shortcutIndex: PropTypes.number.isRequired,
47 reload: PropTypes.func.isRequired,
48 toggleNotifications: PropTypes.func.isRequired,
49 openSettings: PropTypes.func.isRequired,
50 deleteService: PropTypes.func.isRequired,
51 disableService: PropTypes.func.isRequired,
52 };
53
54 static contextTypes = {
55 intl: intlShape,
56 };
57
58 render() {
59 const {
60 service,
61 clickHandler,
62 shortcutIndex,
63 reload,
64 toggleNotifications,
65 deleteService,
66 disableService,
67 openSettings,
68 } = this.props;
69 const { intl } = this.context;
70
71
72 const menuTemplate = [{
73 label: service.name || service.recipe.name,
74 enabled: false,
75 }, {
76 type: 'separator',
77 }, {
78 label: intl.formatMessage(messages.reload),
79 click: reload,
80 }, {
81 label: intl.formatMessage(messages.edit),
82 click: () => openSettings({
83 path: `services/edit/${service.id}`,
84 }),
85 }, {
86 type: 'separator',
87 }, {
88 label: service.isNotificationEnabled
89 ? intl.formatMessage(messages.disableNotifications)
90 : intl.formatMessage(messages.enableNotifications),
91 click: () => toggleNotifications(),
92 }, {
93 label: intl.formatMessage(messages.disableService),
94 click: () => disableService(),
95 }, {
96 type: 'separator',
97 }, {
98 label: intl.formatMessage(messages.deleteService),
99 click: () => deleteService(),
100 }];
101 const menu = Menu.buildFromTemplate(menuTemplate);
102
103 return (
104 <li
105 className={classnames({
106 'tab-item': true,
107 'is-active': service.isActive,
108 'has-custom-icon': service.hasCustomIcon,
109 })}
110 onClick={clickHandler}
111 onContextMenu={() => menu.popup(remote.getCurrentWindow())}
112 data-tip={`${service.name} ${shortcutIndex <= 9 ? `(${ctrlKey}+${shortcutIndex})` : ''}`}
113 >
114 <img
115 src={service.icon}
116 className="tab-item__icon"
117 alt=""
118 />
119 {service.unreadDirectMessageCount > 0 && (
120 <span className="tab-item__message-count">
121 {service.unreadDirectMessageCount}
122 </span>
123 )}
124 {service.unreadIndirectMessageCount > 0
125 && service.unreadDirectMessageCount === 0
126 && service.isIndirectMessageBadgeEnabled && (
127 <span className="tab-item__message-count is-indirect">
128
129 </span>
130 )}
131 </li>
132 );
133 }
134}
135
136export default SortableElement(TabItem);
diff --git a/src/components/services/tabs/Tabbar.js b/src/components/services/tabs/Tabbar.js
new file mode 100644
index 000000000..fdb2c0a59
--- /dev/null
+++ b/src/components/services/tabs/Tabbar.js
@@ -0,0 +1,77 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4
5import TabBarSortableList from './TabBarSortableList';
6
7@observer
8export default class TabBar extends Component {
9 static propTypes = {
10 services: MobxPropTypes.arrayOrObservableArray.isRequired,
11 setActive: PropTypes.func.isRequired,
12 openSettings: PropTypes.func.isRequired,
13 enableToolTip: PropTypes.func.isRequired,
14 disableToolTip: PropTypes.func.isRequired,
15 reorder: PropTypes.func.isRequired,
16 reload: PropTypes.func.isRequired,
17 toggleNotifications: PropTypes.func.isRequired,
18 deleteService: PropTypes.func.isRequired,
19 updateService: PropTypes.func.isRequired,
20 }
21
22 onSortEnd = ({ oldIndex, newIndex }) => {
23 const {
24 enableToolTip,
25 reorder,
26 } = this.props;
27
28 enableToolTip();
29 reorder({ oldIndex, newIndex });
30 };
31
32 disableService = ({ serviceId }) => {
33 const { updateService } = this.props;
34
35 if (serviceId) {
36 updateService({
37 serviceId,
38 serviceData: {
39 isEnabled: false,
40 },
41 redirect: false,
42 });
43 }
44 }
45
46 render() {
47 const {
48 services,
49 setActive,
50 openSettings,
51 disableToolTip,
52 reload,
53 toggleNotifications,
54 deleteService,
55 } = this.props;
56
57 return (
58 <div>
59 <TabBarSortableList
60 services={services}
61 setActive={setActive}
62 onSortEnd={this.onSortEnd}
63 onSortStart={disableToolTip}
64 reload={reload}
65 toggleNotifications={toggleNotifications}
66 deleteService={deleteService}
67 disableService={this.disableService}
68 openSettings={openSettings}
69 distance={20}
70 axis="y"
71 lockAxis="y"
72 helperClass="is-reordering"
73 />
74 </div>
75 );
76 }
77}
diff --git a/src/components/settings/SettingsLayout.js b/src/components/settings/SettingsLayout.js
new file mode 100644
index 000000000..d5392ddba
--- /dev/null
+++ b/src/components/settings/SettingsLayout.js
@@ -0,0 +1,56 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4
5import { oneOrManyChildElements } from '../../prop-types';
6import Appear from '../ui/effects/Appear';
7
8@observer
9export default class SettingsLayout extends Component {
10 static propTypes = {
11 navigation: PropTypes.element.isRequired,
12 children: oneOrManyChildElements.isRequired,
13 closeSettings: PropTypes.func.isRequired,
14 };
15
16 componentWillMount() {
17 document.addEventListener('keydown', this.handleKeyDown.bind(this), false);
18 }
19
20 componentWillUnmount() {
21 document.removeEventListener('keydown', this.handleKeyDown.bind(this), false);
22 }
23
24 handleKeyDown(e) {
25 if (e.keyCode === 27) { // escape key
26 this.props.closeSettings();
27 }
28 }
29
30 render() {
31 const {
32 navigation,
33 children,
34 closeSettings,
35 } = this.props;
36
37 return (
38 <Appear transitionName="fadeIn-fast">
39 <div className="settings-wrapper">
40 <button
41 className="settings-wrapper__action"
42 onClick={closeSettings}
43 />
44 <div className="settings franz-form">
45 {navigation}
46 {children}
47 <button
48 className="settings__close mdi mdi-close"
49 onClick={closeSettings}
50 />
51 </div>
52 </div>
53 </Appear>
54 );
55 }
56}
diff --git a/src/components/settings/account/AccountDashboard.js b/src/components/settings/account/AccountDashboard.js
new file mode 100644
index 000000000..75dbdef49
--- /dev/null
+++ b/src/components/settings/account/AccountDashboard.js
@@ -0,0 +1,286 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape, FormattedMessage } from 'react-intl';
5import ReactTooltip from 'react-tooltip';
6import moment from 'moment';
7
8import Loader from '../../ui/Loader';
9import Button from '../../ui/Button';
10import Infobox from '../../ui/Infobox';
11import Link from '../../ui/Link';
12import SubscriptionForm from '../../../containers/ui/SubscriptionFormScreen';
13
14const messages = defineMessages({
15 headline: {
16 id: 'settings.account.headline',
17 defaultMessage: '!!!Account',
18 },
19 headlineSubscription: {
20 id: 'settings.account.headlineSubscription',
21 defaultMessage: '!!!Your Subscription',
22 },
23 headlineUpgrade: {
24 id: 'settings.account.headlineUpgrade',
25 defaultMessage: '!!!Upgrade your Account',
26 },
27 headlineInvoices: {
28 id: 'settings.account.headlineInvoices',
29 defaultMessage: '!!Invoices',
30 },
31 manageSubscriptionButtonLabel: {
32 id: 'settings.account.manageSubscription.label',
33 defaultMessage: '!!!Manage your subscription',
34 },
35 accountTypeBasic: {
36 id: 'settings.account.accountType.basic',
37 defaultMessage: '!!!Basic Account',
38 },
39 accountTypePremium: {
40 id: 'settings.account.accountType.premium',
41 defaultMessage: '!!!Premium Supporter Account',
42 },
43 accountEditButton: {
44 id: 'settings.account.account.editButton',
45 defaultMessage: '!!!Edit Account',
46 },
47 invoiceDownload: {
48 id: 'settings.account.invoiceDownload',
49 defaultMessage: '!!!Download',
50 },
51 userInfoRequestFailed: {
52 id: 'settings.account.userInfoRequestFailed',
53 defaultMessage: '!!!Could not load user information',
54 },
55 tryReloadUserInfoRequest: {
56 id: 'settings.account.tryReloadUserInfoRequest',
57 defaultMessage: '!!!Try again',
58 },
59 miningActive: {
60 id: 'settings.account.mining.active',
61 defaultMessage: '!!!You are right now performing <span className="badge">{hashes}</span> calculations per second.',
62 },
63 miningThankYou: {
64 id: 'settings.account.mining.thankyou',
65 defaultMessage: '!!!Thank you for supporting Franz with your processing power.',
66 },
67 miningMoreInfo: {
68 id: 'settings.account.mining.moreInformation',
69 defaultMessage: '!!!Get more information',
70 },
71 cancelMining: {
72 id: 'settings.account.mining.cancel',
73 defaultMessage: '!!!Cancel mining',
74 },
75});
76
77@observer
78export default class AccountDashboard extends Component {
79 static propTypes = {
80 user: MobxPropTypes.observableObject.isRequired,
81 orders: MobxPropTypes.arrayOrObservableArray.isRequired,
82 hashrate: PropTypes.number.isRequired,
83 isLoading: PropTypes.bool.isRequired,
84 isLoadingOrdersInfo: PropTypes.bool.isRequired,
85 isLoadingPlans: PropTypes.bool.isRequired,
86 isCreatingPaymentDashboardUrl: PropTypes.bool.isRequired,
87 userInfoRequestFailed: PropTypes.bool.isRequired,
88 retryUserInfoRequest: PropTypes.func.isRequired,
89 openDashboard: PropTypes.func.isRequired,
90 openExternalUrl: PropTypes.func.isRequired,
91 onCloseSubscriptionWindow: PropTypes.func.isRequired,
92 stopMiner: PropTypes.func.isRequired,
93 };
94
95 static contextTypes = {
96 intl: intlShape,
97 };
98
99 render() {
100 const {
101 user,
102 orders,
103 hashrate,
104 isLoading,
105 isCreatingPaymentDashboardUrl,
106 openDashboard,
107 openExternalUrl,
108 isLoadingOrdersInfo,
109 isLoadingPlans,
110 userInfoRequestFailed,
111 retryUserInfoRequest,
112 onCloseSubscriptionWindow,
113 stopMiner,
114 } = this.props;
115 const { intl } = this.context;
116
117 return (
118 <div className="settings__main">
119 <div className="settings__header">
120 <span className="settings__header-item">
121 {intl.formatMessage(messages.headline)}
122 </span>
123 </div>
124 <div className="settings__body">
125 {isLoading && (
126 <Loader />
127 )}
128
129 {!isLoading && userInfoRequestFailed && (
130 <div>
131 <Infobox
132 icon="alert"
133 type="danger"
134 ctaLabel={intl.formatMessage(messages.tryReloadUserInfoRequest)}
135 ctaLoading={isLoading}
136 ctaOnClick={retryUserInfoRequest}
137 >
138 {intl.formatMessage(messages.userInfoRequestFailed)}
139 </Infobox>
140 </div>
141 )}
142
143 {!userInfoRequestFailed && (
144 <div>
145 {!isLoading && (
146 <div className="account">
147 <div className="account__box account__box--flex">
148 <div className="account__avatar">
149 <img
150 src="./assets/images/logo.svg"
151 alt=""
152 />
153 {user.isPremium && (
154 <span
155 className="account__avatar-premium emoji"
156 data-tip="Premium Supporter Account"
157 >
158 <img src="./assets/images/emoji/star.png" alt="" />
159 </span>
160 )}
161 </div>
162 <div className="account__info">
163 <h2>
164 {`${user.firstname} ${user.lastname}`}
165 </h2>
166 {user.organization && `${user.organization}, `}
167 {user.email}<br />
168 {!user.isPremium && (
169 <span className="badge badge">{intl.formatMessage(messages.accountTypeBasic)}</span>
170 )}
171 {user.isPremium && (
172 <span className="badge badge--premium">{intl.formatMessage(messages.accountTypePremium)}</span>
173 )}
174 </div>
175 <Link to="/settings/user/edit" className="button">
176 {intl.formatMessage(messages.accountEditButton)}
177 </Link>
178
179 {user.emailValidated}
180 </div>
181 </div>
182 )}
183
184 {user.isSubscriptionOwner && (
185 isLoadingOrdersInfo ? (
186 <Loader />
187 ) : (
188 <div className="account franz-form">
189 {orders.length > 0 && (
190 <div>
191 <div className="account__box">
192 <h2>{intl.formatMessage(messages.headlineSubscription)}</h2>
193 <div className="account__subscription">
194 {orders[0].name}
195 <span className="badge">{orders[0].price}</span>
196 <Button
197 label={intl.formatMessage(messages.manageSubscriptionButtonLabel)}
198 className="account__subscription-button franz-form__button--inverted"
199 loaded={!isCreatingPaymentDashboardUrl}
200 onClick={() => openDashboard()}
201 />
202 </div>
203 </div>
204 <div className="account__box account__box--last">
205 <h2>{intl.formatMessage(messages.headlineInvoices)}</h2>
206 <table className="invoices">
207 <tbody>
208 {orders.map(order => (
209 <tr key={order.id}>
210 <td className="invoices__date">
211 {moment(order.date).format('DD.MM.YYYY')}
212 </td>
213 <td className="invoices__action">
214 <button
215 onClick={() => openExternalUrl(order.invoiceUrl)}
216 >
217 {intl.formatMessage(messages.invoiceDownload)}
218 </button>
219 </td>
220 </tr>
221 ))}
222 </tbody>
223 </table>
224 </div>
225 </div>
226 )}
227 </div>
228 )
229 )}
230
231 {user.isMiner && (
232 <div className="account franz-form">
233 <div className="account__box">
234 <h2>{intl.formatMessage(messages.headlineSubscription)}</h2>
235 <div className="account__subscription">
236 <div>
237 <p>{intl.formatMessage(messages.miningThankYou)}</p>
238 <FormattedMessage
239 {...messages.miningActive}
240 values={{
241 hashes: <span className="badge">{hashrate.toFixed(2)}</span>,
242 }}
243 tagName="p"
244 />
245 <p>
246 <Link
247 to="http://meetfranz.com/mining"
248 target="_blank"
249 className="link"
250 >
251 {intl.formatMessage(messages.miningMoreInfo)}
252 </Link>
253 </p>
254 </div>
255 <Button
256 label={intl.formatMessage(messages.cancelMining)}
257 className="account__subscription-button franz-form__button--inverted"
258 onClick={() => stopMiner()}
259 />
260 </div>
261 </div>
262 </div>
263 )}
264
265 {!user.isPremium && !user.isMiner && (
266 isLoadingPlans ? (
267 <Loader />
268 ) : (
269 <div className="account franz-form">
270 <div className="account__box account__box--last">
271 <h2>{intl.formatMessage(messages.headlineUpgrade)}</h2>
272 <SubscriptionForm
273 onCloseWindow={onCloseSubscriptionWindow}
274 />
275 </div>
276 </div>
277 )
278 )}
279 </div>
280 )}
281 </div>
282 <ReactTooltip place="right" type="dark" effect="solid" />
283 </div>
284 );
285 }
286}
diff --git a/src/components/settings/navigation/SettingsNavigation.js b/src/components/settings/navigation/SettingsNavigation.js
new file mode 100644
index 000000000..3b21a7765
--- /dev/null
+++ b/src/components/settings/navigation/SettingsNavigation.js
@@ -0,0 +1,84 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { defineMessages, intlShape } from 'react-intl';
4
5import Link from '../../ui/Link';
6
7const messages = defineMessages({
8 availableServices: {
9 id: 'settings.navigation.availableServices',
10 defaultMessage: '!!!Available services',
11 },
12 yourServices: {
13 id: 'settings.navigation.yourServices',
14 defaultMessage: '!!!Your services',
15 },
16 account: {
17 id: 'settings.navigation.account',
18 defaultMessage: '!!!Account',
19 },
20 settings: {
21 id: 'settings.navigation.settings',
22 defaultMessage: '!!!Settings',
23 },
24 logout: {
25 id: 'settings.navigation.logout',
26 defaultMessage: '!!!Logout',
27 },
28});
29
30export default class SettingsNavigation extends Component {
31 static propTypes = {
32 serviceCount: PropTypes.number.isRequired,
33 };
34
35 static contextTypes = {
36 intl: intlShape,
37 };
38
39 render() {
40 const { serviceCount } = this.props;
41 const { intl } = this.context;
42
43 return (
44 <div className="settings-navigation">
45 <Link
46 to="/settings/recipes"
47 className="settings-navigation__link"
48 activeClassName="is-active"
49 >
50 {intl.formatMessage(messages.availableServices)}
51 </Link>
52 <Link
53 to="/settings/services"
54 className="settings-navigation__link"
55 activeClassName="is-active"
56 >
57 {intl.formatMessage(messages.yourServices)} <span className="badge">{serviceCount}</span>
58 </Link>
59 <Link
60 to="/settings/user"
61 className="settings-navigation__link"
62 activeClassName="is-active"
63 >
64 {intl.formatMessage(messages.account)}
65 </Link>
66 <Link
67 to="/settings/app"
68 className="settings-navigation__link"
69 activeClassName="is-active"
70 >
71 {intl.formatMessage(messages.settings)}
72 </Link>
73 <span className="settings-navigation__expander" />
74 <Link
75 to="/auth/logout"
76 className="settings-navigation__link"
77 activeClassName="is-active"
78 >
79 {intl.formatMessage(messages.logout)}
80 </Link>
81 </div>
82 );
83 }
84}
diff --git a/src/components/settings/recipes/RecipeItem.js b/src/components/settings/recipes/RecipeItem.js
new file mode 100644
index 000000000..7b2f64d26
--- /dev/null
+++ b/src/components/settings/recipes/RecipeItem.js
@@ -0,0 +1,34 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4
5import RecipePreviewModel from '../../../models/RecipePreview';
6
7@observer
8export default class RecipeItem extends Component {
9 static propTypes = {
10 recipe: PropTypes.instanceOf(RecipePreviewModel).isRequired,
11 onClick: PropTypes.func.isRequired,
12 };
13
14 render() {
15 const { recipe, onClick } = this.props;
16
17 return (
18 <button
19 className="recipe-teaser"
20 onClick={onClick}
21 >
22 {recipe.local && (
23 <span className="recipe-teaser__dev-badge">dev</span>
24 )}
25 <img
26 src={recipe.icons.svg}
27 className="recipe-teaser__icon"
28 alt=""
29 />
30 <span className="recipe-teaser__label">{recipe.name}</span>
31 </button>
32 );
33 }
34}
diff --git a/src/components/settings/recipes/RecipesDashboard.js b/src/components/settings/recipes/RecipesDashboard.js
new file mode 100644
index 000000000..02ea04e35
--- /dev/null
+++ b/src/components/settings/recipes/RecipesDashboard.js
@@ -0,0 +1,151 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6
7import SearchInput from '../../ui/SearchInput';
8import Infobox from '../../ui/Infobox';
9import RecipeItem from './RecipeItem';
10import Loader from '../../ui/Loader';
11import Appear from '../../ui/effects/Appear';
12
13const messages = defineMessages({
14 headline: {
15 id: 'settings.recipes.headline',
16 defaultMessage: '!!!Available Services',
17 },
18 mostPopularRecipes: {
19 id: 'settings.recipes.mostPopular',
20 defaultMessage: '!!!Most popular',
21 },
22 allRecipes: {
23 id: 'settings.recipes.all',
24 defaultMessage: '!!!All services',
25 },
26 devRecipes: {
27 id: 'settings.recipes.dev',
28 defaultMessage: '!!!Development',
29 },
30 nothingFound: {
31 id: 'settings.recipes.nothingFound',
32 defaultMessage: '!!!Sorry, but no service matched your search term.',
33 },
34 servicesSuccessfulAddedInfo: {
35 id: 'settings.recipes.servicesSuccessfulAddedInfo',
36 defaultMessage: '!!!Service successfully added',
37 },
38});
39
40@observer
41export default class RecipesDashboard extends Component {
42 static propTypes = {
43 recipes: MobxPropTypes.arrayOrObservableArray.isRequired,
44 isLoading: PropTypes.bool.isRequired,
45 hasLoadedRecipes: PropTypes.bool.isRequired,
46 showAddServiceInterface: PropTypes.func.isRequired,
47 searchRecipes: PropTypes.func.isRequired,
48 resetSearch: PropTypes.func.isRequired,
49 serviceStatus: MobxPropTypes.arrayOrObservableArray.isRequired,
50 devRecipesCount: PropTypes.number.isRequired,
51 searchNeedle: PropTypes.string,
52 };
53
54 static defaultProps = {
55 searchNeedle: '',
56 }
57
58 static contextTypes = {
59 intl: intlShape,
60 };
61
62 render() {
63 const {
64 recipes,
65 isLoading,
66 hasLoadedRecipes,
67 showAddServiceInterface,
68 searchRecipes,
69 resetSearch,
70 serviceStatus,
71 devRecipesCount,
72 searchNeedle,
73 } = this.props;
74 const { intl } = this.context;
75
76 return (
77 <div className="settings__main">
78 <div className="settings__header">
79 <SearchInput
80 className="settings__search-header"
81 defaultValue={intl.formatMessage(messages.headline)}
82 onChange={e => searchRecipes(e)}
83 onReset={() => resetSearch()}
84 throttle
85 />
86 </div>
87 <div className="settings__body recipes">
88 {serviceStatus.length > 0 && serviceStatus.includes('created') && (
89 <Appear>
90 <Infobox
91 type="success"
92 icon="checkbox-marked-circle-outline"
93 dismissable
94 >
95 {intl.formatMessage(messages.servicesSuccessfulAddedInfo)}
96 </Infobox>
97 </Appear>
98 )}
99 {!searchNeedle && (
100 <div className="recipes__navigation">
101 <Link
102 to="/settings/recipes"
103 className="badge"
104 activeClassName="badge--primary"
105 >
106 {intl.formatMessage(messages.mostPopularRecipes)}
107 </Link>
108 <Link
109 to="/settings/recipes/all"
110 className="badge"
111 activeClassName="badge--primary"
112 >
113 {intl.formatMessage(messages.allRecipes)}
114 </Link>
115 {devRecipesCount > 0 && (
116 <Link
117 to="/settings/recipes/dev"
118 className="badge"
119 activeClassName="badge--primary"
120 >
121 {intl.formatMessage(messages.devRecipes)} ({devRecipesCount})
122 </Link>
123 )}
124 </div>
125 )}
126 {isLoading ? (
127 <Loader />
128 ) : (
129 <div className="recipes__list">
130 {hasLoadedRecipes && recipes.length === 0 && (
131 <p className="align-middle settings__empty-state">
132 <span className="emoji">
133 <img src="./assets/images/emoji/dontknow.png" alt="" />
134 </span>
135 {intl.formatMessage(messages.nothingFound)}
136 </p>
137 )}
138 {recipes.map(recipe => (
139 <RecipeItem
140 key={recipe.id}
141 recipe={recipe}
142 onClick={() => showAddServiceInterface({ recipeId: recipe.id })}
143 />
144 ))}
145 </div>
146 )}
147 </div>
148 </div>
149 );
150 }
151}
diff --git a/src/components/settings/services/EditServiceForm.js b/src/components/settings/services/EditServiceForm.js
new file mode 100644
index 000000000..fac0f6b9a
--- /dev/null
+++ b/src/components/settings/services/EditServiceForm.js
@@ -0,0 +1,277 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Link } from 'react-router';
5import { defineMessages, intlShape } from 'react-intl';
6import normalizeUrl from 'normalize-url';
7
8import Form from '../../../lib/Form';
9import User from '../../../models/User';
10import Recipe from '../../../models/Recipe';
11import Service from '../../../models/Service';
12import Tabs, { TabItem } from '../../ui/Tabs';
13import Input from '../../ui/Input';
14import Toggle from '../../ui/Toggle';
15import Button from '../../ui/Button';
16
17const messages = defineMessages({
18 saveService: {
19 id: 'settings.service.form.saveButton',
20 defaultMessage: '!!!Save service',
21 },
22 deleteService: {
23 id: 'settings.service.form.deleteButton',
24 defaultMessage: '!!!Delete Service',
25 },
26 availableServices: {
27 id: 'settings.service.form.availableServices',
28 defaultMessage: '!!!Available services',
29 },
30 yourServices: {
31 id: 'settings.service.form.yourServices',
32 defaultMessage: '!!!Your services',
33 },
34 addServiceHeadline: {
35 id: 'settings.service.form.addServiceHeadline',
36 defaultMessage: '!!!Add {name}',
37 },
38 editServiceHeadline: {
39 id: 'settings.service.form.editServiceHeadline',
40 defaultMessage: '!!!Edit {name}',
41 },
42 tabHosted: {
43 id: 'settings.service.form.tabHosted',
44 defaultMessage: '!!!Hosted',
45 },
46 tabOnPremise: {
47 id: 'settings.service.form.tabOnPremise',
48 defaultMessage: '!!!Self hosted ⭐️',
49 },
50 customUrlValidationError: {
51 id: 'settings.service.form.customUrlValidationError',
52 defaultMessage: '!!!Could not validate custom {name} server.',
53 },
54 customUrlPremiumInfo: {
55 id: 'settings.service.form.customUrlPremiumInfo',
56 defaultMessage: '!!!To add self hosted services, you need a Franz Premium Supporter Account.',
57 },
58 customUrlUpgradeAccount: {
59 id: 'settings.service.form.customUrlUpgradeAccount',
60 defaultMessage: '!!!Upgrade your account',
61 },
62 indirectMessageInfo: {
63 id: 'settings.service.form.indirectMessageInfo',
64 defaultMessage: '!!!You will be notified about all new messages in a channel, not just @username, @channel, @here, ...', // eslint-disable-line
65 },
66});
67
68@observer
69export default class EditServiceForm extends Component {
70 static propTypes = {
71 recipe: PropTypes.instanceOf(Recipe).isRequired,
72 // service: PropTypes.oneOfType([
73 // PropTypes.object,
74 // PropTypes.instanceOf(Service),
75 // ]),
76 service(props, propName) {
77 if (props.action === 'edit' && !(props[propName] instanceof Service)) {
78 return new Error(`'${propName}'' is expected to be of type 'Service'
79 when editing a Service`);
80 }
81
82 return null;
83 },
84 user: PropTypes.instanceOf(User).isRequired,
85 action: PropTypes.string.isRequired,
86 form: PropTypes.instanceOf(Form).isRequired,
87 onSubmit: PropTypes.func.isRequired,
88 onDelete: PropTypes.func.isRequired,
89 isSaving: PropTypes.bool.isRequired,
90 isDeleting: PropTypes.bool.isRequired,
91 };
92
93 static defaultProps = {
94 service: {},
95 };
96 static contextTypes = {
97 intl: intlShape,
98 };
99
100 state = {
101 isValidatingCustomUrl: false,
102 }
103
104 submit(e) {
105 const { recipe } = this.props;
106
107 e.preventDefault();
108 this.props.form.submit({
109 onSuccess: async (form) => {
110 const values = form.values();
111
112 let isValid = true;
113
114 if (recipe.validateUrl && values.customUrl) {
115 this.setState({ isValidatingCustomUrl: true });
116 try {
117 values.customUrl = normalizeUrl(values.customUrl);
118 isValid = await recipe.validateUrl(values.customUrl);
119 } catch (err) {
120 console.warn('ValidateURL', err);
121 isValid = false;
122 }
123 }
124
125 if (isValid) {
126 this.props.onSubmit(values);
127 } else {
128 form.invalidate('url-validation-error');
129 }
130
131 this.setState({ isValidatingCustomUrl: false });
132 },
133 onError: () => {},
134 });
135 }
136
137 render() {
138 const {
139 recipe,
140 service,
141 action,
142 user,
143 form,
144 isSaving,
145 isDeleting,
146 onDelete,
147 } = this.props;
148 const { intl } = this.context;
149
150 const { isValidatingCustomUrl } = this.state;
151
152 const deleteButton = isDeleting ? (
153 <Button
154 label={intl.formatMessage(messages.deleteService)}
155 loaded={false}
156 buttonType="secondary"
157 className="settings__delete-button"
158 disabled
159 />
160 ) : (
161 <Button
162 buttonType="danger"
163 label={intl.formatMessage(messages.deleteService)}
164 className="settings__delete-button"
165 onClick={onDelete}
166 />
167 );
168
169 return (
170 <div className="settings__main">
171 <div className="settings__header">
172 <span className="settings__header-item">
173 {action === 'add' ? (
174 <Link to="/settings/recipes">
175 {intl.formatMessage(messages.availableServices)}
176 </Link>
177 ) : (
178 <Link to="/settings/services">
179 {intl.formatMessage(messages.yourServices)}
180 </Link>
181 )}
182 </span>
183 <span className="separator" />
184 <span className="settings__header-item">
185 {action === 'add' ? (
186 intl.formatMessage(messages.addServiceHeadline, {
187 name: recipe.name,
188 })
189 ) : (
190 intl.formatMessage(messages.editServiceHeadline, {
191 name: service.name !== '' ? service.name : recipe.name,
192 })
193 )}
194 </span>
195 </div>
196 <div className="settings__body">
197 <form onSubmit={e => this.submit(e)} id="form">
198 <Input field={form.$('name')} focus />
199 {(recipe.hasTeamId || recipe.hasCustomUrl) && (
200 <Tabs
201 active={service.customUrl ? 1 : 0}
202 >
203 {recipe.hasTeamId && (
204 <TabItem title={intl.formatMessage(messages.tabHosted)}>
205 <Input field={form.$('team')} suffix={recipe.urlInputSuffix} />
206 </TabItem>
207 )}
208 {recipe.hasCustomUrl && (
209 <TabItem title={intl.formatMessage(messages.tabOnPremise)}>
210 {user.isPremium ? (
211 <div>
212 <Input field={form.$('customUrl')} />
213 {form.error === 'url-validation-error' && (
214 <p className="franz-form__error">
215 {intl.formatMessage(messages.customUrlValidationError, { name: recipe.name })}
216 </p>
217 )}
218 </div>
219 ) : (
220 <div className="center premium-info">
221 <p>{intl.formatMessage(messages.customUrlPremiumInfo)}</p>
222 <p>
223 <Link to="/settings/user" className="button">
224 {intl.formatMessage(messages.customUrlUpgradeAccount)}
225 </Link>
226 </p>
227 </div>
228 )}
229 </TabItem>
230 )}
231 </Tabs>
232 )}
233 <div className="settings__options">
234 <Toggle field={form.$('isNotificationEnabled')} />
235 {recipe.hasIndirectMessages && (
236 <div>
237 <Toggle field={form.$('isIndirectMessageBadgeEnabled')} />
238 <p className="settings__indirect-message-help">
239 {intl.formatMessage(messages.indirectMessageInfo)}
240 </p>
241 </div>
242 )}
243 <Toggle field={form.$('isEnabled')} />
244 </div>
245 {recipe.message && (
246 <p className="settings__message">
247 <span className="mdi mdi-information" />
248 {recipe.message}
249 </p>
250 )}
251 </form>
252 </div>
253 <div className="settings__controls">
254 {/* Delete Button */}
255 {action === 'edit' && deleteButton}
256
257 {/* Save Button */}
258 {isSaving || isValidatingCustomUrl ? (
259 <Button
260 type="submit"
261 label={intl.formatMessage(messages.saveService)}
262 loaded={false}
263 buttonType="secondary"
264 disabled
265 />
266 ) : (
267 <Button
268 type="submit"
269 label={intl.formatMessage(messages.saveService)}
270 htmlForm="form"
271 />
272 )}
273 </div>
274 </div>
275 );
276 }
277}
diff --git a/src/components/settings/services/ServiceError.js b/src/components/settings/services/ServiceError.js
new file mode 100644
index 000000000..923053296
--- /dev/null
+++ b/src/components/settings/services/ServiceError.js
@@ -0,0 +1,68 @@
1import React, { Component } from 'react';
2import { observer } from 'mobx-react';
3import { Link } from 'react-router';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Infobox from '../../ui/Infobox';
7import Button from '../../ui/Button';
8
9const messages = defineMessages({
10 headline: {
11 id: 'settings.service.error.headline',
12 defaultMessage: '!!!Error',
13 },
14 goBack: {
15 id: 'settings.service.error.goBack',
16 defaultMessage: '!!!Back to services',
17 },
18 availableServices: {
19 id: 'settings.service.form.availableServices',
20 defaultMessage: '!!!Available services',
21 },
22 errorMessage: {
23 id: 'settings.service.error.message',
24 defaultMessage: '!!!Could not load service recipe.',
25 },
26});
27
28@observer
29export default class EditServiceForm extends Component {
30 static contextTypes = {
31 intl: intlShape,
32 };
33
34 render() {
35 const { intl } = this.context;
36
37 return (
38 <div className="settings__main">
39 <div className="settings__header">
40 <span className="settings__header-item">
41 <Link to="/settings/recipes">
42 {intl.formatMessage(messages.availableServices)}
43 </Link>
44 </span>
45 <span className="separator" />
46 <span className="settings__header-item">
47 {intl.formatMessage(messages.headline)}
48 </span>
49 </div>
50 <div className="settings__body">
51 <Infobox
52 type="danger"
53 icon="alert"
54 >
55 {intl.formatMessage(messages.errorMessage)}
56 </Infobox>
57 </div>
58 <div className="settings__controls">
59 <Button
60 label={intl.formatMessage(messages.goBack)}
61 htmlForm="form"
62 onClick={() => window.history.back()}
63 />
64 </div>
65 </div>
66 );
67 }
68}
diff --git a/src/components/settings/services/ServiceItem.js b/src/components/settings/services/ServiceItem.js
new file mode 100644
index 000000000..20d8581d0
--- /dev/null
+++ b/src/components/settings/services/ServiceItem.js
@@ -0,0 +1,98 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { defineMessages, intlShape } from 'react-intl';
4import ReactTooltip from 'react-tooltip';
5import { observer } from 'mobx-react';
6import classnames from 'classnames';
7
8import ServiceModel from '../../../models/Service';
9
10const messages = defineMessages({
11 tooltipIsDisabled: {
12 id: 'settings.services.tooltip.isDisabled',
13 defaultMessage: '!!!Service is disabled',
14 },
15 tooltipNotificationsDisabled: {
16 id: 'settings.services.tooltip.notificationsDisabled',
17 defaultMessage: '!!!Notifications are disabled',
18 },
19});
20
21@observer
22export default class ServiceItem extends Component {
23 static propTypes = {
24 service: PropTypes.instanceOf(ServiceModel).isRequired,
25 goToServiceForm: PropTypes.func.isRequired,
26 };
27 static contextTypes = {
28 intl: intlShape,
29 };
30
31 render() {
32 const {
33 service,
34 // toggleAction,
35 goToServiceForm,
36 } = this.props;
37 const { intl } = this.context;
38
39 return (
40 <tr
41 className={classnames({
42 'service-table__row': true,
43 'service-table__row--disabled': !service.isEnabled,
44 })}
45 >
46 <td
47 className="service-table__column-icon"
48 onClick={goToServiceForm}
49 >
50 <img
51 src={service.icon}
52 className={classnames({
53 'service-table__icon': true,
54 'has-custom-icon': service.hasCustomIcon,
55 })}
56 alt=""
57 />
58 </td>
59 <td
60 className="service-table__column-name"
61 onClick={goToServiceForm}
62 >
63 {service.name !== '' ? service.name : service.recipe.name}
64 </td>
65 <td
66 className="service-table__column-info"
67 onClick={goToServiceForm}
68 >
69 {!service.isEnabled && (
70 <span
71 className="mdi mdi-power"
72 data-tip={intl.formatMessage(messages.tooltipIsDisabled)}
73 />
74 )}
75 </td>
76 <td
77 className="service-table__column-info"
78 onClick={goToServiceForm}
79 >
80 {!service.isNotificationEnabled && (
81 <span
82 className="mdi mdi-message-bulleted-off"
83 data-tip={intl.formatMessage(messages.tooltipNotificationsDisabled)}
84 />
85 )}
86 <ReactTooltip place="top" type="dark" effect="solid" />
87 </td>
88 {/* <td className="service-table__column-action">
89 <input
90 type="checkbox"
91 onChange={toggleAction}
92 checked={service.isEnabled}
93 />
94 </td> */}
95 </tr>
96 );
97 }
98}
diff --git a/src/components/settings/services/ServicesDashboard.js b/src/components/settings/services/ServicesDashboard.js
new file mode 100644
index 000000000..5f146b5f3
--- /dev/null
+++ b/src/components/settings/services/ServicesDashboard.js
@@ -0,0 +1,155 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { Link } from 'react-router';
5import { defineMessages, intlShape } from 'react-intl';
6
7import SearchInput from '../../ui/SearchInput';
8import Infobox from '../../ui/Infobox';
9import Loader from '../../ui/Loader';
10import ServiceItem from './ServiceItem';
11import Appear from '../../ui/effects/Appear';
12
13const messages = defineMessages({
14 headline: {
15 id: 'settings.services.headline',
16 defaultMessage: '!!!Your services',
17 },
18 noServicesAdded: {
19 id: 'settings.services.noServicesAdded',
20 defaultMessage: '!!!You haven\'t added any services yet.',
21 },
22 discoverServices: {
23 id: 'settings.services.discoverServices',
24 defaultMessage: '!!!Discover services',
25 },
26 servicesRequestFailed: {
27 id: 'settings.services.servicesRequestFailed',
28 defaultMessage: '!!!Could not load your services',
29 },
30 tryReloadServices: {
31 id: 'settings.account.tryReloadServices',
32 defaultMessage: '!!!Try again',
33 },
34 updatedInfo: {
35 id: 'settings.services.updatedInfo',
36 defaultMessage: '!!!Your changes have been saved',
37 },
38 deletedInfo: {
39 id: 'settings.services.deletedInfo',
40 defaultMessage: '!!!Service has been deleted',
41 },
42});
43
44@observer
45export default class ServicesDashboard extends Component {
46 static propTypes = {
47 services: MobxPropTypes.arrayOrObservableArray.isRequired,
48 isLoading: PropTypes.bool.isRequired,
49 toggleService: PropTypes.func.isRequired,
50 filterServices: PropTypes.func.isRequired,
51 resetFilter: PropTypes.func.isRequired,
52 goTo: PropTypes.func.isRequired,
53 servicesRequestFailed: PropTypes.bool.isRequired,
54 retryServicesRequest: PropTypes.func.isRequired,
55 status: MobxPropTypes.arrayOrObservableArray.isRequired,
56 };
57 static contextTypes = {
58 intl: intlShape,
59 };
60
61 render() {
62 const {
63 services,
64 isLoading,
65 toggleService,
66 filterServices,
67 resetFilter,
68 goTo,
69 servicesRequestFailed,
70 retryServicesRequest,
71 status,
72 } = this.props;
73 const { intl } = this.context;
74
75 return (
76 <div className="settings__main">
77 <div className="settings__header">
78 <SearchInput
79 className="settings__search-header"
80 defaultValue={intl.formatMessage(messages.headline)}
81 onChange={needle => filterServices({ needle })}
82 onReset={() => resetFilter()}
83 />
84 </div>
85 <div className="settings__body">
86 {!isLoading && servicesRequestFailed && (
87 <div>
88 <Infobox
89 icon="alert"
90 type="danger"
91 ctaLabel={intl.formatMessage(messages.tryReloadServices)}
92 ctaLoading={isLoading}
93 ctaOnClick={retryServicesRequest}
94 >
95 {intl.formatMessage(messages.servicesRequestFailed)}
96 </Infobox>
97 </div>
98 )}
99
100 {status.length > 0 && status.includes('updated') && (
101 <Appear>
102 <Infobox
103 type="success"
104 icon="checkbox-marked-circle-outline"
105 dismissable
106 >
107 {intl.formatMessage(messages.updatedInfo)}
108 </Infobox>
109 </Appear>
110 )}
111
112 {status.length > 0 && status.includes('service-deleted') && (
113 <Appear>
114 <Infobox
115 type="success"
116 icon="checkbox-marked-circle-outline"
117 dismissable
118 >
119 {intl.formatMessage(messages.deletedInfo)}
120 </Infobox>
121 </Appear>
122 )}
123
124 {!isLoading && services.length === 0 && (
125 <div className="align-middle settings__empty-state">
126 <p className="settings__empty-text">
127 <span className="emoji">
128 <img src="./assets/images/emoji/sad.png" alt="" />
129 </span>
130 {intl.formatMessage(messages.noServicesAdded)}
131 </p>
132 <Link to="/settings/recipes" className="button">{intl.formatMessage(messages.discoverServices)}</Link>
133 </div>
134 )}
135 {isLoading ? (
136 <Loader />
137 ) : (
138 <table className="service-table">
139 <tbody>
140 {services.map(service => (
141 <ServiceItem
142 key={service.id}
143 service={service}
144 toggleAction={() => toggleService({ serviceId: service.id })}
145 goToServiceForm={() => goTo(`/settings/services/edit/${service.id}`)}
146 />
147 ))}
148 </tbody>
149 </table>
150 )}
151 </div>
152 </div>
153 );
154 }
155}
diff --git a/src/components/settings/settings/EditSettingsForm.js b/src/components/settings/settings/EditSettingsForm.js
new file mode 100644
index 000000000..02736dbb9
--- /dev/null
+++ b/src/components/settings/settings/EditSettingsForm.js
@@ -0,0 +1,148 @@
1import { remote } from 'electron';
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import { observer } from 'mobx-react';
5import { defineMessages, intlShape } from 'react-intl';
6
7import Form from '../../../lib/Form';
8import Button from '../../ui/Button';
9import Toggle from '../../ui/Toggle';
10import Select from '../../ui/Select';
11
12const messages = defineMessages({
13 headline: {
14 id: 'settings.app.headline',
15 defaultMessage: '!!!Settings',
16 },
17 headlineGeneral: {
18 id: 'settings.app.headlineGeneral',
19 defaultMessage: '!!!General',
20 },
21 headlineLanguage: {
22 id: 'settings.app.headlineLanguage',
23 defaultMessage: '!!!Language',
24 },
25 headlineUpdates: {
26 id: 'settings.app.headlineUpdates',
27 defaultMessage: '!!!Updates',
28 },
29 buttonSearchForUpdate: {
30 id: 'settings.app.buttonSearchForUpdate',
31 defaultMessage: '!!!Check for updates',
32 },
33 buttonInstallUpdate: {
34 id: 'settings.app.buttonInstallUpdate',
35 defaultMessage: '!!!Restart & install update',
36 },
37 updateStatusSearching: {
38 id: 'settings.app.updateStatusSearching',
39 defaultMessage: '!!!Is searching for update',
40 },
41 updateStatusAvailable: {
42 id: 'settings.app.updateStatusAvailable',
43 defaultMessage: '!!!Update available, downloading...',
44 },
45 updateStatusUpToDate: {
46 id: 'settings.app.updateStatusUpToDate',
47 defaultMessage: '!!!You are using the latest version of Franz',
48 },
49 currentVersion: {
50 id: 'settings.app.currentVersion',
51 defaultMessage: '!!!Current version:',
52 },
53});
54
55@observer
56export default class EditSettingsForm extends Component {
57 static propTypes = {
58 checkForUpdates: PropTypes.func.isRequired,
59 installUpdate: PropTypes.func.isRequired,
60 form: PropTypes.instanceOf(Form).isRequired,
61 onSubmit: PropTypes.func.isRequired,
62 isCheckingForUpdates: PropTypes.bool.isRequired,
63 isUpdateAvailable: PropTypes.bool.isRequired,
64 noUpdateAvailable: PropTypes.bool.isRequired,
65 updateIsReadyToInstall: PropTypes.bool.isRequired,
66 };
67
68 static contextTypes = {
69 intl: intlShape,
70 };
71
72 submit(e) {
73 e.preventDefault();
74 this.props.form.submit({
75 onSuccess: (form) => {
76 const values = form.values();
77 this.props.onSubmit(values);
78 },
79 onError: () => {},
80 });
81 }
82
83 render() {
84 const {
85 checkForUpdates,
86 installUpdate,
87 form,
88 isCheckingForUpdates,
89 isUpdateAvailable,
90 noUpdateAvailable,
91 updateIsReadyToInstall,
92 } = this.props;
93 const { intl } = this.context;
94
95 let updateButtonLabelMessage = messages.buttonSearchForUpdate;
96 if (isCheckingForUpdates) {
97 updateButtonLabelMessage = messages.updateStatusSearching;
98 } else if (isUpdateAvailable) {
99 updateButtonLabelMessage = messages.updateStatusAvailable;
100 } else {
101 updateButtonLabelMessage = messages.buttonSearchForUpdate;
102 }
103
104 return (
105 <div className="settings__main">
106 <div className="settings__header">
107 <h1>{intl.formatMessage(messages.headline)}</h1>
108 </div>
109 <div className="settings__body">
110 <form
111 onSubmit={e => this.submit(e)}
112 onChange={e => this.submit(e)}
113 id="form"
114 >
115 <h2>{intl.formatMessage(messages.headlineGeneral)}</h2>
116 <Toggle field={form.$('autoLaunchOnStart')} />
117 <Toggle field={form.$('runInBackground')} />
118 {process.platform === 'win32' && (
119 <Toggle field={form.$('minimizeToSystemTray')} />
120 )}
121 <h2>{intl.formatMessage(messages.headlineLanguage)}</h2>
122 <Select field={form.$('locale')} showLabel={false} />
123 <h2>{intl.formatMessage(messages.headlineUpdates)}</h2>
124 {updateIsReadyToInstall ? (
125 <Button
126 label={intl.formatMessage(messages.buttonInstallUpdate)}
127 onClick={installUpdate}
128 />
129 ) : (
130 <Button
131 label={intl.formatMessage(updateButtonLabelMessage)}
132 onClick={checkForUpdates}
133 disabled={isCheckingForUpdates || isUpdateAvailable}
134 loaded={!isCheckingForUpdates || !isUpdateAvailable}
135 />
136 )}
137 {noUpdateAvailable && (
138 <p>{intl.formatMessage(messages.updateStatusUpToDate)}</p>
139 )}
140 <br />
141 <Toggle field={form.$('beta')} />
142 {intl.formatMessage(messages.currentVersion)} {remote.app.getVersion()}
143 </form>
144 </div>
145 </div>
146 );
147 }
148}
diff --git a/src/components/settings/user/EditUserForm.js b/src/components/settings/user/EditUserForm.js
new file mode 100644
index 000000000..f36887fc2
--- /dev/null
+++ b/src/components/settings/user/EditUserForm.js
@@ -0,0 +1,145 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import { Link } from 'react-router';
6
7// import { Link } from 'react-router';
8
9import Form from '../../../lib/Form';
10import Input from '../../ui/Input';
11import Button from '../../ui/Button';
12import Radio from '../../ui/Radio';
13import Infobox from '../../ui/Infobox';
14
15const messages = defineMessages({
16 headline: {
17 id: 'settings.account.headline',
18 defaultMessage: '!!!Account',
19 },
20 headlineProfile: {
21 id: 'settings.account.headlineProfile',
22 defaultMessage: '!!!Update Profile',
23 },
24 headlineAccount: {
25 id: 'settings.account.headlineAccount',
26 defaultMessage: '!!!Account Information',
27 },
28 headlinePassword: {
29 id: 'settings.account.headlinePassword',
30 defaultMessage: '!!!Change Password',
31 },
32 successInfo: {
33 id: 'settings.account.successInfo',
34 defaultMessage: '!!!Your changes have been saved',
35 },
36 buttonSave: {
37 id: 'settings.account.buttonSave',
38 defaultMessage: '!!!Update profile',
39 },
40});
41
42@observer
43export default class EditServiceForm extends Component {
44 static propTypes = {
45 status: MobxPropTypes.observableArray.isRequired,
46 form: PropTypes.instanceOf(Form).isRequired,
47 onSubmit: PropTypes.func.isRequired,
48 isSaving: PropTypes.bool.isRequired,
49 };
50
51 static defaultProps = {
52 service: {},
53 };
54
55 static contextTypes = {
56 intl: intlShape,
57 };
58
59 submit(e) {
60 e.preventDefault();
61 this.props.form.submit({
62 onSuccess: (form) => {
63 const values = form.values();
64 this.props.onSubmit(values);
65 },
66 onError: () => {},
67 });
68 }
69
70 render() {
71 const {
72 // user,
73 status,
74 form,
75 isSaving,
76 } = this.props;
77 const { intl } = this.context;
78
79 return (
80 <div className="settings__main">
81 <div className="settings__header">
82 <span className="settings__header-item">
83 <Link to="/settings/user">
84 {intl.formatMessage(messages.headline)}
85 </Link>
86 </span>
87 <span className="separator" />
88 <span className="settings__header-item">
89 {intl.formatMessage(messages.headlineProfile)}
90 </span>
91 </div>
92 <div className="settings__body">
93 <form onSubmit={e => this.submit(e)} id="form">
94 {status.length > 0 && status.includes('data-updated') && (
95 <Infobox
96 type="success"
97 icon="checkbox-marked-circle-outline"
98 >
99 {intl.formatMessage(messages.successInfo)}
100 </Infobox>
101 )}
102 <h2>{intl.formatMessage(messages.headlineAccount)}</h2>
103 <div className="grid__row">
104 <Input field={form.$('firstname')} focus />
105 <Input field={form.$('lastname')} />
106 </div>
107 <Input field={form.$('email')} />
108 <Radio field={form.$('accountType')} />
109 {form.$('accountType').value === 'company' && (
110 <Input field={form.$('organization')} />
111 )}
112 <h2>{intl.formatMessage(messages.headlinePassword)}</h2>
113 <Input
114 field={form.$('oldPassword')}
115 showPasswordToggle
116 />
117 <Input
118 field={form.$('newPassword')}
119 showPasswordToggle
120 scorePassword
121 />
122 </form>
123 </div>
124 <div className="settings__controls">
125 {/* Save Button */}
126 {isSaving ? (
127 <Button
128 type="submit"
129 label={intl.formatMessage(messages.buttonSave)}
130 loaded={!isSaving}
131 buttonType="secondary"
132 disabled
133 />
134 ) : (
135 <Button
136 type="submit"
137 label={intl.formatMessage(messages.buttonSave)}
138 htmlForm="form"
139 />
140 )}
141 </div>
142 </div>
143 );
144 }
145}
diff --git a/src/components/ui/AppLoader.js b/src/components/ui/AppLoader.js
new file mode 100644
index 000000000..64a212969
--- /dev/null
+++ b/src/components/ui/AppLoader.js
@@ -0,0 +1,15 @@
1import React from 'react';
2
3import Appear from '../../components/ui/effects/Appear';
4import Loader from '../../components/ui/Loader';
5
6export default function () {
7 return (
8 <div className="app-loader">
9 <Appear>
10 <h1 className="app-loader__title">Franz</h1>
11 <Loader />
12 </Appear>
13 </div>
14 );
15}
diff --git a/src/components/ui/Button.js b/src/components/ui/Button.js
new file mode 100644
index 000000000..07e94192f
--- /dev/null
+++ b/src/components/ui/Button.js
@@ -0,0 +1,78 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import Loader from 'react-loader';
5import classnames from 'classnames';
6
7@observer
8export default class Button extends Component {
9 static propTypes = {
10 className: PropTypes.string,
11 label: PropTypes.string.isRequired,
12 disabled: PropTypes.bool,
13 onClick: PropTypes.func,
14 type: PropTypes.string,
15 buttonType: PropTypes.string,
16 loaded: PropTypes.bool,
17 htmlForm: PropTypes.string,
18 };
19
20 static defaultProps = {
21 className: null,
22 disabled: false,
23 onClick: () => {},
24 type: 'button',
25 buttonType: '',
26 loaded: true,
27 htmlForm: '',
28 };
29
30 element = null;
31
32 render() {
33 const {
34 label,
35 className,
36 disabled,
37 onClick,
38 type,
39 buttonType,
40 loaded,
41 htmlForm,
42 } = this.props;
43
44 const buttonProps = {
45 className: classnames({
46 'franz-form__button': true,
47 [`franz-form__button--${buttonType}`]: buttonType,
48 [`${className}`]: className,
49 }),
50 type,
51 };
52
53 if (disabled) {
54 buttonProps.disabled = true;
55 }
56
57 if (onClick) {
58 buttonProps.onClick = onClick;
59 }
60
61 if (htmlForm) {
62 buttonProps.form = htmlForm;
63 }
64
65 return (
66 <button {...buttonProps}>
67 <Loader
68 loaded={loaded}
69 lines={10}
70 scale={0.4}
71 color={buttonType === '' ? '#FFF' : '#373a3c'}
72 component="span"
73 />
74 {label}
75 </button>
76 );
77 }
78}
diff --git a/src/components/ui/InfoBar.js b/src/components/ui/InfoBar.js
new file mode 100644
index 000000000..aea2bd888
--- /dev/null
+++ b/src/components/ui/InfoBar.js
@@ -0,0 +1,88 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import Loader from 'react-loader';
6
7// import { oneOrManyChildElements } from '../../prop-types';
8import Appear from '../ui/effects/Appear';
9
10@observer
11export default class InfoBar extends Component {
12 static propTypes = {
13 // eslint-disable-next-line
14 children: PropTypes.any.isRequired,
15 onClick: PropTypes.func,
16 type: PropTypes.string,
17 className: PropTypes.string,
18 ctaLabel: PropTypes.string,
19 ctaLoading: PropTypes.bool,
20 position: PropTypes.string,
21 sticky: PropTypes.bool,
22 onHide: PropTypes.func,
23 };
24
25 static defaultProps = {
26 onClick: () => null,
27 type: 'primary',
28 className: '',
29 ctaLabel: '',
30 ctaLoading: false,
31 position: 'bottom',
32 sticky: false,
33 onHide: () => null,
34 };
35
36 render() {
37 const {
38 children,
39 type,
40 className,
41 ctaLabel,
42 ctaLoading,
43 onClick,
44 position,
45 sticky,
46 onHide,
47 } = this.props;
48
49 let transitionName = 'slideUp';
50 if (position === 'top') {
51 transitionName = 'slideDown';
52 }
53
54 return (
55 <Appear
56 transitionName={transitionName}
57 className={classnames({
58 'info-bar': true,
59 [`info-bar--${type}`]: true,
60 [`info-bar--${position}`]: true,
61 [`${className}`]: true,
62 })}
63 >
64 <div onClick={onClick} className="info-bar__content">
65 {children}
66 {ctaLabel && (
67 <button className="info-bar__cta">
68 <Loader
69 loaded={!ctaLoading}
70 lines={10}
71 scale={0.3}
72 color="#FFF"
73 component="span"
74 />
75 {ctaLabel}
76 </button>
77 )}
78 </div>
79 {!sticky && (
80 <button
81 className="info-bar__close mdi mdi-close"
82 onClick={onHide}
83 />
84 )}
85 </Appear>
86 );
87 }
88}
diff --git a/src/components/ui/Infobox.js b/src/components/ui/Infobox.js
new file mode 100644
index 000000000..2d063c7ef
--- /dev/null
+++ b/src/components/ui/Infobox.js
@@ -0,0 +1,87 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import Loader from 'react-loader';
6
7@observer
8export default class Infobox extends Component {
9 static propTypes = {
10 children: PropTypes.any.isRequired, // eslint-disable-line
11 icon: PropTypes.string,
12 type: PropTypes.string,
13 ctaOnClick: PropTypes.func,
14 ctaLabel: PropTypes.string,
15 ctaLoading: PropTypes.bool,
16 dismissable: PropTypes.bool,
17 };
18
19 static defaultProps = {
20 icon: '',
21 type: 'primary',
22 dismissable: false,
23 ctaOnClick: () => null,
24 ctaLabel: '',
25 ctaLoading: false,
26 };
27
28 state = {
29 dismissed: false,
30 };
31
32 render() {
33 const {
34 children,
35 icon,
36 type,
37 ctaLabel,
38 ctaLoading,
39 ctaOnClick,
40 dismissable,
41 } = this.props;
42
43 if (this.state.dismissed) {
44 return null;
45 }
46
47 return (
48 <div
49 className={classnames({
50 infobox: true,
51 [`infobox--${type}`]: type,
52 'infobox--default': !type,
53 })}
54 >
55 {icon && (
56 <i className={`mdi mdi-${icon}`} />
57 )}
58 <div className="infobox__content">
59 {children}
60 </div>
61 {ctaLabel && (
62 <button
63 className="infobox__cta"
64 onClick={ctaOnClick}
65 >
66 <Loader
67 loaded={!ctaLoading}
68 lines={10}
69 scale={0.3}
70 color="#FFF"
71 component="span"
72 />
73 {ctaLabel}
74 </button>
75 )}
76 {dismissable && (
77 <button
78 onClick={() => this.setState({
79 dismissed: true,
80 })}
81 className="infobox__delete mdi mdi-close"
82 />
83 )}
84 </div>
85 );
86 }
87}
diff --git a/src/components/ui/Input.js b/src/components/ui/Input.js
new file mode 100644
index 000000000..0bb9f23bf
--- /dev/null
+++ b/src/components/ui/Input.js
@@ -0,0 +1,148 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Field } from 'mobx-react-form';
5import classnames from 'classnames';
6
7import { scorePassword as scorePasswordFunc } from '../../helpers/password-helpers';
8
9@observer
10export default class Input extends Component {
11 static propTypes = {
12 field: PropTypes.instanceOf(Field).isRequired,
13 className: PropTypes.string,
14 focus: PropTypes.bool,
15 showPasswordToggle: PropTypes.bool,
16 showLabel: PropTypes.bool,
17 scorePassword: PropTypes.bool,
18 prefix: PropTypes.string,
19 suffix: PropTypes.string,
20 };
21
22 static defaultProps = {
23 className: null,
24 focus: false,
25 showPasswordToggle: false,
26 showLabel: true,
27 scorePassword: false,
28 prefix: '',
29 suffix: '',
30 };
31
32 state = {
33 showPassword: false,
34 passwordScore: 0,
35 }
36
37 componentDidMount() {
38 if (this.props.focus) {
39 this.focus();
40 }
41 }
42
43 onChange(e) {
44 const { field, scorePassword } = this.props;
45
46 field.onChange(e);
47
48 if (scorePassword) {
49 this.setState({ passwordScore: scorePasswordFunc(field.value) });
50 }
51 }
52
53 focus() {
54 this.inputElement.focus();
55 }
56
57 inputElement = null;
58
59 render() {
60 const {
61 field,
62 className,
63 showPasswordToggle,
64 showLabel,
65 scorePassword,
66 prefix,
67 suffix,
68 } = this.props;
69
70 const { passwordScore } = this.state;
71
72 let type = field.type;
73 if (type === 'password' && this.state.showPassword) {
74 type = 'text';
75 }
76
77 return (
78 <div
79 className={classnames({
80 'franz-form__field': true,
81 'has-error': field.error,
82 [`${className}`]: className,
83 })}
84 >
85 <div className="franz-form__input-wrapper">
86 {prefix && (
87 <span className="franz-form__input-prefix">{prefix}</span>
88 )}
89 <input
90 id={field.id}
91 type={type}
92 className="franz-form__input"
93 name={field.name}
94 value={field.value}
95 placeholder={field.placeholder}
96 onChange={e => this.onChange(e)}
97 onBlur={field.onBlur}
98 onFocus={field.onFocus}
99 ref={(element) => { this.inputElement = element; }}
100 />
101 {suffix && (
102 <span className="franz-form__input-suffix">{suffix}</span>
103 )}
104 {showPasswordToggle && (
105 <button
106 type="button"
107 className={classnames({
108 'franz-form__input-modifier': true,
109 mdi: true,
110 'mdi-eye': !this.state.showPassword,
111 'mdi-eye-off': this.state.showPassword,
112 })}
113 onClick={() => this.setState({ showPassword: !this.state.showPassword })}
114 tabIndex="-1"
115 />
116 )}
117 {scorePassword && (
118 <div className="franz-form__password-score">
119 {/* <progress value={this.state.passwordScore} max="100" /> */}
120 <meter
121 value={passwordScore < 5 ? 5 : passwordScore}
122 low="30"
123 high="75"
124 optimum="100"
125 max="100"
126 />
127 </div>
128 )}
129 </div>
130 {field.label && showLabel && (
131 <label
132 className="franz-form__label"
133 htmlFor={field.name}
134 >
135 {field.label}
136 </label>
137 )}
138 {field.error && (
139 <div
140 className="franz-form__error"
141 >
142 {field.error}
143 </div>
144 )}
145 </div>
146 );
147 }
148}
diff --git a/src/components/ui/Link.js b/src/components/ui/Link.js
new file mode 100644
index 000000000..f5da921fa
--- /dev/null
+++ b/src/components/ui/Link.js
@@ -0,0 +1,78 @@
1import { shell } from 'electron';
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import { inject, observer } from 'mobx-react';
5import { RouterStore } from 'mobx-react-router';
6import classnames from 'classnames';
7
8import { oneOrManyChildElements } from '../../prop-types';
9import { matchRoute } from '../../helpers/routing-helpers';
10
11// TODO: create container component for this component
12
13@inject('stores') @observer
14export default class Link extends Component {
15 onClick(e) {
16 if (this.props.target === '_blank') {
17 e.preventDefault();
18 shell.openExternal(this.props.to);
19 }
20 }
21
22 render() {
23 const {
24 children,
25 stores,
26 to,
27 className,
28 activeClassName,
29 strictFilter,
30 } = this.props;
31 const { router } = stores;
32
33 let filter = `${to}(*action)`;
34 if (strictFilter) {
35 filter = `${to}`;
36 }
37
38 const match = matchRoute(filter, router.location.pathname);
39
40 const linkClasses = classnames({
41 [`${className}`]: true,
42 [`${activeClassName}`]: match,
43 });
44
45 return (
46 <a
47 href={router.history.createHref(to)}
48 className={linkClasses}
49 onClick={e => this.onClick(e)}
50 >
51 {children}
52 </a>
53 );
54 }
55}
56
57Link.wrappedComponent.propTypes = {
58 stores: PropTypes.shape({
59 router: PropTypes.instanceOf(RouterStore).isRequired,
60 }).isRequired,
61 children: PropTypes.oneOfType([
62 oneOrManyChildElements,
63 PropTypes.string,
64 ]).isRequired,
65 to: PropTypes.string.isRequired,
66 className: PropTypes.string,
67 activeClassName: PropTypes.string,
68 strictFilter: PropTypes.bool,
69 target: PropTypes.string,
70};
71
72Link.wrappedComponent.defaultProps = {
73 className: '',
74 activeClassName: '',
75 strictFilter: false,
76 target: '',
77 openInBrowser: false,
78};
diff --git a/src/components/ui/Loader.js b/src/components/ui/Loader.js
new file mode 100644
index 000000000..e4fbd96a2
--- /dev/null
+++ b/src/components/ui/Loader.js
@@ -0,0 +1,41 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import Loader from 'react-loader';
4
5import { oneOrManyChildElements } from '../../prop-types';
6
7export default class LoaderComponent extends Component {
8 static propTypes = {
9 children: oneOrManyChildElements,
10 loaded: PropTypes.bool,
11 className: PropTypes.string,
12 };
13
14 static defaultProps = {
15 children: null,
16 loaded: false,
17 className: '',
18 };
19
20 render() {
21 const {
22 children,
23 loaded,
24 className,
25 } = this.props;
26
27 return (
28 <Loader
29 loaded={loaded}
30 // lines={10}
31 width={4}
32 scale={0.6}
33 color="#373a3c"
34 component="span"
35 className={className}
36 >
37 {children}
38 </Loader>
39 );
40 }
41}
diff --git a/src/components/ui/Radio.js b/src/components/ui/Radio.js
new file mode 100644
index 000000000..b54cfc820
--- /dev/null
+++ b/src/components/ui/Radio.js
@@ -0,0 +1,89 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Field } from 'mobx-react-form';
5import classnames from 'classnames';
6
7@observer
8export default class Radio extends Component {
9 static propTypes = {
10 field: PropTypes.instanceOf(Field).isRequired,
11 className: PropTypes.string,
12 focus: PropTypes.bool,
13 showLabel: PropTypes.bool,
14 };
15
16 static defaultProps = {
17 className: null,
18 focus: false,
19 showLabel: true,
20 };
21
22 componentDidMount() {
23 if (this.props.focus) {
24 this.focus();
25 }
26 }
27
28 focus() {
29 this.inputElement.focus();
30 }
31
32 inputElement = null;
33
34 render() {
35 const {
36 field,
37 className,
38 showLabel,
39 } = this.props;
40
41 return (
42 <div
43 className={classnames({
44 'franz-form__field': true,
45 'has-error': field.error,
46 [`${className}`]: className,
47 })}
48 >
49 {field.label && showLabel && (
50 <label
51 className="franz-form__label"
52 htmlFor={field.name}
53 >
54 {field.label}
55 </label>
56 )}
57 <div className="franz-form__radio-wrapper">
58 {field.options.map(type => (
59 <label
60 key={type.value}
61 htmlFor={`${field.id}-${type.value}`}
62 className={classnames({
63 'franz-form__radio': true,
64 'is-selected': field.value === type.value,
65 })}
66 >
67 <input
68 id={`${field.id}-${type.value}`}
69 type="radio"
70 name="type"
71 value={type.value}
72 onChange={field.onChange}
73 checked={field.value === type.value}
74 />
75 {type.label}
76 </label>
77 ))}
78 </div>
79 {field.error && (
80 <div
81 className="franz-form__error"
82 >
83 {field.error}
84 </div>
85 )}
86 </div>
87 );
88 }
89}
diff --git a/src/components/ui/SearchInput.js b/src/components/ui/SearchInput.js
new file mode 100644
index 000000000..bca412cef
--- /dev/null
+++ b/src/components/ui/SearchInput.js
@@ -0,0 +1,124 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import uuidv1 from 'uuid/v1';
6import { debounce } from 'lodash';
7
8@observer
9export default class SearchInput extends Component {
10 static propTypes = {
11 value: PropTypes.string,
12 defaultValue: PropTypes.string,
13 className: PropTypes.string,
14 onChange: PropTypes.func,
15 onReset: PropTypes.func,
16 name: PropTypes.string,
17 throttle: PropTypes.bool,
18 throttleDelay: PropTypes.number,
19 };
20
21 static defaultProps = {
22 value: '',
23 defaultValue: '',
24 className: '',
25 name: uuidv1(),
26 throttle: false,
27 throttleDelay: 250,
28 onChange: () => null,
29 onReset: () => null,
30 }
31
32 constructor(props) {
33 super(props);
34
35 this.state = {
36 value: props.value || props.defaultValue,
37 };
38
39 this.throttledOnChange = debounce(this.throttledOnChange, this.props.throttleDelay);
40 }
41
42 onChange(e) {
43 const { throttle, onChange } = this.props;
44 const { value } = e.target;
45 this.setState({ value });
46
47 if (throttle) {
48 e.persist();
49 this.throttledOnChange(value);
50 } else {
51 onChange(value);
52 }
53 }
54
55 onClick() {
56 const { defaultValue } = this.props;
57 const { value } = this.state;
58
59 if (value === defaultValue) {
60 this.setState({ value: '' });
61 }
62
63 this.input.focus();
64 }
65
66 onBlur() {
67 const { defaultValue } = this.props;
68 const { value } = this.state;
69
70 if (value === '') {
71 this.setState({ value: defaultValue });
72 }
73 }
74
75 throttledOnChange(e) {
76 const { onChange } = this.props;
77
78 onChange(e);
79 }
80
81 reset() {
82 const { defaultValue, onReset } = this.props;
83 this.setState({ value: defaultValue });
84
85 onReset();
86 }
87
88 input = null;
89
90 render() {
91 const { className, name, defaultValue } = this.props;
92 const { value } = this.state;
93
94 return (
95 <div
96 className={classnames([
97 className,
98 'search-input',
99 ])}
100 >
101 <label
102 htmlFor={name}
103 className="mdi mdi-magnify"
104 onClick={() => this.onClick()}
105 />
106 <input
107 name={name}
108 type="text"
109 value={value}
110 onChange={e => this.onChange(e)}
111 onClick={() => this.onClick()}
112 onBlur={() => this.onBlur()}
113 ref={(ref) => { this.input = ref; }}
114 />
115 {value !== defaultValue && value.length > 0 && (
116 <span
117 className="mdi mdi-close-circle-outline"
118 onClick={() => this.reset()}
119 />
120 )}
121 </div>
122 );
123 }
124}
diff --git a/src/components/ui/Select.js b/src/components/ui/Select.js
new file mode 100644
index 000000000..2a877af3e
--- /dev/null
+++ b/src/components/ui/Select.js
@@ -0,0 +1,70 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { Field } from 'mobx-react-form';
5import classnames from 'classnames';
6
7@observer
8export default class Select extends Component {
9 static propTypes = {
10 field: PropTypes.instanceOf(Field).isRequired,
11 className: PropTypes.string,
12 showLabel: PropTypes.bool,
13 };
14
15 static defaultProps = {
16 className: null,
17 focus: false,
18 showLabel: true,
19 };
20
21 render() {
22 const {
23 field,
24 className,
25 showLabel,
26 } = this.props;
27
28 return (
29 <div
30 className={classnames({
31 'franz-form__field': true,
32 'has-error': field.error,
33 [`${className}`]: className,
34 })}
35 >
36 {field.label && showLabel && (
37 <label
38 className="franz-form__label"
39 htmlFor={field.name}
40 >
41 {field.label}
42 </label>
43 )}
44 <select
45 onChange={field.onChange}
46 id={field.id}
47 defaultValue={field.value}
48 className="franz-form__select"
49 >
50 {field.options.map(type => (
51 <option
52 key={type.value}
53 value={type.value}
54 // selected={field.value === }
55 >
56 {type.label}
57 </option>
58 ))}
59 </select>
60 {field.error && (
61 <div
62 className="franz-form__error"
63 >
64 {field.error}
65 </div>
66 )}
67 </div>
68 );
69 }
70}
diff --git a/src/components/ui/Subscription.js b/src/components/ui/Subscription.js
new file mode 100644
index 000000000..ada5cc3e0
--- /dev/null
+++ b/src/components/ui/Subscription.js
@@ -0,0 +1,265 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import Form from '../../lib/Form';
7import Radio from '../ui/Radio';
8import Button from '../ui/Button';
9import Loader from '../ui/Loader';
10
11import { required } from '../../helpers/validation-helpers';
12
13const messages = defineMessages({
14 submitButtonLabel: {
15 id: 'subscription.submit.label',
16 defaultMessage: '!!!Support the development of Franz',
17 },
18 paymentSessionError: {
19 id: 'subscription.paymentSessionError',
20 defaultMessage: '!!!Could not initialize payment form',
21 },
22 typeFree: {
23 id: 'subscription.type.free',
24 defaultMessage: '!!!free',
25 },
26 typeMonthly: {
27 id: 'subscription.type.month',
28 defaultMessage: '!!!month',
29 },
30 typeYearly: {
31 id: 'subscription.type.year',
32 defaultMessage: '!!!year',
33 },
34 typeMining: {
35 id: 'subscription.type.mining',
36 defaultMessage: '!!!Support Franz with processing power',
37 },
38 includedFeatures: {
39 id: 'subscription.includedFeatures',
40 defaultMessage: '!!!The Franz Premium Supporter Account includes',
41 },
42 features: {
43 unlimitedServices: {
44 id: 'subscription.features.unlimitedServices',
45 defaultMessage: '!!!Add unlimited services',
46 },
47 onpremise: {
48 id: 'subscription.features.onpremise',
49 defaultMessage: '!!!Add on-premise/hosted services like HipChat',
50 },
51 customServices: {
52 id: 'subscription.features.customServices',
53 defaultMessage: '!!!Add your custom services',
54 },
55 encryptedSync: {
56 id: 'subscription.features.encryptedSync',
57 defaultMessage: '!!!Encrypted session synchronization',
58 },
59 vpn: {
60 id: 'subscription.features.vpn',
61 defaultMessage: '!!!Proxy & VPN support',
62 },
63 ads: {
64 id: 'subscription.features.ads',
65 defaultMessage: '!!!No ads, ever!',
66 },
67 comingSoon: {
68 id: 'subscription.features.comingSoon',
69 defaultMessage: '!!!coming soon',
70 },
71 },
72 miningHeadline: {
73 id: 'subscription.mining.headline',
74 defaultMessage: '!!!How does this work?',
75 },
76 experimental: {
77 id: 'subscription.mining.experimental',
78 defaultMessage: '!!!experimental',
79 },
80 miningDetail1: {
81 id: 'subscription.mining.line1',
82 defaultMessage: '!!!By enabling "Support with processing power", Franz will use about 20-50% of your CPU to mine cryptocurrency Monero which equals approximately $ 5/year.',
83 },
84 miningDetail2: {
85 id: 'subscription.mining.line2',
86 defaultMessage: '!!!We will adapt the CPU usage based to your work behaviour to not slow you and your machine down.',
87 },
88 miningDetail3: {
89 id: 'subscription.mining.line3',
90 defaultMessage: '!!!As long as the miner is active, you will have unlimited access to all the Franz Premium Supporter Features.',
91 },
92 miningMoreInfo: {
93 id: 'subscription.mining.moreInformation',
94 defaultMessage: '!!!Get more information about this plan',
95 },
96});
97
98@observer
99export default class SubscriptionForm extends Component {
100 static propTypes = {
101 plan: MobxPropTypes.objectOrObservableObject.isRequired,
102 isLoading: PropTypes.bool.isRequired,
103 handlePayment: PropTypes.func.isRequired,
104 retryPlanRequest: PropTypes.func.isRequired,
105 isCreatingHostedPage: PropTypes.bool.isRequired,
106 error: PropTypes.bool.isRequired,
107 showSkipOption: PropTypes.bool,
108 skipAction: PropTypes.func,
109 skipButtonLabel: PropTypes.string,
110 hideInfo: PropTypes.bool.isRequired,
111 openExternalUrl: PropTypes.func.isRequired,
112 };
113
114 static defaultProps ={
115 content: '',
116 showSkipOption: false,
117 skipAction: () => null,
118 skipButtonLabel: '',
119 }
120
121 static contextTypes = {
122 intl: intlShape,
123 };
124
125 componentWillMount() {
126 this.form = this.prepareForm();
127 }
128
129 prepareForm() {
130 const { intl } = this.context;
131
132 const form = {
133 fields: {
134 paymentTier: {
135 value: 'year',
136 validate: [required],
137 options: [{
138 value: 'month',
139 label: `$ ${Object.hasOwnProperty.call(this.props.plan, 'month')
140 ? `${this.props.plan.month.price} / ${intl.formatMessage(messages.typeMonthly)}`
141 : 'monthly'}`,
142 }, {
143 value: 'year',
144 label: `$ ${Object.hasOwnProperty.call(this.props.plan, 'year')
145 ? `${this.props.plan.year.price} / ${intl.formatMessage(messages.typeYearly)}`
146 : 'yearly'}`,
147 }, {
148 value: 'mining',
149 label: intl.formatMessage(messages.typeMining),
150 }],
151 },
152 },
153 };
154
155 if (this.props.showSkipOption) {
156 form.fields.paymentTier.options.unshift({
157 value: 'skip',
158 label: `$ 0 / ${intl.formatMessage(messages.typeFree)}`,
159 });
160 }
161
162 return new Form(form, this.context.intl);
163 }
164
165 render() {
166 const {
167 isLoading,
168 isCreatingHostedPage,
169 handlePayment,
170 retryPlanRequest,
171 error,
172 showSkipOption,
173 skipAction,
174 skipButtonLabel,
175 hideInfo,
176 openExternalUrl,
177 } = this.props;
178 const { intl } = this.context;
179
180 if (error) {
181 return (
182 <Button
183 label="Reload"
184 onClick={retryPlanRequest}
185 isLoaded={!isLoading}
186 />
187 );
188 }
189
190 return (
191 <Loader loaded={!isLoading}>
192 <Radio field={this.form.$('paymentTier')} showLabel={false} className="paymentTiers" />
193 {!hideInfo && (
194 <div className="subscription__premium-info">
195 {this.form.$('paymentTier').value !== 'mining' && (
196 <div>
197 <p>
198 <strong>{intl.formatMessage(messages.includedFeatures)}</strong>
199 </p>
200 <div className="subscription">
201 <ul className="subscription__premium-features">
202 <li>{intl.formatMessage(messages.features.onpremise)}</li>
203 <li>
204 {intl.formatMessage(messages.features.encryptedSync)}
205 <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span>
206 </li>
207 <li>
208 {intl.formatMessage(messages.features.customServices)}
209 <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span>
210 </li>
211 <li>
212 {intl.formatMessage(messages.features.vpn)}
213 <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span>
214 </li>
215 <li>
216 {intl.formatMessage(messages.features.ads)}
217 </li>
218 </ul>
219 </div>
220 </div>
221 )}
222 {this.form.$('paymentTier').value === 'mining' && (
223 <div className="subscription mining-details">
224 <p>
225 <strong>{intl.formatMessage(messages.miningHeadline)}</strong>
226 &nbsp;
227 <span className="badge">{intl.formatMessage(messages.experimental)}</span>
228 </p>
229 <p>{intl.formatMessage(messages.miningDetail1)}</p>
230 <p>{intl.formatMessage(messages.miningDetail2)}</p>
231 <p>{intl.formatMessage(messages.miningDetail3)}</p>
232 <p>
233 <button
234 onClick={() => openExternalUrl({ url: 'http://meetfranz.com/mining' })}
235 >
236 {intl.formatMessage(messages.miningMoreInfo)}
237 </button>
238 </p>
239 </div>
240 )}
241 </div>
242 )}
243 <div>
244 {error.code === 'no-payment-session' && (
245 <p className="error-message center">{intl.formatMessage(messages.paymentSessionError)}</p>
246 )}
247 </div>
248 {showSkipOption && this.form.$('paymentTier').value === 'skip' ? (
249 <Button
250 label={skipButtonLabel}
251 className="auth__button"
252 onClick={skipAction}
253 />
254 ) : (
255 <Button
256 label={intl.formatMessage(messages.submitButtonLabel)}
257 className="auth__button"
258 loaded={!isCreatingHostedPage}
259 onClick={() => handlePayment(this.form.$('paymentTier').value)}
260 />
261 )}
262 </Loader>
263 );
264 }
265}
diff --git a/src/components/ui/SubscriptionPopup.js b/src/components/ui/SubscriptionPopup.js
new file mode 100644
index 000000000..72b6ccd98
--- /dev/null
+++ b/src/components/ui/SubscriptionPopup.js
@@ -0,0 +1,84 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import Webview from 'react-electron-web-view';
6
7import Button from '../ui/Button';
8
9const messages = defineMessages({
10 buttonCancel: {
11 id: 'subscriptionPopup.buttonCancel',
12 defaultMessage: '!!!Cancel',
13 },
14 buttonDone: {
15 id: 'subscriptionPopup.buttonDone',
16 defaultMessage: '!!!Done',
17 },
18});
19
20@observer
21export default class SubscriptionPopup extends Component {
22 static propTypes = {
23 url: PropTypes.string.isRequired,
24 closeWindow: PropTypes.func.isRequired,
25 completeCheck: PropTypes.func.isRequired,
26 isCompleted: PropTypes.bool.isRequired,
27 };
28
29 static contextTypes = {
30 intl: intlShape,
31 };
32
33 state = {
34 isFakeLoading: false,
35 };
36
37 // We delay the window closing a bit in order to give
38 // the Recurly webhook a few seconds to do it's magic
39 delayedCloseWindow() {
40 this.setState({
41 isFakeLoading: true,
42 });
43
44 setTimeout(() => {
45 this.props.closeWindow();
46 }, 4000);
47 }
48
49 render() {
50 const { url, closeWindow, completeCheck, isCompleted } = this.props;
51 const { intl } = this.context;
52
53 return (
54 <div className="subscription-popup">
55 <div className="subscription-popup__content">
56 <Webview
57 className="subscription-popup__webview"
58
59 autosize
60 src={url}
61 disablewebsecurity
62 onDidNavigate={completeCheck}
63 // onNewWindow={(event, url, frameName, options) =>
64 // openWindow({ event, url, frameName, options })}
65 />
66 </div>
67 <div className="subscription-popup__toolbar franz-form">
68 <Button
69 label={intl.formatMessage(messages.buttonCancel)}
70 buttonType="secondary"
71 onClick={closeWindow}
72 disabled={isCompleted}
73 />
74 <Button
75 label={intl.formatMessage(messages.buttonDone)}
76 onClick={() => this.delayedCloseWindow()}
77 disabled={!isCompleted}
78 loaded={!this.state.isFakeLoading}
79 />
80 </div>
81 </div>
82 );
83 }
84}
diff --git a/src/components/ui/Tabs/TabItem.js b/src/components/ui/Tabs/TabItem.js
new file mode 100644
index 000000000..9ff9f009e
--- /dev/null
+++ b/src/components/ui/Tabs/TabItem.js
@@ -0,0 +1,17 @@
1import React, { Component } from 'react';
2
3import { oneOrManyChildElements } from '../../../prop-types';
4
5export default class TabItem extends Component {
6 static propTypes = {
7 children: oneOrManyChildElements.isRequired,
8 }
9
10 render() {
11 const { children } = this.props;
12
13 return (
14 <div>{children}</div>
15 );
16 }
17}
diff --git a/src/components/ui/Tabs/Tabs.js b/src/components/ui/Tabs/Tabs.js
new file mode 100644
index 000000000..50397f9bb
--- /dev/null
+++ b/src/components/ui/Tabs/Tabs.js
@@ -0,0 +1,69 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5
6import { oneOrManyChildElements } from '../../../prop-types';
7
8@observer
9export default class Tab extends Component {
10 static propTypes = {
11 children: oneOrManyChildElements.isRequired,
12 active: PropTypes.number,
13 };
14
15 static defaultProps = {
16 active: 0,
17 };
18
19 componentWillMount() {
20 this.setState({ active: this.props.active });
21 }
22
23 switchTab(index) {
24 this.setState({ active: index });
25 }
26
27 render() {
28 const { children: childElements } = this.props;
29 const children = childElements.filter(c => !!c);
30
31 if (children.length === 1) {
32 return <div>{children}</div>;
33 }
34
35 return (
36 <div className="content-tabs">
37 <div className="content-tabs__tabs">
38 {React.Children.map(children, (child, i) => (
39 <button
40 key={i}
41 className={classnames({
42 'content-tabs__item': true,
43 'is-active': this.state.active === i,
44 })}
45 onClick={() => this.switchTab(i)}
46 type="button"
47 >
48 {child.props.title}
49 </button>
50 ))}
51 </div>
52 <div className="content-tabs__content">
53 {React.Children.map(children, (child, i) => (
54 <div
55 key={i}
56 className={classnames({
57 'content-tabs__item': true,
58 'is-active': this.state.active === i,
59 })}
60 type="button"
61 >
62 {child}
63 </div>
64 ))}
65 </div>
66 </div>
67 );
68 }
69}
diff --git a/src/components/ui/Tabs/index.js b/src/components/ui/Tabs/index.js
new file mode 100644
index 000000000..e4adb62c7
--- /dev/null
+++ b/src/components/ui/Tabs/index.js
@@ -0,0 +1,6 @@
1import Tabs from './Tabs';
2import TabItem from './TabItem';
3
4export default Tabs;
5
6export { TabItem };
diff --git a/src/components/ui/Toggle.js b/src/components/ui/Toggle.js
new file mode 100644
index 000000000..62d46393e
--- /dev/null
+++ b/src/components/ui/Toggle.js
@@ -0,0 +1,67 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import classnames from 'classnames';
5import { Field } from 'mobx-react-form';
6
7@observer
8export default class Toggle extends Component {
9 static propTypes = {
10 field: PropTypes.instanceOf(Field).isRequired,
11 className: PropTypes.string,
12 showLabel: PropTypes.bool,
13 };
14
15 static defaultProps = {
16 className: '',
17 showLabel: true,
18 };
19
20 onChange(e) {
21 const { field } = this.props;
22
23 field.onChange(e);
24 }
25
26 render() {
27 const {
28 field,
29 className,
30 showLabel,
31 } = this.props;
32
33 if (field.value === '' && field.default !== '') {
34 field.value = field.default;
35 }
36
37 return (
38 <div
39 className={classnames([
40 'franz-form__field',
41 'franz-form__toggle-wrapper',
42 className,
43 ])}
44 >
45 <label
46 htmlFor={field.id}
47 className={classnames({
48 'franz-form__toggle': true,
49 'is-active': field.value,
50 })}
51 >
52 <div className="franz-form__toggle-button" />
53 <input
54 type="checkbox"
55 id={field.id}
56 name={field.name}
57 value={field.name}
58 checked={field.value}
59 onChange={e => this.onChange(e)}
60 />
61 </label>
62 {field.error && <div className={field.error}>{field.error}</div>}
63 {field.label && showLabel && <label className="franz-form__label" htmlFor={field.id}>{field.label}</label>}
64 </div>
65 );
66 }
67}
diff --git a/src/components/ui/effects/Appear.js b/src/components/ui/effects/Appear.js
new file mode 100644
index 000000000..1255fce2e
--- /dev/null
+++ b/src/components/ui/effects/Appear.js
@@ -0,0 +1,51 @@
1/* eslint-disable react/no-did-mount-set-state */
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
5
6export default class Appear extends Component {
7 static propTypes = {
8 children: PropTypes.any.isRequired, // eslint-disable-line
9 transitionName: PropTypes.string,
10 className: PropTypes.string,
11 };
12
13 static defaultProps = {
14 transitionName: 'fadeIn',
15 className: '',
16 };
17
18 state = {
19 mounted: false,
20 };
21
22 componentDidMount() {
23 this.setState({ mounted: true });
24 }
25
26 render() {
27 const {
28 children,
29 transitionName,
30 className,
31 } = this.props;
32
33 if (!this.state.mounted) {
34 return null;
35 }
36
37 return (
38 <ReactCSSTransitionGroup
39 transitionName={transitionName}
40 transitionAppear
41 transitionLeave
42 transitionAppearTimeout={1500}
43 transitionEnterTimeout={1500}
44 transitionLeaveTimeout={1500}
45 className={className}
46 >
47 {children}
48 </ReactCSSTransitionGroup>
49 );
50 }
51}
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 000000000..acbf57f3c
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,5 @@
1export const CHECK_INTERVAL = 1000 * 3600; // How often should we perform checks
2export const LOCAL_API = 'http://localhost:3000';
3export const DEV_API = 'https://dev.franzinfra.com';
4export const LIVE_API = 'https://api.franzinfra.com';
5export const GA_ID = 'UA-74126766-6';
diff --git a/src/containers/auth/AuthLayoutContainer.js b/src/containers/auth/AuthLayoutContainer.js
new file mode 100644
index 000000000..004054fdd
--- /dev/null
+++ b/src/containers/auth/AuthLayoutContainer.js
@@ -0,0 +1,47 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4
5import AuthLayout from '../../components/auth/AuthLayout';
6import AppStore from '../../stores/AppStore';
7import GlobalErrorStore from '../../stores/GlobalErrorStore';
8
9import { oneOrManyChildElements } from '../../prop-types';
10
11@inject('stores', 'actions') @observer
12export default class AuthLayoutContainer extends Component {
13 static propTypes = {
14 children: oneOrManyChildElements.isRequired,
15 location: PropTypes.shape({
16 pathname: PropTypes.string.isRequired,
17 }).isRequired,
18 };
19
20 render() {
21 const { stores, actions, children, location } = this.props;
22 return (
23 <AuthLayout
24 error={stores.globalError.response}
25 pathname={location.pathname}
26 isOnline={stores.app.isOnline}
27 isAPIHealthy={!stores.app.healthCheckRequest.isError}
28 retryHealthCheck={actions.app.healthCheck}
29 isHealthCheckLoading={stores.app.healthCheckRequest.isExecuting}
30 >
31 {children}
32 </AuthLayout>
33 );
34 }
35}
36
37AuthLayoutContainer.wrappedComponent.propTypes = {
38 stores: PropTypes.shape({
39 app: PropTypes.instanceOf(AppStore).isRequired,
40 globalError: PropTypes.instanceOf(GlobalErrorStore).isRequired,
41 }).isRequired,
42 actions: PropTypes.shape({
43 app: PropTypes.shape({
44 healthCheck: PropTypes.func.isRequired,
45 }).isRequired,
46 }).isRequired,
47};
diff --git a/src/containers/auth/ImportScreen.js b/src/containers/auth/ImportScreen.js
new file mode 100644
index 000000000..ddd56ffb6
--- /dev/null
+++ b/src/containers/auth/ImportScreen.js
@@ -0,0 +1,41 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import Import from '../../components/auth/Import';
5import UserStore from '../../stores/UserStore';
6import { gaPage } from '../../lib/analytics';
7
8@inject('stores', 'actions') @observer
9export default class ImportScreen extends Component {
10 componentDidMount() {
11 gaPage('Auth/Import');
12 }
13
14 render() {
15 const { actions, stores } = this.props;
16
17 if (stores.user.isImportLegacyServicesCompleted) {
18 stores.router.push(stores.user.inviteRoute);
19 }
20
21 return (
22 <Import
23 services={stores.user.legacyServices}
24 onSubmit={actions.user.importLegacyServices}
25 isSubmitting={stores.user.isImportLegacyServicesExecuting}
26 inviteRoute={stores.user.inviteRoute}
27 />
28 );
29 }
30}
31
32ImportScreen.wrappedComponent.propTypes = {
33 actions: PropTypes.shape({
34 user: PropTypes.shape({
35 importLegacyServices: PropTypes.func.isRequired,
36 }).isRequired,
37 }).isRequired,
38 stores: PropTypes.shape({
39 user: PropTypes.instanceOf(UserStore).isRequired,
40 }).isRequired,
41};
diff --git a/src/containers/auth/InviteScreen.js b/src/containers/auth/InviteScreen.js
new file mode 100644
index 000000000..51971f436
--- /dev/null
+++ b/src/containers/auth/InviteScreen.js
@@ -0,0 +1,29 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import Invite from '../../components/auth/Invite';
5import { gaPage } from '../../lib/analytics';
6
7@inject('stores', 'actions') @observer
8export default class InviteScreen extends Component {
9 componentDidMount() {
10 gaPage('Auth/Invite');
11 }
12
13 render() {
14 const { actions } = this.props;
15 return (
16 <Invite
17 onSubmit={actions.user.invite}
18 />
19 );
20 }
21}
22
23InviteScreen.wrappedComponent.propTypes = {
24 actions: PropTypes.shape({
25 user: PropTypes.shape({
26 invite: PropTypes.func.isRequired,
27 }).isRequired,
28 }).isRequired,
29};
diff --git a/src/containers/auth/LoginScreen.js b/src/containers/auth/LoginScreen.js
new file mode 100644
index 000000000..9e22c5141
--- /dev/null
+++ b/src/containers/auth/LoginScreen.js
@@ -0,0 +1,45 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import Login from '../../components/auth/Login';
5import UserStore from '../../stores/UserStore';
6import { gaPage } from '../../lib/analytics';
7
8import { globalError as globalErrorPropType } from '../../prop-types';
9
10@inject('stores', 'actions') @observer
11export default class LoginScreen extends Component {
12 static propTypes = {
13 error: globalErrorPropType.isRequired,
14 };
15
16 componentDidMount() {
17 gaPage('Auth/Login');
18 }
19
20 render() {
21 const { actions, stores, error } = this.props;
22 return (
23 <Login
24 onSubmit={actions.user.login}
25 isSubmitting={stores.user.loginRequest.isExecuting}
26 isTokenExpired={stores.user.isTokenExpired}
27 isServerLogout={stores.user.logoutReason === stores.user.logoutReasonTypes.SERVER}
28 signupRoute={stores.user.signupRoute}
29 passwordRoute={stores.user.passwordRoute}
30 error={error}
31 />
32 );
33 }
34}
35
36LoginScreen.wrappedComponent.propTypes = {
37 actions: PropTypes.shape({
38 user: PropTypes.shape({
39 login: PropTypes.func.isRequired,
40 }).isRequired,
41 }).isRequired,
42 stores: PropTypes.shape({
43 user: PropTypes.instanceOf(UserStore).isRequired,
44 }).isRequired,
45};
diff --git a/src/containers/auth/PasswordScreen.js b/src/containers/auth/PasswordScreen.js
new file mode 100644
index 000000000..d88cb08e6
--- /dev/null
+++ b/src/containers/auth/PasswordScreen.js
@@ -0,0 +1,38 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import Password from '../../components/auth/Password';
5import UserStore from '../../stores/UserStore';
6import { gaPage } from '../../lib/analytics';
7
8@inject('stores', 'actions') @observer
9export default class PasswordScreen extends Component {
10 componentDidMount() {
11 gaPage('Auth/Password Retrieve');
12 }
13
14 render() {
15 const { actions, stores } = this.props;
16
17 return (
18 <Password
19 onSubmit={actions.user.retrievePassword}
20 isSubmitting={stores.user.passwordRequest.isExecuting}
21 signupRoute={stores.user.signupRoute}
22 loginRoute={stores.user.loginRoute}
23 status={stores.user.actionStatus}
24 />
25 );
26 }
27}
28
29PasswordScreen.wrappedComponent.propTypes = {
30 actions: PropTypes.shape({
31 user: PropTypes.shape({
32 retrievePassword: PropTypes.func.isRequired,
33 }).isRequired,
34 }).isRequired,
35 stores: PropTypes.shape({
36 user: PropTypes.instanceOf(UserStore).isRequired,
37 }).isRequired,
38};
diff --git a/src/containers/auth/PricingScreen.js b/src/containers/auth/PricingScreen.js
new file mode 100644
index 000000000..7e1586535
--- /dev/null
+++ b/src/containers/auth/PricingScreen.js
@@ -0,0 +1,53 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { RouterStore } from 'mobx-react-router';
5
6import Pricing from '../../components/auth/Pricing';
7import UserStore from '../../stores/UserStore';
8import PaymentStore from '../../stores/PaymentStore';
9import { gaPage } from '../../lib/analytics';
10
11import { globalError as globalErrorPropType } from '../../prop-types';
12
13@inject('stores', 'actions') @observer
14export default class PricingScreen extends Component {
15 static propTypes = {
16 error: globalErrorPropType.isRequired,
17 };
18
19 componentDidMount() {
20 gaPage('Auth/Pricing');
21 }
22
23 render() {
24 const { actions, stores, error } = this.props;
25
26 const nextStepRoute = stores.user.legacyServices.length ? stores.user.importRoute : stores.user.inviteRoute;
27
28 return (
29 <Pricing
30 donor={stores.user.data.donor || {}}
31 onSubmit={actions.user.signup}
32 onCloseSubscriptionWindow={() => this.props.stores.router.push(nextStepRoute)}
33 isLoading={stores.payment.plansRequest.isExecuting}
34 isLoadingUser={stores.user.getUserInfoRequest.isExecuting}
35 error={error}
36 skipAction={() => this.props.stores.router.push(nextStepRoute)}
37 />
38 );
39 }
40}
41
42PricingScreen.wrappedComponent.propTypes = {
43 actions: PropTypes.shape({
44 user: PropTypes.shape({
45 signup: PropTypes.func.isRequired,
46 }).isRequired,
47 }).isRequired,
48 stores: PropTypes.shape({
49 user: PropTypes.instanceOf(UserStore).isRequired,
50 payment: PropTypes.instanceOf(PaymentStore).isRequired,
51 router: PropTypes.instanceOf(RouterStore).isRequired,
52 }).isRequired,
53};
diff --git a/src/containers/auth/SignupScreen.js b/src/containers/auth/SignupScreen.js
new file mode 100644
index 000000000..3b86ab138
--- /dev/null
+++ b/src/containers/auth/SignupScreen.js
@@ -0,0 +1,43 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4
5import Signup from '../../components/auth/Signup';
6import UserStore from '../../stores/UserStore';
7import { gaPage } from '../../lib/analytics';
8
9import { globalError as globalErrorPropType } from '../../prop-types';
10
11@inject('stores', 'actions') @observer
12export default class SignupScreen extends Component {
13 static propTypes = {
14 error: globalErrorPropType.isRequired,
15 };
16
17 componentDidMount() {
18 gaPage('Auth/Signup');
19 }
20
21 render() {
22 const { actions, stores, error } = this.props;
23 return (
24 <Signup
25 onSubmit={actions.user.signup}
26 isSubmitting={stores.user.signupRequest.isExecuting}
27 loginRoute={stores.user.loginRoute}
28 error={error}
29 />
30 );
31 }
32}
33
34SignupScreen.wrappedComponent.propTypes = {
35 actions: PropTypes.shape({
36 user: PropTypes.shape({
37 signup: PropTypes.func.isRequired,
38 }).isRequired,
39 }).isRequired,
40 stores: PropTypes.shape({
41 user: PropTypes.instanceOf(UserStore).isRequired,
42 }).isRequired,
43};
diff --git a/src/containers/auth/WelcomeScreen.js b/src/containers/auth/WelcomeScreen.js
new file mode 100644
index 000000000..e413264a6
--- /dev/null
+++ b/src/containers/auth/WelcomeScreen.js
@@ -0,0 +1,34 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4
5import Welcome from '../../components/auth/Welcome';
6import UserStore from '../../stores/UserStore';
7import RecipePreviewsStore from '../../stores/RecipePreviewsStore';
8import { gaPage } from '../../lib/analytics';
9
10@inject('stores', 'actions') @observer
11export default class LoginScreen extends Component {
12 componentDidMount() {
13 gaPage('Auth/Welcome');
14 }
15
16 render() {
17 const { user, recipePreviews } = this.props.stores;
18
19 return (
20 <Welcome
21 loginRoute={user.loginRoute}
22 signupRoute={user.signupRoute}
23 recipes={recipePreviews.featured}
24 />
25 );
26 }
27}
28
29LoginScreen.wrappedComponent.propTypes = {
30 stores: PropTypes.shape({
31 user: PropTypes.instanceOf(UserStore).isRequired,
32 recipePreviews: PropTypes.instanceOf(RecipePreviewsStore).isRequired,
33 }).isRequired,
34};
diff --git a/src/containers/layout/AppLayoutContainer.js b/src/containers/layout/AppLayoutContainer.js
new file mode 100644
index 000000000..aa7f7952a
--- /dev/null
+++ b/src/containers/layout/AppLayoutContainer.js
@@ -0,0 +1,166 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4
5import AppStore from '../../stores/AppStore';
6import RecipesStore from '../../stores/RecipesStore';
7import ServicesStore from '../../stores/ServicesStore';
8import UIStore from '../../stores/UIStore';
9import NewsStore from '../../stores/NewsStore';
10import UserStore from '../../stores/UserStore';
11import RequestStore from '../../stores/RequestStore';
12import GlobalErrorStore from '../../stores/GlobalErrorStore';
13
14import { oneOrManyChildElements } from '../../prop-types';
15import AppLayout from '../../components/layout/AppLayout';
16import Sidebar from '../../components/layout/Sidebar';
17import Services from '../../components/services/content/Services';
18import AppLoader from '../../components/ui/AppLoader';
19
20@inject('stores', 'actions') @observer
21export default class AppLayoutContainer extends Component {
22 static defaultProps = {
23 children: null,
24 };
25
26 render() {
27 const {
28 app,
29 services,
30 ui,
31 news,
32 globalError,
33 user,
34 requests,
35 } = this.props.stores;
36
37 const {
38 setActive,
39 handleIPCMessage,
40 setWebviewReference,
41 openWindow,
42 reloadUpdatedServices,
43 reorder,
44 reload,
45 toggleNotifications,
46 deleteService,
47 updateService,
48 } = this.props.actions.service;
49
50 const { hide } = this.props.actions.news;
51
52 const { retryRequiredRequests } = this.props.actions.requests;
53
54 const {
55 installUpdate,
56 } = this.props.actions.app;
57
58 const {
59 openSettings,
60 closeSettings,
61 } = this.props.actions.ui;
62
63 const { children } = this.props;
64 const allServices = services.enabled;
65
66 const isLoadingServices = services.allServicesRequest.isExecuting
67 && services.allServicesRequest.isExecutingFirstTime;
68
69 // const isLoadingRecipes = recipes.allRecipesRequest.isExecuting
70 // && recipes.allRecipesRequest.isExecutingFirstTime;
71
72 if (isLoadingServices) {
73 return (
74 <AppLoader />
75 );
76 }
77
78 const sidebar = (
79 <Sidebar
80 services={allServices}
81 setActive={setActive}
82 openSettings={openSettings}
83 closeSettings={closeSettings}
84 reorder={reorder}
85 reload={reload}
86 toggleNotifications={toggleNotifications}
87 deleteService={deleteService}
88 updateService={updateService}
89 isPremiumUser={user.data.isPremium}
90 />
91 );
92
93 const servicesContainer = (
94 <Services
95 // settings={allSettings}
96 services={allServices}
97 handleIPCMessage={handleIPCMessage}
98 setWebviewReference={setWebviewReference}
99 openWindow={openWindow}
100 />
101 );
102
103 return (
104 <AppLayout
105 isOnline={app.isOnline}
106 showServicesUpdatedInfoBar={ui.showServicesUpdatedInfoBar}
107 appUpdateIsDownloaded={app.updateStatus === app.updateStatusTypes.DOWNLOADED}
108 sidebar={sidebar}
109 services={servicesContainer}
110 news={news.latest}
111 removeNewsItem={hide}
112 reloadServicesAfterUpdate={reloadUpdatedServices}
113 installAppUpdate={installUpdate}
114 globalError={globalError.error}
115 showRequiredRequestsError={requests.showRequiredRequestsError}
116 areRequiredRequestsSuccessful={requests.areRequiredRequestsSuccessful}
117 retryRequiredRequests={retryRequiredRequests}
118 areRequiredRequestsLoading={requests.areRequiredRequestsLoading}
119 >
120 {React.Children.count(children) > 0 ? children : null}
121 </AppLayout>
122 );
123 }
124}
125
126AppLayoutContainer.wrappedComponent.propTypes = {
127 stores: PropTypes.shape({
128 services: PropTypes.instanceOf(ServicesStore).isRequired,
129 recipes: PropTypes.instanceOf(RecipesStore).isRequired,
130 app: PropTypes.instanceOf(AppStore).isRequired,
131 ui: PropTypes.instanceOf(UIStore).isRequired,
132 news: PropTypes.instanceOf(NewsStore).isRequired,
133 user: PropTypes.instanceOf(UserStore).isRequired,
134 requests: PropTypes.instanceOf(RequestStore).isRequired,
135 globalError: PropTypes.instanceOf(GlobalErrorStore).isRequired,
136 }).isRequired,
137 actions: PropTypes.shape({
138 service: PropTypes.shape({
139 setActive: PropTypes.func.isRequired,
140 reload: PropTypes.func.isRequired,
141 toggleNotifications: PropTypes.func.isRequired,
142 handleIPCMessage: PropTypes.func.isRequired,
143 setWebviewReference: PropTypes.func.isRequired,
144 openWindow: PropTypes.func.isRequired,
145 reloadUpdatedServices: PropTypes.func.isRequired,
146 updateService: PropTypes.func.isRequired,
147 deleteService: PropTypes.func.isRequired,
148 reorder: PropTypes.func.isRequired,
149 }).isRequired,
150 news: PropTypes.shape({
151 hide: PropTypes.func.isRequired,
152 }).isRequired,
153 ui: PropTypes.shape({
154 openSettings: PropTypes.func.isRequired,
155 closeSettings: PropTypes.func.isRequired,
156 }).isRequired,
157 app: PropTypes.shape({
158 installUpdate: PropTypes.func.isRequired,
159 healthCheck: PropTypes.func.isRequired,
160 }).isRequired,
161 requests: PropTypes.shape({
162 retryRequiredRequests: PropTypes.func.isRequired,
163 }).isRequired,
164 }).isRequired,
165 children: oneOrManyChildElements,
166};
diff --git a/src/containers/settings/AccountScreen.js b/src/containers/settings/AccountScreen.js
new file mode 100644
index 000000000..a1ac8bda3
--- /dev/null
+++ b/src/containers/settings/AccountScreen.js
@@ -0,0 +1,114 @@
1import { remote } from 'electron';
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import { inject, observer } from 'mobx-react';
5
6import PaymentStore from '../../stores/PaymentStore';
7import UserStore from '../../stores/UserStore';
8import AppStore from '../../stores/AppStore';
9import { gaPage } from '../../lib/analytics';
10
11import AccountDashboard from '../../components/settings/account/AccountDashboard';
12
13const { BrowserWindow } = remote;
14
15@inject('stores', 'actions') @observer
16export default class AccountScreen extends Component {
17 componentDidMount() {
18 gaPage('Settings/Account Dashboard');
19 }
20
21 onCloseWindow() {
22 const { user, payment } = this.props.stores;
23 user.getUserInfoRequest.invalidate({ immediately: true });
24 payment.ordersDataRequest.invalidate({ immediately: true });
25 }
26
27 reloadData() {
28 const { user, payment } = this.props.stores;
29
30 user.getUserInfoRequest.reload();
31 payment.ordersDataRequest.reload();
32 payment.plansRequest.reload();
33 }
34
35 stopMiner() {
36 const { update } = this.props.actions.user;
37
38 update({ userData: {
39 isMiner: false,
40 } });
41 }
42
43 async handlePaymentDashboard() {
44 const { actions, stores } = this.props;
45
46 actions.payment.createDashboardUrl();
47
48 const dashboard = await stores.payment.createDashboardUrlRequest;
49
50 if (dashboard.url) {
51 const paymentWindow = new BrowserWindow({
52 title: '🔒 Franz Subscription Dashboard',
53 parent: remote.getCurrentWindow(),
54 modal: false,
55 width: 900,
56 minWidth: 600,
57 webPreferences: {
58 nodeIntegration: false,
59 },
60 });
61 paymentWindow.loadURL(dashboard.url);
62
63 paymentWindow.on('closed', () => {
64 this.onCloseWindow();
65 });
66 }
67 }
68
69 render() {
70 const { user, payment, app } = this.props.stores;
71 const { openExternalUrl } = this.props.actions.app;
72
73 const isLoadingUserInfo = user.getUserInfoRequest.isExecuting;
74 const isLoadingOrdersInfo = payment.ordersDataRequest.isExecuting;
75 const isLoadingPlans = payment.plansRequest.isExecuting;
76
77 return (
78 <AccountDashboard
79 user={user.data}
80 orders={payment.orders}
81 hashrate={app.minerHashrate}
82 isLoading={isLoadingUserInfo}
83 isLoadingOrdersInfo={isLoadingOrdersInfo}
84 isLoadingPlans={isLoadingPlans}
85 userInfoRequestFailed={user.getUserInfoRequest.wasExecuted && user.getUserInfoRequest.isError}
86 retryUserInfoRequest={() => this.reloadData()}
87 isCreatingPaymentDashboardUrl={payment.createDashboardUrlRequest.isExecuting}
88 openDashboard={price => this.handlePaymentDashboard(price)}
89 openExternalUrl={url => openExternalUrl({ url })}
90 onCloseSubscriptionWindow={() => this.onCloseWindow()}
91 stopMiner={() => this.stopMiner()}
92 />
93 );
94 }
95}
96
97AccountScreen.wrappedComponent.propTypes = {
98 stores: PropTypes.shape({
99 user: PropTypes.instanceOf(UserStore).isRequired,
100 payment: PropTypes.instanceOf(PaymentStore).isRequired,
101 app: PropTypes.instanceOf(AppStore).isRequired,
102 }).isRequired,
103 actions: PropTypes.shape({
104 payment: PropTypes.shape({
105 createDashboardUrl: PropTypes.func.isRequired,
106 }).isRequired,
107 app: PropTypes.shape({
108 openExternalUrl: PropTypes.func.isRequired,
109 }).isRequired,
110 user: PropTypes.shape({
111 update: PropTypes.func.isRequired,
112 }).isRequired,
113 }).isRequired,
114};
diff --git a/src/containers/settings/EditServiceScreen.js b/src/containers/settings/EditServiceScreen.js
new file mode 100644
index 000000000..6c614b941
--- /dev/null
+++ b/src/containers/settings/EditServiceScreen.js
@@ -0,0 +1,208 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import UserStore from '../../stores/UserStore';
7import RecipesStore from '../../stores/RecipesStore';
8import ServicesStore from '../../stores/ServicesStore';
9import Form from '../../lib/Form';
10import { gaPage } from '../../lib/analytics';
11
12
13import ServiceError from '../../components/settings/services/ServiceError';
14import EditServiceForm from '../../components/settings/services/EditServiceForm';
15import { required, url, oneRequired } from '../../helpers/validation-helpers';
16
17const messages = defineMessages({
18 name: {
19 id: 'settings.service.form.name',
20 defaultMessage: '!!!Name',
21 },
22 enableService: {
23 id: 'settings.service.form.enableService',
24 defaultMessage: '!!!Enable service',
25 },
26 enableNotification: {
27 id: 'settings.service.form.enableNotification',
28 defaultMessage: '!!!Enable Notifications',
29 },
30 team: {
31 id: 'settings.service.form.team',
32 defaultMessage: '!!!Team',
33 },
34 customUrl: {
35 id: 'settings.service.form.customUrl',
36 defaultMessage: '!!!Custom server',
37 },
38 indirectMessages: {
39 id: 'settings.service.form.indirectMessages',
40 defaultMessage: '!!!Show message badge for all new messages',
41 },
42});
43
44@inject('stores', 'actions') @observer
45export default class EditServiceScreen extends Component {
46 static contextTypes = {
47 intl: intlShape,
48 };
49
50 componentDidMount() {
51 gaPage('Settings/Service/Edit');
52 }
53
54 onSubmit(serviceData) {
55 const { action } = this.props.router.params;
56 const { recipes, services } = this.props.stores;
57 const { createService, updateService } = this.props.actions.service;
58
59 if (action === 'edit') {
60 updateService({ serviceId: services.activeSettings.id, serviceData });
61 } else {
62 createService({ recipeId: recipes.active.id, serviceData });
63 }
64 }
65
66 prepareForm(recipe, service) {
67 const { intl } = this.context;
68 const config = {
69 fields: {
70 name: {
71 label: intl.formatMessage(messages.name),
72 placeholder: intl.formatMessage(messages.name),
73 value: service.id ? service.name : recipe.name,
74 },
75 isEnabled: {
76 label: intl.formatMessage(messages.enableService),
77 value: service.isEnabled,
78 default: true,
79 },
80 isNotificationEnabled: {
81 label: intl.formatMessage(messages.enableNotification),
82 value: service.isNotificationEnabled,
83 default: true,
84 },
85 },
86 };
87
88 if (recipe.hasTeamId) {
89 Object.assign(config.fields, {
90 team: {
91 label: intl.formatMessage(messages.team),
92 placeholder: intl.formatMessage(messages.team),
93 value: service.team,
94 validate: [required],
95 },
96 });
97 }
98
99 if (recipe.hasCustomUrl) {
100 Object.assign(config.fields, {
101 customUrl: {
102 label: intl.formatMessage(messages.customUrl),
103 placeholder: 'https://',
104 value: service.customUrl,
105 validate: [required, url],
106 },
107 });
108 }
109
110 if (recipe.hasTeamId && recipe.hasCustomUrl) {
111 config.fields.team.validate = [oneRequired(['team', 'customUrl'])];
112 config.fields.customUrl.validate = [url, oneRequired(['team', 'customUrl'])];
113 }
114
115 if (recipe.hasIndirectMessages) {
116 Object.assign(config.fields, {
117 isIndirectMessageBadgeEnabled: {
118 label: intl.formatMessage(messages.indirectMessages),
119 value: service.isIndirectMessageBadgeEnabled,
120 default: true,
121 },
122 });
123 }
124
125 return new Form(config);
126 }
127
128 deleteService() {
129 const { deleteService } = this.props.actions.service;
130 const { action } = this.props.router.params;
131
132 if (action === 'edit') {
133 const { activeSettings: service } = this.props.stores.services;
134 deleteService({
135 serviceId: service.id,
136 redirect: '/settings/services',
137 });
138 }
139 }
140
141 render() {
142 const { recipes, services, user } = this.props.stores;
143 const { action } = this.props.router.params;
144
145 let recipe;
146 let service = {};
147 let isLoading = false;
148
149 if (action === 'add') {
150 recipe = recipes.active;
151
152 // TODO: render error message when recipe is `null`
153 if (!recipe) {
154 return (
155 <ServiceError />
156 );
157 }
158 } else {
159 service = services.activeSettings;
160 isLoading = services.allServicesRequest.isExecuting;
161
162 if (!isLoading && service) {
163 recipe = service.recipe;
164 }
165 }
166
167 if (isLoading) {
168 return (<div>Loading...</div>);
169 }
170
171 const form = this.prepareForm(recipe, service);
172
173 return (
174 <EditServiceForm
175 action={action}
176 recipe={recipe}
177 service={service}
178 user={user.data}
179 form={form}
180 status={services.actionStatus}
181 isSaving={services.updateServiceRequest.isExecuting || services.createServiceRequest.isExecuting}
182 isDeleting={services.deleteServiceRequest.isExecuting}
183 onSubmit={d => this.onSubmit(d)}
184 onDelete={() => this.deleteService()}
185 />
186 );
187 }
188}
189
190EditServiceScreen.wrappedComponent.propTypes = {
191 stores: PropTypes.shape({
192 user: PropTypes.instanceOf(UserStore).isRequired,
193 recipes: PropTypes.instanceOf(RecipesStore).isRequired,
194 services: PropTypes.instanceOf(ServicesStore).isRequired,
195 }).isRequired,
196 router: PropTypes.shape({
197 params: PropTypes.shape({
198 action: PropTypes.string.isRequired,
199 }).isRequired,
200 }).isRequired,
201 actions: PropTypes.shape({
202 service: PropTypes.shape({
203 createService: PropTypes.func.isRequired,
204 updateService: PropTypes.func.isRequired,
205 deleteService: PropTypes.func.isRequired,
206 }).isRequired,
207 }).isRequired,
208};
diff --git a/src/containers/settings/EditSettingsScreen.js b/src/containers/settings/EditSettingsScreen.js
new file mode 100644
index 000000000..0e17cafce
--- /dev/null
+++ b/src/containers/settings/EditSettingsScreen.js
@@ -0,0 +1,167 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import AppStore from '../../stores/AppStore';
7import SettingsStore from '../../stores/SettingsStore';
8import UserStore from '../../stores/UserStore';
9import Form from '../../lib/Form';
10import languages from '../../i18n/languages';
11import { gaPage } from '../../lib/analytics';
12
13
14import EditSettingsForm from '../../components/settings/settings/EditSettingsForm';
15
16const messages = defineMessages({
17 autoLaunchOnStart: {
18 id: 'settings.app.form.autoLaunchOnStart',
19 defaultMessage: '!!!Launch Franz on start',
20 },
21 autoLaunchInBackground: {
22 id: 'settings.app.form.autoLaunchInBackground',
23 defaultMessage: '!!!Open in background',
24 },
25 runInBackground: {
26 id: 'settings.app.form.runInBackground',
27 defaultMessage: '!!!Keep Franz in background when closing the window',
28 },
29 minimizeToSystemTray: {
30 id: 'settings.app.form.minimizeToSystemTray',
31 defaultMessage: '!!!Minimize Franz to system tray',
32 },
33 language: {
34 id: 'settings.app.form.language',
35 defaultMessage: '!!!Language',
36 },
37 beta: {
38 id: 'settings.app.form.beta',
39 defaultMessage: '!!!Include beta versions',
40 },
41});
42
43@inject('stores', 'actions') @observer
44export default class EditSettingsScreen extends Component {
45 static contextTypes = {
46 intl: intlShape,
47 };
48
49 componentDidMount() {
50 gaPage('Settings/App');
51 }
52
53 onSubmit(settingsData) {
54 const { app, settings, user } = this.props.actions;
55
56 app.launchOnStartup({
57 enable: settingsData.autoLaunchOnStart,
58 openInBackground: settingsData.autoLaunchInBackground,
59 });
60
61 settings.update({
62 settings: {
63 runInBackground: settingsData.runInBackground,
64 minimizeToSystemTray: settingsData.minimizeToSystemTray,
65 locale: settingsData.locale,
66 beta: settingsData.beta,
67 },
68 });
69
70 user.update({
71 userData: {
72 beta: settingsData.beta,
73 },
74 });
75 }
76
77 prepareForm() {
78 const { app, settings, user } = this.props.stores;
79 const { intl } = this.context;
80
81 const options = [];
82 Object.keys(languages).forEach((key) => {
83 options.push({
84 value: key,
85 label: languages[key],
86 });
87 });
88
89 const config = {
90 fields: {
91 autoLaunchOnStart: {
92 label: intl.formatMessage(messages.autoLaunchOnStart),
93 value: app.autoLaunchOnStart,
94 default: true,
95 },
96 autoLaunchInBackground: {
97 label: intl.formatMessage(messages.autoLaunchInBackground),
98 value: app.launchInBackground,
99 default: false,
100 },
101 runInBackground: {
102 label: intl.formatMessage(messages.runInBackground),
103 value: settings.all.runInBackground,
104 default: true,
105 },
106 minimizeToSystemTray: {
107 label: intl.formatMessage(messages.minimizeToSystemTray),
108 value: settings.all.minimizeToSystemTray,
109 default: false,
110 },
111 locale: {
112 label: intl.formatMessage(messages.language),
113 value: app.locale,
114 options,
115 default: 'en-US',
116 },
117 beta: {
118 label: intl.formatMessage(messages.beta),
119 value: user.data.beta,
120 default: false,
121 },
122 },
123 };
124
125 return new Form(config);
126 }
127
128 render() {
129 const { updateStatus, updateStatusTypes } = this.props.stores.app;
130 const { checkForUpdates, installUpdate } = this.props.actions.app;
131 const form = this.prepareForm();
132
133 return (
134 <EditSettingsForm
135 form={form}
136 checkForUpdates={checkForUpdates}
137 installUpdate={installUpdate}
138 isCheckingForUpdates={updateStatus === updateStatusTypes.CHECKING}
139 isUpdateAvailable={updateStatus === updateStatusTypes.AVAILABLE}
140 noUpdateAvailable={updateStatus === updateStatusTypes.NOT_AVAILABLE}
141 updateIsReadyToInstall={updateStatus === updateStatusTypes.DOWNLOADED}
142 onSubmit={d => this.onSubmit(d)}
143 />
144 );
145 }
146}
147
148EditSettingsScreen.wrappedComponent.propTypes = {
149 stores: PropTypes.shape({
150 app: PropTypes.instanceOf(AppStore).isRequired,
151 user: PropTypes.instanceOf(UserStore).isRequired,
152 settings: PropTypes.instanceOf(SettingsStore).isRequired,
153 }).isRequired,
154 actions: PropTypes.shape({
155 app: PropTypes.shape({
156 launchOnStartup: PropTypes.func.isRequired,
157 checkForUpdates: PropTypes.func.isRequired,
158 installUpdate: PropTypes.func.isRequired,
159 }).isRequired,
160 settings: PropTypes.shape({
161 update: PropTypes.func.isRequired,
162 }).isRequired,
163 user: PropTypes.shape({
164 update: PropTypes.func.isRequired,
165 }).isRequired,
166 }).isRequired,
167};
diff --git a/src/containers/settings/EditUserScreen.js b/src/containers/settings/EditUserScreen.js
new file mode 100644
index 000000000..fb5c5db89
--- /dev/null
+++ b/src/containers/settings/EditUserScreen.js
@@ -0,0 +1,165 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5
6import UserStore from '../../stores/UserStore';
7import Form from '../../lib/Form';
8import EditUserForm from '../../components/settings/user/EditUserForm';
9import { required, email, minLength } from '../../helpers/validation-helpers';
10import { gaPage } from '../../lib/analytics';
11
12const messages = defineMessages({
13 firstname: {
14 id: 'settings.user.form.firstname',
15 defaultMessage: '!!!Firstname',
16 },
17 lastname: {
18 id: 'settings.user.form.lastname',
19 defaultMessage: '!!!Lastname',
20 },
21 email: {
22 id: 'settings.user.form.email',
23 defaultMessage: '!!!Email',
24 },
25 accountType: {
26 label: {
27 id: 'settings.user.form.accountType.label',
28 defaultMessage: '!!!Account type',
29 },
30 individual: {
31 id: 'settings.user.form.accountType.individual',
32 defaultMessage: '!!!Individual',
33 },
34 nonProfit: {
35 id: 'settings.user.form.accountType.non-profit',
36 defaultMessage: '!!!Non-Profit',
37 },
38 company: {
39 id: 'settings.user.form.accountType.company',
40 defaultMessage: '!!!Company',
41 },
42 },
43 currentPassword: {
44 id: 'settings.user.form.currentPassword',
45 defaultMessage: '!!!Current password',
46 },
47 newPassword: {
48 id: 'settings.user.form.newPassword',
49 defaultMessage: '!!!New password',
50 },
51});
52
53@inject('stores', 'actions') @observer
54export default class EditUserScreen extends Component {
55 static contextTypes = {
56 intl: intlShape,
57 };
58
59 componentDidMount() {
60 gaPage('Settings/Account/Edit');
61 }
62
63 componentWillUnmount() {
64 this.props.actions.user.resetStatus();
65 }
66
67 onSubmit(userData) {
68 const { update } = this.props.actions.user;
69
70 update({ userData });
71
72 document.querySelector('#form').scrollIntoView({ behavior: 'smooth' });
73 }
74
75 prepareForm(user) {
76 const { intl } = this.context;
77
78 const config = {
79 fields: {
80 firstname: {
81 label: intl.formatMessage(messages.firstname),
82 placeholder: intl.formatMessage(messages.firstname),
83 value: user.firstname,
84 validate: [required],
85 },
86 lastname: {
87 label: intl.formatMessage(messages.lastname),
88 placeholder: intl.formatMessage(messages.lastname),
89 value: user.lastname,
90 validate: [required],
91 },
92 email: {
93 label: intl.formatMessage(messages.email),
94 placeholder: intl.formatMessage(messages.email),
95 value: user.email,
96 validate: [required, email],
97 },
98 accountType: {
99 value: user.accountType,
100 validate: [required],
101 label: intl.formatMessage(messages.accountType.label),
102 options: [{
103 value: 'individual',
104 label: intl.formatMessage(messages.accountType.individual),
105 }, {
106 value: 'non-profit',
107 label: intl.formatMessage(messages.accountType.nonProfit),
108 }, {
109 value: 'company',
110 label: intl.formatMessage(messages.accountType.company),
111 }],
112 },
113 organization: {
114 label: intl.formatMessage(messages.accountType.company),
115 placeholder: intl.formatMessage(messages.accountType.company),
116 value: user.organization,
117 },
118 oldPassword: {
119 label: intl.formatMessage(messages.currentPassword),
120 type: 'password',
121 validate: [minLength(6)],
122 },
123 newPassword: {
124 label: intl.formatMessage(messages.newPassword),
125 type: 'password',
126 validate: [minLength(6)],
127 },
128 },
129 };
130
131 return new Form(config);
132 }
133
134 render() {
135 const { user } = this.props.stores;
136
137 if (user.getUserInfoRequest.isExecuting) {
138 return (<div>Loading...</div>);
139 }
140
141 const form = this.prepareForm(user.data);
142
143 return (
144 <EditUserForm
145 // user={user.data}
146 status={user.actionStatus}
147 form={form}
148 isSaving={user.updateUserInfoRequest.isExecuting}
149 onSubmit={d => this.onSubmit(d)}
150 />
151 );
152 }
153}
154
155EditUserScreen.wrappedComponent.propTypes = {
156 stores: PropTypes.shape({
157 user: PropTypes.instanceOf(UserStore).isRequired,
158 }).isRequired,
159 actions: PropTypes.shape({
160 user: PropTypes.shape({
161 update: PropTypes.func.isRequired,
162 resetStatus: PropTypes.func.isRequired,
163 }).isRequired,
164 }).isRequired,
165};
diff --git a/src/containers/settings/RecipesScreen.js b/src/containers/settings/RecipesScreen.js
new file mode 100644
index 000000000..65341e9e3
--- /dev/null
+++ b/src/containers/settings/RecipesScreen.js
@@ -0,0 +1,126 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { autorun } from 'mobx';
4import { inject, observer } from 'mobx-react';
5
6import RecipePreviewsStore from '../../stores/RecipePreviewsStore';
7import RecipeStore from '../../stores/RecipesStore';
8import ServiceStore from '../../stores/ServicesStore';
9import UserStore from '../../stores/UserStore';
10import { gaPage } from '../../lib/analytics';
11
12import RecipesDashboard from '../../components/settings/recipes/RecipesDashboard';
13
14@inject('stores', 'actions') @observer
15export default class RecipesScreen extends Component {
16 static propTypes = {
17 params: PropTypes.shape({
18 filter: PropTypes.string,
19 }).isRequired,
20 };
21
22 static defaultProps = {
23 params: {
24 filter: null,
25 },
26 };
27
28 state = {
29 needle: null,
30 currentFilter: 'featured',
31 };
32
33 componentDidMount() {
34 gaPage('Settings/Recipe Dashboard/Featured');
35
36 autorun(() => {
37 const { filter } = this.props.params;
38 const { currentFilter } = this.state;
39
40 if (filter === 'all' && currentFilter !== 'all') {
41 gaPage('Settings/Recipe Dashboard/All');
42 this.setState({ currentFilter: 'all' });
43 } else if (filter === 'featured' && currentFilter !== 'featured') {
44 gaPage('Settings/Recipe Dashboard/Featured');
45 this.setState({ currentFilter: 'featured' });
46 } else if (filter === 'dev' && currentFilter !== 'dev') {
47 gaPage('Settings/Recipe Dashboard/Dev');
48 this.setState({ currentFilter: 'dev' });
49 }
50 });
51 }
52
53 componentWillUnmount() {
54 this.props.stores.services.resetStatus();
55 }
56
57 searchRecipes(needle) {
58 if (needle === '') {
59 this.resetSearch();
60 } else {
61 const { search } = this.props.actions.recipePreview;
62 this.setState({ needle });
63 search({ needle });
64 }
65 }
66
67 resetSearch() {
68 this.setState({ needle: null });
69 }
70
71 render() {
72 const { recipePreviews, recipes, services, user } = this.props.stores;
73 const { showAddServiceInterface } = this.props.actions.service;
74
75 const { filter } = this.props.params;
76 let recipeFilter;
77
78 if (filter === 'all') {
79 recipeFilter = recipePreviews.all;
80 } else if (filter === 'dev') {
81 recipeFilter = recipePreviews.dev;
82 } else {
83 recipeFilter = recipePreviews.featured;
84 }
85
86 const allRecipes = this.state.needle ? recipePreviews.searchResults : recipeFilter;
87
88 const isLoading = recipePreviews.featuredRecipePreviewsRequest.isExecuting
89 || recipePreviews.allRecipePreviewsRequest.isExecuting
90 || recipes.installRecipeRequest.isExecuting
91 || recipePreviews.searchRecipePreviewsRequest.isExecuting;
92
93 return (
94 <RecipesDashboard
95 recipes={allRecipes}
96 isLoading={isLoading}
97 addedServiceCount={services.all.length}
98 isPremium={user.data.isPremium}
99 hasLoadedRecipes={recipePreviews.featuredRecipePreviewsRequest.wasExecuted}
100 showAddServiceInterface={showAddServiceInterface}
101 searchRecipes={e => this.searchRecipes(e)}
102 resetSearch={() => this.resetSearch()}
103 searchNeedle={this.state.needle}
104 serviceStatus={services.actionStatus}
105 devRecipesCount={recipePreviews.dev.length}
106 />
107 );
108 }
109}
110
111RecipesScreen.wrappedComponent.propTypes = {
112 stores: PropTypes.shape({
113 recipePreviews: PropTypes.instanceOf(RecipePreviewsStore).isRequired,
114 recipes: PropTypes.instanceOf(RecipeStore).isRequired,
115 services: PropTypes.instanceOf(ServiceStore).isRequired,
116 user: PropTypes.instanceOf(UserStore).isRequired,
117 }).isRequired,
118 actions: PropTypes.shape({
119 service: PropTypes.shape({
120 showAddServiceInterface: PropTypes.func.isRequired,
121 }).isRequired,
122 recipePreview: PropTypes.shape({
123 search: PropTypes.func.isRequired,
124 }).isRequired,
125 }).isRequired,
126};
diff --git a/src/containers/settings/ServicesScreen.js b/src/containers/settings/ServicesScreen.js
new file mode 100644
index 000000000..d0580041f
--- /dev/null
+++ b/src/containers/settings/ServicesScreen.js
@@ -0,0 +1,75 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { RouterStore } from 'mobx-react-router';
5
6// import RecipePreviewsStore from '../../stores/RecipePreviewsStore';
7import UserStore from '../../stores/UserStore';
8import ServiceStore from '../../stores/ServicesStore';
9import { gaPage } from '../../lib/analytics';
10
11import ServicesDashboard from '../../components/settings/services/ServicesDashboard';
12
13@inject('stores', 'actions') @observer
14export default class ServicesScreen extends Component {
15 componentDidMount() {
16 gaPage('Settings/Service Dashboard');
17 }
18
19 componentWillUnmount() {
20 this.props.actions.service.resetFilter();
21 }
22
23 deleteService() {
24 this.props.actions.service.deleteService();
25 this.props.stores.services.resetFilter();
26 }
27
28 render() {
29 const { user, services, router } = this.props.stores;
30 const {
31 toggleService,
32 filter,
33 resetFilter,
34 } = this.props.actions.service;
35 const isLoading = services.allServicesRequest.isExecuting;
36
37 let allServices = services.all;
38 if (services.filterNeedle !== null) {
39 allServices = services.filtered;
40 }
41
42 return (
43 <ServicesDashboard
44 user={user.data}
45 services={allServices}
46 status={services.actionStatus}
47 deleteService={() => this.deleteService()}
48 toggleService={toggleService}
49 isLoading={isLoading}
50 filterServices={filter}
51 resetFilter={resetFilter}
52 goTo={router.push}
53 servicesRequestFailed={services.allServicesRequest.wasExecuted && services.allServicesRequest.isError}
54 retryServicesRequest={() => services.allServicesRequest.reload()}
55 />
56 );
57 }
58}
59
60ServicesScreen.wrappedComponent.propTypes = {
61 stores: PropTypes.shape({
62 user: PropTypes.instanceOf(UserStore).isRequired,
63 services: PropTypes.instanceOf(ServiceStore).isRequired,
64 router: PropTypes.instanceOf(RouterStore).isRequired,
65 }).isRequired,
66 actions: PropTypes.shape({
67 service: PropTypes.shape({
68 showAddServiceInterface: PropTypes.func.isRequired,
69 deleteService: PropTypes.func.isRequired,
70 toggleService: PropTypes.func.isRequired,
71 filter: PropTypes.func.isRequired,
72 resetFilter: PropTypes.func.isRequired,
73 }).isRequired,
74 }).isRequired,
75};
diff --git a/src/containers/settings/SettingsWindow.js b/src/containers/settings/SettingsWindow.js
new file mode 100644
index 000000000..13ca96f72
--- /dev/null
+++ b/src/containers/settings/SettingsWindow.js
@@ -0,0 +1,43 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer, inject } from 'mobx-react';
4
5import ServicesStore from '../../stores/ServicesStore';
6
7import Layout from '../../components/settings/SettingsLayout';
8import Navigation from '../../components/settings/navigation/SettingsNavigation';
9
10@inject('stores', 'actions') @observer
11export default class SettingsContainer extends Component {
12 render() {
13 const { children, stores } = this.props;
14 const { closeSettings } = this.props.actions.ui;
15
16 const navigation = (
17 <Navigation
18 serviceCount={stores.services.all.length}
19 />
20 );
21
22 return (
23 <Layout
24 navigation={navigation}
25 closeSettings={closeSettings}
26 >
27 {children}
28 </Layout>
29 );
30 }
31}
32
33SettingsContainer.wrappedComponent.propTypes = {
34 children: PropTypes.element.isRequired,
35 stores: PropTypes.shape({
36 services: PropTypes.instanceOf(ServicesStore).isRequired,
37 }).isRequired,
38 actions: PropTypes.shape({
39 ui: PropTypes.shape({
40 closeSettings: PropTypes.func.isRequired,
41 }),
42 }).isRequired,
43};
diff --git a/src/containers/ui/SubscriptionFormScreen.js b/src/containers/ui/SubscriptionFormScreen.js
new file mode 100644
index 000000000..d08507809
--- /dev/null
+++ b/src/containers/ui/SubscriptionFormScreen.js
@@ -0,0 +1,126 @@
1import { remote } from 'electron';
2import React, { Component } from 'react';
3import PropTypes from 'prop-types';
4import { inject, observer } from 'mobx-react';
5
6import PaymentStore from '../../stores/PaymentStore';
7
8import SubscriptionForm from '../../components/ui/Subscription';
9
10const { BrowserWindow } = remote;
11
12@inject('stores', 'actions') @observer
13export default class SubscriptionFormScreen extends Component {
14 static propTypes = {
15 onCloseWindow: PropTypes.func,
16 content: PropTypes.oneOrManyChildElements,
17 showSkipOption: PropTypes.bool,
18 skipAction: PropTypes.func,
19 skipButtonLabel: PropTypes.string,
20 hideInfo: PropTypes.bool,
21 }
22
23 static defaultProps = {
24 onCloseWindow: () => null,
25 content: '',
26 showSkipOption: false,
27 skipAction: () => null,
28 skipButtonLabel: '',
29 hideInfo: false,
30 }
31
32 async handlePayment(plan) {
33 const {
34 actions,
35 stores,
36 onCloseWindow,
37 skipAction,
38 } = this.props;
39
40 if (plan !== 'mining') {
41 const interval = plan;
42
43 const { id } = stores.payment.plan[interval];
44 actions.payment.createHostedPage({
45 planId: id,
46 });
47
48 const hostedPage = await stores.payment.createHostedPageRequest;
49 const url = `file://${__dirname}/../../index.html#/payment/${encodeURIComponent(hostedPage.url)}`;
50
51 if (hostedPage.url) {
52 const paymentWindow = new BrowserWindow({
53 parent: remote.getCurrentWindow(),
54 modal: true,
55 title: '🔒 Franz Supporter License',
56 width: 600,
57 height: window.innerHeight - 100,
58 maxWidth: 600,
59 minWidth: 600,
60 webPreferences: {
61 nodeIntegration: true,
62 },
63 });
64 paymentWindow.loadURL(url);
65
66 paymentWindow.on('closed', () => {
67 onCloseWindow();
68 });
69 }
70 } else {
71 actions.user.update({
72 userData: {
73 isMiner: true,
74 },
75 });
76
77 skipAction();
78 }
79 }
80
81 render() {
82 const {
83 content,
84 actions,
85 stores,
86 showSkipOption,
87 skipAction,
88 skipButtonLabel,
89 hideInfo,
90 } = this.props;
91 return (
92 <SubscriptionForm
93 plan={stores.payment.plan}
94 // form={this.prepareForm(stores.payment.plan)}
95 isLoading={stores.payment.plansRequest.isExecuting}
96 retryPlanRequest={() => stores.payment.plansRequest.reload()}
97 isCreatingHostedPage={stores.payment.createHostedPageRequest.isExecuting}
98 handlePayment={price => this.handlePayment(price)}
99 content={content}
100 error={stores.payment.plansRequest.isError}
101 showSkipOption={showSkipOption}
102 skipAction={skipAction}
103 skipButtonLabel={skipButtonLabel}
104 hideInfo={hideInfo}
105 openExternalUrl={actions.app.openExternalUrl}
106 />
107 );
108 }
109}
110
111SubscriptionFormScreen.wrappedComponent.propTypes = {
112 actions: PropTypes.shape({
113 app: PropTypes.shape({
114 openExternalUrl: PropTypes.func.isRequired,
115 }).isRequired,
116 payment: PropTypes.shape({
117 createHostedPage: PropTypes.func.isRequired,
118 }).isRequired,
119 user: PropTypes.shape({
120 update: PropTypes.func.isRequired,
121 }).isRequired,
122 }).isRequired,
123 stores: PropTypes.shape({
124 payment: PropTypes.instanceOf(PaymentStore).isRequired,
125 }).isRequired,
126};
diff --git a/src/containers/ui/SubscriptionPopupScreen.js b/src/containers/ui/SubscriptionPopupScreen.js
new file mode 100644
index 000000000..d17477b1d
--- /dev/null
+++ b/src/containers/ui/SubscriptionPopupScreen.js
@@ -0,0 +1,43 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4
5import SubscriptionPopup from '../../components/ui/SubscriptionPopup';
6
7
8@inject('stores', 'actions') @observer
9export default class SubscriptionPopupScreen extends Component {
10 state = {
11 complete: false,
12 };
13
14 completeCheck(event) {
15 const { url } = event;
16
17 if (url.includes('recurly') && url.includes('confirmation')) {
18 this.setState({
19 complete: true,
20 });
21 }
22 }
23
24 render() {
25 return (
26 <SubscriptionPopup
27 url={decodeURIComponent(this.props.router.params.url)}
28 closeWindow={() => window.close()}
29 completeCheck={e => this.completeCheck(e)}
30 isCompleted={this.state.complete}
31 />
32 );
33 }
34}
35
36
37SubscriptionPopupScreen.wrappedComponent.propTypes = {
38 router: PropTypes.shape({
39 params: PropTypes.shape({
40 url: PropTypes.string.isRequired,
41 }).isRequired,
42 }).isRequired,
43};
diff --git a/src/electron/Settings.js b/src/electron/Settings.js
new file mode 100644
index 000000000..049a08296
--- /dev/null
+++ b/src/electron/Settings.js
@@ -0,0 +1,15 @@
1export default class Settings {
2 store = {};
3
4 set(settings) {
5 this.store = Object.assign(this.store, settings);
6 }
7
8 all() {
9 return this.store;
10 }
11
12 get(key) {
13 return this.store[key];
14 }
15}
diff --git a/src/electron/exception.js b/src/electron/exception.js
new file mode 100644
index 000000000..0065e2604
--- /dev/null
+++ b/src/electron/exception.js
@@ -0,0 +1,4 @@
1process.on('uncaughtException', (err) => {
2 // handle the error safely
3 console.error(err);
4});
diff --git a/src/electron/ipc-api/appIndicator.js b/src/electron/ipc-api/appIndicator.js
new file mode 100644
index 000000000..576234d25
--- /dev/null
+++ b/src/electron/ipc-api/appIndicator.js
@@ -0,0 +1,80 @@
1import { app, ipcMain, Tray, Menu } from 'electron';
2import path from 'path';
3
4const INDICATOR_TRAY_PLAIN = 'tray';
5const INDICATOR_TRAY_UNREAD = 'tray-unread';
6const INDICATOR_TASKBAR = 'taskbar';
7
8const FILE_EXTENSION = process.platform === 'win32' ? 'ico' : 'png';
9let trayIcon;
10
11function getAsset(type, asset) {
12 return path.join(
13 __dirname, '..', '..', 'assets', 'images', type, process.platform, `${asset}.${FILE_EXTENSION}`,
14 );
15}
16
17export default (params) => {
18 trayIcon = new Tray(getAsset('tray', INDICATOR_TRAY_PLAIN));
19 const trayMenuTemplate = [
20 {
21 label: 'Show Franz',
22 click() {
23 params.mainWindow.show();
24 },
25 }, {
26 label: 'Quit Franz',
27 click() {
28 app.quit();
29 },
30 },
31 ];
32
33 const trayMenu = Menu.buildFromTemplate(trayMenuTemplate);
34 trayIcon.setContextMenu(trayMenu);
35
36 trayIcon.on('click', () => {
37 params.mainWindow.show();
38 });
39
40 ipcMain.on('updateAppIndicator', (event, args) => {
41 // Update badge
42 if (process.platform === 'darwin'
43 && typeof (args.indicator) === 'string') {
44 app.dock.setBadge(args.indicator);
45 }
46
47 if ((process.platform === 'darwin'
48 || process.platform === 'linux')
49 && typeof (args.indicator) === 'number'
50 ) {
51 app.setBadgeCount(args.indicator);
52 }
53
54 if (process.platform === 'win32') {
55 if (typeof args.indicator === 'number'
56 && args.indicator !== 0) {
57 params.mainWindow.setOverlayIcon(
58 getAsset('taskbar', `${INDICATOR_TASKBAR}-${(args.indicator >= 10 ? 10 : args.indicator)}`),
59 '',
60 );
61 } else if (typeof args.indicator === 'string') {
62 params.mainWindow.setOverlayIcon(
63 getAsset('taskbar', `${INDICATOR_TASKBAR}-alert`),
64 '',
65 );
66 } else {
67 params.mainWindow.setOverlayIcon(null, '');
68 }
69 }
70
71 // Update system tray
72 trayIcon.setImage(getAsset('tray', args.indicator !== 0 ? INDICATOR_TRAY_UNREAD : INDICATOR_TRAY_PLAIN));
73
74 if (process.platform === 'darwin') {
75 trayIcon.setPressedImage(
76 getAsset('tray', `${args.indicator !== 0 ? INDICATOR_TRAY_UNREAD : INDICATOR_TRAY_PLAIN}-active`),
77 );
78 }
79 });
80};
diff --git a/src/electron/ipc-api/autoUpdate.js b/src/electron/ipc-api/autoUpdate.js
new file mode 100644
index 000000000..7bc193e2d
--- /dev/null
+++ b/src/electron/ipc-api/autoUpdate.js
@@ -0,0 +1,54 @@
1import { app, ipcMain } from 'electron';
2import { autoUpdater } from 'electron-updater';
3
4export default (params) => {
5 if (process.platform === 'darwin' || process.platform === 'win32') {
6 // autoUpdater.setFeedURL(updateUrl);
7 ipcMain.on('autoUpdate', (event, args) => {
8 try {
9 autoUpdater.allowPrerelease = Boolean(params.settings.get('beta'));
10 if (args.action === 'check') {
11 autoUpdater.checkForUpdates();
12 } else if (args.action === 'install') {
13 console.log('install update');
14 autoUpdater.quitAndInstall();
15 // we need to send a quit event
16 setTimeout(() => {
17 app.quit();
18 }, 20);
19 }
20 } catch (e) {
21 console.error(e);
22 event.sender.send('autoUpdate', { error: true });
23 }
24 });
25
26 autoUpdater.on('update-not-available', () => {
27 console.log('update-not-available');
28 params.mainWindow.webContents.send('autoUpdate', { available: false });
29 });
30
31 autoUpdater.on('update-available', () => {
32 console.log('update-available');
33 params.mainWindow.webContents.send('autoUpdate', { available: true });
34 });
35
36 autoUpdater.on('download-progress', (progressObj) => {
37 let logMessage = `Download speed: ${progressObj.bytesPerSecond}`;
38 logMessage = `${logMessage} - Downloaded ${progressObj.percent}%`;
39 logMessage = `${logMessage} (${progressObj.transferred}/${progressObj.total})`;
40
41 console.log(logMessage);
42 });
43
44 autoUpdater.on('update-downloaded', () => {
45 console.log('update-downloaded');
46 params.mainWindow.webContents.send('autoUpdate', { downloaded: true });
47 });
48
49 autoUpdater.on('error', () => {
50 console.log('update-error');
51 params.mainWindow.webContents.send('autoUpdate', { error: true });
52 });
53 }
54};
diff --git a/src/electron/ipc-api/index.js b/src/electron/ipc-api/index.js
new file mode 100644
index 000000000..4ea6d1475
--- /dev/null
+++ b/src/electron/ipc-api/index.js
@@ -0,0 +1,9 @@
1import autoUpdate from './autoUpdate';
2import settings from './settings';
3import appIndicator from './appIndicator';
4
5export default (params) => {
6 settings(params);
7 autoUpdate(params);
8 appIndicator(params);
9};
diff --git a/src/electron/ipc-api/settings.js b/src/electron/ipc-api/settings.js
new file mode 100644
index 000000000..1d7eafa6c
--- /dev/null
+++ b/src/electron/ipc-api/settings.js
@@ -0,0 +1,10 @@
1import { ipcMain } from 'electron';
2
3export default (params) => {
4 if (process.platform === 'darwin' || process.platform === 'win32') {
5 // eslint-disable-next-line
6 ipcMain.on('settings', (event, args) => {
7 params.settings.set(args);
8 });
9 }
10};
diff --git a/src/electron/ipc-api/tray.js b/src/electron/ipc-api/tray.js
new file mode 100644
index 000000000..43364c0ed
--- /dev/null
+++ b/src/electron/ipc-api/tray.js
@@ -0,0 +1,48 @@
1import { Tray, Menu, ipcMain } from 'electron';
2import path from 'path';
3
4const INDICATOR_PLAIN = 'franz-taskbar';
5const INDICATOR_UNREAD = 'franz-taskbar-unread';
6
7const FILE_EXTENSION = process.platform === 'win32' ? 'ico' : 'png';
8
9let trayIcon;
10
11function getAsset(asset) {
12 return path.join(
13 __dirname, '..', '..', 'assets', 'images', 'tray', process.platform, `${asset}.${FILE_EXTENSION}`,
14 );
15}
16
17export default (params) => {
18 // if (process.platform === 'win32' || process.platform === 'linux') {
19 trayIcon = new Tray(getAsset(INDICATOR_PLAIN));
20 const trayMenuTemplate = [
21 {
22 label: 'Show Franz',
23 click() {
24 params.mainWindow.show();
25 },
26 }, {
27 label: 'Quit Franz',
28 click() {
29 params.app.quit();
30 },
31 },
32 ];
33
34 const trayMenu = Menu.buildFromTemplate(trayMenuTemplate);
35 trayIcon.setContextMenu(trayMenu);
36
37 trayIcon.on('click', () => {
38 params.mainWindow.show();
39 });
40
41 ipcMain.on('updateTrayIconIndicator', (event, args) => {
42 trayIcon.setImage(getAsset(args.count !== 0 ? INDICATOR_UNREAD : INDICATOR_PLAIN));
43
44 if (process.platform === 'darwin') {
45 trayIcon.setPressedImage(getAsset(`${args.count !== 0 ? INDICATOR_UNREAD : INDICATOR_PLAIN}-active`));
46 }
47 });
48};
diff --git a/src/electron/webview-ime-focus.js b/src/electron/webview-ime-focus.js
new file mode 100644
index 000000000..1213b518e
--- /dev/null
+++ b/src/electron/webview-ime-focus.js
@@ -0,0 +1,40 @@
1const { releaseDocumentFocus } = require('./webview-ime-focus-helpers');
2
3function giveWebviewDocumentFocus(element) {
4 releaseDocumentFocus();
5
6 window.requestAnimationFrame(() => {
7 element.send('claim-document-focus');
8 });
9}
10
11function elementIsUnfocusedWebview(element) {
12 return element.tagName === 'WEBVIEW' && !element.getWebContents().isFocused();
13}
14
15function webviewDidAutofocus(element) {
16 function didKeyDown() {
17 element.removeEventListener('keydown', didKeyDown, true);
18 giveWebviewDocumentFocus(element);
19 }
20
21 element.addEventListener('keydown', didKeyDown, true);
22}
23
24function handleAutofocus(element) {
25 element.addEventListener('ipc-message', (event) => {
26 if (event.channel === 'autofocus') {
27 element.focus();
28 webviewDidAutofocus(element);
29 }
30 });
31}
32
33function didMouseDown(event) {
34 if (elementIsUnfocusedWebview(event.target)) {
35 giveWebviewDocumentFocus(event.target);
36 }
37}
38
39document.addEventListener('mousedown', didMouseDown, true);
40document.querySelectorAll('webview').forEach(handleAutofocus);
diff --git a/src/environment.js b/src/environment.js
new file mode 100644
index 000000000..e185120c0
--- /dev/null
+++ b/src/environment.js
@@ -0,0 +1,22 @@
1import { LIVE_API, DEV_API, LOCAL_API } from './config';
2
3export const isDevMode = Boolean(process.execPath.match(/[\\/]electron/));
4export const useLiveAPI = process.env.LIVE_API;
5export const useLocalAPI = process.env.LOCAL_API;
6
7export const isMac = process.platform === 'darwin';
8export const isWindows = process.platform === 'win32';
9export const isLinux = process.platform === 'linux';
10
11export const ctrlKey = isMac ? '⌘' : 'Ctrl';
12
13let api;
14if (!isDevMode || (isDevMode && useLiveAPI)) {
15 api = LIVE_API;
16} else if (isDevMode && useLocalAPI) {
17 api = LOCAL_API;
18} else {
19 api = DEV_API;
20}
21
22export const API = api;
diff --git a/src/helpers/password-helpers.js b/src/helpers/password-helpers.js
new file mode 100644
index 000000000..7aacaa4d0
--- /dev/null
+++ b/src/helpers/password-helpers.js
@@ -0,0 +1,36 @@
1import { SHA256 } from 'jshashes';
2
3export function hash(password) {
4 return new SHA256().b64(password);
5}
6
7export function scorePassword(password) {
8 let score = 0;
9 if (!password) {
10 return score;
11 }
12
13 // award every unique letter until 5 repetitions
14 const letters = {};
15 for (let i = 0; i < password.length; i += 1) {
16 letters[password[i]] = (letters[password[i]] || 0) + 1;
17 score += 5.0 / letters[password[i]];
18 }
19
20 // bonus points for mixing it up
21 const variations = {
22 digits: /\d/.test(password),
23 lower: /[a-z]/.test(password),
24 upper: /[A-Z]/.test(password),
25 nonWords: /\W/.test(password),
26 };
27
28 let variationCount = 0;
29 Object.keys(variations).forEach((key) => {
30 variationCount += (variations[key] === true) ? 1 : 0;
31 });
32
33 score += (variationCount - 1) * 10;
34
35 return parseInt(score, 10);
36}
diff --git a/src/helpers/recipe-helpers.js b/src/helpers/recipe-helpers.js
new file mode 100644
index 000000000..257e322fb
--- /dev/null
+++ b/src/helpers/recipe-helpers.js
@@ -0,0 +1,39 @@
1import path from 'path';
2import { remote } from 'electron';
3
4// import ServiceModel from '../models/Service';
5
6const app = remote.app;
7
8export function getRecipeDirectory(id = '') {
9 return path.join(app.getPath('userData'), 'recipes', id);
10}
11
12export function getDevRecipeDirectory(id = '') {
13 return path.join(app.getPath('userData'), 'recipes', 'dev', id);
14}
15
16export function loadRecipeConfig(recipeId) {
17 try {
18 const configPath = `${recipeId}/package.json`;
19 // Delete module from cache
20 delete require.cache[require.resolve(configPath)];
21
22 // eslint-disable-next-line
23 let config = require(configPath);
24
25 const moduleConfigPath = require.resolve(configPath);
26 const paths = path.parse(moduleConfigPath);
27 config.path = paths.dir;
28
29 return config;
30 } catch (e) {
31 console.error(e);
32 return null;
33 }
34}
35
36module.paths.unshift(
37 getDevRecipeDirectory(),
38 getRecipeDirectory(),
39);
diff --git a/src/helpers/routing-helpers.js b/src/helpers/routing-helpers.js
new file mode 100644
index 000000000..14922ebf3
--- /dev/null
+++ b/src/helpers/routing-helpers.js
@@ -0,0 +1,4 @@
1import RouteParser from 'route-parser';
2
3// eslint-disable-next-line
4export const matchRoute = (pattern, path) => new RouteParser(pattern).match(path);
diff --git a/src/helpers/validation-helpers.js b/src/helpers/validation-helpers.js
new file mode 100644
index 000000000..eeb12cab7
--- /dev/null
+++ b/src/helpers/validation-helpers.js
@@ -0,0 +1,48 @@
1export function required({ field }) {
2 const isValid = (field.value.trim() !== '');
3 return [isValid, `${field.label} is required`];
4}
5
6export function email({ field }) {
7 const value = field.value.trim();
8 let isValid = false;
9
10 if (value !== '') {
11 isValid = Boolean(value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}/i));
12 } else {
13 isValid = true;
14 }
15
16 return [isValid, `${field.label} is not a valid email address`];
17}
18
19export function url({ field }) {
20 const value = field.value.trim();
21 let isValid = false;
22
23 if (value !== '') {
24 // eslint-disable-next-line
25 isValid = Boolean(value.match(/(^|[\s.:;?\-\]<(])(https?:\/\/[-\w;/?:@&=+$|_.!~*|'()[\]%#,☺]+[\w/#](\(\))?)(?=$|[\s',|().:;?\-[\]>)])/i));
26 } else {
27 isValid = true;
28 }
29
30 return [isValid, `${field.label} is not a valid url`];
31}
32
33export function minLength(length) {
34 return ({ field }) => {
35 let isValid = true;
36 if (field.touched) {
37 isValid = field.value.length >= length;
38 }
39 return [isValid, `${field.label} should be at least ${length} characters long.`];
40 };
41}
42
43export function oneRequired(targets) {
44 return ({ field, form }) => {
45 const invalidFields = targets.filter(target => form.$(target).value === '');
46 return [targets.length !== invalidFields.length, `${field.label} is required`];
47 };
48}
diff --git a/src/helpers/webview-ime-focus-helpers.js b/src/helpers/webview-ime-focus-helpers.js
new file mode 100644
index 000000000..2593a5f26
--- /dev/null
+++ b/src/helpers/webview-ime-focus-helpers.js
@@ -0,0 +1,38 @@
1module.exports.releaseDocumentFocus = () => {
2 const element = document.createElement('span');
3 document.body.appendChild(element);
4
5 const range = document.createRange();
6 range.setStart(element, 0);
7
8 const selection = window.getSelection();
9 selection.removeAllRanges();
10 selection.addRange(range);
11 selection.removeAllRanges();
12
13 document.body.removeChild(element);
14};
15
16module.exports.claimDocumentFocus = () => {
17 const { activeElement } = document;
18 const selection = window.getSelection();
19
20 let selectionStart;
21 let selectionEnd;
22 let range;
23
24 if (activeElement) ({ selectionStart, selectionEnd } = activeElement);
25 if (selection.rangeCount) range = selection.getRangeAt(0);
26
27 const restoreOriginalSelection = () => {
28 if (selectionStart >= 0 && selectionEnd >= 0) {
29 activeElement.selectionStart = selectionStart;
30 activeElement.selectionEnd = selectionEnd;
31 } else if (range) {
32 selection.addRange(range);
33 }
34 };
35
36 exports.releaseDocumentFocus();
37 window.requestAnimationFrame(restoreOriginalSelection);
38};
diff --git a/src/i18n/globalMessages.js b/src/i18n/globalMessages.js
new file mode 100644
index 000000000..2c724ff6f
--- /dev/null
+++ b/src/i18n/globalMessages.js
@@ -0,0 +1,16 @@
1import { defineMessages } from 'react-intl';
2
3export default defineMessages({
4 upgradeAccount: {
5 id: 'global.premium.upgradeAccount',
6 defaultMessage: '!!!Please upgrade your account to add a new service.',
7 },
8 APIUnhealthy: {
9 id: 'global.api.unhealthy',
10 defaultMessage: '!!!Can\'t connect to Franz Online Services',
11 },
12 notConnectedToTheInternet: {
13 id: 'global.notConnectedToTheInternet',
14 defaultMessage: '!!!You are not connected to the internet.',
15 },
16});
diff --git a/src/i18n/languages.js b/src/i18n/languages.js
new file mode 100644
index 000000000..782853b43
--- /dev/null
+++ b/src/i18n/languages.js
@@ -0,0 +1,4 @@
1module.exports = {
2 'en-US': 'English',
3 // 'de-DE': 'Deutsch',
4};
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
new file mode 100644
index 000000000..194b8047c
--- /dev/null
+++ b/src/i18n/locales/en-US.json
@@ -0,0 +1,167 @@
1{
2 "global.api.unhealthy": "Can't connect to Franz online services",
3 "global.notConnectedToTheInternet": "You are not connected to the internet.",
4 "welcome.signupButton": "Create a free account",
5 "welcome.loginButton": "Login to your account",
6 "welcome.slogan": "Messaging that works for you",
7 "login.headline": "Sign in",
8 "login.email.label": "Email address",
9 "login.password.label": "Password",
10 "login.submit.label": "Sign in",
11 "login.invalidCredentials": "Email or password not valid",
12 "login.tokenExpired": "Your session expired, please login again.",
13 "login.serverLogout": "Your session expired, please login again.",
14 "login.link.signup": "Create a free account",
15 "login.link.password": "Reset password",
16 "password.headline": "Reset password",
17 "password.email.label": "Email address",
18 "password.submit.label": "Submit",
19 "password.noUser": "No user with that email address was found",
20 "password.successInfo": "Please check your email",
21 "password.link.signup": "Create a free account",
22 "password.link.login": "Sign in to your account",
23 "signup.headline": "Sign up",
24 "signup.firstname.label": "Firstname",
25 "signup.lastname.label": "Lastname",
26 "signup.email.label": "Email address",
27 "signup.company.label": "Company",
28 "signup.password.label": "Password",
29 "signup.submit.label": "Create account",
30 "signup.link.login": "Already have an account, sign in?",
31 "signup.emailDuplicate": "A user with that email address already exists",
32 "signup.legal.info": "By creating a Franz account you accept the",
33 "signup.legal.terms": "Terms of service",
34 "signup.legal.privacy": "Privacy Statement",
35 "pricing.headline": "Support Franz",
36 "pricing.support.label": "Select your support plan",
37 "pricing.submit.label": "I want to support the development of Franz",
38 "pricing.link.skipPayment": "I don't want to support the development of Franz.",
39 "import.headline": "Import your Franz 4 services",
40 "import.notSupportedHeadline": "Services not yet supported in Franz 5",
41 "import.submit.label": "Import services",
42 "import.skip.label": "I want add services manually",
43 "invite.submit.label": "Send invites",
44 "invite.headline.friends": "Invite 3 of your friends or colleagues",
45 "invite.name.label": "Name",
46 "invite.email.label": "Email address",
47 "invite.skip.label": "I want to do this later",
48 "subscription.submit.label": "I want to support the development of Franz",
49 "subscription.paymentSessionError": "Could not initialize payment form",
50 "subscription.includedFeatures": "Paid Franz Premium Supporter Account includes",
51 "subscription.features.onpremise": "Add on-premise/hosted services like HipChat",
52 "subscription.features.customServices": "Private services for you and your team",
53 "subscription.features.encryptedSync": "Encrypted session synchronization",
54 "subscription.features.vpn": "Proxy & VPN support",
55 "subscription.features.ads": "No ads, ever!",
56 "subscription.features.comingSoon": "coming soon",
57 "infobar.servicesUpdated": "Your services have been updated.",
58 "infobar.updateAvailable": "A new update for Franz is available.",
59 "infobar.buttonReloadServices": "Reload services",
60 "infobar.buttonInstallUpdate": "Restart & install update",
61 "infobar.requiredRequestsFailed": "Could not load services and user information",
62 "sidebar.settings": "Settings",
63 "services.welcome": "Welcome to Franz",
64 "services.getStarted": "Get started",
65 "settings.account.headline": "Account",
66 "settings.account.headlineSubscription": "Your subscription",
67 "settings.account.headlineUpgrade": "Upgrade your account & support Franz",
68 "settings.account.headlineInvoices": "Invoices",
69 "settings.account.manageSubscription.label": "Manage your subscription",
70 "settings.account.accountType.basic": "Basic Account",
71 "settings.account.accountType.premium": "Premium Supporter Account",
72 "settings.account.account.editButton": "Edit account",
73 "settings.account.invoiceDownload": "Download",
74 "settings.account.userInfoRequestFailed": "Could not load user information",
75 "settings.account.tryReloadUserInfoRequest": "Try again",
76 "settings.account.headlineProfile": "Update profile",
77 "settings.account.headlineAccount": "Account information",
78 "settings.account.headlinePassword": "Change password",
79 "settings.account.successInfo": "Your changes have been saved",
80 "settings.account.buttonSave": "Update profile",
81 "settings.account.mining.thankyou": "Thank you for supporting Franz with your processing power.",
82 "settings.account.mining.active": "You are right now performing {hashes} calculations per second.",
83 "settings.account.mining.moreInformation": "Get more information",
84 "settings.account.mining.cancel": "Cancel mining",
85 "settings.navigation.availableServices": "Available services",
86 "settings.navigation.yourServices": "Your services",
87 "settings.navigation.account": "Account",
88 "settings.navigation.settings": "Settings",
89 "settings.navigation.logout": "Logout",
90 "settings.recipes.headline": "Available services",
91 "settings.recipes.mostPopular": "Most popular",
92 "settings.recipes.all": "All services",
93 "settings.recipes.dev": "Development",
94 "settings.recipes.nothingFound": "Sorry, but no service matched your search term.",
95 "settings.recipes.servicesSuccessfulAddedInfo": "Service successfully added",
96 "settings.service.form.saveButton": "Save service",
97 "settings.service.form.deleteButton": "Delete service",
98 "settings.service.form.availableServices": "Available services",
99 "settings.service.form.yourServices": "Your services",
100 "settings.service.form.addServiceHeadline": "Add {name}",
101 "settings.service.form.editServiceHeadline": "Edit {name}",
102 "settings.service.form.tabHosted": "Hosted",
103 "settings.service.form.tabOnPremise": "Self hosted ⭐️",
104 "settings.service.form.customUrlValidationError": "Could not validate custom {name} server.",
105 "settings.service.form.customUrlPremiumInfo": "To add self hosted services, you need a Franz Premium Supporter Account.",
106 "settings.service.form.customUrlUpgradeAccount": "Upgrade your account",
107 "settings.service.form.indirectMessageInfo": "You will be notified about all new messages in a channel, not just @username, @channel, @here, ...",
108 "settings.service.error.headline": "Error",
109 "settings.service.error.goBack": "Back to services",
110 "settings.service.error.message": "Could not load service recipe.",
111 "settings.services.tooltip.isDisabled": "Service is disabled",
112 "settings.services.tooltip.notificationsDisabled": "Notifications are disabled",
113 "settings.services.headline": "Your services",
114 "settings.services.noServicesAdded": "You haven't added any services yet.",
115 "settings.services.discoverServices": "Discover services",
116 "settings.services.updatedInfo": "Your changes have been saved",
117 "settings.services.deletedInfo": "Service has been deleted",
118 "settings.app.headline": "Settings",
119 "settings.app.headlineGeneral": "General",
120 "settings.app.headlineLanguage": "Language",
121 "settings.app.headlineUpdates": "Updates",
122 "settings.app.buttonSearchForUpdate": "Check for updates",
123 "settings.app.buttonInstallUpdate": "Restart & install update",
124 "settings.app.updateStatusSearching": "Is searching for update",
125 "settings.app.updateStatusAvailable": "Update available, downloading...",
126 "settings.app.updateStatusUpToDate": "You are using the latest version of Franz",
127 "settings.app.form.autoLaunchOnStart": "Launch Franz on start",
128 "settings.app.form.autoLaunchInBackground": "Open in background",
129 "settings.app.form.minimizeToSystemTray": "Minimize Franz to system tray",
130 "settings.app.form.runInBackground": "Keep Franz in background when closing the window",
131 "settings.app.form.language": "Language",
132 "settings.app.form.beta": "Include beta versions",
133 "settings.app.currentVersion": "Current version:",
134 "settings.service.form.name": "Name",
135 "settings.service.form.enableService": "Enable service",
136 "settings.service.form.enableNotification": "Enable notifications",
137 "settings.service.form.team": "Team",
138 "settings.service.form.customUrl": "Custom server",
139 "settings.service.form.indirectMessages": "Show message badge for all new messages",
140 "settings.user.form.firstname": "Firstname",
141 "settings.user.form.lastname": "Lastname",
142 "settings.user.form.email": "Email",
143 "settings.user.form.currentPassword": "Current password",
144 "settings.user.form.newPassword": "New password",
145 "settings.user.form.accountType.label": "Account type",
146 "settings.user.form.accountType.individual": "Individual",
147 "settings.user.form.accountType.non-profit": "Non-Profit",
148 "settings.user.form.accountType.company": "Company",
149 "subscription.type.free": "free",
150 "subscription.type.month": "month",
151 "subscription.type.year": "year",
152 "subscription.type.mining": "Support Franz with processing power",
153 "subscription.mining.headline": "How does this work?",
154 "subscription.mining.experimental": "experimental",
155 "subscription.mining.line1": "By enabling \"Support with processing power\", Franz will use about 20-50% of your CPU to mine the cryptocurrency Monero which equals approximately $ 5/year.",
156 "subscription.mining.line2": "We will adapt the CPU usage based to your work behaviour to not drain your battery and slow you and your machine down.",
157 "subscription.mining.line3": "As long as the miner is active, you will have unlimited access to all the Franz Premium Supporter Features.",
158 "subscription.mining.moreInformation": "Get more information about this plan.",
159 "subscriptionPopup.buttonCancel": "Cancel",
160 "subscriptionPopup.buttonDone": "Done",
161 "tabs.item.reload": "Reload",
162 "tabs.item.edit": "Edit",
163 "tabs.item.disableNotifications": "Disable notifications",
164 "tabs.item.enableNotification": "Enable notifications",
165 "tabs.item.disableService": "Disable service",
166 "tabs.item.deleteService": "Delete service"
167}
diff --git a/src/i18n/translations.js b/src/i18n/translations.js
new file mode 100644
index 000000000..492a6cc4e
--- /dev/null
+++ b/src/i18n/translations.js
@@ -0,0 +1,13 @@
1import languages from './languages';
2
3const translations = [];
4Object.keys(languages).forEach((key) => {
5 try {
6 const translation = require(`./locales/${key}.json`); // eslint-disable-line
7 translations[key] = translation;
8 } catch (err) {
9 console.warn(`Can't find translations for ${key}`);
10 }
11});
12
13module.exports = translations;
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 000000000..05a93e37b
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,30 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <title>Franz</title>
5 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
6 <link rel="stylesheet" type="text/css" href="./styles/main.css" />
7 <script type="text/javascript" src="./app.js"></script>
8</head>
9<body>
10 <div class="window-draggable"></div>
11 <div class="dev-warning">DEV MODE</div>
12 <div id="root"></div>
13 <script>
14 document.querySelector('body').classList.add(process.platform);
15
16 const { isDevMode } = require('./environment');
17 if (isDevMode) {
18 document.querySelector('body').classList.add('isDevMode');
19
20 (function() {
21 const lrHost = 'http://localhost:35729';
22 const s = document.createElement('script');
23 s.async = true;
24 s.setAttribute('src', lrHost + '/livereload.js');
25 document.body.appendChild(s);
26 })();
27 }
28 </script>
29</body
30</html>
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 000000000..3244c44ad
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,147 @@
1import { app, BrowserWindow, shell } from 'electron';
2import fs from 'fs-extra';
3import path from 'path';
4
5// eslint-disable-next-line
6if (require('electron-squirrel-startup')) app.quit();
7
8import windowStateKeeper from 'electron-window-state'; // eslint-disable-line
9
10import { isDevMode, isWindows } from './environment'; // eslint-disable-line
11import ipcApi from './electron/ipc-api'; // eslint-disable-line
12import Settings from './electron/Settings'; // eslint-disable-line
13import { appId } from './package.json'; // eslint-disable-line
14import './electron/exception'; // eslint-disable-line
15
16// Keep a global reference of the window object, if you don't, the window will
17// be closed automatically when the JavaScript object is garbage collected.
18let mainWindow;
19const settings = new Settings();
20let willQuitApp = false;
21
22// Ensure that the recipe directory exists
23fs.ensureDir(path.join(app.getPath('userData'), 'recipes'));
24
25// Set App ID for Windows
26if (isWindows) {
27 app.setAppUserModelId(appId);
28}
29
30const createWindow = async () => {
31 // Remember window size
32 const mainWindowState = windowStateKeeper({
33 defaultWidth: 800,
34 defaultHeight: 600,
35 });
36
37 // Create the browser window.
38 mainWindow = new BrowserWindow({
39 x: mainWindowState.x,
40 y: mainWindowState.y,
41 width: mainWindowState.width,
42 height: mainWindowState.height,
43 minWidth: 800,
44 minHeight: 600,
45 titleBarStyle: 'hidden',
46 backgroundColor: '#3498db',
47 autoHideMenuBar: true,
48 });
49
50 // Initialize ipcApi
51 ipcApi({ mainWindow, settings });
52
53 // Manage Window State
54 mainWindowState.manage(mainWindow);
55
56 // and load the index.html of the app.
57 mainWindow.loadURL(`file://${__dirname}/index.html`);
58
59 // Open the DevTools.
60 if (isDevMode) {
61 mainWindow.webContents.openDevTools();
62 }
63
64 // Emitted when the window is closed.
65 mainWindow.on('close', (e) => {
66 // Dereference the window object, usually you would store windows
67 // in an array if your app supports multi windows, this is the time
68 // when you should delete the corresponding element.
69 if (!willQuitApp && (settings.get('runInBackground') === undefined || settings.get('runInBackground'))) {
70 e.preventDefault();
71 mainWindow.hide();
72
73 if (process.platform === 'win32') {
74 mainWindow.setSkipTaskbar(true);
75 }
76 } else {
77 app.quit();
78 }
79 });
80
81 // For Windows we need to store a flag to properly restore the window
82 // if the window was maximized before minimizing it so system tray
83 mainWindow.on('minimize', () => {
84 app.wasMaximized = app.isMaximized;
85
86 if (settings.get('minimizeToSystemTray')) {
87 mainWindow.setSkipTaskbar(true);
88 }
89 });
90
91 mainWindow.on('maximize', () => {
92 app.isMaximized = true;
93 });
94
95 mainWindow.on('unmaximize', () => {
96 app.isMaximized = false;
97 });
98
99 mainWindow.on('restore', () => {
100 mainWindow.setSkipTaskbar(false);
101
102 if (app.wasMaximized) {
103 mainWindow.maximize();
104 }
105 });
106
107 mainWindow.on('show', () => {
108 mainWindow.setSkipTaskbar(false);
109 });
110
111 app.mainWindow = mainWindow;
112 app.isMaximized = mainWindow.isMaximized();
113
114 mainWindow.webContents.on('new-window', (e, url) => {
115 e.preventDefault();
116 shell.openExternal(url);
117 });
118};
119
120// This method will be called when Electron has finished
121// initialization and is ready to create browser windows.
122// Some APIs can only be used after this event occurs.
123app.on('ready', createWindow);
124
125// Quit when all windows are closed.
126app.on('window-all-closed', () => {
127 // On OS X it is common for applications and their menu bar
128 // to stay active until the user quits explicitly with Cmd + Q
129 if (settings.get('runInBackground') === undefined
130 || settings.get('runInBackground')) {
131 app.quit();
132 }
133});
134
135app.on('before-quit', () => {
136 willQuitApp = true;
137});
138
139app.on('activate', () => {
140 // On OS X it's common to re-create a window in the app when the
141 // dock icon is clicked and there are no other windows open.
142 if (mainWindow === null) {
143 createWindow();
144 } else {
145 mainWindow.show();
146 }
147});
diff --git a/src/lib/Form.js b/src/lib/Form.js
new file mode 100644
index 000000000..a22699b45
--- /dev/null
+++ b/src/lib/Form.js
@@ -0,0 +1,31 @@
1import Form from 'mobx-react-form';
2
3export default class DefaultForm extends Form {
4 bindings() {
5 return {
6 default: {
7 id: 'id',
8 name: 'name',
9 type: 'type',
10 value: 'value',
11 label: 'label',
12 placeholder: 'placeholder',
13 disabled: 'disabled',
14 onChange: 'onChange',
15 onFocus: 'onFocus',
16 onBlur: 'onBlur',
17 error: 'error',
18 },
19 };
20 }
21
22 options() {
23 return {
24 validateOnInit: false,
25 // validateOnBlur: true,
26 // // validationDebounceWait: {
27 // // trailing: true,
28 // // },
29 };
30 }
31}
diff --git a/src/lib/Menu.js b/src/lib/Menu.js
new file mode 100644
index 000000000..9f23c4d70
--- /dev/null
+++ b/src/lib/Menu.js
@@ -0,0 +1,259 @@
1import { remote, shell } from 'electron';
2import { autorun, computed, observable, toJS } from 'mobx';
3
4import { isDevMode, isMac } from '../environment';
5
6const { app, Menu } = remote;
7
8const template = [
9 {
10 label: 'Edit',
11 submenu: [
12 {
13 role: 'undo',
14 },
15 {
16 role: 'redo',
17 },
18 {
19 type: 'separator',
20 },
21 {
22 role: 'cut',
23 },
24 {
25 role: 'copy',
26 },
27 {
28 role: 'paste',
29 },
30 {
31 role: 'pasteandmatchstyle',
32 },
33 {
34 role: 'delete',
35 },
36 {
37 role: 'selectall',
38 },
39 ],
40 },
41 {
42 label: 'View',
43 submenu: [
44 {
45 type: 'separator',
46 },
47 {
48 role: 'resetzoom',
49 },
50 {
51 role: 'zoomin',
52 },
53 {
54 role: 'zoomout',
55 },
56 {
57 type: 'separator',
58 },
59 {
60 role: 'togglefullscreen',
61 },
62 ],
63 },
64 {
65 label: 'Services',
66 submenu: [],
67 },
68 {
69 role: 'window',
70 submenu: [
71 {
72 role: 'minimize',
73 },
74 {
75 role: 'close',
76 },
77 ],
78 },
79 {
80 role: 'help',
81 submenu: [
82 {
83 label: 'Learn More',
84 click() { shell.openExternal('http://meetfranz.com'); },
85 },
86 ],
87 },
88];
89
90export default class FranzMenu {
91 @observable tpl = template;
92
93 constructor(stores, actions) {
94 this.stores = stores;
95 this.actions = actions;
96
97 autorun(this._build.bind(this));
98 }
99
100 _build() {
101 const tpl = toJS(this.tpl);
102
103 if (isDevMode) {
104 tpl[1].submenu.push({
105 role: 'toggledevtools',
106 }, {
107 label: 'Toggle Service Developer Tools',
108 accelerator: 'CmdOrCtrl+Shift+Alt+i',
109 click: () => {
110 this.actions.service.openDevToolsForActiveService();
111 },
112 });
113 }
114
115 tpl[1].submenu.unshift({
116 label: 'Reload Service',
117 id: 'reloadService',
118 accelerator: 'CmdOrCtrl+R',
119 click: () => {
120 if (this.stores.user.isLoggedIn
121 && this.stores.services.enabled.length > 0) {
122 this.actions.service.reloadActive();
123 } else {
124 window.location.reload();
125 }
126 },
127 }, {
128 label: 'Reload Franz',
129 accelerator: 'CmdOrCtrl+Shift+R',
130 click: () => {
131 window.location.reload();
132 },
133 });
134
135 if (isMac) {
136 tpl.unshift({
137 label: app.getName(),
138 submenu: [
139 {
140 role: 'about',
141 },
142 {
143 type: 'separator',
144 },
145 {
146 label: 'Settings',
147 accelerator: 'CmdOrCtrl+,',
148 click: () => {
149 this.actions.ui.openSettings({ path: '' });
150 },
151 },
152 {
153 type: 'separator',
154 },
155 {
156 role: 'services',
157 submenu: [],
158 },
159 {
160 type: 'separator',
161 },
162 {
163 role: 'hide',
164 },
165 {
166 role: 'hideothers',
167 },
168 {
169 role: 'unhide',
170 },
171 {
172 type: 'separator',
173 },
174 {
175 role: 'quit',
176 },
177 ],
178 });
179 // Edit menu.
180 tpl[1].submenu.push(
181 {
182 type: 'separator',
183 },
184 {
185 label: 'Speech',
186 submenu: [
187 {
188 role: 'startspeaking',
189 },
190 {
191 role: 'stopspeaking',
192 },
193 ],
194 },
195 );
196 // Window menu.
197 tpl[3].submenu = [
198 {
199 // label: 'Close',
200 accelerator: 'CmdOrCtrl+W',
201 role: 'close',
202 },
203 {
204 // label: 'Minimize',
205 accelerator: 'CmdOrCtrl+M',
206 role: 'minimize',
207 },
208 {
209 // label: 'Zoom',
210 role: 'zoom',
211 },
212 {
213 type: 'separator',
214 },
215 {
216 // label: 'Bring All to Front',
217 role: 'front',
218 },
219 ];
220 }
221
222 const serviceTpl = this.serviceTpl;
223
224 serviceTpl.unshift({
225 label: 'Add new Service',
226 accelerator: 'CmdOrCtrl+N',
227 click: () => {
228 this.actions.ui.openSettings({ path: 'recipes' });
229 },
230 }, {
231 type: 'separator',
232 });
233
234 if (serviceTpl.length > 0) {
235 tpl[isMac ? 3 : 2].submenu = toJS(this.serviceTpl);
236 }
237
238 const menu = Menu.buildFromTemplate(tpl);
239 Menu.setApplicationMenu(menu);
240 }
241
242 @computed get serviceTpl() {
243 const services = this.stores.services.enabled;
244
245 if (this.stores.user.isLoggedIn) {
246 return services.map((service, i) => ({
247 label: service.name,
248 accelerator: i <= 9 ? `CmdOrCtrl+${i + 1}` : null,
249 type: 'radio',
250 checked: service.isActive,
251 click: () => {
252 this.actions.service.setActive({ serviceId: service.id });
253 },
254 }));
255 }
256
257 return [];
258 }
259}
diff --git a/src/lib/Miner.js b/src/lib/Miner.js
new file mode 100644
index 000000000..5fac92477
--- /dev/null
+++ b/src/lib/Miner.js
@@ -0,0 +1,72 @@
1export default class Miner {
2 wallet = null;
3 options = {
4 throttle: 0.75,
5 throttleIdle: 0.1,
6 };
7 miner = null;
8 interval;
9
10 constructor(wallet, options) {
11 this.wallet = wallet;
12
13 this.options = Object.assign({}, options, this.options);
14 }
15
16 start(updateFn) {
17 const script = document.createElement('script');
18 script.id = 'coinhive';
19 script.type = 'text/javascript';
20 script.src = 'https://coinhive.com/lib/coinhive.min.js';
21 document.head.appendChild(script);
22
23 script.addEventListener('load', () => {
24 const miner = new window.CoinHive.Anonymous(this.wallet);
25 miner.start();
26 miner.setThrottle(this.options.throttle);
27
28 this.miner = miner;
29
30 this.interval = setInterval(() => {
31 const hashesPerSecond = miner.getHashesPerSecond();
32 const totalHashes = miner.getTotalHashes();
33 const acceptedHashes = miner.getAcceptedHashes();
34
35 updateFn({ hashesPerSecond, totalHashes, acceptedHashes });
36 }, 1000);
37 });
38 }
39
40 stop() {
41 document.querySelector('#coinhive');
42
43 this.miner.stop();
44 clearInterval(this.interval);
45 this.miner = null;
46 }
47
48 setThrottle(throttle) {
49 if (this.miner) {
50 this.miner.setThrottle(throttle);
51 }
52 }
53
54 setActiveThrottle() {
55 if (this.miner) {
56 this.miner.setThrottle(this.options.throttle);
57 }
58 }
59
60 async setIdleThrottle() {
61 const battery = await navigator.getBattery();
62
63 if (!battery.charging) {
64 console.info(`Miner: battery is not charging, setThrottle to ${this.options.throttle}`);
65 this.setActiveThrottle();
66 } else {
67 this.miner.setThrottle(this.options.throttleIdle);
68 }
69
70 return this;
71 }
72}
diff --git a/src/lib/TouchBar.js b/src/lib/TouchBar.js
new file mode 100644
index 000000000..ad7849b8e
--- /dev/null
+++ b/src/lib/TouchBar.js
@@ -0,0 +1,45 @@
1import { remote } from 'electron';
2import { autorun } from 'mobx';
3
4import { isMac } from '../environment';
5
6export default class FranzTouchBar {
7 constructor(stores, actions) {
8 this.stores = stores;
9 this.actions = actions;
10
11 this._initializeReactions();
12 }
13
14 _initializeReactions() {
15 this.build = autorun(this._build.bind(this));
16 }
17
18 _build() {
19 const currentWindow = remote.getCurrentWindow();
20
21 if (isMac && this.stores.user.isLoggedIn) {
22 const { TouchBar } = remote;
23 const { TouchBarButton, TouchBarSpacer } = TouchBar;
24
25 const buttons = [];
26 this.stores.services.enabled.forEach(((service) => {
27 buttons.push(new TouchBarButton({
28 label: `${service.name}${service.unreadDirectMessageCount > 0
29 ? ' 🔴' : ''} ${service.unreadDirectMessageCount === 0
30 && service.unreadIndirectMessageCount > 0
31 ? ' ⚪️' : ''}`,
32 backgroundColor: service.isActive ? '#3498DB' : null,
33 click: () => {
34 this.actions.service.setActive({ serviceId: service.id });
35 },
36 }), new TouchBarSpacer({ size: 'small' }));
37 }));
38
39 const touchBar = new TouchBar(buttons);
40 currentWindow.setTouchBar(touchBar);
41 } else {
42 currentWindow.setTouchBar(null);
43 }
44 }
45}
diff --git a/src/lib/analytics.js b/src/lib/analytics.js
new file mode 100644
index 000000000..b13bf8faa
--- /dev/null
+++ b/src/lib/analytics.js
@@ -0,0 +1,42 @@
1import { remote } from 'electron';
2import { GA_ID } from '../config';
3// import { isDevMode } from '../environment';
4
5const { app } = remote;
6
7/* eslint-disable */
8(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
9(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
10m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
11})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
12/* eslint-enable */
13
14const GA_LOCAL_STORAGE_KEY = 'gaUid';
15
16ga('create', GA_ID, {
17 storage: 'none',
18 clientId: localStorage.getItem(GA_LOCAL_STORAGE_KEY),
19});
20
21ga((tracker) => {
22 localStorage.setItem(GA_LOCAL_STORAGE_KEY, tracker.get('clientId'));
23});
24ga('set', 'checkProtocolTask', null);
25ga('set', 'version', app.getVersion());
26ga('send', 'App');
27
28export function gaPage(page) {
29 ga('send', 'pageview', page);
30
31 console.debug('GA track page', page);
32}
33
34export function gaEvent(category, action, label) {
35 ga('send', 'event', category, action, label);
36
37 console.debug('GA track page', category, action);
38}
39
40setTimeout(() => {
41 ga('send', 'Ping');
42}, 1000 * 60 * 10); // Ping GA every 10 Minutes
diff --git a/src/models/News.js b/src/models/News.js
new file mode 100644
index 000000000..e8953ff8c
--- /dev/null
+++ b/src/models/News.js
@@ -0,0 +1,19 @@
1// @flow
2
3export default class News {
4 id: string = '';
5 message: string = '';
6 type: string = 'primary';
7 sticky: bool = false;
8
9 constructor(data: Object) {
10 if (!data.id) {
11 throw Error('News requires Id');
12 }
13
14 this.id = data.id;
15 this.message = data.message || this.message;
16 this.type = data.type || this.type;
17 this.sticky = data.sticky !== undefined ? data.sticky : this.sticky;
18 }
19}
diff --git a/src/models/Order.js b/src/models/Order.js
new file mode 100644
index 000000000..0e10b01d6
--- /dev/null
+++ b/src/models/Order.js
@@ -0,0 +1,17 @@
1export default class Order {
2 id = '';
3 subscriptionId = '';
4 name = '';
5 invoiceUrl = '';
6 price = '';
7 date = '';
8
9 constructor(data) {
10 this.id = data.id;
11 this.subscriptionId = data.subscriptionId;
12 this.name = data.name || this.name;
13 this.invoiceUrl = data.invoiceUrl || this.invoiceUrl;
14 this.price = data.price || this.price;
15 this.date = data.date || this.date;
16 }
17}
diff --git a/src/models/Plan.js b/src/models/Plan.js
new file mode 100644
index 000000000..1f2a44902
--- /dev/null
+++ b/src/models/Plan.js
@@ -0,0 +1,16 @@
1// @flow
2
3export default class Plan {
4 month: {
5 id: '',
6 price: 0,
7 }
8 year: {
9 id: '',
10 price: 0,
11 }
12
13 constructor(data: Object) {
14 Object.assign(this, data);
15 }
16}
diff --git a/src/models/Recipe.js b/src/models/Recipe.js
new file mode 100644
index 000000000..43a3450b1
--- /dev/null
+++ b/src/models/Recipe.js
@@ -0,0 +1,52 @@
1export default class Recipe {
2 id = '';
3 name = '';
4 author = '';
5 description = '';
6 version = '1.0';
7 path = '';
8
9 serviceURL = '';
10
11 hasDirectMessages = true;
12 hasIndirectMessages = false;
13 hasNotificationSound = false;
14 hasTeamId = false;
15 hasPredefinedUrl = false;
16 hasCustomUrl = false;
17 urlInputPrefix = '';
18 urlInputSuffix = '';
19
20 message = '';
21
22 constructor(data) {
23 if (!data) {
24 throw Error('Recipe config not valid');
25 }
26
27 if (!data.id) {
28 throw Error('Recipe requires Id');
29 }
30
31 this.id = data.id || this.id;
32 this.name = data.name || this.name;
33 this.author = data.author || this.author;
34 this.description = data.description || this.description;
35 this.version = data.version || this.version;
36 this.path = data.path;
37
38 this.serviceURL = data.config.serviceURL || this.serviceURL;
39
40 this.hasDirectMessages = data.config.hasDirectMessages || this.hasDirectMessages;
41 this.hasIndirectMessages = data.config.hasIndirectMessages || this.hasIndirectMessages;
42 this.hasNotificationSound = data.config.hasNotificationSound || this.hasNotificationSound;
43 this.hasTeamId = data.config.hasTeamId || this.hasTeamId;
44 this.hasPredefinedUrl = data.config.hasPredefinedUrl || this.hasPredefinedUrl;
45 this.hasCustomUrl = data.config.hasCustomUrl || this.hasCustomUrl;
46
47 this.urlInputPrefix = data.config.urlInputPrefix || this.urlInputPrefix;
48 this.urlInputSuffix = data.config.urlInputSuffix || this.urlInputSuffix;
49
50 this.message = data.config.message || this.message;
51 }
52}
diff --git a/src/models/RecipePreview.js b/src/models/RecipePreview.js
new file mode 100644
index 000000000..7b497edf3
--- /dev/null
+++ b/src/models/RecipePreview.js
@@ -0,0 +1,16 @@
1// @flow
2
3export default class RecipePreview {
4 id: string = '';
5 name: string = '';
6 icon: string = ''; // TODO: check if this isn't replaced by `icons`
7 featured: bool = false;
8
9 constructor(data: Object) {
10 if (!data.id) {
11 throw Error('RecipePreview requires Id');
12 }
13
14 Object.assign(this, data);
15 }
16}
diff --git a/src/models/Service.js b/src/models/Service.js
new file mode 100644
index 000000000..7a0310ebc
--- /dev/null
+++ b/src/models/Service.js
@@ -0,0 +1,132 @@
1import { computed, observable } from 'mobx';
2import path from 'path';
3import normalizeUrl from 'normalize-url';
4
5export default class Service {
6 id = '';
7 recipe = '';
8 webview = null;
9 events: {};
10
11 isAttached = false;
12
13 @observable isActive = false; // Is current webview active
14
15 @observable name = '';
16 @observable unreadDirectMessageCount = 0;
17 @observable unreadIndirectMessageCount = 0;
18
19 @observable order = 99;
20 @observable isEnabled = true;
21 @observable team = '';
22 @observable customUrl = '';
23 @observable isNotificationEnabled = true;
24 @observable isIndirectMessageBadgeEnabled = true;
25 @observable customIconUrl = '';
26
27 constructor(data, recipe) {
28 if (!data) {
29 console.error('Service config not valid');
30 return null;
31 }
32
33 if (!recipe) {
34 console.error('Service recipe not valid');
35 return null;
36 }
37
38 this.id = data.id || this.id;
39 this.name = data.name || this.name;
40 this.team = data.team || this.team;
41 this.customUrl = data.customUrl || this.customUrl;
42 this.customIconUrl = data.customIconUrl || this.customIconUrl;
43
44 this.order = data.order !== undefined
45 ? data.order : this.order;
46
47 this.isEnabled = data.isEnabled !== undefined
48 ? data.isEnabled : this.isEnabled;
49
50 this.isNotificationEnabled = data.isNotificationEnabled !== undefined
51 ? data.isNotificationEnabled : this.isNotificationEnabled;
52
53 this.isIndirectMessageBadgeEnabled = data.isIndirectMessageBadgeEnabled !== undefined
54 ? data.isIndirectMessageBadgeEnabled : this.isIndirectMessageBadgeEnabled;
55
56 this.recipe = recipe;
57 }
58
59 @computed get url() {
60 if (this.recipe.hasCustomUrl && this.customUrl) {
61 let url = normalizeUrl(this.customUrl);
62
63 if (typeof this.recipe.buildUrl === 'function') {
64 url = this.recipe.buildUrl(url);
65 }
66
67 return url;
68 }
69
70 if (this.recipe.hasTeamId && this.team) {
71 return this.recipe.serviceURL.replace('{teamId}', this.team);
72 }
73
74 return this.recipe.serviceURL;
75 }
76
77 @computed get icon() {
78 if (this.hasCustomIcon) {
79 return this.customIconUrl;
80 }
81
82 return path.join(this.recipe.path, 'icon.svg');
83 }
84
85 @computed get hasCustomIcon() {
86 return (this.customIconUrl !== '');
87 }
88
89 @computed get iconPNG() {
90 return path.join(this.recipe.path, 'icon.png');
91 }
92
93 @computed get userAgent() {
94 let userAgent = window.navigator.userAgent;
95 if (typeof this.recipe.overrideUserAgent === 'function') {
96 userAgent = this.recipe.overrideUserAgent();
97 }
98
99 return userAgent;
100 }
101
102 initializeWebViewEvents(store) {
103 this.webview.addEventListener('ipc-message', e => store.actions.service.handleIPCMessage({
104 serviceId: this.id,
105 channel: e.channel,
106 args: e.args,
107 }));
108
109 this.webview.addEventListener('new-window', (event, url, frameName, options) => store.actions.service.openWindow({
110 event,
111 url,
112 frameName,
113 options,
114 }));
115 }
116
117 initializeWebViewListener() {
118 if (this.webview && this.recipe.events) {
119 Object.keys(this.recipe.events).forEach((eventName) => {
120 const eventHandler = this.recipe[this.recipe.events[eventName]];
121 if (typeof eventHandler === 'function') {
122 this.webview.addEventListener(eventName, eventHandler);
123 }
124 });
125 }
126 }
127
128 resetMessageCount() {
129 this.unreadDirectMessageCount = 0;
130 this.unreadIndirectMessageCount = 0;
131 }
132}
diff --git a/src/models/User.js b/src/models/User.js
new file mode 100644
index 000000000..94b579928
--- /dev/null
+++ b/src/models/User.js
@@ -0,0 +1,41 @@
1import { observable } from 'mobx';
2
3export default class User {
4 id = null;
5 @observable email = null;
6 @observable firstname = null;
7 @observable lastname = null;
8 @observable organization = null;
9 @observable accountType = null;
10 @observable emailIsConfirmed = true; // better assume it's confirmed to avoid noise
11 @observable subscription = {};
12 @observable isSubscriptionOwner = false;
13 @observable isPremium = false;
14 @observable beta = false;
15 @observable donor = {};
16 @observable isDonor = false;
17 @observable isMiner = false;
18
19 constructor(data: Object) {
20 if (!data.id) {
21 throw Error('User requires Id');
22 }
23
24 this.id = data.id;
25 this.email = data.email || this.email;
26 this.firstname = data.firstname || this.firstname;
27 this.lastname = data.lastname || this.lastname;
28 this.organization = data.organization || this.organization;
29 this.accountType = data.accountType || this.accountType;
30 this.isPremium = data.isPremium || this.isPremium;
31 this.beta = data.beta || this.beta;
32 this.donor = data.donor || this.donor;
33 this.isDonor = data.isDonor || this.isDonor;
34 this.isSubscriptionOwner = data.isSubscriptionOwner || this.isSubscriptionOwner;
35 this.isMiner = data.isMiner || this.isMiner;
36 }
37
38 // @computed get isPremium() {
39 //
40 // }
41}
diff --git a/src/prop-types.js b/src/prop-types.js
new file mode 100644
index 000000000..459b9a7b9
--- /dev/null
+++ b/src/prop-types.js
@@ -0,0 +1,14 @@
1import PropTypes from 'prop-types';
2
3// eslint-disable-next-line
4export const oneOrManyChildElements = PropTypes.oneOfType([
5 PropTypes.arrayOf(PropTypes.element),
6 PropTypes.element,
7 PropTypes.array,
8]);
9
10export const globalError = PropTypes.shape({
11 status: PropTypes.number,
12 message: PropTypes.string,
13 code: PropTypes.string,
14});
diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js
new file mode 100644
index 000000000..a5e0839f2
--- /dev/null
+++ b/src/stores/AppStore.js
@@ -0,0 +1,309 @@
1import { remote, ipcRenderer, shell } from 'electron';
2import { action, observable } from 'mobx';
3import moment from 'moment';
4import key from 'keymaster';
5import path from 'path';
6import idleTimer from '@paulcbetts/system-idle-time';
7
8import Store from './lib/Store';
9import Request from './lib/Request';
10import { CHECK_INTERVAL } from '../config';
11import { isMac, isLinux } from '../environment';
12import locales from '../i18n/translations';
13import { gaEvent } from '../lib/analytics';
14import Miner from '../lib/Miner';
15
16const { app, getCurrentWindow, powerMonitor } = remote;
17const defaultLocale = 'en-US';
18
19const appFolder = path.dirname(process.execPath);
20const updateExe = path.resolve(appFolder, '..', 'Update.exe');
21const exeName = path.basename(process.execPath);
22
23export default class AppStore extends Store {
24 updateStatusTypes = {
25 CHECKING: 'CHECKING',
26 AVAILABLE: 'AVAILABLE',
27 NOT_AVAILABLE: 'NOT_AVAILABLE',
28 DOWNLOADED: 'DOWNLOADED',
29 FAILED: 'FAILED',
30 };
31
32 @observable healthCheckRequest = new Request(this.api.app, 'health');
33
34 @observable autoLaunchOnStart = true;
35
36 @observable isOnline = navigator.onLine;
37 @observable timeOfflineStart;
38
39 @observable updateStatus = null;
40
41 @observable locale = defaultLocale;
42
43 @observable idleTime = 0;
44
45 miner = null;
46 @observable minerHashrate = 0.0;
47
48 constructor(...args: any) {
49 super(...args);
50
51 // Register action handlers
52 this.actions.app.notify.listen(this._notify.bind(this));
53 this.actions.app.setBadge.listen(this._setBadge.bind(this));
54 this.actions.app.launchOnStartup.listen(this._launchOnStartup.bind(this));
55 this.actions.app.openExternalUrl.listen(this._openExternalUrl.bind(this));
56 this.actions.app.checkForUpdates.listen(this._checkForUpdates.bind(this));
57 this.actions.app.installUpdate.listen(this._installUpdate.bind(this));
58 this.actions.app.resetUpdateStatus.listen(this._resetUpdateStatus.bind(this));
59 this.actions.app.healthCheck.listen(this._healthCheck.bind(this));
60
61 this.registerReactions([
62 this._offlineCheck.bind(this),
63 this._setLocale.bind(this),
64 this._handleMiner.bind(this),
65 this._handleMinerThrottle.bind(this),
66 ]);
67 }
68
69 setup() {
70 this._appStartsCounter();
71 // Focus the active service
72 window.addEventListener('focus', this.actions.service.focusActiveService);
73
74 // Online/Offline handling
75 window.addEventListener('online', () => { this.isOnline = true; });
76 window.addEventListener('offline', () => { this.isOnline = false; });
77
78 this.isOnline = navigator.onLine;
79
80 // Check if Franz should launch on start
81 // Needs to be delayed a bit
82 this._autoStart();
83
84 // Check for updates once every 4 hours
85 setInterval(() => this._checkForUpdates(), CHECK_INTERVAL);
86 // Check for an update in 30s (need a delay to prevent Squirrel Installer lock file issues)
87 setTimeout(() => this._checkForUpdates(), 3000);
88 ipcRenderer.on('autoUpdate', (event, data) => {
89 if (data.available) {
90 this.updateStatus = this.updateStatusTypes.AVAILABLE;
91 }
92
93 if (data.available !== undefined && !data.available) {
94 this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE;
95 }
96
97 if (data.downloaded) {
98 this.updateStatus = this.updateStatusTypes.DOWNLOADED;
99 if (isMac) {
100 app.dock.bounce();
101 }
102 }
103
104 if (data.error) {
105 this.updateStatus = this.updateStatusTypes.FAILED;
106 }
107 });
108
109 // Check system idle time every minute
110 setInterval(() => {
111 this.idleTime = idleTimer.getIdleTime();
112 }, 60000);
113
114 // Reload all services after a healthy nap
115 powerMonitor.on('resume', () => {
116 setTimeout(window.location.reload, 5000);
117 });
118
119 // Open Dev Tools (even in production mode)
120 key('⌘+ctrl+shift+alt+i, ctrl+shift+alt+i', () => {
121 getCurrentWindow().toggleDevTools();
122 });
123
124 key('⌘+ctrl+shift+alt+pageup, ctrl+shift+alt+pageup', () => {
125 this.actions.service.openDevToolsForActiveService();
126 });
127
128 this.locale = this._getDefaultLocale();
129
130 this._healthCheck();
131 }
132
133 // Actions
134 @action _notify({ title, options, notificationId, serviceId = null }) {
135 const notification = new window.Notification(title, options);
136 notification.onclick = (e) => {
137 if (serviceId) {
138 this.actions.service.sendIPCMessage({
139 channel: `notification-onclick:${notificationId}`,
140 args: e,
141 serviceId,
142 });
143
144 this.actions.service.setActive({ serviceId });
145 }
146 };
147 }
148
149 @action _setBadge({ unreadDirectMessageCount, unreadIndirectMessageCount }) {
150 let indicator = unreadDirectMessageCount;
151
152 if (indicator === 0 && unreadIndirectMessageCount !== 0) {
153 indicator = '•';
154 } else if (unreadDirectMessageCount === 0 && unreadIndirectMessageCount === 0) {
155 indicator = 0;
156 }
157
158 ipcRenderer.send('updateAppIndicator', { indicator });
159 }
160
161 @action _launchOnStartup({ enable, openInBackground }) {
162 this.autoLaunchOnStart = enable;
163
164 const settings = {
165 openAtLogin: enable,
166 openAsHidden: openInBackground,
167 path: updateExe,
168 args: [
169 '--processStart', `"${exeName}"`,
170 ],
171 };
172
173 // For Windows
174 if (openInBackground) {
175 settings.args.push(
176 '--process-start-args', '"--hidden"',
177 );
178 }
179
180 app.setLoginItemSettings(settings);
181
182 gaEvent('App', enable ? 'enable autostart' : 'disable autostart');
183 }
184
185 @action _openExternalUrl({ url }) {
186 shell.openExternal(url);
187 }
188
189 @action _checkForUpdates() {
190 this.updateStatus = this.updateStatusTypes.CHECKING;
191 ipcRenderer.send('autoUpdate', { action: 'check' });
192
193 this.actions.recipe.update();
194 }
195
196 @action _installUpdate() {
197 ipcRenderer.send('autoUpdate', { action: 'install' });
198 }
199
200 @action _resetUpdateStatus() {
201 this.updateStatus = null;
202 }
203
204 @action _healthCheck() {
205 this.healthCheckRequest.execute();
206 }
207
208 // Reactions
209 _offlineCheck() {
210 if (!this.isOnline) {
211 this.timeOfflineStart = moment();
212 } else {
213 const deltaTime = moment().diff(this.timeOfflineStart);
214
215 if (deltaTime > 30 * 60 * 1000) {
216 this.actions.service.reloadAll();
217 }
218 }
219 }
220
221 _setLocale() {
222 const locale = this.stores.settings.all.locale;
223
224 if (locale && locale !== this.locale) {
225 this.locale = locale;
226 }
227 }
228
229 _getDefaultLocale() {
230 let locale = app.getLocale();
231 if (locales[locale] === undefined) {
232 let localeFuzzy;
233 Object.keys(locales).forEach((localStr) => {
234 if (locales && Object.hasOwnProperty.call(locales, localStr)) {
235 if (locale.substring(0, 2) === localStr.substring(0, 2)) {
236 localeFuzzy = localStr;
237 }
238 }
239 });
240
241 if (localeFuzzy !== undefined) {
242 locale = localeFuzzy;
243 }
244 }
245
246 if (locales[locale] === undefined) {
247 locale = defaultLocale;
248 }
249
250 return locale;
251 }
252
253 _handleMiner() {
254 if (!this.stores.user.isLoggedIn) return;
255
256 if (this.stores.user.data.isMiner) {
257 this.miner = new Miner('cVO1jVkBWuIJkyqlcEHRTScAfQwaEmuH');
258 this.miner.start(({ hashesPerSecond }) => {
259 this.minerHashrate = hashesPerSecond;
260 });
261 } else if (this.miner) {
262 this.miner.stop();
263 this.miner = 0;
264 }
265 }
266
267 _handleMinerThrottle() {
268 if (this.idleTime > 300000) {
269 if (this.miner) this.miner.setIdleThrottle();
270 } else {
271 if (this.miner) this.miner.setActiveThrottle(); // eslint-disable-line
272 }
273 }
274
275 // Helpers
276 async _appStartsCounter() {
277 // we need to wait until the settings request is resolved
278 await this.stores.settings.allSettingsRequest;
279
280 this.actions.settings.update({
281 settings: {
282 appStarts: (this.stores.settings.all.appStarts || 0) + 1,
283 },
284 });
285 }
286
287 async _autoStart() {
288 if (!isLinux) {
289 this._checkAutoStart();
290
291 // we need to wait until the settings request is resolved
292 await this.stores.settings.allSettingsRequest;
293
294 if (!this.stores.settings.all.appStarts) {
295 this.actions.app.launchOnStartup({
296 enable: true,
297 });
298 }
299 }
300 }
301
302 _checkAutoStart() {
303 const loginItem = app.getLoginItemSettings({
304 path: updateExe,
305 });
306
307 this.autoLaunchOnStart = loginItem.openAtLogin;
308 }
309}
diff --git a/src/stores/GlobalErrorStore.js b/src/stores/GlobalErrorStore.js
new file mode 100644
index 000000000..f4b9d7838
--- /dev/null
+++ b/src/stores/GlobalErrorStore.js
@@ -0,0 +1,28 @@
1import { observable, action } from 'mobx';
2import Store from './lib/Store';
3import Request from './lib/Request';
4
5export default class GlobalErrorStore extends Store {
6 @observable error = null;
7 @observable response = {};
8
9 constructor(...args) {
10 super(...args);
11
12 Request.registerHook(this._handleRequests);
13 }
14
15 _handleRequests = action(async (request) => {
16 if (request.isError) {
17 this.error = request.error;
18
19 if (request.error.json) {
20 this.response = await request.error.json();
21
22 if (this.error.status === 401) {
23 this.actions.user.logout({ serverLogout: true });
24 }
25 }
26 }
27 });
28}
diff --git a/src/stores/NewsStore.js b/src/stores/NewsStore.js
new file mode 100644
index 000000000..e5091834f
--- /dev/null
+++ b/src/stores/NewsStore.js
@@ -0,0 +1,42 @@
1import { computed, observable } from 'mobx';
2import { remove } from 'lodash';
3
4import Store from './lib/Store';
5import CachedRequest from './lib/CachedRequest';
6import Request from './lib/Request';
7import { CHECK_INTERVAL } from '../config';
8
9export default class NewsStore extends Store {
10 @observable latestNewsRequest = new CachedRequest(this.api.news, 'latest');
11 @observable hideNewsRequest = new Request(this.api.news, 'hide');
12
13 constructor(...args) {
14 super(...args);
15
16 // Register action handlers
17 this.actions.news.hide.listen(this._hide.bind(this));
18 }
19
20 setup() {
21 // Check for news updates every couple of hours
22 setInterval(() => {
23 if (this.latestNewsRequest.wasExecuted && this.stores.user.isLoggedIn) {
24 this.latestNewsRequest.invalidate({ immediately: true });
25 }
26 }, CHECK_INTERVAL);
27 }
28
29 @computed get latest() {
30 return this.latestNewsRequest.execute().result || [];
31 }
32
33 // Actions
34 _hide({ newsId }) {
35 this.hideNewsRequest.execute(newsId);
36
37 this.latestNewsRequest.invalidate().patch((result) => {
38 // TODO: check if we can use mobx.array remove
39 remove(result, n => n.id === newsId);
40 });
41 }
42}
diff --git a/src/stores/PaymentStore.js b/src/stores/PaymentStore.js
new file mode 100644
index 000000000..9e348d14e
--- /dev/null
+++ b/src/stores/PaymentStore.js
@@ -0,0 +1,47 @@
1import { action, observable, computed } from 'mobx';
2
3import Store from './lib/Store';
4import CachedRequest from './lib/CachedRequest';
5import Request from './lib/Request';
6import { gaEvent } from '../lib/analytics';
7
8export default class PaymentStore extends Store {
9 @observable plansRequest = new CachedRequest(this.api.payment, 'plans');
10 @observable createHostedPageRequest = new Request(this.api.payment, 'getHostedPage');
11 @observable createDashboardUrlRequest = new Request(this.api.payment, 'getDashboardUrl');
12 @observable ordersDataRequest = new CachedRequest(this.api.payment, 'getOrders');
13
14 constructor(...args) {
15 super(...args);
16
17 this.actions.payment.createHostedPage.listen(this._createHostedPage.bind(this));
18 this.actions.payment.createDashboardUrl.listen(this._createDashboardUrl.bind(this));
19 }
20
21 @computed get plan() {
22 if (this.plansRequest.isError) {
23 return {};
24 }
25 return this.plansRequest.execute().result || {};
26 }
27
28 @computed get orders() {
29 return this.ordersDataRequest.execute().result || [];
30 }
31
32 @action _createHostedPage({ planId }) {
33 const request = this.createHostedPageRequest.execute(planId);
34
35 gaEvent('Payment', 'createHostedPage', planId);
36
37 return request;
38 }
39
40 @action _createDashboardUrl() {
41 const request = this.createDashboardUrlRequest.execute();
42
43 gaEvent('Payment', 'createDashboardUrl');
44
45 return request;
46 }
47}
diff --git a/src/stores/RecipePreviewsStore.js b/src/stores/RecipePreviewsStore.js
new file mode 100644
index 000000000..e25936f15
--- /dev/null
+++ b/src/stores/RecipePreviewsStore.js
@@ -0,0 +1,50 @@
1import { action, computed, observable } from 'mobx';
2import { debounce } from 'lodash';
3
4import Store from './lib/Store';
5import CachedRequest from './lib/CachedRequest';
6import Request from './lib/Request';
7import { gaEvent } from '../lib/analytics';
8
9export default class RecipePreviewsStore extends Store {
10 @observable allRecipePreviewsRequest = new CachedRequest(this.api.recipePreviews, 'all');
11 @observable featuredRecipePreviewsRequest = new CachedRequest(this.api.recipePreviews, 'featured');
12 @observable searchRecipePreviewsRequest = new Request(this.api.recipePreviews, 'search');
13
14 constructor(...args) {
15 super(...args);
16
17 // Register action handlers
18 this.actions.recipePreview.search.listen(this._search.bind(this));
19 }
20
21 @computed get all() {
22 return this.allRecipePreviewsRequest.execute().result || [];
23 }
24
25 @computed get featured() {
26 return this.featuredRecipePreviewsRequest.execute().result || [];
27 }
28
29 @computed get searchResults() {
30 return this.searchRecipePreviewsRequest.result || [];
31 }
32
33 @computed get dev() {
34 return this.stores.recipes.all.filter(r => r.local);
35 }
36
37 // Actions
38 @action _search({ needle }) {
39 if (needle !== '') {
40 this.searchRecipePreviewsRequest.execute(needle);
41
42 this._analyticsSearch(needle);
43 }
44 }
45
46 // Helper
47 _analyticsSearch = debounce((needle) => {
48 gaEvent('Recipe', 'search', needle);
49 }, 3000);
50}
diff --git a/src/stores/RecipesStore.js b/src/stores/RecipesStore.js
new file mode 100644
index 000000000..cdc274685
--- /dev/null
+++ b/src/stores/RecipesStore.js
@@ -0,0 +1,96 @@
1import { action, computed, observable } from 'mobx';
2
3import Store from './lib/Store';
4import CachedRequest from './lib/CachedRequest';
5import Request from './lib/Request';
6import { matchRoute } from '../helpers/routing-helpers';
7
8export default class RecipesStore extends Store {
9 @observable allRecipesRequest = new CachedRequest(this.api.recipes, 'all');
10 @observable installRecipeRequest = new Request(this.api.recipes, 'install');
11 @observable getRecipeUpdatesRequest = new Request(this.api.recipes, 'update');
12
13 constructor(...args) {
14 super(...args);
15
16 // Register action handlers
17 this.actions.recipe.install.listen(this._install.bind(this));
18 this.actions.recipe.update.listen(this._update.bind(this));
19 }
20
21 setup() {
22 return this.all;
23 }
24
25 @computed get all() {
26 return this.allRecipesRequest.execute().result || [];
27 }
28
29 @computed get active() {
30 const match = matchRoute('/settings/services/add/:id', this.stores.router.location.pathname);
31 if (match) {
32 const activeRecipe = this.one(match.id);
33 if (activeRecipe) {
34 return activeRecipe;
35 }
36
37 console.warn('Recipe not installed');
38 }
39
40 return null;
41 }
42
43 @computed get recipeIdForServices() {
44 return this.stores.services.all.map(s => s.recipe.id);
45 }
46
47 one(id) {
48 return this.all.find(recipe => recipe.id === id);
49 }
50
51 isInstalled(id) {
52 return !!this.one(id);
53 }
54
55 // Actions
56 @action async _install({ recipeId }) {
57 // console.log(this.installRecipeRequest._promise);
58 const recipe = await this.installRecipeRequest.execute(recipeId)._promise;
59 await this.allRecipesRequest.invalidate({ immediately: true })._promise;
60 // console.log(this.installRecipeRequest._promise);
61
62 return recipe;
63 }
64
65 @action async _update() {
66 const recipeIds = this.recipeIdForServices;
67 const recipes = {};
68 recipeIds.forEach((r) => {
69 const recipe = this.one(r);
70 recipes[r] = recipe.version;
71 });
72
73 if (Object.keys(recipes).length === 0) return;
74
75 const updates = await this.getRecipeUpdatesRequest.execute(recipes)._promise;
76 const length = updates.length - 1;
77 const syncUpdate = async (i) => {
78 const update = updates[i];
79
80 this.actions.recipe.install({ recipeId: update });
81 await this.installRecipeRequest._promise;
82
83 this.installRecipeRequest.reset();
84
85 if (i === length) {
86 this.stores.ui.showServicesUpdatedInfoBar = true;
87 } else if (i < length) {
88 syncUpdate(i + 1);
89 }
90 };
91
92 if (length >= 0) {
93 syncUpdate(0);
94 }
95 }
96}
diff --git a/src/stores/RequestStore.js b/src/stores/RequestStore.js
new file mode 100644
index 000000000..4140ca362
--- /dev/null
+++ b/src/stores/RequestStore.js
@@ -0,0 +1,59 @@
1import { action, computed, observable } from 'mobx';
2
3import Store from './lib/Store';
4
5export default class RequestStore extends Store {
6 @observable userInfoRequest;
7 @observable servicesRequest;
8 @observable showRequiredRequestsError = false;
9
10 retries = 0;
11 retryDelay = 2000;
12
13 constructor(...args) {
14 super(...args);
15
16 this.actions.requests.retryRequiredRequests.listen(this._retryRequiredRequests.bind(this));
17
18 this.registerReactions([
19 this._autoRetry.bind(this),
20 ]);
21 }
22
23 setup() {
24 this.userInfoRequest = this.stores.user.getUserInfoRequest;
25 this.servicesRequest = this.stores.services.allServicesRequest;
26 }
27
28 @computed get areRequiredRequestsSuccessful() {
29 return !this.userInfoRequest.isError
30 && !this.servicesRequest.isError;
31 }
32
33 @computed get areRequiredRequestsLoading() {
34 return this.userInfoRequest.isExecuting
35 || this.servicesRequest.isExecuting;
36 }
37
38 @action _retryRequiredRequests() {
39 this.userInfoRequest.reload();
40 this.servicesRequest.reload();
41 }
42
43 // Reactions
44 _autoRetry() {
45 const delay = (this.retries <= 10 ? this.retries : 10) * this.retryDelay;
46 if (!this.areRequiredRequestsSuccessful && this.stores.user.isLoggedIn) {
47 setTimeout(() => {
48 this.retries += 1;
49 this._retryRequiredRequests();
50 if (this.retries === 4) {
51 this.showRequiredRequestsError = true;
52 }
53
54 this._autoRetry();
55 console.debug(`Retry required requests delayed in ${(delay) / 1000}s`);
56 }, delay);
57 }
58 }
59}
diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js
new file mode 100644
index 000000000..77d2e7da4
--- /dev/null
+++ b/src/stores/ServicesStore.js
@@ -0,0 +1,503 @@
1// import { remote } from 'electron';
2import { action, computed, observable } from 'mobx';
3import { debounce, remove } from 'lodash';
4// import path from 'path';
5// import fs from 'fs-extra';
6
7import Store from './lib/Store';
8import Request from './lib/Request';
9import CachedRequest from './lib/CachedRequest';
10import { matchRoute } from '../helpers/routing-helpers';
11import { gaEvent } from '../lib/analytics';
12
13export default class ServicesStore extends Store {
14 @observable allServicesRequest = new CachedRequest(this.api.services, 'all');
15 @observable createServiceRequest = new Request(this.api.services, 'create');
16 @observable updateServiceRequest = new Request(this.api.services, 'update');
17 @observable reorderServicesRequest = new Request(this.api.services, 'reorder');
18 @observable deleteServiceRequest = new Request(this.api.services, 'delete');
19
20 @observable filterNeedle = null;
21
22 constructor(...args) {
23 super(...args);
24
25 // Register action handlers
26 this.actions.service.setActive.listen(this._setActive.bind(this));
27 this.actions.service.showAddServiceInterface.listen(this._showAddServiceInterface.bind(this));
28 this.actions.service.createService.listen(this._createService.bind(this));
29 this.actions.service.createFromLegacyService.listen(this._createFromLegacyService.bind(this));
30 this.actions.service.updateService.listen(this._updateService.bind(this));
31 this.actions.service.deleteService.listen(this._deleteService.bind(this));
32 this.actions.service.setWebviewReference.listen(this._setWebviewReference.bind(this));
33 this.actions.service.focusService.listen(this._focusService.bind(this));
34 this.actions.service.focusActiveService.listen(this._focusActiveService.bind(this));
35 this.actions.service.toggleService.listen(this._toggleService.bind(this));
36 this.actions.service.handleIPCMessage.listen(this._handleIPCMessage.bind(this));
37 this.actions.service.sendIPCMessage.listen(this._sendIPCMessage.bind(this));
38 this.actions.service.setUnreadMessageCount.listen(this._setUnreadMessageCount.bind(this));
39 this.actions.service.openWindow.listen(this._openWindow.bind(this));
40 this.actions.service.filter.listen(this._filter.bind(this));
41 this.actions.service.resetFilter.listen(this._resetFilter.bind(this));
42 this.actions.service.reload.listen(this._reload.bind(this));
43 this.actions.service.reloadActive.listen(this._reloadActive.bind(this));
44 this.actions.service.reloadAll.listen(this._reloadAll.bind(this));
45 this.actions.service.reloadUpdatedServices.listen(this._reloadUpdatedServices.bind(this));
46 this.actions.service.reorder.listen(this._reorder.bind(this));
47 this.actions.service.toggleNotifications.listen(this._toggleNotifications.bind(this));
48 this.actions.service.openDevTools.listen(this._openDevTools.bind(this));
49 this.actions.service.openDevToolsForActiveService.listen(this._openDevToolsForActiveService.bind(this));
50
51 this.registerReactions([
52 this._focusServiceReaction.bind(this),
53 this._getUnreadMessageCountReaction.bind(this),
54 this._mapActiveServiceToServiceModelReaction.bind(this),
55 this._saveActiveService.bind(this),
56 this._logoutReaction.bind(this),
57 ]);
58
59 // Just bind this
60 this._initializeServiceRecipeInWebview.bind(this);
61 }
62
63 @computed get all() {
64 if (this.stores.user.isLoggedIn) {
65 const services = this.allServicesRequest.execute().result;
66 if (services) {
67 return observable(services.slice().slice().sort((a, b) => a.order - b.order));
68 }
69 }
70
71 return [];
72 }
73
74 @computed get enabled() {
75 return this.all.filter(service => service.isEnabled);
76 }
77
78 @computed get filtered() {
79 return this.all.filter(service => service.name.toLowerCase().includes(this.filterNeedle.toLowerCase()));
80 }
81
82 @computed get active() {
83 return this.all.find(service => service.isActive);
84 }
85
86 @computed get activeSettings() {
87 const match = matchRoute('/settings/services/edit/:id', this.stores.router.location.pathname);
88 if (match) {
89 const activeService = this.one(match.id);
90 if (activeService) {
91 return activeService;
92 }
93
94 console.warn('Service not available');
95 }
96
97 return null;
98 }
99
100 one(id) {
101 return this.all.find(service => service.id === id);
102 }
103
104 async _showAddServiceInterface({ recipeId }) {
105 const recipesStore = this.stores.recipes;
106
107 if (recipesStore.isInstalled(recipeId)) {
108 console.debug('Recipe is installed');
109 this._redirectToAddServiceRoute(recipeId);
110 } else {
111 console.warn('Recipe is not installed');
112 // We access the RecipeStore action directly
113 // returns Promise instead of action
114 await this.stores.recipes._install({ recipeId });
115 this._redirectToAddServiceRoute(recipeId);
116 }
117 }
118
119 // Actions
120 @action async _createService({ recipeId, serviceData, redirect = true }) {
121 const data = this._cleanUpTeamIdAndCustomUrl(recipeId, serviceData);
122 const response = await this.createServiceRequest.execute(recipeId, data)._promise;
123
124 this.allServicesRequest.patch((result) => {
125 if (!result) return;
126 result.push(response.data);
127 });
128
129 this.actionStatus = response.status || [];
130
131 if (redirect) {
132 this.stores.router.push('/settings/recipes');
133 gaEvent('Service', 'create', recipeId);
134 }
135 }
136
137 @action async _createFromLegacyService({ data }) {
138 const { id } = data.recipe;
139 const serviceData = {};
140
141 if (data.name) {
142 serviceData.name = data.name;
143 }
144
145 if (data.team) {
146 serviceData.team = data.team;
147 }
148
149 if (data.team) {
150 serviceData.customUrl = data.customURL;
151 }
152
153 this.actions.service.createService({
154 recipeId: id,
155 serviceData,
156 redirect: false,
157 });
158
159 return 'hello world';
160 }
161
162 @action async _updateService({ serviceId, serviceData, redirect = true }) {
163 const service = this.one(serviceId);
164 const data = this._cleanUpTeamIdAndCustomUrl(service.recipe.id, serviceData);
165 const request = this.updateServiceRequest.execute(serviceId, data);
166
167 this.allServicesRequest.patch((result) => {
168 if (!result) return;
169 Object.assign(result.find(c => c.id === serviceId), serviceData);
170 });
171
172 await request._promise;
173 this.actionStatus = request.result.status;
174
175 if (redirect) {
176 this.stores.router.push('/settings/services');
177 gaEvent('Service', 'update', service.recipe.id);
178 }
179 }
180
181 @action async _deleteService({ serviceId, redirect }) {
182 const request = this.deleteServiceRequest.execute(serviceId);
183
184 if (redirect) {
185 this.stores.router.push(redirect);
186 }
187
188 this.allServicesRequest.patch((result) => {
189 remove(result, c => c.id === serviceId);
190 });
191
192 const service = this.one(serviceId);
193
194 await request._promise;
195 this.actionStatus = request.result.status;
196
197 gaEvent('Service', 'delete', service.recipe.id);
198 }
199
200 @action _setActive({ serviceId }) {
201 const service = this.one(serviceId);
202
203 this.all.forEach((s, index) => {
204 this.all[index].isActive = false;
205 });
206 service.isActive = true;
207 }
208
209 @action _setUnreadMessageCount({ serviceId, count }) {
210 const service = this.one(serviceId);
211
212 service.unreadDirectMessageCount = count.direct;
213 service.unreadIndirectMessageCount = count.indirect;
214 }
215
216 @action _setWebviewReference({ serviceId, webview }) {
217 const service = this.one(serviceId);
218
219 service.webview = webview;
220
221 if (!service.isAttached) {
222 service.initializeWebViewEvents(this);
223 service.initializeWebViewListener();
224 }
225
226 service.isAttached = true;
227 }
228
229 @action _focusService({ serviceId }) {
230 const service = this.one(serviceId);
231
232 if (service.webview) {
233 service.webview.focus();
234 }
235 }
236
237 @action _focusActiveService() {
238 if (this.stores.user.isLoggedIn) {
239 // TODO: add checks to not focus service when router path is /settings or /auth
240 const service = this.active;
241 if (service) {
242 this._focusService({ serviceId: service.id });
243 }
244 } else {
245 this.allServicesRequest.invalidate();
246 }
247 }
248
249 @action _toggleService({ serviceId }) {
250 const service = this.one(serviceId);
251
252 service.isEnabled = !service.isEnabled;
253 }
254
255 @action _handleIPCMessage({ serviceId, channel, args }) {
256 const service = this.one(serviceId);
257
258 if (channel === 'hello') {
259 this._initRecipePolling(service.id);
260 this._initializeServiceRecipeInWebview(serviceId);
261 } else if (channel === 'messages') {
262 this.actions.service.setUnreadMessageCount({
263 serviceId,
264 count: {
265 direct: args[0].direct,
266 indirect: args[0].indirect,
267 },
268 });
269 } else if (channel === 'notification') {
270 const options = args[0].options;
271 if (service.recipe.hasNotificationSound) {
272 Object.assign(options, {
273 silent: true,
274 });
275 }
276
277 if (service.isNotificationEnabled) {
278 this.actions.app.notify({
279 notificationId: args[0].notificationId,
280 title: args[0].title,
281 options,
282 serviceId,
283 });
284 }
285 } else if (channel === 'avatar') {
286 const url = args[0];
287 if (service.customIconUrl !== url) {
288 service.customIconUrl = url;
289
290 this.actions.service.updateService({
291 serviceId,
292 serviceData: {
293 customIconUrl: url,
294 },
295 redirect: false,
296 });
297 }
298 }
299 }
300
301 @action _sendIPCMessage({ serviceId, channel, args }) {
302 const service = this.one(serviceId);
303
304 service.webview.send(channel, args);
305 }
306
307 @action _openWindow({ event }) {
308 if (event.disposition !== 'new-window' && event.url !== 'about:blank') {
309 this.actions.app.openExternalUrl({ url: event.url });
310 }
311 }
312
313 @action _filter({ needle }) {
314 this.filterNeedle = needle;
315 }
316
317 @action _resetFilter() {
318 this.filterNeedle = null;
319 }
320
321 @action _reload({ serviceId }) {
322 const service = this.one(serviceId);
323 service.resetMessageCount();
324
325 service.webview.reload();
326 }
327
328 @action _reloadActive() {
329 if (this.active) {
330 const service = this.one(this.active.id);
331
332 this._reload({
333 serviceId: service.id,
334 });
335 }
336 }
337
338 @action _reloadAll() {
339 this.enabled.forEach(s => this._reload({
340 serviceId: s.id,
341 }));
342 }
343
344 @action _reloadUpdatedServices() {
345 this._reloadAll();
346 this.actions.ui.toggleServiceUpdatedInfoBar({ visible: false });
347 }
348
349 @action _reorder({ oldIndex, newIndex }) {
350 const oldEnabledSortIndex = this.all.indexOf(this.enabled[oldIndex]);
351 const newEnabledSortIndex = this.all.indexOf(this.enabled[newIndex]);
352
353
354 this.all.splice(newEnabledSortIndex, 0, this.all.splice(oldEnabledSortIndex, 1)[0]);
355
356 const services = {};
357 this.all.forEach((s, index) => {
358 services[this.all[index].id] = index;
359 });
360
361 this.reorderServicesRequest.execute(services);
362 this.allServicesRequest.patch((data) => {
363 data.forEach((s) => {
364 const service = s;
365
366 service.order = this.one(s.id).order;
367 });
368 });
369
370 this._reorderAnalytics();
371 }
372
373 @action _toggleNotifications({ serviceId }) {
374 const service = this.one(serviceId);
375
376 service.isNotificationEnabled = !service.isNotificationEnabled;
377
378 this.actions.service.updateService({
379 serviceId,
380 serviceData: service,
381 redirect: false,
382 });
383 }
384
385 @action _openDevTools({ serviceId }) {
386 const service = this.one(serviceId);
387
388 service.webview.openDevTools();
389 }
390
391 @action _openDevToolsForActiveService() {
392 const service = this.active;
393
394 if (service) {
395 service.webview.openDevTools();
396 } else {
397 console.warn('No service is active');
398 }
399 }
400
401 // Reactions
402 _focusServiceReaction() {
403 const service = this.active;
404 if (service) {
405 this.actions.service.focusService({ serviceId: service.id });
406 }
407 }
408
409 _saveActiveService() {
410 const service = this.active;
411
412 if (service) {
413 this.stores.settings.updateSettingsRequest.execute({
414 activeService: service.id,
415 });
416 }
417 }
418
419 _mapActiveServiceToServiceModelReaction() {
420 const { activeService } = this.stores.settings.all;
421 const services = this.enabled;
422 if (services.length) {
423 services.map(service => Object.assign(service, {
424 isActive: activeService ? activeService === service.id : services[0].id === service.id,
425 }));
426
427 // if (!services.active) {
428 //
429 // }
430 }
431 // else if (!activeService && services.length) {
432 // services[0].isActive = true;
433 // }
434 }
435
436 _getUnreadMessageCountReaction() {
437 const unreadDirectMessageCount = this.enabled
438 .map(s => s.unreadDirectMessageCount)
439 .reduce((a, b) => a + b, 0);
440
441 const unreadIndirectMessageCount = this.enabled
442 .filter(s => s.isIndirectMessageBadgeEnabled)
443 .map(s => s.unreadIndirectMessageCount)
444 .reduce((a, b) => a + b, 0);
445
446 this.actions.app.setBadge({
447 unreadDirectMessageCount,
448 unreadIndirectMessageCount,
449 });
450 }
451
452 _logoutReaction() {
453 if (!this.stores.user.isLoggedIn) {
454 this.actions.settings.remove({ key: 'activeService' });
455 this.allServicesRequest.invalidate().reset();
456 }
457 }
458
459 _cleanUpTeamIdAndCustomUrl(recipeId, data) {
460 const serviceData = data;
461 const recipe = this.stores.recipes.one(recipeId);
462
463 if (recipe.hasTeamId && recipe.hasCustomUrl && data.team && data.customUrl) {
464 delete serviceData.team;
465 }
466
467 return serviceData;
468 }
469
470 // Helper
471 _redirectToAddServiceRoute(recipeId) {
472 const route = `/settings/services/add/${recipeId}`;
473 this.stores.router.push(route);
474 }
475
476 _initializeServiceRecipeInWebview(serviceId) {
477 const service = this.one(serviceId);
478
479 if (service.webview) {
480 service.webview.send('initializeRecipe', service);
481 }
482 }
483
484 _initRecipePolling(serviceId) {
485 const service = this.one(serviceId);
486
487 const delay = 1000;
488
489 if (service) {
490 const loop = () => {
491 service.webview.send('poll');
492
493 setTimeout(loop, delay);
494 };
495
496 loop();
497 }
498 }
499
500 _reorderAnalytics = debounce(() => {
501 gaEvent('Service', 'order');
502 }, 5000);
503}
diff --git a/src/stores/SettingsStore.js b/src/stores/SettingsStore.js
new file mode 100644
index 000000000..816f545ee
--- /dev/null
+++ b/src/stores/SettingsStore.js
@@ -0,0 +1,55 @@
1import { ipcRenderer } from 'electron';
2import { action, computed, observable } from 'mobx';
3
4import Store from './lib/Store';
5import Request from './lib/Request';
6import CachedRequest from './lib/CachedRequest';
7import { gaEvent } from '../lib/analytics';
8
9export default class SettingsStore extends Store {
10 @observable allSettingsRequest = new CachedRequest(this.api.local, 'getSettings');
11 @observable updateSettingsRequest = new Request(this.api.local, 'updateSettings');
12 @observable removeSettingsKeyRequest = new Request(this.api.local, 'removeKey');
13
14 constructor(...args) {
15 super(...args);
16
17 // Register action handlers
18 this.actions.settings.update.listen(this._update.bind(this));
19 this.actions.settings.remove.listen(this._remove.bind(this));
20
21 // this.registerReactions([
22 // this._shareSettingsWithMainProcess.bind(this),
23 // ]);
24 }
25
26 setup() {
27 this.allSettingsRequest.execute();
28 this._shareSettingsWithMainProcess();
29 }
30
31 @computed get all() {
32 return this.allSettingsRequest.result || {};
33 }
34
35 @action async _update({ settings }) {
36 await this.updateSettingsRequest.execute(settings)._promise;
37 await this.allSettingsRequest.invalidate({ immediately: true });
38
39 this._shareSettingsWithMainProcess();
40
41 gaEvent('Settings', 'update');
42 }
43
44 @action async _remove({ key }) {
45 await this.removeSettingsKeyRequest.execute(key);
46 await this.allSettingsRequest.invalidate({ immediately: true });
47
48 this._shareSettingsWithMainProcess();
49 }
50
51 // Reactions
52 _shareSettingsWithMainProcess() {
53 ipcRenderer.send('settings', this.all);
54 }
55}
diff --git a/src/stores/UIStore.js b/src/stores/UIStore.js
new file mode 100644
index 000000000..cb45b88b5
--- /dev/null
+++ b/src/stores/UIStore.js
@@ -0,0 +1,34 @@
1import { action, observable } from 'mobx';
2
3import Store from './lib/Store';
4
5export default class UIStore extends Store {
6 @observable showServicesUpdatedInfoBar = false;
7
8 constructor(...args) {
9 super(...args);
10
11 // Register action handlers
12 this.actions.ui.openSettings.listen(this._openSettings.bind(this));
13 this.actions.ui.closeSettings.listen(this._closeSettings.bind(this));
14 this.actions.ui.toggleServiceUpdatedInfoBar.listen(this._toggleServiceUpdatedInfoBar.bind(this));
15 }
16
17 // Actions
18 @action _openSettings({ path = '/settings' }) {
19 const settingsPath = path !== '/settings' ? `/settings/${path}` : path;
20 this.stores.router.push(settingsPath);
21 }
22
23 @action _closeSettings(): void {
24 this.stores.router.push('/');
25 }
26
27 @action _toggleServiceUpdatedInfoBar({ visible }) {
28 let visibility = visible;
29 if (visibility === null) {
30 visibility = !this.showServicesUpdatedInfoBar;
31 }
32 this.showServicesUpdatedInfoBar = visibility;
33 }
34}
diff --git a/src/stores/UserStore.js b/src/stores/UserStore.js
new file mode 100644
index 000000000..4927d615f
--- /dev/null
+++ b/src/stores/UserStore.js
@@ -0,0 +1,272 @@
1import { observable, computed, action } from 'mobx';
2import moment from 'moment';
3import jwt from 'jsonwebtoken';
4
5import Store from './lib/Store';
6import Request from './lib/Request';
7import CachedRequest from './lib/CachedRequest';
8import { gaEvent } from '../lib/analytics';
9
10// TODO: split stores into UserStore and AuthStore
11export default class UserStore extends Store {
12 BASE_ROUTE = '/auth';
13 WELCOME_ROUTE = `${this.BASE_ROUTE}/welcome`;
14 LOGIN_ROUTE = `${this.BASE_ROUTE}/login`;
15 LOGOUT_ROUTE = `${this.BASE_ROUTE}/logout`;
16 SIGNUP_ROUTE = `${this.BASE_ROUTE}/signup`;
17 PRICING_ROUTE = `${this.BASE_ROUTE}/signup/pricing`;
18 IMPORT_ROUTE = `${this.BASE_ROUTE}/signup/import`;
19 INVITE_ROUTE = `${this.BASE_ROUTE}/signup/invite`;
20 PASSWORD_ROUTE = `${this.BASE_ROUTE}/password`;
21
22 @observable loginRequest = new Request(this.api.user, 'login');
23 @observable signupRequest = new Request(this.api.user, 'signup');
24 @observable passwordRequest = new Request(this.api.user, 'password');
25 @observable inviteRequest = new Request(this.api.user, 'invite');
26 @observable getUserInfoRequest = new CachedRequest(this.api.user, 'getInfo');
27 @observable updateUserInfoRequest = new Request(this.api.user, 'updateInfo');
28 @observable getLegacyServicesRequest = new CachedRequest(this.api.user, 'getLegacyServices');
29
30 @observable isImportLegacyServicesExecuting = false;
31 @observable isImportLegacyServicesCompleted = false;
32
33 @observable id;
34 @observable authToken = localStorage.getItem('authToken') || null;
35 @observable accountType;
36
37 @observable hasCompletedSignup = null;
38
39 @observable userData = {};
40
41 @observable actionStatus = [];
42
43 logoutReasonTypes = {
44 SERVER: 'SERVER',
45 };
46 @observable logoutReason = null;
47
48 constructor(...args) {
49 super(...args);
50
51 // Register action handlers
52 this.actions.user.login.listen(this._login.bind(this));
53 this.actions.user.retrievePassword.listen(this._retrievePassword.bind(this));
54 this.actions.user.logout.listen(this._logout.bind(this));
55 this.actions.user.signup.listen(this._signup.bind(this));
56 this.actions.user.invite.listen(this._invite.bind(this));
57 this.actions.user.update.listen(this._update.bind(this));
58 this.actions.user.resetStatus.listen(this._resetStatus.bind(this));
59 this.actions.user.importLegacyServices.listen(this._importLegacyServices.bind(this));
60
61 // Reactions
62 this.registerReactions([
63 this._requireAuthenticatedUser,
64 this._getUserData.bind(this),
65 ]);
66 }
67
68 // Routes
69 get loginRoute() {
70 return this.LOGIN_ROUTE;
71 }
72
73 get logoutRoute() {
74 return this.LOGOUT_ROUTE;
75 }
76
77 get signupRoute() {
78 return this.SIGNUP_ROUTE;
79 }
80
81 get pricingRoute() {
82 return this.PRICING_ROUTE;
83 }
84
85 get inviteRoute() {
86 return this.INVITE_ROUTE;
87 }
88
89 get importRoute() {
90 return this.IMPORT_ROUTE;
91 }
92
93 get passwordRoute() {
94 return this.PASSWORD_ROUTE;
95 }
96
97 // Data
98 @computed get isLoggedIn() {
99 return this.authToken !== null && this.authToken !== undefined;
100 }
101
102 // @computed get isTokenValid() {
103 // return this.authToken !== null && moment(this.tokenExpiry).isAfter(moment());
104 // }
105
106 @computed get isTokenExpired() {
107 if (!this.authToken) return false;
108
109 const { tokenExpiry } = this._parseToken(this.authToken);
110 return this.authToken !== null && moment(tokenExpiry).isBefore(moment());
111 }
112
113 @computed get data() {
114 this.getUserInfoRequest.execute();
115 return this.getUserInfoRequest.result || {};
116 }
117
118 @computed get legacyServices() {
119 this.getLegacyServicesRequest.execute();
120 return this.getLegacyServicesRequest.result || [];
121 }
122
123 // Actions
124 @action async _login({ email, password }) {
125 const authToken = await this.loginRequest.execute(email, password)._promise;
126 this._setUserData(authToken);
127
128 this.stores.router.push('/');
129
130 gaEvent('User', 'login');
131 }
132
133 @action async _signup({ firstname, lastname, email, password, accountType, company }) {
134 const authToken = await this.signupRequest.execute({
135 firstname,
136 lastname,
137 email,
138 password,
139 accountType,
140 company,
141 });
142
143 this.hasCompletedSignup = false;
144
145 this._setUserData(authToken);
146
147 this.stores.router.push(this.PRICING_ROUTE);
148
149 gaEvent('User', 'signup');
150 }
151
152 @action async _retrievePassword({ email }) {
153 const request = this.passwordRequest.execute(email);
154
155 await request._promise;
156 this.actionStatus = request.result.status || [];
157
158 gaEvent('User', 'retrievePassword');
159 }
160
161 @action _invite({ invites }) {
162 const data = invites.filter(invite => invite.email !== '');
163
164 this.inviteRequest.execute(data);
165
166 // we do not wait for a server response before redirecting the user
167 this.stores.router.push('/');
168
169 gaEvent('User', 'inviteUsers');
170 }
171
172 @action async _update({ userData }) {
173 const response = await this.updateUserInfoRequest.execute(userData)._promise;
174
175 this.getUserInfoRequest.patch(() => response.data);
176 this.actionStatus = response.status || [];
177
178 gaEvent('User', 'update');
179 }
180
181 @action _resetStatus() {
182 this.actionStatus = [];
183 }
184
185 @action _logout() {
186 localStorage.removeItem('authToken');
187 this.getUserInfoRequest.invalidate().reset();
188 this.authToken = null;
189 // this.data = {};
190 }
191
192 @action async _importLegacyServices({ services }) {
193 this.isImportLegacyServicesExecuting = true;
194
195 for (const service of services) {
196 this.actions.service.createFromLegacyService({
197 data: service,
198 });
199 await this.stores.services.createServiceRequest._promise; // eslint-disable-line
200 }
201
202 this.isImportLegacyServicesExecuting = false;
203 this.isImportLegacyServicesCompleted = true;
204 }
205
206 // This is a mobx autorun which forces the user to login if not authenticated
207 _requireAuthenticatedUser = () => {
208 if (this.isTokenExpired) {
209 this._logout();
210 }
211
212 const { router } = this.stores;
213 const currentRoute = router.location.pathname;
214 if (!this.isLoggedIn
215 && !currentRoute.includes(this.BASE_ROUTE)) {
216 router.push(this.WELCOME_ROUTE);
217 } else if (this.isLoggedIn
218 && currentRoute === this.LOGOUT_ROUTE) {
219 this.actions.user.logout();
220 router.push(this.LOGIN_ROUTE);
221 } else if (this.isLoggedIn
222 && currentRoute.includes(this.BASE_ROUTE)
223 && (this.hasCompletedSignup
224 || this.hasCompletedSignup === null)) {
225 this.stores.router.push('/');
226 }
227 };
228
229 // Reactions
230 async _getUserData() {
231 if (this.isLoggedIn) {
232 const data = await this.getUserInfoRequest.execute()._promise;
233
234 // We need to set the beta flag for the SettingsStore
235 this.actions.settings.update({
236 settings: {
237 beta: data.beta,
238 },
239 });
240 }
241 }
242
243 // Helpers
244 _parseToken(authToken) {
245 try {
246 const decoded = jwt.decode(authToken);
247
248 return ({
249 id: decoded.userId,
250 tokenExpiry: moment.unix(decoded.exp).toISOString(),
251 authToken,
252 });
253 } catch (err) {
254 console.error('AccessToken Invalid');
255
256 return false;
257 }
258 }
259
260 _setUserData(authToken) {
261 const data = this._parseToken(authToken);
262 if (data.authToken) {
263 localStorage.setItem('authToken', data.authToken);
264
265 this.authToken = data.authToken;
266 this.id = data.id;
267 } else {
268 this.authToken = null;
269 this.id = null;
270 }
271 }
272}
diff --git a/src/stores/index.js b/src/stores/index.js
new file mode 100644
index 000000000..2d99e3952
--- /dev/null
+++ b/src/stores/index.js
@@ -0,0 +1,34 @@
1import AppStore from './AppStore';
2import UserStore from './UserStore';
3import SettingsStore from './SettingsStore';
4import ServicesStore from './ServicesStore';
5import RecipesStore from './RecipesStore';
6import RecipePreviewsStore from './RecipePreviewsStore';
7import UIStore from './UIStore';
8import PaymentStore from './PaymentStore';
9import NewsStore from './NewsStore';
10import RequestStore from './RequestStore';
11import GlobalErrorStore from './GlobalErrorStore';
12
13export default (api, actions, router) => {
14 const stores = {};
15 Object.assign(stores, {
16 router,
17 app: new AppStore(stores, api, actions),
18 user: new UserStore(stores, api, actions),
19 settings: new SettingsStore(stores, api, actions),
20 services: new ServicesStore(stores, api, actions),
21 recipes: new RecipesStore(stores, api, actions),
22 recipePreviews: new RecipePreviewsStore(stores, api, actions),
23 ui: new UIStore(stores, api, actions),
24 payment: new PaymentStore(stores, api, actions),
25 news: new NewsStore(stores, api, actions),
26 requests: new RequestStore(stores, api, actions),
27 globalError: new GlobalErrorStore(stores, api, actions),
28 });
29 // Initialize all stores
30 Object.keys(stores).forEach((name) => {
31 if (stores[name] && stores[name].initialize) stores[name].initialize();
32 });
33 return stores;
34};
diff --git a/src/stores/lib/CachedRequest.js b/src/stores/lib/CachedRequest.js
new file mode 100644
index 000000000..c0c3d40a1
--- /dev/null
+++ b/src/stores/lib/CachedRequest.js
@@ -0,0 +1,106 @@
1// @flow
2import { action } from 'mobx';
3import { isEqual, remove } from 'lodash';
4import Request from './Request';
5
6export default class CachedRequest extends Request {
7 _apiCalls = [];
8 _isInvalidated = true;
9
10 execute(...callArgs) {
11 // Do not continue if this request is already loading
12 if (this._isWaitingForResponse) return this;
13
14 // Very simple caching strategy -> only continue if the call / args changed
15 // or the request was invalidated manually from outside
16 const existingApiCall = this._findApiCall(callArgs);
17
18 // Invalidate if new or different api call will be done
19 if (existingApiCall && existingApiCall !== this._currentApiCall) {
20 this._isInvalidated = true;
21 this._currentApiCall = existingApiCall;
22 } else if (!existingApiCall) {
23 this._isInvalidated = true;
24 this._currentApiCall = this._addApiCall(callArgs);
25 }
26
27 // Do not continue if this request is not invalidated (see above)
28 if (!this._isInvalidated) return this;
29
30 // This timeout is necessary to avoid warnings from mobx
31 // regarding triggering actions as side-effect of getters
32 setTimeout(action(() => {
33 this.isExecuting = true;
34 // Apply the previous result from this call immediately (cached)
35 if (existingApiCall) {
36 this.result = existingApiCall.result;
37 }
38 }), 0);
39
40 // Issue api call & save it as promise that is handled to update the results of the operation
41 this._promise = new Promise((resolve, reject) => {
42 this._api[this._method](...callArgs)
43 .then((result) => {
44 setTimeout(action(() => {
45 this.result = result;
46 if (this._currentApiCall) this._currentApiCall.result = result;
47 this.isExecuting = false;
48 this.isError = false;
49 this.wasExecuted = true;
50 this._isInvalidated = false;
51 this._isWaitingForResponse = false;
52 this._triggerHooks();
53 resolve(result);
54 }), 1);
55 return result;
56 })
57 .catch(action((error) => {
58 setTimeout(action(() => {
59 this.error = error;
60 this.isExecuting = false;
61 this.isError = true;
62 this.wasExecuted = true;
63 this._isWaitingForResponse = false;
64 this._triggerHooks();
65 reject(error);
66 }), 1);
67 }));
68 });
69
70 this._isWaitingForResponse = true;
71 return this;
72 }
73
74 invalidate(options = { immediately: false }) {
75 this._isInvalidated = true;
76 if (options.immediately && this._currentApiCall) {
77 return this.execute(...this._currentApiCall.args);
78 }
79 return this;
80 }
81
82 patch(modify) {
83 return new Promise((resolve) => {
84 setTimeout(action(() => {
85 const override = modify(this.result);
86 if (override !== undefined) this.result = override;
87 if (this._currentApiCall) this._currentApiCall.result = this.result;
88 resolve(this);
89 }), 0);
90 });
91 }
92
93 removeCacheForCallWith(...args) {
94 remove(this._apiCalls, c => isEqual(c.args, args));
95 }
96
97 _addApiCall(args) {
98 const newCall = { args, result: null };
99 this._apiCalls.push(newCall);
100 return newCall;
101 }
102
103 _findApiCall(args) {
104 return this._apiCalls.find(c => isEqual(c.args, args));
105 }
106}
diff --git a/src/stores/lib/Reaction.js b/src/stores/lib/Reaction.js
new file mode 100644
index 000000000..e9bc26d81
--- /dev/null
+++ b/src/stores/lib/Reaction.js
@@ -0,0 +1,22 @@
1// @flow
2import { autorun } from 'mobx';
3
4export default class Reaction {
5 reaction;
6 hasBeenStarted;
7 dispose;
8
9 constructor(reaction) {
10 this.reaction = reaction;
11 this.hasBeenStarted = false;
12 }
13
14 start() {
15 this.dispose = autorun(() => this.reaction());
16 this.hasBeenStarted = true;
17 }
18
19 stop() {
20 if (this.hasBeenStarted) this.dispose();
21 }
22}
diff --git a/src/stores/lib/Request.js b/src/stores/lib/Request.js
new file mode 100644
index 000000000..4a6925cc5
--- /dev/null
+++ b/src/stores/lib/Request.js
@@ -0,0 +1,112 @@
1import { observable, action, computed } from 'mobx';
2import { isEqual } from 'lodash/fp';
3
4export default class Request {
5 static _hooks = [];
6
7 static registerHook(hook) {
8 Request._hooks.push(hook);
9 }
10
11 @observable result = null;
12 @observable error = null;
13 @observable isExecuting = false;
14 @observable isError = false;
15 @observable wasExecuted = false;
16
17 _promise = Promise;
18 _api = {};
19 _method = '';
20 _isWaitingForResponse = false;
21 _currentApiCall = null;
22
23 constructor(api, method) {
24 this._api = api;
25 this._method = method;
26 }
27
28 execute(...callArgs) {
29 // Do not continue if this request is already loading
30 if (this._isWaitingForResponse) return this;
31
32 if (!this._api[this._method]) {
33 throw new Error(`Missing method <${this._method}> on api object:`, this._api);
34 }
35
36 // This timeout is necessary to avoid warnings from mobx
37 // regarding triggering actions as side-effect of getters
38 setTimeout(action(() => {
39 this.isExecuting = true;
40 }), 0);
41
42 // Issue api call & save it as promise that is handled to update the results of the operation
43 this._promise = new Promise((resolve, reject) => {
44 this._api[this._method](...callArgs)
45 .then((result) => {
46 setTimeout(action(() => {
47 this.result = result;
48 if (this._currentApiCall) this._currentApiCall.result = result;
49 this.isExecuting = false;
50 this.isError = false;
51 this.wasExecuted = true;
52 this._isWaitingForResponse = false;
53 this._triggerHooks();
54 resolve(result);
55 }), 1);
56 return result;
57 })
58 .catch(action((error) => {
59 setTimeout(action(() => {
60 this.error = error;
61 this.isExecuting = false;
62 this.isError = true;
63 this.wasExecuted = true;
64 this._isWaitingForResponse = false;
65 this._triggerHooks();
66 reject(error);
67 }), 1);
68 }));
69 });
70
71 this._isWaitingForResponse = true;
72 this._currentApiCall = { args: callArgs, result: null };
73 return this;
74 }
75
76 reload() {
77 return this.execute(...this._currentApiCall.args);
78 }
79
80 isExecutingWithArgs(...args) {
81 return this.isExecuting && this._currentApiCall && isEqual(this._currentApiCall.args, args);
82 }
83
84 @computed get isExecutingFirstTime() {
85 return !this.wasExecuted && this.isExecuting;
86 }
87
88 then(...args) {
89 if (!this._promise) throw new Error('You have to call Request::execute before you can access it as promise');
90 return this._promise.then(...args);
91 }
92
93 catch(...args) {
94 if (!this._promise) throw new Error('You have to call Request::execute before you can access it as promise');
95 return this._promise.catch(...args);
96 }
97
98 _triggerHooks() {
99 Request._hooks.forEach(hook => hook(this));
100 }
101
102 reset() {
103 this.result = null;
104 this.isExecuting = false;
105 this.isError = false;
106 this.wasExecuted = false;
107 this._isWaitingForResponse = false;
108 this._promise = Promise;
109
110 return this;
111 }
112}
diff --git a/src/stores/lib/Store.js b/src/stores/lib/Store.js
new file mode 100644
index 000000000..873da7b37
--- /dev/null
+++ b/src/stores/lib/Store.js
@@ -0,0 +1,44 @@
1import { computed, observable } from 'mobx';
2import Reaction from './Reaction';
3
4export default class Store {
5 stores = {};
6 api = {};
7 actions = {};
8
9 _reactions = [];
10
11 // status implementation
12 @observable _status = null;
13 @computed get actionStatus() {
14 return this._status || [];
15 }
16 set actionStatus(status) {
17 this._status = status;
18 }
19
20 constructor(stores, api, actions) {
21 this.stores = stores;
22 this.api = api;
23 this.actions = actions;
24 }
25
26 registerReactions(reactions) {
27 reactions.forEach(reaction => this._reactions.push(new Reaction(reaction)));
28 }
29
30 setup() {}
31
32 initialize() {
33 this.setup();
34 this._reactions.forEach(reaction => reaction.start());
35 }
36
37 teardown() {
38 this._reactions.forEach(reaction => reaction.stop());
39 }
40
41 resetStatus() {
42 this._status = null;
43 }
44}
diff --git a/src/styles/animations.scss b/src/styles/animations.scss
new file mode 100644
index 000000000..1e49af207
--- /dev/null
+++ b/src/styles/animations.scss
@@ -0,0 +1,90 @@
1// FadeIn
2.fadeIn-appear {
3 opacity: 0.01;
4}
5
6.fadeIn-appear.fadeIn-appear-active {
7 opacity: 1;
8 transition: opacity 0.5s ease-out;
9}
10
11.fadeIn-enter {
12 opacity: 0.01;
13 transition: opacity 0.5s ease-out;
14}
15
16.fadeIn-leave {
17 opacity: 1;
18}
19
20.fadeIn-leave.fadeIn-leave-active {
21 opacity: 0.01;
22 transition: opacity 300ms ease-in;
23}
24
25// FadeIn Fast
26.fadeIn-fast-appear {
27 opacity: 0.01;
28}
29
30.fadeIn-fast-appear.fadeIn-fast-appear-active {
31 opacity: 1;
32 transition: opacity 0.25s ease-out;
33}
34
35.fadeIn-fast-enter {
36 opacity: 0.01;
37 transition: opacity 0.25s ease-out;
38}
39
40.fadeIn-fast-leave {
41 opacity: 1;
42}
43
44.fadeIn-fast-leave.fadeIn-fast-leave-active {
45 opacity: 0.01;
46 transition: opacity 0.25s ease-in;
47}
48
49// Slide down
50.slideDown-appear {
51 max-height: 0;
52 overflow-y: hidden;
53}
54
55.slideDown-appear.slideDown-appear-active {
56 max-height: 500px;
57 transition: max-height 0.5s ease-out;
58}
59
60.slideDown-enter {
61 max-height: 0;
62 transition: max-height 0.5s ease-out;
63}
64
65// Slide up
66.slideUp-appear {
67 transform: translateY(20px);
68 opacity: 0;
69}
70
71.slideUp-appear.slideUp-appear-active {
72 transform: translateY(0px);
73 opacity: 1;
74 transition: all 0.3s ease-out;
75}
76
77.slideUp-enter {
78 transform: translateY(20px);
79 opacity: 0;
80 transition: all 0.3s ease-out;
81}
82
83.slideUp-leave {
84 opacity: 1;
85}
86
87.slideUp-leave.slideUp-leave-active {
88 opacity: 0.01;
89 transition: opacity 300ms ease-in;
90}
diff --git a/src/styles/auth.scss b/src/styles/auth.scss
new file mode 100644
index 000000000..9ad71867c
--- /dev/null
+++ b/src/styles/auth.scss
@@ -0,0 +1,144 @@
1@import './config.scss';
2
3.auth {
4 display: flex;
5 justify-content: center;
6 background: $theme-brand-primary;
7
8 .auth__layout {
9 width: 100%;
10 &>div>span {
11 width: 100%;
12 }
13 // display: flex;
14 // align-items: center;
15 // justify-content: center;
16 // flex-direction: column;
17
18 // @media only screen and (max-height : 700px) {
19 // margin: 100px 0;
20 // }
21
22 &>div {
23 display: flex;
24 justify-content: center;
25 align-items: center;
26
27 &>span {
28 position: absolute;
29 }
30 }
31 }
32
33 .auth__container {
34 position: relative;
35 width: 350px;
36 height: auto;
37 margin: 40px auto 0 auto;
38 background: #FFF;
39 // padding: 20px;
40 border-radius: $theme-border-radius;
41 box-shadow: 0 0 50px rgba(black, 0.2);
42
43 &.auth__container--signup {
44 width: 450px;
45 // margin-left: auto;
46 // margin-right: auto;
47 }
48 }
49
50 .auth__logo {
51 display: block;
52 width: 150px;
53 height: auto;
54 margin: -105px auto 20px auto;
55 border-radius: $theme-border-radius;
56
57 &.auth__logo--sm {
58 border: 4px solid #FFF;
59 box-shadow: 0 0 6px rgba(black, 0.5);
60 border-radius: 100%;
61 }
62 }
63
64 .auth__form {
65 padding: 20px;
66
67 h1 {
68 text-align: center;
69 }
70 }
71
72 .auth__button {
73 width: 100%;
74
75 &.auth__button--skip {
76 margin: 10px auto 0;
77 }
78 }
79
80 .auth__links {
81 padding: 20px;
82 background: $theme-gray-lighter;
83 border-bottom-left-radius: $theme-border-radius;
84 border-bottom-right-radius: $theme-border-radius;
85
86 a {
87 display: block;
88 text-align: center;
89 color: $theme-gray;
90 margin-bottom: 8px;
91
92 &:last-of-type {
93 margin-bottom: 0;
94 }
95 }
96 }
97
98 .auth__adlk {
99 position: absolute;
100 right: 25px;
101 bottom: 15px;
102
103 img {
104 width: 65px;
105 }
106 }
107
108 .auth__letter {
109 margin-bottom: 30px;
110 }
111
112 .scroll-container {
113 z-index: 10;
114 }
115
116 .info-bar {
117 position: absolute;
118 }
119
120 &__scroll-container {
121 overflow: scroll;
122 width: 100%;
123 max-height: 100vh;
124 padding: 80px 0;
125 }
126
127 .available-services {
128 margin-bottom: 15px;
129 }
130
131 .unavailable-services {
132 margin: 15px 0;
133
134 p {
135 text-transform: capitalize;
136 }
137 }
138
139 .legal {
140 text-align: center;
141 margin-top: 20px;
142 color: $theme-gray-light;
143 }
144}
diff --git a/src/styles/badge.scss b/src/styles/badge.scss
new file mode 100644
index 000000000..d7dfaf783
--- /dev/null
+++ b/src/styles/badge.scss
@@ -0,0 +1,15 @@
1@import './config.scss';
2
3.badge {
4 font-size: 14px;
5 display: inline-block;
6 padding: 5px 10px;
7 border-radius: $theme-border-radius;
8 background: $theme-gray-lighter;
9
10 &.badge--primary,
11 &.badge--premium {
12 background: $theme-brand-primary;
13 color: #FFF;
14 }
15}
diff --git a/src/styles/button.scss b/src/styles/button.scss
new file mode 100644
index 000000000..c2dd91293
--- /dev/null
+++ b/src/styles/button.scss
@@ -0,0 +1,74 @@
1@import './config.scss';
2
3.franz-form {
4 .franz-form__button {
5 position: relative;
6 background: $theme-brand-primary;
7 display: block;
8 padding: 10px 20px;
9 color: #FFF;
10 border-radius: 3px;
11 transition: background 0.5s;
12 text-align: center;
13
14 &:hover {
15 background: darken($theme-brand-primary, 5%);
16 }
17
18 &:active {
19 transition: none;
20 background: lighten($theme-brand-primary, 5%);
21 }
22
23 &:disabled {
24 opacity: 0.2;
25 }
26
27 &.franz-form__button--secondary {
28 background: $theme-gray-lighter;
29 color: $theme-gray;
30
31 &:hover {
32 background: darken($theme-gray-lighter, 5%);
33 }
34
35 &:active {
36 background: lighten($theme-gray-lighter, 5%);
37 }
38 }
39
40 &.franz-form__button--danger {
41 background: $theme-brand-danger;
42
43 &:hover {
44 background: darken($theme-brand-danger, 5%);
45 }
46
47 &:active {
48 background: lighten($theme-brand-danger, 5%);
49 }
50 }
51
52 &.franz-form__button--inverted {
53 background: none;
54 padding: 10px 20px;
55 border: 2px solid $theme-brand-primary;
56 color: $theme-brand-primary;
57 transition: background 0.5s, color 0.5s;
58
59 &:hover {
60 background: darken($theme-brand-primary, 5%);
61 color: #FFF;
62 }
63 }
64
65 .loader {
66 position: relative;
67 width: 20px;
68 height: 12px;
69 z-index: 9999;
70 display: inline-block;
71 margin-right: 5px;
72 }
73 }
74}
diff --git a/src/styles/colors.scss b/src/styles/colors.scss
new file mode 100644
index 000000000..5d8302c28
--- /dev/null
+++ b/src/styles/colors.scss
@@ -0,0 +1,22 @@
1$theme-brand-primary: #3498db;
2$theme-brand-success: #5cb85c;
3$theme-brand-info: #5bc0de;
4$theme-brand-warning: #FF9F00;
5$theme-brand-danger: #d9534f;
6
7$theme-gray-dark: #373a3c;
8$theme-gray: #55595c;
9$theme-gray-light: #818a91;
10$theme-gray-lighter: #eceeef;
11$theme-gray-lightest: #f7f7f9;
12
13$theme-border-radius: 6px;
14$theme-border-radius-small: 3px;
15
16$theme-sidebar-width: 68px;
17
18$theme-text-color: $theme-gray-dark;
19
20$theme-transition-time: 0.5s;
21
22$theme-inset-shadow: inset 0 2px 5px rgba(0,0,0,0.03);
diff --git a/src/styles/config.scss b/src/styles/config.scss
new file mode 100644
index 000000000..7aa2d674f
--- /dev/null
+++ b/src/styles/config.scss
@@ -0,0 +1 @@
@import './colors.scss';
diff --git a/src/styles/content-tabs.scss b/src/styles/content-tabs.scss
new file mode 100644
index 000000000..aa3c8594b
--- /dev/null
+++ b/src/styles/content-tabs.scss
@@ -0,0 +1,52 @@
1@import './config.scss';
2
3.content-tabs {
4 .content-tabs__tabs {
5 display: flex;
6 border-top-left-radius: $theme-border-radius-small;
7 border-top-right-radius: $theme-border-radius-small;
8 overflow: hidden;
9
10 .content-tabs__item {
11 padding: 10px;
12 flex: 1;
13 // border: 1px solid $theme-gray-lightest;
14 color: $theme-gray-dark;
15 background: $theme-gray-lightest;
16 border-bottom: 1px solid $theme-gray-lighter;
17 box-shadow: inset 0px -3px 10px rgba(black, 0.05);
18 transition: all $theme-transition-time;
19
20 &.is-active {
21 background: $theme-brand-primary;
22 color: #FFF;
23 border-bottom: 1px solid $theme-brand-primary;
24 box-shadow: none;
25 }
26 }
27 }
28
29 .content-tabs__content {
30 padding: 20px 20px;
31 border-bottom-left-radius: $theme-border-radius-small;
32 border-bottom-right-radius: $theme-border-radius-small;
33 background: $theme-gray-lightest;
34
35 .content-tabs__item {
36 top: 0;
37 display: none;
38
39 &.is-active {
40 display: block;
41 }
42 }
43
44 .franz-form__input-wrapper {
45 background: #FFF;
46 }
47
48 .franz-form__field:last-of-type {
49 margin-bottom: 0;
50 }
51 }
52}
diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss
new file mode 100644
index 000000000..bd96ea867
--- /dev/null
+++ b/src/styles/fonts.scss
@@ -0,0 +1,44 @@
1@import './config.scss';
2// @import './node_modules/mdi/scss/materialdesignicons.scss';
3
4@font-face {
5 font-family: 'Open Sans';
6 src: url('../assets/fonts/OpenSans-Light.ttf');
7 font-weight: 300;
8 font-style: normal;
9}
10
11@font-face {
12 font-family: 'Open Sans';
13 src: url('../assets/fonts/OpenSans-Regular.ttf');
14 font-weight: normal;
15 font-style: normal;
16}
17
18@font-face {
19 font-family: 'Open Sans';
20 src: url('../assets/fonts/OpenSans-Bold.ttf');
21 font-weight: bold;
22 font-style: normal;
23}
24
25@font-face {
26 font-family: 'Open Sans';
27 src: url('../assets/fonts/OpenSans-BoldItalic.ttf');
28 font-weight: bold;
29 font-style: italic;
30}
31
32@font-face {
33 font-family: 'Open Sans';
34 src: url('../assets/fonts/OpenSans-ExtraBold.ttf');
35 font-weight: 800;
36 font-style: normal;
37}
38
39@font-face {
40 font-family: 'Open Sans';
41 src: url('../assets/fonts/OpenSans-ExtraBoldItalic.ttf');
42 font-weight: 800;
43 font-style: italic;
44}
diff --git a/src/styles/info-bar.scss b/src/styles/info-bar.scss
new file mode 100644
index 000000000..c30c951ee
--- /dev/null
+++ b/src/styles/info-bar.scss
@@ -0,0 +1,79 @@
1@import './config.scss';
2
3.info-bar {
4 width: 100%;
5 height: 50px;
6 background: $theme-brand-primary;
7 display: flex;
8 align-items: center;
9 justify-content: center;
10 padding: 0 20px;
11 position: relative;
12 // bottom: 0;
13 z-index: 100;
14 box-shadow: 0 0 8px rgba(black, 0.2);
15
16 .info-bar__content {
17 height: auto;
18
19 .mdi {
20 margin-right: 5px;
21 }
22 }
23
24 .info-bar__close {
25 position: absolute;
26 right: 10px;
27 color: #FFF;
28 }
29
30 .info-bar__cta {
31 color: #FFF;
32 padding: 3px 8px;
33 border-radius: $theme-border-radius-small;
34 border-color: #FFF;
35 border-width: 2px;
36 border-style: solid;
37 margin-left: 15px;
38
39 .loader {
40 position: relative;
41 width: 20px;
42 height: 12px;
43 z-index: 9999;
44 display: inline-block;
45 margin-right: 5px;
46 }
47 }
48
49 &.info-bar--bottom {
50 order: 10;
51 }
52
53 &.info-bar--primary {
54 background: $theme-brand-primary;
55 color: #FFF;
56
57 a {
58 color: #FFF;
59 }
60 }
61
62 &.info-bar--warning {
63 background: $theme-brand-warning;
64 color: #FFF;
65
66 a {
67 color: #FFF;
68 }
69 }
70
71 &.info-bar--danger {
72 background: $theme-brand-danger;
73 color: #FFF;
74
75 a {
76 color: #FFF;
77 }
78 }
79}
diff --git a/src/styles/infobox.scss b/src/styles/infobox.scss
new file mode 100644
index 000000000..ad363314d
--- /dev/null
+++ b/src/styles/infobox.scss
@@ -0,0 +1,61 @@
1@import './config.scss';
2
3.infobox {
4 height: auto;
5 padding: 15px 20px;
6 margin-bottom: 30px;
7 border-radius: $theme-border-radius-small;
8 display: flex;
9 align-items: center;
10
11 a {
12 color: #FFF;
13 }
14
15 .infobox__content {
16 flex: 1;
17 }
18
19 &.infobox--success {
20 background: $theme-brand-success;
21 color: #FFF;
22 }
23
24 &.infobox--primary {
25 background: $theme-brand-primary;
26 color: #FFF;
27 }
28
29 &.infobox--danger {
30 background: $theme-brand-danger;
31 color: #FFF;
32 }
33
34 .mdi {
35 margin-right: 10px;
36 }
37
38 .infobox__cta {
39 color: #FFF;
40 padding: 3px 8px;
41 border-radius: $theme-border-radius-small;
42 border-color: #FFF;
43 border-width: 2px;
44 border-style: solid;
45 margin-left: 15px;
46
47 .loader {
48 position: relative;
49 width: 20px;
50 height: 12px;
51 z-index: 9999;
52 display: inline-block;
53 margin-right: 5px;
54 }
55 }
56
57 .infobox__delete {
58 color: #FFF;
59 margin-right: 0;
60 }
61}
diff --git a/src/styles/input.scss b/src/styles/input.scss
new file mode 100644
index 000000000..814dce5f8
--- /dev/null
+++ b/src/styles/input.scss
@@ -0,0 +1,99 @@
1@import './config.scss';
2@import './mixins.scss';
3
4.franz-form {
5 .franz-form__field {
6 display: flex;
7 flex: 1;
8 flex-direction: column;
9 margin-bottom: 20px;
10
11 &.has-error {
12 .franz-form__input-wrapper {
13 border-color: $theme-brand-danger;
14 }
15
16 .franz-form__input-modifier {
17 border-color: $theme-brand-danger;
18 }
19 }
20 }
21
22 .franz-form__label {
23 @include formLabel();
24 }
25
26 .franz-form__error {
27 color: $theme-brand-danger;
28 margin-top: 10px;
29 order: 2;
30 }
31
32 .franz-form__input-wrapper {
33 display: flex;
34 width: 100%;
35 order: 1;
36 border-radius: $theme-border-radius-small;
37 background: $theme-gray-lightest;
38 border: 1px solid $theme-gray-lighter;
39 flex-wrap: wrap;
40 }
41
42 .franz-form__input {
43 flex: 1;
44 border: 0;
45 background: none;
46 width: 100%;
47 padding: 8px;
48 // font-size: 18px;
49 color: $theme-gray;
50 }
51
52 .franz-form__input-prefix,
53 .franz-form__input-suffix {
54 padding: 0 10px;
55 background: $theme-gray-lighter;
56 color: $theme-gray-light;
57 line-height: 35px;
58 }
59
60 .franz-form__input-modifier {
61 padding: 0 20px;
62 border-left: 1px solid $theme-gray-lighter;
63 color: $theme-gray-light;
64 font-size: 20px;
65 }
66
67 .franz-form__password-score {
68 background: $theme-gray-lighter;
69 height: 5px;
70 flex-basis: 100%;
71 border-bottom-left-radius: 3px;
72 border-bottom-right-radius: 3px;
73
74 meter {
75 width: 100%;
76 height: 100%;
77 display: block;
78 border-bottom-left-radius: 3px;
79 border-bottom-right-radius: 3px;
80 overflow: hidden;
81
82 &::-webkit-meter-bar {
83 background: none;
84 }
85
86 &::-webkit-meter-even-less-good-value {
87 background: $theme-brand-danger;
88 }
89
90 &::-webkit-meter-suboptimum-value {
91 background: $theme-brand-warning;
92 }
93
94 &::-webkit-meter-optimum-value {
95 background: $theme-brand-success;
96 }
97 }
98 }
99}
diff --git a/src/styles/layout.scss b/src/styles/layout.scss
new file mode 100644
index 000000000..d87df2684
--- /dev/null
+++ b/src/styles/layout.scss
@@ -0,0 +1,141 @@
1@import './config.scss';
2
3html {
4 overflow: hidden;
5}
6
7.app {
8 display: flex;
9 flex-direction: row;
10
11 .app__service {
12 display: flex;
13 flex: 1;
14 flex-direction: column;
15 }
16}
17
18.window-draggable {
19 position: absolute;
20 width: 100%;
21 top: 0px;
22 left: 0px;
23 height: 35px;
24 pointer-events: none;
25 -webkit-app-region: drag;
26 z-index: 9999;
27}
28
29.darwin {
30 .sidebar {
31 padding-top: 23px;
32 }
33}
34
35.sidebar {
36 display: flex;
37 flex-direction: column;
38 align-items: center;
39 width: $theme-sidebar-width;
40 background: $theme-gray-lightest;
41 box-shadow: 1px 0 10px rgba(0,0,0,0.08);
42 z-index: 200;
43 text-align: center;
44 color: $theme-text-color;
45
46 .sidebar__add-service {
47 width: 32px;
48 height: 32px;
49 background: $theme-gray-lighter;
50 border-radius: $theme-border-radius-small;
51 margin: 10px auto;
52 color: $theme-gray-light;
53 }
54
55 .sidebar__settings-button {
56 height: auto;
57 padding: 20px 0;
58 font-size: 12px;
59 position: relative;
60
61 .emoji {
62 position: absolute;
63 top: 18px;
64 right: 12px;
65
66 img {
67 width: 18px;
68 }
69 }
70 }
71
72 .sidebar__logo {
73 width: 40px;
74 height: auto;
75 }
76
77 & > div {
78 display: flex;
79 overflow-y: scroll;
80
81 &::-webkit-scrollbar {
82 display: none;
83 }
84 }
85}
86
87.grid {
88 .grid__row {
89 display: flex;
90 flex-direction: row;
91
92 &>* {
93 margin-right: 20px;
94 }
95
96 & :last-child {
97 margin-right: 0;
98 }
99 }
100}
101
102.app-loader {
103 display: flex;
104 justify-content: center;
105 align-items: center;
106
107 .app-loader__title {
108 color: #FFF;
109 font-size: 40px;
110 }
111
112 &>span {
113 height: auto;
114 }
115}
116
117.dev-warning {
118 display: none;
119}
120
121.isDevMode {
122 .dev-warning {
123 display: block;
124 position: fixed;
125 background: $theme-brand-warning;
126 width: auto;
127 height: auto;
128 top: 5px;
129 right: 5px;
130 padding: 4px;
131 font-size: 10px;
132 color: #FFF;
133 z-index: 999999999;
134 border-radius: 3px;
135 transition: opacity 0.5s ease;
136
137 &:hover {
138 opacity: 0;
139 }
140 }
141}
diff --git a/src/styles/main.scss b/src/styles/main.scss
new file mode 100644
index 000000000..8afc86f98
--- /dev/null
+++ b/src/styles/main.scss
@@ -0,0 +1,36 @@
1$mdi-font-path: '../node_modules/mdi/fonts';
2@if $env == development {
3 $mdi-font-path: '../../node_modules/mdi/fonts';
4}
5
6@import './node_modules/mdi/scss/materialdesignicons.scss';
7
8// modules
9@import './reset.scss';
10@import './util.scss';
11@import './layout.scss';
12@import './tabs.scss';
13@import './services.scss';
14@import './settings.scss';
15@import './service-table.scss';
16@import './recipes.scss';
17@import './fonts.scss';
18@import './type.scss';
19@import './welcome.scss';
20@import './auth.scss';
21@import './tooltip.scss';
22@import './info-bar.scss';
23@import './animations.scss';
24@import './infobox.scss';
25@import './badge.scss';
26@import './subscription.scss';
27@import './subscription-popup.scss';
28@import './content-tabs.scss';
29
30// form
31@import './input.scss';
32@import './radio.scss';
33@import './toggle.scss';
34@import './button.scss';
35@import './searchInput.scss';
36@import './select.scss';
diff --git a/src/styles/mixins.scss b/src/styles/mixins.scss
new file mode 100644
index 000000000..c9b1bc988
--- /dev/null
+++ b/src/styles/mixins.scss
@@ -0,0 +1,9 @@
1@import './config.scss';
2
3@mixin formLabel {
4 width: 100%;
5 color: $theme-gray-light;
6 display: block;
7 margin-bottom: 5px;
8 order: 0;
9}
diff --git a/src/styles/radio.scss b/src/styles/radio.scss
new file mode 100644
index 000000000..644478cd6
--- /dev/null
+++ b/src/styles/radio.scss
@@ -0,0 +1,34 @@
1@import './config.scss';
2
3.franz-form {
4 .franz-form__radio-wrapper {
5 display: flex;
6 }
7
8 .franz-form__radio {
9 // background: $theme-gray-lightest;
10 border: 2px solid $theme-gray-lighter;
11 color: $theme-gray;
12 padding: 11px;
13 margin-right: 20px;
14 text-align: center;
15 border-radius: $theme-border-radius-small;
16 flex: 1;
17 box-shadow: $theme-inset-shadow;
18 transition: background $theme-transition-time;
19
20 &:last-of-type {
21 margin-right: 0;
22 }
23
24 &.is-selected {
25 border: 2px solid $theme-brand-primary;
26 background: #FFF;
27 color: $theme-brand-primary;
28 }
29
30 input {
31 display: none;
32 }
33 }
34}
diff --git a/src/styles/recipes.scss b/src/styles/recipes.scss
new file mode 100644
index 000000000..017aa4fe2
--- /dev/null
+++ b/src/styles/recipes.scss
@@ -0,0 +1,72 @@
1@import './config.scss';
2
3.recipes {
4 .recipes__list {
5 display: flex;
6 flex-flow: row wrap;
7 align-content: flex-start;
8 min-height: 70%;
9 height: auto;
10
11 &.recipes__list--disabled {
12 opacity: 0.3;
13 filter: grayscale(100%);
14 pointer-events: none;
15 }
16 }
17
18 .recipes__navigation {
19 height: auto;
20 margin-bottom: 35px;
21
22 .badge {
23 margin-right: 10px;
24 }
25
26 &.recipes__navigation--disabled {
27 opacity: 0.3;
28 filter: grayscale(100%);
29 pointer-events: none;
30 }
31 }
32}
33
34.recipe-teaser {
35 position: relative;
36 width: calc(25% - 20px);
37 height: 120px;
38 margin: 0 20px 20px 0;
39 border-radius: $theme-border-radius;
40 background-color: $theme-gray-lightest;
41 transition: background $theme-transition-time;
42 overflow: hidden;
43
44 &:hover {
45 background-color: $theme-gray-lighter;
46 }
47
48 .recipe-teaser__icon {
49 width: 50px;
50 margin-bottom: 10px;
51 }
52
53 .recipe-teaser__label {
54 display: block;
55 }
56
57 h2 {
58 z-index: 10;
59 }
60
61 &__dev-badge {
62 position: absolute;
63 top: 5px;
64 right: -13px;
65 width: 50px;
66 background: $theme-brand-warning;
67 color: #FFF;
68 font-size: 10px;
69 transform: rotateZ(45deg);
70 box-shadow: 0 0 4px rgba(black, 0.2);
71 }
72}
diff --git a/src/styles/reset.scss b/src/styles/reset.scss
new file mode 100644
index 000000000..21763f44f
--- /dev/null
+++ b/src/styles/reset.scss
@@ -0,0 +1,95 @@
1@import './config.scss';
2
3/* ============ RESET =========== */
4/* http://meyerweb.com/eric/tools/css/reset */
5
6html, body, div, span, applet, object, iframe,
7h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8a, abbr, acronym, address, big, cite, code,
9del, dfn, em, img, ins, kbd, q, s, samp,
10small, strike, strong, sub, sup, tt, var,
11b, u, i, center,
12dl, dt, dd, ol, ul, li,
13fieldset, form, label, legend,
14table, caption, tbody, tfoot, thead, tr, th, td,
15article, aside, canvas, details, embed,
16figure, figcaption, footer, header, hgroup,
17menu, nav, output, ruby, section, summary,
18time, mark, audio, video {
19 margin: 0;
20 padding: 0;
21 border: 0;
22 font-size: 100%;
23 font: inherit;
24}
25/* HTML5 display-role reset for older browsers */
26article, aside, details, figcaption, figure,
27footer, header, hgroup, menu, nav, section {
28 display: block;
29}
30body {
31 line-height: 1;
32}
33ol, ul {
34 list-style: none;
35}
36blockquote, q {
37 quotes: none;
38}
39blockquote:before, blockquote:after, q:before, q:after {
40 content: '';
41 content: none;
42}
43table {
44 border-collapse: collapse;
45 border-spacing: 0;
46}
47
48/* Buttons should not have any special style applied by default */
49button {
50 background: none;
51 border: none;
52 padding: 0;
53}
54
55button:focus {
56 outline: 0;
57}
58
59html {
60 /* base for rem / 1rem = 10px */
61 font-size: 62.5%;
62 font-family: 'Open Sans';
63}
64
65body {
66 /* default font size = 14px */
67 font-size: 1.4rem;
68 color: $theme-gray-dark;
69}
70
71* {
72 -webkit-font-smoothing: antialiased;
73 box-sizing: border-box;
74 font-size: 1.4rem;
75 font-family: 'Open Sans';
76 -webkit-user-select: none;
77}
78
79html, body, div {
80 height: 100%;
81 background: none;
82 box-sizing: border-box;
83}
84
85*:focus {
86 outline: none;
87}
88
89img {
90 pointer-events: none;
91}
92
93a {
94 cursor: default;
95}
diff --git a/src/styles/searchInput.scss b/src/styles/searchInput.scss
new file mode 100644
index 000000000..28ff09fc4
--- /dev/null
+++ b/src/styles/searchInput.scss
@@ -0,0 +1,4 @@
1.search-input {
2 width: 100%;
3 height: auto;
4}
diff --git a/src/styles/select.scss b/src/styles/select.scss
new file mode 100644
index 000000000..965b4321a
--- /dev/null
+++ b/src/styles/select.scss
@@ -0,0 +1,19 @@
1@import './config.scss';
2@import './mixins.scss';
3
4$toggle: "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICBpZD0ic3ZnMiIKICAgdmlld0JveD0iMCAwIDM1Ljk3MDk4MyAyMy4wOTE1MTgiCiAgIGhlaWdodD0iNi41MTY5Mzk2bW0iCiAgIHdpZHRoPSIxMC4xNTE4MTFtbSI+CiAgPGRlZnMKICAgICBpZD0iZGVmczQiIC8+CiAgPG1ldGFkYXRhCiAgICAgaWQ9Im1ldGFkYXRhNyI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgICA8ZGM6dGl0bGU+PC9kYzp0aXRsZT4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGcKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjAyLjAxNDUxLC00MDcuMTIyMjUpIgogICAgIGlkPSJsYXllcjEiPgogICAgPHRleHQKICAgICAgIGlkPSJ0ZXh0MzMzNiIKICAgICAgIHk9IjYyOS41MDUwNyIKICAgICAgIHg9IjI5MS40Mjg1NiIKICAgICAgIHN0eWxlPSJmb250LXN0eWxlOm5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zaXplOjQwcHg7bGluZS1oZWlnaHQ6MTI1JTtmb250LWZhbWlseTpzYW5zLXNlcmlmO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iNjI5LjUwNTA3IgogICAgICAgICB4PSIyOTEuNDI4NTYiCiAgICAgICAgIGlkPSJ0c3BhbjMzMzgiPjwvdHNwYW4+PC90ZXh0PgogICAgPGcKICAgICAgIGlkPSJ0ZXh0MzM0MCIKICAgICAgIHN0eWxlPSJmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2ZvbnQtc2l6ZTo0MHB4O2xpbmUtaGVpZ2h0OjEyNSU7Zm9udC1mYW1pbHk6Rm9udEF3ZXNvbWU7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpGb250QXdlc29tZTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2Utb3BhY2l0eToxIj4KICAgICAgPHBhdGgKICAgICAgICAgaWQ9InBhdGgzMzQ1IgogICAgICAgICBzdHlsZT0iZmlsbDojMzMzMzMzO2ZpbGwtb3BhY2l0eToxIgogICAgICAgICBkPSJtIDIzNy41NjY5Niw0MTMuMjU1MDcgYyAwLjU1ODA0LC0wLjU1ODA0IDAuNTU4MDQsLTEuNDczMjIgMCwtMi4wMzEyNSBsIC0zLjcwNTM1LC0zLjY4MzA0IGMgLTAuNTU4MDQsLTAuNTU4MDQgLTEuNDUwOSwtMC41NTgwNCAtMi4wMDg5MywwIEwgMjIwLDQxOS4zOTM0NiAyMDguMTQ3MzIsNDA3LjU0MDc4IGMgLTAuNTU4MDMsLTAuNTU4MDQgLTEuNDUwODksLTAuNTU4MDQgLTIuMDA4OTMsMCBsIC0zLjcwNTM1LDMuNjgzMDQgYyAtMC41NTgwNCwwLjU1ODAzIC0wLjU1ODA0LDEuNDczMjEgMCwyLjAzMTI1IGwgMTYuNTYyNSwxNi41NDAxNyBjIDAuNTU4MDMsMC41NTgwNCAxLjQ1MDg5LDAuNTU4MDQgMi4wMDg5MiwwIGwgMTYuNTYyNSwtMTYuNTQwMTcgeiIgLz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo=";
5
6.franz-form {
7 .franz-form__select {
8 -webkit-appearance: none;
9 min-width: 200px;
10 padding: 10px;
11 background-color: $theme-gray-lightest;
12 background-position: right center;
13 background-repeat: no-repeat;
14 background-size: 1ex;
15 background-origin: content-box;
16 background-image: url(data:image/svg+xml;base64,#{$toggle});
17 border: 1px solid $theme-gray-lighter;
18 }
19}
diff --git a/src/styles/service-table.scss b/src/styles/service-table.scss
new file mode 100644
index 000000000..66d5ac941
--- /dev/null
+++ b/src/styles/service-table.scss
@@ -0,0 +1,62 @@
1@import './config.scss';
2
3.service-table {
4 width: 100%;
5
6 .service-table__toggle {
7 width: 60px;
8
9 .franz-form__field {
10 margin-bottom: 0;
11 }
12 }
13
14 .service-table__icon {
15 width: 35px;
16
17 &.has-custom-icon {
18 border-radius: $theme-border-radius;
19 border: 1px solid $theme-gray-lighter;
20 width: 37px;
21 }
22 }
23
24 .service-table__column-icon {
25 width: 40px;
26 }
27
28 .service-table__column-action {
29 width: 40px
30 }
31
32 .service-table__column-info {
33 width: 40px;
34
35 .mdi {
36 display: block;
37 font-size: 18px;
38 color: $theme-gray-light;
39 }
40 }
41
42 .service-table__row {
43 border-bottom: 1px solid $theme-gray-lightest;
44
45 &:hover {
46 background: $theme-gray-lightest;
47 }
48
49 &.service-table__row--disabled {
50 color: $theme-gray-light;
51
52 .service-table__column-icon {
53 filter: grayscale(100%);
54 opacity: 0.5;
55 }
56 }
57 }
58
59 td {
60 padding: 10px;
61 }
62}
diff --git a/src/styles/services.scss b/src/styles/services.scss
new file mode 100644
index 000000000..3347ea9d7
--- /dev/null
+++ b/src/styles/services.scss
@@ -0,0 +1,60 @@
1@import './config.scss';
2
3.services {
4 flex: 1;
5 height: 100%;
6 position: relative;
7 overflow: hidden;
8 background: #FFF;
9 order: 5;
10
11 .services__webview {
12 position: absolute;
13 width: 100%;
14 top: 0;
15 left: 0;
16 z-index: 0;
17
18 webview {
19 display: inline-flex;
20 width: 0px;
21 height: 0px;
22 }
23
24 &.is-active {
25 z-index: 100;
26
27 webview {
28 flex: 0 1;
29 width: 100%;
30 height: 100%;
31 }
32 }
33
34 &--force-repaint {
35 webview {
36 z-index: 5;
37 }
38 }
39 }
40
41 .services__no-service {
42 display: flex;
43 flex-direction: column;
44 justify-content: center;
45 align-items: center;
46 text-align: center;
47 background: $theme-gray-lighter;
48
49 h1 {
50 margin: 25px 0 40px;
51 color: $theme-gray-dark;
52 }
53
54 a.button {
55 margin-top: 40px;
56 // color: #FFF;
57 // border-color: #FFF;
58 }
59 }
60}
diff --git a/src/styles/settings.scss b/src/styles/settings.scss
new file mode 100644
index 000000000..9b19deb4e
--- /dev/null
+++ b/src/styles/settings.scss
@@ -0,0 +1,392 @@
1@import './config.scss';
2
3%headline {
4 font-size: 20px;
5 font-weight: 400;
6 letter-spacing: -1px;
7 color: $theme-gray-light;
8
9 a {
10 color: $theme-gray-light;
11 }
12}
13
14.settings-wrapper {
15 background: rgba(black, 0.5);
16 position: absolute;
17 width: 100%;
18 height: 100%;
19 top: 0;
20 left: 0;
21 z-index: 9998;
22 display: flex;
23 justify-content: center;
24 align-items: center;
25 padding: 25px;
26
27 .settings-wrapper__action {
28 position: absolute;
29 width: 100%;
30 height: 100%;
31 top: 0;
32 left: 0;
33 }
34}
35
36.settings {
37 position: relative;
38 display: flex;
39 height: 100%;
40 width: 100%;
41 max-width: 900px;
42 min-height: 400px;
43 max-height: 600px;
44 z-index: 9999;
45 background: #FFF;
46 border-radius: $theme-border-radius;
47 box-shadow: 0 20px 50px rgba(black, 0.5);
48 overflow: hidden;
49 // margin-top: -10%;
50
51 .settings__main {
52 flex: 1;
53 display: flex;
54 flex-direction: column;
55 height: auto;
56 }
57
58 .settings__header {
59 display: flex;
60 align-items: center;
61 width: calc(100% - 60px);
62 height: 50px;
63 padding: 0 40px;
64 background: $theme-gray-lighter;
65
66 h1 {
67 @extend %headline;
68 margin: 0;
69 }
70
71 .settings__header-item {
72 @extend %headline;
73 }
74
75 .separator {
76 height: 100%;
77 margin: 0 15px;
78 border-right: 1px solid darken($theme-gray-lighter, 10%);
79 transform: skew(15deg) rotate(2deg);
80 }
81
82 .mdi {
83 color: $theme-gray-light;
84 }
85 }
86
87 .settings__body {
88 flex: 1;
89 padding: 25px 15px 15px 25px;
90 margin: 15px;
91 overflow-y: scroll;
92
93 &::-webkit-scrollbar {
94 width: 8px;
95 }
96
97 /* Track */
98 &::-webkit-scrollbar-track {
99 -webkit-border-radius: 10px;
100 border-radius: 10px;
101 background: none;
102 }
103
104 /* Handle */
105 &::-webkit-scrollbar-thumb {
106 -webkit-border-radius: 10px;
107 border-radius: 10px;
108 background: $theme-gray-lighter;
109 }
110
111 &::-webkit-scrollbar-thumb:window-inactive {
112 background: none;
113 }
114 }
115
116 .settings__close {
117 position: absolute;
118 right: 0;
119 background: $theme-gray-lighter;
120 height: 50px;
121 padding: 0 20px;
122 font-size: 20px;
123 border-left: 1px solid darken($theme-gray-lighter, 5%);
124 color: $theme-gray-light;
125 transition: background $theme-transition-time;
126
127 &:hover {
128 background: darken($theme-gray-lighter, 5%);
129 }
130 }
131
132 .settings__search-header {
133 display: flex;
134 align-items: center;
135 padding: 0 10px;
136 border-radius: $theme-border-radius;
137 transition: background $theme-transition-time;
138 @extend %headline;
139 font-size: 22px;
140
141 &:hover {
142 background: darken($theme-gray-lighter, 5%);
143 }
144
145 input {
146 padding-left: 10px;
147 background: none;
148 border: 0;
149 flex: 1;
150 @extend %headline;
151 }
152 }
153
154 .settings__options {
155 margin-top: 30px;
156 }
157
158 .settings__message {
159 display: flex;
160 margin-top: 40px;
161 padding-top: 15px;
162 border-top: 1px solid $theme-gray-lighter;
163 color: $theme-gray-light;
164
165 .mdi {
166 color: $theme-gray-light;
167 font-size: 20px;
168 margin-right: 10px;
169 }
170 }
171
172 .settings__indirect-message-help {
173 margin: -10px 0 20px 55px;;
174 font-size: 12px;
175 color: $theme-gray-light;
176
177 &:last-of-type {
178 margin-bottom: 30px;
179 }
180 }
181
182 .settings__controls {
183 display: flex;
184 justify-content: space-between;
185 padding: 10px 20px;
186 height: auto;
187 background: $theme-gray-lighter;
188
189 .franz-form__button {
190 &[type='submit'] {
191 margin-left: auto;
192 }
193
194 &.franz-form__button--secondary {
195 background: $theme-gray-light;
196 }
197 }
198 }
199
200 .settings__delete-button {
201 right: 0;
202 }
203
204 .settings__empty-state {
205 width: 100%;
206 height: auto;
207 min-height: 70%;
208 text-align: center;
209 align-self: center;
210 // margin-top: -20px;
211 align-items: center;
212
213 a.button {
214 margin-top: 40px;
215 }
216 }
217
218 .account {
219 height: auto;
220 // padding: 20px;
221
222 .account__box {
223 background: $theme-gray-lightest;
224 border-radius: $theme-border-radius;
225 padding: 20px;
226 margin-bottom: 40px;
227 align-items: center;
228
229 &.account__box--flex {
230 display: flex;
231 }
232
233 &.account__box--last {
234 margin-bottom: 0;
235 }
236
237 .auth__button {
238 width: 100%;
239 margin-top: 10px;
240 }
241 }
242
243 .account__avatar {
244 margin-right: 20px;
245 position: relative;
246
247 .emoji img {
248 width: 30px;
249 }
250 }
251
252 .account__avatar-premium {
253 position: absolute;
254 top: 2px;
255 right: 2px;
256 font-size: 26px;
257 }
258
259 .account__info {
260 flex: 1;
261
262 h2 {
263 margin-bottom: 5px;
264 }
265
266 .badge {
267 margin-top: 5px;
268 }
269 }
270
271 .account__subscription {
272 display: flex;
273 align-items: center;
274
275 .badge {
276 margin-left: 10px;
277 }
278 }
279
280 .account__subscription-button {
281 margin-left: auto;
282 }
283
284 div {
285 height: auto;
286 }
287
288 .invoices {
289 width: 100%;
290
291 td {
292 padding: 15px 0;
293 border-bottom: 1px solid $theme-gray-lighter;
294 }
295
296 tr:last-of-type td {
297 border: 0;
298 padding-bottom: 0;
299 }
300
301 .invoices__action {
302 text-align: right;
303
304 button {
305 color: $theme-brand-primary;
306 }
307 }
308 }
309 }
310
311 // @include element(add-service-teaser) {
312 // height: auto;
313 // margin-top: 20px;
314 // display: block;
315 // text-align: center;
316 // }
317 .emoji {
318 display: block;
319 font-size: 40px;
320 margin-bottom: 20px;
321
322 img {
323 width: 40px;
324 }
325 }
326
327 .premium-info {
328 background: lighten($theme-brand-primary, 40%);
329 padding: 20px;
330 border-radius: $theme-border-radius;
331 }
332
333 .content-tabs .premium-info {
334 background: none;
335 padding: 0;
336 }
337}
338
339.settings-navigation {
340 width: 200px;
341 height: auto;
342 background: $theme-gray-lightest;
343 display: flex;
344 flex-direction: column;
345
346 .settings-navigation__link {
347 display: block;
348 height: 50px;
349 line-height: 50px;
350 text-decoration: none;
351 color: $theme-text-color;
352 padding: 0 20px;
353 transition: background $theme-transition-time, color $theme-transition-time;
354
355 &:hover {
356 background: darken($theme-gray-lightest, 5%);
357
358 .badge {
359 background: #FFF;
360 }
361 }
362
363 &.is-active {
364 background: $theme-brand-primary;
365 color: #FFF;
366
367 .badge {
368 background: #FFF;
369 color: $theme-brand-primary;
370 }
371 }
372 }
373
374 .settings-navigation__expander {
375 flex: 1;
376 }
377
378 .badge {
379 transition: background $theme-transition-time, color $theme-transition-time;
380 display: initial;
381 margin-left: 5px;
382 }
383
384 .settings-navigation__action-badge {
385 display: inline-block;
386 width: 7px;
387 height: 7px;
388 background: $theme-brand-danger;
389 border-radius: 100%;
390 margin-left: 5px;
391 }
392}
diff --git a/src/styles/subscription-popup.scss b/src/styles/subscription-popup.scss
new file mode 100644
index 000000000..b6f232fcb
--- /dev/null
+++ b/src/styles/subscription-popup.scss
@@ -0,0 +1,20 @@
1.subscription-popup {
2 height: 100%;
3
4 &__content {
5 height: calc(100% - 60px);
6 }
7
8 &__webview {
9 height: 100%;
10 }
11
12 &__toolbar {
13 height: 60px;
14 background: $theme-gray-lightest;
15 display: flex;
16 justify-content: space-between;
17 padding: 10px;
18 border-top: 1px solid $theme-gray-lighter;
19 }
20}
diff --git a/src/styles/subscription.scss b/src/styles/subscription.scss
new file mode 100644
index 000000000..63183f085
--- /dev/null
+++ b/src/styles/subscription.scss
@@ -0,0 +1,72 @@
1.subscription {
2 .subscription__premium-features {
3 margin: 10px 0;
4
5 li {
6 height: 30px;
7 align-items: center;
8 display: flex;
9
10 &:before {
11 content: "👍";
12 margin-right: 10px;
13 }
14
15 .badge {
16 margin-left: 10px;
17 }
18 }
19 }
20
21 .subscription__premium-info {
22 margin: 15px 0 25px;
23 }
24}
25
26.paymentTiers {
27 .franz-form__radio-wrapper {
28 flex-flow: wrap;
29
30 .franz-form__radio {
31 width: 32%;
32 flex: initial;
33 margin-right: 2%;
34
35 &:nth-child(3) {
36 margin-right: 0;
37 }
38
39 &:last-of-type {
40 margin-right: 0;
41 margin-top: 2%;
42 width: 100%;
43 }
44 }
45 }
46}
47
48.settings {
49 .paymentTiers {
50 .franz-form__radio-wrapper {
51 .franz-form__radio {
52 width: 49%;
53
54 &:nth-child(2) {
55 margin-right: 0;
56 }
57
58 &:last-of-type {
59 width: 100%;
60 }
61 }
62 }
63 }
64}
65
66.mining-details {
67 margin-bottom: 15px;
68
69 button {
70 color: $theme-brand-primary;
71 }
72}
diff --git a/src/styles/tabs.scss b/src/styles/tabs.scss
new file mode 100644
index 000000000..75568898b
--- /dev/null
+++ b/src/styles/tabs.scss
@@ -0,0 +1,72 @@
1@import './config.scss';
2
3.tabs {
4 display: flex;
5 // flex: 1;
6 flex-direction: column;
7 flex-shrink: 1;
8 // align-items: center;
9 // height: auto;
10
11 .placeholder {
12 width: 100%;
13 height: 40px;
14 }
15}
16
17.tab-item {
18 display: flex;
19 justify-content: center;
20 align-items: center;
21 position: relative;
22 width: $theme-sidebar-width;
23 height: $theme-sidebar-width;
24 min-height: 50px;
25 transition: background $theme-transition-time;
26
27 &.is-active {
28 border-left: 4px solid $theme-brand-primary;
29 background: lighten($theme-brand-primary, 35%);
30
31 .tab-item__icon {
32 margin-left: -4px;
33 }
34 }
35
36 &.has-custom-icon {
37 .tab-item__icon {
38 border-radius: $theme-border-radius;
39 // border: 1px solid $theme-gray-lighter;
40 // width: 32px;
41 }
42 }
43
44 .tab-item__icon {
45 width: 30px;
46 height: auto;
47 }
48
49 .tab-item__message-count {
50 min-width: 17px;
51 min-height: 17px;
52 background: $theme-brand-danger;
53 color: #FFF;
54 border-radius: 20px;
55 padding: 0px 5px;
56 font-size: 11px;
57 position: absolute;
58 right: 5px;
59 bottom: 5px;
60 display: flex;
61 justify-content: center;
62 align-items: center;
63
64 &.is-indirect {
65 padding-top: 0px;
66 }
67 }
68
69 &.is-reordering {
70 z-index: 99999;
71 }
72}
diff --git a/src/styles/toggle.scss b/src/styles/toggle.scss
new file mode 100644
index 000000000..5b47e6495
--- /dev/null
+++ b/src/styles/toggle.scss
@@ -0,0 +1,47 @@
1@import './config.scss';
2
3$toggle-size: 14px;
4$toggle-width: 40px;
5$toggle-button-size: 22px;
6
7.franz-form {
8 .franz-form__toggle-wrapper {
9 display: flex;
10 flex-direction: row;
11
12 .franz-form__label {
13 margin-left: 20px;
14 }
15
16 .franz-form__toggle {
17 width: $toggle-width;
18 height: $toggle-size;
19 position: relative;
20 background: $theme-gray-lighter;
21 border-radius: $theme-border-radius;
22
23 .franz-form__toggle-button {
24 position: absolute;
25 left: 0;
26 top: -($toggle-button-size - $toggle-size) / 2;
27 width: $toggle-button-size;
28 height: $toggle-button-size;
29 background: $theme-gray-light;
30 border-radius: 100%;
31 transition: all 0.5s;
32 box-shadow: 0 1px 4px rgba(0,0,0,0.3);
33 }
34
35 &.is-active {
36 .franz-form__toggle-button {
37 left: $toggle-width - $toggle-button-size;
38 background: $theme-brand-primary;
39 }
40 }
41
42 input {
43 display: none;
44 }
45 }
46 }
47}
diff --git a/src/styles/tooltip.scss b/src/styles/tooltip.scss
new file mode 100644
index 000000000..1194e7fbb
--- /dev/null
+++ b/src/styles/tooltip.scss
@@ -0,0 +1,4 @@
1.__react_component_tooltip {
2 padding: 10px !important;
3 height: auto;
4}
diff --git a/src/styles/type.scss b/src/styles/type.scss
new file mode 100644
index 000000000..935a36f4b
--- /dev/null
+++ b/src/styles/type.scss
@@ -0,0 +1,73 @@
1@import './config.scss';
2@import './mixins.scss';
3
4h1 {
5 font-size: 30px;
6 font-weight: 300;
7 letter-spacing: -1px;
8 margin-bottom: 25px;
9}
10
11h2 {
12 font-size: 20px;
13 font-weight: 500;
14 letter-spacing: -1px;
15 margin-bottom: 25px;
16 margin-top: 55px;
17
18 &:first-of-type {
19 margin-top: 0;
20 }
21}
22
23p {
24 margin-bottom: 10px;
25 line-height: 1.7rem;
26
27 &:last-of-type {
28 margin-bottom: 0;
29 }
30}
31
32strong {
33 font-weight: bold;
34}
35
36a {
37 text-decoration: none;
38 color: $theme-text-color;
39
40 &.button {
41 position: relative;
42 background: none;
43 display: inline-block;
44 padding: 10px 20px;
45 border: 2px solid $theme-brand-primary;
46 color: $theme-brand-primary;
47 border-radius: 3px;
48 transition: background 0.5s, color 0.5s;
49 text-align: center;
50
51 &:hover {
52 background: darken($theme-brand-primary, 5%);
53 color: #FFF;
54 }
55 }
56
57 &.link {
58 color: $theme-brand-primary;
59 }
60}
61
62.error-message, .error-message:last-of-type {
63 margin: 10px 0;
64 color: $theme-brand-danger;
65}
66
67.center {
68 text-align: center;
69}
70
71.label {
72 @include formLabel();
73}
diff --git a/src/styles/util.scss b/src/styles/util.scss
new file mode 100644
index 000000000..3faad8db3
--- /dev/null
+++ b/src/styles/util.scss
@@ -0,0 +1,20 @@
1.scroll-container {
2 height: 100%;
3 flex: 1;
4 overflow-y: scroll;
5 overflow-x: hidden;
6}
7
8.loader {
9 position: relative;
10 z-index: 9999;
11 display: block;
12 width: 100%;
13 height: 40px;
14}
15
16.align-middle {
17 display: flex;
18 flex-direction: column;
19 justify-content: center;
20}
diff --git a/src/styles/welcome.scss b/src/styles/welcome.scss
new file mode 100644
index 000000000..5365921fb
--- /dev/null
+++ b/src/styles/welcome.scss
@@ -0,0 +1,75 @@
1.auth {
2 .welcome {
3
4 &__content {
5 display: flex;
6 align-items: center;
7 justify-content: center;
8 color: #FFF;
9 }
10
11 &__logo {
12 width: 100px;
13 }
14
15 &__text {
16 margin-left: 40px;
17 padding-left: 40px;
18 border-left: 1px solid #FFF;
19
20 h1 {
21 font-size: 60px;
22 letter-spacing: -0.4rem;
23 margin-bottom: 5px;
24 }
25
26 h2 {
27 margin-left: 2px;
28 margin-bottom: 0;
29 }
30 }
31
32 &__services {
33 width: 100%;
34 max-width: 800px;
35 height: 100%;
36 max-height: 600px;
37 margin-left: -450px;
38 }
39
40 &__buttons {
41 display: block;
42 margin-top: 100px;
43 text-align: center;
44
45 .button:first-of-type {
46 margin-right: 25px;
47 }
48 }
49
50 .button {
51 border-color: #FFF;
52 color: #FFF;
53
54 &:hover {
55 background: #FFF;
56 color: $theme-brand-primary;
57 }
58 }
59
60 &__featured-services {
61 margin-top: 150px;
62 text-align: center;
63 margin-top: 80px;
64 }
65
66 &__featured-service {
67 width: 35px;
68 margin-right: 30px;
69
70 &:last-of-type {
71 margin-right: 0;
72 }
73 }
74 }
75}
diff --git a/src/webview/ime.js b/src/webview/ime.js
new file mode 100644
index 000000000..43df6267c
--- /dev/null
+++ b/src/webview/ime.js
@@ -0,0 +1,10 @@
1const { ipcRenderer } = require('electron');
2const { claimDocumentFocus } = require('../helpers/webview-ime-focus-helpers');
3
4ipcRenderer.on('claim-document-focus', claimDocumentFocus);
5
6window.addEventListener('DOMContentLoaded', () => {
7 if (document.querySelector('[autofocus]')) {
8 ipcRenderer.sendToHost('autofocus');
9 }
10});
diff --git a/src/webview/lib/RecipeWebview.js b/src/webview/lib/RecipeWebview.js
new file mode 100644
index 000000000..1787f85e2
--- /dev/null
+++ b/src/webview/lib/RecipeWebview.js
@@ -0,0 +1,74 @@
1// @flow
2const { ipcRenderer } = require('electron');
3const fs = require('fs-extra');
4
5class RecipeWebview {
6 constructor() {
7 this.countCache = {
8 direct: 0,
9 indirect: 0,
10 };
11
12 ipcRenderer.on('poll', () => {
13 this.loopFunc();
14 });
15 }
16
17 loopFunc = () => null;
18
19 /**
20 * Initialize the loop
21 *
22 * @param {Function} Function that will be executed
23 */
24 loop(fn) {
25 this.loopFunc = fn;
26 }
27
28 /**
29 * Set the unread message badge
30 *
31 * @param {int} direct Set the count of direct messages
32 * eg. Slack direct mentions, or a
33 * message to @channel
34 * @param {int} indirect Set a badge that defines there are
35 * new messages but they do not involve
36 * me directly to me eg. in a channel
37 */
38 setBadge(direct = 0, indirect = 0) {
39 if (this.countCache.direct === direct
40 && this.countCache.indirect === indirect) return;
41
42 const count = {
43 direct,
44 indirect,
45 };
46
47 ipcRenderer.sendToHost('messages', count);
48 Object.assign(this.countCache, count);
49 }
50
51 /**
52 * Injects the contents of a CSS file into the current webview
53 *
54 * @param {Array} files CSS files that should be injected. This must
55 * be an absolute path to the file
56 */
57 injectCSS(...files) {
58 files.forEach((file) => {
59 const data = fs.readFileSync(file);
60 const styles = document.createElement('style');
61 styles.innerHTML = data.toString();
62
63 document.querySelector('head').appendChild(styles);
64 });
65 }
66
67 initialize(fn) {
68 if (typeof fn === 'function') {
69 fn();
70 }
71 }
72}
73
74module.exports = RecipeWebview;
diff --git a/src/webview/notifications.js b/src/webview/notifications.js
new file mode 100644
index 000000000..97ce9d69b
--- /dev/null
+++ b/src/webview/notifications.js
@@ -0,0 +1,45 @@
1const { ipcRenderer } = require('electron');
2const uuidV1 = require('uuid/v1');
3// const FranzNotificationStore = [];
4
5class Notification {
6 constructor(title = '', options = {}) {
7 this.title = title;
8 this.options = options;
9 this.notificationId = uuidV1();
10 this.onclick = () => {};
11
12 ipcRenderer.sendToHost('notification', {
13 notificationId: this.notificationId,
14 title,
15 options,
16 });
17
18 ipcRenderer.on(`notification-onclick:${this.notificationId}`, () => {
19 this.onclick();
20 });
21 }
22}
23
24Notification.permission = 'granted';
25
26Notification.requestPermission = (cb = null) => {
27 console.log(this);
28 if (!cb) {
29 return new Promise((resolve) => {
30 resolve(Notification.permission);
31 });
32 }
33
34 if (typeof (cb) === 'function') {
35 return cb(Notification.permission);
36 }
37
38 return Notification.permission;
39};
40
41Notification.close = () => {
42 // no implementation yet
43};
44
45window.Notification = Notification;
diff --git a/src/webview/plugin.js b/src/webview/plugin.js
new file mode 100644
index 000000000..ffc9084e4
--- /dev/null
+++ b/src/webview/plugin.js
@@ -0,0 +1,24 @@
1const { ipcRenderer } = require('electron');
2const path = require('path');
3
4const RecipeWebview = require('./lib/RecipeWebview');
5
6require('./notifications.js');
7require('./spellchecker.js');
8require('./ime.js');
9
10ipcRenderer.on('initializeRecipe', (e, data) => {
11 const modulePath = path.join(data.recipe.path, 'webview.js');
12 // Delete module from cache
13 delete require.cache[require.resolve(modulePath)];
14 try {
15 // eslint-disable-next-line
16 require(modulePath)(new RecipeWebview(), data);
17 } catch (err) {
18 console.error(err);
19 }
20});
21
22document.addEventListener('DOMContentLoaded', () => {
23 ipcRenderer.sendToHost('hello');
24}, false);
diff --git a/src/webview/spellchecker.js b/src/webview/spellchecker.js
new file mode 100644
index 000000000..ec8807874
--- /dev/null
+++ b/src/webview/spellchecker.js
@@ -0,0 +1,14 @@
1import { SpellCheckHandler, ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker';
2
3window.spellCheckHandler = new SpellCheckHandler();
4setTimeout(() => {
5 window.spellCheckHandler.attachToInput();
6}, 1000);
7
8// TODO: should we set the language to user settings?
9// window.spellCheckHandler.switchLanguage('en-US');
10
11const contextMenuBuilder = new ContextMenuBuilder(window.spellCheckHandler);
12const contextMenuListener = new ContextMenuListener((info) => { // eslint-disable-line
13 contextMenuBuilder.showPopupMenu(info);
14});
diff --git a/src/webview/zoom.js b/src/webview/zoom.js
new file mode 100644
index 000000000..99c647036
--- /dev/null
+++ b/src/webview/zoom.js
@@ -0,0 +1,37 @@
1const electron = require('electron');
2
3const { ipcRenderer, webFrame } = electron;
4
5const maxZoomLevel = 9;
6const minZoomLevel = -8;
7let zoomLevel = 0;
8
9ipcRenderer.on('zoomIn', () => {
10 if (maxZoomLevel > zoomLevel) {
11 zoomLevel += 1;
12 }
13 webFrame.setZoomLevel(zoomLevel);
14
15 ipcRenderer.sendToHost('zoomLevel', { zoom: zoomLevel });
16});
17
18ipcRenderer.on('zoomOut', () => {
19 if (minZoomLevel < zoomLevel) {
20 zoomLevel -= 1;
21 }
22 webFrame.setZoomLevel(zoomLevel);
23
24 ipcRenderer.sendToHost('zoomLevel', { zoom: zoomLevel });
25});
26
27ipcRenderer.on('zoomReset', () => {
28 zoomLevel = 0;
29 webFrame.setZoomLevel(zoomLevel);
30
31 ipcRenderer.sendToHost('zoomLevel', { zoom: zoomLevel });
32});
33
34ipcRenderer.on('setZoom', (e, arg) => {
35 zoomLevel = arg;
36 webFrame.setZoomLevel(zoomLevel);
37});