From 0b08e1e7e6a07acd21af71fd27f4c4acfa34dbba Mon Sep 17 00:00:00 2001 From: Stefan Malzner Date: Wed, 16 Oct 2019 15:16:26 +0200 Subject: Add trialStatusBar & polishing --- src/features/planSelection/actions.js | 4 - .../planSelection/components/PlanSelection.js | 2 +- .../containers/PlanSelectionScreen.js | 13 +- src/features/planSelection/store.js | 40 +----- src/features/trialStatusBar/actions.js | 13 ++ .../trialStatusBar/components/ProgressBar.js | 46 +++++++ .../trialStatusBar/components/TrialStatusBar.js | 135 +++++++++++++++++++++ .../containers/TrialStatusBarScreen.js | 101 +++++++++++++++ src/features/trialStatusBar/index.js | 30 +++++ src/features/trialStatusBar/store.js | 72 +++++++++++ 10 files changed, 408 insertions(+), 48 deletions(-) create mode 100644 src/features/trialStatusBar/actions.js create mode 100644 src/features/trialStatusBar/components/ProgressBar.js create mode 100644 src/features/trialStatusBar/components/TrialStatusBar.js create mode 100644 src/features/trialStatusBar/containers/TrialStatusBarScreen.js create mode 100644 src/features/trialStatusBar/index.js create mode 100644 src/features/trialStatusBar/store.js (limited to 'src/features') diff --git a/src/features/planSelection/actions.js b/src/features/planSelection/actions.js index 21aa38ace..83f58bfd7 100644 --- a/src/features/planSelection/actions.js +++ b/src/features/planSelection/actions.js @@ -2,10 +2,6 @@ import PropTypes from 'prop-types'; import { createActionsFromDefinitions } from '../../actions/lib/actions'; export const planSelectionActions = createActionsFromDefinitions({ - upgradeAccount: { - planId: PropTypes.string.isRequired, - onCloseWindow: PropTypes.func.isRequired, - }, downgradeAccount: {}, hideOverlay: {}, }, PropTypes.checkPropTypes); diff --git a/src/features/planSelection/components/PlanSelection.js b/src/features/planSelection/components/PlanSelection.js index 1a45cf035..cf4474114 100644 --- a/src/features/planSelection/components/PlanSelection.js +++ b/src/features/planSelection/components/PlanSelection.js @@ -205,7 +205,7 @@ class PlanSelection extends Component { price={plans.pro.yearly.price} currency={currency} ctaLabel={intl.formatMessage(hadSubscription ? messages.shortActionPro : messages.actionTrial)} - upgrade={() => upgradeAccount(plans.personal.yearly.id)} + upgrade={() => upgradeAccount(plans.pro.yearly.id)} className={classes.featuredPlan} perUser bestValue diff --git a/src/features/planSelection/containers/PlanSelectionScreen.js b/src/features/planSelection/containers/PlanSelectionScreen.js index dff9051d8..6e8cdbf47 100644 --- a/src/features/planSelection/containers/PlanSelectionScreen.js +++ b/src/features/planSelection/containers/PlanSelectionScreen.js @@ -43,13 +43,10 @@ class PlanSelectionScreen extends Component { } upgradeAccount(planId) { - const { upgradeAccount, hideOverlay } = this.props.actions.planSelection; + const { upgradeAccount } = this.props.actions.payment; upgradeAccount({ planId, - onCloseWindow: () => { - hideOverlay(); - }, }); } @@ -63,7 +60,7 @@ class PlanSelectionScreen extends Component { const { user, features } = this.props.stores; const { plans, currency } = features.features.pricingConfig; const { activateTrial } = this.props.actions.user; - const { upgradeAccount, downgradeAccount, hideOverlay } = this.props.actions.planSelection; + const { downgradeAccount, hideOverlay } = this.props.actions.planSelection; return ( @@ -102,7 +99,7 @@ class PlanSelectionScreen extends Component { downgradeAccount(); hideOverlay(); } else { - upgradeAccount(plans.personal.yearly.id); + this.upgradeAccount(plans.personal.yearly.id); gaEvent(GA_CATEGORY_PLAN_SELECTION, 'SelectPlan', 'Revoke'); } @@ -123,8 +120,10 @@ PlanSelectionScreen.wrappedComponent.propTypes = { user: PropTypes.instanceOf(UserStore).isRequired, }).isRequired, actions: PropTypes.shape({ - planSelection: PropTypes.shape({ + payment: PropTypes.shape({ upgradeAccount: PropTypes.func.isRequired, + }), + planSelection: PropTypes.shape({ downgradeAccount: PropTypes.func.isRequired, hideOverlay: PropTypes.func.isRequired, }), diff --git a/src/features/planSelection/store.js b/src/features/planSelection/store.js index e229c37e5..0d4672722 100644 --- a/src/features/planSelection/store.js +++ b/src/features/planSelection/store.js @@ -42,7 +42,6 @@ export default class PlanSelectionStore extends FeatureStore { // ACTIONS this._registerActions(createActionBindings([ - [planSelectionActions.upgradeAccount, this._upgradeAccount], [planSelectionActions.downgradeAccount, this._downgradeAccount], [planSelectionActions.hideOverlay, this._hideOverlay], ])); @@ -64,47 +63,12 @@ export default class PlanSelectionStore extends FeatureStore { @action stop() { super.stop(); debug('PlanSelectionStore::stop'); - this.reset(); this.isFeatureActive = false; } // ========== PRIVATE METHODS ========= // // Actions - - @action _upgradeAccount = ({ planId, onCloseWindow = () => null }) => { - let hostedPageURL = this.stores.features.features.subscribeURL; - - const parsedUrl = new URL(hostedPageURL); - const params = new URLSearchParams(parsedUrl.search.slice(1)); - - params.set('plan', planId); - - hostedPageURL = this.stores.user.getAuthURL(`${parsedUrl.origin}${parsedUrl.pathname}?${params.toString()}`); - - const win = new BrowserWindow({ - parent: remote.getCurrentWindow(), - modal: true, - title: '🔒 Upgrade Your Franz Account', - width: 800, - height: window.innerHeight - 100, - maxWidth: 800, - minWidth: 600, - webPreferences: { - nodeIntegration: true, - webviewTag: true, - }, - }); - win.loadURL(`file://${__dirname}/../../index.html#/payment/${encodeURIComponent(hostedPageURL)}`); - - win.on('closed', () => { - this.stores.user.getUserInfoRequest.invalidate({ immediately: true }); - this.stores.features.featuresRequest.invalidate({ immediately: true }); - - onCloseWindow(); - }); - }; - @action _downgradeAccount = () => { downgradeUserRequest.execute(); } @@ -112,4 +76,8 @@ export default class PlanSelectionStore extends FeatureStore { @action _hideOverlay = () => { this.hideOverlay = true; } + + @action _showOverlay = () => { + this.hideOverlay = false; + } } 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 @@ +import PropTypes from 'prop-types'; +import { createActionsFromDefinitions } from '../../actions/lib/actions'; + +export const trialStatusBarActions = createActionsFromDefinitions({ + upgradeAccount: { + planId: PropTypes.string.isRequired, + onCloseWindow: PropTypes.func.isRequired, + }, + downgradeAccount: {}, + hideOverlay: {}, +}, PropTypes.checkPropTypes); + +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..80d478d8c --- /dev/null +++ b/src/features/trialStatusBar/components/ProgressBar.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import injectSheet from 'react-jss'; + +const styles = theme => ({ + root: { + background: theme.trialStatusBar.progressBar.background, + width: '25%', + maxWidth: 200, + height: 8, + display: 'flex', + alignItems: 'center', + borderRadius: theme.borderRadius, + overflow: 'hidden', + }, + progress: { + background: theme.trialStatusBar.progressBar.progressIndicator, + width: ({ percent }) => `${percent}%`, + height: '100%', + }, +}); + +@injectSheet(styles) @observer +class ProgressBar extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + percent: PropTypes.number.isRequired, + }; + + render() { + const { + classes, + } = this.props; + + return ( +
+
+
+ ); + } +} + +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 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import injectSheet from 'react-jss'; +import { defineMessages, intlShape } from 'react-intl'; +import { Icon } from '@meetfranz/ui'; +import { mdiArrowRight, mdiWindowClose } from '@mdi/js'; +import classnames from 'classnames'; + +import ProgressBar from './ProgressBar'; + +const messages = defineMessages({ + restTime: { + id: 'feature.trialStatusBar.restTime', + defaultMessage: '!!!Your Free Franz {plan} Trial ends in {time}.', + }, + expired: { + id: 'feature.trialStatusBar.expired', + defaultMessage: '!!!Your free Franz {plan} Trial has expired, please upgrade your account.', + }, + cta: { + id: 'feature.trialStatusBar.cta', + defaultMessage: '!!!Upgrade now', + }, +}); + +const styles = theme => ({ + root: { + background: theme.trialStatusBar.bar.background, + width: '100%', + height: 25, + order: 10, + display: 'flex', + alignItems: 'center', + fontSize: 12, + padding: [0, 10], + justifyContent: 'flex-end', + }, + ended: { + background: theme.styleTypes.warning.accent, + color: theme.styleTypes.warning.contrast, + }, + message: { + marginLeft: 20, + }, + action: { + marginLeft: 20, + fontSize: 12, + color: theme.colorText, + textDecoration: 'underline', + display: 'flex', + + '& svg': { + margin: [1, 2, 0, 0], + }, + }, +}); + +@injectSheet(styles) @observer +class TrialStatusBar extends Component { + static propTypes = { + planName: PropTypes.string.isRequired, + percent: PropTypes.number.isRequired, + upgradeAccount: PropTypes.func.isRequired, + hideOverlay: PropTypes.func.isRequired, + trialEnd: PropTypes.string.isRequired, + hasEnded: PropTypes.bool.isRequired, + classes: PropTypes.object.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + planName, + percent, + upgradeAccount, + hideOverlay, + trialEnd, + hasEnded, + classes, + } = this.props; + + const { intl } = this.context; + + return ( +
+ + {' '} + + {!hasEnded ? ( + intl.formatMessage(messages.restTime, { + plan: planName, + time: trialEnd, + }) + ) : ( + intl.formatMessage(messages.expired, { + plan: planName, + }) + )} + + + +
+ ); + } +} + +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..eb0aafaea --- /dev/null +++ b/src/features/trialStatusBar/containers/TrialStatusBarScreen.js @@ -0,0 +1,101 @@ +import React, { Component } from 'react'; +import { observer, inject } from 'mobx-react'; +import PropTypes from 'prop-types'; +import ms from 'ms'; + +import FeaturesStore from '../../../stores/FeaturesStore'; +import UserStore from '../../../stores/UserStore'; +import TrialStatusBar from '../components/TrialStatusBar'; +import ErrorBoundary from '../../../components/util/ErrorBoundary'; +import { trialStatusBarStore } from '..'; + +@inject('stores', 'actions') @observer +class TrialStatusBarScreen extends Component { + state = { + showOverlay: true, + percent: 0, + restTime: '', + hasEnded: false, + }; + + percentInterval = null; + + componentDidMount() { + this.percentInterval = setInterval(() => { + this.calculateRestTime(); + }, ms('1m')); + + this.calculateRestTime(); + } + + componentWillUnmount() { + clearInterval(this.percentInterval); + } + + calculateRestTime() { + const { trialEndTime } = trialStatusBarStore; + const percent = Math.abs(100 - Math.abs(trialEndTime.asMilliseconds()) * 100 / ms('14d')).toFixed(2); + const restTime = trialEndTime.humanize(); + const hasEnded = trialEndTime.asMilliseconds() > 0; + + this.setState({ + percent, + restTime, + hasEnded, + }); + } + + hideOverlay() { + this.setState({ + showOverlay: false, + }); + } + + + render() { + const { + showOverlay, + percent, + restTime, + hasEnded, + } = this.state; + + if (!trialStatusBarStore || !trialStatusBarStore.isFeatureActive || !showOverlay || !trialStatusBarStore.showTrialStatusBarOverlay) { + return null; + } + + const { user } = this.props.stores; + const { upgradeAccount } = this.props.actions.payment; + + console.log('hasEnded', hasEnded); + + return ( + + upgradeAccount({ + planId: user.team.plan, + })} + hideOverlay={() => this.hideOverlay()} + hasEnded={hasEnded} + /> + + ); + } +} + +export default TrialStatusBarScreen; + +TrialStatusBarScreen.wrappedComponent.propTypes = { + stores: PropTypes.shape({ + features: PropTypes.instanceOf(FeaturesStore).isRequired, + user: PropTypes.instanceOf(UserStore).isRequired, + }).isRequired, + actions: PropTypes.shape({ + payment: PropTypes.shape({ + upgradeAccount: PropTypes.func.isRequired, + }), + }).isRequired, +}; 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 @@ +import { reaction } from 'mobx'; +import TrialStatusBarStore from './store'; + +const debug = require('debug')('Franz:feature:trialStatusBar'); + +export const GA_CATEGORY_TRIAL_STATUS_BAR = 'trialStatusBar'; + +export const trialStatusBarStore = new TrialStatusBarStore(); + +export default function initTrialStatusBar(stores, actions) { + stores.trialStatusBar = trialStatusBarStore; + const { features } = stores; + + // Toggle trialStatusBar feature + reaction( + () => features.features.isTrialStatusBarEnabled, + (isEnabled) => { + if (isEnabled) { + debug('Initializing `trialStatusBar` feature'); + trialStatusBarStore.start(stores, actions); + } else if (trialStatusBarStore.isFeatureActive) { + debug('Disabling `trialStatusBar` feature'); + trialStatusBarStore.stop(); + } + }, + { + fireImmediately: true, + }, + ); +} 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 @@ +import { + action, + observable, + computed, +} from 'mobx'; +import moment from 'moment'; + +import { trialStatusBarActions } from './actions'; +import { FeatureStore } from '../utils/FeatureStore'; +import { createActionBindings } from '../utils/ActionBinding'; + +const debug = require('debug')('Franz:feature:trialStatusBar:store'); + +export default class TrialStatusBarStore extends FeatureStore { + @observable isFeatureActive = false; + + @observable isFeatureEnabled = false; + + @computed get showTrialStatusBarOverlay() { + if (this.isFeatureActive) { + const { team } = this.stores.user; + if (team && !this.hideOverlay) { + return team.state !== 'expired' && team.isTrial; + } + } + + return false; + } + + @computed get trialEndTime() { + if (this.isFeatureActive) { + const { team } = this.stores.user; + + if (team && !this.hideOverlay) { + return moment.duration(moment().diff(team.trialEnd)); + } + } + + return moment.duration(); + } + + // ========== PUBLIC API ========= // + + @action start(stores, actions, api) { + debug('TrialStatusBarStore::start'); + this.stores = stores; + this.actions = actions; + this.api = api; + + // ACTIONS + + this._registerActions(createActionBindings([ + [trialStatusBarActions.hideOverlay, this._hideOverlay], + ])); + + this.isFeatureActive = true; + } + + @action stop() { + super.stop(); + debug('TrialStatusBarStore::stop'); + this.isFeatureActive = false; + } + + // ========== PRIVATE METHODS ========= // + + // Actions + + @action _hideOverlay = () => { + this.hideOverlay = true; + } +} -- cgit v1.2.3-54-g00ecf