aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/delayApp/Component.js2
-rw-r--r--src/features/delayApp/index.js9
-rw-r--r--src/features/planSelection/actions.js9
-rw-r--r--src/features/planSelection/api.js26
-rw-r--r--src/features/planSelection/components/PlanItem.js207
-rw-r--r--src/features/planSelection/components/PlanSelection.js279
-rw-r--r--src/features/planSelection/containers/PlanSelectionScreen.js123
-rw-r--r--src/features/planSelection/index.js28
-rw-r--r--src/features/planSelection/store.js68
-rw-r--r--src/features/shareFranz/index.js3
-rw-r--r--src/features/trialStatusBar/actions.js13
-rw-r--r--src/features/trialStatusBar/components/ProgressBar.js45
-rw-r--r--src/features/trialStatusBar/components/TrialStatusBar.js135
-rw-r--r--src/features/trialStatusBar/containers/TrialStatusBarScreen.js109
-rw-r--r--src/features/trialStatusBar/index.js30
-rw-r--r--src/features/trialStatusBar/store.js72
-rw-r--r--src/features/workspaces/components/WorkspaceDrawer.js9
-rw-r--r--src/features/workspaces/components/WorkspacesDashboard.js5
-rw-r--r--src/features/workspaces/store.js5
19 files changed, 1169 insertions, 8 deletions
diff --git a/src/features/delayApp/Component.js b/src/features/delayApp/Component.js
index c61cb06c9..81f89bc52 100644
--- a/src/features/delayApp/Component.js
+++ b/src/features/delayApp/Component.js
@@ -21,7 +21,7 @@ const messages = defineMessages({
21 }, 21 },
22 action: { 22 action: {
23 id: 'feature.delayApp.upgrade.action', 23 id: 'feature.delayApp.upgrade.action',
24 defaultMessage: '!!!Get a Franz Supporter License', 24 defaultMessage: '!!!Upgrade Franz',
25 }, 25 },
26 actionTrial: { 26 actionTrial: {
27 id: 'feature.delayApp.trial.action', 27 id: 'feature.delayApp.trial.action',
diff --git a/src/features/delayApp/index.js b/src/features/delayApp/index.js
index 5cc6c9506..51bd887a2 100644
--- a/src/features/delayApp/index.js
+++ b/src/features/delayApp/index.js
@@ -3,6 +3,7 @@ import moment from 'moment';
3import DelayAppComponent from './Component'; 3import DelayAppComponent from './Component';
4 4
5import { DEFAULT_FEATURES_CONFIG } from '../../config'; 5import { DEFAULT_FEATURES_CONFIG } from '../../config';
6import { getUserWorkspacesRequest } from '../workspaces/api';
6 7
7const debug = require('debug')('Ferdi:feature:delayApp'); 8const debug = require('debug')('Ferdi:feature:delayApp');
8 9
@@ -32,7 +33,13 @@ export default function init(stores) {
32 }; 33 };
33 34
34 reaction( 35 reaction(
35 () => stores.user.isLoggedIn && stores.services.allServicesRequest.wasExecuted && stores.features.features.needToWaitToProceed && !stores.user.data.isPremium, 36 () => (
37 stores.user.isLoggedIn
38 && stores.services.allServicesRequest.wasExecuted
39 && getUserWorkspacesRequest.wasExecuted
40 && stores.features.features.needToWaitToProceed
41 && !stores.user.data.isPremium
42 ),
36 (isEnabled) => { 43 (isEnabled) => {
37 if (isEnabled) { 44 if (isEnabled) {
38 debug('Enabling `delayApp` feature'); 45 debug('Enabling `delayApp` feature');
diff --git a/src/features/planSelection/actions.js b/src/features/planSelection/actions.js
new file mode 100644
index 000000000..83f58bfd7
--- /dev/null
+++ b/src/features/planSelection/actions.js
@@ -0,0 +1,9 @@
1import PropTypes from 'prop-types';
2import { createActionsFromDefinitions } from '../../actions/lib/actions';
3
4export const planSelectionActions = createActionsFromDefinitions({
5 downgradeAccount: {},
6 hideOverlay: {},
7}, PropTypes.checkPropTypes);
8
9export default planSelectionActions;
diff --git a/src/features/planSelection/api.js b/src/features/planSelection/api.js
new file mode 100644
index 000000000..734643f10
--- /dev/null
+++ b/src/features/planSelection/api.js
@@ -0,0 +1,26 @@
1import { sendAuthRequest } from '../../api/utils/auth';
2import { API, API_VERSION } from '../../environment';
3import Request from '../../stores/lib/Request';
4
5const debug = require('debug')('Franz:feature:planSelection:api');
6
7export const planSelectionApi = {
8 downgrade: async () => {
9 const url = `${API}/${API_VERSION}/payment/downgrade`;
10 const options = {
11 method: 'PUT',
12 };
13 debug('downgrade UPDATE', url, options);
14 const result = await sendAuthRequest(url, options);
15 debug('downgrade RESULT', result);
16 if (!result.ok) throw result;
17
18 return result.ok;
19 },
20};
21
22export const downgradeUserRequest = new Request(planSelectionApi, 'downgrade');
23
24export const resetApiRequests = () => {
25 downgradeUserRequest.reset();
26};
diff --git a/src/features/planSelection/components/PlanItem.js b/src/features/planSelection/components/PlanItem.js
new file mode 100644
index 000000000..ec061377b
--- /dev/null
+++ b/src/features/planSelection/components/PlanItem.js
@@ -0,0 +1,207 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss';
6import classnames from 'classnames';
7import color from 'color';
8
9import { H2 } from '@meetfranz/ui';
10
11import { Button } from '@meetfranz/forms';
12import { mdiArrowRight } from '@mdi/js';
13
14const messages = defineMessages({
15 perMonth: {
16 id: 'subscription.interval.perMonth',
17 defaultMessage: '!!!per month',
18 },
19 perMonthPerUser: {
20 id: 'subscription.interval.perMonthPerUser',
21 defaultMessage: '!!!per month & user',
22 },
23 bestValue: {
24 id: 'subscription.bestValue',
25 defaultMessage: '!!!Best value',
26 },
27});
28
29const styles = theme => ({
30 root: {
31 display: 'flex',
32 flexDirection: 'column',
33 borderRadius: theme.borderRadius,
34 flex: 1,
35 color: theme.styleTypes.primary.accent,
36 overflow: 'hidden',
37 textAlign: 'center',
38
39 '& h2': {
40 textAlign: 'center',
41 marginBottom: 10,
42 fontSize: 30,
43 color: theme.styleTypes.primary.contrast,
44 },
45 },
46 currency: {
47 fontSize: 35,
48 },
49 priceWrapper: {
50 height: 50,
51 marginBottom: 0,
52 },
53 price: {
54 fontSize: 50,
55
56 '& sup': {
57 fontSize: 20,
58 verticalAlign: 20,
59 },
60 },
61 text: {
62 marginBottom: 'auto',
63 },
64 cta: {
65 background: theme.styleTypes.primary.accent,
66 color: theme.styleTypes.primary.contrast,
67 margin: [40, 'auto', 0, 'auto'],
68 },
69 divider: {
70 width: 40,
71 border: 0,
72 borderTop: [1, 'solid', theme.styleTypes.primary.contrast],
73 margin: [15, 'auto', 20],
74 },
75 header: {
76 padding: 20,
77 background: color(theme.styleTypes.primary.accent).darken(0.25).hex(),
78 color: theme.styleTypes.primary.contrast,
79 position: 'relative',
80 },
81 content: {
82 padding: [10, 20, 20],
83 background: '#EFEFEF',
84 },
85 simpleCTA: {
86 background: 'none',
87 color: theme.styleTypes.primary.accent,
88
89 '& svg': {
90 fill: theme.styleTypes.primary.accent,
91 },
92 },
93 bestValue: {
94 background: theme.styleTypes.success.accent,
95 color: theme.styleTypes.success.contrast,
96 right: -66,
97 top: -40,
98 height: 'auto',
99 position: 'absolute',
100 transform: 'rotateZ(45deg)',
101 textAlign: 'center',
102 padding: [5, 50],
103 transformOrigin: 'left bottom',
104 fontSize: 12,
105 boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
106 },
107});
108
109
110export default @observer @injectSheet(styles) class PlanItem extends Component {
111 static propTypes = {
112 name: PropTypes.string.isRequired,
113 text: PropTypes.string.isRequired,
114 price: PropTypes.number.isRequired,
115 currency: PropTypes.string.isRequired,
116 upgrade: PropTypes.func.isRequired,
117 ctaLabel: PropTypes.string.isRequired,
118 simpleCTA: PropTypes.bool,
119 perUser: PropTypes.bool,
120 classes: PropTypes.object.isRequired,
121 bestValue: PropTypes.bool,
122 className: PropTypes.string,
123 children: PropTypes.element,
124 };
125
126 static defaultProps = {
127 simpleCTA: false,
128 perUser: false,
129 children: null,
130 bestValue: false,
131 className: '',
132 }
133
134 static contextTypes = {
135 intl: intlShape,
136 };
137
138 render() {
139 const {
140 name,
141 text,
142 price,
143 currency,
144 classes,
145 upgrade,
146 ctaLabel,
147 simpleCTA,
148 perUser,
149 bestValue,
150 className,
151 children,
152 } = this.props;
153 const { intl } = this.context;
154
155 const priceParts = `${price}`.split('.');
156
157 return (
158 <div className={classnames({
159 [classes.root]: true,
160 [className]: className,
161 })}
162 >
163 <div className={classes.header}>
164 {bestValue && (
165 <div className={classes.bestValue}>
166 {intl.formatMessage(messages.bestValue)}
167 </div>
168 )}
169 <H2 className={classes.planName}>{name}</H2>
170 <p className={classes.text}>
171 {text}
172 </p>
173 <hr className={classes.divider} />
174 <p className={classes.priceWrapper}>
175 <span className={classes.currency}>{currency}</span>
176 <span className={classes.price}>
177 {priceParts[0]}
178 <sup>{priceParts[1]}</sup>
179 </span>
180 </p>
181 <p className={classes.interval}>
182 {intl.formatMessage(perUser ? messages.perMonthPerUser : messages.perMonth)}
183 </p>
184 </div>
185
186 <div className={classes.content}>
187 {children}
188
189 <Button
190 className={classnames({
191 [classes.cta]: true,
192 [classes.simpleCTA]: simpleCTA,
193 })}
194 icon={simpleCTA ? mdiArrowRight : null}
195 label={(
196 <>
197 {ctaLabel}
198 </>
199 )}
200 onClick={upgrade}
201 />
202 </div>
203
204 </div>
205 );
206 }
207}
diff --git a/src/features/planSelection/components/PlanSelection.js b/src/features/planSelection/components/PlanSelection.js
new file mode 100644
index 000000000..4bf5238dd
--- /dev/null
+++ b/src/features/planSelection/components/PlanSelection.js
@@ -0,0 +1,279 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import injectSheet from 'react-jss';
5import { defineMessages, intlShape } from 'react-intl';
6import { H1, H2, Icon } from '@meetfranz/ui';
7import color from 'color';
8
9import { mdiRocket, mdiArrowRight } from '@mdi/js';
10import PlanItem from './PlanItem';
11import { i18nPlanName } from '../../../helpers/plan-helpers';
12import { PLANS } from '../../../config';
13import { FeatureList } from '../../../components/ui/FeatureList';
14import Appear from '../../../components/ui/effects/Appear';
15
16const messages = defineMessages({
17 welcome: {
18 id: 'feature.planSelection.fullscreen.welcome',
19 defaultMessage: '!!!Are you ready to choose, {name}',
20 },
21 subheadline: {
22 id: 'feature.planSelection.fullscreen.subheadline',
23 defaultMessage: '!!!It\'s time to make a choice. Franz works best on our Personal and Professional plans. Please have a look and choose the best one for you.',
24 },
25 textFree: {
26 id: 'feature.planSelection.free.text',
27 defaultMessage: '!!!Basic functionality',
28 },
29 textPersonal: {
30 id: 'feature.planSelection.personal.text',
31 defaultMessage: '!!!More services, no waiting - ideal for personal use.',
32 },
33 textProfessional: {
34 id: 'feature.planSelection.pro.text',
35 defaultMessage: '!!!Unlimited services and professional features for you - and your team.',
36 },
37 ctaStayOnFree: {
38 id: 'feature.planSelection.cta.stayOnFree',
39 defaultMessage: '!!!Stay on Free',
40 },
41 ctaDowngradeFree: {
42 id: 'feature.planSelection.cta.ctaDowngradeFree',
43 defaultMessage: '!!!Downgrade to Free',
44 },
45 actionTrial: {
46 id: 'feature.planSelection.cta.trial',
47 defaultMessage: '!!!Start my free 14-days Trial',
48 },
49 shortActionPersonal: {
50 id: 'feature.planSelection.cta.upgradePersonal',
51 defaultMessage: '!!!Choose Personal',
52 },
53 shortActionPro: {
54 id: 'feature.planSelection.cta.upgradePro',
55 defaultMessage: '!!!Choose Professional',
56 },
57 fullFeatureList: {
58 id: 'feature.planSelection.fullFeatureList',
59 defaultMessage: '!!!Complete comparison of all plans',
60 },
61 pricesBasedOnAnnualPayment: {
62 id: 'feature.planSelection.pricesBasedOnAnnualPayment',
63 defaultMessage: '!!!All prices based on yearly payment',
64 },
65});
66
67const styles = theme => ({
68 root: {
69 background: theme.colorModalOverlayBackground,
70 width: '100%',
71 height: '100%',
72 position: 'absolute',
73 top: 0,
74 left: 0,
75 display: 'flex',
76 justifyContent: 'center',
77 alignItems: 'center',
78 zIndex: 999999,
79 overflowY: 'scroll',
80 },
81 container: {
82 width: '80%',
83 height: 'auto',
84 background: theme.styleTypes.primary.accent,
85 padding: 40,
86 borderRadius: theme.borderRadius,
87 maxWidth: 1000,
88
89 '& h1, & h2': {
90 textAlign: 'center',
91 color: theme.styleTypes.primary.contrast,
92 },
93 },
94 plans: {
95 display: 'flex',
96 margin: [40, 0, 0],
97 height: 'auto',
98
99 '& > div': {
100 margin: [0, 15],
101 flex: 1,
102 height: 'auto',
103 background: theme.styleTypes.primary.contrast,
104 boxShadow: [0, 2, 30, color('#000').alpha(0.1).rgb().string()],
105 },
106 },
107 bigIcon: {
108 background: theme.styleTypes.danger.accent,
109 width: 120,
110 height: 120,
111 display: 'flex',
112 alignItems: 'center',
113 borderRadius: '100%',
114 justifyContent: 'center',
115 margin: [-100, 'auto', 20],
116
117 '& svg': {
118 width: '80px !important',
119 height: '80px !important',
120 filter: 'drop-shadow( 0px 2px 3px rgba(0, 0, 0, 0.3))',
121 fill: theme.styleTypes.danger.contrast,
122 },
123 },
124 headline: {
125 fontSize: 40,
126 },
127 subheadline: {
128 maxWidth: 660,
129 fontSize: 22,
130 lineHeight: 1.1,
131 margin: [0, 'auto'],
132 },
133 featureList: {
134 '& li': {
135 borderBottom: [1, 'solid', '#CECECE'],
136 },
137 },
138 footer: {
139 display: 'flex',
140 color: theme.styleTypes.primary.contrast,
141 marginTop: 20,
142 padding: [0, 15],
143 },
144 fullFeatureList: {
145 marginRight: 'auto',
146 textAlign: 'center',
147 display: 'flex',
148 justifyContent: 'center',
149 alignItems: 'center',
150 color: `${theme.styleTypes.primary.contrast} !important`,
151
152 '& svg': {
153 marginRight: 5,
154 },
155 },
156 scrollContainer: {
157 border: '1px solid red',
158 overflow: 'scroll-x',
159 },
160 featuredPlan: {
161 transform: 'scale(1.05)',
162 },
163 disclaimer: {
164 textAlign: 'right',
165 margin: [10, 15, 0, 0],
166 },
167});
168
169@injectSheet(styles) @observer
170class PlanSelection extends Component {
171 static propTypes = {
172 classes: PropTypes.object.isRequired,
173 firstname: PropTypes.string.isRequired,
174 plans: PropTypes.object.isRequired,
175 currency: PropTypes.string.isRequired,
176 subscriptionExpired: PropTypes.bool.isRequired,
177 upgradeAccount: PropTypes.func.isRequired,
178 stayOnFree: PropTypes.func.isRequired,
179 hadSubscription: PropTypes.bool.isRequired,
180 };
181
182 static contextTypes = {
183 intl: intlShape,
184 };
185
186 componentDidMount() {
187 }
188
189 render() {
190 const {
191 classes,
192 firstname,
193 plans,
194 currency,
195 subscriptionExpired,
196 upgradeAccount,
197 stayOnFree,
198 hadSubscription,
199 } = this.props;
200
201 const { intl } = this.context;
202
203 return (
204 <Appear>
205 <div
206 className={classes.root}
207 >
208 <div className={classes.container}>
209 <div className={classes.bigIcon}>
210 <Icon icon={mdiRocket} />
211 </div>
212 <H1 className={classes.headline}>{intl.formatMessage(messages.welcome, { name: firstname })}</H1>
213 <H2 className={classes.subheadline}>{intl.formatMessage(messages.subheadline)}</H2>
214 <div className={classes.plans}>
215 <PlanItem
216 name={i18nPlanName(PLANS.FREE, intl)}
217 text={intl.formatMessage(messages.textFree)}
218 price={0}
219 currency={currency}
220 ctaLabel={intl.formatMessage(subscriptionExpired ? messages.ctaDowngradeFree : messages.ctaStayOnFree)}
221 upgrade={() => stayOnFree()}
222 simpleCTA
223 >
224 <FeatureList
225 plan={PLANS.FREE}
226 className={classes.featureList}
227 />
228 </PlanItem>
229 <PlanItem
230 name={i18nPlanName(plans.pro.yearly.id, intl)}
231 text={intl.formatMessage(messages.textProfessional)}
232 price={plans.pro.yearly.price}
233 currency={currency}
234 ctaLabel={intl.formatMessage(hadSubscription ? messages.shortActionPro : messages.actionTrial)}
235 upgrade={() => upgradeAccount(plans.pro.yearly.id)}
236 className={classes.featuredPlan}
237 perUser
238 bestValue
239 >
240 <FeatureList
241 plan={PLANS.PRO}
242 className={classes.featureList}
243 />
244 </PlanItem>
245 <PlanItem
246 name={i18nPlanName(plans.personal.yearly.id, intl)}
247 text={intl.formatMessage(messages.textPersonal)}
248 price={plans.personal.yearly.price}
249 currency={currency}
250 ctaLabel={intl.formatMessage(hadSubscription ? messages.shortActionPersonal : messages.actionTrial)}
251 upgrade={() => upgradeAccount(plans.personal.yearly.id)}
252 >
253 <FeatureList
254 plan={PLANS.PERSONAL}
255 className={classes.featureList}
256 />
257 </PlanItem>
258 </div>
259 <div className={classes.footer}>
260 <a
261 href="https://meetfranz.com/pricing"
262 target="_blank"
263 className={classes.fullFeatureList}
264 >
265 <Icon icon={mdiArrowRight} />
266 {intl.formatMessage(messages.fullFeatureList)}
267 </a>
268 {/* <p className={classes.disclaimer}> */}
269 {intl.formatMessage(messages.pricesBasedOnAnnualPayment)}
270 {/* </p> */}
271 </div>
272 </div>
273 </div>
274 </Appear>
275 );
276 }
277}
278
279export default PlanSelection;
diff --git a/src/features/planSelection/containers/PlanSelectionScreen.js b/src/features/planSelection/containers/PlanSelectionScreen.js
new file mode 100644
index 000000000..d202c924e
--- /dev/null
+++ b/src/features/planSelection/containers/PlanSelectionScreen.js
@@ -0,0 +1,123 @@
1import React, { Component } from 'react';
2import { observer, inject } from 'mobx-react';
3import PropTypes from 'prop-types';
4import { remote } from 'electron';
5import { defineMessages, intlShape } from 'react-intl';
6
7import FeaturesStore from '../../../stores/FeaturesStore';
8import UserStore from '../../../stores/UserStore';
9import PlanSelection from '../components/PlanSelection';
10import ErrorBoundary from '../../../components/util/ErrorBoundary';
11import { planSelectionStore } from '..';
12
13const { dialog, app } = remote;
14
15const messages = defineMessages({
16 dialogTitle: {
17 id: 'feature.planSelection.fullscreen.dialog.title',
18 defaultMessage: '!!!Downgrade your Franz Plan',
19 },
20 dialogMessage: {
21 id: 'feature.planSelection.fullscreen.dialog.message',
22 defaultMessage: '!!!You\'re about to downgrade to our Free account. Are you sure? Click here instead to get more services and functionality for just {currency}{price} a month.',
23 },
24 dialogCTADowngrade: {
25 id: 'feature.planSelection.fullscreen.dialog.cta.downgrade',
26 defaultMessage: '!!!Downgrade to Free',
27 },
28 dialogCTAUpgrade: {
29 id: 'feature.planSelection.fullscreen.dialog.cta.upgrade',
30 defaultMessage: '!!!Choose Personal',
31 },
32});
33
34@inject('stores', 'actions') @observer
35class PlanSelectionScreen extends Component {
36 static contextTypes = {
37 intl: intlShape,
38 };
39
40 upgradeAccount(planId) {
41 const { upgradeAccount } = this.props.actions.payment;
42
43 upgradeAccount({
44 planId,
45 });
46 }
47
48 render() {
49 if (!planSelectionStore || !planSelectionStore.isFeatureActive || !planSelectionStore.showPlanSelectionOverlay) {
50 return null;
51 }
52
53 const { intl } = this.context;
54
55 const { user, features } = this.props.stores;
56 const { plans, currency } = features.features.pricingConfig;
57 const { activateTrial } = this.props.actions.user;
58 const { downgradeAccount, hideOverlay } = this.props.actions.planSelection;
59
60 return (
61 <ErrorBoundary>
62 <PlanSelection
63 firstname={user.data.firstname}
64 plans={plans}
65 currency={currency}
66 upgradeAccount={(planId) => {
67 if (user.data.hadSubscription) {
68 this.upgradeAccount(planId);
69 } else {
70 activateTrial({
71 planId,
72 });
73 }
74 }}
75 stayOnFree={() => {
76 const selection = dialog.showMessageBoxSync(app.mainWindow, {
77 type: 'question',
78 message: intl.formatMessage(messages.dialogTitle),
79 detail: intl.formatMessage(messages.dialogMessage, {
80 currency,
81 price: plans.personal.yearly.price,
82 }),
83 buttons: [
84 intl.formatMessage(messages.dialogCTADowngrade),
85 intl.formatMessage(messages.dialogCTAUpgrade),
86 ],
87 });
88
89 if (selection === 0) {
90 downgradeAccount();
91 hideOverlay();
92 } else {
93 this.upgradeAccount(plans.personal.yearly.id);
94 }
95 }}
96 subscriptionExpired={user.team && user.team.state === 'expired' && !user.team.userHasDowngraded}
97 hadSubscription={user.data.hadSubscription}
98 />
99 </ErrorBoundary>
100 );
101 }
102}
103
104export default PlanSelectionScreen;
105
106PlanSelectionScreen.wrappedComponent.propTypes = {
107 stores: PropTypes.shape({
108 features: PropTypes.instanceOf(FeaturesStore).isRequired,
109 user: PropTypes.instanceOf(UserStore).isRequired,
110 }).isRequired,
111 actions: PropTypes.shape({
112 payment: PropTypes.shape({
113 upgradeAccount: PropTypes.func.isRequired,
114 }),
115 planSelection: PropTypes.shape({
116 downgradeAccount: PropTypes.func.isRequired,
117 hideOverlay: PropTypes.func.isRequired,
118 }),
119 user: PropTypes.shape({
120 activateTrial: PropTypes.func.isRequired,
121 }),
122 }).isRequired,
123};
diff --git a/src/features/planSelection/index.js b/src/features/planSelection/index.js
new file mode 100644
index 000000000..890be8871
--- /dev/null
+++ b/src/features/planSelection/index.js
@@ -0,0 +1,28 @@
1import { reaction } from 'mobx';
2import PlanSelectionStore from './store';
3
4const debug = require('debug')('Franz:feature:planSelection');
5
6export const planSelectionStore = new PlanSelectionStore();
7
8export default function initPlanSelection(stores, actions) {
9 stores.planSelection = planSelectionStore;
10 const { features } = stores;
11
12 // Toggle planSelection feature
13 reaction(
14 () => features.features.isPlanSelectionEnabled,
15 (isEnabled) => {
16 if (isEnabled) {
17 debug('Initializing `planSelection` feature');
18 planSelectionStore.start(stores, actions);
19 } else if (planSelectionStore.isFeatureActive) {
20 debug('Disabling `planSelection` feature');
21 planSelectionStore.stop();
22 }
23 },
24 {
25 fireImmediately: true,
26 },
27 );
28}
diff --git a/src/features/planSelection/store.js b/src/features/planSelection/store.js
new file mode 100644
index 000000000..448e323ff
--- /dev/null
+++ b/src/features/planSelection/store.js
@@ -0,0 +1,68 @@
1import {
2 action,
3 observable,
4 computed,
5} from 'mobx';
6
7import { planSelectionActions } from './actions';
8import { FeatureStore } from '../utils/FeatureStore';
9import { createActionBindings } from '../utils/ActionBinding';
10import { downgradeUserRequest } from './api';
11
12const debug = require('debug')('Franz:feature:planSelection:store');
13
14export default class PlanSelectionStore extends FeatureStore {
15 @observable isFeatureEnabled = false;
16
17 @observable isFeatureActive = false;
18
19 @observable hideOverlay = false;
20
21 @computed get showPlanSelectionOverlay() {
22 const { team, isPremium } = this.stores.user;
23 if (team && !this.hideOverlay && !isPremium) {
24 return team.state === 'expired' && !team.userHasDowngraded;
25 }
26
27 return false;
28 }
29
30 // ========== PUBLIC API ========= //
31
32 @action start(stores, actions, api) {
33 debug('PlanSelectionStore::start');
34 this.stores = stores;
35 this.actions = actions;
36 this.api = api;
37
38 // ACTIONS
39
40 this._registerActions(createActionBindings([
41 [planSelectionActions.downgradeAccount, this._downgradeAccount],
42 [planSelectionActions.hideOverlay, this._hideOverlay],
43 ]));
44
45 this.isFeatureActive = true;
46 }
47
48 @action stop() {
49 super.stop();
50 debug('PlanSelectionStore::stop');
51 this.isFeatureActive = false;
52 }
53
54 // ========== PRIVATE METHODS ========= //
55
56 // Actions
57 @action _downgradeAccount = () => {
58 downgradeUserRequest.execute();
59 }
60
61 @action _hideOverlay = () => {
62 this.hideOverlay = true;
63 }
64
65 @action _showOverlay = () => {
66 this.hideOverlay = false;
67 }
68}
diff --git a/src/features/shareFranz/index.js b/src/features/shareFranz/index.js
index 217e926f9..04e3684ae 100644
--- a/src/features/shareFranz/index.js
+++ b/src/features/shareFranz/index.js
@@ -2,6 +2,7 @@ import { observable, reaction } from 'mobx';
2import ms from 'ms'; 2import ms from 'ms';
3 3
4import { state as delayAppState } from '../delayApp'; 4import { state as delayAppState } from '../delayApp';
5import { planSelectionStore } from '../planSelection';
5 6
6export { default as Component } from './Component'; 7export { default as Component } from './Component';
7 8
@@ -31,7 +32,7 @@ export default function initialize(stores) {
31 () => stores.user.isLoggedIn, 32 () => stores.user.isLoggedIn,
32 () => { 33 () => {
33 setTimeout(() => { 34 setTimeout(() => {
34 if (stores.settings.stats.appStarts % 50 === 0) { 35 if (stores.settings.stats.appStarts % 50 === 0 && !planSelectionStore.showPlanSelectionOverlay) {
35 if (delayAppState.isDelayAppScreenVisible) { 36 if (delayAppState.isDelayAppScreenVisible) {
36 debug('Delaying share modal by 5 minutes'); 37 debug('Delaying share modal by 5 minutes');
37 setTimeout(() => showModal(), ms('5m')); 38 setTimeout(() => showModal(), ms('5m'));
diff --git a/src/features/trialStatusBar/actions.js b/src/features/trialStatusBar/actions.js
new file mode 100644
index 000000000..38df76458
--- /dev/null
+++ b/src/features/trialStatusBar/actions.js
@@ -0,0 +1,13 @@
1import PropTypes from 'prop-types';
2import { createActionsFromDefinitions } from '../../actions/lib/actions';
3
4export const trialStatusBarActions = createActionsFromDefinitions({
5 upgradeAccount: {
6 planId: PropTypes.string.isRequired,
7 onCloseWindow: PropTypes.func.isRequired,
8 },
9 downgradeAccount: {},
10 hideOverlay: {},
11}, PropTypes.checkPropTypes);
12
13export default trialStatusBarActions;
diff --git a/src/features/trialStatusBar/components/ProgressBar.js b/src/features/trialStatusBar/components/ProgressBar.js
new file mode 100644
index 000000000..41b74d396
--- /dev/null
+++ b/src/features/trialStatusBar/components/ProgressBar.js
@@ -0,0 +1,45 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import injectSheet from 'react-jss';
5
6const styles = theme => ({
7 root: {
8 background: theme.trialStatusBar.progressBar.background,
9 width: '25%',
10 maxWidth: 200,
11 height: 8,
12 display: 'flex',
13 alignItems: 'center',
14 borderRadius: theme.borderRadius,
15 overflow: 'hidden',
16 },
17 progress: {
18 background: theme.trialStatusBar.progressBar.progressIndicator,
19 width: ({ percent }) => `${percent}%`,
20 height: '100%',
21 },
22});
23
24@injectSheet(styles) @observer
25class ProgressBar extends Component {
26 static propTypes = {
27 classes: PropTypes.object.isRequired,
28 };
29
30 render() {
31 const {
32 classes,
33 } = this.props;
34
35 return (
36 <div
37 className={classes.root}
38 >
39 <div className={classes.progress} />
40 </div>
41 );
42 }
43}
44
45export default ProgressBar;
diff --git a/src/features/trialStatusBar/components/TrialStatusBar.js b/src/features/trialStatusBar/components/TrialStatusBar.js
new file mode 100644
index 000000000..b8fe4acc9
--- /dev/null
+++ b/src/features/trialStatusBar/components/TrialStatusBar.js
@@ -0,0 +1,135 @@
1import React, { Component } from 'react';
2import PropTypes from 'prop-types';
3import { observer } from 'mobx-react';
4import injectSheet from 'react-jss';
5import { defineMessages, intlShape } from 'react-intl';
6import { Icon } from '@meetfranz/ui';
7import { mdiArrowRight, mdiWindowClose } from '@mdi/js';
8import classnames from 'classnames';
9
10import ProgressBar from './ProgressBar';
11
12const messages = defineMessages({
13 restTime: {
14 id: 'feature.trialStatusBar.restTime',
15 defaultMessage: '!!!Your Free Franz {plan} Trial ends in {time}.',
16 },
17 expired: {
18 id: 'feature.trialStatusBar.expired',
19 defaultMessage: '!!!Your free Franz {plan} Trial has expired, please upgrade your account.',
20 },
21 cta: {
22 id: 'feature.trialStatusBar.cta',
23 defaultMessage: '!!!Upgrade now',
24 },
25});
26
27const styles = theme => ({
28 root: {
29 background: theme.trialStatusBar.bar.background,
30 width: '100%',
31 height: 25,
32 order: 10,
33 display: 'flex',
34 alignItems: 'center',
35 fontSize: 12,
36 padding: [0, 10],
37 justifyContent: 'flex-end',
38 },
39 ended: {
40 background: theme.styleTypes.warning.accent,
41 color: theme.styleTypes.warning.contrast,
42 },
43 message: {
44 marginLeft: 20,
45 },
46 action: {
47 marginLeft: 20,
48 fontSize: 12,
49 color: theme.colorText,
50 textDecoration: 'underline',
51 display: 'flex',
52
53 '& svg': {
54 margin: [1, 2, 0, 0],
55 },
56 },
57});
58
59@injectSheet(styles) @observer
60class TrialStatusBar extends Component {
61 static propTypes = {
62 planName: PropTypes.string.isRequired,
63 percent: PropTypes.number.isRequired,
64 upgradeAccount: PropTypes.func.isRequired,
65 hideOverlay: PropTypes.func.isRequired,
66 trialEnd: PropTypes.string.isRequired,
67 hasEnded: PropTypes.bool.isRequired,
68 classes: PropTypes.object.isRequired,
69 };
70
71 static contextTypes = {
72 intl: intlShape,
73 };
74
75 render() {
76 const {
77 planName,
78 percent,
79 upgradeAccount,
80 hideOverlay,
81 trialEnd,
82 hasEnded,
83 classes,
84 } = this.props;
85
86 const { intl } = this.context;
87
88 return (
89 <div
90 className={classnames({
91 [classes.root]: true,
92 [classes.ended]: hasEnded,
93 })}
94 >
95 <ProgressBar
96 percent={percent}
97 />
98 {' '}
99 <span className={classes.message}>
100 {!hasEnded ? (
101 intl.formatMessage(messages.restTime, {
102 plan: planName,
103 time: trialEnd,
104 })
105 ) : (
106 intl.formatMessage(messages.expired, {
107 plan: planName,
108 })
109 )}
110 </span>
111 <button
112 className={classes.action}
113 type="button"
114 onClick={() => {
115 upgradeAccount();
116 }}
117 >
118 <Icon icon={mdiArrowRight} />
119 {intl.formatMessage(messages.cta)}
120 </button>
121 <button
122 className={classes.action}
123 type="button"
124 onClick={() => {
125 hideOverlay();
126 }}
127 >
128 <Icon icon={mdiWindowClose} />
129 </button>
130 </div>
131 );
132 }
133}
134
135export default TrialStatusBar;
diff --git a/src/features/trialStatusBar/containers/TrialStatusBarScreen.js b/src/features/trialStatusBar/containers/TrialStatusBarScreen.js
new file mode 100644
index 000000000..e15a1204f
--- /dev/null
+++ b/src/features/trialStatusBar/containers/TrialStatusBarScreen.js
@@ -0,0 +1,109 @@
1import React, { Component } from 'react';
2import { observer, inject } from 'mobx-react';
3import PropTypes from 'prop-types';
4import ms from 'ms';
5import { intlShape } from 'react-intl';
6
7import FeaturesStore from '../../../stores/FeaturesStore';
8import UserStore from '../../../stores/UserStore';
9import TrialStatusBar from '../components/TrialStatusBar';
10import ErrorBoundary from '../../../components/util/ErrorBoundary';
11import { trialStatusBarStore } from '..';
12import { i18nPlanName } from '../../../helpers/plan-helpers';
13
14@inject('stores', 'actions') @observer
15class TrialStatusBarScreen extends Component {
16 static contextTypes = {
17 intl: intlShape,
18 };
19
20 state = {
21 showOverlay: true,
22 percent: 0,
23 restTime: '',
24 hasEnded: false,
25 };
26
27 percentInterval = null;
28
29 componentDidMount() {
30 this.percentInterval = setInterval(() => {
31 this.calculateRestTime();
32 }, ms('1m'));
33
34 this.calculateRestTime();
35 }
36
37 componentWillUnmount() {
38 clearInterval(this.percentInterval);
39 }
40
41 calculateRestTime() {
42 const { trialEndTime } = trialStatusBarStore;
43 const percent = Math.abs(100 - Math.abs(trialEndTime.asMilliseconds()) * 100 / ms('14d')).toFixed(2);
44 const restTime = trialEndTime.humanize();
45 const hasEnded = trialEndTime.asMilliseconds() > 0;
46
47 this.setState({
48 percent,
49 restTime,
50 hasEnded,
51 });
52 }
53
54 hideOverlay() {
55 this.setState({
56 showOverlay: false,
57 });
58 }
59
60
61 render() {
62 const { intl } = this.context;
63
64 const {
65 showOverlay,
66 percent,
67 restTime,
68 hasEnded,
69 } = this.state;
70
71 if (!trialStatusBarStore || !trialStatusBarStore.isFeatureActive || !showOverlay || !trialStatusBarStore.showTrialStatusBarOverlay) {
72 return null;
73 }
74
75 const { user } = this.props.stores;
76 const { upgradeAccount } = this.props.actions.payment;
77
78 const planName = i18nPlanName(user.team.plan, intl);
79
80 return (
81 <ErrorBoundary>
82 <TrialStatusBar
83 planName={planName}
84 percent={parseFloat(percent < 5 ? 5 : percent)}
85 trialEnd={restTime}
86 upgradeAccount={() => upgradeAccount({
87 planId: user.team.plan,
88 })}
89 hideOverlay={() => this.hideOverlay()}
90 hasEnded={hasEnded}
91 />
92 </ErrorBoundary>
93 );
94 }
95}
96
97export default TrialStatusBarScreen;
98
99TrialStatusBarScreen.wrappedComponent.propTypes = {
100 stores: PropTypes.shape({
101 features: PropTypes.instanceOf(FeaturesStore).isRequired,
102 user: PropTypes.instanceOf(UserStore).isRequired,
103 }).isRequired,
104 actions: PropTypes.shape({
105 payment: PropTypes.shape({
106 upgradeAccount: PropTypes.func.isRequired,
107 }),
108 }).isRequired,
109};
diff --git a/src/features/trialStatusBar/index.js b/src/features/trialStatusBar/index.js
new file mode 100644
index 000000000..ec84cdfd7
--- /dev/null
+++ b/src/features/trialStatusBar/index.js
@@ -0,0 +1,30 @@
1import { reaction } from 'mobx';
2import TrialStatusBarStore from './store';
3
4const debug = require('debug')('Franz:feature:trialStatusBar');
5
6export const GA_CATEGORY_TRIAL_STATUS_BAR = 'trialStatusBar';
7
8export const trialStatusBarStore = new TrialStatusBarStore();
9
10export default function initTrialStatusBar(stores, actions) {
11 stores.trialStatusBar = trialStatusBarStore;
12 const { features } = stores;
13
14 // Toggle trialStatusBar feature
15 reaction(
16 () => features.features.isTrialStatusBarEnabled,
17 (isEnabled) => {
18 if (isEnabled) {
19 debug('Initializing `trialStatusBar` feature');
20 trialStatusBarStore.start(stores, actions);
21 } else if (trialStatusBarStore.isFeatureActive) {
22 debug('Disabling `trialStatusBar` feature');
23 trialStatusBarStore.stop();
24 }
25 },
26 {
27 fireImmediately: true,
28 },
29 );
30}
diff --git a/src/features/trialStatusBar/store.js b/src/features/trialStatusBar/store.js
new file mode 100644
index 000000000..89cf32392
--- /dev/null
+++ b/src/features/trialStatusBar/store.js
@@ -0,0 +1,72 @@
1import {
2 action,
3 observable,
4 computed,
5} from 'mobx';
6import moment from 'moment';
7
8import { trialStatusBarActions } from './actions';
9import { FeatureStore } from '../utils/FeatureStore';
10import { createActionBindings } from '../utils/ActionBinding';
11
12const debug = require('debug')('Franz:feature:trialStatusBar:store');
13
14export default class TrialStatusBarStore extends FeatureStore {
15 @observable isFeatureActive = false;
16
17 @observable isFeatureEnabled = false;
18
19 @computed get showTrialStatusBarOverlay() {
20 if (this.isFeatureActive) {
21 const { team } = this.stores.user;
22 if (team && !this.hideOverlay) {
23 return team.state !== 'expired' && team.isTrial;
24 }
25 }
26
27 return false;
28 }
29
30 @computed get trialEndTime() {
31 if (this.isFeatureActive) {
32 const { team } = this.stores.user;
33
34 if (team && !this.hideOverlay) {
35 return moment.duration(moment().diff(team.trialEnd));
36 }
37 }
38
39 return moment.duration();
40 }
41
42 // ========== PUBLIC API ========= //
43
44 @action start(stores, actions, api) {
45 debug('TrialStatusBarStore::start');
46 this.stores = stores;
47 this.actions = actions;
48 this.api = api;
49
50 // ACTIONS
51
52 this._registerActions(createActionBindings([
53 [trialStatusBarActions.hideOverlay, this._hideOverlay],
54 ]));
55
56 this.isFeatureActive = true;
57 }
58
59 @action stop() {
60 super.stop();
61 debug('TrialStatusBarStore::stop');
62 this.isFeatureActive = false;
63 }
64
65 // ========== PRIVATE METHODS ========= //
66
67 // Actions
68
69 @action _hideOverlay = () => {
70 this.hideOverlay = true;
71 }
72}
diff --git a/src/features/workspaces/components/WorkspaceDrawer.js b/src/features/workspaces/components/WorkspaceDrawer.js
index e991b9909..baa94f6b3 100644
--- a/src/features/workspaces/components/WorkspaceDrawer.js
+++ b/src/features/workspaces/components/WorkspaceDrawer.js
@@ -7,7 +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 { mdiPlusBox, mdiSettings, mdiStar } from '@mdi/js';
11import WorkspaceDrawerItem from './WorkspaceDrawerItem'; 11import WorkspaceDrawerItem from './WorkspaceDrawerItem';
12import { workspaceActions } from '../actions'; 12import { workspaceActions } from '../actions';
13import { workspaceStore } from '../index'; 13import { workspaceStore } from '../index';
@@ -51,6 +51,8 @@ const styles = theme => ({
51 drawer: { 51 drawer: {
52 background: theme.workspaces.drawer.background, 52 background: theme.workspaces.drawer.background,
53 width: `${theme.workspaces.drawer.width}px`, 53 width: `${theme.workspaces.drawer.width}px`,
54 display: 'flex',
55 flexDirection: 'column',
54 }, 56 },
55 headline: { 57 headline: {
56 fontSize: '24px', 58 fontSize: '24px',
@@ -74,6 +76,7 @@ const styles = theme => ({
74 }, 76 },
75 workspaces: { 77 workspaces: {
76 height: 'auto', 78 height: 'auto',
79 overflowY: 'scroll',
77 }, 80 },
78 premiumAnnouncement: { 81 premiumAnnouncement: {
79 padding: '20px', 82 padding: '20px',
@@ -88,7 +91,7 @@ const styles = theme => ({
88 addNewWorkspaceLabel: { 91 addNewWorkspaceLabel: {
89 height: 'auto', 92 height: 'auto',
90 color: theme.workspaces.drawer.buttons.color, 93 color: theme.workspaces.drawer.buttons.color,
91 marginTop: 40, 94 margin: [40, 0],
92 textAlign: 'center', 95 textAlign: 'center',
93 '& > svg': { 96 '& > svg': {
94 fill: theme.workspaces.drawer.buttons.color, 97 fill: theme.workspaces.drawer.buttons.color,
@@ -172,7 +175,7 @@ class WorkspaceDrawer extends Component {
172 className={classes.premiumCtaButton} 175 className={classes.premiumCtaButton}
173 buttonType="primary" 176 buttonType="primary"
174 label={intl.formatMessage(messages.reactivatePremiumAccount)} 177 label={intl.formatMessage(messages.reactivatePremiumAccount)}
175 icon="mdiStar" 178 icon={mdiStar}
176 onClick={() => { 179 onClick={() => {
177 onUpgradeAccountClick(); 180 onUpgradeAccountClick();
178 }} 181 }}
diff --git a/src/features/workspaces/components/WorkspacesDashboard.js b/src/features/workspaces/components/WorkspacesDashboard.js
index 977b23999..b499e02a4 100644
--- a/src/features/workspaces/components/WorkspacesDashboard.js
+++ b/src/features/workspaces/components/WorkspacesDashboard.js
@@ -5,6 +5,7 @@ import { defineMessages, intlShape } from 'react-intl';
5import injectSheet from 'react-jss'; 5import injectSheet from 'react-jss';
6import { Infobox, Badge } from '@meetfranz/ui'; 6import { Infobox, Badge } from '@meetfranz/ui';
7 7
8import { mdiCheckboxMarkedCircleOutline } from '@mdi/js';
8import Loader from '../../../components/ui/Loader'; 9import Loader from '../../../components/ui/Loader';
9import WorkspaceItem from './WorkspaceItem'; 10import WorkspaceItem from './WorkspaceItem';
10import CreateWorkspaceForm from './CreateWorkspaceForm'; 11import CreateWorkspaceForm from './CreateWorkspaceForm';
@@ -128,7 +129,7 @@ class WorkspacesDashboard extends Component {
128 <Appear className={classes.appear}> 129 <Appear className={classes.appear}>
129 <Infobox 130 <Infobox
130 type="success" 131 type="success"
131 icon="mdiCheckboxMarkedCircleOutline" 132 icon={mdiCheckboxMarkedCircleOutline}
132 dismissable 133 dismissable
133 onUnmount={updateWorkspaceRequest.reset} 134 onUnmount={updateWorkspaceRequest.reset}
134 > 135 >
@@ -142,7 +143,7 @@ class WorkspacesDashboard extends Component {
142 <Appear className={classes.appear}> 143 <Appear className={classes.appear}>
143 <Infobox 144 <Infobox
144 type="success" 145 type="success"
145 icon="mdiCheckboxMarkedCircleOutline" 146 icon={mdiCheckboxMarkedCircleOutline}
146 dismissable 147 dismissable
147 onUnmount={deleteWorkspaceRequest.reset} 148 onUnmount={deleteWorkspaceRequest.reset}
148 > 149 >
diff --git a/src/features/workspaces/store.js b/src/features/workspaces/store.js
index 949f8a792..5c90ff180 100644
--- a/src/features/workspaces/store.js
+++ b/src/features/workspaces/store.js
@@ -47,6 +47,11 @@ export default class WorkspacesStore extends FeatureStore {
47 return getUserWorkspacesRequest.result || []; 47 return getUserWorkspacesRequest.result || [];
48 } 48 }
49 49
50 @computed get isLoadingWorkspaces() {
51 if (!this.isFeatureActive) return false;
52 return getUserWorkspacesRequest.isExecutingFirstTime;
53 }
54
50 @computed get settings() { 55 @computed get settings() {
51 return localStorage.getItem('workspaces') || {}; 56 return localStorage.getItem('workspaces') || {};
52 } 57 }