diff options
Diffstat (limited to 'src')
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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import { IntlProvider } from 'react-intl'; | ||
5 | |||
6 | import { oneOrManyChildElements } from './prop-types'; | ||
7 | import translations from './i18n/translations'; | ||
8 | import UserStore from './stores/UserStore'; | ||
9 | |||
10 | @inject('stores') @observer | ||
11 | export 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 | |||
23 | I18N.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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | import defineActions from './lib/actions'; | ||
4 | import service from './service'; | ||
5 | import recipe from './recipe'; | ||
6 | import recipePreview from './recipePreview'; | ||
7 | import ui from './ui'; | ||
8 | import app from './app'; | ||
9 | import user from './user'; | ||
10 | import payment from './payment'; | ||
11 | import news from './news'; | ||
12 | import settings from './settings'; | ||
13 | import requests from './requests'; | ||
14 | |||
15 | const 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 | |||
28 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import { hash } from '../helpers/password-helpers'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import AppApi from './AppApi'; | ||
2 | import ServicesApi from './ServicesApi'; | ||
3 | import RecipePreviewsApi from './RecipePreviewsApi'; | ||
4 | import RecipesApi from './RecipesApi'; | ||
5 | import UserApi from './UserApi'; | ||
6 | import LocalApi from './LocalApi'; | ||
7 | import PaymentApi from './PaymentApi'; | ||
8 | import NewsApi from './NewsApi'; | ||
9 | |||
10 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import os from 'os'; | ||
2 | import path from 'path'; | ||
3 | import targz from 'tar.gz'; | ||
4 | import fs from 'fs-extra'; | ||
5 | import { remote } from 'electron'; | ||
6 | |||
7 | import ServiceModel from '../../models/Service'; | ||
8 | import RecipePreviewModel from '../../models/RecipePreview'; | ||
9 | import RecipeModel from '../../models/Recipe'; | ||
10 | import PlanModel from '../../models/Plan'; | ||
11 | import NewsModel from '../../models/News'; | ||
12 | import UserModel from '../../models/User'; | ||
13 | import OrderModel from '../../models/Order'; | ||
14 | |||
15 | import { API } from '../../environment'; | ||
16 | |||
17 | import { | ||
18 | getRecipeDirectory, | ||
19 | getDevRecipeDirectory, | ||
20 | loadRecipeConfig, | ||
21 | } from '../../helpers/recipe-helpers'; | ||
22 | |||
23 | module.paths.unshift( | ||
24 | getDevRecipeDirectory(), | ||
25 | getRecipeDirectory(), | ||
26 | ); | ||
27 | |||
28 | const { app } = remote; | ||
29 | const fetch = remote.require('electron-fetch'); | ||
30 | |||
31 | const SERVER_URL = API; | ||
32 | const API_VERSION = 'v1'; | ||
33 | |||
34 | export 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 @@ | |||
1 | import { webFrame } from 'electron'; | ||
2 | |||
3 | import React from 'react'; | ||
4 | import { render } from 'react-dom'; | ||
5 | import { Provider } from 'mobx-react'; | ||
6 | import { syncHistoryWithStore, RouterStore } from 'mobx-react-router'; | ||
7 | import { Router, Route, hashHistory, IndexRedirect } from 'react-router'; | ||
8 | |||
9 | import 'babel-polyfill'; | ||
10 | import smoothScroll from 'smoothscroll-polyfill'; | ||
11 | |||
12 | import ServerApi from './api/server/ServerApi'; | ||
13 | import LocalApi from './api/server/LocalApi'; | ||
14 | import storeFactory from './stores'; | ||
15 | import apiFactory from './api'; | ||
16 | import actions from './actions'; | ||
17 | import MenuFactory from './lib/Menu'; | ||
18 | import TouchBarFactory from './lib/TouchBar'; | ||
19 | import * as analytics from './lib/analytics'; | ||
20 | |||
21 | import I18N from './I18n'; | ||
22 | import AppLayoutContainer from './containers/layout/AppLayoutContainer'; | ||
23 | import SettingsWindow from './containers/settings/SettingsWindow'; | ||
24 | import RecipesScreen from './containers/settings/RecipesScreen'; | ||
25 | import ServicesScreen from './containers/settings/ServicesScreen'; | ||
26 | import EditServiceScreen from './containers/settings/EditServiceScreen'; | ||
27 | import AccountScreen from './containers/settings/AccountScreen'; | ||
28 | import EditUserScreen from './containers/settings/EditUserScreen'; | ||
29 | import EditSettingsScreen from './containers/settings/EditSettingsScreen'; | ||
30 | import WelcomeScreen from './containers/auth/WelcomeScreen'; | ||
31 | import LoginScreen from './containers/auth/LoginScreen'; | ||
32 | import PasswordScreen from './containers/auth/PasswordScreen'; | ||
33 | import SignupScreen from './containers/auth/SignupScreen'; | ||
34 | import ImportScreen from './containers/auth/ImportScreen'; | ||
35 | import PricingScreen from './containers/auth/PricingScreen'; | ||
36 | import InviteScreen from './containers/auth/InviteScreen'; | ||
37 | import AuthLayoutContainer from './containers/auth/AuthLayoutContainer'; | ||
38 | import SubscriptionPopupScreen from './containers/ui/SubscriptionPopupScreen'; | ||
39 | |||
40 | // Add Polyfills | ||
41 | smoothScroll.polyfill(); | ||
42 | |||
43 | // Basic electron Setup | ||
44 | webFrame.setVisualZoomLevelLimits(1, 1); | ||
45 | webFrame.setLayoutZoomLevelLimits(0, 0); | ||
46 | |||
47 | window.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { RouteTransition } from 'react-router-transition'; | ||
5 | import { intlShape } from 'react-intl'; | ||
6 | |||
7 | import Link from '../ui/Link'; | ||
8 | import InfoBar from '../ui/InfoBar'; | ||
9 | |||
10 | import { oneOrManyChildElements, globalError as globalErrorPropType } from '../../prop-types'; | ||
11 | import globalMessages from '../../i18n/globalMessages'; | ||
12 | |||
13 | @observer | ||
14 | export default class AuthLayout extends Component { | ||
15 | static propTypes = { | ||
16 | children: oneOrManyChildElements.isRequired, | ||
17 | pathname: PropTypes.string.isRequired, | ||
18 | error: globalErrorPropType.isRequired, | ||
19 | isOnline: PropTypes.bool.isRequired, | ||
20 | isAPIHealthy: PropTypes.bool.isRequired, | ||
21 | retryHealthCheck: PropTypes.func.isRequired, | ||
22 | isHealthCheckLoading: PropTypes.bool.isRequired, | ||
23 | }; | ||
24 | |||
25 | static contextTypes = { | ||
26 | intl: intlShape, | ||
27 | }; | ||
28 | |||
29 | render() { | ||
30 | const { | ||
31 | children, | ||
32 | pathname, | ||
33 | error, | ||
34 | isOnline, | ||
35 | isAPIHealthy, | ||
36 | retryHealthCheck, | ||
37 | isHealthCheckLoading, | ||
38 | } = this.props; | ||
39 | const { intl } = this.context; | ||
40 | |||
41 | return ( | ||
42 | <div className="auth"> | ||
43 | {!isOnline && ( | ||
44 | <InfoBar | ||
45 | type="warning" | ||
46 | > | ||
47 | <span className="mdi mdi-flash" /> | ||
48 | {intl.formatMessage(globalMessages.notConnectedToTheInternet)} | ||
49 | </InfoBar> | ||
50 | )} | ||
51 | {isOnline && !isAPIHealthy && ( | ||
52 | <InfoBar | ||
53 | type="danger" | ||
54 | ctaLabel="Try again" | ||
55 | ctaLoading={isHealthCheckLoading} | ||
56 | sticky | ||
57 | onClick={retryHealthCheck} | ||
58 | > | ||
59 | <span className="mdi mdi-flash" /> | ||
60 | {intl.formatMessage(globalMessages.APIUnhealthy)} | ||
61 | </InfoBar> | ||
62 | )} | ||
63 | <div className="auth__layout"> | ||
64 | <RouteTransition | ||
65 | pathname={pathname} | ||
66 | atEnter={{ opacity: 0 }} | ||
67 | atLeave={{ opacity: 0 }} | ||
68 | atActive={{ opacity: 1 }} | ||
69 | mapStyles={styles => ({ | ||
70 | transform: `translateX(${styles.translateX}%)`, | ||
71 | opacity: styles.opacity, | ||
72 | })} | ||
73 | component="span" | ||
74 | > | ||
75 | {/* Inject globalError into children */} | ||
76 | {React.cloneElement(children, { | ||
77 | error, | ||
78 | })} | ||
79 | </RouteTransition> | ||
80 | </div> | ||
81 | {/* </div> */} | ||
82 | <Link to="https://adlk.io" className="auth__adlk" target="_blank"> | ||
83 | <img src="./assets/images/adlk.svg" alt="" /> | ||
84 | </Link> | ||
85 | </div> | ||
86 | ); | ||
87 | } | ||
88 | } | ||
diff --git a/src/components/auth/Import.js b/src/components/auth/Import.js new file mode 100644 index 000000000..cf83aa9c8 --- /dev/null +++ b/src/components/auth/Import.js | |||
@@ -0,0 +1,168 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import Form from '../../lib/Form'; | ||
9 | import Toggle from '../ui/Toggle'; | ||
10 | import Button from '../ui/Button'; | ||
11 | |||
12 | const messages = defineMessages({ | ||
13 | headline: { | ||
14 | id: 'import.headline', | ||
15 | defaultMessage: '!!!Import your Franz 4 services', | ||
16 | }, | ||
17 | notSupportedHeadline: { | ||
18 | id: 'import.notSupportedHeadline', | ||
19 | defaultMessage: '!!!Services not yet supported in Franz 5', | ||
20 | }, | ||
21 | submitButtonLabel: { | ||
22 | id: 'import.submit.label', | ||
23 | defaultMessage: '!!!Import {count} services', | ||
24 | }, | ||
25 | skipButtonLabel: { | ||
26 | id: 'import.skip.label', | ||
27 | defaultMessage: '!!!I want add services manually', | ||
28 | }, | ||
29 | }); | ||
30 | |||
31 | @observer | ||
32 | export default class Import extends Component { | ||
33 | static propTypes = { | ||
34 | services: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
35 | onSubmit: PropTypes.func.isRequired, | ||
36 | isSubmitting: PropTypes.bool.isRequired, | ||
37 | inviteRoute: PropTypes.string.isRequired, | ||
38 | }; | ||
39 | |||
40 | static contextTypes = { | ||
41 | intl: intlShape, | ||
42 | }; | ||
43 | |||
44 | prepareForm() { | ||
45 | const { services } = this.props; | ||
46 | |||
47 | const config = { | ||
48 | fields: { | ||
49 | import: [...services.filter(s => s.recipe).map(s => ({ | ||
50 | add: { | ||
51 | default: true, | ||
52 | options: s, | ||
53 | }, | ||
54 | }))], | ||
55 | }, | ||
56 | }; | ||
57 | |||
58 | return new Form(config, this.context.intl); | ||
59 | } | ||
60 | |||
61 | submit(e) { | ||
62 | const { services } = this.props; | ||
63 | e.preventDefault(); | ||
64 | this.form.submit({ | ||
65 | onSuccess: (form) => { | ||
66 | const servicesImport = form.values().import | ||
67 | .map((value, i) => !value.add || services.filter(s => s.recipe)[i]) | ||
68 | .filter(s => typeof s !== 'boolean'); | ||
69 | |||
70 | this.props.onSubmit({ services: servicesImport }); | ||
71 | }, | ||
72 | onError: () => {}, | ||
73 | }); | ||
74 | } | ||
75 | |||
76 | render() { | ||
77 | this.form = this.prepareForm(); | ||
78 | const { intl } = this.context; | ||
79 | const { services, isSubmitting, inviteRoute } = this.props; | ||
80 | |||
81 | const availableServices = services.filter(s => s.recipe); | ||
82 | const unavailableServices = services.filter(s => !s.recipe); | ||
83 | |||
84 | return ( | ||
85 | <div className="auth__scroll-container"> | ||
86 | <div className="auth__container auth__container--signup"> | ||
87 | <form className="franz-form auth__form" onSubmit={e => this.submit(e)}> | ||
88 | <img | ||
89 | src="./assets/images/logo.svg" | ||
90 | className="auth__logo" | ||
91 | alt="" | ||
92 | /> | ||
93 | <h1> | ||
94 | {intl.formatMessage(messages.headline)} | ||
95 | </h1> | ||
96 | <table className="service-table available-services"> | ||
97 | <tbody> | ||
98 | {this.form.$('import').map((service, i) => ( | ||
99 | <tr | ||
100 | key={service.id} | ||
101 | className="service-table__row" | ||
102 | onClick={() => service.$('add').set(!service.$('add').value)} | ||
103 | > | ||
104 | <td className="service-table__toggle"> | ||
105 | <Toggle | ||
106 | field={service.$('add')} | ||
107 | showLabel={false} | ||
108 | /> | ||
109 | </td> | ||
110 | <td className="service-table__column-icon"> | ||
111 | <img | ||
112 | src={availableServices[i].custom_icon || availableServices[i].recipe.icons.svg} | ||
113 | className={classnames({ | ||
114 | 'service-table__icon': true, | ||
115 | 'has-custom-icon': availableServices[i].custom_icon, | ||
116 | })} | ||
117 | alt="" | ||
118 | /> | ||
119 | </td> | ||
120 | <td className="service-table__column-name"> | ||
121 | {availableServices[i].name !== '' | ||
122 | ? availableServices[i].name | ||
123 | : availableServices[i].recipe.name} | ||
124 | </td> | ||
125 | </tr> | ||
126 | ))} | ||
127 | </tbody> | ||
128 | </table> | ||
129 | {unavailableServices.length > 0 && ( | ||
130 | <div className="unavailable-services"> | ||
131 | <strong>{intl.formatMessage(messages.notSupportedHeadline)}</strong> | ||
132 | <p> | ||
133 | {services.filter(s => !s.recipe).map((service, i) => ( | ||
134 | <span key={service.id}> | ||
135 | {service.name !== '' ? service.name : service.service} | ||
136 | {unavailableServices.length > i + 1 ? ', ' : ''} | ||
137 | </span> | ||
138 | ))} | ||
139 | </p> | ||
140 | </div> | ||
141 | )} | ||
142 | |||
143 | {isSubmitting ? ( | ||
144 | <Button | ||
145 | className="auth__button is-loading" | ||
146 | label={`${intl.formatMessage(messages.submitButtonLabel)} ...`} | ||
147 | loaded={false} | ||
148 | disabled | ||
149 | /> | ||
150 | ) : ( | ||
151 | <Button | ||
152 | type="submit" | ||
153 | className="auth__button" | ||
154 | label={intl.formatMessage(messages.submitButtonLabel)} | ||
155 | /> | ||
156 | )} | ||
157 | <Link | ||
158 | to={inviteRoute} | ||
159 | className="franz-form__button franz-form__button--secondary auth__button auth__button--skip" | ||
160 | > | ||
161 | {intl.formatMessage(messages.skipButtonLabel)} | ||
162 | </Link> | ||
163 | </form> | ||
164 | </div> | ||
165 | </div> | ||
166 | ); | ||
167 | } | ||
168 | } | ||
diff --git a/src/components/auth/Invite.js b/src/components/auth/Invite.js new file mode 100644 index 000000000..c1d815dcd --- /dev/null +++ b/src/components/auth/Invite.js | |||
@@ -0,0 +1,111 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | |||
7 | import Form from '../../lib/Form'; | ||
8 | import { email } from '../../helpers/validation-helpers'; | ||
9 | import Input from '../ui/Input'; | ||
10 | import Button from '../ui/Button'; | ||
11 | |||
12 | const messages = defineMessages({ | ||
13 | headline: { | ||
14 | id: 'invite.headline.friends', | ||
15 | defaultMessage: '!!!Invite 3 of your friends or colleagues', | ||
16 | }, | ||
17 | nameLabel: { | ||
18 | id: 'invite.name.label', | ||
19 | defaultMessage: '!!!Name', | ||
20 | }, | ||
21 | emailLabel: { | ||
22 | id: 'invite.email.label', | ||
23 | defaultMessage: '!!!Email address', | ||
24 | }, | ||
25 | submitButtonLabel: { | ||
26 | id: 'invite.submit.label', | ||
27 | defaultMessage: '!!!Send invites', | ||
28 | }, | ||
29 | skipButtonLabel: { | ||
30 | id: 'invite.skip.label', | ||
31 | defaultMessage: '!!!I want to do this later', | ||
32 | }, | ||
33 | }); | ||
34 | |||
35 | @observer | ||
36 | export default class Invite extends Component { | ||
37 | static propTypes = { | ||
38 | onSubmit: PropTypes.func.isRequired, | ||
39 | }; | ||
40 | |||
41 | static contextTypes = { | ||
42 | intl: intlShape, | ||
43 | }; | ||
44 | |||
45 | form = new Form({ | ||
46 | fields: { | ||
47 | invite: [...Array(3).fill({ | ||
48 | name: { | ||
49 | label: this.context.intl.formatMessage(messages.nameLabel), | ||
50 | // value: '', | ||
51 | placeholder: this.context.intl.formatMessage(messages.nameLabel), | ||
52 | }, | ||
53 | email: { | ||
54 | label: this.context.intl.formatMessage(messages.emailLabel), | ||
55 | // value: '', | ||
56 | validate: [email], | ||
57 | placeholder: this.context.intl.formatMessage(messages.emailLabel), | ||
58 | }, | ||
59 | })], | ||
60 | }, | ||
61 | }, this.context.intl); | ||
62 | |||
63 | submit(e) { | ||
64 | e.preventDefault(); | ||
65 | this.form.submit({ | ||
66 | onSuccess: (form) => { | ||
67 | this.props.onSubmit({ invites: form.values().invite }); | ||
68 | }, | ||
69 | onError: () => {}, | ||
70 | }); | ||
71 | } | ||
72 | |||
73 | render() { | ||
74 | const { form } = this; | ||
75 | const { intl } = this.context; | ||
76 | |||
77 | return ( | ||
78 | <div className="auth__container auth__container--signup"> | ||
79 | <form className="franz-form auth__form" onSubmit={e => this.submit(e)}> | ||
80 | <img | ||
81 | src="./assets/images/logo.svg" | ||
82 | className="auth__logo" | ||
83 | alt="" | ||
84 | /> | ||
85 | <h1> | ||
86 | {intl.formatMessage(messages.headline)} | ||
87 | </h1> | ||
88 | {form.$('invite').map(invite => ( | ||
89 | <div className="grid" key={invite.key}> | ||
90 | <div className="grid__row"> | ||
91 | <Input field={invite.$('name')} showLabel={false} /> | ||
92 | <Input field={invite.$('email')} showLabel={false} /> | ||
93 | </div> | ||
94 | </div> | ||
95 | ))} | ||
96 | <Button | ||
97 | type="submit" | ||
98 | className="auth__button" | ||
99 | label={intl.formatMessage(messages.submitButtonLabel)} | ||
100 | /> | ||
101 | <Link | ||
102 | to="/" | ||
103 | className="franz-form__button franz-form__button--secondary auth__button auth__button--skip" | ||
104 | > | ||
105 | {intl.formatMessage(messages.skipButtonLabel)} | ||
106 | </Link> | ||
107 | </form> | ||
108 | </div> | ||
109 | ); | ||
110 | } | ||
111 | } | ||
diff --git a/src/components/auth/Login.js b/src/components/auth/Login.js new file mode 100644 index 000000000..015079f02 --- /dev/null +++ b/src/components/auth/Login.js | |||
@@ -0,0 +1,161 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import { required, email } from '../../helpers/validation-helpers'; | ||
8 | import Input from '../ui/Input'; | ||
9 | import Button from '../ui/Button'; | ||
10 | import Link from '../ui/Link'; | ||
11 | |||
12 | import { globalError as globalErrorPropType } from '../../prop-types'; | ||
13 | |||
14 | // import Appear from '../ui/effects/Appear'; | ||
15 | |||
16 | const messages = defineMessages({ | ||
17 | headline: { | ||
18 | id: 'login.headline', | ||
19 | defaultMessage: '!!!Sign in', | ||
20 | }, | ||
21 | emailLabel: { | ||
22 | id: 'login.email.label', | ||
23 | defaultMessage: '!!!Email address', | ||
24 | }, | ||
25 | passwordLabel: { | ||
26 | id: 'login.password.label', | ||
27 | defaultMessage: '!!!Password', | ||
28 | }, | ||
29 | submitButtonLabel: { | ||
30 | id: 'login.submit.label', | ||
31 | defaultMessage: '!!!Sign in', | ||
32 | }, | ||
33 | invalidCredentials: { | ||
34 | id: 'login.invalidCredentials', | ||
35 | defaultMessage: '!!!Email or password not valid', | ||
36 | }, | ||
37 | tokenExpired: { | ||
38 | id: 'login.tokenExpired', | ||
39 | defaultMessage: '!!!Your session expired, please login again.', | ||
40 | }, | ||
41 | serverLogout: { | ||
42 | id: 'login.serverLogout', | ||
43 | defaultMessage: '!!!Your session expired, please login again.', | ||
44 | }, | ||
45 | signupLink: { | ||
46 | id: 'login.link.signup', | ||
47 | defaultMessage: '!!!Create a free account', | ||
48 | }, | ||
49 | passwordLink: { | ||
50 | id: 'login.link.password', | ||
51 | defaultMessage: '!!!Forgot password', | ||
52 | }, | ||
53 | }); | ||
54 | |||
55 | @observer | ||
56 | export default class Login extends Component { | ||
57 | static propTypes = { | ||
58 | onSubmit: PropTypes.func.isRequired, | ||
59 | isSubmitting: PropTypes.bool.isRequired, | ||
60 | isTokenExpired: PropTypes.bool.isRequired, | ||
61 | isServerLogout: PropTypes.bool.isRequired, | ||
62 | signupRoute: PropTypes.string.isRequired, | ||
63 | passwordRoute: PropTypes.string.isRequired, | ||
64 | error: globalErrorPropType.isRequired, | ||
65 | }; | ||
66 | |||
67 | static contextTypes = { | ||
68 | intl: intlShape, | ||
69 | }; | ||
70 | |||
71 | form = new Form({ | ||
72 | fields: { | ||
73 | email: { | ||
74 | label: this.context.intl.formatMessage(messages.emailLabel), | ||
75 | value: '', | ||
76 | validate: [required, email], | ||
77 | }, | ||
78 | password: { | ||
79 | label: this.context.intl.formatMessage(messages.passwordLabel), | ||
80 | value: '', | ||
81 | validate: [required], | ||
82 | type: 'password', | ||
83 | }, | ||
84 | }, | ||
85 | }, this.context.intl); | ||
86 | |||
87 | submit(e) { | ||
88 | e.preventDefault(); | ||
89 | this.form.submit({ | ||
90 | onSuccess: (form) => { | ||
91 | this.props.onSubmit(form.values()); | ||
92 | }, | ||
93 | onError: () => {}, | ||
94 | }); | ||
95 | } | ||
96 | |||
97 | emailField = null; | ||
98 | |||
99 | render() { | ||
100 | const { form } = this; | ||
101 | const { intl } = this.context; | ||
102 | const { | ||
103 | isSubmitting, | ||
104 | isTokenExpired, | ||
105 | isServerLogout, | ||
106 | signupRoute, | ||
107 | passwordRoute, | ||
108 | error, | ||
109 | } = this.props; | ||
110 | |||
111 | return ( | ||
112 | <div className="auth__container"> | ||
113 | <form className="franz-form auth__form" onSubmit={e => this.submit(e)}> | ||
114 | <img | ||
115 | src="./assets/images/logo.svg" | ||
116 | className="auth__logo" | ||
117 | alt="" | ||
118 | /> | ||
119 | <h1>{intl.formatMessage(messages.headline)}</h1> | ||
120 | {isTokenExpired && ( | ||
121 | <p className="error-message center">{intl.formatMessage(messages.tokenExpired)}</p> | ||
122 | )} | ||
123 | {isServerLogout && ( | ||
124 | <p className="error-message center">{intl.formatMessage(messages.serverLogout)}</p> | ||
125 | )} | ||
126 | <Input | ||
127 | field={form.$('email')} | ||
128 | ref={(element) => { this.emailField = element; }} | ||
129 | focus | ||
130 | /> | ||
131 | <Input | ||
132 | field={form.$('password')} | ||
133 | showPasswordToggle | ||
134 | /> | ||
135 | {error.code === 'invalid-credentials' && ( | ||
136 | <p className="error-message center">{intl.formatMessage(messages.invalidCredentials)}</p> | ||
137 | )} | ||
138 | {isSubmitting ? ( | ||
139 | <Button | ||
140 | className="auth__button is-loading" | ||
141 | buttonType="secondary" | ||
142 | label={`${intl.formatMessage(messages.submitButtonLabel)} ...`} | ||
143 | loaded={false} | ||
144 | disabled | ||
145 | /> | ||
146 | ) : ( | ||
147 | <Button | ||
148 | type="submit" | ||
149 | className="auth__button" | ||
150 | label={intl.formatMessage(messages.submitButtonLabel)} | ||
151 | /> | ||
152 | )} | ||
153 | </form> | ||
154 | <div className="auth__links"> | ||
155 | <Link to={signupRoute}>{intl.formatMessage(messages.signupLink)}</Link> | ||
156 | <Link to={passwordRoute}>{intl.formatMessage(messages.passwordLink)}</Link> | ||
157 | </div> | ||
158 | </div> | ||
159 | ); | ||
160 | } | ||
161 | } | ||
diff --git a/src/components/auth/Password.js b/src/components/auth/Password.js new file mode 100644 index 000000000..d2b196853 --- /dev/null +++ b/src/components/auth/Password.js | |||
@@ -0,0 +1,135 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import { required, email } from '../../helpers/validation-helpers'; | ||
8 | import Input from '../ui/Input'; | ||
9 | import Button from '../ui/Button'; | ||
10 | import Link from '../ui/Link'; | ||
11 | import Infobox from '../ui/Infobox'; | ||
12 | |||
13 | const messages = defineMessages({ | ||
14 | headline: { | ||
15 | id: 'password.headline', | ||
16 | defaultMessage: '!!!Forgot password', | ||
17 | }, | ||
18 | emailLabel: { | ||
19 | id: 'password.email.label', | ||
20 | defaultMessage: '!!!Email address', | ||
21 | }, | ||
22 | submitButtonLabel: { | ||
23 | id: 'password.submit.label', | ||
24 | defaultMessage: '!!!Submit', | ||
25 | }, | ||
26 | successInfo: { | ||
27 | id: 'password.successInfo', | ||
28 | defaultMessage: '!!!Your new password was sent to your email address', | ||
29 | }, | ||
30 | noUser: { | ||
31 | id: 'password.noUser', | ||
32 | defaultMessage: '!!!No user affiliated with that email address', | ||
33 | }, | ||
34 | signupLink: { | ||
35 | id: 'password.link.signup', | ||
36 | defaultMessage: '!!!Create a free account', | ||
37 | }, | ||
38 | loginLink: { | ||
39 | id: 'password.link.login', | ||
40 | defaultMessage: '!!!Sign in to your account', | ||
41 | }, | ||
42 | }); | ||
43 | |||
44 | @observer | ||
45 | export default class Password extends Component { | ||
46 | static propTypes = { | ||
47 | onSubmit: PropTypes.func.isRequired, | ||
48 | isSubmitting: PropTypes.bool.isRequired, | ||
49 | signupRoute: PropTypes.string.isRequired, | ||
50 | loginRoute: PropTypes.string.isRequired, | ||
51 | status: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
52 | }; | ||
53 | |||
54 | static contextTypes = { | ||
55 | intl: intlShape, | ||
56 | }; | ||
57 | |||
58 | form = new Form({ | ||
59 | fields: { | ||
60 | email: { | ||
61 | label: this.context.intl.formatMessage(messages.emailLabel), | ||
62 | value: '', | ||
63 | validate: [required, email], | ||
64 | }, | ||
65 | }, | ||
66 | }, this.context.intl); | ||
67 | |||
68 | submit(e) { | ||
69 | e.preventDefault(); | ||
70 | this.form.submit({ | ||
71 | onSuccess: (form) => { | ||
72 | this.props.onSubmit(form.values()); | ||
73 | }, | ||
74 | onError: () => {}, | ||
75 | }); | ||
76 | } | ||
77 | |||
78 | render() { | ||
79 | const { form } = this; | ||
80 | const { intl } = this.context; | ||
81 | const { | ||
82 | isSubmitting, | ||
83 | signupRoute, | ||
84 | loginRoute, | ||
85 | status, | ||
86 | } = this.props; | ||
87 | |||
88 | return ( | ||
89 | <div className="auth__container"> | ||
90 | <form className="franz-form auth__form" onSubmit={e => this.submit(e)}> | ||
91 | <img | ||
92 | src="./assets/images/logo.svg" | ||
93 | className="auth__logo" | ||
94 | alt="" | ||
95 | /> | ||
96 | <h1>{intl.formatMessage(messages.headline)}</h1> | ||
97 | {status.length > 0 && status.includes('sent') && ( | ||
98 | <Infobox | ||
99 | type="success" | ||
100 | icon="checkbox-marked-circle-outline" | ||
101 | > | ||
102 | {intl.formatMessage(messages.successInfo)} | ||
103 | </Infobox> | ||
104 | )} | ||
105 | <Input | ||
106 | field={form.$('email')} | ||
107 | focus | ||
108 | /> | ||
109 | {status.length > 0 && status.includes('no-user') && ( | ||
110 | <p className="error-message center">{intl.formatMessage(messages.noUser)}</p> | ||
111 | )} | ||
112 | {isSubmitting ? ( | ||
113 | <Button | ||
114 | className="auth__button is-loading" | ||
115 | buttonType="secondary" | ||
116 | label={`${intl.formatMessage(messages.submitButtonLabel)} ...`} | ||
117 | loaded={false} | ||
118 | disabled | ||
119 | /> | ||
120 | ) : ( | ||
121 | <Button | ||
122 | type="submit" | ||
123 | className="auth__button" | ||
124 | label={intl.formatMessage(messages.submitButtonLabel)} | ||
125 | /> | ||
126 | )} | ||
127 | </form> | ||
128 | <div className="auth__links"> | ||
129 | <Link to={loginRoute}>{intl.formatMessage(messages.loginLink)}</Link> | ||
130 | <Link to={signupRoute}>{intl.formatMessage(messages.signupLink)}</Link> | ||
131 | </div> | ||
132 | </div> | ||
133 | ); | ||
134 | } | ||
135 | } | ||
diff --git a/src/components/auth/Pricing.js b/src/components/auth/Pricing.js new file mode 100644 index 000000000..761561a89 --- /dev/null +++ b/src/components/auth/Pricing.js | |||
@@ -0,0 +1,130 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | // import { Link } from 'react-router'; | ||
6 | |||
7 | // import Button from '../ui/Button'; | ||
8 | import Loader from '../ui/Loader'; | ||
9 | import Appear from '../ui/effects/Appear'; | ||
10 | import SubscriptionForm from '../../containers/ui/SubscriptionFormScreen'; | ||
11 | |||
12 | const messages = defineMessages({ | ||
13 | headline: { | ||
14 | id: 'pricing.headline', | ||
15 | defaultMessage: '!!!Support Franz', | ||
16 | }, | ||
17 | monthlySupportLabel: { | ||
18 | id: 'pricing.support.label', | ||
19 | defaultMessage: '!!!Select your support plan', | ||
20 | }, | ||
21 | submitButtonLabel: { | ||
22 | id: 'pricing.submit.label', | ||
23 | defaultMessage: '!!!Support the development of Franz', | ||
24 | }, | ||
25 | skipPayment: { | ||
26 | id: 'pricing.link.skipPayment', | ||
27 | defaultMessage: '!!!I don\'t want to support the development of Franz.', | ||
28 | }, | ||
29 | }); | ||
30 | |||
31 | @observer | ||
32 | export default class Signup extends Component { | ||
33 | static propTypes = { | ||
34 | donor: MobxPropTypes.objectOrObservableObject.isRequired, | ||
35 | isLoading: PropTypes.bool.isRequired, | ||
36 | isLoadingUser: PropTypes.bool.isRequired, | ||
37 | onCloseSubscriptionWindow: PropTypes.func.isRequired, | ||
38 | skipAction: PropTypes.func.isRequired, | ||
39 | }; | ||
40 | |||
41 | static contextTypes = { | ||
42 | intl: intlShape, | ||
43 | }; | ||
44 | |||
45 | render() { | ||
46 | const { | ||
47 | donor, | ||
48 | isLoading, | ||
49 | isLoadingUser, | ||
50 | onCloseSubscriptionWindow, | ||
51 | skipAction, | ||
52 | } = this.props; | ||
53 | const { intl } = this.context; | ||
54 | |||
55 | return ( | ||
56 | <div className="auth__scroll-container"> | ||
57 | <div className="auth__container auth__container--signup"> | ||
58 | <form className="franz-form auth__form"> | ||
59 | <img | ||
60 | src="./assets/images/sm.png" | ||
61 | className="auth__logo auth__logo--sm" | ||
62 | alt="" | ||
63 | /> | ||
64 | <h1>{intl.formatMessage(messages.headline)}</h1> | ||
65 | <div className="auth__letter"> | ||
66 | {isLoadingUser && ( | ||
67 | <p>Loading</p> | ||
68 | )} | ||
69 | {!isLoadingUser && ( | ||
70 | donor.amount ? ( | ||
71 | <span> | ||
72 | <p> | ||
73 | Thank you so much for your previous donation of <strong>$ {donor.amount}</strong>. | ||
74 | <br /> | ||
75 | Your support allowed us to get where we are today. | ||
76 | <br /> | ||
77 | </p> | ||
78 | <p> | ||
79 | As an early supporter, you get <strong>a lifetime premium supporter license</strong> without any | ||
80 | additional charges. | ||
81 | </p> | ||
82 | <p> | ||
83 | However, If you want to keep supporting us, you are more than welcome to subscribe to a plan. | ||
84 | <br /><br /> | ||
85 | </p> | ||
86 | </span> | ||
87 | ) : ( | ||
88 | <span> | ||
89 | <p> | ||
90 | We built Franz with a lot of effort, manpower and love, | ||
91 | to bring you the best messaging experience. | ||
92 | <br /> | ||
93 | </p> | ||
94 | <p> | ||
95 | Getting a Franz Premium Supporter License will allow us to keep improving Franz for you. | ||
96 | </p> | ||
97 | </span> | ||
98 | ) | ||
99 | )} | ||
100 | <p> | ||
101 | Thanks for being a hero. | ||
102 | </p> | ||
103 | <p> | ||
104 | <strong>Stefan Malzner</strong> | ||
105 | </p> | ||
106 | </div> | ||
107 | <Loader loaded={!isLoading}> | ||
108 | <Appear transitionName="slideDown"> | ||
109 | <span className="label">{intl.formatMessage(messages.monthlySupportLabel)}</span> | ||
110 | <SubscriptionForm | ||
111 | onCloseWindow={onCloseSubscriptionWindow} | ||
112 | showSkipOption | ||
113 | skipAction={skipAction} | ||
114 | hideInfo={Boolean(donor.amount)} | ||
115 | skipButtonLabel={intl.formatMessage(messages.skipPayment)} | ||
116 | /> | ||
117 | {/* <Link | ||
118 | to={inviteRoute} | ||
119 | className="franz-form__button franz-form__button--secondary auth__button auth__button--skip" | ||
120 | > | ||
121 | {intl.formatMessage(messages.skipPayment)} | ||
122 | </Link> */} | ||
123 | </Appear> | ||
124 | </Loader> | ||
125 | </form> | ||
126 | </div> | ||
127 | </div> | ||
128 | ); | ||
129 | } | ||
130 | } | ||
diff --git a/src/components/auth/Signup.js b/src/components/auth/Signup.js new file mode 100644 index 000000000..71ca16111 --- /dev/null +++ b/src/components/auth/Signup.js | |||
@@ -0,0 +1,206 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import { required, email, minLength } from '../../helpers/validation-helpers'; | ||
8 | import Input from '../ui/Input'; | ||
9 | import Radio from '../ui/Radio'; | ||
10 | import Button from '../ui/Button'; | ||
11 | import Link from '../ui/Link'; | ||
12 | |||
13 | import { globalError as globalErrorPropType } from '../../prop-types'; | ||
14 | |||
15 | const messages = defineMessages({ | ||
16 | headline: { | ||
17 | id: 'signup.headline', | ||
18 | defaultMessage: '!!!Sign up', | ||
19 | }, | ||
20 | firstnameLabel: { | ||
21 | id: 'signup.firstname.label', | ||
22 | defaultMessage: '!!!Firstname', | ||
23 | }, | ||
24 | lastnameLabel: { | ||
25 | id: 'signup.lastname.label', | ||
26 | defaultMessage: '!!!Lastname', | ||
27 | }, | ||
28 | emailLabel: { | ||
29 | id: 'signup.email.label', | ||
30 | defaultMessage: '!!!Email address', | ||
31 | }, | ||
32 | companyLabel: { | ||
33 | id: 'signup.company.label', | ||
34 | defaultMessage: '!!!Company', | ||
35 | }, | ||
36 | passwordLabel: { | ||
37 | id: 'signup.password.label', | ||
38 | defaultMessage: '!!!Password', | ||
39 | }, | ||
40 | legalInfo: { | ||
41 | id: 'signup.legal.info', | ||
42 | defaultMessage: '!!!By creating a Franz account you accept the', | ||
43 | }, | ||
44 | terms: { | ||
45 | id: 'signup.legal.terms', | ||
46 | defaultMessage: '!!!Terms of service', | ||
47 | }, | ||
48 | privacy: { | ||
49 | id: 'signup.legal.privacy', | ||
50 | defaultMessage: '!!!Privacy Statement', | ||
51 | }, | ||
52 | submitButtonLabel: { | ||
53 | id: 'signup.submit.label', | ||
54 | defaultMessage: '!!!Create account', | ||
55 | }, | ||
56 | loginLink: { | ||
57 | id: 'signup.link.login', | ||
58 | defaultMessage: '!!!Already have an account, sign in?', | ||
59 | }, | ||
60 | emailDuplicate: { | ||
61 | id: 'signup.emailDuplicate', | ||
62 | defaultMessage: '!!!A user with that email address already exists', | ||
63 | }, | ||
64 | }); | ||
65 | |||
66 | @observer | ||
67 | export default class Signup extends Component { | ||
68 | static propTypes = { | ||
69 | onSubmit: PropTypes.func.isRequired, | ||
70 | isSubmitting: PropTypes.bool.isRequired, | ||
71 | loginRoute: PropTypes.string.isRequired, | ||
72 | error: globalErrorPropType.isRequired, | ||
73 | }; | ||
74 | |||
75 | static contextTypes = { | ||
76 | intl: intlShape, | ||
77 | }; | ||
78 | |||
79 | form = new Form({ | ||
80 | fields: { | ||
81 | accountType: { | ||
82 | value: 'individual', | ||
83 | validate: [required], | ||
84 | options: [{ | ||
85 | value: 'individual', | ||
86 | label: 'Individual', | ||
87 | }, { | ||
88 | value: 'non-profit', | ||
89 | label: 'Non-Profit', | ||
90 | }, { | ||
91 | value: 'company', | ||
92 | label: 'Company', | ||
93 | }], | ||
94 | }, | ||
95 | firstname: { | ||
96 | label: this.context.intl.formatMessage(messages.firstnameLabel), | ||
97 | value: '', | ||
98 | validate: [required], | ||
99 | }, | ||
100 | lastname: { | ||
101 | label: this.context.intl.formatMessage(messages.lastnameLabel), | ||
102 | value: '', | ||
103 | validate: [required], | ||
104 | }, | ||
105 | email: { | ||
106 | label: this.context.intl.formatMessage(messages.emailLabel), | ||
107 | value: '', | ||
108 | validate: [required, email], | ||
109 | }, | ||
110 | organization: { | ||
111 | label: this.context.intl.formatMessage(messages.companyLabel), | ||
112 | value: '', // TODO: make required when accountType: company | ||
113 | }, | ||
114 | password: { | ||
115 | label: this.context.intl.formatMessage(messages.passwordLabel), | ||
116 | value: '', | ||
117 | validate: [required, minLength(6)], | ||
118 | type: 'password', | ||
119 | }, | ||
120 | }, | ||
121 | }, this.context.intl); | ||
122 | |||
123 | submit(e) { | ||
124 | e.preventDefault(); | ||
125 | this.form.submit({ | ||
126 | onSuccess: (form) => { | ||
127 | this.props.onSubmit(form.values()); | ||
128 | }, | ||
129 | onError: () => {}, | ||
130 | }); | ||
131 | } | ||
132 | |||
133 | render() { | ||
134 | const { form } = this; | ||
135 | const { intl } = this.context; | ||
136 | const { isSubmitting, loginRoute, error } = this.props; | ||
137 | |||
138 | return ( | ||
139 | <div className="auth__scroll-container"> | ||
140 | <div className="auth__container auth__container--signup"> | ||
141 | <form className="franz-form auth__form" onSubmit={e => this.submit(e)}> | ||
142 | <img | ||
143 | src="./assets/images/logo.svg" | ||
144 | className="auth__logo" | ||
145 | alt="" | ||
146 | /> | ||
147 | <h1>{intl.formatMessage(messages.headline)}</h1> | ||
148 | <Radio field={form.$('accountType')} showLabel={false} /> | ||
149 | <div className="grid__row"> | ||
150 | <Input field={form.$('firstname')} focus /> | ||
151 | <Input field={form.$('lastname')} /> | ||
152 | </div> | ||
153 | <Input field={form.$('email')} /> | ||
154 | <Input | ||
155 | field={form.$('password')} | ||
156 | showPasswordToggle | ||
157 | scorePassword | ||
158 | /> | ||
159 | {form.$('accountType').value === 'company' && ( | ||
160 | <Input field={form.$('organization')} /> | ||
161 | )} | ||
162 | {error.code === 'email-duplicate' && ( | ||
163 | <p className="error-message center">{intl.formatMessage(messages.emailDuplicate)}</p> | ||
164 | )} | ||
165 | {isSubmitting ? ( | ||
166 | <Button | ||
167 | className="auth__button is-loading" | ||
168 | label={`${intl.formatMessage(messages.submitButtonLabel)} ...`} | ||
169 | loaded={false} | ||
170 | disabled | ||
171 | /> | ||
172 | ) : ( | ||
173 | <Button | ||
174 | type="submit" | ||
175 | className="auth__button" | ||
176 | label={intl.formatMessage(messages.submitButtonLabel)} | ||
177 | /> | ||
178 | )} | ||
179 | <p className="legal"> | ||
180 | {intl.formatMessage(messages.legalInfo)} | ||
181 | <br /> | ||
182 | <Link | ||
183 | to="http://meetfranz.com/terms" | ||
184 | target="_blank" | ||
185 | className="link" | ||
186 | > | ||
187 | {intl.formatMessage(messages.terms)} | ||
188 | </Link> | ||
189 | & | ||
190 | <Link | ||
191 | to="http://meetfranz.com/privacy" | ||
192 | target="_blank" | ||
193 | className="link" | ||
194 | > | ||
195 | {intl.formatMessage(messages.privacy)} | ||
196 | </Link>. | ||
197 | </p> | ||
198 | </form> | ||
199 | <div className="auth__links"> | ||
200 | <Link to={loginRoute}>{intl.formatMessage(messages.loginLink)}</Link> | ||
201 | </div> | ||
202 | </div> | ||
203 | </div> | ||
204 | ); | ||
205 | } | ||
206 | } | ||
diff --git a/src/components/auth/Welcome.js b/src/components/auth/Welcome.js new file mode 100644 index 000000000..06b10ecfe --- /dev/null +++ b/src/components/auth/Welcome.js | |||
@@ -0,0 +1,69 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Link from '../ui/Link'; | ||
7 | |||
8 | const messages = defineMessages({ | ||
9 | signupButton: { | ||
10 | id: 'welcome.signupButton', | ||
11 | defaultMessage: '!!!Create a free account', | ||
12 | }, | ||
13 | loginButton: { | ||
14 | id: 'welcome.loginButton', | ||
15 | defaultMessage: '!!!Login to your account', | ||
16 | }, | ||
17 | }); | ||
18 | |||
19 | @observer | ||
20 | export default class Login extends Component { | ||
21 | static propTypes = { | ||
22 | loginRoute: PropTypes.string.isRequired, | ||
23 | signupRoute: PropTypes.string.isRequired, | ||
24 | recipes: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
25 | }; | ||
26 | |||
27 | static contextTypes = { | ||
28 | intl: intlShape, | ||
29 | }; | ||
30 | |||
31 | render() { | ||
32 | const { intl } = this.context; | ||
33 | const { | ||
34 | loginRoute, | ||
35 | signupRoute, | ||
36 | recipes, | ||
37 | } = this.props; | ||
38 | |||
39 | return ( | ||
40 | <div className="welcome"> | ||
41 | <div className="welcome__content"> | ||
42 | <img src="./assets/images/logo.svg" className="welcome__logo" alt="" /> | ||
43 | {/* <img src="./assets/images/welcome.png" className="welcome__services" alt="" /> */} | ||
44 | <div className="welcome__text"> | ||
45 | <h1>Franz</h1> | ||
46 | </div> | ||
47 | </div> | ||
48 | <div className="welcome__buttons"> | ||
49 | <Link to={signupRoute} className="button"> | ||
50 | {intl.formatMessage(messages.signupButton)} | ||
51 | </Link> | ||
52 | <Link to={loginRoute} className="button"> | ||
53 | {intl.formatMessage(messages.loginButton)} | ||
54 | </Link> | ||
55 | </div> | ||
56 | <div className="welcome__featured-services"> | ||
57 | {recipes.map(recipe => ( | ||
58 | <img | ||
59 | key={recipe.id} | ||
60 | src={recipe.icons.svg} | ||
61 | className="welcome__featured-service" | ||
62 | alt="" | ||
63 | /> | ||
64 | ))} | ||
65 | </div> | ||
66 | </div> | ||
67 | ); | ||
68 | } | ||
69 | } | ||
diff --git a/src/components/layout/AppLayout.js b/src/components/layout/AppLayout.js new file mode 100644 index 000000000..f60c170a8 --- /dev/null +++ b/src/components/layout/AppLayout.js | |||
@@ -0,0 +1,148 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import InfoBar from '../ui/InfoBar'; | ||
7 | import globalMessages from '../../i18n/globalMessages'; | ||
8 | |||
9 | function createMarkup(HTMLString) { | ||
10 | return { __html: HTMLString }; | ||
11 | } | ||
12 | |||
13 | const messages = defineMessages({ | ||
14 | servicesUpdated: { | ||
15 | id: 'infobar.servicesUpdated', | ||
16 | defaultMessage: '!!!Your services have been updated.', | ||
17 | }, | ||
18 | updateAvailable: { | ||
19 | id: 'infobar.updateAvailable', | ||
20 | defaultMessage: '!!!A new update for Franz is available.', | ||
21 | }, | ||
22 | buttonReloadServices: { | ||
23 | id: 'infobar.buttonReloadServices', | ||
24 | defaultMessage: '!!!Reload services', | ||
25 | }, | ||
26 | buttonInstallUpdate: { | ||
27 | id: 'infobar.buttonInstallUpdate', | ||
28 | defaultMessage: '!!!Restart & install update', | ||
29 | }, | ||
30 | requiredRequestsFailed: { | ||
31 | id: 'infobar.requiredRequestsFailed', | ||
32 | defaultMessage: '!!!Could not load services and user information', | ||
33 | }, | ||
34 | }); | ||
35 | |||
36 | @observer | ||
37 | export default class AppLayout extends Component { | ||
38 | static propTypes = { | ||
39 | sidebar: PropTypes.element.isRequired, | ||
40 | services: PropTypes.element.isRequired, | ||
41 | children: PropTypes.element, | ||
42 | news: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
43 | isOnline: PropTypes.bool.isRequired, | ||
44 | showServicesUpdatedInfoBar: PropTypes.bool.isRequired, | ||
45 | appUpdateIsDownloaded: PropTypes.bool.isRequired, | ||
46 | removeNewsItem: PropTypes.func.isRequired, | ||
47 | reloadServicesAfterUpdate: PropTypes.func.isRequired, | ||
48 | installAppUpdate: PropTypes.func.isRequired, | ||
49 | showRequiredRequestsError: PropTypes.bool.isRequired, | ||
50 | areRequiredRequestsSuccessful: PropTypes.bool.isRequired, | ||
51 | retryRequiredRequests: PropTypes.func.isRequired, | ||
52 | areRequiredRequestsLoading: PropTypes.bool.isRequired, | ||
53 | }; | ||
54 | |||
55 | static defaultProps = { | ||
56 | children: [], | ||
57 | }; | ||
58 | |||
59 | static contextTypes = { | ||
60 | intl: intlShape, | ||
61 | }; | ||
62 | |||
63 | render() { | ||
64 | const { | ||
65 | sidebar, | ||
66 | services, | ||
67 | children, | ||
68 | isOnline, | ||
69 | news, | ||
70 | showServicesUpdatedInfoBar, | ||
71 | appUpdateIsDownloaded, | ||
72 | removeNewsItem, | ||
73 | reloadServicesAfterUpdate, | ||
74 | installAppUpdate, | ||
75 | showRequiredRequestsError, | ||
76 | areRequiredRequestsSuccessful, | ||
77 | retryRequiredRequests, | ||
78 | areRequiredRequestsLoading, | ||
79 | } = this.props; | ||
80 | |||
81 | const { intl } = this.context; | ||
82 | |||
83 | return ( | ||
84 | <div> | ||
85 | <div className="app"> | ||
86 | {sidebar} | ||
87 | <div className="app__service"> | ||
88 | {news.length > 0 && news.map(item => ( | ||
89 | <InfoBar | ||
90 | key={item.id} | ||
91 | position="top" | ||
92 | type={item.type} | ||
93 | sticky={item.sticky} | ||
94 | onHide={() => removeNewsItem({ newsId: item.id })} | ||
95 | > | ||
96 | <span dangerouslySetInnerHTML={createMarkup(item.message)} /> | ||
97 | </InfoBar> | ||
98 | ))} | ||
99 | {!isOnline && ( | ||
100 | <InfoBar | ||
101 | type="danger" | ||
102 | > | ||
103 | <span className="mdi mdi-flash" /> | ||
104 | {intl.formatMessage(globalMessages.notConnectedToTheInternet)} | ||
105 | </InfoBar> | ||
106 | )} | ||
107 | {!areRequiredRequestsSuccessful && showRequiredRequestsError && ( | ||
108 | <InfoBar | ||
109 | type="danger" | ||
110 | ctaLabel="Try again" | ||
111 | ctaLoading={areRequiredRequestsLoading} | ||
112 | sticky | ||
113 | onClick={retryRequiredRequests} | ||
114 | > | ||
115 | <span className="mdi mdi-flash" /> | ||
116 | {intl.formatMessage(messages.requiredRequestsFailed)} | ||
117 | </InfoBar> | ||
118 | )} | ||
119 | {showServicesUpdatedInfoBar && ( | ||
120 | <InfoBar | ||
121 | type="primary" | ||
122 | ctaLabel={intl.formatMessage(messages.buttonReloadServices)} | ||
123 | onClick={reloadServicesAfterUpdate} | ||
124 | sticky | ||
125 | > | ||
126 | <span className="mdi mdi-power-plug" /> | ||
127 | {intl.formatMessage(messages.servicesUpdated)} | ||
128 | </InfoBar> | ||
129 | )} | ||
130 | {appUpdateIsDownloaded && ( | ||
131 | <InfoBar | ||
132 | type="primary" | ||
133 | ctaLabel={intl.formatMessage(messages.buttonInstallUpdate)} | ||
134 | onClick={installAppUpdate} | ||
135 | sticky | ||
136 | > | ||
137 | <span className="mdi mdi-information" /> | ||
138 | {intl.formatMessage(messages.updateAvailable)} | ||
139 | </InfoBar> | ||
140 | )} | ||
141 | {services} | ||
142 | </div> | ||
143 | </div> | ||
144 | {children} | ||
145 | </div> | ||
146 | ); | ||
147 | } | ||
148 | } | ||
diff --git a/src/components/layout/Sidebar.js b/src/components/layout/Sidebar.js new file mode 100644 index 000000000..4aee1ec60 --- /dev/null +++ b/src/components/layout/Sidebar.js | |||
@@ -0,0 +1,75 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import ReactTooltip from 'react-tooltip'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Tabbar from '../services/tabs/Tabbar'; | ||
7 | import { ctrlKey } from '../../environment'; | ||
8 | |||
9 | const messages = defineMessages({ | ||
10 | settings: { | ||
11 | id: 'sidebar.settings', | ||
12 | defaultMessage: '!!!Settings', | ||
13 | }, | ||
14 | }); | ||
15 | |||
16 | export default class Sidebar extends Component { | ||
17 | static propTypes = { | ||
18 | openSettings: PropTypes.func.isRequired, | ||
19 | isPremiumUser: PropTypes.bool, | ||
20 | } | ||
21 | |||
22 | static defaultProps = { | ||
23 | isPremiumUser: false, | ||
24 | } | ||
25 | |||
26 | static contextTypes = { | ||
27 | intl: intlShape, | ||
28 | }; | ||
29 | |||
30 | state = { | ||
31 | tooltipEnabled: true, | ||
32 | }; | ||
33 | |||
34 | enableToolTip() { | ||
35 | this.setState({ tooltipEnabled: true }); | ||
36 | } | ||
37 | |||
38 | disableToolTip() { | ||
39 | this.setState({ tooltipEnabled: false }); | ||
40 | } | ||
41 | |||
42 | render() { | ||
43 | const { openSettings, isPremiumUser } = this.props; | ||
44 | const { intl } = this.context; | ||
45 | return ( | ||
46 | <div className="sidebar"> | ||
47 | <Tabbar | ||
48 | {...this.props} | ||
49 | enableToolTip={() => this.enableToolTip()} | ||
50 | disableToolTip={() => this.disableToolTip()} | ||
51 | /> | ||
52 | <button | ||
53 | onClick={openSettings} | ||
54 | className="sidebar__settings-button" | ||
55 | data-tip={`Settings (${ctrlKey}+,)`} | ||
56 | > | ||
57 | {isPremiumUser && ( | ||
58 | <span className="emoji"> | ||
59 | <img src="./assets/images/emoji/star.png" alt="" /> | ||
60 | </span> | ||
61 | )} | ||
62 | <img | ||
63 | src="./assets/images/logo.svg" | ||
64 | className="sidebar__logo" | ||
65 | alt="" | ||
66 | /> | ||
67 | {intl.formatMessage(messages.settings)} | ||
68 | </button> | ||
69 | {this.state.tooltipEnabled && ( | ||
70 | <ReactTooltip place="right" type="dark" effect="solid" /> | ||
71 | )} | ||
72 | </div> | ||
73 | ); | ||
74 | } | ||
75 | } | ||
diff --git a/src/components/services/content/ServiceWebview.js b/src/components/services/content/ServiceWebview.js new file mode 100644 index 000000000..043ff42ea --- /dev/null +++ b/src/components/services/content/ServiceWebview.js | |||
@@ -0,0 +1,73 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { autorun } from 'mobx'; | ||
4 | import { observer } from 'mobx-react'; | ||
5 | import Webview from 'react-electron-web-view'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import ServiceModel from '../../../models/Service'; | ||
9 | |||
10 | @observer | ||
11 | export default class ServiceWebview extends Component { | ||
12 | static propTypes = { | ||
13 | service: PropTypes.instanceOf(ServiceModel).isRequired, | ||
14 | setWebviewReference: PropTypes.func.isRequired, | ||
15 | }; | ||
16 | |||
17 | static defaultProps = { | ||
18 | isActive: false, | ||
19 | }; | ||
20 | |||
21 | state = { | ||
22 | forceRepaint: false, | ||
23 | }; | ||
24 | |||
25 | componentDidMount() { | ||
26 | autorun(() => { | ||
27 | if (this.props.service.isActive) { | ||
28 | this.setState({ forceRepaint: true }); | ||
29 | setTimeout(() => { | ||
30 | this.setState({ forceRepaint: false }); | ||
31 | }, 100); | ||
32 | } | ||
33 | }); | ||
34 | } | ||
35 | |||
36 | webview = null; | ||
37 | |||
38 | render() { | ||
39 | const { | ||
40 | service, | ||
41 | setWebviewReference, | ||
42 | } = this.props; | ||
43 | |||
44 | const webviewClasses = classnames({ | ||
45 | services__webview: true, | ||
46 | 'is-active': service.isActive, | ||
47 | 'services__webview--force-repaint': this.state.forceRepaint, | ||
48 | }); | ||
49 | |||
50 | return ( | ||
51 | <div className={webviewClasses}> | ||
52 | <Webview | ||
53 | ref={(element) => { this.webview = element; }} | ||
54 | |||
55 | autosize | ||
56 | src={service.url} | ||
57 | preload="./webview/plugin.js" | ||
58 | partition={`persist:service-${service.id}`} | ||
59 | |||
60 | onDidAttach={() => setWebviewReference({ | ||
61 | serviceId: service.id, | ||
62 | webview: this.webview.view, | ||
63 | })} | ||
64 | |||
65 | useragent={service.userAgent} | ||
66 | |||
67 | disablewebsecurity | ||
68 | allowpopups | ||
69 | /> | ||
70 | </div> | ||
71 | ); | ||
72 | } | ||
73 | } | ||
diff --git a/src/components/services/content/Services.js b/src/components/services/content/Services.js new file mode 100644 index 000000000..03c68b06f --- /dev/null +++ b/src/components/services/content/Services.js | |||
@@ -0,0 +1,81 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { Link } from 'react-router'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | |||
7 | import Webview from './ServiceWebview'; | ||
8 | import Appear from '../../ui/effects/Appear'; | ||
9 | |||
10 | const messages = defineMessages({ | ||
11 | welcome: { | ||
12 | id: 'services.welcome', | ||
13 | defaultMessage: '!!!Welcome to Franz', | ||
14 | }, | ||
15 | getStarted: { | ||
16 | id: 'services.getStarted', | ||
17 | defaultMessage: '!!!Get started', | ||
18 | }, | ||
19 | }); | ||
20 | |||
21 | @observer | ||
22 | export default class Services extends Component { | ||
23 | static propTypes = { | ||
24 | services: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
25 | setWebviewReference: PropTypes.func.isRequired, | ||
26 | handleIPCMessage: PropTypes.func.isRequired, | ||
27 | openWindow: PropTypes.func.isRequired, | ||
28 | }; | ||
29 | |||
30 | static defaultProps = { | ||
31 | services: [], | ||
32 | activeService: '', | ||
33 | }; | ||
34 | |||
35 | static contextTypes = { | ||
36 | intl: intlShape, | ||
37 | }; | ||
38 | |||
39 | render() { | ||
40 | const { | ||
41 | services, | ||
42 | handleIPCMessage, | ||
43 | setWebviewReference, | ||
44 | openWindow, | ||
45 | } = this.props; | ||
46 | const { intl } = this.context; | ||
47 | |||
48 | return ( | ||
49 | <div className="services"> | ||
50 | {services.length === 0 && ( | ||
51 | <Appear | ||
52 | timeout={1500} | ||
53 | transitionName="slideUp" | ||
54 | > | ||
55 | <div className="services__no-service"> | ||
56 | <img src="./assets/images/logo.svg" alt="" /> | ||
57 | <h1>{intl.formatMessage(messages.welcome)}</h1> | ||
58 | <Appear | ||
59 | timeout={300} | ||
60 | transitionName="slideUp" | ||
61 | > | ||
62 | <Link to="/settings/recipes" className="button"> | ||
63 | {intl.formatMessage(messages.getStarted)} | ||
64 | </Link> | ||
65 | </Appear> | ||
66 | </div> | ||
67 | </Appear> | ||
68 | )} | ||
69 | {services.map(service => ( | ||
70 | <Webview | ||
71 | key={service.id} | ||
72 | service={service} | ||
73 | handleIPCMessage={handleIPCMessage} | ||
74 | setWebviewReference={setWebviewReference} | ||
75 | openWindow={openWindow} | ||
76 | /> | ||
77 | ))} | ||
78 | </div> | ||
79 | ); | ||
80 | } | ||
81 | } | ||
diff --git a/src/components/services/tabs/TabBarSortableList.js b/src/components/services/tabs/TabBarSortableList.js new file mode 100644 index 000000000..c0a68d1a5 --- /dev/null +++ b/src/components/services/tabs/TabBarSortableList.js | |||
@@ -0,0 +1,44 @@ | |||
1 | import React from 'react'; | ||
2 | import { observer } from 'mobx-react'; | ||
3 | import { SortableContainer } from 'react-sortable-hoc'; | ||
4 | |||
5 | import TabItem from './TabItem'; | ||
6 | import { ctrlKey } from '../../../environment'; | ||
7 | |||
8 | export default SortableContainer(observer(({ | ||
9 | services, | ||
10 | setActive, | ||
11 | reload, | ||
12 | toggleNotifications, | ||
13 | deleteService, | ||
14 | disableService, | ||
15 | openSettings, | ||
16 | }) => ( | ||
17 | <ul | ||
18 | className="tabs" | ||
19 | > | ||
20 | {services.map((service, index) => ( | ||
21 | <TabItem | ||
22 | key={service.id} | ||
23 | clickHandler={() => setActive({ serviceId: service.id })} | ||
24 | service={service} | ||
25 | index={index} | ||
26 | shortcutIndex={index + 1} | ||
27 | reload={() => reload({ serviceId: service.id })} | ||
28 | toggleNotifications={() => toggleNotifications({ serviceId: service.id })} | ||
29 | deleteService={() => deleteService({ serviceId: service.id })} | ||
30 | disableService={() => disableService({ serviceId: service.id })} | ||
31 | openSettings={openSettings} | ||
32 | /> | ||
33 | ))} | ||
34 | <li> | ||
35 | <button | ||
36 | className="sidebar__add-service" | ||
37 | onClick={() => openSettings({ path: 'recipes' })} | ||
38 | data-tip={`Add new service (${ctrlKey}+N)`} | ||
39 | > | ||
40 | <span className="mdi mdi-plus" /> | ||
41 | </button> | ||
42 | </li> | ||
43 | </ul> | ||
44 | ))); | ||
diff --git a/src/components/services/tabs/TabItem.js b/src/components/services/tabs/TabItem.js new file mode 100644 index 000000000..9e03d2e21 --- /dev/null +++ b/src/components/services/tabs/TabItem.js | |||
@@ -0,0 +1,136 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import { defineMessages, intlShape } from 'react-intl'; | ||
4 | import PropTypes from 'prop-types'; | ||
5 | import { observer } from 'mobx-react'; | ||
6 | import classnames from 'classnames'; | ||
7 | import { SortableElement } from 'react-sortable-hoc'; | ||
8 | |||
9 | import ServiceModel from '../../../models/Service'; | ||
10 | import { ctrlKey } from '../../../environment'; | ||
11 | |||
12 | const { Menu } = remote; | ||
13 | |||
14 | const messages = defineMessages({ | ||
15 | reload: { | ||
16 | id: 'tabs.item.reload', | ||
17 | defaultMessage: '!!!Reload', | ||
18 | }, | ||
19 | edit: { | ||
20 | id: 'tabs.item.edit', | ||
21 | defaultMessage: '!!!Edit', | ||
22 | }, | ||
23 | disableNotifications: { | ||
24 | id: 'tabs.item.disableNotifications', | ||
25 | defaultMessage: '!!!Disable notifications', | ||
26 | }, | ||
27 | enableNotifications: { | ||
28 | id: 'tabs.item.enableNotification', | ||
29 | defaultMessage: '!!!Enable notifications', | ||
30 | }, | ||
31 | disableService: { | ||
32 | id: 'tabs.item.disableService', | ||
33 | defaultMessage: '!!!Disable Service', | ||
34 | }, | ||
35 | deleteService: { | ||
36 | id: 'tabs.item.deleteService', | ||
37 | defaultMessage: '!!!Delete Service', | ||
38 | }, | ||
39 | }); | ||
40 | |||
41 | @observer | ||
42 | class TabItem extends Component { | ||
43 | static propTypes = { | ||
44 | service: PropTypes.instanceOf(ServiceModel).isRequired, | ||
45 | clickHandler: PropTypes.func.isRequired, | ||
46 | shortcutIndex: PropTypes.number.isRequired, | ||
47 | reload: PropTypes.func.isRequired, | ||
48 | toggleNotifications: PropTypes.func.isRequired, | ||
49 | openSettings: PropTypes.func.isRequired, | ||
50 | deleteService: PropTypes.func.isRequired, | ||
51 | disableService: PropTypes.func.isRequired, | ||
52 | }; | ||
53 | |||
54 | static contextTypes = { | ||
55 | intl: intlShape, | ||
56 | }; | ||
57 | |||
58 | render() { | ||
59 | const { | ||
60 | service, | ||
61 | clickHandler, | ||
62 | shortcutIndex, | ||
63 | reload, | ||
64 | toggleNotifications, | ||
65 | deleteService, | ||
66 | disableService, | ||
67 | openSettings, | ||
68 | } = this.props; | ||
69 | const { intl } = this.context; | ||
70 | |||
71 | |||
72 | const menuTemplate = [{ | ||
73 | label: service.name || service.recipe.name, | ||
74 | enabled: false, | ||
75 | }, { | ||
76 | type: 'separator', | ||
77 | }, { | ||
78 | label: intl.formatMessage(messages.reload), | ||
79 | click: reload, | ||
80 | }, { | ||
81 | label: intl.formatMessage(messages.edit), | ||
82 | click: () => openSettings({ | ||
83 | path: `services/edit/${service.id}`, | ||
84 | }), | ||
85 | }, { | ||
86 | type: 'separator', | ||
87 | }, { | ||
88 | label: service.isNotificationEnabled | ||
89 | ? intl.formatMessage(messages.disableNotifications) | ||
90 | : intl.formatMessage(messages.enableNotifications), | ||
91 | click: () => toggleNotifications(), | ||
92 | }, { | ||
93 | label: intl.formatMessage(messages.disableService), | ||
94 | click: () => disableService(), | ||
95 | }, { | ||
96 | type: 'separator', | ||
97 | }, { | ||
98 | label: intl.formatMessage(messages.deleteService), | ||
99 | click: () => deleteService(), | ||
100 | }]; | ||
101 | const menu = Menu.buildFromTemplate(menuTemplate); | ||
102 | |||
103 | return ( | ||
104 | <li | ||
105 | className={classnames({ | ||
106 | 'tab-item': true, | ||
107 | 'is-active': service.isActive, | ||
108 | 'has-custom-icon': service.hasCustomIcon, | ||
109 | })} | ||
110 | onClick={clickHandler} | ||
111 | onContextMenu={() => menu.popup(remote.getCurrentWindow())} | ||
112 | data-tip={`${service.name} ${shortcutIndex <= 9 ? `(${ctrlKey}+${shortcutIndex})` : ''}`} | ||
113 | > | ||
114 | <img | ||
115 | src={service.icon} | ||
116 | className="tab-item__icon" | ||
117 | alt="" | ||
118 | /> | ||
119 | {service.unreadDirectMessageCount > 0 && ( | ||
120 | <span className="tab-item__message-count"> | ||
121 | {service.unreadDirectMessageCount} | ||
122 | </span> | ||
123 | )} | ||
124 | {service.unreadIndirectMessageCount > 0 | ||
125 | && service.unreadDirectMessageCount === 0 | ||
126 | && service.isIndirectMessageBadgeEnabled && ( | ||
127 | <span className="tab-item__message-count is-indirect"> | ||
128 | • | ||
129 | </span> | ||
130 | )} | ||
131 | </li> | ||
132 | ); | ||
133 | } | ||
134 | } | ||
135 | |||
136 | export default SortableElement(TabItem); | ||
diff --git a/src/components/services/tabs/Tabbar.js b/src/components/services/tabs/Tabbar.js new file mode 100644 index 000000000..fdb2c0a59 --- /dev/null +++ b/src/components/services/tabs/Tabbar.js | |||
@@ -0,0 +1,77 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | |||
5 | import TabBarSortableList from './TabBarSortableList'; | ||
6 | |||
7 | @observer | ||
8 | export default class TabBar extends Component { | ||
9 | static propTypes = { | ||
10 | services: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
11 | setActive: PropTypes.func.isRequired, | ||
12 | openSettings: PropTypes.func.isRequired, | ||
13 | enableToolTip: PropTypes.func.isRequired, | ||
14 | disableToolTip: PropTypes.func.isRequired, | ||
15 | reorder: PropTypes.func.isRequired, | ||
16 | reload: PropTypes.func.isRequired, | ||
17 | toggleNotifications: PropTypes.func.isRequired, | ||
18 | deleteService: PropTypes.func.isRequired, | ||
19 | updateService: PropTypes.func.isRequired, | ||
20 | } | ||
21 | |||
22 | onSortEnd = ({ oldIndex, newIndex }) => { | ||
23 | const { | ||
24 | enableToolTip, | ||
25 | reorder, | ||
26 | } = this.props; | ||
27 | |||
28 | enableToolTip(); | ||
29 | reorder({ oldIndex, newIndex }); | ||
30 | }; | ||
31 | |||
32 | disableService = ({ serviceId }) => { | ||
33 | const { updateService } = this.props; | ||
34 | |||
35 | if (serviceId) { | ||
36 | updateService({ | ||
37 | serviceId, | ||
38 | serviceData: { | ||
39 | isEnabled: false, | ||
40 | }, | ||
41 | redirect: false, | ||
42 | }); | ||
43 | } | ||
44 | } | ||
45 | |||
46 | render() { | ||
47 | const { | ||
48 | services, | ||
49 | setActive, | ||
50 | openSettings, | ||
51 | disableToolTip, | ||
52 | reload, | ||
53 | toggleNotifications, | ||
54 | deleteService, | ||
55 | } = this.props; | ||
56 | |||
57 | return ( | ||
58 | <div> | ||
59 | <TabBarSortableList | ||
60 | services={services} | ||
61 | setActive={setActive} | ||
62 | onSortEnd={this.onSortEnd} | ||
63 | onSortStart={disableToolTip} | ||
64 | reload={reload} | ||
65 | toggleNotifications={toggleNotifications} | ||
66 | deleteService={deleteService} | ||
67 | disableService={this.disableService} | ||
68 | openSettings={openSettings} | ||
69 | distance={20} | ||
70 | axis="y" | ||
71 | lockAxis="y" | ||
72 | helperClass="is-reordering" | ||
73 | /> | ||
74 | </div> | ||
75 | ); | ||
76 | } | ||
77 | } | ||
diff --git a/src/components/settings/SettingsLayout.js b/src/components/settings/SettingsLayout.js new file mode 100644 index 000000000..d5392ddba --- /dev/null +++ b/src/components/settings/SettingsLayout.js | |||
@@ -0,0 +1,56 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | |||
5 | import { oneOrManyChildElements } from '../../prop-types'; | ||
6 | import Appear from '../ui/effects/Appear'; | ||
7 | |||
8 | @observer | ||
9 | export default class SettingsLayout extends Component { | ||
10 | static propTypes = { | ||
11 | navigation: PropTypes.element.isRequired, | ||
12 | children: oneOrManyChildElements.isRequired, | ||
13 | closeSettings: PropTypes.func.isRequired, | ||
14 | }; | ||
15 | |||
16 | componentWillMount() { | ||
17 | document.addEventListener('keydown', this.handleKeyDown.bind(this), false); | ||
18 | } | ||
19 | |||
20 | componentWillUnmount() { | ||
21 | document.removeEventListener('keydown', this.handleKeyDown.bind(this), false); | ||
22 | } | ||
23 | |||
24 | handleKeyDown(e) { | ||
25 | if (e.keyCode === 27) { // escape key | ||
26 | this.props.closeSettings(); | ||
27 | } | ||
28 | } | ||
29 | |||
30 | render() { | ||
31 | const { | ||
32 | navigation, | ||
33 | children, | ||
34 | closeSettings, | ||
35 | } = this.props; | ||
36 | |||
37 | return ( | ||
38 | <Appear transitionName="fadeIn-fast"> | ||
39 | <div className="settings-wrapper"> | ||
40 | <button | ||
41 | className="settings-wrapper__action" | ||
42 | onClick={closeSettings} | ||
43 | /> | ||
44 | <div className="settings franz-form"> | ||
45 | {navigation} | ||
46 | {children} | ||
47 | <button | ||
48 | className="settings__close mdi mdi-close" | ||
49 | onClick={closeSettings} | ||
50 | /> | ||
51 | </div> | ||
52 | </div> | ||
53 | </Appear> | ||
54 | ); | ||
55 | } | ||
56 | } | ||
diff --git a/src/components/settings/account/AccountDashboard.js b/src/components/settings/account/AccountDashboard.js new file mode 100644 index 000000000..75dbdef49 --- /dev/null +++ b/src/components/settings/account/AccountDashboard.js | |||
@@ -0,0 +1,286 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape, FormattedMessage } from 'react-intl'; | ||
5 | import ReactTooltip from 'react-tooltip'; | ||
6 | import moment from 'moment'; | ||
7 | |||
8 | import Loader from '../../ui/Loader'; | ||
9 | import Button from '../../ui/Button'; | ||
10 | import Infobox from '../../ui/Infobox'; | ||
11 | import Link from '../../ui/Link'; | ||
12 | import SubscriptionForm from '../../../containers/ui/SubscriptionFormScreen'; | ||
13 | |||
14 | const messages = defineMessages({ | ||
15 | headline: { | ||
16 | id: 'settings.account.headline', | ||
17 | defaultMessage: '!!!Account', | ||
18 | }, | ||
19 | headlineSubscription: { | ||
20 | id: 'settings.account.headlineSubscription', | ||
21 | defaultMessage: '!!!Your Subscription', | ||
22 | }, | ||
23 | headlineUpgrade: { | ||
24 | id: 'settings.account.headlineUpgrade', | ||
25 | defaultMessage: '!!!Upgrade your Account', | ||
26 | }, | ||
27 | headlineInvoices: { | ||
28 | id: 'settings.account.headlineInvoices', | ||
29 | defaultMessage: '!!Invoices', | ||
30 | }, | ||
31 | manageSubscriptionButtonLabel: { | ||
32 | id: 'settings.account.manageSubscription.label', | ||
33 | defaultMessage: '!!!Manage your subscription', | ||
34 | }, | ||
35 | accountTypeBasic: { | ||
36 | id: 'settings.account.accountType.basic', | ||
37 | defaultMessage: '!!!Basic Account', | ||
38 | }, | ||
39 | accountTypePremium: { | ||
40 | id: 'settings.account.accountType.premium', | ||
41 | defaultMessage: '!!!Premium Supporter Account', | ||
42 | }, | ||
43 | accountEditButton: { | ||
44 | id: 'settings.account.account.editButton', | ||
45 | defaultMessage: '!!!Edit Account', | ||
46 | }, | ||
47 | invoiceDownload: { | ||
48 | id: 'settings.account.invoiceDownload', | ||
49 | defaultMessage: '!!!Download', | ||
50 | }, | ||
51 | userInfoRequestFailed: { | ||
52 | id: 'settings.account.userInfoRequestFailed', | ||
53 | defaultMessage: '!!!Could not load user information', | ||
54 | }, | ||
55 | tryReloadUserInfoRequest: { | ||
56 | id: 'settings.account.tryReloadUserInfoRequest', | ||
57 | defaultMessage: '!!!Try again', | ||
58 | }, | ||
59 | miningActive: { | ||
60 | id: 'settings.account.mining.active', | ||
61 | defaultMessage: '!!!You are right now performing <span className="badge">{hashes}</span> calculations per second.', | ||
62 | }, | ||
63 | miningThankYou: { | ||
64 | id: 'settings.account.mining.thankyou', | ||
65 | defaultMessage: '!!!Thank you for supporting Franz with your processing power.', | ||
66 | }, | ||
67 | miningMoreInfo: { | ||
68 | id: 'settings.account.mining.moreInformation', | ||
69 | defaultMessage: '!!!Get more information', | ||
70 | }, | ||
71 | cancelMining: { | ||
72 | id: 'settings.account.mining.cancel', | ||
73 | defaultMessage: '!!!Cancel mining', | ||
74 | }, | ||
75 | }); | ||
76 | |||
77 | @observer | ||
78 | export default class AccountDashboard extends Component { | ||
79 | static propTypes = { | ||
80 | user: MobxPropTypes.observableObject.isRequired, | ||
81 | orders: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
82 | hashrate: PropTypes.number.isRequired, | ||
83 | isLoading: PropTypes.bool.isRequired, | ||
84 | isLoadingOrdersInfo: PropTypes.bool.isRequired, | ||
85 | isLoadingPlans: PropTypes.bool.isRequired, | ||
86 | isCreatingPaymentDashboardUrl: PropTypes.bool.isRequired, | ||
87 | userInfoRequestFailed: PropTypes.bool.isRequired, | ||
88 | retryUserInfoRequest: PropTypes.func.isRequired, | ||
89 | openDashboard: PropTypes.func.isRequired, | ||
90 | openExternalUrl: PropTypes.func.isRequired, | ||
91 | onCloseSubscriptionWindow: PropTypes.func.isRequired, | ||
92 | stopMiner: PropTypes.func.isRequired, | ||
93 | }; | ||
94 | |||
95 | static contextTypes = { | ||
96 | intl: intlShape, | ||
97 | }; | ||
98 | |||
99 | render() { | ||
100 | const { | ||
101 | user, | ||
102 | orders, | ||
103 | hashrate, | ||
104 | isLoading, | ||
105 | isCreatingPaymentDashboardUrl, | ||
106 | openDashboard, | ||
107 | openExternalUrl, | ||
108 | isLoadingOrdersInfo, | ||
109 | isLoadingPlans, | ||
110 | userInfoRequestFailed, | ||
111 | retryUserInfoRequest, | ||
112 | onCloseSubscriptionWindow, | ||
113 | stopMiner, | ||
114 | } = this.props; | ||
115 | const { intl } = this.context; | ||
116 | |||
117 | return ( | ||
118 | <div className="settings__main"> | ||
119 | <div className="settings__header"> | ||
120 | <span className="settings__header-item"> | ||
121 | {intl.formatMessage(messages.headline)} | ||
122 | </span> | ||
123 | </div> | ||
124 | <div className="settings__body"> | ||
125 | {isLoading && ( | ||
126 | <Loader /> | ||
127 | )} | ||
128 | |||
129 | {!isLoading && userInfoRequestFailed && ( | ||
130 | <div> | ||
131 | <Infobox | ||
132 | icon="alert" | ||
133 | type="danger" | ||
134 | ctaLabel={intl.formatMessage(messages.tryReloadUserInfoRequest)} | ||
135 | ctaLoading={isLoading} | ||
136 | ctaOnClick={retryUserInfoRequest} | ||
137 | > | ||
138 | {intl.formatMessage(messages.userInfoRequestFailed)} | ||
139 | </Infobox> | ||
140 | </div> | ||
141 | )} | ||
142 | |||
143 | {!userInfoRequestFailed && ( | ||
144 | <div> | ||
145 | {!isLoading && ( | ||
146 | <div className="account"> | ||
147 | <div className="account__box account__box--flex"> | ||
148 | <div className="account__avatar"> | ||
149 | <img | ||
150 | src="./assets/images/logo.svg" | ||
151 | alt="" | ||
152 | /> | ||
153 | {user.isPremium && ( | ||
154 | <span | ||
155 | className="account__avatar-premium emoji" | ||
156 | data-tip="Premium Supporter Account" | ||
157 | > | ||
158 | <img src="./assets/images/emoji/star.png" alt="" /> | ||
159 | </span> | ||
160 | )} | ||
161 | </div> | ||
162 | <div className="account__info"> | ||
163 | <h2> | ||
164 | {`${user.firstname} ${user.lastname}`} | ||
165 | </h2> | ||
166 | {user.organization && `${user.organization}, `} | ||
167 | {user.email}<br /> | ||
168 | {!user.isPremium && ( | ||
169 | <span className="badge badge">{intl.formatMessage(messages.accountTypeBasic)}</span> | ||
170 | )} | ||
171 | {user.isPremium && ( | ||
172 | <span className="badge badge--premium">{intl.formatMessage(messages.accountTypePremium)}</span> | ||
173 | )} | ||
174 | </div> | ||
175 | <Link to="/settings/user/edit" className="button"> | ||
176 | {intl.formatMessage(messages.accountEditButton)} | ||
177 | </Link> | ||
178 | |||
179 | {user.emailValidated} | ||
180 | </div> | ||
181 | </div> | ||
182 | )} | ||
183 | |||
184 | {user.isSubscriptionOwner && ( | ||
185 | isLoadingOrdersInfo ? ( | ||
186 | <Loader /> | ||
187 | ) : ( | ||
188 | <div className="account franz-form"> | ||
189 | {orders.length > 0 && ( | ||
190 | <div> | ||
191 | <div className="account__box"> | ||
192 | <h2>{intl.formatMessage(messages.headlineSubscription)}</h2> | ||
193 | <div className="account__subscription"> | ||
194 | {orders[0].name} | ||
195 | <span className="badge">{orders[0].price}</span> | ||
196 | <Button | ||
197 | label={intl.formatMessage(messages.manageSubscriptionButtonLabel)} | ||
198 | className="account__subscription-button franz-form__button--inverted" | ||
199 | loaded={!isCreatingPaymentDashboardUrl} | ||
200 | onClick={() => openDashboard()} | ||
201 | /> | ||
202 | </div> | ||
203 | </div> | ||
204 | <div className="account__box account__box--last"> | ||
205 | <h2>{intl.formatMessage(messages.headlineInvoices)}</h2> | ||
206 | <table className="invoices"> | ||
207 | <tbody> | ||
208 | {orders.map(order => ( | ||
209 | <tr key={order.id}> | ||
210 | <td className="invoices__date"> | ||
211 | {moment(order.date).format('DD.MM.YYYY')} | ||
212 | </td> | ||
213 | <td className="invoices__action"> | ||
214 | <button | ||
215 | onClick={() => openExternalUrl(order.invoiceUrl)} | ||
216 | > | ||
217 | {intl.formatMessage(messages.invoiceDownload)} | ||
218 | </button> | ||
219 | </td> | ||
220 | </tr> | ||
221 | ))} | ||
222 | </tbody> | ||
223 | </table> | ||
224 | </div> | ||
225 | </div> | ||
226 | )} | ||
227 | </div> | ||
228 | ) | ||
229 | )} | ||
230 | |||
231 | {user.isMiner && ( | ||
232 | <div className="account franz-form"> | ||
233 | <div className="account__box"> | ||
234 | <h2>{intl.formatMessage(messages.headlineSubscription)}</h2> | ||
235 | <div className="account__subscription"> | ||
236 | <div> | ||
237 | <p>{intl.formatMessage(messages.miningThankYou)}</p> | ||
238 | <FormattedMessage | ||
239 | {...messages.miningActive} | ||
240 | values={{ | ||
241 | hashes: <span className="badge">{hashrate.toFixed(2)}</span>, | ||
242 | }} | ||
243 | tagName="p" | ||
244 | /> | ||
245 | <p> | ||
246 | <Link | ||
247 | to="http://meetfranz.com/mining" | ||
248 | target="_blank" | ||
249 | className="link" | ||
250 | > | ||
251 | {intl.formatMessage(messages.miningMoreInfo)} | ||
252 | </Link> | ||
253 | </p> | ||
254 | </div> | ||
255 | <Button | ||
256 | label={intl.formatMessage(messages.cancelMining)} | ||
257 | className="account__subscription-button franz-form__button--inverted" | ||
258 | onClick={() => stopMiner()} | ||
259 | /> | ||
260 | </div> | ||
261 | </div> | ||
262 | </div> | ||
263 | )} | ||
264 | |||
265 | {!user.isPremium && !user.isMiner && ( | ||
266 | isLoadingPlans ? ( | ||
267 | <Loader /> | ||
268 | ) : ( | ||
269 | <div className="account franz-form"> | ||
270 | <div className="account__box account__box--last"> | ||
271 | <h2>{intl.formatMessage(messages.headlineUpgrade)}</h2> | ||
272 | <SubscriptionForm | ||
273 | onCloseWindow={onCloseSubscriptionWindow} | ||
274 | /> | ||
275 | </div> | ||
276 | </div> | ||
277 | ) | ||
278 | )} | ||
279 | </div> | ||
280 | )} | ||
281 | </div> | ||
282 | <ReactTooltip place="right" type="dark" effect="solid" /> | ||
283 | </div> | ||
284 | ); | ||
285 | } | ||
286 | } | ||
diff --git a/src/components/settings/navigation/SettingsNavigation.js b/src/components/settings/navigation/SettingsNavigation.js new file mode 100644 index 000000000..3b21a7765 --- /dev/null +++ b/src/components/settings/navigation/SettingsNavigation.js | |||
@@ -0,0 +1,84 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { defineMessages, intlShape } from 'react-intl'; | ||
4 | |||
5 | import Link from '../../ui/Link'; | ||
6 | |||
7 | const messages = defineMessages({ | ||
8 | availableServices: { | ||
9 | id: 'settings.navigation.availableServices', | ||
10 | defaultMessage: '!!!Available services', | ||
11 | }, | ||
12 | yourServices: { | ||
13 | id: 'settings.navigation.yourServices', | ||
14 | defaultMessage: '!!!Your services', | ||
15 | }, | ||
16 | account: { | ||
17 | id: 'settings.navigation.account', | ||
18 | defaultMessage: '!!!Account', | ||
19 | }, | ||
20 | settings: { | ||
21 | id: 'settings.navigation.settings', | ||
22 | defaultMessage: '!!!Settings', | ||
23 | }, | ||
24 | logout: { | ||
25 | id: 'settings.navigation.logout', | ||
26 | defaultMessage: '!!!Logout', | ||
27 | }, | ||
28 | }); | ||
29 | |||
30 | export default class SettingsNavigation extends Component { | ||
31 | static propTypes = { | ||
32 | serviceCount: PropTypes.number.isRequired, | ||
33 | }; | ||
34 | |||
35 | static contextTypes = { | ||
36 | intl: intlShape, | ||
37 | }; | ||
38 | |||
39 | render() { | ||
40 | const { serviceCount } = this.props; | ||
41 | const { intl } = this.context; | ||
42 | |||
43 | return ( | ||
44 | <div className="settings-navigation"> | ||
45 | <Link | ||
46 | to="/settings/recipes" | ||
47 | className="settings-navigation__link" | ||
48 | activeClassName="is-active" | ||
49 | > | ||
50 | {intl.formatMessage(messages.availableServices)} | ||
51 | </Link> | ||
52 | <Link | ||
53 | to="/settings/services" | ||
54 | className="settings-navigation__link" | ||
55 | activeClassName="is-active" | ||
56 | > | ||
57 | {intl.formatMessage(messages.yourServices)} <span className="badge">{serviceCount}</span> | ||
58 | </Link> | ||
59 | <Link | ||
60 | to="/settings/user" | ||
61 | className="settings-navigation__link" | ||
62 | activeClassName="is-active" | ||
63 | > | ||
64 | {intl.formatMessage(messages.account)} | ||
65 | </Link> | ||
66 | <Link | ||
67 | to="/settings/app" | ||
68 | className="settings-navigation__link" | ||
69 | activeClassName="is-active" | ||
70 | > | ||
71 | {intl.formatMessage(messages.settings)} | ||
72 | </Link> | ||
73 | <span className="settings-navigation__expander" /> | ||
74 | <Link | ||
75 | to="/auth/logout" | ||
76 | className="settings-navigation__link" | ||
77 | activeClassName="is-active" | ||
78 | > | ||
79 | {intl.formatMessage(messages.logout)} | ||
80 | </Link> | ||
81 | </div> | ||
82 | ); | ||
83 | } | ||
84 | } | ||
diff --git a/src/components/settings/recipes/RecipeItem.js b/src/components/settings/recipes/RecipeItem.js new file mode 100644 index 000000000..7b2f64d26 --- /dev/null +++ b/src/components/settings/recipes/RecipeItem.js | |||
@@ -0,0 +1,34 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | |||
5 | import RecipePreviewModel from '../../../models/RecipePreview'; | ||
6 | |||
7 | @observer | ||
8 | export default class RecipeItem extends Component { | ||
9 | static propTypes = { | ||
10 | recipe: PropTypes.instanceOf(RecipePreviewModel).isRequired, | ||
11 | onClick: PropTypes.func.isRequired, | ||
12 | }; | ||
13 | |||
14 | render() { | ||
15 | const { recipe, onClick } = this.props; | ||
16 | |||
17 | return ( | ||
18 | <button | ||
19 | className="recipe-teaser" | ||
20 | onClick={onClick} | ||
21 | > | ||
22 | {recipe.local && ( | ||
23 | <span className="recipe-teaser__dev-badge">dev</span> | ||
24 | )} | ||
25 | <img | ||
26 | src={recipe.icons.svg} | ||
27 | className="recipe-teaser__icon" | ||
28 | alt="" | ||
29 | /> | ||
30 | <span className="recipe-teaser__label">{recipe.name}</span> | ||
31 | </button> | ||
32 | ); | ||
33 | } | ||
34 | } | ||
diff --git a/src/components/settings/recipes/RecipesDashboard.js b/src/components/settings/recipes/RecipesDashboard.js new file mode 100644 index 000000000..02ea04e35 --- /dev/null +++ b/src/components/settings/recipes/RecipesDashboard.js | |||
@@ -0,0 +1,151 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | |||
7 | import SearchInput from '../../ui/SearchInput'; | ||
8 | import Infobox from '../../ui/Infobox'; | ||
9 | import RecipeItem from './RecipeItem'; | ||
10 | import Loader from '../../ui/Loader'; | ||
11 | import Appear from '../../ui/effects/Appear'; | ||
12 | |||
13 | const messages = defineMessages({ | ||
14 | headline: { | ||
15 | id: 'settings.recipes.headline', | ||
16 | defaultMessage: '!!!Available Services', | ||
17 | }, | ||
18 | mostPopularRecipes: { | ||
19 | id: 'settings.recipes.mostPopular', | ||
20 | defaultMessage: '!!!Most popular', | ||
21 | }, | ||
22 | allRecipes: { | ||
23 | id: 'settings.recipes.all', | ||
24 | defaultMessage: '!!!All services', | ||
25 | }, | ||
26 | devRecipes: { | ||
27 | id: 'settings.recipes.dev', | ||
28 | defaultMessage: '!!!Development', | ||
29 | }, | ||
30 | nothingFound: { | ||
31 | id: 'settings.recipes.nothingFound', | ||
32 | defaultMessage: '!!!Sorry, but no service matched your search term.', | ||
33 | }, | ||
34 | servicesSuccessfulAddedInfo: { | ||
35 | id: 'settings.recipes.servicesSuccessfulAddedInfo', | ||
36 | defaultMessage: '!!!Service successfully added', | ||
37 | }, | ||
38 | }); | ||
39 | |||
40 | @observer | ||
41 | export default class RecipesDashboard extends Component { | ||
42 | static propTypes = { | ||
43 | recipes: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
44 | isLoading: PropTypes.bool.isRequired, | ||
45 | hasLoadedRecipes: PropTypes.bool.isRequired, | ||
46 | showAddServiceInterface: PropTypes.func.isRequired, | ||
47 | searchRecipes: PropTypes.func.isRequired, | ||
48 | resetSearch: PropTypes.func.isRequired, | ||
49 | serviceStatus: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
50 | devRecipesCount: PropTypes.number.isRequired, | ||
51 | searchNeedle: PropTypes.string, | ||
52 | }; | ||
53 | |||
54 | static defaultProps = { | ||
55 | searchNeedle: '', | ||
56 | } | ||
57 | |||
58 | static contextTypes = { | ||
59 | intl: intlShape, | ||
60 | }; | ||
61 | |||
62 | render() { | ||
63 | const { | ||
64 | recipes, | ||
65 | isLoading, | ||
66 | hasLoadedRecipes, | ||
67 | showAddServiceInterface, | ||
68 | searchRecipes, | ||
69 | resetSearch, | ||
70 | serviceStatus, | ||
71 | devRecipesCount, | ||
72 | searchNeedle, | ||
73 | } = this.props; | ||
74 | const { intl } = this.context; | ||
75 | |||
76 | return ( | ||
77 | <div className="settings__main"> | ||
78 | <div className="settings__header"> | ||
79 | <SearchInput | ||
80 | className="settings__search-header" | ||
81 | defaultValue={intl.formatMessage(messages.headline)} | ||
82 | onChange={e => searchRecipes(e)} | ||
83 | onReset={() => resetSearch()} | ||
84 | throttle | ||
85 | /> | ||
86 | </div> | ||
87 | <div className="settings__body recipes"> | ||
88 | {serviceStatus.length > 0 && serviceStatus.includes('created') && ( | ||
89 | <Appear> | ||
90 | <Infobox | ||
91 | type="success" | ||
92 | icon="checkbox-marked-circle-outline" | ||
93 | dismissable | ||
94 | > | ||
95 | {intl.formatMessage(messages.servicesSuccessfulAddedInfo)} | ||
96 | </Infobox> | ||
97 | </Appear> | ||
98 | )} | ||
99 | {!searchNeedle && ( | ||
100 | <div className="recipes__navigation"> | ||
101 | <Link | ||
102 | to="/settings/recipes" | ||
103 | className="badge" | ||
104 | activeClassName="badge--primary" | ||
105 | > | ||
106 | {intl.formatMessage(messages.mostPopularRecipes)} | ||
107 | </Link> | ||
108 | <Link | ||
109 | to="/settings/recipes/all" | ||
110 | className="badge" | ||
111 | activeClassName="badge--primary" | ||
112 | > | ||
113 | {intl.formatMessage(messages.allRecipes)} | ||
114 | </Link> | ||
115 | {devRecipesCount > 0 && ( | ||
116 | <Link | ||
117 | to="/settings/recipes/dev" | ||
118 | className="badge" | ||
119 | activeClassName="badge--primary" | ||
120 | > | ||
121 | {intl.formatMessage(messages.devRecipes)} ({devRecipesCount}) | ||
122 | </Link> | ||
123 | )} | ||
124 | </div> | ||
125 | )} | ||
126 | {isLoading ? ( | ||
127 | <Loader /> | ||
128 | ) : ( | ||
129 | <div className="recipes__list"> | ||
130 | {hasLoadedRecipes && recipes.length === 0 && ( | ||
131 | <p className="align-middle settings__empty-state"> | ||
132 | <span className="emoji"> | ||
133 | <img src="./assets/images/emoji/dontknow.png" alt="" /> | ||
134 | </span> | ||
135 | {intl.formatMessage(messages.nothingFound)} | ||
136 | </p> | ||
137 | )} | ||
138 | {recipes.map(recipe => ( | ||
139 | <RecipeItem | ||
140 | key={recipe.id} | ||
141 | recipe={recipe} | ||
142 | onClick={() => showAddServiceInterface({ recipeId: recipe.id })} | ||
143 | /> | ||
144 | ))} | ||
145 | </div> | ||
146 | )} | ||
147 | </div> | ||
148 | </div> | ||
149 | ); | ||
150 | } | ||
151 | } | ||
diff --git a/src/components/settings/services/EditServiceForm.js b/src/components/settings/services/EditServiceForm.js new file mode 100644 index 000000000..fac0f6b9a --- /dev/null +++ b/src/components/settings/services/EditServiceForm.js | |||
@@ -0,0 +1,277 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Link } from 'react-router'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | import normalizeUrl from 'normalize-url'; | ||
7 | |||
8 | import Form from '../../../lib/Form'; | ||
9 | import User from '../../../models/User'; | ||
10 | import Recipe from '../../../models/Recipe'; | ||
11 | import Service from '../../../models/Service'; | ||
12 | import Tabs, { TabItem } from '../../ui/Tabs'; | ||
13 | import Input from '../../ui/Input'; | ||
14 | import Toggle from '../../ui/Toggle'; | ||
15 | import Button from '../../ui/Button'; | ||
16 | |||
17 | const messages = defineMessages({ | ||
18 | saveService: { | ||
19 | id: 'settings.service.form.saveButton', | ||
20 | defaultMessage: '!!!Save service', | ||
21 | }, | ||
22 | deleteService: { | ||
23 | id: 'settings.service.form.deleteButton', | ||
24 | defaultMessage: '!!!Delete Service', | ||
25 | }, | ||
26 | availableServices: { | ||
27 | id: 'settings.service.form.availableServices', | ||
28 | defaultMessage: '!!!Available services', | ||
29 | }, | ||
30 | yourServices: { | ||
31 | id: 'settings.service.form.yourServices', | ||
32 | defaultMessage: '!!!Your services', | ||
33 | }, | ||
34 | addServiceHeadline: { | ||
35 | id: 'settings.service.form.addServiceHeadline', | ||
36 | defaultMessage: '!!!Add {name}', | ||
37 | }, | ||
38 | editServiceHeadline: { | ||
39 | id: 'settings.service.form.editServiceHeadline', | ||
40 | defaultMessage: '!!!Edit {name}', | ||
41 | }, | ||
42 | tabHosted: { | ||
43 | id: 'settings.service.form.tabHosted', | ||
44 | defaultMessage: '!!!Hosted', | ||
45 | }, | ||
46 | tabOnPremise: { | ||
47 | id: 'settings.service.form.tabOnPremise', | ||
48 | defaultMessage: '!!!Self hosted ⭐️', | ||
49 | }, | ||
50 | customUrlValidationError: { | ||
51 | id: 'settings.service.form.customUrlValidationError', | ||
52 | defaultMessage: '!!!Could not validate custom {name} server.', | ||
53 | }, | ||
54 | customUrlPremiumInfo: { | ||
55 | id: 'settings.service.form.customUrlPremiumInfo', | ||
56 | defaultMessage: '!!!To add self hosted services, you need a Franz Premium Supporter Account.', | ||
57 | }, | ||
58 | customUrlUpgradeAccount: { | ||
59 | id: 'settings.service.form.customUrlUpgradeAccount', | ||
60 | defaultMessage: '!!!Upgrade your account', | ||
61 | }, | ||
62 | indirectMessageInfo: { | ||
63 | id: 'settings.service.form.indirectMessageInfo', | ||
64 | defaultMessage: '!!!You will be notified about all new messages in a channel, not just @username, @channel, @here, ...', // eslint-disable-line | ||
65 | }, | ||
66 | }); | ||
67 | |||
68 | @observer | ||
69 | export default class EditServiceForm extends Component { | ||
70 | static propTypes = { | ||
71 | recipe: PropTypes.instanceOf(Recipe).isRequired, | ||
72 | // service: PropTypes.oneOfType([ | ||
73 | // PropTypes.object, | ||
74 | // PropTypes.instanceOf(Service), | ||
75 | // ]), | ||
76 | service(props, propName) { | ||
77 | if (props.action === 'edit' && !(props[propName] instanceof Service)) { | ||
78 | return new Error(`'${propName}'' is expected to be of type 'Service' | ||
79 | when editing a Service`); | ||
80 | } | ||
81 | |||
82 | return null; | ||
83 | }, | ||
84 | user: PropTypes.instanceOf(User).isRequired, | ||
85 | action: PropTypes.string.isRequired, | ||
86 | form: PropTypes.instanceOf(Form).isRequired, | ||
87 | onSubmit: PropTypes.func.isRequired, | ||
88 | onDelete: PropTypes.func.isRequired, | ||
89 | isSaving: PropTypes.bool.isRequired, | ||
90 | isDeleting: PropTypes.bool.isRequired, | ||
91 | }; | ||
92 | |||
93 | static defaultProps = { | ||
94 | service: {}, | ||
95 | }; | ||
96 | static contextTypes = { | ||
97 | intl: intlShape, | ||
98 | }; | ||
99 | |||
100 | state = { | ||
101 | isValidatingCustomUrl: false, | ||
102 | } | ||
103 | |||
104 | submit(e) { | ||
105 | const { recipe } = this.props; | ||
106 | |||
107 | e.preventDefault(); | ||
108 | this.props.form.submit({ | ||
109 | onSuccess: async (form) => { | ||
110 | const values = form.values(); | ||
111 | |||
112 | let isValid = true; | ||
113 | |||
114 | if (recipe.validateUrl && values.customUrl) { | ||
115 | this.setState({ isValidatingCustomUrl: true }); | ||
116 | try { | ||
117 | values.customUrl = normalizeUrl(values.customUrl); | ||
118 | isValid = await recipe.validateUrl(values.customUrl); | ||
119 | } catch (err) { | ||
120 | console.warn('ValidateURL', err); | ||
121 | isValid = false; | ||
122 | } | ||
123 | } | ||
124 | |||
125 | if (isValid) { | ||
126 | this.props.onSubmit(values); | ||
127 | } else { | ||
128 | form.invalidate('url-validation-error'); | ||
129 | } | ||
130 | |||
131 | this.setState({ isValidatingCustomUrl: false }); | ||
132 | }, | ||
133 | onError: () => {}, | ||
134 | }); | ||
135 | } | ||
136 | |||
137 | render() { | ||
138 | const { | ||
139 | recipe, | ||
140 | service, | ||
141 | action, | ||
142 | user, | ||
143 | form, | ||
144 | isSaving, | ||
145 | isDeleting, | ||
146 | onDelete, | ||
147 | } = this.props; | ||
148 | const { intl } = this.context; | ||
149 | |||
150 | const { isValidatingCustomUrl } = this.state; | ||
151 | |||
152 | const deleteButton = isDeleting ? ( | ||
153 | <Button | ||
154 | label={intl.formatMessage(messages.deleteService)} | ||
155 | loaded={false} | ||
156 | buttonType="secondary" | ||
157 | className="settings__delete-button" | ||
158 | disabled | ||
159 | /> | ||
160 | ) : ( | ||
161 | <Button | ||
162 | buttonType="danger" | ||
163 | label={intl.formatMessage(messages.deleteService)} | ||
164 | className="settings__delete-button" | ||
165 | onClick={onDelete} | ||
166 | /> | ||
167 | ); | ||
168 | |||
169 | return ( | ||
170 | <div className="settings__main"> | ||
171 | <div className="settings__header"> | ||
172 | <span className="settings__header-item"> | ||
173 | {action === 'add' ? ( | ||
174 | <Link to="/settings/recipes"> | ||
175 | {intl.formatMessage(messages.availableServices)} | ||
176 | </Link> | ||
177 | ) : ( | ||
178 | <Link to="/settings/services"> | ||
179 | {intl.formatMessage(messages.yourServices)} | ||
180 | </Link> | ||
181 | )} | ||
182 | </span> | ||
183 | <span className="separator" /> | ||
184 | <span className="settings__header-item"> | ||
185 | {action === 'add' ? ( | ||
186 | intl.formatMessage(messages.addServiceHeadline, { | ||
187 | name: recipe.name, | ||
188 | }) | ||
189 | ) : ( | ||
190 | intl.formatMessage(messages.editServiceHeadline, { | ||
191 | name: service.name !== '' ? service.name : recipe.name, | ||
192 | }) | ||
193 | )} | ||
194 | </span> | ||
195 | </div> | ||
196 | <div className="settings__body"> | ||
197 | <form onSubmit={e => this.submit(e)} id="form"> | ||
198 | <Input field={form.$('name')} focus /> | ||
199 | {(recipe.hasTeamId || recipe.hasCustomUrl) && ( | ||
200 | <Tabs | ||
201 | active={service.customUrl ? 1 : 0} | ||
202 | > | ||
203 | {recipe.hasTeamId && ( | ||
204 | <TabItem title={intl.formatMessage(messages.tabHosted)}> | ||
205 | <Input field={form.$('team')} suffix={recipe.urlInputSuffix} /> | ||
206 | </TabItem> | ||
207 | )} | ||
208 | {recipe.hasCustomUrl && ( | ||
209 | <TabItem title={intl.formatMessage(messages.tabOnPremise)}> | ||
210 | {user.isPremium ? ( | ||
211 | <div> | ||
212 | <Input field={form.$('customUrl')} /> | ||
213 | {form.error === 'url-validation-error' && ( | ||
214 | <p className="franz-form__error"> | ||
215 | {intl.formatMessage(messages.customUrlValidationError, { name: recipe.name })} | ||
216 | </p> | ||
217 | )} | ||
218 | </div> | ||
219 | ) : ( | ||
220 | <div className="center premium-info"> | ||
221 | <p>{intl.formatMessage(messages.customUrlPremiumInfo)}</p> | ||
222 | <p> | ||
223 | <Link to="/settings/user" className="button"> | ||
224 | {intl.formatMessage(messages.customUrlUpgradeAccount)} | ||
225 | </Link> | ||
226 | </p> | ||
227 | </div> | ||
228 | )} | ||
229 | </TabItem> | ||
230 | )} | ||
231 | </Tabs> | ||
232 | )} | ||
233 | <div className="settings__options"> | ||
234 | <Toggle field={form.$('isNotificationEnabled')} /> | ||
235 | {recipe.hasIndirectMessages && ( | ||
236 | <div> | ||
237 | <Toggle field={form.$('isIndirectMessageBadgeEnabled')} /> | ||
238 | <p className="settings__indirect-message-help"> | ||
239 | {intl.formatMessage(messages.indirectMessageInfo)} | ||
240 | </p> | ||
241 | </div> | ||
242 | )} | ||
243 | <Toggle field={form.$('isEnabled')} /> | ||
244 | </div> | ||
245 | {recipe.message && ( | ||
246 | <p className="settings__message"> | ||
247 | <span className="mdi mdi-information" /> | ||
248 | {recipe.message} | ||
249 | </p> | ||
250 | )} | ||
251 | </form> | ||
252 | </div> | ||
253 | <div className="settings__controls"> | ||
254 | {/* Delete Button */} | ||
255 | {action === 'edit' && deleteButton} | ||
256 | |||
257 | {/* Save Button */} | ||
258 | {isSaving || isValidatingCustomUrl ? ( | ||
259 | <Button | ||
260 | type="submit" | ||
261 | label={intl.formatMessage(messages.saveService)} | ||
262 | loaded={false} | ||
263 | buttonType="secondary" | ||
264 | disabled | ||
265 | /> | ||
266 | ) : ( | ||
267 | <Button | ||
268 | type="submit" | ||
269 | label={intl.formatMessage(messages.saveService)} | ||
270 | htmlForm="form" | ||
271 | /> | ||
272 | )} | ||
273 | </div> | ||
274 | </div> | ||
275 | ); | ||
276 | } | ||
277 | } | ||
diff --git a/src/components/settings/services/ServiceError.js b/src/components/settings/services/ServiceError.js new file mode 100644 index 000000000..923053296 --- /dev/null +++ b/src/components/settings/services/ServiceError.js | |||
@@ -0,0 +1,68 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import { observer } from 'mobx-react'; | ||
3 | import { Link } from 'react-router'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Infobox from '../../ui/Infobox'; | ||
7 | import Button from '../../ui/Button'; | ||
8 | |||
9 | const messages = defineMessages({ | ||
10 | headline: { | ||
11 | id: 'settings.service.error.headline', | ||
12 | defaultMessage: '!!!Error', | ||
13 | }, | ||
14 | goBack: { | ||
15 | id: 'settings.service.error.goBack', | ||
16 | defaultMessage: '!!!Back to services', | ||
17 | }, | ||
18 | availableServices: { | ||
19 | id: 'settings.service.form.availableServices', | ||
20 | defaultMessage: '!!!Available services', | ||
21 | }, | ||
22 | errorMessage: { | ||
23 | id: 'settings.service.error.message', | ||
24 | defaultMessage: '!!!Could not load service recipe.', | ||
25 | }, | ||
26 | }); | ||
27 | |||
28 | @observer | ||
29 | export default class EditServiceForm extends Component { | ||
30 | static contextTypes = { | ||
31 | intl: intlShape, | ||
32 | }; | ||
33 | |||
34 | render() { | ||
35 | const { intl } = this.context; | ||
36 | |||
37 | return ( | ||
38 | <div className="settings__main"> | ||
39 | <div className="settings__header"> | ||
40 | <span className="settings__header-item"> | ||
41 | <Link to="/settings/recipes"> | ||
42 | {intl.formatMessage(messages.availableServices)} | ||
43 | </Link> | ||
44 | </span> | ||
45 | <span className="separator" /> | ||
46 | <span className="settings__header-item"> | ||
47 | {intl.formatMessage(messages.headline)} | ||
48 | </span> | ||
49 | </div> | ||
50 | <div className="settings__body"> | ||
51 | <Infobox | ||
52 | type="danger" | ||
53 | icon="alert" | ||
54 | > | ||
55 | {intl.formatMessage(messages.errorMessage)} | ||
56 | </Infobox> | ||
57 | </div> | ||
58 | <div className="settings__controls"> | ||
59 | <Button | ||
60 | label={intl.formatMessage(messages.goBack)} | ||
61 | htmlForm="form" | ||
62 | onClick={() => window.history.back()} | ||
63 | /> | ||
64 | </div> | ||
65 | </div> | ||
66 | ); | ||
67 | } | ||
68 | } | ||
diff --git a/src/components/settings/services/ServiceItem.js b/src/components/settings/services/ServiceItem.js new file mode 100644 index 000000000..20d8581d0 --- /dev/null +++ b/src/components/settings/services/ServiceItem.js | |||
@@ -0,0 +1,98 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { defineMessages, intlShape } from 'react-intl'; | ||
4 | import ReactTooltip from 'react-tooltip'; | ||
5 | import { observer } from 'mobx-react'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import ServiceModel from '../../../models/Service'; | ||
9 | |||
10 | const messages = defineMessages({ | ||
11 | tooltipIsDisabled: { | ||
12 | id: 'settings.services.tooltip.isDisabled', | ||
13 | defaultMessage: '!!!Service is disabled', | ||
14 | }, | ||
15 | tooltipNotificationsDisabled: { | ||
16 | id: 'settings.services.tooltip.notificationsDisabled', | ||
17 | defaultMessage: '!!!Notifications are disabled', | ||
18 | }, | ||
19 | }); | ||
20 | |||
21 | @observer | ||
22 | export default class ServiceItem extends Component { | ||
23 | static propTypes = { | ||
24 | service: PropTypes.instanceOf(ServiceModel).isRequired, | ||
25 | goToServiceForm: PropTypes.func.isRequired, | ||
26 | }; | ||
27 | static contextTypes = { | ||
28 | intl: intlShape, | ||
29 | }; | ||
30 | |||
31 | render() { | ||
32 | const { | ||
33 | service, | ||
34 | // toggleAction, | ||
35 | goToServiceForm, | ||
36 | } = this.props; | ||
37 | const { intl } = this.context; | ||
38 | |||
39 | return ( | ||
40 | <tr | ||
41 | className={classnames({ | ||
42 | 'service-table__row': true, | ||
43 | 'service-table__row--disabled': !service.isEnabled, | ||
44 | })} | ||
45 | > | ||
46 | <td | ||
47 | className="service-table__column-icon" | ||
48 | onClick={goToServiceForm} | ||
49 | > | ||
50 | <img | ||
51 | src={service.icon} | ||
52 | className={classnames({ | ||
53 | 'service-table__icon': true, | ||
54 | 'has-custom-icon': service.hasCustomIcon, | ||
55 | })} | ||
56 | alt="" | ||
57 | /> | ||
58 | </td> | ||
59 | <td | ||
60 | className="service-table__column-name" | ||
61 | onClick={goToServiceForm} | ||
62 | > | ||
63 | {service.name !== '' ? service.name : service.recipe.name} | ||
64 | </td> | ||
65 | <td | ||
66 | className="service-table__column-info" | ||
67 | onClick={goToServiceForm} | ||
68 | > | ||
69 | {!service.isEnabled && ( | ||
70 | <span | ||
71 | className="mdi mdi-power" | ||
72 | data-tip={intl.formatMessage(messages.tooltipIsDisabled)} | ||
73 | /> | ||
74 | )} | ||
75 | </td> | ||
76 | <td | ||
77 | className="service-table__column-info" | ||
78 | onClick={goToServiceForm} | ||
79 | > | ||
80 | {!service.isNotificationEnabled && ( | ||
81 | <span | ||
82 | className="mdi mdi-message-bulleted-off" | ||
83 | data-tip={intl.formatMessage(messages.tooltipNotificationsDisabled)} | ||
84 | /> | ||
85 | )} | ||
86 | <ReactTooltip place="top" type="dark" effect="solid" /> | ||
87 | </td> | ||
88 | {/* <td className="service-table__column-action"> | ||
89 | <input | ||
90 | type="checkbox" | ||
91 | onChange={toggleAction} | ||
92 | checked={service.isEnabled} | ||
93 | /> | ||
94 | </td> */} | ||
95 | </tr> | ||
96 | ); | ||
97 | } | ||
98 | } | ||
diff --git a/src/components/settings/services/ServicesDashboard.js b/src/components/settings/services/ServicesDashboard.js new file mode 100644 index 000000000..5f146b5f3 --- /dev/null +++ b/src/components/settings/services/ServicesDashboard.js | |||
@@ -0,0 +1,155 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { Link } from 'react-router'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | |||
7 | import SearchInput from '../../ui/SearchInput'; | ||
8 | import Infobox from '../../ui/Infobox'; | ||
9 | import Loader from '../../ui/Loader'; | ||
10 | import ServiceItem from './ServiceItem'; | ||
11 | import Appear from '../../ui/effects/Appear'; | ||
12 | |||
13 | const messages = defineMessages({ | ||
14 | headline: { | ||
15 | id: 'settings.services.headline', | ||
16 | defaultMessage: '!!!Your services', | ||
17 | }, | ||
18 | noServicesAdded: { | ||
19 | id: 'settings.services.noServicesAdded', | ||
20 | defaultMessage: '!!!You haven\'t added any services yet.', | ||
21 | }, | ||
22 | discoverServices: { | ||
23 | id: 'settings.services.discoverServices', | ||
24 | defaultMessage: '!!!Discover services', | ||
25 | }, | ||
26 | servicesRequestFailed: { | ||
27 | id: 'settings.services.servicesRequestFailed', | ||
28 | defaultMessage: '!!!Could not load your services', | ||
29 | }, | ||
30 | tryReloadServices: { | ||
31 | id: 'settings.account.tryReloadServices', | ||
32 | defaultMessage: '!!!Try again', | ||
33 | }, | ||
34 | updatedInfo: { | ||
35 | id: 'settings.services.updatedInfo', | ||
36 | defaultMessage: '!!!Your changes have been saved', | ||
37 | }, | ||
38 | deletedInfo: { | ||
39 | id: 'settings.services.deletedInfo', | ||
40 | defaultMessage: '!!!Service has been deleted', | ||
41 | }, | ||
42 | }); | ||
43 | |||
44 | @observer | ||
45 | export default class ServicesDashboard extends Component { | ||
46 | static propTypes = { | ||
47 | services: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
48 | isLoading: PropTypes.bool.isRequired, | ||
49 | toggleService: PropTypes.func.isRequired, | ||
50 | filterServices: PropTypes.func.isRequired, | ||
51 | resetFilter: PropTypes.func.isRequired, | ||
52 | goTo: PropTypes.func.isRequired, | ||
53 | servicesRequestFailed: PropTypes.bool.isRequired, | ||
54 | retryServicesRequest: PropTypes.func.isRequired, | ||
55 | status: MobxPropTypes.arrayOrObservableArray.isRequired, | ||
56 | }; | ||
57 | static contextTypes = { | ||
58 | intl: intlShape, | ||
59 | }; | ||
60 | |||
61 | render() { | ||
62 | const { | ||
63 | services, | ||
64 | isLoading, | ||
65 | toggleService, | ||
66 | filterServices, | ||
67 | resetFilter, | ||
68 | goTo, | ||
69 | servicesRequestFailed, | ||
70 | retryServicesRequest, | ||
71 | status, | ||
72 | } = this.props; | ||
73 | const { intl } = this.context; | ||
74 | |||
75 | return ( | ||
76 | <div className="settings__main"> | ||
77 | <div className="settings__header"> | ||
78 | <SearchInput | ||
79 | className="settings__search-header" | ||
80 | defaultValue={intl.formatMessage(messages.headline)} | ||
81 | onChange={needle => filterServices({ needle })} | ||
82 | onReset={() => resetFilter()} | ||
83 | /> | ||
84 | </div> | ||
85 | <div className="settings__body"> | ||
86 | {!isLoading && servicesRequestFailed && ( | ||
87 | <div> | ||
88 | <Infobox | ||
89 | icon="alert" | ||
90 | type="danger" | ||
91 | ctaLabel={intl.formatMessage(messages.tryReloadServices)} | ||
92 | ctaLoading={isLoading} | ||
93 | ctaOnClick={retryServicesRequest} | ||
94 | > | ||
95 | {intl.formatMessage(messages.servicesRequestFailed)} | ||
96 | </Infobox> | ||
97 | </div> | ||
98 | )} | ||
99 | |||
100 | {status.length > 0 && status.includes('updated') && ( | ||
101 | <Appear> | ||
102 | <Infobox | ||
103 | type="success" | ||
104 | icon="checkbox-marked-circle-outline" | ||
105 | dismissable | ||
106 | > | ||
107 | {intl.formatMessage(messages.updatedInfo)} | ||
108 | </Infobox> | ||
109 | </Appear> | ||
110 | )} | ||
111 | |||
112 | {status.length > 0 && status.includes('service-deleted') && ( | ||
113 | <Appear> | ||
114 | <Infobox | ||
115 | type="success" | ||
116 | icon="checkbox-marked-circle-outline" | ||
117 | dismissable | ||
118 | > | ||
119 | {intl.formatMessage(messages.deletedInfo)} | ||
120 | </Infobox> | ||
121 | </Appear> | ||
122 | )} | ||
123 | |||
124 | {!isLoading && services.length === 0 && ( | ||
125 | <div className="align-middle settings__empty-state"> | ||
126 | <p className="settings__empty-text"> | ||
127 | <span className="emoji"> | ||
128 | <img src="./assets/images/emoji/sad.png" alt="" /> | ||
129 | </span> | ||
130 | {intl.formatMessage(messages.noServicesAdded)} | ||
131 | </p> | ||
132 | <Link to="/settings/recipes" className="button">{intl.formatMessage(messages.discoverServices)}</Link> | ||
133 | </div> | ||
134 | )} | ||
135 | {isLoading ? ( | ||
136 | <Loader /> | ||
137 | ) : ( | ||
138 | <table className="service-table"> | ||
139 | <tbody> | ||
140 | {services.map(service => ( | ||
141 | <ServiceItem | ||
142 | key={service.id} | ||
143 | service={service} | ||
144 | toggleAction={() => toggleService({ serviceId: service.id })} | ||
145 | goToServiceForm={() => goTo(`/settings/services/edit/${service.id}`)} | ||
146 | /> | ||
147 | ))} | ||
148 | </tbody> | ||
149 | </table> | ||
150 | )} | ||
151 | </div> | ||
152 | </div> | ||
153 | ); | ||
154 | } | ||
155 | } | ||
diff --git a/src/components/settings/settings/EditSettingsForm.js b/src/components/settings/settings/EditSettingsForm.js new file mode 100644 index 000000000..02736dbb9 --- /dev/null +++ b/src/components/settings/settings/EditSettingsForm.js | |||
@@ -0,0 +1,148 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { observer } from 'mobx-react'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | |||
7 | import Form from '../../../lib/Form'; | ||
8 | import Button from '../../ui/Button'; | ||
9 | import Toggle from '../../ui/Toggle'; | ||
10 | import Select from '../../ui/Select'; | ||
11 | |||
12 | const messages = defineMessages({ | ||
13 | headline: { | ||
14 | id: 'settings.app.headline', | ||
15 | defaultMessage: '!!!Settings', | ||
16 | }, | ||
17 | headlineGeneral: { | ||
18 | id: 'settings.app.headlineGeneral', | ||
19 | defaultMessage: '!!!General', | ||
20 | }, | ||
21 | headlineLanguage: { | ||
22 | id: 'settings.app.headlineLanguage', | ||
23 | defaultMessage: '!!!Language', | ||
24 | }, | ||
25 | headlineUpdates: { | ||
26 | id: 'settings.app.headlineUpdates', | ||
27 | defaultMessage: '!!!Updates', | ||
28 | }, | ||
29 | buttonSearchForUpdate: { | ||
30 | id: 'settings.app.buttonSearchForUpdate', | ||
31 | defaultMessage: '!!!Check for updates', | ||
32 | }, | ||
33 | buttonInstallUpdate: { | ||
34 | id: 'settings.app.buttonInstallUpdate', | ||
35 | defaultMessage: '!!!Restart & install update', | ||
36 | }, | ||
37 | updateStatusSearching: { | ||
38 | id: 'settings.app.updateStatusSearching', | ||
39 | defaultMessage: '!!!Is searching for update', | ||
40 | }, | ||
41 | updateStatusAvailable: { | ||
42 | id: 'settings.app.updateStatusAvailable', | ||
43 | defaultMessage: '!!!Update available, downloading...', | ||
44 | }, | ||
45 | updateStatusUpToDate: { | ||
46 | id: 'settings.app.updateStatusUpToDate', | ||
47 | defaultMessage: '!!!You are using the latest version of Franz', | ||
48 | }, | ||
49 | currentVersion: { | ||
50 | id: 'settings.app.currentVersion', | ||
51 | defaultMessage: '!!!Current version:', | ||
52 | }, | ||
53 | }); | ||
54 | |||
55 | @observer | ||
56 | export default class EditSettingsForm extends Component { | ||
57 | static propTypes = { | ||
58 | checkForUpdates: PropTypes.func.isRequired, | ||
59 | installUpdate: PropTypes.func.isRequired, | ||
60 | form: PropTypes.instanceOf(Form).isRequired, | ||
61 | onSubmit: PropTypes.func.isRequired, | ||
62 | isCheckingForUpdates: PropTypes.bool.isRequired, | ||
63 | isUpdateAvailable: PropTypes.bool.isRequired, | ||
64 | noUpdateAvailable: PropTypes.bool.isRequired, | ||
65 | updateIsReadyToInstall: PropTypes.bool.isRequired, | ||
66 | }; | ||
67 | |||
68 | static contextTypes = { | ||
69 | intl: intlShape, | ||
70 | }; | ||
71 | |||
72 | submit(e) { | ||
73 | e.preventDefault(); | ||
74 | this.props.form.submit({ | ||
75 | onSuccess: (form) => { | ||
76 | const values = form.values(); | ||
77 | this.props.onSubmit(values); | ||
78 | }, | ||
79 | onError: () => {}, | ||
80 | }); | ||
81 | } | ||
82 | |||
83 | render() { | ||
84 | const { | ||
85 | checkForUpdates, | ||
86 | installUpdate, | ||
87 | form, | ||
88 | isCheckingForUpdates, | ||
89 | isUpdateAvailable, | ||
90 | noUpdateAvailable, | ||
91 | updateIsReadyToInstall, | ||
92 | } = this.props; | ||
93 | const { intl } = this.context; | ||
94 | |||
95 | let updateButtonLabelMessage = messages.buttonSearchForUpdate; | ||
96 | if (isCheckingForUpdates) { | ||
97 | updateButtonLabelMessage = messages.updateStatusSearching; | ||
98 | } else if (isUpdateAvailable) { | ||
99 | updateButtonLabelMessage = messages.updateStatusAvailable; | ||
100 | } else { | ||
101 | updateButtonLabelMessage = messages.buttonSearchForUpdate; | ||
102 | } | ||
103 | |||
104 | return ( | ||
105 | <div className="settings__main"> | ||
106 | <div className="settings__header"> | ||
107 | <h1>{intl.formatMessage(messages.headline)}</h1> | ||
108 | </div> | ||
109 | <div className="settings__body"> | ||
110 | <form | ||
111 | onSubmit={e => this.submit(e)} | ||
112 | onChange={e => this.submit(e)} | ||
113 | id="form" | ||
114 | > | ||
115 | <h2>{intl.formatMessage(messages.headlineGeneral)}</h2> | ||
116 | <Toggle field={form.$('autoLaunchOnStart')} /> | ||
117 | <Toggle field={form.$('runInBackground')} /> | ||
118 | {process.platform === 'win32' && ( | ||
119 | <Toggle field={form.$('minimizeToSystemTray')} /> | ||
120 | )} | ||
121 | <h2>{intl.formatMessage(messages.headlineLanguage)}</h2> | ||
122 | <Select field={form.$('locale')} showLabel={false} /> | ||
123 | <h2>{intl.formatMessage(messages.headlineUpdates)}</h2> | ||
124 | {updateIsReadyToInstall ? ( | ||
125 | <Button | ||
126 | label={intl.formatMessage(messages.buttonInstallUpdate)} | ||
127 | onClick={installUpdate} | ||
128 | /> | ||
129 | ) : ( | ||
130 | <Button | ||
131 | label={intl.formatMessage(updateButtonLabelMessage)} | ||
132 | onClick={checkForUpdates} | ||
133 | disabled={isCheckingForUpdates || isUpdateAvailable} | ||
134 | loaded={!isCheckingForUpdates || !isUpdateAvailable} | ||
135 | /> | ||
136 | )} | ||
137 | {noUpdateAvailable && ( | ||
138 | <p>{intl.formatMessage(messages.updateStatusUpToDate)}</p> | ||
139 | )} | ||
140 | <br /> | ||
141 | <Toggle field={form.$('beta')} /> | ||
142 | {intl.formatMessage(messages.currentVersion)} {remote.app.getVersion()} | ||
143 | </form> | ||
144 | </div> | ||
145 | </div> | ||
146 | ); | ||
147 | } | ||
148 | } | ||
diff --git a/src/components/settings/user/EditUserForm.js b/src/components/settings/user/EditUserForm.js new file mode 100644 index 000000000..f36887fc2 --- /dev/null +++ b/src/components/settings/user/EditUserForm.js | |||
@@ -0,0 +1,145 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import { Link } from 'react-router'; | ||
6 | |||
7 | // import { Link } from 'react-router'; | ||
8 | |||
9 | import Form from '../../../lib/Form'; | ||
10 | import Input from '../../ui/Input'; | ||
11 | import Button from '../../ui/Button'; | ||
12 | import Radio from '../../ui/Radio'; | ||
13 | import Infobox from '../../ui/Infobox'; | ||
14 | |||
15 | const messages = defineMessages({ | ||
16 | headline: { | ||
17 | id: 'settings.account.headline', | ||
18 | defaultMessage: '!!!Account', | ||
19 | }, | ||
20 | headlineProfile: { | ||
21 | id: 'settings.account.headlineProfile', | ||
22 | defaultMessage: '!!!Update Profile', | ||
23 | }, | ||
24 | headlineAccount: { | ||
25 | id: 'settings.account.headlineAccount', | ||
26 | defaultMessage: '!!!Account Information', | ||
27 | }, | ||
28 | headlinePassword: { | ||
29 | id: 'settings.account.headlinePassword', | ||
30 | defaultMessage: '!!!Change Password', | ||
31 | }, | ||
32 | successInfo: { | ||
33 | id: 'settings.account.successInfo', | ||
34 | defaultMessage: '!!!Your changes have been saved', | ||
35 | }, | ||
36 | buttonSave: { | ||
37 | id: 'settings.account.buttonSave', | ||
38 | defaultMessage: '!!!Update profile', | ||
39 | }, | ||
40 | }); | ||
41 | |||
42 | @observer | ||
43 | export default class EditServiceForm extends Component { | ||
44 | static propTypes = { | ||
45 | status: MobxPropTypes.observableArray.isRequired, | ||
46 | form: PropTypes.instanceOf(Form).isRequired, | ||
47 | onSubmit: PropTypes.func.isRequired, | ||
48 | isSaving: PropTypes.bool.isRequired, | ||
49 | }; | ||
50 | |||
51 | static defaultProps = { | ||
52 | service: {}, | ||
53 | }; | ||
54 | |||
55 | static contextTypes = { | ||
56 | intl: intlShape, | ||
57 | }; | ||
58 | |||
59 | submit(e) { | ||
60 | e.preventDefault(); | ||
61 | this.props.form.submit({ | ||
62 | onSuccess: (form) => { | ||
63 | const values = form.values(); | ||
64 | this.props.onSubmit(values); | ||
65 | }, | ||
66 | onError: () => {}, | ||
67 | }); | ||
68 | } | ||
69 | |||
70 | render() { | ||
71 | const { | ||
72 | // user, | ||
73 | status, | ||
74 | form, | ||
75 | isSaving, | ||
76 | } = this.props; | ||
77 | const { intl } = this.context; | ||
78 | |||
79 | return ( | ||
80 | <div className="settings__main"> | ||
81 | <div className="settings__header"> | ||
82 | <span className="settings__header-item"> | ||
83 | <Link to="/settings/user"> | ||
84 | {intl.formatMessage(messages.headline)} | ||
85 | </Link> | ||
86 | </span> | ||
87 | <span className="separator" /> | ||
88 | <span className="settings__header-item"> | ||
89 | {intl.formatMessage(messages.headlineProfile)} | ||
90 | </span> | ||
91 | </div> | ||
92 | <div className="settings__body"> | ||
93 | <form onSubmit={e => this.submit(e)} id="form"> | ||
94 | {status.length > 0 && status.includes('data-updated') && ( | ||
95 | <Infobox | ||
96 | type="success" | ||
97 | icon="checkbox-marked-circle-outline" | ||
98 | > | ||
99 | {intl.formatMessage(messages.successInfo)} | ||
100 | </Infobox> | ||
101 | )} | ||
102 | <h2>{intl.formatMessage(messages.headlineAccount)}</h2> | ||
103 | <div className="grid__row"> | ||
104 | <Input field={form.$('firstname')} focus /> | ||
105 | <Input field={form.$('lastname')} /> | ||
106 | </div> | ||
107 | <Input field={form.$('email')} /> | ||
108 | <Radio field={form.$('accountType')} /> | ||
109 | {form.$('accountType').value === 'company' && ( | ||
110 | <Input field={form.$('organization')} /> | ||
111 | )} | ||
112 | <h2>{intl.formatMessage(messages.headlinePassword)}</h2> | ||
113 | <Input | ||
114 | field={form.$('oldPassword')} | ||
115 | showPasswordToggle | ||
116 | /> | ||
117 | <Input | ||
118 | field={form.$('newPassword')} | ||
119 | showPasswordToggle | ||
120 | scorePassword | ||
121 | /> | ||
122 | </form> | ||
123 | </div> | ||
124 | <div className="settings__controls"> | ||
125 | {/* Save Button */} | ||
126 | {isSaving ? ( | ||
127 | <Button | ||
128 | type="submit" | ||
129 | label={intl.formatMessage(messages.buttonSave)} | ||
130 | loaded={!isSaving} | ||
131 | buttonType="secondary" | ||
132 | disabled | ||
133 | /> | ||
134 | ) : ( | ||
135 | <Button | ||
136 | type="submit" | ||
137 | label={intl.formatMessage(messages.buttonSave)} | ||
138 | htmlForm="form" | ||
139 | /> | ||
140 | )} | ||
141 | </div> | ||
142 | </div> | ||
143 | ); | ||
144 | } | ||
145 | } | ||
diff --git a/src/components/ui/AppLoader.js b/src/components/ui/AppLoader.js new file mode 100644 index 000000000..64a212969 --- /dev/null +++ b/src/components/ui/AppLoader.js | |||
@@ -0,0 +1,15 @@ | |||
1 | import React from 'react'; | ||
2 | |||
3 | import Appear from '../../components/ui/effects/Appear'; | ||
4 | import Loader from '../../components/ui/Loader'; | ||
5 | |||
6 | export default function () { | ||
7 | return ( | ||
8 | <div className="app-loader"> | ||
9 | <Appear> | ||
10 | <h1 className="app-loader__title">Franz</h1> | ||
11 | <Loader /> | ||
12 | </Appear> | ||
13 | </div> | ||
14 | ); | ||
15 | } | ||
diff --git a/src/components/ui/Button.js b/src/components/ui/Button.js new file mode 100644 index 000000000..07e94192f --- /dev/null +++ b/src/components/ui/Button.js | |||
@@ -0,0 +1,78 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import Loader from 'react-loader'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | @observer | ||
8 | export default class Button extends Component { | ||
9 | static propTypes = { | ||
10 | className: PropTypes.string, | ||
11 | label: PropTypes.string.isRequired, | ||
12 | disabled: PropTypes.bool, | ||
13 | onClick: PropTypes.func, | ||
14 | type: PropTypes.string, | ||
15 | buttonType: PropTypes.string, | ||
16 | loaded: PropTypes.bool, | ||
17 | htmlForm: PropTypes.string, | ||
18 | }; | ||
19 | |||
20 | static defaultProps = { | ||
21 | className: null, | ||
22 | disabled: false, | ||
23 | onClick: () => {}, | ||
24 | type: 'button', | ||
25 | buttonType: '', | ||
26 | loaded: true, | ||
27 | htmlForm: '', | ||
28 | }; | ||
29 | |||
30 | element = null; | ||
31 | |||
32 | render() { | ||
33 | const { | ||
34 | label, | ||
35 | className, | ||
36 | disabled, | ||
37 | onClick, | ||
38 | type, | ||
39 | buttonType, | ||
40 | loaded, | ||
41 | htmlForm, | ||
42 | } = this.props; | ||
43 | |||
44 | const buttonProps = { | ||
45 | className: classnames({ | ||
46 | 'franz-form__button': true, | ||
47 | [`franz-form__button--${buttonType}`]: buttonType, | ||
48 | [`${className}`]: className, | ||
49 | }), | ||
50 | type, | ||
51 | }; | ||
52 | |||
53 | if (disabled) { | ||
54 | buttonProps.disabled = true; | ||
55 | } | ||
56 | |||
57 | if (onClick) { | ||
58 | buttonProps.onClick = onClick; | ||
59 | } | ||
60 | |||
61 | if (htmlForm) { | ||
62 | buttonProps.form = htmlForm; | ||
63 | } | ||
64 | |||
65 | return ( | ||
66 | <button {...buttonProps}> | ||
67 | <Loader | ||
68 | loaded={loaded} | ||
69 | lines={10} | ||
70 | scale={0.4} | ||
71 | color={buttonType === '' ? '#FFF' : '#373a3c'} | ||
72 | component="span" | ||
73 | /> | ||
74 | {label} | ||
75 | </button> | ||
76 | ); | ||
77 | } | ||
78 | } | ||
diff --git a/src/components/ui/InfoBar.js b/src/components/ui/InfoBar.js new file mode 100644 index 000000000..aea2bd888 --- /dev/null +++ b/src/components/ui/InfoBar.js | |||
@@ -0,0 +1,88 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import Loader from 'react-loader'; | ||
6 | |||
7 | // import { oneOrManyChildElements } from '../../prop-types'; | ||
8 | import Appear from '../ui/effects/Appear'; | ||
9 | |||
10 | @observer | ||
11 | export default class InfoBar extends Component { | ||
12 | static propTypes = { | ||
13 | // eslint-disable-next-line | ||
14 | children: PropTypes.any.isRequired, | ||
15 | onClick: PropTypes.func, | ||
16 | type: PropTypes.string, | ||
17 | className: PropTypes.string, | ||
18 | ctaLabel: PropTypes.string, | ||
19 | ctaLoading: PropTypes.bool, | ||
20 | position: PropTypes.string, | ||
21 | sticky: PropTypes.bool, | ||
22 | onHide: PropTypes.func, | ||
23 | }; | ||
24 | |||
25 | static defaultProps = { | ||
26 | onClick: () => null, | ||
27 | type: 'primary', | ||
28 | className: '', | ||
29 | ctaLabel: '', | ||
30 | ctaLoading: false, | ||
31 | position: 'bottom', | ||
32 | sticky: false, | ||
33 | onHide: () => null, | ||
34 | }; | ||
35 | |||
36 | render() { | ||
37 | const { | ||
38 | children, | ||
39 | type, | ||
40 | className, | ||
41 | ctaLabel, | ||
42 | ctaLoading, | ||
43 | onClick, | ||
44 | position, | ||
45 | sticky, | ||
46 | onHide, | ||
47 | } = this.props; | ||
48 | |||
49 | let transitionName = 'slideUp'; | ||
50 | if (position === 'top') { | ||
51 | transitionName = 'slideDown'; | ||
52 | } | ||
53 | |||
54 | return ( | ||
55 | <Appear | ||
56 | transitionName={transitionName} | ||
57 | className={classnames({ | ||
58 | 'info-bar': true, | ||
59 | [`info-bar--${type}`]: true, | ||
60 | [`info-bar--${position}`]: true, | ||
61 | [`${className}`]: true, | ||
62 | })} | ||
63 | > | ||
64 | <div onClick={onClick} className="info-bar__content"> | ||
65 | {children} | ||
66 | {ctaLabel && ( | ||
67 | <button className="info-bar__cta"> | ||
68 | <Loader | ||
69 | loaded={!ctaLoading} | ||
70 | lines={10} | ||
71 | scale={0.3} | ||
72 | color="#FFF" | ||
73 | component="span" | ||
74 | /> | ||
75 | {ctaLabel} | ||
76 | </button> | ||
77 | )} | ||
78 | </div> | ||
79 | {!sticky && ( | ||
80 | <button | ||
81 | className="info-bar__close mdi mdi-close" | ||
82 | onClick={onHide} | ||
83 | /> | ||
84 | )} | ||
85 | </Appear> | ||
86 | ); | ||
87 | } | ||
88 | } | ||
diff --git a/src/components/ui/Infobox.js b/src/components/ui/Infobox.js new file mode 100644 index 000000000..2d063c7ef --- /dev/null +++ b/src/components/ui/Infobox.js | |||
@@ -0,0 +1,87 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import Loader from 'react-loader'; | ||
6 | |||
7 | @observer | ||
8 | export default class Infobox extends Component { | ||
9 | static propTypes = { | ||
10 | children: PropTypes.any.isRequired, // eslint-disable-line | ||
11 | icon: PropTypes.string, | ||
12 | type: PropTypes.string, | ||
13 | ctaOnClick: PropTypes.func, | ||
14 | ctaLabel: PropTypes.string, | ||
15 | ctaLoading: PropTypes.bool, | ||
16 | dismissable: PropTypes.bool, | ||
17 | }; | ||
18 | |||
19 | static defaultProps = { | ||
20 | icon: '', | ||
21 | type: 'primary', | ||
22 | dismissable: false, | ||
23 | ctaOnClick: () => null, | ||
24 | ctaLabel: '', | ||
25 | ctaLoading: false, | ||
26 | }; | ||
27 | |||
28 | state = { | ||
29 | dismissed: false, | ||
30 | }; | ||
31 | |||
32 | render() { | ||
33 | const { | ||
34 | children, | ||
35 | icon, | ||
36 | type, | ||
37 | ctaLabel, | ||
38 | ctaLoading, | ||
39 | ctaOnClick, | ||
40 | dismissable, | ||
41 | } = this.props; | ||
42 | |||
43 | if (this.state.dismissed) { | ||
44 | return null; | ||
45 | } | ||
46 | |||
47 | return ( | ||
48 | <div | ||
49 | className={classnames({ | ||
50 | infobox: true, | ||
51 | [`infobox--${type}`]: type, | ||
52 | 'infobox--default': !type, | ||
53 | })} | ||
54 | > | ||
55 | {icon && ( | ||
56 | <i className={`mdi mdi-${icon}`} /> | ||
57 | )} | ||
58 | <div className="infobox__content"> | ||
59 | {children} | ||
60 | </div> | ||
61 | {ctaLabel && ( | ||
62 | <button | ||
63 | className="infobox__cta" | ||
64 | onClick={ctaOnClick} | ||
65 | > | ||
66 | <Loader | ||
67 | loaded={!ctaLoading} | ||
68 | lines={10} | ||
69 | scale={0.3} | ||
70 | color="#FFF" | ||
71 | component="span" | ||
72 | /> | ||
73 | {ctaLabel} | ||
74 | </button> | ||
75 | )} | ||
76 | {dismissable && ( | ||
77 | <button | ||
78 | onClick={() => this.setState({ | ||
79 | dismissed: true, | ||
80 | })} | ||
81 | className="infobox__delete mdi mdi-close" | ||
82 | /> | ||
83 | )} | ||
84 | </div> | ||
85 | ); | ||
86 | } | ||
87 | } | ||
diff --git a/src/components/ui/Input.js b/src/components/ui/Input.js new file mode 100644 index 000000000..0bb9f23bf --- /dev/null +++ b/src/components/ui/Input.js | |||
@@ -0,0 +1,148 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Field } from 'mobx-react-form'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | import { scorePassword as scorePasswordFunc } from '../../helpers/password-helpers'; | ||
8 | |||
9 | @observer | ||
10 | export default class Input extends Component { | ||
11 | static propTypes = { | ||
12 | field: PropTypes.instanceOf(Field).isRequired, | ||
13 | className: PropTypes.string, | ||
14 | focus: PropTypes.bool, | ||
15 | showPasswordToggle: PropTypes.bool, | ||
16 | showLabel: PropTypes.bool, | ||
17 | scorePassword: PropTypes.bool, | ||
18 | prefix: PropTypes.string, | ||
19 | suffix: PropTypes.string, | ||
20 | }; | ||
21 | |||
22 | static defaultProps = { | ||
23 | className: null, | ||
24 | focus: false, | ||
25 | showPasswordToggle: false, | ||
26 | showLabel: true, | ||
27 | scorePassword: false, | ||
28 | prefix: '', | ||
29 | suffix: '', | ||
30 | }; | ||
31 | |||
32 | state = { | ||
33 | showPassword: false, | ||
34 | passwordScore: 0, | ||
35 | } | ||
36 | |||
37 | componentDidMount() { | ||
38 | if (this.props.focus) { | ||
39 | this.focus(); | ||
40 | } | ||
41 | } | ||
42 | |||
43 | onChange(e) { | ||
44 | const { field, scorePassword } = this.props; | ||
45 | |||
46 | field.onChange(e); | ||
47 | |||
48 | if (scorePassword) { | ||
49 | this.setState({ passwordScore: scorePasswordFunc(field.value) }); | ||
50 | } | ||
51 | } | ||
52 | |||
53 | focus() { | ||
54 | this.inputElement.focus(); | ||
55 | } | ||
56 | |||
57 | inputElement = null; | ||
58 | |||
59 | render() { | ||
60 | const { | ||
61 | field, | ||
62 | className, | ||
63 | showPasswordToggle, | ||
64 | showLabel, | ||
65 | scorePassword, | ||
66 | prefix, | ||
67 | suffix, | ||
68 | } = this.props; | ||
69 | |||
70 | const { passwordScore } = this.state; | ||
71 | |||
72 | let type = field.type; | ||
73 | if (type === 'password' && this.state.showPassword) { | ||
74 | type = 'text'; | ||
75 | } | ||
76 | |||
77 | return ( | ||
78 | <div | ||
79 | className={classnames({ | ||
80 | 'franz-form__field': true, | ||
81 | 'has-error': field.error, | ||
82 | [`${className}`]: className, | ||
83 | })} | ||
84 | > | ||
85 | <div className="franz-form__input-wrapper"> | ||
86 | {prefix && ( | ||
87 | <span className="franz-form__input-prefix">{prefix}</span> | ||
88 | )} | ||
89 | <input | ||
90 | id={field.id} | ||
91 | type={type} | ||
92 | className="franz-form__input" | ||
93 | name={field.name} | ||
94 | value={field.value} | ||
95 | placeholder={field.placeholder} | ||
96 | onChange={e => this.onChange(e)} | ||
97 | onBlur={field.onBlur} | ||
98 | onFocus={field.onFocus} | ||
99 | ref={(element) => { this.inputElement = element; }} | ||
100 | /> | ||
101 | {suffix && ( | ||
102 | <span className="franz-form__input-suffix">{suffix}</span> | ||
103 | )} | ||
104 | {showPasswordToggle && ( | ||
105 | <button | ||
106 | type="button" | ||
107 | className={classnames({ | ||
108 | 'franz-form__input-modifier': true, | ||
109 | mdi: true, | ||
110 | 'mdi-eye': !this.state.showPassword, | ||
111 | 'mdi-eye-off': this.state.showPassword, | ||
112 | })} | ||
113 | onClick={() => this.setState({ showPassword: !this.state.showPassword })} | ||
114 | tabIndex="-1" | ||
115 | /> | ||
116 | )} | ||
117 | {scorePassword && ( | ||
118 | <div className="franz-form__password-score"> | ||
119 | {/* <progress value={this.state.passwordScore} max="100" /> */} | ||
120 | <meter | ||
121 | value={passwordScore < 5 ? 5 : passwordScore} | ||
122 | low="30" | ||
123 | high="75" | ||
124 | optimum="100" | ||
125 | max="100" | ||
126 | /> | ||
127 | </div> | ||
128 | )} | ||
129 | </div> | ||
130 | {field.label && showLabel && ( | ||
131 | <label | ||
132 | className="franz-form__label" | ||
133 | htmlFor={field.name} | ||
134 | > | ||
135 | {field.label} | ||
136 | </label> | ||
137 | )} | ||
138 | {field.error && ( | ||
139 | <div | ||
140 | className="franz-form__error" | ||
141 | > | ||
142 | {field.error} | ||
143 | </div> | ||
144 | )} | ||
145 | </div> | ||
146 | ); | ||
147 | } | ||
148 | } | ||
diff --git a/src/components/ui/Link.js b/src/components/ui/Link.js new file mode 100644 index 000000000..f5da921fa --- /dev/null +++ b/src/components/ui/Link.js | |||
@@ -0,0 +1,78 @@ | |||
1 | import { shell } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { inject, observer } from 'mobx-react'; | ||
5 | import { RouterStore } from 'mobx-react-router'; | ||
6 | import classnames from 'classnames'; | ||
7 | |||
8 | import { oneOrManyChildElements } from '../../prop-types'; | ||
9 | import { matchRoute } from '../../helpers/routing-helpers'; | ||
10 | |||
11 | // TODO: create container component for this component | ||
12 | |||
13 | @inject('stores') @observer | ||
14 | export default class Link extends Component { | ||
15 | onClick(e) { | ||
16 | if (this.props.target === '_blank') { | ||
17 | e.preventDefault(); | ||
18 | shell.openExternal(this.props.to); | ||
19 | } | ||
20 | } | ||
21 | |||
22 | render() { | ||
23 | const { | ||
24 | children, | ||
25 | stores, | ||
26 | to, | ||
27 | className, | ||
28 | activeClassName, | ||
29 | strictFilter, | ||
30 | } = this.props; | ||
31 | const { router } = stores; | ||
32 | |||
33 | let filter = `${to}(*action)`; | ||
34 | if (strictFilter) { | ||
35 | filter = `${to}`; | ||
36 | } | ||
37 | |||
38 | const match = matchRoute(filter, router.location.pathname); | ||
39 | |||
40 | const linkClasses = classnames({ | ||
41 | [`${className}`]: true, | ||
42 | [`${activeClassName}`]: match, | ||
43 | }); | ||
44 | |||
45 | return ( | ||
46 | <a | ||
47 | href={router.history.createHref(to)} | ||
48 | className={linkClasses} | ||
49 | onClick={e => this.onClick(e)} | ||
50 | > | ||
51 | {children} | ||
52 | </a> | ||
53 | ); | ||
54 | } | ||
55 | } | ||
56 | |||
57 | Link.wrappedComponent.propTypes = { | ||
58 | stores: PropTypes.shape({ | ||
59 | router: PropTypes.instanceOf(RouterStore).isRequired, | ||
60 | }).isRequired, | ||
61 | children: PropTypes.oneOfType([ | ||
62 | oneOrManyChildElements, | ||
63 | PropTypes.string, | ||
64 | ]).isRequired, | ||
65 | to: PropTypes.string.isRequired, | ||
66 | className: PropTypes.string, | ||
67 | activeClassName: PropTypes.string, | ||
68 | strictFilter: PropTypes.bool, | ||
69 | target: PropTypes.string, | ||
70 | }; | ||
71 | |||
72 | Link.wrappedComponent.defaultProps = { | ||
73 | className: '', | ||
74 | activeClassName: '', | ||
75 | strictFilter: false, | ||
76 | target: '', | ||
77 | openInBrowser: false, | ||
78 | }; | ||
diff --git a/src/components/ui/Loader.js b/src/components/ui/Loader.js new file mode 100644 index 000000000..e4fbd96a2 --- /dev/null +++ b/src/components/ui/Loader.js | |||
@@ -0,0 +1,41 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import Loader from 'react-loader'; | ||
4 | |||
5 | import { oneOrManyChildElements } from '../../prop-types'; | ||
6 | |||
7 | export default class LoaderComponent extends Component { | ||
8 | static propTypes = { | ||
9 | children: oneOrManyChildElements, | ||
10 | loaded: PropTypes.bool, | ||
11 | className: PropTypes.string, | ||
12 | }; | ||
13 | |||
14 | static defaultProps = { | ||
15 | children: null, | ||
16 | loaded: false, | ||
17 | className: '', | ||
18 | }; | ||
19 | |||
20 | render() { | ||
21 | const { | ||
22 | children, | ||
23 | loaded, | ||
24 | className, | ||
25 | } = this.props; | ||
26 | |||
27 | return ( | ||
28 | <Loader | ||
29 | loaded={loaded} | ||
30 | // lines={10} | ||
31 | width={4} | ||
32 | scale={0.6} | ||
33 | color="#373a3c" | ||
34 | component="span" | ||
35 | className={className} | ||
36 | > | ||
37 | {children} | ||
38 | </Loader> | ||
39 | ); | ||
40 | } | ||
41 | } | ||
diff --git a/src/components/ui/Radio.js b/src/components/ui/Radio.js new file mode 100644 index 000000000..b54cfc820 --- /dev/null +++ b/src/components/ui/Radio.js | |||
@@ -0,0 +1,89 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Field } from 'mobx-react-form'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | @observer | ||
8 | export default class Radio extends Component { | ||
9 | static propTypes = { | ||
10 | field: PropTypes.instanceOf(Field).isRequired, | ||
11 | className: PropTypes.string, | ||
12 | focus: PropTypes.bool, | ||
13 | showLabel: PropTypes.bool, | ||
14 | }; | ||
15 | |||
16 | static defaultProps = { | ||
17 | className: null, | ||
18 | focus: false, | ||
19 | showLabel: true, | ||
20 | }; | ||
21 | |||
22 | componentDidMount() { | ||
23 | if (this.props.focus) { | ||
24 | this.focus(); | ||
25 | } | ||
26 | } | ||
27 | |||
28 | focus() { | ||
29 | this.inputElement.focus(); | ||
30 | } | ||
31 | |||
32 | inputElement = null; | ||
33 | |||
34 | render() { | ||
35 | const { | ||
36 | field, | ||
37 | className, | ||
38 | showLabel, | ||
39 | } = this.props; | ||
40 | |||
41 | return ( | ||
42 | <div | ||
43 | className={classnames({ | ||
44 | 'franz-form__field': true, | ||
45 | 'has-error': field.error, | ||
46 | [`${className}`]: className, | ||
47 | })} | ||
48 | > | ||
49 | {field.label && showLabel && ( | ||
50 | <label | ||
51 | className="franz-form__label" | ||
52 | htmlFor={field.name} | ||
53 | > | ||
54 | {field.label} | ||
55 | </label> | ||
56 | )} | ||
57 | <div className="franz-form__radio-wrapper"> | ||
58 | {field.options.map(type => ( | ||
59 | <label | ||
60 | key={type.value} | ||
61 | htmlFor={`${field.id}-${type.value}`} | ||
62 | className={classnames({ | ||
63 | 'franz-form__radio': true, | ||
64 | 'is-selected': field.value === type.value, | ||
65 | })} | ||
66 | > | ||
67 | <input | ||
68 | id={`${field.id}-${type.value}`} | ||
69 | type="radio" | ||
70 | name="type" | ||
71 | value={type.value} | ||
72 | onChange={field.onChange} | ||
73 | checked={field.value === type.value} | ||
74 | /> | ||
75 | {type.label} | ||
76 | </label> | ||
77 | ))} | ||
78 | </div> | ||
79 | {field.error && ( | ||
80 | <div | ||
81 | className="franz-form__error" | ||
82 | > | ||
83 | {field.error} | ||
84 | </div> | ||
85 | )} | ||
86 | </div> | ||
87 | ); | ||
88 | } | ||
89 | } | ||
diff --git a/src/components/ui/SearchInput.js b/src/components/ui/SearchInput.js new file mode 100644 index 000000000..bca412cef --- /dev/null +++ b/src/components/ui/SearchInput.js | |||
@@ -0,0 +1,124 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import uuidv1 from 'uuid/v1'; | ||
6 | import { debounce } from 'lodash'; | ||
7 | |||
8 | @observer | ||
9 | export default class SearchInput extends Component { | ||
10 | static propTypes = { | ||
11 | value: PropTypes.string, | ||
12 | defaultValue: PropTypes.string, | ||
13 | className: PropTypes.string, | ||
14 | onChange: PropTypes.func, | ||
15 | onReset: PropTypes.func, | ||
16 | name: PropTypes.string, | ||
17 | throttle: PropTypes.bool, | ||
18 | throttleDelay: PropTypes.number, | ||
19 | }; | ||
20 | |||
21 | static defaultProps = { | ||
22 | value: '', | ||
23 | defaultValue: '', | ||
24 | className: '', | ||
25 | name: uuidv1(), | ||
26 | throttle: false, | ||
27 | throttleDelay: 250, | ||
28 | onChange: () => null, | ||
29 | onReset: () => null, | ||
30 | } | ||
31 | |||
32 | constructor(props) { | ||
33 | super(props); | ||
34 | |||
35 | this.state = { | ||
36 | value: props.value || props.defaultValue, | ||
37 | }; | ||
38 | |||
39 | this.throttledOnChange = debounce(this.throttledOnChange, this.props.throttleDelay); | ||
40 | } | ||
41 | |||
42 | onChange(e) { | ||
43 | const { throttle, onChange } = this.props; | ||
44 | const { value } = e.target; | ||
45 | this.setState({ value }); | ||
46 | |||
47 | if (throttle) { | ||
48 | e.persist(); | ||
49 | this.throttledOnChange(value); | ||
50 | } else { | ||
51 | onChange(value); | ||
52 | } | ||
53 | } | ||
54 | |||
55 | onClick() { | ||
56 | const { defaultValue } = this.props; | ||
57 | const { value } = this.state; | ||
58 | |||
59 | if (value === defaultValue) { | ||
60 | this.setState({ value: '' }); | ||
61 | } | ||
62 | |||
63 | this.input.focus(); | ||
64 | } | ||
65 | |||
66 | onBlur() { | ||
67 | const { defaultValue } = this.props; | ||
68 | const { value } = this.state; | ||
69 | |||
70 | if (value === '') { | ||
71 | this.setState({ value: defaultValue }); | ||
72 | } | ||
73 | } | ||
74 | |||
75 | throttledOnChange(e) { | ||
76 | const { onChange } = this.props; | ||
77 | |||
78 | onChange(e); | ||
79 | } | ||
80 | |||
81 | reset() { | ||
82 | const { defaultValue, onReset } = this.props; | ||
83 | this.setState({ value: defaultValue }); | ||
84 | |||
85 | onReset(); | ||
86 | } | ||
87 | |||
88 | input = null; | ||
89 | |||
90 | render() { | ||
91 | const { className, name, defaultValue } = this.props; | ||
92 | const { value } = this.state; | ||
93 | |||
94 | return ( | ||
95 | <div | ||
96 | className={classnames([ | ||
97 | className, | ||
98 | 'search-input', | ||
99 | ])} | ||
100 | > | ||
101 | <label | ||
102 | htmlFor={name} | ||
103 | className="mdi mdi-magnify" | ||
104 | onClick={() => this.onClick()} | ||
105 | /> | ||
106 | <input | ||
107 | name={name} | ||
108 | type="text" | ||
109 | value={value} | ||
110 | onChange={e => this.onChange(e)} | ||
111 | onClick={() => this.onClick()} | ||
112 | onBlur={() => this.onBlur()} | ||
113 | ref={(ref) => { this.input = ref; }} | ||
114 | /> | ||
115 | {value !== defaultValue && value.length > 0 && ( | ||
116 | <span | ||
117 | className="mdi mdi-close-circle-outline" | ||
118 | onClick={() => this.reset()} | ||
119 | /> | ||
120 | )} | ||
121 | </div> | ||
122 | ); | ||
123 | } | ||
124 | } | ||
diff --git a/src/components/ui/Select.js b/src/components/ui/Select.js new file mode 100644 index 000000000..2a877af3e --- /dev/null +++ b/src/components/ui/Select.js | |||
@@ -0,0 +1,70 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { Field } from 'mobx-react-form'; | ||
5 | import classnames from 'classnames'; | ||
6 | |||
7 | @observer | ||
8 | export default class Select extends Component { | ||
9 | static propTypes = { | ||
10 | field: PropTypes.instanceOf(Field).isRequired, | ||
11 | className: PropTypes.string, | ||
12 | showLabel: PropTypes.bool, | ||
13 | }; | ||
14 | |||
15 | static defaultProps = { | ||
16 | className: null, | ||
17 | focus: false, | ||
18 | showLabel: true, | ||
19 | }; | ||
20 | |||
21 | render() { | ||
22 | const { | ||
23 | field, | ||
24 | className, | ||
25 | showLabel, | ||
26 | } = this.props; | ||
27 | |||
28 | return ( | ||
29 | <div | ||
30 | className={classnames({ | ||
31 | 'franz-form__field': true, | ||
32 | 'has-error': field.error, | ||
33 | [`${className}`]: className, | ||
34 | })} | ||
35 | > | ||
36 | {field.label && showLabel && ( | ||
37 | <label | ||
38 | className="franz-form__label" | ||
39 | htmlFor={field.name} | ||
40 | > | ||
41 | {field.label} | ||
42 | </label> | ||
43 | )} | ||
44 | <select | ||
45 | onChange={field.onChange} | ||
46 | id={field.id} | ||
47 | defaultValue={field.value} | ||
48 | className="franz-form__select" | ||
49 | > | ||
50 | {field.options.map(type => ( | ||
51 | <option | ||
52 | key={type.value} | ||
53 | value={type.value} | ||
54 | // selected={field.value === } | ||
55 | > | ||
56 | {type.label} | ||
57 | </option> | ||
58 | ))} | ||
59 | </select> | ||
60 | {field.error && ( | ||
61 | <div | ||
62 | className="franz-form__error" | ||
63 | > | ||
64 | {field.error} | ||
65 | </div> | ||
66 | )} | ||
67 | </div> | ||
68 | ); | ||
69 | } | ||
70 | } | ||
diff --git a/src/components/ui/Subscription.js b/src/components/ui/Subscription.js new file mode 100644 index 000000000..ada5cc3e0 --- /dev/null +++ b/src/components/ui/Subscription.js | |||
@@ -0,0 +1,265 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import Form from '../../lib/Form'; | ||
7 | import Radio from '../ui/Radio'; | ||
8 | import Button from '../ui/Button'; | ||
9 | import Loader from '../ui/Loader'; | ||
10 | |||
11 | import { required } from '../../helpers/validation-helpers'; | ||
12 | |||
13 | const messages = defineMessages({ | ||
14 | submitButtonLabel: { | ||
15 | id: 'subscription.submit.label', | ||
16 | defaultMessage: '!!!Support the development of Franz', | ||
17 | }, | ||
18 | paymentSessionError: { | ||
19 | id: 'subscription.paymentSessionError', | ||
20 | defaultMessage: '!!!Could not initialize payment form', | ||
21 | }, | ||
22 | typeFree: { | ||
23 | id: 'subscription.type.free', | ||
24 | defaultMessage: '!!!free', | ||
25 | }, | ||
26 | typeMonthly: { | ||
27 | id: 'subscription.type.month', | ||
28 | defaultMessage: '!!!month', | ||
29 | }, | ||
30 | typeYearly: { | ||
31 | id: 'subscription.type.year', | ||
32 | defaultMessage: '!!!year', | ||
33 | }, | ||
34 | typeMining: { | ||
35 | id: 'subscription.type.mining', | ||
36 | defaultMessage: '!!!Support Franz with processing power', | ||
37 | }, | ||
38 | includedFeatures: { | ||
39 | id: 'subscription.includedFeatures', | ||
40 | defaultMessage: '!!!The Franz Premium Supporter Account includes', | ||
41 | }, | ||
42 | features: { | ||
43 | unlimitedServices: { | ||
44 | id: 'subscription.features.unlimitedServices', | ||
45 | defaultMessage: '!!!Add unlimited services', | ||
46 | }, | ||
47 | onpremise: { | ||
48 | id: 'subscription.features.onpremise', | ||
49 | defaultMessage: '!!!Add on-premise/hosted services like HipChat', | ||
50 | }, | ||
51 | customServices: { | ||
52 | id: 'subscription.features.customServices', | ||
53 | defaultMessage: '!!!Add your custom services', | ||
54 | }, | ||
55 | encryptedSync: { | ||
56 | id: 'subscription.features.encryptedSync', | ||
57 | defaultMessage: '!!!Encrypted session synchronization', | ||
58 | }, | ||
59 | vpn: { | ||
60 | id: 'subscription.features.vpn', | ||
61 | defaultMessage: '!!!Proxy & VPN support', | ||
62 | }, | ||
63 | ads: { | ||
64 | id: 'subscription.features.ads', | ||
65 | defaultMessage: '!!!No ads, ever!', | ||
66 | }, | ||
67 | comingSoon: { | ||
68 | id: 'subscription.features.comingSoon', | ||
69 | defaultMessage: '!!!coming soon', | ||
70 | }, | ||
71 | }, | ||
72 | miningHeadline: { | ||
73 | id: 'subscription.mining.headline', | ||
74 | defaultMessage: '!!!How does this work?', | ||
75 | }, | ||
76 | experimental: { | ||
77 | id: 'subscription.mining.experimental', | ||
78 | defaultMessage: '!!!experimental', | ||
79 | }, | ||
80 | miningDetail1: { | ||
81 | id: 'subscription.mining.line1', | ||
82 | defaultMessage: '!!!By enabling "Support with processing power", Franz will use about 20-50% of your CPU to mine cryptocurrency Monero which equals approximately $ 5/year.', | ||
83 | }, | ||
84 | miningDetail2: { | ||
85 | id: 'subscription.mining.line2', | ||
86 | defaultMessage: '!!!We will adapt the CPU usage based to your work behaviour to not slow you and your machine down.', | ||
87 | }, | ||
88 | miningDetail3: { | ||
89 | id: 'subscription.mining.line3', | ||
90 | defaultMessage: '!!!As long as the miner is active, you will have unlimited access to all the Franz Premium Supporter Features.', | ||
91 | }, | ||
92 | miningMoreInfo: { | ||
93 | id: 'subscription.mining.moreInformation', | ||
94 | defaultMessage: '!!!Get more information about this plan', | ||
95 | }, | ||
96 | }); | ||
97 | |||
98 | @observer | ||
99 | export default class SubscriptionForm extends Component { | ||
100 | static propTypes = { | ||
101 | plan: MobxPropTypes.objectOrObservableObject.isRequired, | ||
102 | isLoading: PropTypes.bool.isRequired, | ||
103 | handlePayment: PropTypes.func.isRequired, | ||
104 | retryPlanRequest: PropTypes.func.isRequired, | ||
105 | isCreatingHostedPage: PropTypes.bool.isRequired, | ||
106 | error: PropTypes.bool.isRequired, | ||
107 | showSkipOption: PropTypes.bool, | ||
108 | skipAction: PropTypes.func, | ||
109 | skipButtonLabel: PropTypes.string, | ||
110 | hideInfo: PropTypes.bool.isRequired, | ||
111 | openExternalUrl: PropTypes.func.isRequired, | ||
112 | }; | ||
113 | |||
114 | static defaultProps ={ | ||
115 | content: '', | ||
116 | showSkipOption: false, | ||
117 | skipAction: () => null, | ||
118 | skipButtonLabel: '', | ||
119 | } | ||
120 | |||
121 | static contextTypes = { | ||
122 | intl: intlShape, | ||
123 | }; | ||
124 | |||
125 | componentWillMount() { | ||
126 | this.form = this.prepareForm(); | ||
127 | } | ||
128 | |||
129 | prepareForm() { | ||
130 | const { intl } = this.context; | ||
131 | |||
132 | const form = { | ||
133 | fields: { | ||
134 | paymentTier: { | ||
135 | value: 'year', | ||
136 | validate: [required], | ||
137 | options: [{ | ||
138 | value: 'month', | ||
139 | label: `$ ${Object.hasOwnProperty.call(this.props.plan, 'month') | ||
140 | ? `${this.props.plan.month.price} / ${intl.formatMessage(messages.typeMonthly)}` | ||
141 | : 'monthly'}`, | ||
142 | }, { | ||
143 | value: 'year', | ||
144 | label: `$ ${Object.hasOwnProperty.call(this.props.plan, 'year') | ||
145 | ? `${this.props.plan.year.price} / ${intl.formatMessage(messages.typeYearly)}` | ||
146 | : 'yearly'}`, | ||
147 | }, { | ||
148 | value: 'mining', | ||
149 | label: intl.formatMessage(messages.typeMining), | ||
150 | }], | ||
151 | }, | ||
152 | }, | ||
153 | }; | ||
154 | |||
155 | if (this.props.showSkipOption) { | ||
156 | form.fields.paymentTier.options.unshift({ | ||
157 | value: 'skip', | ||
158 | label: `$ 0 / ${intl.formatMessage(messages.typeFree)}`, | ||
159 | }); | ||
160 | } | ||
161 | |||
162 | return new Form(form, this.context.intl); | ||
163 | } | ||
164 | |||
165 | render() { | ||
166 | const { | ||
167 | isLoading, | ||
168 | isCreatingHostedPage, | ||
169 | handlePayment, | ||
170 | retryPlanRequest, | ||
171 | error, | ||
172 | showSkipOption, | ||
173 | skipAction, | ||
174 | skipButtonLabel, | ||
175 | hideInfo, | ||
176 | openExternalUrl, | ||
177 | } = this.props; | ||
178 | const { intl } = this.context; | ||
179 | |||
180 | if (error) { | ||
181 | return ( | ||
182 | <Button | ||
183 | label="Reload" | ||
184 | onClick={retryPlanRequest} | ||
185 | isLoaded={!isLoading} | ||
186 | /> | ||
187 | ); | ||
188 | } | ||
189 | |||
190 | return ( | ||
191 | <Loader loaded={!isLoading}> | ||
192 | <Radio field={this.form.$('paymentTier')} showLabel={false} className="paymentTiers" /> | ||
193 | {!hideInfo && ( | ||
194 | <div className="subscription__premium-info"> | ||
195 | {this.form.$('paymentTier').value !== 'mining' && ( | ||
196 | <div> | ||
197 | <p> | ||
198 | <strong>{intl.formatMessage(messages.includedFeatures)}</strong> | ||
199 | </p> | ||
200 | <div className="subscription"> | ||
201 | <ul className="subscription__premium-features"> | ||
202 | <li>{intl.formatMessage(messages.features.onpremise)}</li> | ||
203 | <li> | ||
204 | {intl.formatMessage(messages.features.encryptedSync)} | ||
205 | <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span> | ||
206 | </li> | ||
207 | <li> | ||
208 | {intl.formatMessage(messages.features.customServices)} | ||
209 | <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span> | ||
210 | </li> | ||
211 | <li> | ||
212 | {intl.formatMessage(messages.features.vpn)} | ||
213 | <span className="badge">{intl.formatMessage(messages.features.comingSoon)}</span> | ||
214 | </li> | ||
215 | <li> | ||
216 | {intl.formatMessage(messages.features.ads)} | ||
217 | </li> | ||
218 | </ul> | ||
219 | </div> | ||
220 | </div> | ||
221 | )} | ||
222 | {this.form.$('paymentTier').value === 'mining' && ( | ||
223 | <div className="subscription mining-details"> | ||
224 | <p> | ||
225 | <strong>{intl.formatMessage(messages.miningHeadline)}</strong> | ||
226 | | ||
227 | <span className="badge">{intl.formatMessage(messages.experimental)}</span> | ||
228 | </p> | ||
229 | <p>{intl.formatMessage(messages.miningDetail1)}</p> | ||
230 | <p>{intl.formatMessage(messages.miningDetail2)}</p> | ||
231 | <p>{intl.formatMessage(messages.miningDetail3)}</p> | ||
232 | <p> | ||
233 | <button | ||
234 | onClick={() => openExternalUrl({ url: 'http://meetfranz.com/mining' })} | ||
235 | > | ||
236 | {intl.formatMessage(messages.miningMoreInfo)} | ||
237 | </button> | ||
238 | </p> | ||
239 | </div> | ||
240 | )} | ||
241 | </div> | ||
242 | )} | ||
243 | <div> | ||
244 | {error.code === 'no-payment-session' && ( | ||
245 | <p className="error-message center">{intl.formatMessage(messages.paymentSessionError)}</p> | ||
246 | )} | ||
247 | </div> | ||
248 | {showSkipOption && this.form.$('paymentTier').value === 'skip' ? ( | ||
249 | <Button | ||
250 | label={skipButtonLabel} | ||
251 | className="auth__button" | ||
252 | onClick={skipAction} | ||
253 | /> | ||
254 | ) : ( | ||
255 | <Button | ||
256 | label={intl.formatMessage(messages.submitButtonLabel)} | ||
257 | className="auth__button" | ||
258 | loaded={!isCreatingHostedPage} | ||
259 | onClick={() => handlePayment(this.form.$('paymentTier').value)} | ||
260 | /> | ||
261 | )} | ||
262 | </Loader> | ||
263 | ); | ||
264 | } | ||
265 | } | ||
diff --git a/src/components/ui/SubscriptionPopup.js b/src/components/ui/SubscriptionPopup.js new file mode 100644 index 000000000..72b6ccd98 --- /dev/null +++ b/src/components/ui/SubscriptionPopup.js | |||
@@ -0,0 +1,84 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import Webview from 'react-electron-web-view'; | ||
6 | |||
7 | import Button from '../ui/Button'; | ||
8 | |||
9 | const messages = defineMessages({ | ||
10 | buttonCancel: { | ||
11 | id: 'subscriptionPopup.buttonCancel', | ||
12 | defaultMessage: '!!!Cancel', | ||
13 | }, | ||
14 | buttonDone: { | ||
15 | id: 'subscriptionPopup.buttonDone', | ||
16 | defaultMessage: '!!!Done', | ||
17 | }, | ||
18 | }); | ||
19 | |||
20 | @observer | ||
21 | export default class SubscriptionPopup extends Component { | ||
22 | static propTypes = { | ||
23 | url: PropTypes.string.isRequired, | ||
24 | closeWindow: PropTypes.func.isRequired, | ||
25 | completeCheck: PropTypes.func.isRequired, | ||
26 | isCompleted: PropTypes.bool.isRequired, | ||
27 | }; | ||
28 | |||
29 | static contextTypes = { | ||
30 | intl: intlShape, | ||
31 | }; | ||
32 | |||
33 | state = { | ||
34 | isFakeLoading: false, | ||
35 | }; | ||
36 | |||
37 | // We delay the window closing a bit in order to give | ||
38 | // the Recurly webhook a few seconds to do it's magic | ||
39 | delayedCloseWindow() { | ||
40 | this.setState({ | ||
41 | isFakeLoading: true, | ||
42 | }); | ||
43 | |||
44 | setTimeout(() => { | ||
45 | this.props.closeWindow(); | ||
46 | }, 4000); | ||
47 | } | ||
48 | |||
49 | render() { | ||
50 | const { url, closeWindow, completeCheck, isCompleted } = this.props; | ||
51 | const { intl } = this.context; | ||
52 | |||
53 | return ( | ||
54 | <div className="subscription-popup"> | ||
55 | <div className="subscription-popup__content"> | ||
56 | <Webview | ||
57 | className="subscription-popup__webview" | ||
58 | |||
59 | autosize | ||
60 | src={url} | ||
61 | disablewebsecurity | ||
62 | onDidNavigate={completeCheck} | ||
63 | // onNewWindow={(event, url, frameName, options) => | ||
64 | // openWindow({ event, url, frameName, options })} | ||
65 | /> | ||
66 | </div> | ||
67 | <div className="subscription-popup__toolbar franz-form"> | ||
68 | <Button | ||
69 | label={intl.formatMessage(messages.buttonCancel)} | ||
70 | buttonType="secondary" | ||
71 | onClick={closeWindow} | ||
72 | disabled={isCompleted} | ||
73 | /> | ||
74 | <Button | ||
75 | label={intl.formatMessage(messages.buttonDone)} | ||
76 | onClick={() => this.delayedCloseWindow()} | ||
77 | disabled={!isCompleted} | ||
78 | loaded={!this.state.isFakeLoading} | ||
79 | /> | ||
80 | </div> | ||
81 | </div> | ||
82 | ); | ||
83 | } | ||
84 | } | ||
diff --git a/src/components/ui/Tabs/TabItem.js b/src/components/ui/Tabs/TabItem.js new file mode 100644 index 000000000..9ff9f009e --- /dev/null +++ b/src/components/ui/Tabs/TabItem.js | |||
@@ -0,0 +1,17 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | |||
3 | import { oneOrManyChildElements } from '../../../prop-types'; | ||
4 | |||
5 | export default class TabItem extends Component { | ||
6 | static propTypes = { | ||
7 | children: oneOrManyChildElements.isRequired, | ||
8 | } | ||
9 | |||
10 | render() { | ||
11 | const { children } = this.props; | ||
12 | |||
13 | return ( | ||
14 | <div>{children}</div> | ||
15 | ); | ||
16 | } | ||
17 | } | ||
diff --git a/src/components/ui/Tabs/Tabs.js b/src/components/ui/Tabs/Tabs.js new file mode 100644 index 000000000..50397f9bb --- /dev/null +++ b/src/components/ui/Tabs/Tabs.js | |||
@@ -0,0 +1,69 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | |||
6 | import { oneOrManyChildElements } from '../../../prop-types'; | ||
7 | |||
8 | @observer | ||
9 | export default class Tab extends Component { | ||
10 | static propTypes = { | ||
11 | children: oneOrManyChildElements.isRequired, | ||
12 | active: PropTypes.number, | ||
13 | }; | ||
14 | |||
15 | static defaultProps = { | ||
16 | active: 0, | ||
17 | }; | ||
18 | |||
19 | componentWillMount() { | ||
20 | this.setState({ active: this.props.active }); | ||
21 | } | ||
22 | |||
23 | switchTab(index) { | ||
24 | this.setState({ active: index }); | ||
25 | } | ||
26 | |||
27 | render() { | ||
28 | const { children: childElements } = this.props; | ||
29 | const children = childElements.filter(c => !!c); | ||
30 | |||
31 | if (children.length === 1) { | ||
32 | return <div>{children}</div>; | ||
33 | } | ||
34 | |||
35 | return ( | ||
36 | <div className="content-tabs"> | ||
37 | <div className="content-tabs__tabs"> | ||
38 | {React.Children.map(children, (child, i) => ( | ||
39 | <button | ||
40 | key={i} | ||
41 | className={classnames({ | ||
42 | 'content-tabs__item': true, | ||
43 | 'is-active': this.state.active === i, | ||
44 | })} | ||
45 | onClick={() => this.switchTab(i)} | ||
46 | type="button" | ||
47 | > | ||
48 | {child.props.title} | ||
49 | </button> | ||
50 | ))} | ||
51 | </div> | ||
52 | <div className="content-tabs__content"> | ||
53 | {React.Children.map(children, (child, i) => ( | ||
54 | <div | ||
55 | key={i} | ||
56 | className={classnames({ | ||
57 | 'content-tabs__item': true, | ||
58 | 'is-active': this.state.active === i, | ||
59 | })} | ||
60 | type="button" | ||
61 | > | ||
62 | {child} | ||
63 | </div> | ||
64 | ))} | ||
65 | </div> | ||
66 | </div> | ||
67 | ); | ||
68 | } | ||
69 | } | ||
diff --git a/src/components/ui/Tabs/index.js b/src/components/ui/Tabs/index.js new file mode 100644 index 000000000..e4adb62c7 --- /dev/null +++ b/src/components/ui/Tabs/index.js | |||
@@ -0,0 +1,6 @@ | |||
1 | import Tabs from './Tabs'; | ||
2 | import TabItem from './TabItem'; | ||
3 | |||
4 | export default Tabs; | ||
5 | |||
6 | export { TabItem }; | ||
diff --git a/src/components/ui/Toggle.js b/src/components/ui/Toggle.js new file mode 100644 index 000000000..62d46393e --- /dev/null +++ b/src/components/ui/Toggle.js | |||
@@ -0,0 +1,67 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import classnames from 'classnames'; | ||
5 | import { Field } from 'mobx-react-form'; | ||
6 | |||
7 | @observer | ||
8 | export default class Toggle extends Component { | ||
9 | static propTypes = { | ||
10 | field: PropTypes.instanceOf(Field).isRequired, | ||
11 | className: PropTypes.string, | ||
12 | showLabel: PropTypes.bool, | ||
13 | }; | ||
14 | |||
15 | static defaultProps = { | ||
16 | className: '', | ||
17 | showLabel: true, | ||
18 | }; | ||
19 | |||
20 | onChange(e) { | ||
21 | const { field } = this.props; | ||
22 | |||
23 | field.onChange(e); | ||
24 | } | ||
25 | |||
26 | render() { | ||
27 | const { | ||
28 | field, | ||
29 | className, | ||
30 | showLabel, | ||
31 | } = this.props; | ||
32 | |||
33 | if (field.value === '' && field.default !== '') { | ||
34 | field.value = field.default; | ||
35 | } | ||
36 | |||
37 | return ( | ||
38 | <div | ||
39 | className={classnames([ | ||
40 | 'franz-form__field', | ||
41 | 'franz-form__toggle-wrapper', | ||
42 | className, | ||
43 | ])} | ||
44 | > | ||
45 | <label | ||
46 | htmlFor={field.id} | ||
47 | className={classnames({ | ||
48 | 'franz-form__toggle': true, | ||
49 | 'is-active': field.value, | ||
50 | })} | ||
51 | > | ||
52 | <div className="franz-form__toggle-button" /> | ||
53 | <input | ||
54 | type="checkbox" | ||
55 | id={field.id} | ||
56 | name={field.name} | ||
57 | value={field.name} | ||
58 | checked={field.value} | ||
59 | onChange={e => this.onChange(e)} | ||
60 | /> | ||
61 | </label> | ||
62 | {field.error && <div className={field.error}>{field.error}</div>} | ||
63 | {field.label && showLabel && <label className="franz-form__label" htmlFor={field.id}>{field.label}</label>} | ||
64 | </div> | ||
65 | ); | ||
66 | } | ||
67 | } | ||
diff --git a/src/components/ui/effects/Appear.js b/src/components/ui/effects/Appear.js new file mode 100644 index 000000000..1255fce2e --- /dev/null +++ b/src/components/ui/effects/Appear.js | |||
@@ -0,0 +1,51 @@ | |||
1 | /* eslint-disable react/no-did-mount-set-state */ | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; | ||
5 | |||
6 | export default class Appear extends Component { | ||
7 | static propTypes = { | ||
8 | children: PropTypes.any.isRequired, // eslint-disable-line | ||
9 | transitionName: PropTypes.string, | ||
10 | className: PropTypes.string, | ||
11 | }; | ||
12 | |||
13 | static defaultProps = { | ||
14 | transitionName: 'fadeIn', | ||
15 | className: '', | ||
16 | }; | ||
17 | |||
18 | state = { | ||
19 | mounted: false, | ||
20 | }; | ||
21 | |||
22 | componentDidMount() { | ||
23 | this.setState({ mounted: true }); | ||
24 | } | ||
25 | |||
26 | render() { | ||
27 | const { | ||
28 | children, | ||
29 | transitionName, | ||
30 | className, | ||
31 | } = this.props; | ||
32 | |||
33 | if (!this.state.mounted) { | ||
34 | return null; | ||
35 | } | ||
36 | |||
37 | return ( | ||
38 | <ReactCSSTransitionGroup | ||
39 | transitionName={transitionName} | ||
40 | transitionAppear | ||
41 | transitionLeave | ||
42 | transitionAppearTimeout={1500} | ||
43 | transitionEnterTimeout={1500} | ||
44 | transitionLeaveTimeout={1500} | ||
45 | className={className} | ||
46 | > | ||
47 | {children} | ||
48 | </ReactCSSTransitionGroup> | ||
49 | ); | ||
50 | } | ||
51 | } | ||
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 @@ | |||
1 | export const CHECK_INTERVAL = 1000 * 3600; // How often should we perform checks | ||
2 | export const LOCAL_API = 'http://localhost:3000'; | ||
3 | export const DEV_API = 'https://dev.franzinfra.com'; | ||
4 | export const LIVE_API = 'https://api.franzinfra.com'; | ||
5 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | |||
5 | import AuthLayout from '../../components/auth/AuthLayout'; | ||
6 | import AppStore from '../../stores/AppStore'; | ||
7 | import GlobalErrorStore from '../../stores/GlobalErrorStore'; | ||
8 | |||
9 | import { oneOrManyChildElements } from '../../prop-types'; | ||
10 | |||
11 | @inject('stores', 'actions') @observer | ||
12 | export 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 | |||
37 | AuthLayoutContainer.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import Import from '../../components/auth/Import'; | ||
5 | import UserStore from '../../stores/UserStore'; | ||
6 | import { gaPage } from '../../lib/analytics'; | ||
7 | |||
8 | @inject('stores', 'actions') @observer | ||
9 | export 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 | |||
32 | ImportScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import Invite from '../../components/auth/Invite'; | ||
5 | import { gaPage } from '../../lib/analytics'; | ||
6 | |||
7 | @inject('stores', 'actions') @observer | ||
8 | export 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 | |||
23 | InviteScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import Login from '../../components/auth/Login'; | ||
5 | import UserStore from '../../stores/UserStore'; | ||
6 | import { gaPage } from '../../lib/analytics'; | ||
7 | |||
8 | import { globalError as globalErrorPropType } from '../../prop-types'; | ||
9 | |||
10 | @inject('stores', 'actions') @observer | ||
11 | export 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 | |||
36 | LoginScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import Password from '../../components/auth/Password'; | ||
5 | import UserStore from '../../stores/UserStore'; | ||
6 | import { gaPage } from '../../lib/analytics'; | ||
7 | |||
8 | @inject('stores', 'actions') @observer | ||
9 | export 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 | |||
29 | PasswordScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import { RouterStore } from 'mobx-react-router'; | ||
5 | |||
6 | import Pricing from '../../components/auth/Pricing'; | ||
7 | import UserStore from '../../stores/UserStore'; | ||
8 | import PaymentStore from '../../stores/PaymentStore'; | ||
9 | import { gaPage } from '../../lib/analytics'; | ||
10 | |||
11 | import { globalError as globalErrorPropType } from '../../prop-types'; | ||
12 | |||
13 | @inject('stores', 'actions') @observer | ||
14 | export 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 | |||
42 | PricingScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | |||
5 | import Signup from '../../components/auth/Signup'; | ||
6 | import UserStore from '../../stores/UserStore'; | ||
7 | import { gaPage } from '../../lib/analytics'; | ||
8 | |||
9 | import { globalError as globalErrorPropType } from '../../prop-types'; | ||
10 | |||
11 | @inject('stores', 'actions') @observer | ||
12 | export 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 | |||
34 | SignupScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | |||
5 | import Welcome from '../../components/auth/Welcome'; | ||
6 | import UserStore from '../../stores/UserStore'; | ||
7 | import RecipePreviewsStore from '../../stores/RecipePreviewsStore'; | ||
8 | import { gaPage } from '../../lib/analytics'; | ||
9 | |||
10 | @inject('stores', 'actions') @observer | ||
11 | export 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 | |||
29 | LoginScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | |||
5 | import AppStore from '../../stores/AppStore'; | ||
6 | import RecipesStore from '../../stores/RecipesStore'; | ||
7 | import ServicesStore from '../../stores/ServicesStore'; | ||
8 | import UIStore from '../../stores/UIStore'; | ||
9 | import NewsStore from '../../stores/NewsStore'; | ||
10 | import UserStore from '../../stores/UserStore'; | ||
11 | import RequestStore from '../../stores/RequestStore'; | ||
12 | import GlobalErrorStore from '../../stores/GlobalErrorStore'; | ||
13 | |||
14 | import { oneOrManyChildElements } from '../../prop-types'; | ||
15 | import AppLayout from '../../components/layout/AppLayout'; | ||
16 | import Sidebar from '../../components/layout/Sidebar'; | ||
17 | import Services from '../../components/services/content/Services'; | ||
18 | import AppLoader from '../../components/ui/AppLoader'; | ||
19 | |||
20 | @inject('stores', 'actions') @observer | ||
21 | export 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 | |||
126 | AppLayoutContainer.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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { inject, observer } from 'mobx-react'; | ||
5 | |||
6 | import PaymentStore from '../../stores/PaymentStore'; | ||
7 | import UserStore from '../../stores/UserStore'; | ||
8 | import AppStore from '../../stores/AppStore'; | ||
9 | import { gaPage } from '../../lib/analytics'; | ||
10 | |||
11 | import AccountDashboard from '../../components/settings/account/AccountDashboard'; | ||
12 | |||
13 | const { BrowserWindow } = remote; | ||
14 | |||
15 | @inject('stores', 'actions') @observer | ||
16 | export 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 | |||
97 | AccountScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import UserStore from '../../stores/UserStore'; | ||
7 | import RecipesStore from '../../stores/RecipesStore'; | ||
8 | import ServicesStore from '../../stores/ServicesStore'; | ||
9 | import Form from '../../lib/Form'; | ||
10 | import { gaPage } from '../../lib/analytics'; | ||
11 | |||
12 | |||
13 | import ServiceError from '../../components/settings/services/ServiceError'; | ||
14 | import EditServiceForm from '../../components/settings/services/EditServiceForm'; | ||
15 | import { required, url, oneRequired } from '../../helpers/validation-helpers'; | ||
16 | |||
17 | const 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 | ||
45 | export 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 | |||
190 | EditServiceScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import AppStore from '../../stores/AppStore'; | ||
7 | import SettingsStore from '../../stores/SettingsStore'; | ||
8 | import UserStore from '../../stores/UserStore'; | ||
9 | import Form from '../../lib/Form'; | ||
10 | import languages from '../../i18n/languages'; | ||
11 | import { gaPage } from '../../lib/analytics'; | ||
12 | |||
13 | |||
14 | import EditSettingsForm from '../../components/settings/settings/EditSettingsForm'; | ||
15 | |||
16 | const 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 | ||
44 | export 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 | |||
148 | EditSettingsScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | |||
6 | import UserStore from '../../stores/UserStore'; | ||
7 | import Form from '../../lib/Form'; | ||
8 | import EditUserForm from '../../components/settings/user/EditUserForm'; | ||
9 | import { required, email, minLength } from '../../helpers/validation-helpers'; | ||
10 | import { gaPage } from '../../lib/analytics'; | ||
11 | |||
12 | const 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 | ||
54 | export 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 | |||
155 | EditUserScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { autorun } from 'mobx'; | ||
4 | import { inject, observer } from 'mobx-react'; | ||
5 | |||
6 | import RecipePreviewsStore from '../../stores/RecipePreviewsStore'; | ||
7 | import RecipeStore from '../../stores/RecipesStore'; | ||
8 | import ServiceStore from '../../stores/ServicesStore'; | ||
9 | import UserStore from '../../stores/UserStore'; | ||
10 | import { gaPage } from '../../lib/analytics'; | ||
11 | |||
12 | import RecipesDashboard from '../../components/settings/recipes/RecipesDashboard'; | ||
13 | |||
14 | @inject('stores', 'actions') @observer | ||
15 | export 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 | |||
111 | RecipesScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | import { RouterStore } from 'mobx-react-router'; | ||
5 | |||
6 | // import RecipePreviewsStore from '../../stores/RecipePreviewsStore'; | ||
7 | import UserStore from '../../stores/UserStore'; | ||
8 | import ServiceStore from '../../stores/ServicesStore'; | ||
9 | import { gaPage } from '../../lib/analytics'; | ||
10 | |||
11 | import ServicesDashboard from '../../components/settings/services/ServicesDashboard'; | ||
12 | |||
13 | @inject('stores', 'actions') @observer | ||
14 | export 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 | |||
60 | ServicesScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer, inject } from 'mobx-react'; | ||
4 | |||
5 | import ServicesStore from '../../stores/ServicesStore'; | ||
6 | |||
7 | import Layout from '../../components/settings/SettingsLayout'; | ||
8 | import Navigation from '../../components/settings/navigation/SettingsNavigation'; | ||
9 | |||
10 | @inject('stores', 'actions') @observer | ||
11 | export 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 | |||
33 | SettingsContainer.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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import React, { Component } from 'react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { inject, observer } from 'mobx-react'; | ||
5 | |||
6 | import PaymentStore from '../../stores/PaymentStore'; | ||
7 | |||
8 | import SubscriptionForm from '../../components/ui/Subscription'; | ||
9 | |||
10 | const { BrowserWindow } = remote; | ||
11 | |||
12 | @inject('stores', 'actions') @observer | ||
13 | export 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 | |||
111 | SubscriptionFormScreen.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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { inject, observer } from 'mobx-react'; | ||
4 | |||
5 | import SubscriptionPopup from '../../components/ui/SubscriptionPopup'; | ||
6 | |||
7 | |||
8 | @inject('stores', 'actions') @observer | ||
9 | export 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 | |||
37 | SubscriptionPopupScreen.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 @@ | |||
1 | export 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 @@ | |||
1 | process.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 @@ | |||
1 | import { app, ipcMain, Tray, Menu } from 'electron'; | ||
2 | import path from 'path'; | ||
3 | |||
4 | const INDICATOR_TRAY_PLAIN = 'tray'; | ||
5 | const INDICATOR_TRAY_UNREAD = 'tray-unread'; | ||
6 | const INDICATOR_TASKBAR = 'taskbar'; | ||
7 | |||
8 | const FILE_EXTENSION = process.platform === 'win32' ? 'ico' : 'png'; | ||
9 | let trayIcon; | ||
10 | |||
11 | function getAsset(type, asset) { | ||
12 | return path.join( | ||
13 | __dirname, '..', '..', 'assets', 'images', type, process.platform, `${asset}.${FILE_EXTENSION}`, | ||
14 | ); | ||
15 | } | ||
16 | |||
17 | export 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 @@ | |||
1 | import { app, ipcMain } from 'electron'; | ||
2 | import { autoUpdater } from 'electron-updater'; | ||
3 | |||
4 | export 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 @@ | |||
1 | import autoUpdate from './autoUpdate'; | ||
2 | import settings from './settings'; | ||
3 | import appIndicator from './appIndicator'; | ||
4 | |||
5 | export 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 @@ | |||
1 | import { ipcMain } from 'electron'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import { Tray, Menu, ipcMain } from 'electron'; | ||
2 | import path from 'path'; | ||
3 | |||
4 | const INDICATOR_PLAIN = 'franz-taskbar'; | ||
5 | const INDICATOR_UNREAD = 'franz-taskbar-unread'; | ||
6 | |||
7 | const FILE_EXTENSION = process.platform === 'win32' ? 'ico' : 'png'; | ||
8 | |||
9 | let trayIcon; | ||
10 | |||
11 | function getAsset(asset) { | ||
12 | return path.join( | ||
13 | __dirname, '..', '..', 'assets', 'images', 'tray', process.platform, `${asset}.${FILE_EXTENSION}`, | ||
14 | ); | ||
15 | } | ||
16 | |||
17 | export 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 @@ | |||
1 | const { releaseDocumentFocus } = require('./webview-ime-focus-helpers'); | ||
2 | |||
3 | function giveWebviewDocumentFocus(element) { | ||
4 | releaseDocumentFocus(); | ||
5 | |||
6 | window.requestAnimationFrame(() => { | ||
7 | element.send('claim-document-focus'); | ||
8 | }); | ||
9 | } | ||
10 | |||
11 | function elementIsUnfocusedWebview(element) { | ||
12 | return element.tagName === 'WEBVIEW' && !element.getWebContents().isFocused(); | ||
13 | } | ||
14 | |||
15 | function webviewDidAutofocus(element) { | ||
16 | function didKeyDown() { | ||
17 | element.removeEventListener('keydown', didKeyDown, true); | ||
18 | giveWebviewDocumentFocus(element); | ||
19 | } | ||
20 | |||
21 | element.addEventListener('keydown', didKeyDown, true); | ||
22 | } | ||
23 | |||
24 | function handleAutofocus(element) { | ||
25 | element.addEventListener('ipc-message', (event) => { | ||
26 | if (event.channel === 'autofocus') { | ||
27 | element.focus(); | ||
28 | webviewDidAutofocus(element); | ||
29 | } | ||
30 | }); | ||
31 | } | ||
32 | |||
33 | function didMouseDown(event) { | ||
34 | if (elementIsUnfocusedWebview(event.target)) { | ||
35 | giveWebviewDocumentFocus(event.target); | ||
36 | } | ||
37 | } | ||
38 | |||
39 | document.addEventListener('mousedown', didMouseDown, true); | ||
40 | document.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 @@ | |||
1 | import { LIVE_API, DEV_API, LOCAL_API } from './config'; | ||
2 | |||
3 | export const isDevMode = Boolean(process.execPath.match(/[\\/]electron/)); | ||
4 | export const useLiveAPI = process.env.LIVE_API; | ||
5 | export const useLocalAPI = process.env.LOCAL_API; | ||
6 | |||
7 | export const isMac = process.platform === 'darwin'; | ||
8 | export const isWindows = process.platform === 'win32'; | ||
9 | export const isLinux = process.platform === 'linux'; | ||
10 | |||
11 | export const ctrlKey = isMac ? '⌘' : 'Ctrl'; | ||
12 | |||
13 | let api; | ||
14 | if (!isDevMode || (isDevMode && useLiveAPI)) { | ||
15 | api = LIVE_API; | ||
16 | } else if (isDevMode && useLocalAPI) { | ||
17 | api = LOCAL_API; | ||
18 | } else { | ||
19 | api = DEV_API; | ||
20 | } | ||
21 | |||
22 | export 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 @@ | |||
1 | import { SHA256 } from 'jshashes'; | ||
2 | |||
3 | export function hash(password) { | ||
4 | return new SHA256().b64(password); | ||
5 | } | ||
6 | |||
7 | export 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 @@ | |||
1 | import path from 'path'; | ||
2 | import { remote } from 'electron'; | ||
3 | |||
4 | // import ServiceModel from '../models/Service'; | ||
5 | |||
6 | const app = remote.app; | ||
7 | |||
8 | export function getRecipeDirectory(id = '') { | ||
9 | return path.join(app.getPath('userData'), 'recipes', id); | ||
10 | } | ||
11 | |||
12 | export function getDevRecipeDirectory(id = '') { | ||
13 | return path.join(app.getPath('userData'), 'recipes', 'dev', id); | ||
14 | } | ||
15 | |||
16 | export 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 | |||
36 | module.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 @@ | |||
1 | import RouteParser from 'route-parser'; | ||
2 | |||
3 | // eslint-disable-next-line | ||
4 | export 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 @@ | |||
1 | export function required({ field }) { | ||
2 | const isValid = (field.value.trim() !== ''); | ||
3 | return [isValid, `${field.label} is required`]; | ||
4 | } | ||
5 | |||
6 | export 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 | |||
19 | export 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 | |||
33 | export 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 | |||
43 | export 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 @@ | |||
1 | module.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 | |||
16 | module.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 @@ | |||
1 | import { defineMessages } from 'react-intl'; | ||
2 | |||
3 | export 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 @@ | |||
1 | module.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 @@ | |||
1 | import languages from './languages'; | ||
2 | |||
3 | const translations = []; | ||
4 | Object.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 | |||
13 | module.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 @@ | |||
1 | import { app, BrowserWindow, shell } from 'electron'; | ||
2 | import fs from 'fs-extra'; | ||
3 | import path from 'path'; | ||
4 | |||
5 | // eslint-disable-next-line | ||
6 | if (require('electron-squirrel-startup')) app.quit(); | ||
7 | |||
8 | import windowStateKeeper from 'electron-window-state'; // eslint-disable-line | ||
9 | |||
10 | import { isDevMode, isWindows } from './environment'; // eslint-disable-line | ||
11 | import ipcApi from './electron/ipc-api'; // eslint-disable-line | ||
12 | import Settings from './electron/Settings'; // eslint-disable-line | ||
13 | import { appId } from './package.json'; // eslint-disable-line | ||
14 | import './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. | ||
18 | let mainWindow; | ||
19 | const settings = new Settings(); | ||
20 | let willQuitApp = false; | ||
21 | |||
22 | // Ensure that the recipe directory exists | ||
23 | fs.ensureDir(path.join(app.getPath('userData'), 'recipes')); | ||
24 | |||
25 | // Set App ID for Windows | ||
26 | if (isWindows) { | ||
27 | app.setAppUserModelId(appId); | ||
28 | } | ||
29 | |||
30 | const 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. | ||
123 | app.on('ready', createWindow); | ||
124 | |||
125 | // Quit when all windows are closed. | ||
126 | app.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 | |||
135 | app.on('before-quit', () => { | ||
136 | willQuitApp = true; | ||
137 | }); | ||
138 | |||
139 | app.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 @@ | |||
1 | import Form from 'mobx-react-form'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import { remote, shell } from 'electron'; | ||
2 | import { autorun, computed, observable, toJS } from 'mobx'; | ||
3 | |||
4 | import { isDevMode, isMac } from '../environment'; | ||
5 | |||
6 | const { app, Menu } = remote; | ||
7 | |||
8 | const 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 | |||
90 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import { autorun } from 'mobx'; | ||
3 | |||
4 | import { isMac } from '../environment'; | ||
5 | |||
6 | export 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 @@ | |||
1 | import { remote } from 'electron'; | ||
2 | import { GA_ID } from '../config'; | ||
3 | // import { isDevMode } from '../environment'; | ||
4 | |||
5 | const { 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), | ||
10 | m=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 | |||
14 | const GA_LOCAL_STORAGE_KEY = 'gaUid'; | ||
15 | |||
16 | ga('create', GA_ID, { | ||
17 | storage: 'none', | ||
18 | clientId: localStorage.getItem(GA_LOCAL_STORAGE_KEY), | ||
19 | }); | ||
20 | |||
21 | ga((tracker) => { | ||
22 | localStorage.setItem(GA_LOCAL_STORAGE_KEY, tracker.get('clientId')); | ||
23 | }); | ||
24 | ga('set', 'checkProtocolTask', null); | ||
25 | ga('set', 'version', app.getVersion()); | ||
26 | ga('send', 'App'); | ||
27 | |||
28 | export function gaPage(page) { | ||
29 | ga('send', 'pageview', page); | ||
30 | |||
31 | console.debug('GA track page', page); | ||
32 | } | ||
33 | |||
34 | export function gaEvent(category, action, label) { | ||
35 | ga('send', 'event', category, action, label); | ||
36 | |||
37 | console.debug('GA track page', category, action); | ||
38 | } | ||
39 | |||
40 | setTimeout(() => { | ||
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 | |||
3 | export 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 @@ | |||
1 | export 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 | |||
3 | export 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 @@ | |||
1 | export 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 | |||
3 | export 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 @@ | |||
1 | import { computed, observable } from 'mobx'; | ||
2 | import path from 'path'; | ||
3 | import normalizeUrl from 'normalize-url'; | ||
4 | |||
5 | export 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 @@ | |||
1 | import { observable } from 'mobx'; | ||
2 | |||
3 | export 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | |||
3 | // eslint-disable-next-line | ||
4 | export const oneOrManyChildElements = PropTypes.oneOfType([ | ||
5 | PropTypes.arrayOf(PropTypes.element), | ||
6 | PropTypes.element, | ||
7 | PropTypes.array, | ||
8 | ]); | ||
9 | |||
10 | export 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 @@ | |||
1 | import { remote, ipcRenderer, shell } from 'electron'; | ||
2 | import { action, observable } from 'mobx'; | ||
3 | import moment from 'moment'; | ||
4 | import key from 'keymaster'; | ||
5 | import path from 'path'; | ||
6 | import idleTimer from '@paulcbetts/system-idle-time'; | ||
7 | |||
8 | import Store from './lib/Store'; | ||
9 | import Request from './lib/Request'; | ||
10 | import { CHECK_INTERVAL } from '../config'; | ||
11 | import { isMac, isLinux } from '../environment'; | ||
12 | import locales from '../i18n/translations'; | ||
13 | import { gaEvent } from '../lib/analytics'; | ||
14 | import Miner from '../lib/Miner'; | ||
15 | |||
16 | const { app, getCurrentWindow, powerMonitor } = remote; | ||
17 | const defaultLocale = 'en-US'; | ||
18 | |||
19 | const appFolder = path.dirname(process.execPath); | ||
20 | const updateExe = path.resolve(appFolder, '..', 'Update.exe'); | ||
21 | const exeName = path.basename(process.execPath); | ||
22 | |||
23 | export 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 @@ | |||
1 | import { observable, action } from 'mobx'; | ||
2 | import Store from './lib/Store'; | ||
3 | import Request from './lib/Request'; | ||
4 | |||
5 | export 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 @@ | |||
1 | import { computed, observable } from 'mobx'; | ||
2 | import { remove } from 'lodash'; | ||
3 | |||
4 | import Store from './lib/Store'; | ||
5 | import CachedRequest from './lib/CachedRequest'; | ||
6 | import Request from './lib/Request'; | ||
7 | import { CHECK_INTERVAL } from '../config'; | ||
8 | |||
9 | export 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 @@ | |||
1 | import { action, observable, computed } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | import CachedRequest from './lib/CachedRequest'; | ||
5 | import Request from './lib/Request'; | ||
6 | import { gaEvent } from '../lib/analytics'; | ||
7 | |||
8 | export 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 @@ | |||
1 | import { action, computed, observable } from 'mobx'; | ||
2 | import { debounce } from 'lodash'; | ||
3 | |||
4 | import Store from './lib/Store'; | ||
5 | import CachedRequest from './lib/CachedRequest'; | ||
6 | import Request from './lib/Request'; | ||
7 | import { gaEvent } from '../lib/analytics'; | ||
8 | |||
9 | export 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 @@ | |||
1 | import { action, computed, observable } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | import CachedRequest from './lib/CachedRequest'; | ||
5 | import Request from './lib/Request'; | ||
6 | import { matchRoute } from '../helpers/routing-helpers'; | ||
7 | |||
8 | export 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 @@ | |||
1 | import { action, computed, observable } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | |||
5 | export 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'; | ||
2 | import { action, computed, observable } from 'mobx'; | ||
3 | import { debounce, remove } from 'lodash'; | ||
4 | // import path from 'path'; | ||
5 | // import fs from 'fs-extra'; | ||
6 | |||
7 | import Store from './lib/Store'; | ||
8 | import Request from './lib/Request'; | ||
9 | import CachedRequest from './lib/CachedRequest'; | ||
10 | import { matchRoute } from '../helpers/routing-helpers'; | ||
11 | import { gaEvent } from '../lib/analytics'; | ||
12 | |||
13 | export 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 @@ | |||
1 | import { ipcRenderer } from 'electron'; | ||
2 | import { action, computed, observable } from 'mobx'; | ||
3 | |||
4 | import Store from './lib/Store'; | ||
5 | import Request from './lib/Request'; | ||
6 | import CachedRequest from './lib/CachedRequest'; | ||
7 | import { gaEvent } from '../lib/analytics'; | ||
8 | |||
9 | export 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 @@ | |||
1 | import { action, observable } from 'mobx'; | ||
2 | |||
3 | import Store from './lib/Store'; | ||
4 | |||
5 | export 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 @@ | |||
1 | import { observable, computed, action } from 'mobx'; | ||
2 | import moment from 'moment'; | ||
3 | import jwt from 'jsonwebtoken'; | ||
4 | |||
5 | import Store from './lib/Store'; | ||
6 | import Request from './lib/Request'; | ||
7 | import CachedRequest from './lib/CachedRequest'; | ||
8 | import { gaEvent } from '../lib/analytics'; | ||
9 | |||
10 | // TODO: split stores into UserStore and AuthStore | ||
11 | export 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 @@ | |||
1 | import AppStore from './AppStore'; | ||
2 | import UserStore from './UserStore'; | ||
3 | import SettingsStore from './SettingsStore'; | ||
4 | import ServicesStore from './ServicesStore'; | ||
5 | import RecipesStore from './RecipesStore'; | ||
6 | import RecipePreviewsStore from './RecipePreviewsStore'; | ||
7 | import UIStore from './UIStore'; | ||
8 | import PaymentStore from './PaymentStore'; | ||
9 | import NewsStore from './NewsStore'; | ||
10 | import RequestStore from './RequestStore'; | ||
11 | import GlobalErrorStore from './GlobalErrorStore'; | ||
12 | |||
13 | export 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 | ||
2 | import { action } from 'mobx'; | ||
3 | import { isEqual, remove } from 'lodash'; | ||
4 | import Request from './Request'; | ||
5 | |||
6 | export 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 | ||
2 | import { autorun } from 'mobx'; | ||
3 | |||
4 | export 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 @@ | |||
1 | import { observable, action, computed } from 'mobx'; | ||
2 | import { isEqual } from 'lodash/fp'; | ||
3 | |||
4 | export 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 @@ | |||
1 | import { computed, observable } from 'mobx'; | ||
2 | import Reaction from './Reaction'; | ||
3 | |||
4 | export 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 | |||
3 | html { | ||
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 | |||
6 | html, body, div, span, applet, object, iframe, | ||
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, | ||
8 | a, abbr, acronym, address, big, cite, code, | ||
9 | del, dfn, em, img, ins, kbd, q, s, samp, | ||
10 | small, strike, strong, sub, sup, tt, var, | ||
11 | b, u, i, center, | ||
12 | dl, dt, dd, ol, ul, li, | ||
13 | fieldset, form, label, legend, | ||
14 | table, caption, tbody, tfoot, thead, tr, th, td, | ||
15 | article, aside, canvas, details, embed, | ||
16 | figure, figcaption, footer, header, hgroup, | ||
17 | menu, nav, output, ruby, section, summary, | ||
18 | time, 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 */ | ||
26 | article, aside, details, figcaption, figure, | ||
27 | footer, header, hgroup, menu, nav, section { | ||
28 | display: block; | ||
29 | } | ||
30 | body { | ||
31 | line-height: 1; | ||
32 | } | ||
33 | ol, ul { | ||
34 | list-style: none; | ||
35 | } | ||
36 | blockquote, q { | ||
37 | quotes: none; | ||
38 | } | ||
39 | blockquote:before, blockquote:after, q:before, q:after { | ||
40 | content: ''; | ||
41 | content: none; | ||
42 | } | ||
43 | table { | ||
44 | border-collapse: collapse; | ||
45 | border-spacing: 0; | ||
46 | } | ||
47 | |||
48 | /* Buttons should not have any special style applied by default */ | ||
49 | button { | ||
50 | background: none; | ||
51 | border: none; | ||
52 | padding: 0; | ||
53 | } | ||
54 | |||
55 | button:focus { | ||
56 | outline: 0; | ||
57 | } | ||
58 | |||
59 | html { | ||
60 | /* base for rem / 1rem = 10px */ | ||
61 | font-size: 62.5%; | ||
62 | font-family: 'Open Sans'; | ||
63 | } | ||
64 | |||
65 | body { | ||
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 | |||
79 | html, body, div { | ||
80 | height: 100%; | ||
81 | background: none; | ||
82 | box-sizing: border-box; | ||
83 | } | ||
84 | |||
85 | *:focus { | ||
86 | outline: none; | ||
87 | } | ||
88 | |||
89 | img { | ||
90 | pointer-events: none; | ||
91 | } | ||
92 | |||
93 | a { | ||
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 | |||
4 | h1 { | ||
5 | font-size: 30px; | ||
6 | font-weight: 300; | ||
7 | letter-spacing: -1px; | ||
8 | margin-bottom: 25px; | ||
9 | } | ||
10 | |||
11 | h2 { | ||
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 | |||
23 | p { | ||
24 | margin-bottom: 10px; | ||
25 | line-height: 1.7rem; | ||
26 | |||
27 | &:last-of-type { | ||
28 | margin-bottom: 0; | ||
29 | } | ||
30 | } | ||
31 | |||
32 | strong { | ||
33 | font-weight: bold; | ||
34 | } | ||
35 | |||
36 | a { | ||
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 @@ | |||
1 | const { ipcRenderer } = require('electron'); | ||
2 | const { claimDocumentFocus } = require('../helpers/webview-ime-focus-helpers'); | ||
3 | |||
4 | ipcRenderer.on('claim-document-focus', claimDocumentFocus); | ||
5 | |||
6 | window.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 | ||
2 | const { ipcRenderer } = require('electron'); | ||
3 | const fs = require('fs-extra'); | ||
4 | |||
5 | class 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 | |||
74 | module.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 @@ | |||
1 | const { ipcRenderer } = require('electron'); | ||
2 | const uuidV1 = require('uuid/v1'); | ||
3 | // const FranzNotificationStore = []; | ||
4 | |||
5 | class 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 | |||
24 | Notification.permission = 'granted'; | ||
25 | |||
26 | Notification.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 | |||
41 | Notification.close = () => { | ||
42 | // no implementation yet | ||
43 | }; | ||
44 | |||
45 | window.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 @@ | |||
1 | const { ipcRenderer } = require('electron'); | ||
2 | const path = require('path'); | ||
3 | |||
4 | const RecipeWebview = require('./lib/RecipeWebview'); | ||
5 | |||
6 | require('./notifications.js'); | ||
7 | require('./spellchecker.js'); | ||
8 | require('./ime.js'); | ||
9 | |||
10 | ipcRenderer.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 | |||
22 | document.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 @@ | |||
1 | import { SpellCheckHandler, ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker'; | ||
2 | |||
3 | window.spellCheckHandler = new SpellCheckHandler(); | ||
4 | setTimeout(() => { | ||
5 | window.spellCheckHandler.attachToInput(); | ||
6 | }, 1000); | ||
7 | |||
8 | // TODO: should we set the language to user settings? | ||
9 | // window.spellCheckHandler.switchLanguage('en-US'); | ||
10 | |||
11 | const contextMenuBuilder = new ContextMenuBuilder(window.spellCheckHandler); | ||
12 | const 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 @@ | |||
1 | const electron = require('electron'); | ||
2 | |||
3 | const { ipcRenderer, webFrame } = electron; | ||
4 | |||
5 | const maxZoomLevel = 9; | ||
6 | const minZoomLevel = -8; | ||
7 | let zoomLevel = 0; | ||
8 | |||
9 | ipcRenderer.on('zoomIn', () => { | ||
10 | if (maxZoomLevel > zoomLevel) { | ||
11 | zoomLevel += 1; | ||
12 | } | ||
13 | webFrame.setZoomLevel(zoomLevel); | ||
14 | |||
15 | ipcRenderer.sendToHost('zoomLevel', { zoom: zoomLevel }); | ||
16 | }); | ||
17 | |||
18 | ipcRenderer.on('zoomOut', () => { | ||
19 | if (minZoomLevel < zoomLevel) { | ||
20 | zoomLevel -= 1; | ||
21 | } | ||
22 | webFrame.setZoomLevel(zoomLevel); | ||
23 | |||
24 | ipcRenderer.sendToHost('zoomLevel', { zoom: zoomLevel }); | ||
25 | }); | ||
26 | |||
27 | ipcRenderer.on('zoomReset', () => { | ||
28 | zoomLevel = 0; | ||
29 | webFrame.setZoomLevel(zoomLevel); | ||
30 | |||
31 | ipcRenderer.sendToHost('zoomLevel', { zoom: zoomLevel }); | ||
32 | }); | ||
33 | |||
34 | ipcRenderer.on('setZoom', (e, arg) => { | ||
35 | zoomLevel = arg; | ||
36 | webFrame.setZoomLevel(zoomLevel); | ||
37 | }); | ||