aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorLibravatar vantezzen <properly@protonmail.com>2019-09-07 15:50:23 +0200
committerLibravatar vantezzen <properly@protonmail.com>2019-09-07 15:50:23 +0200
commite7a74514c1e7c3833dfdcf5900cb87f9e6e8354e (patch)
treeb8314e4155503b135dcb07e8b4a0e847e25c19cf /src/features
parentUpdate CHANGELOG.md (diff)
parentUpdate CHANGELOG.md (diff)
downloadferdium-app-e7a74514c1e7c3833dfdcf5900cb87f9e6e8354e.tar.gz
ferdium-app-e7a74514c1e7c3833dfdcf5900cb87f9e6e8354e.tar.zst
ferdium-app-e7a74514c1e7c3833dfdcf5900cb87f9e6e8354e.zip
Merge branch 'master' of https://github.com/meetfranz/franz into franz-5.3.0
Diffstat (limited to 'src/features')
-rw-r--r--src/features/announcements/store.js5
-rw-r--r--src/features/basicAuth/Component.js1
-rw-r--r--src/features/communityRecipes/index.js28
-rw-r--r--src/features/communityRecipes/store.js31
-rw-r--r--src/features/delayApp/Component.js41
-rw-r--r--src/features/delayApp/index.js10
-rw-r--r--src/features/serviceLimit/components/LimitReachedInfobox.js79
-rw-r--r--src/features/serviceLimit/index.js33
-rw-r--r--src/features/serviceLimit/store.js41
-rw-r--r--src/features/serviceProxy/index.js8
-rw-r--r--src/features/shareFranz/Component.js13
-rw-r--r--src/features/spellchecker/index.js8
-rw-r--r--src/features/todos/actions.js23
-rw-r--r--src/features/todos/components/TodosWebview.js300
-rw-r--r--src/features/todos/constants.js4
-rw-r--r--src/features/todos/containers/TodosScreen.js41
-rw-r--r--src/features/todos/index.js39
-rw-r--r--src/features/todos/preload.js23
-rw-r--r--src/features/todos/store.js213
-rw-r--r--src/features/workspaces/components/WorkspaceDrawer.js7
-rw-r--r--src/features/workspaces/components/WorkspaceSwitchingIndicator.js8
-rw-r--r--src/features/workspaces/components/WorkspacesDashboard.js173
-rw-r--r--src/features/workspaces/containers/WorkspacesScreen.js2
-rw-r--r--src/features/workspaces/store.js9
24 files changed, 1028 insertions, 112 deletions
diff --git a/src/features/announcements/store.js b/src/features/announcements/store.js
index 2884fb06f..91348029f 100644
--- a/src/features/announcements/store.js
+++ b/src/features/announcements/store.js
@@ -62,6 +62,11 @@ export class AnnouncementsStore extends FeatureStore {
62 return this.stores.settings.stats.appStarts <= 1; 62 return this.stores.settings.stats.appStarts <= 1;
63 } 63 }
64 64
65 @computed get isAnnouncementShown() {
66 const { router } = this.stores;
67 return router.location.pathname.includes('/announcements');
68 }
69
65 async start(stores, actions) { 70 async start(stores, actions) {
66 debug('AnnouncementsStore::start'); 71 debug('AnnouncementsStore::start');
67 this.stores = stores; 72 this.stores = stores;
diff --git a/src/features/basicAuth/Component.js b/src/features/basicAuth/Component.js
index a8252acb7..ba9ae2273 100644
--- a/src/features/basicAuth/Component.js
+++ b/src/features/basicAuth/Component.js
@@ -27,7 +27,6 @@ export default @injectSheet(styles) @observer class BasicAuthModal extends Compo
27 e.preventDefault(); 27 e.preventDefault();
28 28
29 const values = Form.values(); 29 const values = Form.values();
30 console.log('form submit', values);
31 30
32 sendCredentials(values.user, values.password); 31 sendCredentials(values.user, values.password);
33 resetState(); 32 resetState();
diff --git a/src/features/communityRecipes/index.js b/src/features/communityRecipes/index.js
new file mode 100644
index 000000000..4d050f90e
--- /dev/null
+++ b/src/features/communityRecipes/index.js
@@ -0,0 +1,28 @@
1import { reaction } from 'mobx';
2import { CommunityRecipesStore } from './store';
3
4const debug = require('debug')('Franz:feature:communityRecipes');
5
6export const DEFAULT_SERVICE_LIMIT = 3;
7
8export const communityRecipesStore = new CommunityRecipesStore();
9
10export default function initCommunityRecipes(stores, actions) {
11 const { features } = stores;
12
13 communityRecipesStore.start(stores, actions);
14
15 // Toggle communityRecipe premium status
16 reaction(
17 () => (
18 features.features.isCommunityRecipesIncludedInCurrentPlan
19 ),
20 (isPremiumFeature) => {
21 debug('Community recipes is premium feature: ', isPremiumFeature);
22 communityRecipesStore.isCommunityRecipesIncludedInCurrentPlan = isPremiumFeature;
23 },
24 {
25 fireImmediately: true,
26 },
27 );
28}
diff --git a/src/features/communityRecipes/store.js b/src/features/communityRecipes/store.js
new file mode 100644
index 000000000..4d45c3b33
--- /dev/null
+++ b/src/features/communityRecipes/store.js
@@ -0,0 +1,31 @@
1import { computed, observable } from 'mobx';
2import { FeatureStore } from '../utils/FeatureStore';
3
4const debug = require('debug')('Franz:feature:communityRecipes:store');
5
6export class CommunityRecipesStore extends FeatureStore {
7 @observable isCommunityRecipesIncludedInCurrentPlan = false;
8
9 start(stores, actions) {
10 debug('start');
11 this.stores = stores;
12 this.actions = actions;
13 }
14
15 stop() {
16 debug('stop');
17 super.stop();
18 }
19
20 @computed get communityRecipes() {
21 if (!this.stores) return [];
22
23 return this.stores.recipePreviews.dev.map((r) => {
24 r.isDevRecipe = !!r.author.find(a => a.email === this.stores.user.data.email);
25
26 return r;
27 });
28 }
29}
30
31export default CommunityRecipesStore;
diff --git a/src/features/delayApp/Component.js b/src/features/delayApp/Component.js
index ba50652e8..c61cb06c9 100644
--- a/src/features/delayApp/Component.js
+++ b/src/features/delayApp/Component.js
@@ -4,19 +4,28 @@ import { inject, observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl'; 4import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss'; 5import injectSheet from 'react-jss';
6 6
7import Button from '../../components/ui/Button'; 7import { Button } from '@meetfranz/forms';
8 8
9import { config } from '.'; 9import { config } from '.';
10import styles from './styles'; 10import styles from './styles';
11import UserStore from '../../stores/UserStore';
11 12
12const messages = defineMessages({ 13const messages = defineMessages({
13 headline: { 14 headline: {
14 id: 'feature.delayApp.headline', 15 id: 'feature.delayApp.headline',
15 defaultMessage: '!!!Please purchase license to skip waiting', 16 defaultMessage: '!!!Please purchase license to skip waiting',
16 }, 17 },
18 headlineTrial: {
19 id: 'feature.delayApp.trial.headline',
20 defaultMessage: '!!!Get the free Franz Professional 14 day trial and skip the line',
21 },
17 action: { 22 action: {
18 id: 'feature.delayApp.action', 23 id: 'feature.delayApp.upgrade.action',
19 defaultMessage: '!!!Get a Ferdi Supporter License', 24 defaultMessage: '!!!Get a Franz Supporter License',
25 },
26 actionTrial: {
27 id: 'feature.delayApp.trial.action',
28 defaultMessage: '!!!Yes, I want the free 14 day trial of Franz Professional',
20 }, 29 },
21 text: { 30 text: {
22 id: 'feature.delayApp.text', 31 id: 'feature.delayApp.text',
@@ -24,7 +33,7 @@ const messages = defineMessages({
24 }, 33 },
25}); 34});
26 35
27export default @inject('actions') @injectSheet(styles) @observer class DelayApp extends Component { 36export default @inject('stores', 'actions') @injectSheet(styles) @observer class DelayApp extends Component {
28 static propTypes = { 37 static propTypes = {
29 // eslint-disable-next-line 38 // eslint-disable-next-line
30 classes: PropTypes.object.isRequired, 39 classes: PropTypes.object.isRequired,
@@ -60,23 +69,32 @@ export default @inject('actions') @injectSheet(styles) @observer class DelayApp
60 } 69 }
61 70
62 handleCTAClick() { 71 handleCTAClick() {
63 const { actions } = this.props; 72 const { actions, stores } = this.props;
64 73 const { hadSubscription } = stores.user.data;
65 actions.ui.openSettings({ path: 'user' }); 74 const { defaultTrialPlan } = stores.features.features;
75
76 if (!hadSubscription) {
77 actions.user.activateTrial({ planId: defaultTrialPlan });
78 } else {
79 actions.ui.openSettings({ path: 'user' });
80 }
66 } 81 }
67 82
68 render() { 83 render() {
69 const { classes } = this.props; 84 const { classes, stores } = this.props;
70 const { intl } = this.context; 85 const { intl } = this.context;
71 86
87 const { hadSubscription } = stores.user.data;
88
72 return ( 89 return (
73 <div className={`${classes.container}`}> 90 <div className={`${classes.container}`}>
74 <h1 className={classes.headline}>{intl.formatMessage(messages.headline)}</h1> 91 <h1 className={classes.headline}>{intl.formatMessage(hadSubscription ? messages.headline : messages.headlineTrial)}</h1>
75 <Button 92 <Button
76 label={intl.formatMessage(messages.action)} 93 label={intl.formatMessage(hadSubscription ? messages.action : messages.actionTrial)}
77 className={classes.button} 94 className={classes.button}
78 buttonType="inverted" 95 buttonType="inverted"
79 onClick={this.handleCTAClick.bind(this)} 96 onClick={this.handleCTAClick.bind(this)}
97 busy={stores.user.activateTrialRequest.isExecuting}
80 /> 98 />
81 <p className="footnote"> 99 <p className="footnote">
82 {intl.formatMessage(messages.text, { 100 {intl.formatMessage(messages.text, {
@@ -89,6 +107,9 @@ export default @inject('actions') @injectSheet(styles) @observer class DelayApp
89} 107}
90 108
91DelayApp.wrappedComponent.propTypes = { 109DelayApp.wrappedComponent.propTypes = {
110 stores: PropTypes.shape({
111 user: PropTypes.instanceOf(UserStore).isRequired,
112 }).isRequired,
92 actions: PropTypes.shape({ 113 actions: PropTypes.shape({
93 ui: PropTypes.shape({ 114 ui: PropTypes.shape({
94 openSettings: PropTypes.func.isRequired, 115 openSettings: PropTypes.func.isRequired,
diff --git a/src/features/delayApp/index.js b/src/features/delayApp/index.js
index c753eeffe..4f793f16c 100644
--- a/src/features/delayApp/index.js
+++ b/src/features/delayApp/index.js
@@ -43,14 +43,16 @@ export default function init(stores) {
43 config.delayDuration = globalConfig.wait !== undefined ? globalConfig.wait : DEFAULT_FEATURES_CONFIG.needToWaitToProceedConfig.wait; 43 config.delayDuration = globalConfig.wait !== undefined ? globalConfig.wait : DEFAULT_FEATURES_CONFIG.needToWaitToProceedConfig.wait;
44 44
45 autorun(() => { 45 autorun(() => {
46 if (stores.services.all.length === 0) { 46 const { isAnnouncementShown } = stores.announcements;
47 debug('seas', stores.services.all.length); 47 if (stores.services.allDisplayed.length === 0 || isAnnouncementShown) {
48 shownAfterLaunch = true; 48 shownAfterLaunch = true;
49 setVisibility(false);
49 return; 50 return;
50 } 51 }
51 52
52 const diff = moment().diff(timeLastDelay); 53 const diff = moment().diff(timeLastDelay);
53 if ((stores.app.isFocused && diff >= config.delayOffset) || !shownAfterLaunch) { 54 const itsTimeToWait = diff >= config.delayOffset;
55 if (!isAnnouncementShown && ((stores.app.isFocused && itsTimeToWait) || !shownAfterLaunch)) {
54 debug(`App will be delayed for ${config.delayDuration / 1000}s`); 56 debug(`App will be delayed for ${config.delayDuration / 1000}s`);
55 57
56 setVisibility(true); 58 setVisibility(true);
@@ -63,6 +65,8 @@ export default function init(stores) {
63 65
64 setVisibility(false); 66 setVisibility(false);
65 }, config.delayDuration + 1000); // timer needs to be able to hit 0 67 }, config.delayDuration + 1000); // timer needs to be able to hit 0
68 } else {
69 setVisibility(false);
66 } 70 }
67 }); 71 });
68 } else { 72 } else {
diff --git a/src/features/serviceLimit/components/LimitReachedInfobox.js b/src/features/serviceLimit/components/LimitReachedInfobox.js
new file mode 100644
index 000000000..19285a4eb
--- /dev/null
+++ b/src/features/serviceLimit/components/LimitReachedInfobox.js
@@ -0,0 +1,79 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { inject, observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss';
6import { Infobox } from '@meetfranz/ui';
7
8import { gaEvent } from '../../../lib/analytics';
9
10const messages = defineMessages({
11 limitReached: {
12 id: 'feature.serviceLimit.limitReached',
13 defaultMessage: '!!!You have added {amount} of {limit} services. Please upgrade your account to add more services.',
14 },
15 action: {
16 id: 'premiumFeature.button.upgradeAccount',
17 defaultMessage: '!!!Upgrade account',
18 },
19});
20
21const styles = theme => ({
22 container: {
23 height: 'auto',
24 background: theme.styleTypes.warning.accent,
25 color: theme.styleTypes.warning.contrast,
26 borderRadius: 0,
27 marginBottom: 0,
28
29 '& > div': {
30 marginBottom: 0,
31 },
32
33 '& button': {
34 color: theme.styleTypes.primary.contrast,
35 },
36 },
37});
38
39
40@inject('stores', 'actions') @injectSheet(styles) @observer
41class LimitReachedInfobox extends Component {
42 static propTypes = {
43 classes: PropTypes.object.isRequired,
44 stores: PropTypes.object.isRequired,
45 actions: PropTypes.object.isRequired,
46 };
47
48 static contextTypes = {
49 intl: intlShape,
50 };
51
52 render() {
53 const { classes, stores, actions } = this.props;
54 const { intl } = this.context;
55
56 const {
57 serviceLimit,
58 } = stores;
59
60 if (!serviceLimit.userHasReachedServiceLimit) return null;
61
62 return (
63 <Infobox
64 icon="mdiInformation"
65 type="warning"
66 className={classes.container}
67 ctaLabel={intl.formatMessage(messages.action)}
68 ctaOnClick={() => {
69 actions.ui.openSettings({ path: 'user' });
70 gaEvent('Service Limit', 'upgrade', 'Upgrade account');
71 }}
72 >
73 {intl.formatMessage(messages.limitReached, { amount: serviceLimit.serviceCount, limit: serviceLimit.serviceLimit })}
74 </Infobox>
75 );
76 }
77}
78
79export default LimitReachedInfobox;
diff --git a/src/features/serviceLimit/index.js b/src/features/serviceLimit/index.js
new file mode 100644
index 000000000..92ad8bb98
--- /dev/null
+++ b/src/features/serviceLimit/index.js
@@ -0,0 +1,33 @@
1import { reaction } from 'mobx';
2import { ServiceLimitStore } from './store';
3
4const debug = require('debug')('Franz:feature:serviceLimit');
5
6export const DEFAULT_SERVICE_LIMIT = 3;
7
8let store = null;
9
10export const serviceLimitStore = new ServiceLimitStore();
11
12export default function initServiceLimit(stores, actions) {
13 const { features } = stores;
14
15 // Toggle serviceLimit feature
16 reaction(
17 () => (
18 features.features.isServiceLimitEnabled
19 ),
20 (isEnabled) => {
21 if (isEnabled) {
22 debug('Initializing `serviceLimit` feature');
23 store = serviceLimitStore.start(stores, actions);
24 } else if (store) {
25 debug('Disabling `serviceLimit` feature');
26 serviceLimitStore.stop();
27 }
28 },
29 {
30 fireImmediately: true,
31 },
32 );
33}
diff --git a/src/features/serviceLimit/store.js b/src/features/serviceLimit/store.js
new file mode 100644
index 000000000..9836c5f51
--- /dev/null
+++ b/src/features/serviceLimit/store.js
@@ -0,0 +1,41 @@
1import { computed, observable } from 'mobx';
2import { FeatureStore } from '../utils/FeatureStore';
3import { DEFAULT_SERVICE_LIMIT } from '.';
4
5const debug = require('debug')('Franz:feature:serviceLimit:store');
6
7export class ServiceLimitStore extends FeatureStore {
8 @observable isServiceLimitEnabled = false;
9
10 start(stores, actions) {
11 debug('start');
12 this.stores = stores;
13 this.actions = actions;
14
15 this.isServiceLimitEnabled = true;
16 }
17
18 stop() {
19 super.stop();
20
21 this.isServiceLimitEnabled = false;
22 }
23
24 @computed get userHasReachedServiceLimit() {
25 if (!this.isServiceLimitEnabled) return false;
26
27 return this.serviceLimit !== 0 && this.serviceCount >= this.serviceLimit;
28 }
29
30 @computed get serviceLimit() {
31 if (!this.isServiceLimitEnabled || this.stores.features.features.serviceLimitCount === 0) return 0;
32
33 return this.stores.features.features.serviceLimitCount || DEFAULT_SERVICE_LIMIT;
34 }
35
36 @computed get serviceCount() {
37 return this.stores.services.all.length;
38 }
39}
40
41export default ServiceLimitStore;
diff --git a/src/features/serviceProxy/index.js b/src/features/serviceProxy/index.js
index 4bea327ad..55c600de4 100644
--- a/src/features/serviceProxy/index.js
+++ b/src/features/serviceProxy/index.js
@@ -9,17 +9,17 @@ const debug = require('debug')('Franz:feature:serviceProxy');
9 9
10export const config = observable({ 10export const config = observable({
11 isEnabled: DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled, 11 isEnabled: DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled,
12 isPremium: DEFAULT_FEATURES_CONFIG.isServiceProxyPremiumFeature, 12 isPremium: DEFAULT_FEATURES_CONFIG.isServiceProxyIncludedInCurrentPlan,
13}); 13});
14 14
15export default function init(stores) { 15export default function init(stores) {
16 debug('Initializing `serviceProxy` feature'); 16 debug('Initializing `serviceProxy` feature');
17 17
18 autorun(() => { 18 autorun(() => {
19 const { isServiceProxyEnabled, isServiceProxyPremiumFeature } = stores.features.features; 19 const { isServiceProxyEnabled, isServiceProxyIncludedInCurrentPlan } = stores.features.features;
20 20
21 config.isEnabled = isServiceProxyEnabled !== undefined ? isServiceProxyEnabled : DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled; 21 config.isEnabled = isServiceProxyEnabled !== undefined ? isServiceProxyEnabled : DEFAULT_FEATURES_CONFIG.isServiceProxyEnabled;
22 config.isPremium = isServiceProxyPremiumFeature !== undefined ? isServiceProxyPremiumFeature : DEFAULT_FEATURES_CONFIG.isServiceProxyPremiumFeature; 22 config.isIncludedInCurrentPlan = isServiceProxyIncludedInCurrentPlan !== undefined ? isServiceProxyIncludedInCurrentPlan : DEFAULT_FEATURES_CONFIG.isServiceProxyIncludedInCurrentPlan;
23 23
24 const services = stores.services.enabled; 24 const services = stores.services.enabled;
25 const isPremiumUser = stores.user.data.isPremium; 25 const isPremiumUser = stores.user.data.isPremium;
@@ -30,7 +30,7 @@ export default function init(stores) {
30 services.forEach((service) => { 30 services.forEach((service) => {
31 const s = session.fromPartition(`persist:service-${service.id}`); 31 const s = session.fromPartition(`persist:service-${service.id}`);
32 32
33 if (config.isEnabled && (isPremiumUser || !config.isPremium)) { 33 if (config.isEnabled && (isPremiumUser || !config.isIncludedInCurrentPlan)) {
34 const serviceProxyConfig = proxySettings[service.id]; 34 const serviceProxyConfig = proxySettings[service.id];
35 35
36 if (serviceProxyConfig && serviceProxyConfig.isEnabled && serviceProxyConfig.host) { 36 if (serviceProxyConfig && serviceProxyConfig.isEnabled && serviceProxyConfig.host) {
diff --git a/src/features/shareFranz/Component.js b/src/features/shareFranz/Component.js
index 2e66acaf3..405fb0ab5 100644
--- a/src/features/shareFranz/Component.js
+++ b/src/features/shareFranz/Component.js
@@ -6,6 +6,9 @@ import { defineMessages, intlShape } from 'react-intl';
6import { Button } from '@meetfranz/forms'; 6import { Button } from '@meetfranz/forms';
7import { H1, Icon } from '@meetfranz/ui'; 7import { H1, Icon } from '@meetfranz/ui';
8 8
9import {
10 mdiHeart, mdiEmail, mdiFacebookBox, mdiTwitter,
11} from '@mdi/js';
9import Modal from '../../components/ui/Modal'; 12import Modal from '../../components/ui/Modal';
10import { state } from '.'; 13import { state } from '.';
11import ServicesStore from '../../stores/ServicesStore'; 14import ServicesStore from '../../stores/ServicesStore';
@@ -74,7 +77,7 @@ const styles = theme => ({
74 }, 77 },
75 cta: { 78 cta: {
76 background: theme.styleTypes.primary.contrast, 79 background: theme.styleTypes.primary.contrast,
77 color: theme.styleTypes.primary.accent, 80 color: `${theme.styleTypes.primary.accent} !important`,
78 81
79 '& svg': { 82 '& svg': {
80 fill: theme.styleTypes.primary.accent, 83 fill: theme.styleTypes.primary.accent,
@@ -115,7 +118,7 @@ export default @injectSheet(styles) @inject('stores') @observer class ShareFranz
115 close={this.close.bind(this)} 118 close={this.close.bind(this)}
116 > 119 >
117 <div className={classes.heartContainer}> 120 <div className={classes.heartContainer}>
118 <Icon icon="mdiHeart" className={classes.heart} size={4} /> 121 <Icon icon={mdiHeart} className={classes.heart} size={4} />
119 </div> 122 </div>
120 <H1 className={classes.headline}> 123 <H1 className={classes.headline}>
121 {intl.formatMessage(messages.headline)} 124 {intl.formatMessage(messages.headline)}
@@ -125,21 +128,21 @@ export default @injectSheet(styles) @inject('stores') @observer class ShareFranz
125 <Button 128 <Button
126 label={intl.formatMessage(messages.actionsEmail)} 129 label={intl.formatMessage(messages.actionsEmail)}
127 className={classes.cta} 130 className={classes.cta}
128 icon="mdiEmail" 131 icon={mdiEmail}
129 href={`mailto:?subject=Meet the cool app Franz&body=${intl.formatMessage(messages.shareTextEmail, { count: serviceCount })}}`} 132 href={`mailto:?subject=Meet the cool app Franz&body=${intl.formatMessage(messages.shareTextEmail, { count: serviceCount })}}`}
130 target="_blank" 133 target="_blank"
131 /> 134 />
132 <Button 135 <Button
133 label={intl.formatMessage(messages.actionsFacebook)} 136 label={intl.formatMessage(messages.actionsFacebook)}
134 className={classes.cta} 137 className={classes.cta}
135 icon="mdiFacebookBox" 138 icon={mdiFacebookBox}
136 href="https://www.facebook.com/sharer/sharer.php?u=https://www.meetfranz.com?utm_source=facebook&utm_medium=referral&utm_campaign=share-button" 139 href="https://www.facebook.com/sharer/sharer.php?u=https://www.meetfranz.com?utm_source=facebook&utm_medium=referral&utm_campaign=share-button"
137 target="_blank" 140 target="_blank"
138 /> 141 />
139 <Button 142 <Button
140 label={intl.formatMessage(messages.actionsTwitter)} 143 label={intl.formatMessage(messages.actionsTwitter)}
141 className={classes.cta} 144 className={classes.cta}
142 icon="mdiTwitter" 145 icon={mdiTwitter}
143 href={`http://twitter.com/intent/tweet?status=${intl.formatMessage(messages.shareTextTwitter, { count: serviceCount })}`} 146 href={`http://twitter.com/intent/tweet?status=${intl.formatMessage(messages.shareTextTwitter, { count: serviceCount })}`}
144 target="_blank" 147 target="_blank"
145 /> 148 />
diff --git a/src/features/spellchecker/index.js b/src/features/spellchecker/index.js
index 79a2172b4..a07f9f63a 100644
--- a/src/features/spellchecker/index.js
+++ b/src/features/spellchecker/index.js
@@ -5,18 +5,18 @@ import { DEFAULT_FEATURES_CONFIG } from '../../config';
5const debug = require('debug')('Franz:feature:spellchecker'); 5const debug = require('debug')('Franz:feature:spellchecker');
6 6
7export const config = observable({ 7export const config = observable({
8 isPremium: DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature, 8 isIncludedInCurrentPlan: DEFAULT_FEATURES_CONFIG.isSpellcheckerIncludedInCurrentPlan,
9}); 9});
10 10
11export default function init(stores) { 11export default function init(stores) {
12 debug('Initializing `spellchecker` feature'); 12 debug('Initializing `spellchecker` feature');
13 13
14 autorun(() => { 14 autorun(() => {
15 const { isSpellcheckerPremiumFeature } = stores.features.features; 15 const { isSpellcheckerIncludedInCurrentPlan } = stores.features.features;
16 16
17 config.isPremium = isSpellcheckerPremiumFeature !== undefined ? isSpellcheckerPremiumFeature : DEFAULT_FEATURES_CONFIG.isSpellcheckerPremiumFeature; 17 config.isIncludedInCurrentPlan = isSpellcheckerIncludedInCurrentPlan !== undefined ? isSpellcheckerIncludedInCurrentPlan : DEFAULT_FEATURES_CONFIG.isSpellcheckerIncludedInCurrentPlan;
18 18
19 if (!stores.user.data.isPremium && config.isPremium && stores.settings.app.enableSpellchecking) { 19 if (!stores.user.data.isPremium && config.isIncludedInCurrentPlan && stores.settings.app.enableSpellchecking) {
20 debug('Override settings.spellcheckerEnabled flag to false'); 20 debug('Override settings.spellcheckerEnabled flag to false');
21 21
22 Object.assign(stores.settings.app, { 22 Object.assign(stores.settings.app, {
diff --git a/src/features/todos/actions.js b/src/features/todos/actions.js
new file mode 100644
index 000000000..1ccc9a592
--- /dev/null
+++ b/src/features/todos/actions.js
@@ -0,0 +1,23 @@
1import PropTypes from 'prop-types';
2import { createActionsFromDefinitions } from '../../actions/lib/actions';
3
4export const todoActions = createActionsFromDefinitions({
5 resize: {
6 width: PropTypes.number.isRequired,
7 },
8 toggleTodosPanel: {},
9 toggleTodosFeatureVisibility: {},
10 setTodosWebview: {
11 webview: PropTypes.instanceOf(Element).isRequired,
12 },
13 handleHostMessage: {
14 action: PropTypes.string.isRequired,
15 data: PropTypes.object,
16 },
17 handleClientMessage: {
18 action: PropTypes.string.isRequired,
19 data: PropTypes.object,
20 },
21}, PropTypes.checkPropTypes);
22
23export default todoActions;
diff --git a/src/features/todos/components/TodosWebview.js b/src/features/todos/components/TodosWebview.js
new file mode 100644
index 000000000..c06183e37
--- /dev/null
+++ b/src/features/todos/components/TodosWebview.js
@@ -0,0 +1,300 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import injectSheet from 'react-jss';
5import Webview from 'react-electron-web-view';
6import { Icon } from '@meetfranz/ui';
7import { defineMessages, intlShape } from 'react-intl';
8
9import { mdiChevronRight, mdiCheckAll } from '@mdi/js';
10import * as environment from '../../../environment';
11import Appear from '../../../components/ui/effects/Appear';
12import UpgradeButton from '../../../components/ui/UpgradeButton';
13
14const OPEN_TODOS_BUTTON_SIZE = 45;
15const CLOSE_TODOS_BUTTON_SIZE = 35;
16
17const messages = defineMessages({
18 premiumInfo: {
19 id: 'feature.todos.premium.info',
20 defaultMessage: '!!!Franz Todos are available to premium users now!',
21 },
22 upgradeCTA: {
23 id: 'feature.todos.premium.upgrade',
24 defaultMessage: '!!!Upgrade Account',
25 },
26 rolloutInfo: {
27 id: 'feature.todos.premium.rollout',
28 defaultMessage: '!!!Everyone else will have to wait a little longer.',
29 },
30});
31
32const styles = theme => ({
33 root: {
34 background: theme.colorBackground,
35 position: 'relative',
36 borderLeft: [1, 'solid', theme.todos.todosLayer.borderLeftColor],
37 zIndex: 300,
38
39 transform: ({ isVisible, width }) => `translateX(${isVisible ? 0 : width}px)`,
40
41 '&:hover $closeTodosButton': {
42 opacity: 1,
43 },
44 '& webview': {
45 height: '100%',
46 },
47 },
48 resizeHandler: {
49 position: 'absolute',
50 left: 0,
51 marginLeft: -5,
52 width: 10,
53 zIndex: 400,
54 cursor: 'col-resize',
55 },
56 dragIndicator: {
57 position: 'absolute',
58 left: 0,
59 width: 5,
60 zIndex: 400,
61 background: theme.todos.dragIndicator.background,
62
63 },
64 openTodosButton: {
65 width: OPEN_TODOS_BUTTON_SIZE,
66 height: OPEN_TODOS_BUTTON_SIZE,
67 background: theme.todos.toggleButton.background,
68 position: 'absolute',
69 bottom: 120,
70 right: props => (props.width + (props.isVisible ? -OPEN_TODOS_BUTTON_SIZE / 2 : 0)),
71 borderRadius: OPEN_TODOS_BUTTON_SIZE / 2,
72 opacity: props => (props.isVisible ? 0 : 1),
73 transition: 'right 0.5s',
74 zIndex: 600,
75 display: 'flex',
76 alignItems: 'center',
77 justifyContent: 'center',
78 boxShadow: [0, 0, 10, theme.todos.toggleButton.shadowColor],
79
80 borderTopRightRadius: props => (props.isVisible ? null : 0),
81 borderBottomRightRadius: props => (props.isVisible ? null : 0),
82
83 '& svg': {
84 fill: theme.todos.toggleButton.textColor,
85 transition: 'all 0.5s',
86 },
87 },
88 closeTodosButton: {
89 width: CLOSE_TODOS_BUTTON_SIZE,
90 height: CLOSE_TODOS_BUTTON_SIZE,
91 background: theme.todos.toggleButton.background,
92 position: 'absolute',
93 bottom: 120,
94 right: ({ width }) => (width + -CLOSE_TODOS_BUTTON_SIZE / 2),
95 borderRadius: CLOSE_TODOS_BUTTON_SIZE / 2,
96 opacity: ({ isTodosIncludedInCurrentPlan }) => (!isTodosIncludedInCurrentPlan ? 1 : 0),
97 transition: 'opacity 0.5s',
98 zIndex: 600,
99 display: 'flex',
100 alignItems: 'center',
101 justifyContent: 'center',
102 boxShadow: [0, 0, 10, theme.todos.toggleButton.shadowColor],
103
104 '& svg': {
105 fill: theme.todos.toggleButton.textColor,
106 },
107 },
108 premiumContainer: {
109 display: 'flex',
110 flexDirection: 'column',
111 justifyContent: 'center',
112 alignItems: 'center',
113 width: '80%',
114 maxWidth: 300,
115 margin: [0, 'auto'],
116 textAlign: 'center',
117 },
118 premiumIcon: {
119 marginBottom: 40,
120 background: theme.styleTypes.primary.accent,
121 fill: theme.styleTypes.primary.contrast,
122 padding: 10,
123 borderRadius: 10,
124 },
125 premiumCTA: {
126 marginTop: 40,
127 },
128});
129
130@injectSheet(styles) @observer
131class TodosWebview extends Component {
132 static propTypes = {
133 classes: PropTypes.object.isRequired,
134 isVisible: PropTypes.bool.isRequired,
135 togglePanel: PropTypes.func.isRequired,
136 handleClientMessage: PropTypes.func.isRequired,
137 setTodosWebview: PropTypes.func.isRequired,
138 resize: PropTypes.func.isRequired,
139 width: PropTypes.number.isRequired,
140 minWidth: PropTypes.number.isRequired,
141 isTodosIncludedInCurrentPlan: PropTypes.bool.isRequired,
142 };
143
144 state = {
145 isDragging: false,
146 width: 300,
147 };
148
149 static contextTypes = {
150 intl: intlShape,
151 };
152
153 componentWillMount() {
154 const { width } = this.props;
155
156 this.setState({
157 width,
158 });
159 }
160
161 componentDidMount() {
162 this.node.addEventListener('mousemove', this.resizePanel.bind(this));
163 this.node.addEventListener('mouseup', this.stopResize.bind(this));
164 this.node.addEventListener('mouseleave', this.stopResize.bind(this));
165 }
166
167 startResize = (event) => {
168 this.setState({
169 isDragging: true,
170 initialPos: event.clientX,
171 delta: 0,
172 });
173 };
174
175 resizePanel(e) {
176 const { minWidth } = this.props;
177
178 const {
179 isDragging,
180 initialPos,
181 } = this.state;
182
183 if (isDragging && Math.abs(e.clientX - window.innerWidth) > minWidth) {
184 const delta = e.clientX - initialPos;
185
186 this.setState({
187 delta,
188 });
189 }
190 }
191
192 stopResize() {
193 const {
194 resize,
195 minWidth,
196 } = this.props;
197
198 const {
199 isDragging,
200 delta,
201 width,
202 } = this.state;
203
204 if (isDragging) {
205 let newWidth = width + (delta < 0 ? Math.abs(delta) : -Math.abs(delta));
206
207 if (newWidth < minWidth) {
208 newWidth = minWidth;
209 }
210
211 this.setState({
212 isDragging: false,
213 delta: 0,
214 width: newWidth,
215 });
216
217 resize(newWidth);
218 }
219 }
220
221 startListeningToIpcMessages() {
222 const { handleClientMessage } = this.props;
223 if (!this.webview) return;
224 this.webview.addEventListener('ipc-message', e => handleClientMessage(e.args[0]));
225 }
226
227 render() {
228 const {
229 classes,
230 isVisible,
231 togglePanel,
232 isTodosIncludedInCurrentPlan,
233 } = this.props;
234
235 const {
236 width,
237 delta,
238 isDragging,
239 } = this.state;
240
241 const { intl } = this.context;
242
243 return (
244 <div
245 className={classes.root}
246 style={{ width: isVisible ? width : 0 }}
247 onMouseUp={() => this.stopResize()}
248 ref={(node) => { this.node = node; }}
249 >
250 <button
251 onClick={() => togglePanel()}
252 className={isVisible ? classes.closeTodosButton : classes.openTodosButton}
253 type="button"
254 >
255 <Icon icon={isVisible ? mdiChevronRight : mdiCheckAll} size={2} />
256 </button>
257 <div
258 className={classes.resizeHandler}
259 style={Object.assign({ left: delta }, isDragging ? { width: 600, marginLeft: -200 } : {})} // This hack is required as resizing with webviews beneath behaves quite bad
260 onMouseDown={e => this.startResize(e)}
261 />
262 {isDragging && (
263 <div
264 className={classes.dragIndicator}
265 style={{ left: delta }} // This hack is required as resizing with webviews beneath behaves quite bad
266 />
267 )}
268 {isTodosIncludedInCurrentPlan ? (
269 <Webview
270 className={classes.webview}
271 onDidAttach={() => {
272 const { setTodosWebview } = this.props;
273 setTodosWebview(this.webview);
274 this.startListeningToIpcMessages();
275 }}
276 partition="persist:todos"
277 preload="./features/todos/preload.js"
278 ref={(webview) => { this.webview = webview ? webview.view : null; }}
279 src={environment.TODOS_FRONTEND}
280 />
281 ) : (
282 <Appear>
283 <div className={classes.premiumContainer}>
284 <Icon icon={mdiCheckAll} className={classes.premiumIcon} size={4} />
285 <p>{intl.formatMessage(messages.premiumInfo)}</p>
286 <p>{intl.formatMessage(messages.rolloutInfo)}</p>
287 <UpgradeButton
288 className={classes.premiumCTA}
289 gaEventInfo={{ category: 'Todos', event: 'upgrade' }}
290 short
291 />
292 </div>
293 </Appear>
294 )}
295 </div>
296 );
297 }
298}
299
300export default TodosWebview;
diff --git a/src/features/todos/constants.js b/src/features/todos/constants.js
new file mode 100644
index 000000000..2e8a431cc
--- /dev/null
+++ b/src/features/todos/constants.js
@@ -0,0 +1,4 @@
1export const IPC = {
2 TODOS_HOST_CHANNEL: 'TODOS_HOST_CHANNEL',
3 TODOS_CLIENT_CHANNEL: 'TODOS_CLIENT_CHANNEL',
4};
diff --git a/src/features/todos/containers/TodosScreen.js b/src/features/todos/containers/TodosScreen.js
new file mode 100644
index 000000000..a5da0b014
--- /dev/null
+++ b/src/features/todos/containers/TodosScreen.js
@@ -0,0 +1,41 @@
1import React, { Component } from 'react';
2import { observer, inject } from 'mobx-react';
3import PropTypes from 'prop-types';
4
5import FeaturesStore from '../../../stores/FeaturesStore';
6import TodosWebview from '../components/TodosWebview';
7import ErrorBoundary from '../../../components/util/ErrorBoundary';
8import { TODOS_MIN_WIDTH, todosStore } from '..';
9import { todoActions } from '../actions';
10
11@inject('stores', 'actions') @observer
12class TodosScreen extends Component {
13 render() {
14 if (!todosStore || !todosStore.isFeatureActive || todosStore.isTodosPanelForceHidden) {
15 return null;
16 }
17
18 return (
19 <ErrorBoundary>
20 <TodosWebview
21 isVisible={todosStore.isTodosPanelVisible}
22 togglePanel={todoActions.toggleTodosPanel}
23 handleClientMessage={todoActions.handleClientMessage}
24 setTodosWebview={webview => todoActions.setTodosWebview({ webview })}
25 width={todosStore.width}
26 minWidth={TODOS_MIN_WIDTH}
27 resize={width => todoActions.resize({ width })}
28 isTodosIncludedInCurrentPlan={this.props.stores.features.features.isTodosIncludedInCurrentPlan || false}
29 />
30 </ErrorBoundary>
31 );
32 }
33}
34
35export default TodosScreen;
36
37TodosScreen.wrappedComponent.propTypes = {
38 stores: PropTypes.shape({
39 features: PropTypes.instanceOf(FeaturesStore).isRequired,
40 }).isRequired,
41};
diff --git a/src/features/todos/index.js b/src/features/todos/index.js
new file mode 100644
index 000000000..7388aebaf
--- /dev/null
+++ b/src/features/todos/index.js
@@ -0,0 +1,39 @@
1import { reaction } from 'mobx';
2import TodoStore from './store';
3
4const debug = require('debug')('Franz:feature:todos');
5
6export const GA_CATEGORY_TODOS = 'Todos';
7
8export const DEFAULT_TODOS_WIDTH = 300;
9export const TODOS_MIN_WIDTH = 200;
10export const DEFAULT_TODOS_VISIBLE = true;
11export const DEFAULT_IS_FEATURE_ENABLED_BY_USER = true;
12
13export const TODOS_ROUTES = {
14 TARGET: '/todos',
15};
16
17export const todosStore = new TodoStore();
18
19export default function initTodos(stores, actions) {
20 stores.todos = todosStore;
21 const { features } = stores;
22
23 // Toggle todos feature
24 reaction(
25 () => features.features.isTodosEnabled,
26 (isEnabled) => {
27 if (isEnabled) {
28 debug('Initializing `todos` feature');
29 todosStore.start(stores, actions);
30 } else if (todosStore.isFeatureActive) {
31 debug('Disabling `todos` feature');
32 todosStore.stop();
33 }
34 },
35 {
36 fireImmediately: true,
37 },
38 );
39}
diff --git a/src/features/todos/preload.js b/src/features/todos/preload.js
new file mode 100644
index 000000000..6e38a2ef3
--- /dev/null
+++ b/src/features/todos/preload.js
@@ -0,0 +1,23 @@
1import { ipcRenderer } from 'electron';
2import { IPC } from './constants';
3
4const debug = require('debug')('Franz:feature:todos:preload');
5
6debug('Preloading Todos Webview');
7
8let hostMessageListener = () => {};
9
10window.franz = {
11 onInitialize(ipcHostMessageListener) {
12 hostMessageListener = ipcHostMessageListener;
13 ipcRenderer.sendToHost(IPC.TODOS_CLIENT_CHANNEL, { action: 'todos:initialized' });
14 },
15 sendToHost(message) {
16 ipcRenderer.sendToHost(IPC.TODOS_CLIENT_CHANNEL, message);
17 },
18};
19
20ipcRenderer.on(IPC.TODOS_HOST_CHANNEL, (event, message) => {
21 debug('Received host message', event, message);
22 hostMessageListener(message);
23});
diff --git a/src/features/todos/store.js b/src/features/todos/store.js
new file mode 100644
index 000000000..05eef4ec1
--- /dev/null
+++ b/src/features/todos/store.js
@@ -0,0 +1,213 @@
1import { ThemeType } from '@meetfranz/theme';
2import {
3 computed,
4 action,
5 observable,
6} from 'mobx';
7import localStorage from 'mobx-localstorage';
8
9import { todoActions } from './actions';
10import { FeatureStore } from '../utils/FeatureStore';
11import { createReactions } from '../../stores/lib/Reaction';
12import { createActionBindings } from '../utils/ActionBinding';
13import {
14 DEFAULT_TODOS_WIDTH, TODOS_MIN_WIDTH, DEFAULT_TODOS_VISIBLE, TODOS_ROUTES, DEFAULT_IS_FEATURE_ENABLED_BY_USER,
15} from '.';
16import { IPC } from './constants';
17import { state as delayAppState } from '../delayApp';
18
19const debug = require('debug')('Franz:feature:todos:store');
20
21export default class TodoStore extends FeatureStore {
22 @observable isFeatureEnabled = false;
23
24 @observable isFeatureActive = false;
25
26 webview = null;
27
28 @computed get width() {
29 const width = this.settings.width || DEFAULT_TODOS_WIDTH;
30
31 return width < TODOS_MIN_WIDTH ? TODOS_MIN_WIDTH : width;
32 }
33
34 @computed get isTodosPanelForceHidden() {
35 const { isAnnouncementShown } = this.stores.announcements;
36 return delayAppState.isDelayAppScreenVisible || !this.settings.isFeatureEnabledByUser || isAnnouncementShown;
37 }
38
39 @computed get isTodosPanelVisible() {
40 if (this.settings.isTodosPanelVisible === undefined) return DEFAULT_TODOS_VISIBLE;
41 return this.settings.isTodosPanelVisible;
42 }
43
44 @computed get settings() {
45 return localStorage.getItem('todos') || {};
46 }
47
48 // ========== PUBLIC API ========= //
49
50 @action start(stores, actions) {
51 debug('TodoStore::start');
52 this.stores = stores;
53 this.actions = actions;
54
55 // ACTIONS
56
57 this._registerActions(createActionBindings([
58 [todoActions.resize, this._resize],
59 [todoActions.toggleTodosPanel, this._toggleTodosPanel],
60 [todoActions.setTodosWebview, this._setTodosWebview],
61 [todoActions.handleHostMessage, this._handleHostMessage],
62 [todoActions.handleClientMessage, this._handleClientMessage],
63 [todoActions.toggleTodosFeatureVisibility, this._toggleTodosFeatureVisibility],
64 ]));
65
66 // REACTIONS
67
68 this._allReactions = createReactions([
69 this._setFeatureEnabledReaction,
70 this._updateTodosConfig,
71 this._firstLaunchReaction,
72 this._routeCheckReaction,
73 ]);
74
75 this._registerReactions(this._allReactions);
76
77 this.isFeatureActive = true;
78
79 if (this.settings.isFeatureEnabledByUser === undefined) {
80 this._updateSettings({
81 isFeatureEnabledByUser: DEFAULT_IS_FEATURE_ENABLED_BY_USER,
82 });
83 }
84 }
85
86 @action stop() {
87 super.stop();
88 debug('TodoStore::stop');
89 this.reset();
90 this.isFeatureActive = false;
91 }
92
93 // ========== PRIVATE METHODS ========= //
94
95 _updateSettings = (changes) => {
96 localStorage.setItem('todos', {
97 ...this.settings,
98 ...changes,
99 });
100 };
101
102 // Actions
103
104 @action _resize = ({ width }) => {
105 this._updateSettings({
106 width,
107 });
108 };
109
110 @action _toggleTodosPanel = () => {
111 this._updateSettings({
112 isTodosPanelVisible: !this.isTodosPanelVisible,
113 });
114 };
115
116 @action _setTodosWebview = ({ webview }) => {
117 debug('_setTodosWebview', webview);
118 this.webview = webview;
119 };
120
121 @action _handleHostMessage = (message) => {
122 debug('_handleHostMessage', message);
123 if (message.action === 'todos:create') {
124 this.webview.send(IPC.TODOS_HOST_CHANNEL, message);
125 }
126 };
127
128 @action _handleClientMessage = (message) => {
129 debug('_handleClientMessage', message);
130 switch (message.action) {
131 case 'todos:initialized': this._onTodosClientInitialized(); break;
132 case 'todos:goToService': this._goToService(message.data); break;
133 default:
134 debug('Unknown client message reiceived', message);
135 }
136 };
137
138 @action _toggleTodosFeatureVisibility = () => {
139 debug('_toggleTodosFeatureVisibility');
140
141 this._updateSettings({
142 isFeatureEnabledByUser: !this.settings.isFeatureEnabledByUser,
143 });
144 };
145
146 // Todos client message handlers
147
148 _onTodosClientInitialized = () => {
149 const { authToken } = this.stores.user;
150 const { isDarkThemeActive } = this.stores.ui;
151 const { locale } = this.stores.app;
152 if (!this.webview) return;
153 this.webview.send(IPC.TODOS_HOST_CHANNEL, {
154 action: 'todos:configure',
155 data: {
156 authToken,
157 locale,
158 theme: isDarkThemeActive ? ThemeType.dark : ThemeType.default,
159 },
160 });
161 };
162
163 _goToService = ({ url, serviceId }) => {
164 if (url) {
165 this.stores.services.one(serviceId).webview.loadURL(url);
166 }
167 this.actions.service.setActive({ serviceId });
168 };
169
170 // Reactions
171
172 _setFeatureEnabledReaction = () => {
173 const { isTodosEnabled } = this.stores.features.features;
174
175 this.isFeatureEnabled = isTodosEnabled;
176 };
177
178 _updateTodosConfig = () => {
179 // Resend the config if any part changes in Franz:
180 this._onTodosClientInitialized();
181 };
182
183 _firstLaunchReaction = () => {
184 const { stats } = this.stores.settings.all;
185
186 // Hide todos layer on first app start but show on second
187 if (stats.appStarts <= 1) {
188 this._updateSettings({
189 isTodosPanelVisible: false,
190 });
191 } else if (stats.appStarts <= 2) {
192 this._updateSettings({
193 isTodosPanelVisible: true,
194 });
195 }
196 };
197
198 _routeCheckReaction = () => {
199 const { pathname } = this.stores.router.location;
200
201 if (pathname === TODOS_ROUTES.TARGET) {
202 debug('Router is on todos route, show todos panel');
203 // todosStore.start(stores, actions);
204 this.stores.router.push('/');
205
206 if (!this.isTodosPanelVisible) {
207 this._updateSettings({
208 isTodosPanelVisible: true,
209 });
210 }
211 }
212 }
213}
diff --git a/src/features/workspaces/components/WorkspaceDrawer.js b/src/features/workspaces/components/WorkspaceDrawer.js
index cbc7372ca..f4ee89a14 100644
--- a/src/features/workspaces/components/WorkspaceDrawer.js
+++ b/src/features/workspaces/components/WorkspaceDrawer.js
@@ -7,6 +7,7 @@ import { H1, Icon, ProBadge } from '@meetfranz/ui';
7import { Button } from '@meetfranz/forms/lib'; 7import { Button } from '@meetfranz/forms/lib';
8import ReactTooltip from 'react-tooltip'; 8import ReactTooltip from 'react-tooltip';
9 9
10import { mdiPlusBox, mdiSettings } from '@mdi/js';
10import WorkspaceDrawerItem from './WorkspaceDrawerItem'; 11import WorkspaceDrawerItem from './WorkspaceDrawerItem';
11import { workspaceActions } from '../actions'; 12import { workspaceActions } from '../actions';
12import { workspaceStore } from '../index'; 13import { workspaceStore } from '../index';
@@ -157,7 +158,7 @@ class WorkspaceDrawer extends Component {
157 data-tip={`${intl.formatMessage(messages.workspacesSettingsTooltip)}`} 158 data-tip={`${intl.formatMessage(messages.workspacesSettingsTooltip)}`}
158 > 159 >
159 <Icon 160 <Icon
160 icon="mdiSettings" 161 icon={mdiSettings}
161 size={1.5} 162 size={1.5}
162 className={classes.workspacesSettingsButtonIcon} 163 className={classes.workspacesSettingsButtonIcon}
163 /> 164 />
@@ -181,7 +182,7 @@ class WorkspaceDrawer extends Component {
181 className={classes.premiumCtaButton} 182 className={classes.premiumCtaButton}
182 buttonType="primary" 183 buttonType="primary"
183 label={intl.formatMessage(messages.premiumCtaButtonLabel)} 184 label={intl.formatMessage(messages.premiumCtaButtonLabel)}
184 icon="mdiPlusBox" 185 icon={mdiPlusBox}
185 onClick={() => { 186 onClick={() => {
186 workspaceActions.openWorkspaceSettings(); 187 workspaceActions.openWorkspaceSettings();
187 }} 188 }}
@@ -220,7 +221,7 @@ class WorkspaceDrawer extends Component {
220 }} 221 }}
221 > 222 >
222 <Icon 223 <Icon
223 icon="mdiPlusBox" 224 icon={mdiPlusBox}
224 size={1} 225 size={1}
225 className={classes.workspacesSettingsButtonIcon} 226 className={classes.workspacesSettingsButtonIcon}
226 /> 227 />
diff --git a/src/features/workspaces/components/WorkspaceSwitchingIndicator.js b/src/features/workspaces/components/WorkspaceSwitchingIndicator.js
index c4a800a7b..a70d1d66f 100644
--- a/src/features/workspaces/components/WorkspaceSwitchingIndicator.js
+++ b/src/features/workspaces/components/WorkspaceSwitchingIndicator.js
@@ -21,11 +21,8 @@ const styles = theme => ({
21 alignItems: 'flex-start', 21 alignItems: 'flex-start',
22 position: 'absolute', 22 position: 'absolute',
23 transition: 'width 0.5s ease', 23 transition: 'width 0.5s ease',
24 width: '100%',
25 marginTop: '20px',
26 },
27 wrapperWhenDrawerIsOpen: {
28 width: `calc(100% - ${theme.workspaces.drawer.width}px)`, 24 width: `calc(100% - ${theme.workspaces.drawer.width}px)`,
25 marginTop: '20px',
29 }, 26 },
30 component: { 27 component: {
31 background: 'rgba(20, 20, 20, 0.4)', 28 background: 'rgba(20, 20, 20, 0.4)',
@@ -64,14 +61,13 @@ class WorkspaceSwitchingIndicator extends Component {
64 render() { 61 render() {
65 const { classes, theme } = this.props; 62 const { classes, theme } = this.props;
66 const { intl } = this.context; 63 const { intl } = this.context;
67 const { isSwitchingWorkspace, isWorkspaceDrawerOpen, nextWorkspace } = workspaceStore; 64 const { isSwitchingWorkspace, nextWorkspace } = workspaceStore;
68 if (!isSwitchingWorkspace) return null; 65 if (!isSwitchingWorkspace) return null;
69 const nextWorkspaceName = nextWorkspace ? nextWorkspace.name : 'All services'; 66 const nextWorkspaceName = nextWorkspace ? nextWorkspace.name : 'All services';
70 return ( 67 return (
71 <div 68 <div
72 className={classnames([ 69 className={classnames([
73 classes.wrapper, 70 classes.wrapper,
74 isWorkspaceDrawerOpen ? classes.wrapperWhenDrawerIsOpen : null,
75 ])} 71 ])}
76 > 72 >
77 <div className={classes.component}> 73 <div className={classes.component}>
diff --git a/src/features/workspaces/components/WorkspacesDashboard.js b/src/features/workspaces/components/WorkspacesDashboard.js
index 9b51f2602..977b23999 100644
--- a/src/features/workspaces/components/WorkspacesDashboard.js
+++ b/src/features/workspaces/components/WorkspacesDashboard.js
@@ -1,9 +1,9 @@
1import React, { Component, Fragment } from 'react'; 1import React, { Component, Fragment } from 'react';
2import PropTypes from 'prop-types'; 2import PropTypes from 'prop-types';
3import { observer, PropTypes as MobxPropTypes } from 'mobx-react'; 3import { observer, PropTypes as MobxPropTypes, inject } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl'; 4import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss'; 5import injectSheet from 'react-jss';
6import { Infobox } from '@meetfranz/ui'; 6import { Infobox, Badge } from '@meetfranz/ui';
7 7
8import Loader from '../../../components/ui/Loader'; 8import Loader from '../../../components/ui/Loader';
9import WorkspaceItem from './WorkspaceItem'; 9import WorkspaceItem from './WorkspaceItem';
@@ -11,7 +11,9 @@ import CreateWorkspaceForm from './CreateWorkspaceForm';
11import Request from '../../../stores/lib/Request'; 11import Request from '../../../stores/lib/Request';
12import Appear from '../../../components/ui/effects/Appear'; 12import Appear from '../../../components/ui/effects/Appear';
13import { workspaceStore } from '../index'; 13import { workspaceStore } from '../index';
14import PremiumFeatureContainer from '../../../components/ui/PremiumFeatureContainer'; 14import UIStore from '../../../stores/UIStore';
15import globalMessages from '../../../i18n/globalMessages';
16import UpgradeButton from '../../../components/ui/UpgradeButton';
15 17
16const messages = defineMessages({ 18const messages = defineMessages({
17 headline: { 19 headline: {
@@ -48,7 +50,7 @@ const messages = defineMessages({
48 }, 50 },
49}); 51});
50 52
51const styles = theme => ({ 53const styles = () => ({
52 table: { 54 table: {
53 width: '100%', 55 width: '100%',
54 '& td': { 56 '& td': {
@@ -62,17 +64,28 @@ const styles = theme => ({
62 height: 'auto', 64 height: 'auto',
63 }, 65 },
64 premiumAnnouncement: { 66 premiumAnnouncement: {
65 padding: '20px',
66 backgroundColor: '#3498db',
67 marginLeft: '-20px',
68 marginBottom: '20px',
69 height: 'auto', 67 height: 'auto',
70 color: 'white', 68 },
71 borderRadius: theme.borderRadius, 69 premiumAnnouncementContainer: {
70 display: 'flex',
71 },
72 announcementHeadline: {
73 marginBottom: 0,
74 },
75 teaserImage: {
76 width: 250,
77 margin: [-8, 0, 0, 20],
78 alignSelf: 'center',
79 },
80 upgradeCTA: {
81 margin: [40, 'auto'],
82 },
83 proRequired: {
84 margin: [10, 0, 40],
72 }, 85 },
73}); 86});
74 87
75@injectSheet(styles) @observer 88@inject('stores') @injectSheet(styles) @observer
76class WorkspacesDashboard extends Component { 89class WorkspacesDashboard extends Component {
77 static propTypes = { 90 static propTypes = {
78 classes: PropTypes.object.isRequired, 91 classes: PropTypes.object.isRequired,
@@ -100,7 +113,9 @@ class WorkspacesDashboard extends Component {
100 onWorkspaceClick, 113 onWorkspaceClick,
101 workspaces, 114 workspaces,
102 } = this.props; 115 } = this.props;
116
103 const { intl } = this.context; 117 const { intl } = this.context;
118
104 return ( 119 return (
105 <div className="settings__main"> 120 <div className="settings__main">
106 <div className="settings__header"> 121 <div className="settings__header">
@@ -138,68 +153,80 @@ class WorkspacesDashboard extends Component {
138 153
139 {workspaceStore.isPremiumUpgradeRequired && ( 154 {workspaceStore.isPremiumUpgradeRequired && (
140 <div className={classes.premiumAnnouncement}> 155 <div className={classes.premiumAnnouncement}>
141 <h2>{intl.formatMessage(messages.workspaceFeatureHeadline)}</h2> 156
142 <p>{intl.formatMessage(messages.workspaceFeatureInfo)}</p> 157 <h1 className={classes.announcementHeadline}>{intl.formatMessage(messages.workspaceFeatureHeadline)}</h1>
158 <Badge className={classes.proRequired}>{intl.formatMessage(globalMessages.proRequired)}</Badge>
159 <div className={classes.premiumAnnouncementContainer}>
160 <div className={classes.premiumAnnouncementContent}>
161 <p>{intl.formatMessage(messages.workspaceFeatureInfo)}</p>
162 <UpgradeButton
163 className={classes.upgradeCTA}
164 gaEventInfo={{ category: 'Workspaces', event: 'upgrade' }}
165 short
166 requiresPro
167 />
168 </div>
169 <img src={`https://cdn.franzinfra.com/announcements/assets/workspaces_${this.props.stores.ui.isDarkThemeActive ? 'dark' : 'light'}.png`} className={classes.teaserImage} alt="" />
170 </div>
143 </div> 171 </div>
144 )} 172 )}
145 173
146 <PremiumFeatureContainer 174 {!workspaceStore.isPremiumUpgradeRequired && (
147 condition={workspaceStore.isPremiumFeature} 175 <>
148 gaEventInfo={{ category: 'User', event: 'upgrade', label: 'workspaces' }} 176 {/* ===== Create workspace form ===== */}
149 > 177 <div className={classes.createForm}>
150 {/* ===== Create workspace form ===== */} 178 <CreateWorkspaceForm
151 <div className={classes.createForm}> 179 isSubmitting={createWorkspaceRequest.isExecuting}
152 <CreateWorkspaceForm 180 onSubmit={onCreateWorkspaceSubmit}
153 isSubmitting={createWorkspaceRequest.isExecuting} 181 />
154 onSubmit={onCreateWorkspaceSubmit} 182 </div>
155 /> 183 {getUserWorkspacesRequest.isExecuting ? (
156 </div> 184 <Loader />
157 {getUserWorkspacesRequest.isExecuting ? ( 185 ) : (
158 <Loader /> 186 <Fragment>
159 ) : ( 187 {/* ===== Workspace could not be loaded error ===== */}
160 <Fragment> 188 {getUserWorkspacesRequest.error ? (
161 {/* ===== Workspace could not be loaded error ===== */} 189 <Infobox
162 {getUserWorkspacesRequest.error ? ( 190 icon="alert"
163 <Infobox 191 type="danger"
164 icon="alert" 192 ctaLabel={intl.formatMessage(messages.tryReloadWorkspaces)}
165 type="danger" 193 ctaLoading={getUserWorkspacesRequest.isExecuting}
166 ctaLabel={intl.formatMessage(messages.tryReloadWorkspaces)} 194 ctaOnClick={getUserWorkspacesRequest.retry}
167 ctaLoading={getUserWorkspacesRequest.isExecuting} 195 >
168 ctaOnClick={getUserWorkspacesRequest.retry} 196 {intl.formatMessage(messages.workspacesRequestFailed)}
169 > 197 </Infobox>
170 {intl.formatMessage(messages.workspacesRequestFailed)} 198 ) : (
171 </Infobox> 199 <Fragment>
172 ) : ( 200 {workspaces.length === 0 ? (
173 <Fragment> 201 <div className="align-middle settings__empty-state">
174 {workspaces.length === 0 ? ( 202 {/* ===== Workspaces empty state ===== */}
175 <div className="align-middle settings__empty-state"> 203 <p className="settings__empty-text">
176 {/* ===== Workspaces empty state ===== */} 204 <span className="emoji">
177 <p className="settings__empty-text"> 205 <img src="./assets/images/emoji/sad.png" alt="" />
178 <span className="emoji"> 206 </span>
179 <img src="./assets/images/emoji/sad.png" alt="" /> 207 {intl.formatMessage(messages.noServicesAdded)}
180 </span> 208 </p>
181 {intl.formatMessage(messages.noServicesAdded)} 209 </div>
182 </p> 210 ) : (
183 </div> 211 <table className={classes.table}>
184 ) : ( 212 {/* ===== Workspaces list ===== */}
185 <table className={classes.table}> 213 <tbody>
186 {/* ===== Workspaces list ===== */} 214 {workspaces.map(workspace => (
187 <tbody> 215 <WorkspaceItem
188 {workspaces.map(workspace => ( 216 key={workspace.id}
189 <WorkspaceItem 217 workspace={workspace}
190 key={workspace.id} 218 onItemClick={w => onWorkspaceClick(w)}
191 workspace={workspace} 219 />
192 onItemClick={w => onWorkspaceClick(w)} 220 ))}
193 /> 221 </tbody>
194 ))} 222 </table>
195 </tbody> 223 )}
196 </table> 224 </Fragment>
197 )} 225 )}
198 </Fragment> 226 </Fragment>
199 )} 227 )}
200 </Fragment> 228 </>
201 )} 229 )}
202 </PremiumFeatureContainer>
203 </div> 230 </div>
204 </div> 231 </div>
205 ); 232 );
@@ -207,3 +234,9 @@ class WorkspacesDashboard extends Component {
207} 234}
208 235
209export default WorkspacesDashboard; 236export default WorkspacesDashboard;
237
238WorkspacesDashboard.wrappedComponent.propTypes = {
239 stores: PropTypes.shape({
240 ui: PropTypes.instanceOf(UIStore).isRequired,
241 }).isRequired,
242};
diff --git a/src/features/workspaces/containers/WorkspacesScreen.js b/src/features/workspaces/containers/WorkspacesScreen.js
index 2ab565fa1..affbd230d 100644
--- a/src/features/workspaces/containers/WorkspacesScreen.js
+++ b/src/features/workspaces/containers/WorkspacesScreen.js
@@ -11,7 +11,7 @@ import {
11 updateWorkspaceRequest, 11 updateWorkspaceRequest,
12} from '../api'; 12} from '../api';
13 13
14@inject('actions') @observer 14@inject('stores', 'actions') @observer
15class WorkspacesScreen extends Component { 15class WorkspacesScreen extends Component {
16 static propTypes = { 16 static propTypes = {
17 actions: PropTypes.shape({ 17 actions: PropTypes.shape({
diff --git a/src/features/workspaces/store.js b/src/features/workspaces/store.js
index a82f6895c..4a1f80b4e 100644
--- a/src/features/workspaces/store.js
+++ b/src/features/workspaces/store.js
@@ -253,11 +253,10 @@ export default class WorkspacesStore extends FeatureStore {
253 }; 253 };
254 254
255 _setIsPremiumFeatureReaction = () => { 255 _setIsPremiumFeatureReaction = () => {
256 const { features, user } = this.stores; 256 const { features } = this.stores;
257 const { isPremium } = user.data; 257 const { isWorkspaceIncludedInCurrentPlan } = features.features;
258 const { isWorkspacePremiumFeature } = features.features; 258 this.isPremiumFeature = !isWorkspaceIncludedInCurrentPlan;
259 this.isPremiumFeature = isWorkspacePremiumFeature; 259 this.isPremiumUpgradeRequired = !isWorkspaceIncludedInCurrentPlan;
260 this.isPremiumUpgradeRequired = isWorkspacePremiumFeature && !isPremium;
261 }; 260 };
262 261
263 _setWorkspaceBeingEditedReaction = () => { 262 _setWorkspaceBeingEditedReaction = () => {