From 91a0fb20ef02dfa342cf26df3e047b2bd4370b9f Mon Sep 17 00:00:00 2001 From: Stefan Malzner Date: Tue, 15 Oct 2019 21:40:14 +0200 Subject: simplify plan selection --- src/features/planSelection/actions.js | 13 ++ src/features/planSelection/api.js | 26 +++ src/features/planSelection/components/PlanItem.js | 186 ++++++++++++++++ .../planSelection/components/PlanSelection.js | 233 +++++++++++++++++++++ .../containers/PlanSelectionScreen.js | 138 ++++++++++++ src/features/planSelection/index.js | 30 +++ src/features/planSelection/store.js | 113 ++++++++++ src/features/shareFranz/index.js | 3 +- 8 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 src/features/planSelection/actions.js create mode 100644 src/features/planSelection/api.js create mode 100644 src/features/planSelection/components/PlanItem.js create mode 100644 src/features/planSelection/components/PlanSelection.js create mode 100644 src/features/planSelection/containers/PlanSelectionScreen.js create mode 100644 src/features/planSelection/index.js create mode 100644 src/features/planSelection/store.js (limited to 'src/features') diff --git a/src/features/planSelection/actions.js b/src/features/planSelection/actions.js new file mode 100644 index 000000000..21aa38ace --- /dev/null +++ b/src/features/planSelection/actions.js @@ -0,0 +1,13 @@ +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); + +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 @@ +import { sendAuthRequest } from '../../api/utils/auth'; +import { API, API_VERSION } from '../../environment'; +import Request from '../../stores/lib/Request'; + +const debug = require('debug')('Franz:feature:planSelection:api'); + +export const planSelectionApi = { + downgrade: async () => { + const url = `${API}/${API_VERSION}/payment/downgrade`; + const options = { + method: 'PUT', + }; + debug('downgrade UPDATE', url, options); + const result = await sendAuthRequest(url, options); + debug('downgrade RESULT', result); + if (!result.ok) throw result; + + return result.ok; + }, +}; + +export const downgradeUserRequest = new Request(planSelectionApi, 'downgrade'); + +export const resetApiRequests = () => { + downgradeUserRequest.reset(); +}; diff --git a/src/features/planSelection/components/PlanItem.js b/src/features/planSelection/components/PlanItem.js new file mode 100644 index 000000000..a49cd40d3 --- /dev/null +++ b/src/features/planSelection/components/PlanItem.js @@ -0,0 +1,186 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import injectSheet from 'react-jss'; +import classnames from 'classnames'; +import color from 'color'; + +import { H2 } from '@meetfranz/ui'; + +import { Button } from '@meetfranz/forms'; +import { mdiArrowRight } from '@mdi/js'; +// import { FeatureList } from '../ui/FeatureList'; +// import { PLANS, PAYMENT_INTERVAL } from '../../config'; +// import { i18nPlanName, i18nIntervalName } from '../../helpers/plan-helpers'; +// import { PLAN_INTERVAL_CONFIG_TYPE } from './types'; + +const messages = defineMessages({ + perMonth: { + id: 'subscription.interval.perMonth', + defaultMessage: '!!!per month', + }, + perMonthPerUser: { + id: 'subscription.interval.perMonthPerUser', + defaultMessage: '!!!per month & user', + }, +}); + +const styles = theme => ({ + root: { + display: 'flex', + flexDirection: 'column', + borderRadius: theme.borderRadius, + flex: 1, + color: theme.styleTypes.primary.accent, + overflow: 'hidden', + textAlign: 'center', + + '& h2': { + textAlign: 'center', + marginBottom: 20, + fontSize: 30, + color: theme.styleTypes.primary.contrast, + // fontWeight: 'bold', + }, + }, + currency: { + fontSize: 35, + }, + priceWrapper: { + height: 50, + }, + price: { + fontSize: 50, + + '& sup': { + fontSize: 20, + verticalAlign: 20, + }, + }, + interval: { + // paddingBottom: 40, + }, + text: { + marginBottom: 'auto', + }, + cta: { + background: theme.styleTypes.primary.accent, + color: theme.styleTypes.primary.contrast, + margin: [40, 'auto', 0, 'auto'], + + // '&:active': { + // opacity: 0.7, + // }, + }, + divider: { + width: 40, + border: 0, + borderTop: [1, 'solid', theme.styleTypes.primary.contrast], + margin: [30, 'auto'], + }, + header: { + padding: 20, + background: color(theme.styleTypes.primary.accent).darken(0.25).hex(), + color: theme.styleTypes.primary.contrast, + }, + content: { + padding: 20, + // border: [1, 'solid', 'red'], + background: '#EFEFEF', + }, + simpleCTA: { + background: 'none', + color: theme.styleTypes.primary.accent, + + '& svg': { + fill: theme.styleTypes.primary.accent, + }, + }, +}); + + +export default @observer @injectSheet(styles) class PlanItem extends Component { + static propTypes = { + name: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + price: PropTypes.number.isRequired, + currency: PropTypes.string.isRequired, + upgrade: PropTypes.func.isRequired, + ctaLabel: PropTypes.string.isRequired, + simpleCTA: PropTypes.bool, + perUser: PropTypes.bool, + classes: PropTypes.object.isRequired, + children: PropTypes.element, + }; + + static defaultProps = { + simpleCTA: false, + perUser: false, + children: null, + } + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + name, + text, + price, + currency, + classes, + upgrade, + ctaLabel, + simpleCTA, + perUser, + children, + } = this.props; + const { intl } = this.context; + + const priceParts = `${price}`.split('.'); + // const intervalName = i18nIntervalName(PAYMENT_INTERVAL.MONTHLY, intl); + + return ( +
+
+

{name}

+

+ {text} +

+
+

+ {currency} + + {priceParts[0]} + {priceParts[1]} + +

+

+ {intl.formatMessage(perUser ? messages.perMonthPerUser : messages.perMonth)} +

+
+ +
+ {children} + +
+ +
+ ); + } +} diff --git a/src/features/planSelection/components/PlanSelection.js b/src/features/planSelection/components/PlanSelection.js new file mode 100644 index 000000000..84d2d9e89 --- /dev/null +++ b/src/features/planSelection/components/PlanSelection.js @@ -0,0 +1,233 @@ +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 { H1, H2, Icon } from '@meetfranz/ui'; +import color from 'color'; + +import { mdiRocket } from '@mdi/js'; +import PlanItem from './PlanItem'; +import { i18nPlanName } from '../../../helpers/plan-helpers'; +import { PLANS } from '../../../config'; +import { FeatureList } from '../../../components/ui/FeatureList'; + +const messages = defineMessages({ + welcome: { + id: 'feature.planSelection.fullscreen.welcome', + defaultMessage: '!!!Welcome back, {name}', + }, + subheadline: { + id: 'feature.planSelection.fullscreen.subheadline', + 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.', + }, + textFree: { + id: 'feature.planSelection.free.text', + defaultMessage: '!!!Basic functionality', + }, + textPersonal: { + id: 'feature.planSelection.personal.text', + defaultMessage: '!!!More services, no waiting - ideal for personal use.', + }, + textProfessional: { + id: 'feature.planSelection.pro.text', + defaultMessage: '!!!Unlimited services and professional features for you - and your team.', + }, + ctaStayOnFree: { + id: 'feature.planSelection.cta.stayOnFree', + defaultMessage: '!!!Stay on Free', + }, + ctaDowngradeFree: { + id: 'feature.planSelection.cta.ctaDowngradeFree', + defaultMessage: '!!!Downgrade to Free', + }, + actionTrial: { + id: 'feature.planSelection.cta.trial', + defaultMessage: '!!!Start my free 14-days Trial', + }, + shortActionPersonal: { + id: 'feature.planSelection.cta.upgradePersonal', + defaultMessage: '!!!Choose Personal', + }, + shortActionPro: { + id: 'feature.planSelection.cta.upgradePro', + defaultMessage: '!!!Choose Professional', + }, + fullFeatureList: { + id: 'feature.planSelection.fullFeatureList', + defaultMessage: '!!!Complete comparison of all plans', + }, +}); + +const styles = theme => ({ + root: { + background: theme.colorModalOverlayBackground, + width: '100%', + height: '100%', + position: 'absolute', + top: 0, + left: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 999999, + }, + container: { + width: '80%', + height: 'auto', + background: theme.styleTypes.primary.accent, + padding: 40, + borderRadius: theme.borderRadius, + maxWidth: 1000, + + '& h1, & h2': { + textAlign: 'center', + }, + }, + plans: { + display: 'flex', + margin: [40, 0, 0], + height: 'auto', + + '& > div': { + margin: [0, 15], + flex: 1, + height: 'auto', + background: theme.styleTypes.primary.contrast, + boxShadow: [0, 2, 30, color('#000').alpha(0.1).rgb().string()], + }, + }, + bigIcon: { + background: theme.styleTypes.danger.accent, + width: 120, + height: 120, + display: 'flex', + alignItems: 'center', + borderRadius: '100%', + justifyContent: 'center', + margin: [-100, 'auto', 20], + + '& svg': { + width: '80px !important', + }, + }, + headline: { + fontSize: 40, + }, + subheadline: { + maxWidth: 660, + fontSize: 22, + lineHeight: 1.1, + margin: [0, 'auto'], + }, + featureList: { + '& li': { + borderBottom: [1, 'solid', '#CECECE'], + }, + }, + fullFeatureList: { + marginTop: 40, + textAlign: 'center', + display: 'block', + color: `${theme.styleTypes.primary.contrast} !important`, + }, +}); + +@injectSheet(styles) @observer +class PlanSelection extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + firstname: PropTypes.string.isRequired, + plans: PropTypes.object.isRequired, + currency: PropTypes.string.isRequired, + subscriptionExpired: PropTypes.bool.isRequired, + upgradeAccount: PropTypes.func.isRequired, + stayOnFree: PropTypes.func.isRequired, + hadSubscription: PropTypes.bool.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + render() { + const { + classes, + firstname, + plans, + currency, + subscriptionExpired, + upgradeAccount, + stayOnFree, + hadSubscription, + } = this.props; + + const { intl } = this.context; + + return ( +
+
+
+ +
+

{intl.formatMessage(messages.welcome, { name: firstname })}

+

{intl.formatMessage(messages.subheadline)}

+
+ stayOnFree()} + simpleCTA + > + + + upgradeAccount(plans.personal.yearly.id)} + > + + + upgradeAccount(plans.personal.yearly.id)} + perUser + > + + +
+ + {intl.formatMessage(messages.fullFeatureList)} + +
+
+ ); + } +} + +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..b0d9b5ab5 --- /dev/null +++ b/src/features/planSelection/containers/PlanSelectionScreen.js @@ -0,0 +1,138 @@ +import React, { Component } from 'react'; +import { observer, inject } from 'mobx-react'; +import PropTypes from 'prop-types'; +import { remote } from 'electron'; +import { defineMessages, intlShape } from 'react-intl'; + +import FeaturesStore from '../../../stores/FeaturesStore'; +import UserStore from '../../../stores/UserStore'; +import PlanSelection from '../components/PlanSelection'; +import ErrorBoundary from '../../../components/util/ErrorBoundary'; +import { planSelectionStore } from '..'; + +const { dialog, app } = remote; + +const messages = defineMessages({ + dialogTitle: { + id: 'feature.planSelection.fullscreen.dialog.title', + defaultMessage: '!!!Downgrade your Franz Plan', + }, + dialogMessage: { + id: 'feature.planSelection.fullscreen.dialog.message', + 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.', + }, + dialogCTADowngrade: { + id: 'feature.planSelection.fullscreen.dialog.cta.downgrade', + defaultMessage: '!!!Downgrade to Free', + }, + dialogCTAUpgrade: { + id: 'feature.planSelection.fullscreen.dialog.cta.upgrade', + defaultMessage: '!!!Choose Personal', + }, +}); + +@inject('stores', 'actions') @observer +class PlanSelectionScreen extends Component { + static contextTypes = { + intl: intlShape, + }; + + upgradeAccount(planId) { + const { user, features } = this.props.stores; + const { upgradeAccount, hideOverlay } = this.props.actions.planSelection; + + upgradeAccount({ + planId, + onCloseWindow: () => { + hideOverlay(); + user.getUserInfoRequest.invalidate({ immediately: true }); + features.featuresRequest.invalidate({ immediately: true }); + }, + }); + } + + render() { + if (!planSelectionStore || !planSelectionStore.isFeatureActive || !planSelectionStore.showPlanSelectionOverlay) { + return null; + } + + const { intl } = this.context; + + 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 planConfig = [{ + // id: 'free', + // price: 0, + // }, { + // id: plans.personal.yearly.id, + // price: plans.personal.yearly.price, + // }, { + // id: plans.pro.yearly.id, + // price: plans.pro.yearly.price, + // }]; + + return ( + + { + if (user.data.hadSubscription) { + this.upgradeAccount(planId); + } else { + activateTrial({ + planId, + }); + } + }} + stayOnFree={() => { + const selection = dialog.showMessageBoxSync(app.mainWindow, { + type: 'question', + message: intl.formatMessage(messages.dialogTitle), + detail: intl.formatMessage(messages.dialogMessage, { + currency, + price: plans.personal.yearly.price, + }), + buttons: [ + intl.formatMessage(messages.dialogCTADowngrade), + intl.formatMessage(messages.dialogCTAUpgrade), + ], + }); + + if (selection === 0) { + downgradeAccount(); + hideOverlay(); + } else { + upgradeAccount(plans.personal.yearly.id); + } + }} + subscriptionExpired={user.team && user.team.state === 'expired' && !user.team.userHasDowngraded} + hadSubscription={user.data.hadSubscription} + /> + + ); + } +} + +export default PlanSelectionScreen; + +PlanSelectionScreen.wrappedComponent.propTypes = { + stores: PropTypes.shape({ + features: PropTypes.instanceOf(FeaturesStore).isRequired, + user: PropTypes.instanceOf(UserStore).isRequired, + }).isRequired, + actions: PropTypes.shape({ + planSelection: PropTypes.shape({ + upgradeAccount: PropTypes.func.isRequired, + downgradeAccount: PropTypes.func.isRequired, + hideOverlay: PropTypes.func.isRequired, + }), + user: PropTypes.shape({ + activateTrial: PropTypes.func.isRequired, + }), + }).isRequired, +}; 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 @@ +import { reaction } from 'mobx'; +import PlanSelectionStore from './store'; + +const debug = require('debug')('Franz:feature:planSelection'); + +export const GA_CATEGORY_PLAN_SELECTION = 'planSelection'; + +export const planSelectionStore = new PlanSelectionStore(); + +export default function initPlanSelection(stores, actions) { + stores.planSelection = planSelectionStore; + const { features } = stores; + + // Toggle planSelection feature + reaction( + () => features.features.isPlanSelectionEnabled, + (isEnabled) => { + if (isEnabled) { + debug('Initializing `planSelection` feature'); + planSelectionStore.start(stores, actions); + } else if (planSelectionStore.isFeatureActive) { + debug('Disabling `planSelection` feature'); + planSelectionStore.stop(); + } + }, + { + fireImmediately: true, + }, + ); +} diff --git a/src/features/planSelection/store.js b/src/features/planSelection/store.js new file mode 100644 index 000000000..50e46dfb3 --- /dev/null +++ b/src/features/planSelection/store.js @@ -0,0 +1,113 @@ +import { + action, + observable, + computed, +} from 'mobx'; +import { remote } from 'electron'; + +import { planSelectionActions } from './actions'; +import { FeatureStore } from '../utils/FeatureStore'; +// import { createReactions } from '../../stores/lib/Reaction'; +import { createActionBindings } from '../utils/ActionBinding'; +import { downgradeUserRequest } from './api'; + +const debug = require('debug')('Franz:feature:planSelection:store'); + +const { BrowserWindow } = remote; + +export default class PlanSelectionStore extends FeatureStore { + @observable isFeatureEnabled = false; + + @observable isFeatureActive = false; + + @observable hideOverlay = false; + + @computed get showPlanSelectionOverlay() { + const { team } = this.stores.user; + if (team && !this.hideOverlay) { + return team.state === 'expired' && !team.userHasDowngraded; + } + + return false; + } + + // ========== PUBLIC API ========= // + + @action start(stores, actions, api) { + debug('PlanSelectionStore::start'); + this.stores = stores; + this.actions = actions; + this.api = api; + + // ACTIONS + + this._registerActions(createActionBindings([ + [planSelectionActions.upgradeAccount, this._upgradeAccount], + [planSelectionActions.downgradeAccount, this._downgradeAccount], + [planSelectionActions.hideOverlay, this._hideOverlay], + ])); + + // REACTIONS + + // this._allReactions = createReactions([ + // this._setFeatureEnabledReaction, + // this._updateTodosConfig, + // this._firstLaunchReaction, + // this._routeCheckReaction, + // ]); + + // this._registerReactions(this._allReactions); + + this.isFeatureActive = true; + } + + @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', () => { + onCloseWindow(); + }); + }; + + @action _downgradeAccount = () => { + console.log('downgrade to free', downgradeUserRequest); + downgradeUserRequest.execute(); + } + + @action _hideOverlay = () => { + this.hideOverlay = true; + } +} diff --git a/src/features/shareFranz/index.js b/src/features/shareFranz/index.js index 87deacef4..a39d7a6e6 100644 --- a/src/features/shareFranz/index.js +++ b/src/features/shareFranz/index.js @@ -3,6 +3,7 @@ import ms from 'ms'; import { state as delayAppState } from '../delayApp'; import { gaEvent, gaPage } from '../../lib/analytics'; +import { planSelectionStore } from '../planSelection'; export { default as Component } from './Component'; @@ -35,7 +36,7 @@ export default function initialize(stores) { () => stores.user.isLoggedIn, () => { setTimeout(() => { - if (stores.settings.stats.appStarts % 50 === 0) { + if (stores.settings.stats.appStarts % 50 === 0 && !planSelectionStore.showPlanSelectionOverlay) { if (delayAppState.isDelayAppScreenVisible) { debug('Delaying share modal by 5 minutes'); setTimeout(() => showModal(), ms('5m')); -- cgit v1.2.3-54-g00ecf