diff options
author | vantezzen <hello@vantezzen.io> | 2019-10-24 15:15:42 +0200 |
---|---|---|
committer | vantezzen <hello@vantezzen.io> | 2019-10-24 15:15:42 +0200 |
commit | 54f8b191a94bd78a85b046bbf21dd2245d3a6f3e (patch) | |
tree | ada5876f0e8a697ba4693bba07f5e0f31fea1fc9 /src/features | |
parent | Update submodules (diff) | |
parent | bump version to 5.4.0 (diff) | |
download | ferdium-app-54f8b191a94bd78a85b046bbf21dd2245d3a6f3e.tar.gz ferdium-app-54f8b191a94bd78a85b046bbf21dd2245d3a6f3e.tar.zst ferdium-app-54f8b191a94bd78a85b046bbf21dd2245d3a6f3e.zip |
Merge https://github.com/meetfranz/franz into franz-5.4.0-release
Diffstat (limited to 'src/features')
19 files changed, 1182 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'; | |||
3 | import DelayAppComponent from './Component'; | 3 | import DelayAppComponent from './Component'; |
4 | 4 | ||
5 | import { DEFAULT_FEATURES_CONFIG } from '../../config'; | 5 | import { DEFAULT_FEATURES_CONFIG } from '../../config'; |
6 | import { getUserWorkspacesRequest } from '../workspaces/api'; | ||
6 | 7 | ||
7 | const debug = require('debug')('Ferdi:feature:delayApp'); | 8 | const 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | import { createActionsFromDefinitions } from '../../actions/lib/actions'; | ||
3 | |||
4 | export const planSelectionActions = createActionsFromDefinitions({ | ||
5 | downgradeAccount: {}, | ||
6 | hideOverlay: {}, | ||
7 | }, PropTypes.checkPropTypes); | ||
8 | |||
9 | export 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 @@ | |||
1 | import { sendAuthRequest } from '../../api/utils/auth'; | ||
2 | import { API, API_VERSION } from '../../environment'; | ||
3 | import Request from '../../stores/lib/Request'; | ||
4 | |||
5 | const debug = require('debug')('Franz:feature:planSelection:api'); | ||
6 | |||
7 | export 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 | |||
22 | export const downgradeUserRequest = new Request(planSelectionApi, 'downgrade'); | ||
23 | |||
24 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import { defineMessages, intlShape } from 'react-intl'; | ||
5 | import injectSheet from 'react-jss'; | ||
6 | import classnames from 'classnames'; | ||
7 | import color from 'color'; | ||
8 | |||
9 | import { H2 } from '@meetfranz/ui'; | ||
10 | |||
11 | import { Button } from '@meetfranz/forms'; | ||
12 | import { mdiArrowRight } from '@mdi/js'; | ||
13 | |||
14 | const 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 | |||
29 | const 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 | |||
110 | export 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..b6bb9d32d --- /dev/null +++ b/src/features/planSelection/components/PlanSelection.js | |||
@@ -0,0 +1,281 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | import { H1, H2, Icon } from '@meetfranz/ui'; | ||
7 | import color from 'color'; | ||
8 | |||
9 | import { mdiRocket, mdiArrowRight } from '@mdi/js'; | ||
10 | import PlanItem from './PlanItem'; | ||
11 | import { i18nPlanName } from '../../../helpers/plan-helpers'; | ||
12 | import { PLANS } from '../../../config'; | ||
13 | import { FeatureList } from '../../../components/ui/FeatureList'; | ||
14 | import Appear from '../../../components/ui/effects/Appear'; | ||
15 | import { gaPage } from '../../../lib/analytics'; | ||
16 | |||
17 | const messages = defineMessages({ | ||
18 | welcome: { | ||
19 | id: 'feature.planSelection.fullscreen.welcome', | ||
20 | defaultMessage: '!!!Are you ready to choose, {name}', | ||
21 | }, | ||
22 | subheadline: { | ||
23 | id: 'feature.planSelection.fullscreen.subheadline', | ||
24 | 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.', | ||
25 | }, | ||
26 | textFree: { | ||
27 | id: 'feature.planSelection.free.text', | ||
28 | defaultMessage: '!!!Basic functionality', | ||
29 | }, | ||
30 | textPersonal: { | ||
31 | id: 'feature.planSelection.personal.text', | ||
32 | defaultMessage: '!!!More services, no waiting - ideal for personal use.', | ||
33 | }, | ||
34 | textProfessional: { | ||
35 | id: 'feature.planSelection.pro.text', | ||
36 | defaultMessage: '!!!Unlimited services and professional features for you - and your team.', | ||
37 | }, | ||
38 | ctaStayOnFree: { | ||
39 | id: 'feature.planSelection.cta.stayOnFree', | ||
40 | defaultMessage: '!!!Stay on Free', | ||
41 | }, | ||
42 | ctaDowngradeFree: { | ||
43 | id: 'feature.planSelection.cta.ctaDowngradeFree', | ||
44 | defaultMessage: '!!!Downgrade to Free', | ||
45 | }, | ||
46 | actionTrial: { | ||
47 | id: 'feature.planSelection.cta.trial', | ||
48 | defaultMessage: '!!!Start my free 14-days Trial', | ||
49 | }, | ||
50 | shortActionPersonal: { | ||
51 | id: 'feature.planSelection.cta.upgradePersonal', | ||
52 | defaultMessage: '!!!Choose Personal', | ||
53 | }, | ||
54 | shortActionPro: { | ||
55 | id: 'feature.planSelection.cta.upgradePro', | ||
56 | defaultMessage: '!!!Choose Professional', | ||
57 | }, | ||
58 | fullFeatureList: { | ||
59 | id: 'feature.planSelection.fullFeatureList', | ||
60 | defaultMessage: '!!!Complete comparison of all plans', | ||
61 | }, | ||
62 | pricesBasedOnAnnualPayment: { | ||
63 | id: 'feature.planSelection.pricesBasedOnAnnualPayment', | ||
64 | defaultMessage: '!!!All prices based on yearly payment', | ||
65 | }, | ||
66 | }); | ||
67 | |||
68 | const styles = theme => ({ | ||
69 | root: { | ||
70 | background: theme.colorModalOverlayBackground, | ||
71 | width: '100%', | ||
72 | height: '100%', | ||
73 | position: 'absolute', | ||
74 | top: 0, | ||
75 | left: 0, | ||
76 | display: 'flex', | ||
77 | justifyContent: 'center', | ||
78 | alignItems: 'center', | ||
79 | zIndex: 999999, | ||
80 | overflowY: 'scroll', | ||
81 | }, | ||
82 | container: { | ||
83 | width: '80%', | ||
84 | height: 'auto', | ||
85 | background: theme.styleTypes.primary.accent, | ||
86 | padding: 40, | ||
87 | borderRadius: theme.borderRadius, | ||
88 | maxWidth: 1000, | ||
89 | |||
90 | '& h1, & h2': { | ||
91 | textAlign: 'center', | ||
92 | color: theme.styleTypes.primary.contrast, | ||
93 | }, | ||
94 | }, | ||
95 | plans: { | ||
96 | display: 'flex', | ||
97 | margin: [40, 0, 0], | ||
98 | height: 'auto', | ||
99 | |||
100 | '& > div': { | ||
101 | margin: [0, 15], | ||
102 | flex: 1, | ||
103 | height: 'auto', | ||
104 | background: theme.styleTypes.primary.contrast, | ||
105 | boxShadow: [0, 2, 30, color('#000').alpha(0.1).rgb().string()], | ||
106 | }, | ||
107 | }, | ||
108 | bigIcon: { | ||
109 | background: theme.styleTypes.danger.accent, | ||
110 | width: 120, | ||
111 | height: 120, | ||
112 | display: 'flex', | ||
113 | alignItems: 'center', | ||
114 | borderRadius: '100%', | ||
115 | justifyContent: 'center', | ||
116 | margin: [-100, 'auto', 20], | ||
117 | |||
118 | '& svg': { | ||
119 | width: '80px !important', | ||
120 | height: '80px !important', | ||
121 | filter: 'drop-shadow( 0px 2px 3px rgba(0, 0, 0, 0.3))', | ||
122 | fill: theme.styleTypes.danger.contrast, | ||
123 | }, | ||
124 | }, | ||
125 | headline: { | ||
126 | fontSize: 40, | ||
127 | }, | ||
128 | subheadline: { | ||
129 | maxWidth: 660, | ||
130 | fontSize: 22, | ||
131 | lineHeight: 1.1, | ||
132 | margin: [0, 'auto'], | ||
133 | }, | ||
134 | featureList: { | ||
135 | '& li': { | ||
136 | borderBottom: [1, 'solid', '#CECECE'], | ||
137 | }, | ||
138 | }, | ||
139 | footer: { | ||
140 | display: 'flex', | ||
141 | color: theme.styleTypes.primary.contrast, | ||
142 | marginTop: 20, | ||
143 | padding: [0, 15], | ||
144 | }, | ||
145 | fullFeatureList: { | ||
146 | marginRight: 'auto', | ||
147 | textAlign: 'center', | ||
148 | display: 'flex', | ||
149 | justifyContent: 'center', | ||
150 | alignItems: 'center', | ||
151 | color: `${theme.styleTypes.primary.contrast} !important`, | ||
152 | |||
153 | '& svg': { | ||
154 | marginRight: 5, | ||
155 | }, | ||
156 | }, | ||
157 | scrollContainer: { | ||
158 | border: '1px solid red', | ||
159 | overflow: 'scroll-x', | ||
160 | }, | ||
161 | featuredPlan: { | ||
162 | transform: 'scale(1.05)', | ||
163 | }, | ||
164 | disclaimer: { | ||
165 | textAlign: 'right', | ||
166 | margin: [10, 15, 0, 0], | ||
167 | }, | ||
168 | }); | ||
169 | |||
170 | @injectSheet(styles) @observer | ||
171 | class PlanSelection extends Component { | ||
172 | static propTypes = { | ||
173 | classes: PropTypes.object.isRequired, | ||
174 | firstname: PropTypes.string.isRequired, | ||
175 | plans: PropTypes.object.isRequired, | ||
176 | currency: PropTypes.string.isRequired, | ||
177 | subscriptionExpired: PropTypes.bool.isRequired, | ||
178 | upgradeAccount: PropTypes.func.isRequired, | ||
179 | stayOnFree: PropTypes.func.isRequired, | ||
180 | hadSubscription: PropTypes.bool.isRequired, | ||
181 | }; | ||
182 | |||
183 | static contextTypes = { | ||
184 | intl: intlShape, | ||
185 | }; | ||
186 | |||
187 | componentDidMount() { | ||
188 | gaPage('/select-plan'); | ||
189 | } | ||
190 | |||
191 | render() { | ||
192 | const { | ||
193 | classes, | ||
194 | firstname, | ||
195 | plans, | ||
196 | currency, | ||
197 | subscriptionExpired, | ||
198 | upgradeAccount, | ||
199 | stayOnFree, | ||
200 | hadSubscription, | ||
201 | } = this.props; | ||
202 | |||
203 | const { intl } = this.context; | ||
204 | |||
205 | return ( | ||
206 | <Appear> | ||
207 | <div | ||
208 | className={classes.root} | ||
209 | > | ||
210 | <div className={classes.container}> | ||
211 | <div className={classes.bigIcon}> | ||
212 | <Icon icon={mdiRocket} /> | ||
213 | </div> | ||
214 | <H1 className={classes.headline}>{intl.formatMessage(messages.welcome, { name: firstname })}</H1> | ||
215 | <H2 className={classes.subheadline}>{intl.formatMessage(messages.subheadline)}</H2> | ||
216 | <div className={classes.plans}> | ||
217 | <PlanItem | ||
218 | name={i18nPlanName(PLANS.FREE, intl)} | ||
219 | text={intl.formatMessage(messages.textFree)} | ||
220 | price={0} | ||
221 | currency={currency} | ||
222 | ctaLabel={intl.formatMessage(subscriptionExpired ? messages.ctaDowngradeFree : messages.ctaStayOnFree)} | ||
223 | upgrade={() => stayOnFree()} | ||
224 | simpleCTA | ||
225 | > | ||
226 | <FeatureList | ||
227 | plan={PLANS.FREE} | ||
228 | className={classes.featureList} | ||
229 | /> | ||
230 | </PlanItem> | ||
231 | <PlanItem | ||
232 | name={i18nPlanName(plans.pro.yearly.id, intl)} | ||
233 | text={intl.formatMessage(messages.textProfessional)} | ||
234 | price={plans.pro.yearly.price} | ||
235 | currency={currency} | ||
236 | ctaLabel={intl.formatMessage(hadSubscription ? messages.shortActionPro : messages.actionTrial)} | ||
237 | upgrade={() => upgradeAccount(plans.pro.yearly.id)} | ||
238 | className={classes.featuredPlan} | ||
239 | perUser | ||
240 | bestValue | ||
241 | > | ||
242 | <FeatureList | ||
243 | plan={PLANS.PRO} | ||
244 | className={classes.featureList} | ||
245 | /> | ||
246 | </PlanItem> | ||
247 | <PlanItem | ||
248 | name={i18nPlanName(plans.personal.yearly.id, intl)} | ||
249 | text={intl.formatMessage(messages.textPersonal)} | ||
250 | price={plans.personal.yearly.price} | ||
251 | currency={currency} | ||
252 | ctaLabel={intl.formatMessage(hadSubscription ? messages.shortActionPersonal : messages.actionTrial)} | ||
253 | upgrade={() => upgradeAccount(plans.personal.yearly.id)} | ||
254 | > | ||
255 | <FeatureList | ||
256 | plan={PLANS.PERSONAL} | ||
257 | className={classes.featureList} | ||
258 | /> | ||
259 | </PlanItem> | ||
260 | </div> | ||
261 | <div className={classes.footer}> | ||
262 | <a | ||
263 | href="https://meetfranz.com/pricing" | ||
264 | target="_blank" | ||
265 | className={classes.fullFeatureList} | ||
266 | > | ||
267 | <Icon icon={mdiArrowRight} /> | ||
268 | {intl.formatMessage(messages.fullFeatureList)} | ||
269 | </a> | ||
270 | {/* <p className={classes.disclaimer}> */} | ||
271 | {intl.formatMessage(messages.pricesBasedOnAnnualPayment)} | ||
272 | {/* </p> */} | ||
273 | </div> | ||
274 | </div> | ||
275 | </div> | ||
276 | </Appear> | ||
277 | ); | ||
278 | } | ||
279 | } | ||
280 | |||
281 | export default PlanSelection; | ||
diff --git a/src/features/planSelection/containers/PlanSelectionScreen.js b/src/features/planSelection/containers/PlanSelectionScreen.js new file mode 100644 index 000000000..cb62f45d3 --- /dev/null +++ b/src/features/planSelection/containers/PlanSelectionScreen.js | |||
@@ -0,0 +1,132 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import { observer, inject } from 'mobx-react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import { remote } from 'electron'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | |||
7 | import FeaturesStore from '../../../stores/FeaturesStore'; | ||
8 | import UserStore from '../../../stores/UserStore'; | ||
9 | import PlanSelection from '../components/PlanSelection'; | ||
10 | import ErrorBoundary from '../../../components/util/ErrorBoundary'; | ||
11 | import { planSelectionStore, GA_CATEGORY_PLAN_SELECTION } from '..'; | ||
12 | import { gaEvent, gaPage } from '../../../lib/analytics'; | ||
13 | |||
14 | const { dialog, app } = remote; | ||
15 | |||
16 | const messages = defineMessages({ | ||
17 | dialogTitle: { | ||
18 | id: 'feature.planSelection.fullscreen.dialog.title', | ||
19 | defaultMessage: '!!!Downgrade your Franz Plan', | ||
20 | }, | ||
21 | dialogMessage: { | ||
22 | id: 'feature.planSelection.fullscreen.dialog.message', | ||
23 | 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.', | ||
24 | }, | ||
25 | dialogCTADowngrade: { | ||
26 | id: 'feature.planSelection.fullscreen.dialog.cta.downgrade', | ||
27 | defaultMessage: '!!!Downgrade to Free', | ||
28 | }, | ||
29 | dialogCTAUpgrade: { | ||
30 | id: 'feature.planSelection.fullscreen.dialog.cta.upgrade', | ||
31 | defaultMessage: '!!!Choose Personal', | ||
32 | }, | ||
33 | }); | ||
34 | |||
35 | @inject('stores', 'actions') @observer | ||
36 | class PlanSelectionScreen extends Component { | ||
37 | static contextTypes = { | ||
38 | intl: intlShape, | ||
39 | }; | ||
40 | |||
41 | upgradeAccount(planId) { | ||
42 | const { upgradeAccount } = this.props.actions.payment; | ||
43 | |||
44 | upgradeAccount({ | ||
45 | planId, | ||
46 | }); | ||
47 | } | ||
48 | |||
49 | render() { | ||
50 | if (!planSelectionStore || !planSelectionStore.isFeatureActive || !planSelectionStore.showPlanSelectionOverlay) { | ||
51 | return null; | ||
52 | } | ||
53 | |||
54 | const { intl } = this.context; | ||
55 | |||
56 | const { user, features } = this.props.stores; | ||
57 | const { plans, currency } = features.features.pricingConfig; | ||
58 | const { activateTrial } = this.props.actions.user; | ||
59 | const { downgradeAccount, hideOverlay } = this.props.actions.planSelection; | ||
60 | |||
61 | return ( | ||
62 | <ErrorBoundary> | ||
63 | <PlanSelection | ||
64 | firstname={user.data.firstname} | ||
65 | plans={plans} | ||
66 | currency={currency} | ||
67 | upgradeAccount={(planId) => { | ||
68 | if (user.data.hadSubscription) { | ||
69 | this.upgradeAccount(planId); | ||
70 | |||
71 | gaEvent(GA_CATEGORY_PLAN_SELECTION, 'SelectPlan', planId); | ||
72 | } else { | ||
73 | activateTrial({ | ||
74 | planId, | ||
75 | }); | ||
76 | } | ||
77 | }} | ||
78 | stayOnFree={() => { | ||
79 | gaPage('/select-plan/downgrade'); | ||
80 | |||
81 | const selection = dialog.showMessageBoxSync(app.mainWindow, { | ||
82 | type: 'question', | ||
83 | message: intl.formatMessage(messages.dialogTitle), | ||
84 | detail: intl.formatMessage(messages.dialogMessage, { | ||
85 | currency, | ||
86 | price: plans.personal.yearly.price, | ||
87 | }), | ||
88 | buttons: [ | ||
89 | intl.formatMessage(messages.dialogCTADowngrade), | ||
90 | intl.formatMessage(messages.dialogCTAUpgrade), | ||
91 | ], | ||
92 | }); | ||
93 | |||
94 | gaEvent(GA_CATEGORY_PLAN_SELECTION, 'SelectPlan', 'Stay on Free'); | ||
95 | |||
96 | if (selection === 0) { | ||
97 | downgradeAccount(); | ||
98 | hideOverlay(); | ||
99 | } else { | ||
100 | this.upgradeAccount(plans.personal.yearly.id); | ||
101 | |||
102 | gaEvent(GA_CATEGORY_PLAN_SELECTION, 'SelectPlan', 'Downgrade'); | ||
103 | } | ||
104 | }} | ||
105 | subscriptionExpired={user.team && user.team.state === 'expired' && !user.team.userHasDowngraded} | ||
106 | hadSubscription={user.data.hadSubscription} | ||
107 | /> | ||
108 | </ErrorBoundary> | ||
109 | ); | ||
110 | } | ||
111 | } | ||
112 | |||
113 | export default PlanSelectionScreen; | ||
114 | |||
115 | PlanSelectionScreen.wrappedComponent.propTypes = { | ||
116 | stores: PropTypes.shape({ | ||
117 | features: PropTypes.instanceOf(FeaturesStore).isRequired, | ||
118 | user: PropTypes.instanceOf(UserStore).isRequired, | ||
119 | }).isRequired, | ||
120 | actions: PropTypes.shape({ | ||
121 | payment: PropTypes.shape({ | ||
122 | upgradeAccount: PropTypes.func.isRequired, | ||
123 | }), | ||
124 | planSelection: PropTypes.shape({ | ||
125 | downgradeAccount: PropTypes.func.isRequired, | ||
126 | hideOverlay: PropTypes.func.isRequired, | ||
127 | }), | ||
128 | user: PropTypes.shape({ | ||
129 | activateTrial: PropTypes.func.isRequired, | ||
130 | }), | ||
131 | }).isRequired, | ||
132 | }; | ||
diff --git a/src/features/planSelection/index.js b/src/features/planSelection/index.js new file mode 100644 index 000000000..81189207a --- /dev/null +++ b/src/features/planSelection/index.js | |||
@@ -0,0 +1,30 @@ | |||
1 | import { reaction } from 'mobx'; | ||
2 | import PlanSelectionStore from './store'; | ||
3 | |||
4 | const debug = require('debug')('Franz:feature:planSelection'); | ||
5 | |||
6 | export const GA_CATEGORY_PLAN_SELECTION = 'planSelection'; | ||
7 | |||
8 | export const planSelectionStore = new PlanSelectionStore(); | ||
9 | |||
10 | export default function initPlanSelection(stores, actions) { | ||
11 | stores.planSelection = planSelectionStore; | ||
12 | const { features } = stores; | ||
13 | |||
14 | // Toggle planSelection feature | ||
15 | reaction( | ||
16 | () => features.features.isPlanSelectionEnabled, | ||
17 | (isEnabled) => { | ||
18 | if (isEnabled) { | ||
19 | debug('Initializing `planSelection` feature'); | ||
20 | planSelectionStore.start(stores, actions); | ||
21 | } else if (planSelectionStore.isFeatureActive) { | ||
22 | debug('Disabling `planSelection` feature'); | ||
23 | planSelectionStore.stop(); | ||
24 | } | ||
25 | }, | ||
26 | { | ||
27 | fireImmediately: true, | ||
28 | }, | ||
29 | ); | ||
30 | } | ||
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 @@ | |||
1 | import { | ||
2 | action, | ||
3 | observable, | ||
4 | computed, | ||
5 | } from 'mobx'; | ||
6 | |||
7 | import { planSelectionActions } from './actions'; | ||
8 | import { FeatureStore } from '../utils/FeatureStore'; | ||
9 | import { createActionBindings } from '../utils/ActionBinding'; | ||
10 | import { downgradeUserRequest } from './api'; | ||
11 | |||
12 | const debug = require('debug')('Franz:feature:planSelection:store'); | ||
13 | |||
14 | export 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'; | |||
2 | import ms from 'ms'; | 2 | import ms from 'ms'; |
3 | 3 | ||
4 | import { state as delayAppState } from '../delayApp'; | 4 | import { state as delayAppState } from '../delayApp'; |
5 | import { planSelectionStore } from '../planSelection'; | ||
5 | 6 | ||
6 | export { default as Component } from './Component'; | 7 | export { 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 @@ | |||
1 | import PropTypes from 'prop-types'; | ||
2 | import { createActionsFromDefinitions } from '../../actions/lib/actions'; | ||
3 | |||
4 | export const trialStatusBarActions = createActionsFromDefinitions({ | ||
5 | upgradeAccount: { | ||
6 | planId: PropTypes.string.isRequired, | ||
7 | onCloseWindow: PropTypes.func.isRequired, | ||
8 | }, | ||
9 | downgradeAccount: {}, | ||
10 | hideOverlay: {}, | ||
11 | }, PropTypes.checkPropTypes); | ||
12 | |||
13 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | |||
6 | const 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 | ||
25 | class 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 | |||
45 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import PropTypes from 'prop-types'; | ||
3 | import { observer } from 'mobx-react'; | ||
4 | import injectSheet from 'react-jss'; | ||
5 | import { defineMessages, intlShape } from 'react-intl'; | ||
6 | import { Icon } from '@meetfranz/ui'; | ||
7 | import { mdiArrowRight, mdiWindowClose } from '@mdi/js'; | ||
8 | import classnames from 'classnames'; | ||
9 | |||
10 | import ProgressBar from './ProgressBar'; | ||
11 | |||
12 | const 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 | |||
27 | const 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 | ||
60 | class 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 | |||
135 | export 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 @@ | |||
1 | import React, { Component } from 'react'; | ||
2 | import { observer, inject } from 'mobx-react'; | ||
3 | import PropTypes from 'prop-types'; | ||
4 | import ms from 'ms'; | ||
5 | import { intlShape } from 'react-intl'; | ||
6 | |||
7 | import FeaturesStore from '../../../stores/FeaturesStore'; | ||
8 | import UserStore from '../../../stores/UserStore'; | ||
9 | import TrialStatusBar from '../components/TrialStatusBar'; | ||
10 | import ErrorBoundary from '../../../components/util/ErrorBoundary'; | ||
11 | import { trialStatusBarStore } from '..'; | ||
12 | import { i18nPlanName } from '../../../helpers/plan-helpers'; | ||
13 | |||
14 | @inject('stores', 'actions') @observer | ||
15 | class 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 | |||
97 | export default TrialStatusBarScreen; | ||
98 | |||
99 | TrialStatusBarScreen.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 @@ | |||
1 | import { reaction } from 'mobx'; | ||
2 | import TrialStatusBarStore from './store'; | ||
3 | |||
4 | const debug = require('debug')('Franz:feature:trialStatusBar'); | ||
5 | |||
6 | export const GA_CATEGORY_TRIAL_STATUS_BAR = 'trialStatusBar'; | ||
7 | |||
8 | export const trialStatusBarStore = new TrialStatusBarStore(); | ||
9 | |||
10 | export 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 @@ | |||
1 | import { | ||
2 | action, | ||
3 | observable, | ||
4 | computed, | ||
5 | } from 'mobx'; | ||
6 | import moment from 'moment'; | ||
7 | |||
8 | import { trialStatusBarActions } from './actions'; | ||
9 | import { FeatureStore } from '../utils/FeatureStore'; | ||
10 | import { createActionBindings } from '../utils/ActionBinding'; | ||
11 | |||
12 | const debug = require('debug')('Franz:feature:trialStatusBar:store'); | ||
13 | |||
14 | export 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'; | |||
7 | import { Button } from '@meetfranz/forms/lib'; | 7 | import { Button } from '@meetfranz/forms/lib'; |
8 | import ReactTooltip from 'react-tooltip'; | 8 | import ReactTooltip from 'react-tooltip'; |
9 | 9 | ||
10 | import { mdiPlusBox, mdiSettings } from '@mdi/js'; | 10 | import { mdiPlusBox, mdiSettings, mdiStar } from '@mdi/js'; |
11 | import WorkspaceDrawerItem from './WorkspaceDrawerItem'; | 11 | import WorkspaceDrawerItem from './WorkspaceDrawerItem'; |
12 | import { workspaceActions } from '../actions'; | 12 | import { workspaceActions } from '../actions'; |
13 | import { workspaceStore } from '../index'; | 13 | import { 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'; | |||
5 | import injectSheet from 'react-jss'; | 5 | import injectSheet from 'react-jss'; |
6 | import { Infobox, Badge } from '@meetfranz/ui'; | 6 | import { Infobox, Badge } from '@meetfranz/ui'; |
7 | 7 | ||
8 | import { mdiCheckboxMarkedCircleOutline } from '@mdi/js'; | ||
8 | import Loader from '../../../components/ui/Loader'; | 9 | import Loader from '../../../components/ui/Loader'; |
9 | import WorkspaceItem from './WorkspaceItem'; | 10 | import WorkspaceItem from './WorkspaceItem'; |
10 | import CreateWorkspaceForm from './CreateWorkspaceForm'; | 11 | import 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 | } |