mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-15 17:15:01 +08:00
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:
parent
fbce62d97b
commit
7aefb73ef8
12 changed files with 304 additions and 66 deletions
|
@ -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} />
|
||||
{this.props.label}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (this.props.img) {
|
||||
return (
|
||||
<div className="btn" onClick={this._onClick}>
|
||||
<RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} />
|
||||
{this.props.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="btn" onClick={this._onClick}>{this.props.label}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PreferencesIdentity extends React.Component {
|
||||
static displayName = 'PreferencesIdentity';
|
||||
|
||||
|
|
BIN
internal_packages/thread-snooze/assets/ic-snooze-modal@2x.png
Normal file
BIN
internal_packages/thread-snooze/assets/ic-snooze-modal@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.5 KiB |
|
@ -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 = `You’ll 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)
|
|
@ -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
2
src/K2
|
@ -1 +1 @@
|
|||
Subproject commit 13750c7ba3f5abb4f083ab4a8eaf0f5ab14fffce
|
||||
Subproject commit 32c820d2e50607f9381392bf825b5076f5accd8f
|
50
src/components/feature-used-up-modal.jsx
Normal file
50
src/components/feature-used-up-modal.jsx
Normal 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>… 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,
|
||||
}
|
|
@ -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};
|
||||
};
|
||||
|
|
62
src/components/open-identity-page-button.jsx
Normal file
62
src/components/open-identity-page-button.jsx
Normal 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} />
|
||||
{this.props.label}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (this.props.img) {
|
||||
return (
|
||||
<div className="btn" onClick={this._onClick}>
|
||||
<RetinaImg name={this.props.img} mode={RetinaImg.Mode.ContentPreserve} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
70
static/components/feature-used-up-modal.less
Normal file
70
static/components/feature-used-up-modal.less
Normal 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;
|
||||
}
|
||||
}
|
|
@ -47,3 +47,4 @@
|
|||
@import "components/attachment-items";
|
||||
@import "components/search-bar";
|
||||
@import "components/code-snippet";
|
||||
@import "components/feature-used-up-modal";
|
||||
|
|
Loading…
Add table
Reference in a new issue