feat(usage): add new feature usage modal

Summary:
This adds the "You've reached max features" modal in N1.

http://g.recordit.co/9O7R0mLlXE.gif

Test Plan:
1. Pull latest nylas/cloud-core and start Billing site:
```
  cd cloud-core
  vagrant up
  vagrant ssh
  cd /vagrant
  bin/setup-up-feature-usage
  bin/launch
```
2. Blow away ~/.nylas-mail (err backup your old one first)
3. Restart N1
4. Before logging in, edit `~/.nylas-mail/config.json`
   - set env to "local"
   - remove `thread-snooze` from the list of `disabledPlugins`
5. `cd /nylas-mail/src/k2` and run `npm start`
6. Restart N1 and create accounts & log in

Reviewers: khamidou, juan, halla

Reviewed By: halla

Differential Revision: https://phab.nylas.com/D3846
This commit is contained in:
Evan Morikawa 2017-02-07 15:46:57 -05:00
parent fbce62d97b
commit 7aefb73ef8
12 changed files with 304 additions and 66 deletions

View file

@ -1,59 +1,8 @@
import React from 'react';
import {Actions, IdentityStore} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import {OpenIdentityPageButton, RetinaImg} from 'nylas-component-kit';
import {shell} from 'electron';
class OpenIdentityPageButton extends React.Component {
static propTypes = {
path: React.PropTypes.string,
label: React.PropTypes.string,
source: React.PropTypes.string,
campaign: React.PropTypes.string,
img: React.PropTypes.string,
}
constructor(props) {
super(props);
this.state = {
loading: false,
};
}
_onClick = () => {
this.setState({loading: true});
IdentityStore.fetchSingleSignOnURL(this.props.path, {
source: this.props.source,
campaign: this.props.campaign,
content: this.props.label,
}).then((url) => {
this.setState({loading: false});
shell.openExternal(url);
});
}
render() {
if (this.state.loading) {
return (
<div className="btn btn-disabled">
<RetinaImg name="sending-spinner.gif" width={15} height={15} mode={RetinaImg.Mode.ContentPreserve} />
&nbsp;{this.props.label}&hellip;
</div>
);
}
if (this.props.img) {
return (
<div className="btn" onClick={this._onClick}>
<RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} />
&nbsp;&nbsp;{this.props.label}
</div>
)
}
return (
<div className="btn" onClick={this._onClick}>{this.props.label}</div>
);
}
}
class PreferencesIdentity extends React.Component {
static displayName = 'PreferencesIdentity';

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -1,7 +1,7 @@
import {remote} from 'electron'
import _ from 'underscore';
import {FeatureUsageStore, Actions, NylasAPIHelpers, AccountStore,
import {React, FeatureUsageStore, Actions, NylasAPIHelpers, AccountStore,
DatabaseStore, Message, CategoryStore} from 'nylas-exports';
import {FeatureUsedUpModal} from 'nylas-component-kit'
import SnoozeUtils from './snooze-utils'
import {PLUGIN_ID, PLUGIN_NAME} from './snooze-constants';
import SnoozeActions from './snooze-actions';
@ -70,12 +70,45 @@ class SnoozeStore {
onSnoozeThreads = (threads, snoozeDate, label) => {
if (!FeatureUsageStore.isUsable("snooze")) {
remote.dialog.showMessageBox({
title: 'Out of snoozes',
detail: `You have used your monthly quota of Snoozes`,
buttons: ['OK'],
type: 'info',
});
const featureData = FeatureUsageStore.featureData("snooze");
let headerText = "";
let rechargeText = ""
if (!featureData.quota) {
headerText = "Snooze not yet enabled";
rechargeText = "Upgrade to Pro to start Snoozing"
} else {
headerText = "All Snoozes used";
let time = "later";
if (featureData.period === "hourly") {
time = "next hour"
} else if (featureData.period === "daily") {
time = "tomorrow"
} else if (featureData.period === "weekly") {
time = "next week"
} else if (featureData.period === "monthly") {
time = "next month"
} else if (featureData.period === "yearly") {
time = "next year"
} else if (featureData.period === "unlimited") {
time = "if you upgrade to Pro"
}
rechargeText = `Youll have ${featureData.quota} more snoozes ${time}`
}
Actions.openModal({
component: (
<FeatureUsedUpModal
modalClass="snooze"
featureName="Snooze"
headerText={headerText}
iconUrl="nylas://thread-snooze/assets/ic-snooze-modal@2x.png"
rechargeText={rechargeText}
/>
),
height: 575,
width: 412,
})
return Promise.resolve()
}
this.recordSnoozeEvent(threads, snoozeDate, label)

View file

@ -0,0 +1,20 @@
@import "ui-variables";
.feature-usage-modal.snooze {
@snooze-color: #8e6ce3;
.feature-header {
@from: @snooze-color;
@to: lighten(@snooze-color, 10%);
background: linear-gradient(to top, @from, @to);
}
.feature-name {
color: @snooze-color;
}
.pro-description {
li {
&:before {
color: @snooze-color;
}
}
}
}

2
src/K2

@ -1 +1 @@
Subproject commit 13750c7ba3f5abb4f083ab4a8eaf0f5ab14fffce
Subproject commit 32c820d2e50607f9381392bf825b5076f5accd8f

View file

@ -0,0 +1,50 @@
import React from 'react'
import {shell} from 'electron'
import RetinaImg from './retina-img'
import OpenIdentityPageButton from './open-identity-page-button'
export default function FeatureUsedUpModal(props = {}) {
const gotoFeatures = () => shell.openExternal("https://nylas.com/nylas-pro");
return (
<div className={`feature-usage-modal ${props.modalClass}`}>
<div className="feature-header">
<div className="icon">
<RetinaImg
url={props.iconUrl}
style={{position: "relative", top: "-2px"}}
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
<h2 className="header-text">{props.headerText}</h2>
<p className="recharge-text">{props.rechargeText}</p>
</div>
<div className="feature-cta">
<h2>Want to <span className="feature-name">{props.featureName} more</span>?</h2>
<div className="pro-description">
<h3>Nylas Pro includes:</h3>
<ul>
<li>Unlimited Snoozing</li>
<li>Unlimited Reminders</li>
<li>Unlimited Mail Merge</li>
</ul>
<p>&hellip; plus <a onClick={gotoFeatures}>dozens of other features</a></p>
</div>
<OpenIdentityPageButton
label="Upgrade"
path="/dashboard?upgrade_to_pro=true"
source={`${props.featureName}-Limit-Modal`}
campaign="Limit-Modals"
isCTA
/>
</div>
</div>
)
}
FeatureUsedUpModal.propTypes = {
modalClass: React.PropTypes.string.isRequired,
featureName: React.PropTypes.string.isRequired,
headerText: React.PropTypes.string.isRequired,
rechargeText: React.PropTypes.string.isRequired,
iconUrl: React.PropTypes.string.isRequired,
}

View file

@ -45,22 +45,25 @@ class Modal extends React.Component {
_computeModalStyles = (height, width) => {
const modalStyle = {
top: "50%",
left: "50%",
margin: `-${height / 2}px 0 0 -${width / 2}px`,
height: height,
maxHeight: "95%",
width: width,
maxWidth: "95%",
overflow: "auto",
position: "absolute",
backgroundColor: "white",
boxShadow: "0 10px 20px rgba(0,0,0,0.19), inset 0 0 1px rgba(0,0,0,0.5)",
borderRadius: "5px",
};
const containerStyle = {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
width: "100%",
zIndex: 1000,
position: "absolute",
backgroundColor: "transparent",
backgroundColor: "rgba(255,255,255,0.58)",
};
return {containerStyle, modalStyle};
};

View file

@ -0,0 +1,62 @@
import React from 'react';
import {shell} from 'electron';
import classnames from 'classnames'
import RetinaImg from './retina-img'
import IdentityStore from '../flux/stores/identity-store';
export default class OpenIdentityPageButton extends React.Component {
static propTypes = {
path: React.PropTypes.string,
label: React.PropTypes.string,
source: React.PropTypes.string,
campaign: React.PropTypes.string,
img: React.PropTypes.string,
isCTA: React.PropTypes.bool,
}
constructor(props) {
super(props);
this.state = {
loading: false,
};
}
_onClick = () => {
this.setState({loading: true});
IdentityStore.fetchSingleSignOnURL(this.props.path, {
source: this.props.source,
campaign: this.props.campaign,
content: this.props.label,
}).then((url) => {
this.setState({loading: false});
shell.openExternal(url);
});
}
render() {
if (this.state.loading) {
return (
<div className="btn btn-disabled">
<RetinaImg name="sending-spinner.gif" width={15} height={15} mode={RetinaImg.Mode.ContentPreserve} />
&nbsp;{this.props.label}&hellip;
</div>
);
}
if (this.props.img) {
return (
<div className="btn" onClick={this._onClick}>
<RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} />
&nbsp;&nbsp;{this.props.label}
</div>
)
}
const cls = classnames({
"btn": true,
"btn-emphasis": this.props.isCTA,
})
return (
<div className={cls} onClick={this._onClick}>{this.props.label}</div>
);
}
}

View file

@ -11,6 +11,45 @@ import SendFeatureUsageEventTask from '../tasks/send-feature-usage-event-task'
* The billing site is responsible for returning with the Identity object
* a usage hash that includes all supported features, their quotas for the
* user, and the current usage of that user. We keep a cache locally
*
* The Identity object (aka Nylas ID or N1User) has a field called
* `feature_usage`. The schema for `feature_usage` is computed dynamically
* in `compute_feature_usage` here:
* https://github.com/nylas/cloud-core/blob/master/redwood/models/n1.py#L175-207
*
* The schema of each feature is determined by the `FeatureUsage` model in
* redwood here:
* https://github.com/nylas/cloud-core/blob/master/redwood/models/feature_usage.py#L14-32
*
* The final schema looks like (Feb 7, 2017):
*
* NylasID = {
* ...
* "feature_usage": {
* "snooze": {
* "quota": 15,
* "period": "monthly",
* "used_in_period": 10,
* "feature_limit_name": "snooze-experiment-A",
* },
* "send-later": {
* "quota": 99999,
* "period": "unlimited",
* "used_in_period": 228,
* "feature_limit_name": "send-later-unlimited-A",
* },
* "reminders": {
* "quota": 10,
* "period": "daily",
* "used_in_period": 10,
* "feature_limit_name": null,
* },
* },
* ...
* }
*
* Valid periods are:
* 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'unlimited'
*/
class FeatureUsageStore extends NylasStore {
activate() {
@ -23,10 +62,19 @@ class FeatureUsageStore extends NylasStore {
})
}
featureData(feature) {
const usage = this._featureUsage()
if (!usage[feature]) {
NylasEnv.reportError(new Error(`${feature} isn't supported`));
return {}
}
return usage[feature]
}
isUsable(feature) {
const usage = this._featureUsage()
if (!usage[feature]) {
NylasEnv.reportError(`${feature} isn't supported`);
NylasEnv.reportError(new Error(`${feature} isn't supported`));
return false
}
return usage[feature].used_in_period < usage[feature].quota

View file

@ -46,6 +46,8 @@ class NylasComponentKit
@load "FixedPopover", 'fixed-popover'
@require "DatePickerPopover", 'date-picker-popover'
@load "Modal", 'modal'
@load "FeatureUsedUpModal", 'feature-used-up-modal'
@load "OpenIdentityPageButton", 'open-identity-page-button'
@load "Flexbox", 'flexbox'
@load "RetinaImg", 'retina-img'
@load "SwipeContainer", 'swipe-container'

View file

@ -0,0 +1,70 @@
@import "ui-variables";
.feature-usage-modal {
position: absolute;
top: 0;
width: 100%;
height: 100%;
.feature-header {
text-align: center;
padding-top: 32px;
padding-bottom: 30px;
color: @white;
border-radius: 5px 5px 0 0;
}
.header-text {
color: @white;
margin-top: 24px;
margin-bottom: 11px;
}
.recharge-text {
margin: 0;
opacity: 0.67;
}
.feature-cta {
text-align: center;
padding-bottom: 26px;
h2 {
margin-top: 26px;
margin-bottom: 26px;
}
}
.pro-description {
width: 275px;
margin: 0 auto;
padding-bottom: 28px;
border-top: 1px solid rgba(0,0,0,0.1);
border-bottom: 1px solid rgba(0,0,0,0.1);
ul {
color: rgba(51, 51, 51, 0.67);
text-align: left;
list-style: none;
line-height: 24px;
margin-bottom: 0;
}
li {
&:before {
content: '✓';
margin-right: 11px;
}
}
h3 {
margin-top: 23px;
margin-bottom: 18px;
}
p {
color: rgba(51, 51, 51, 0.67);
margin: 0;
text-align: left;
padding-left: 62px;
line-height: 24px;
}
}
.btn {
margin-top: 21px;
padding: 0 33px 25px 33px;
}
}

View file

@ -47,3 +47,4 @@
@import "components/attachment-items";
@import "components/search-bar";
@import "components/code-snippet";
@import "components/feature-used-up-modal";