mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 13:14:16 +08:00
[client-app] Updates to feature limiting
This is a squash of a bunch of commits releated to feature limiting. Includes making the upgrade path seamless by accessing billing through a WebView and making sure that the feature is enabled after upgrading, or canceled if the upgrade path is stopped by the user in any way. Included diffs --------------- Differential Revision: https://phab.nylas.com/D4078 Differential Revision: https://phab.nylas.com/D4136 Differential Revision: https://phab.nylas.com/D4137 Differential Revision: https://phab.nylas.com/D4143 Differential Revision: https://phab.nylas.com/D4147 Differential Revision: https://phab.nylas.com/D4171 Differential Revision: https://phab.nylas.com/D4173
This commit is contained in:
parent
965946724b
commit
4904a9ae85
14 changed files with 694 additions and 300 deletions
|
@ -1,69 +1,8 @@
|
|||
import React from 'react';
|
||||
import {shell} from 'electron'
|
||||
import classnames from 'classnames';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {IdentityStore} from 'nylas-exports';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
import networkErrors from 'chromium-net-errors';
|
||||
import {Webview} from 'nylas-component-kit';
|
||||
import OnboardingActions from './onboarding-actions';
|
||||
|
||||
class InitialLoadingCover extends React.Component {
|
||||
static propTypes = {
|
||||
ready: React.PropTypes.bool,
|
||||
error: React.PropTypes.string,
|
||||
onTryAgain: React.PropTypes.func,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._slowTimeout = setTimeout(() => {
|
||||
this.setState({slow: true});
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this._slowTimeout);
|
||||
this._slowTimeout = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const classes = classnames({
|
||||
'webview-cover': true,
|
||||
'ready': this.props.ready,
|
||||
'error': this.props.error,
|
||||
'slow': this.state.slow,
|
||||
});
|
||||
|
||||
let message = this.props.error;
|
||||
if (this.props.error) {
|
||||
message = this.props.error;
|
||||
} else if (this.state.slow) {
|
||||
message = "Still trying to reach Nylas…";
|
||||
} else {
|
||||
message = ' '
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div style={{flex: 1}} />
|
||||
<RetinaImg
|
||||
className="spinner"
|
||||
style={{width: 20, height: 20}}
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
/>
|
||||
<div className="message">{message}</div>
|
||||
<div className="btn try-again" onClick={this.props.onTryAgain}>Try Again</div>
|
||||
<div style={{flex: 1}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class AuthenticatePage extends React.Component {
|
||||
static displayName = "AuthenticatePage";
|
||||
|
||||
|
@ -71,72 +10,12 @@ export default class AuthenticatePage extends React.Component {
|
|||
accountInfo: React.PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
ready: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const webview = ReactDOM.findDOMNode(this.refs.webview);
|
||||
_src() {
|
||||
const n1Version = NylasEnv.getVersion();
|
||||
webview.partition = 'in-memory-only';
|
||||
webview.src = `${IdentityStore.URLRoot}/onboarding?utm_medium=N1&utm_source=OnboardingPage&N1_version=${n1Version}&client_edition=basic`;
|
||||
webview.addEventListener('did-start-loading', this.webviewDidStartLoading);
|
||||
webview.addEventListener('did-get-response-details', this.webviewDidGetResponseDetails);
|
||||
webview.addEventListener('did-fail-load', this.webviewDidFailLoad);
|
||||
webview.addEventListener('did-finish-load', this.webviewDidFinishLoad);
|
||||
webview.addEventListener('console-message', (e) => {
|
||||
if (/^http.+/i.test(e.message)) { shell.openExternal(e.message) }
|
||||
console.log('Guest page logged a message:', e.message);
|
||||
});
|
||||
return `${IdentityStore.URLRoot}/onboarding?utm_medium=N1&utm_source=OnboardingPage&N1_version=${n1Version}&client_edition=basic`
|
||||
}
|
||||
|
||||
onTryAgain = () => {
|
||||
const webview = ReactDOM.findDOMNode(this.refs.webview);
|
||||
webview.reload();
|
||||
}
|
||||
|
||||
webviewDidStartLoading = () => {
|
||||
this.setState({error: null, webviewLoading: true});
|
||||
}
|
||||
|
||||
webviewDidGetResponseDetails = ({httpResponseCode, originalURL}) => {
|
||||
if (!originalURL.includes(IdentityStore.URLRoot)) {
|
||||
// This means that some other secondarily loaded resource (like
|
||||
// analytics or Linkedin, etc) got a response. We don't care about
|
||||
// that.
|
||||
return
|
||||
}
|
||||
if (httpResponseCode >= 400) {
|
||||
const error = `
|
||||
Could not reach Nylas to sign in. Please try again or contact
|
||||
support@nylas.com if the issue persists.
|
||||
(${originalURL}: ${httpResponseCode})
|
||||
`;
|
||||
this.setState({ready: false, error: error, webviewLoading: false});
|
||||
}
|
||||
};
|
||||
|
||||
webviewDidFailLoad = ({errorCode, validatedURL}) => {
|
||||
// "Operation was aborted" can be fired when we move between pages quickly.
|
||||
if (errorCode === -3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const e = networkErrors.createByCode(errorCode);
|
||||
const error = `Could not reach ${validatedURL}. ${e ? e.message : errorCode}`;
|
||||
this.setState({ready: false, error: error, webviewLoading: false});
|
||||
}
|
||||
|
||||
webviewDidFinishLoad = () => {
|
||||
// this is sometimes called right after did-fail-load
|
||||
if (this.state.error) return;
|
||||
|
||||
const webview = ReactDOM.findDOMNode(this.refs.webview);
|
||||
|
||||
_onDidFinishLoad = (webview) => {
|
||||
const receiveUserInfo = `
|
||||
var a = document.querySelector('#pro-account');
|
||||
result = a ? a.innerText : null;
|
||||
|
@ -158,19 +37,7 @@ export default class AuthenticatePage extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<div className="page authenticate">
|
||||
<webview ref="webview" is partition="in-memory-only" />
|
||||
<div className={`webview-loading-spinner loading-${this.state.webviewLoading}`}>
|
||||
<RetinaImg
|
||||
style={{width: 20, height: 20}}
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
/>
|
||||
</div>
|
||||
<InitialLoadingCover
|
||||
ready={this.state.ready}
|
||||
error={this.state.error}
|
||||
onTryAgain={this.onTryAgain}
|
||||
/>
|
||||
<Webview src={this._src()} onDidFinishLoad={this._onDidFinishLoad} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import {Actions, IdentityStore} from 'nylas-exports';
|
||||
import {OpenIdentityPageButton, RetinaImg} from 'nylas-component-kit';
|
||||
import {OpenIdentityPageButton, BillingModal, RetinaImg} from 'nylas-component-kit';
|
||||
import {shell} from 'electron';
|
||||
|
||||
class PreferencesIdentity extends React.Component {
|
||||
|
@ -8,12 +8,12 @@ class PreferencesIdentity extends React.Component {
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = this.getStateFromStores();
|
||||
this.state = this._getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = IdentityStore.listen(() => {
|
||||
this.setState(this.getStateFromStores());
|
||||
this.setState(this._getStateFromStores());
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -21,25 +21,60 @@ class PreferencesIdentity extends React.Component {
|
|||
this.unsubscribe();
|
||||
}
|
||||
|
||||
getStateFromStores() {
|
||||
_getStateFromStores() {
|
||||
return {
|
||||
identity: IdentityStore.identity() || {},
|
||||
};
|
||||
}
|
||||
|
||||
_onUpgrade = () => {
|
||||
Actions.openModal({
|
||||
component: (
|
||||
<BillingModal source="preferences" />
|
||||
),
|
||||
height: 575,
|
||||
width: 412,
|
||||
})
|
||||
}
|
||||
|
||||
_renderBasic() {
|
||||
const learnMore = () => shell.openExternal("https://nylas.com/nylas-pro")
|
||||
return (
|
||||
<div className="row padded">
|
||||
<div>
|
||||
You are using <strong>Nylas Mail Basic</strong>. Upgrade to Nylas Mail Pro to unlock a more powerful email experience.
|
||||
</div>
|
||||
<div className="subscription-actions">
|
||||
<div className="btn btn-emphasis" onClick={this._onUpgrade} style={{verticalAlign: "top"}}>Upgrade to Nylas Mail Pro</div>
|
||||
<div className="btn minor-width" onClick={learnMore}>Learn More</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
_renderPro() {
|
||||
return (
|
||||
<div className="row padded">
|
||||
<div>
|
||||
Thank you for using <strong>Nylas Mail Pro</strong>
|
||||
</div>
|
||||
<div className="subscription-actions">
|
||||
<OpenIdentityPageButton label="Manage Billing" path="/dashboard#billing" source="Preferences Billing" campaign="Dashboard" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {identity} = this.state;
|
||||
const {firstname, lastname, email} = identity;
|
||||
|
||||
const logout = () => Actions.logoutNylasIdentity()
|
||||
const learnMore = () => shell.openExternal("https://nylas.com/nylas-pro")
|
||||
|
||||
return (
|
||||
<div className="container-identity">
|
||||
<div className="id-header">
|
||||
Nylas ID:
|
||||
</div>
|
||||
<div className="identity-content-box">
|
||||
|
||||
<div className="row info-row">
|
||||
<div className="logo">
|
||||
<RetinaImg
|
||||
|
@ -52,18 +87,13 @@ class PreferencesIdentity extends React.Component {
|
|||
<div className="email">{email}</div>
|
||||
<div className="identity-actions">
|
||||
<OpenIdentityPageButton label="Account Details" path="/dashboard" source="Preferences" campaign="Dashboard" />
|
||||
<OpenIdentityPageButton label="Upgrade to Nylas Pro" path="/dashboard?upgrade_to_pro=true" source="Preferences" campaign="Dashboard" />
|
||||
<div className="btn" onClick={logout}>Sign Out</div>
|
||||
<div className="btn minor-width" onClick={logout}>Sign Out</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row payment-row">
|
||||
<div>
|
||||
You are using Nylas Mail Basic. Upgrade to Nylas Pro to unlock a more powerful email experience.
|
||||
</div>
|
||||
<div className="btn" onClick={learnMore}>Learn More about Nylas Pro</div>
|
||||
</div>
|
||||
{this.state.identity.has_pro_access ? this._renderPro() : this._renderBasic()}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -3,10 +3,9 @@
|
|||
@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }
|
||||
|
||||
.container-identity {
|
||||
width: 50%;
|
||||
max-width: 887px;
|
||||
min-width: 530px;
|
||||
margin: auto;
|
||||
padding-top: @padding-base-vertical * 2;
|
||||
|
||||
.id-header {
|
||||
color: @text-color-very-subtle;
|
||||
|
@ -38,15 +37,27 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.payment-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-top: 1px solid @border-color-primary;
|
||||
.padded {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
padding-left: 137px;
|
||||
&>div:first-child {
|
||||
margin-bottom: @padding-base-vertical * 2;
|
||||
border-top: 1px solid @border-color-primary;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 180px;
|
||||
&.minor-width {
|
||||
width: 120px;
|
||||
}
|
||||
text-align: center;
|
||||
margin-right: @padding-base-horizontal;
|
||||
margin-bottom: @padding-base-horizontal;
|
||||
}
|
||||
.identity-actions {
|
||||
margin-top: @padding-small-vertical + 1;
|
||||
}
|
||||
.subscription-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
|
@ -55,19 +66,11 @@
|
|||
margin-right: 30px;
|
||||
}
|
||||
.identity-info {
|
||||
flex: 1;
|
||||
line-height: 1.9em;
|
||||
.name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.identity-actions {
|
||||
margin-top: @padding-small-vertical + 1;
|
||||
.btn {
|
||||
width: 170px;
|
||||
text-align: center;
|
||||
margin-right: @padding-base-horizontal;
|
||||
margin-bottom: @padding-base-horizontal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import _ from 'underscore';
|
||||
import {React, FeatureUsageStore, Actions, AccountStore,
|
||||
import {FeatureUsageStore, Actions, 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';
|
||||
|
@ -68,44 +67,16 @@ class SnoozeStore {
|
|||
}
|
||||
};
|
||||
|
||||
_showFeatureLimit() {
|
||||
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";
|
||||
const next = FeatureUsageStore.nextPeriodString(featureData.period)
|
||||
rechargeText = `You’ll have ${featureData.quota} more snoozes ${next}`
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
onSnoozeThreads = (threads, snoozeDate, label) => {
|
||||
if (!FeatureUsageStore.isUsable("snooze")) {
|
||||
this._showFeatureLimit();
|
||||
return Promise.resolve()
|
||||
const lexicon = {
|
||||
displayName: "Snooze",
|
||||
usedUpHeader: "All Snoozes used",
|
||||
iconUrl: "nylas://thread-snooze/assets/ic-snooze-modal@2x.png",
|
||||
}
|
||||
this.recordSnoozeEvent(threads, snoozeDate, label)
|
||||
|
||||
return FeatureUsageStore.useFeature('snooze')
|
||||
FeatureUsageStore.asyncUseFeature('snooze', {lexicon})
|
||||
.then(() => {
|
||||
this.recordSnoozeEvent(threads, snoozeDate, label)
|
||||
return SnoozeUtils.moveThreadsToSnooze(threads, this.snoozeCategoriesPromise, snoozeDate)
|
||||
})
|
||||
.then((updatedThreads) => {
|
||||
|
@ -128,10 +99,14 @@ class SnoozeStore {
|
|||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof FeatureUsageStore.NoProAccess) {
|
||||
return
|
||||
}
|
||||
SnoozeUtils.moveThreadsFromSnooze(threads, this.snoozeCategoriesPromise)
|
||||
Actions.closePopover();
|
||||
NylasEnv.reportError(error);
|
||||
NylasEnv.showErrorDialog(`Sorry, we were unable to save your snooze settings. ${error.message}`);
|
||||
return
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -11,13 +11,13 @@ describe("FeatureUsageStore", function featureUsageStoreSpec() {
|
|||
IdentityStore._identity.feature_usage = {
|
||||
"is-usable": {
|
||||
quota: 10,
|
||||
peroid: 'monthly',
|
||||
period: 'monthly',
|
||||
used_in_period: 8,
|
||||
feature_limit_name: 'Usable Group A',
|
||||
},
|
||||
"not-usable": {
|
||||
quota: 10,
|
||||
peroid: 'monthly',
|
||||
period: 'monthly',
|
||||
used_in_period: 10,
|
||||
feature_limit_name: 'Unusable Group A',
|
||||
},
|
||||
|
@ -28,23 +28,23 @@ describe("FeatureUsageStore", function featureUsageStoreSpec() {
|
|||
IdentityStore._identity = this.oldIdent
|
||||
});
|
||||
|
||||
describe("isUsable", () => {
|
||||
describe("_isUsable", () => {
|
||||
it("returns true if a feature hasn't met it's quota", () => {
|
||||
expect(FeatureUsageStore.isUsable("is-usable")).toBe(true)
|
||||
expect(FeatureUsageStore._isUsable("is-usable")).toBe(true)
|
||||
});
|
||||
|
||||
it("returns false if a feature is at its quota", () => {
|
||||
expect(FeatureUsageStore.isUsable("not-usable")).toBe(false)
|
||||
expect(FeatureUsageStore._isUsable("not-usable")).toBe(false)
|
||||
});
|
||||
|
||||
it("warns if asking for an unsupported feature", () => {
|
||||
spyOn(NylasEnv, "reportError")
|
||||
expect(FeatureUsageStore.isUsable("unsupported")).toBe(false)
|
||||
expect(FeatureUsageStore._isUsable("unsupported")).toBe(false)
|
||||
expect(NylasEnv.reportError).toHaveBeenCalled()
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFeature", () => {
|
||||
describe("_markFeatureUsed", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(SendFeatureUsageEventTask.prototype, "performRemote").andReturn(Promise.resolve(Task.Status.Success));
|
||||
spyOn(IdentityStore, "saveIdentity").andCallFake((ident) => {
|
||||
|
@ -61,29 +61,89 @@ describe("FeatureUsageStore", function featureUsageStoreSpec() {
|
|||
})
|
||||
|
||||
it("returns the num remaining if successful", async () => {
|
||||
let numLeft = await FeatureUsageStore.useFeature('is-usable');
|
||||
let numLeft = await FeatureUsageStore._markFeatureUsed('is-usable');
|
||||
expect(numLeft).toBe(1)
|
||||
numLeft = await FeatureUsageStore.useFeature('is-usable');
|
||||
numLeft = await FeatureUsageStore._markFeatureUsed('is-usable');
|
||||
expect(numLeft).toBe(0)
|
||||
});
|
||||
});
|
||||
|
||||
it("throws if it was over quota", async () => {
|
||||
try {
|
||||
await FeatureUsageStore.useFeature("not-usable");
|
||||
throw new Error("This should throw")
|
||||
} catch (err) {
|
||||
expect(err.message).toMatch(/not usable/)
|
||||
}
|
||||
describe("use feature", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(FeatureUsageStore, "_markFeatureUsed").andReturn(Promise.resolve());
|
||||
spyOn(Actions, "openModal")
|
||||
});
|
||||
|
||||
it("throws if using an unsupported feature", async () => {
|
||||
spyOn(NylasEnv, "reportError")
|
||||
try {
|
||||
await FeatureUsageStore.useFeature("unsupported");
|
||||
throw new Error("This should throw")
|
||||
} catch (err) {
|
||||
expect(err.message).toMatch(/supported/)
|
||||
}
|
||||
it("marks the feature used if you have pro access", async () => {
|
||||
spyOn(IdentityStore, "hasProAccess").andReturn(true);
|
||||
await FeatureUsageStore.asyncUseFeature('not-usable')
|
||||
expect(FeatureUsageStore._markFeatureUsed).toHaveBeenCalled();
|
||||
expect(FeatureUsageStore._markFeatureUsed.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("marks the feature used if it's usable", async () => {
|
||||
spyOn(IdentityStore, "hasProAccess").andReturn(false);
|
||||
await FeatureUsageStore.asyncUseFeature('is-usable')
|
||||
expect(FeatureUsageStore._markFeatureUsed).toHaveBeenCalled();
|
||||
expect(FeatureUsageStore._markFeatureUsed.callCount).toBe(1);
|
||||
});
|
||||
|
||||
describe("showing modal", () => {
|
||||
beforeEach(() => {
|
||||
this.hasProAccess = false;
|
||||
spyOn(IdentityStore, "hasProAccess").andCallFake(() => {
|
||||
return this.hasProAccess;
|
||||
})
|
||||
this.lexicon = {
|
||||
displayName: "Test Name",
|
||||
rechargeCTA: "recharge me",
|
||||
usedUpHeader: "all test used",
|
||||
iconUrl: "icon url",
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the modal if you upgrade", async () => {
|
||||
setImmediate(() => {
|
||||
this.hasProAccess = true;
|
||||
FeatureUsageStore._onModalClose()
|
||||
})
|
||||
await FeatureUsageStore.asyncUseFeature('not-usable', {lexicon: this.lexicon});
|
||||
expect(Actions.openModal).toHaveBeenCalled();
|
||||
expect(Actions.openModal.calls.length).toBe(1)
|
||||
});
|
||||
|
||||
it("pops open a modal with the correct text", async () => {
|
||||
setImmediate(() => {
|
||||
this.hasProAccess = true;
|
||||
FeatureUsageStore._onModalClose()
|
||||
})
|
||||
await FeatureUsageStore.asyncUseFeature('not-usable', {lexicon: this.lexicon});
|
||||
expect(Actions.openModal).toHaveBeenCalled();
|
||||
expect(Actions.openModal.calls.length).toBe(1)
|
||||
const component = Actions.openModal.calls[0].args[0].component;
|
||||
expect(component.props).toEqual({
|
||||
modalClass: "not-usable",
|
||||
featureName: "Test Name",
|
||||
headerText: "all test used",
|
||||
iconUrl: "icon url",
|
||||
rechargeText: "You’ll have 10 more next month",
|
||||
})
|
||||
});
|
||||
|
||||
it("rejects if you don't upgrade", async () => {
|
||||
let caughtError = false;
|
||||
setImmediate(() => {
|
||||
this.hasProAccess = false;
|
||||
FeatureUsageStore._onModalClose()
|
||||
})
|
||||
try {
|
||||
await FeatureUsageStore.asyncUseFeature('not-usable', {lexicon: this.lexicon});
|
||||
} catch (err) {
|
||||
expect(err instanceof FeatureUsageStore.NoProAccess).toBe(true)
|
||||
caughtError = true;
|
||||
}
|
||||
expect(caughtError).toBe(true)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
95
packages/client-app/src/components/billing-modal.jsx
Normal file
95
packages/client-app/src/components/billing-modal.jsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import React from 'react'
|
||||
import Webview from './webview'
|
||||
import Actions from '../flux/actions'
|
||||
import IdentityStore from '../flux/stores/identity-store'
|
||||
|
||||
export default class BillingModal extends React.Component {
|
||||
static propTypes = {
|
||||
upgradeUrl: React.PropTypes.string,
|
||||
source: React.PropTypes.string,
|
||||
}
|
||||
|
||||
constructor(props = {}) {
|
||||
super(props);
|
||||
this.state = {
|
||||
src: props.upgradeUrl,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
if (!this.state.src) {
|
||||
IdentityStore.fetchSingleSignOnURL("/payment?embedded=true").then((url) => {
|
||||
if (!this._mounted) return;
|
||||
this.setState({src: url})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._mounted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Billing modal can get closed for any number of reasons. The user
|
||||
* may push escape, click continue below, or click outside of the area.
|
||||
* Regardless of the method, Actions.closeModal will fire. The
|
||||
* FeatureUsageStore listens for Actions.closeModal and looks at the
|
||||
* IdentityStore.hasProAccess to determine if the user succesffully paid
|
||||
* us or not.
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
this._mounted = false;
|
||||
}
|
||||
|
||||
_onDidFinishLoad = (webview) => {
|
||||
/**
|
||||
* Ahh webviews…
|
||||
*
|
||||
* First we wait for the payment success screen to pop up and do a
|
||||
* quick assertion on the data that's there.
|
||||
*
|
||||
* We then start listening to the continue button, using the console
|
||||
* as a communication bus.
|
||||
*/
|
||||
const receiveUserInfo = `
|
||||
var a = document.querySelector('#payment-success-data');
|
||||
result = a ? a.innerText : null;
|
||||
`;
|
||||
webview.executeJavaScript(receiveUserInfo, false, async (result) => {
|
||||
if (!result) return;
|
||||
if (result !== IdentityStore.identityId()) {
|
||||
NylasEnv.reportError(new Error("billing.nylas.com/payment_success did not have a valid #payment-success-data field"))
|
||||
}
|
||||
const listenForContinue = `
|
||||
var el = document.querySelector('#continue-btn');
|
||||
if (el) {el.addEventListener('click', function(event) {console.log("continue clicked")})}
|
||||
`;
|
||||
webview.executeJavaScript(listenForContinue);
|
||||
webview.addEventListener("console-message", (e) => {
|
||||
if (e.message === "continue clicked") {
|
||||
// See comment on componentWillUnmount
|
||||
Actions.closeModal()
|
||||
}
|
||||
})
|
||||
await IdentityStore.asyncRefreshIdentity();
|
||||
});
|
||||
|
||||
/**
|
||||
* If we see any links on the page, we should open them in new
|
||||
* windows
|
||||
*/
|
||||
const openExternalLink = `
|
||||
var el = document.querySelector('a');
|
||||
if (el) {el.addEventListener('click', function(event) {console.log(this.href); event.preventDefault(); return false;})}
|
||||
`;
|
||||
webview.executeJavaScript(openExternalLink);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="modal-wrap billing-modal">
|
||||
<Webview src={this.state.src} onDidFinishLoad={this._onDidFinishLoad} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,50 +1,75 @@
|
|||
import React from 'react'
|
||||
import {shell} from 'electron'
|
||||
import Actions from '../flux/actions'
|
||||
import RetinaImg from './retina-img'
|
||||
import OpenIdentityPageButton from './open-identity-page-button'
|
||||
import BillingModal from './billing-modal'
|
||||
import IdentityStore from '../flux/stores/identity-store'
|
||||
|
||||
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 use <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 delayed sends</li>
|
||||
</ul>
|
||||
<p>… plus <a onClick={gotoFeatures}>dozens of other features</a></p>
|
||||
</div>
|
||||
export default class FeatureUsedUpModal extends React.Component {
|
||||
static 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,
|
||||
}
|
||||
|
||||
<OpenIdentityPageButton
|
||||
label="Upgrade"
|
||||
path="/dashboard?upgrade_to_pro=true"
|
||||
source={`${props.featureName}-Limit-Modal`}
|
||||
campaign="Limit-Modals"
|
||||
isCTA
|
||||
/>
|
||||
componentDidMount() {
|
||||
this._mounted = true;
|
||||
const start = Date.now()
|
||||
IdentityStore.fetchSingleSignOnURL("/payment?embedded=true").then((url) => {
|
||||
console.log("Done grabbing url", Date.now() - start)
|
||||
if (!this._mounted) return
|
||||
this.setState({upgradeUrl: url})
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._mounted = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const gotoFeatures = () => shell.openExternal("https://nylas.com/nylas-pro");
|
||||
|
||||
const upgrade = (e) => {
|
||||
e.stopPropagation();
|
||||
Actions.openModal({
|
||||
component: (
|
||||
<BillingModal source="feature-limit" upgradeUrl={this.state.upgradeUrl} />
|
||||
),
|
||||
height: 575,
|
||||
width: 412,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`feature-usage-modal ${this.props.modalClass}`}>
|
||||
<div className="feature-header">
|
||||
<div className="icon">
|
||||
<RetinaImg
|
||||
url={this.props.iconUrl}
|
||||
style={{position: "relative", top: "-2px"}}
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="header-text">{this.props.headerText}</h2>
|
||||
<p className="recharge-text">{this.props.rechargeText}</p>
|
||||
</div>
|
||||
<div className="feature-cta">
|
||||
<h2>Want to <span className="feature-name">{this.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>
|
||||
|
||||
<button className="btn btn-cta btn-emphasis" onClick={upgrade}>Upgrade</button>
|
||||
</div>
|
||||
</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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
190
packages/client-app/src/components/webview.jsx
Normal file
190
packages/client-app/src/components/webview.jsx
Normal file
|
@ -0,0 +1,190 @@
|
|||
import url from 'url'
|
||||
import React from 'react'
|
||||
import {shell} from 'electron'
|
||||
import ReactDOM from 'react-dom'
|
||||
import classnames from 'classnames';
|
||||
import networkErrors from 'chromium-net-errors';
|
||||
|
||||
import RetinaImg from './retina-img'
|
||||
|
||||
class InitialLoadingCover extends React.Component {
|
||||
static propTypes = {
|
||||
ready: React.PropTypes.bool,
|
||||
error: React.PropTypes.string,
|
||||
onTryAgain: React.PropTypes.func,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._slowTimeout = setTimeout(() => {
|
||||
this.setState({slow: true});
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this._slowTimeout);
|
||||
this._slowTimeout = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const classes = classnames({
|
||||
'webview-cover': true,
|
||||
'ready': this.props.ready,
|
||||
'error': this.props.error,
|
||||
'slow': this.state.slow,
|
||||
});
|
||||
|
||||
let message = this.props.error;
|
||||
if (this.props.error) {
|
||||
message = this.props.error;
|
||||
} else if (this.state.slow) {
|
||||
message = "Still trying to reach Nylas…";
|
||||
} else {
|
||||
message = ' '
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div style={{flex: 1}} />
|
||||
<RetinaImg
|
||||
className="spinner"
|
||||
style={{width: 20, height: 20}}
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
/>
|
||||
<div className="message">{message}</div>
|
||||
<div className="btn try-again" onClick={this.props.onTryAgain}>Try Again</div>
|
||||
<div style={{flex: 1}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default class Webview extends React.Component {
|
||||
static displayName = "Webview";
|
||||
|
||||
static propTypes = {
|
||||
src: React.PropTypes.string,
|
||||
onDidFinishLoad: React.PropTypes.func,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
ready: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._mounted = true;
|
||||
this._setupWebview(this.props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps = {}) {
|
||||
if (this.props.src !== nextProps.src) {
|
||||
this.setState({error: null, webviewLoading: true, ready: false});
|
||||
this._setupWebview(nextProps)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._mounted = false;
|
||||
}
|
||||
|
||||
_setupWebview(props) {
|
||||
if (!props.src) return
|
||||
const webview = ReactDOM.findDOMNode(this.refs.webview);
|
||||
const listeners = {
|
||||
'did-fail-load': this._webviewDidFailLoad,
|
||||
'did-finish-load': this._webviewDidFinishLoad,
|
||||
'did-get-response-details': this._webviewDidGetResponseDetails,
|
||||
'console-message': this._onConsoleMessage,
|
||||
}
|
||||
for (const event of Object.keys(listeners)) {
|
||||
webview.removeEventListener(event, listeners[event]);
|
||||
}
|
||||
webview.partition = 'in-memory-only';
|
||||
webview.src = props.src;
|
||||
for (const event of Object.keys(listeners)) {
|
||||
webview.addEventListener(event, listeners[event]);
|
||||
}
|
||||
}
|
||||
|
||||
_onTryAgain = () => {
|
||||
const webview = ReactDOM.findDOMNode(this.refs.webview);
|
||||
webview.reload();
|
||||
}
|
||||
|
||||
_onConsoleMessage = (e) => {
|
||||
if (/^http.+/i.test(e.message)) { shell.openExternal(e.message) }
|
||||
console.log('Guest page logged a message:', e.message);
|
||||
}
|
||||
|
||||
_webviewDidGetResponseDetails = ({httpResponseCode, originalURL}) => {
|
||||
if (!this._mounted) return;
|
||||
if (!originalURL.includes(url.parse(this.props.src).host)) {
|
||||
// This means that some other secondarily loaded resource (like
|
||||
// analytics or Linkedin, etc) got a response. We don't care about
|
||||
// that.
|
||||
return
|
||||
}
|
||||
if (httpResponseCode >= 400) {
|
||||
const error = `
|
||||
Could not reach Nylas. Please try again or contact
|
||||
support@nylas.com if the issue persists.
|
||||
(${originalURL}: ${httpResponseCode})
|
||||
`;
|
||||
this.setState({ready: false, error: error, webviewLoading: false});
|
||||
}
|
||||
this.setState({webviewLoading: false})
|
||||
};
|
||||
|
||||
_webviewDidFailLoad = ({errorCode, validatedURL}) => {
|
||||
if (!this._mounted) return;
|
||||
// "Operation was aborted" can be fired when we move between pages quickly.
|
||||
if (errorCode === -3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const e = networkErrors.createByCode(errorCode);
|
||||
const error = `Could not reach ${validatedURL}. ${e ? e.message : errorCode}`;
|
||||
this.setState({ready: false, error: error, webviewLoading: false});
|
||||
}
|
||||
|
||||
_webviewDidFinishLoad = () => {
|
||||
if (!this._mounted) return;
|
||||
// this is sometimes called right after did-fail-load
|
||||
if (this.state.error) return;
|
||||
this.setState({ready: true, webviewLoading: false});
|
||||
|
||||
if (!this.props.onDidFinishLoad) return;
|
||||
const webview = ReactDOM.findDOMNode(this.refs.webview);
|
||||
this.props.onDidFinishLoad(webview)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="webview-wrap">
|
||||
<webview ref="webview" is partition="in-memory-only" />
|
||||
<div className={`webview-loading-spinner loading-${this.state.webviewLoading}`}>
|
||||
<RetinaImg
|
||||
style={{width: 20, height: 20}}
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
/>
|
||||
</div>
|
||||
<InitialLoadingCover
|
||||
ready={this.state.ready}
|
||||
error={this.state.error}
|
||||
onTryAgain={this._onTryAgain}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,10 +1,14 @@
|
|||
import Rx from 'rx-lite'
|
||||
import React from 'react'
|
||||
import NylasStore from 'nylas-store'
|
||||
import {FeatureUsedUpModal} from 'nylas-component-kit'
|
||||
import Actions from '../actions'
|
||||
import IdentityStore from './identity-store'
|
||||
import TaskQueueStatusStore from './task-queue-status-store'
|
||||
import SendFeatureUsageEventTask from '../tasks/send-feature-usage-event-task'
|
||||
|
||||
class NoProAccess extends Error { }
|
||||
|
||||
/**
|
||||
* FeatureUsageStore is backed by the IdentityStore
|
||||
*
|
||||
|
@ -52,17 +56,94 @@ import SendFeatureUsageEventTask from '../tasks/send-feature-usage-event-task'
|
|||
* 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'unlimited'
|
||||
*/
|
||||
class FeatureUsageStore extends NylasStore {
|
||||
constructor() {
|
||||
super()
|
||||
this._waitForModalClose = []
|
||||
this.NoProAccess = NoProAccess
|
||||
}
|
||||
|
||||
activate() {
|
||||
/**
|
||||
* The IdentityStore triggers both after we update it, and when it
|
||||
* polls for new data every several minutes or so.
|
||||
*/
|
||||
this._sub = Rx.Observable.fromStore(IdentityStore).subscribe(() => {
|
||||
this._disp = Rx.Observable.fromStore(IdentityStore).subscribe(() => {
|
||||
this.trigger()
|
||||
})
|
||||
this._usub = Actions.closeModal.listen(this._onModalClose)
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._disp.dispose();
|
||||
this._usub()
|
||||
}
|
||||
|
||||
async asyncUseFeature(feature, {lexicon = {}} = {}) {
|
||||
if (IdentityStore.hasProAccess() || this._isUsable(feature)) {
|
||||
return this._markFeatureUsed(feature)
|
||||
}
|
||||
|
||||
const {headerText, rechargeText} = this._modalText(feature, lexicon)
|
||||
Actions.openModal({
|
||||
component: (
|
||||
<FeatureUsedUpModal
|
||||
modalClass={feature}
|
||||
featureName={lexicon.displayName}
|
||||
headerText={headerText}
|
||||
iconUrl={lexicon.iconUrl}
|
||||
rechargeText={rechargeText}
|
||||
/>
|
||||
),
|
||||
height: 575,
|
||||
width: 412,
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
this._waitForModalClose.push({resolve, reject, feature})
|
||||
})
|
||||
}
|
||||
|
||||
featureData(feature) {
|
||||
_onModalClose = async () => {
|
||||
for (const {feature, resolve, reject} of this._waitForModalClose) {
|
||||
if (IdentityStore.hasProAccess() || this._isUsable(feature)) {
|
||||
await this._markFeatureUsed(feature)
|
||||
resolve()
|
||||
} else {
|
||||
reject(new NoProAccess(feature))
|
||||
}
|
||||
}
|
||||
this._waitForModalClose = []
|
||||
}
|
||||
|
||||
_modalText(feature, lexicon = {}) {
|
||||
const featureData = this._featureData(feature);
|
||||
|
||||
let headerText = "";
|
||||
let rechargeText = ""
|
||||
if (!featureData.quota) {
|
||||
headerText = `${lexicon.displayName} not yet enabled`;
|
||||
rechargeText = `Upgrade to Pro to use ${lexicon.displayName}`
|
||||
} else {
|
||||
headerText = lexicon.usedUpHeader || "You've reached your quota";
|
||||
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 ${time}`
|
||||
}
|
||||
return {headerText, rechargeText}
|
||||
}
|
||||
|
||||
_featureData(feature) {
|
||||
const usage = this._featureUsage()
|
||||
if (!usage[feature]) {
|
||||
NylasEnv.reportError(new Error(`${feature} isn't supported`));
|
||||
|
@ -71,25 +152,7 @@ class FeatureUsageStore extends NylasStore {
|
|||
return usage[feature]
|
||||
}
|
||||
|
||||
nextPeriodString(period) {
|
||||
let time = "later";
|
||||
if (period === "hourly") {
|
||||
time = "next hour"
|
||||
} else if (period === "daily") {
|
||||
time = "tomorrow"
|
||||
} else if (period === "weekly") {
|
||||
time = "next week"
|
||||
} else if (period === "monthly") {
|
||||
time = "next month"
|
||||
} else if (period === "yearly") {
|
||||
time = "next year"
|
||||
} else if (period === "unlimited") {
|
||||
time = "if you upgrade to Pro"
|
||||
}
|
||||
return time
|
||||
}
|
||||
|
||||
isUsable(feature) {
|
||||
_isUsable(feature) {
|
||||
const usage = this._featureUsage()
|
||||
if (!usage[feature]) {
|
||||
NylasEnv.reportError(new Error(`${feature} isn't supported`));
|
||||
|
@ -98,10 +161,7 @@ class FeatureUsageStore extends NylasStore {
|
|||
return usage[feature].used_in_period < usage[feature].quota
|
||||
}
|
||||
|
||||
async useFeature(featureName) {
|
||||
if (!this.isUsable(featureName)) {
|
||||
throw new Error(`${featureName} is not usable! Check "FeatureUsageStore.isUsable" first`);
|
||||
}
|
||||
async _markFeatureUsed(featureName) {
|
||||
const task = new SendFeatureUsageEventTask(featureName)
|
||||
Actions.queueTask(task);
|
||||
await TaskQueueStatusStore.waitForPerformLocal(task)
|
|
@ -54,6 +54,10 @@ class IdentityStore extends NylasStore {
|
|||
return Utils.deepClone(this._identity);
|
||||
}
|
||||
|
||||
hasProAccess() {
|
||||
return this._identity && this._identity.has_pro_access
|
||||
}
|
||||
|
||||
identityId() {
|
||||
if (!this._identity) {
|
||||
return null;
|
||||
|
@ -148,7 +152,7 @@ class IdentityStore extends NylasStore {
|
|||
* https://paper.dropbox.com/doc/Analytics-ID-Unification-oVDTkakFsiBBbk9aeuiA3
|
||||
* for the full list of utm_ labels.
|
||||
*/
|
||||
fetchSingleSignOnURL(path, {source, campaign, content}) {
|
||||
fetchSingleSignOnURL(path, {source, campaign, content} = {}) {
|
||||
if (!this._identity) {
|
||||
return Promise.reject(new Error("fetchSingleSignOnURL: no identity set."));
|
||||
}
|
||||
|
@ -192,6 +196,11 @@ class IdentityStore extends NylasStore {
|
|||
});
|
||||
}
|
||||
|
||||
async asyncRefreshIdentity() {
|
||||
await this._fetchIdentity();
|
||||
return true
|
||||
}
|
||||
|
||||
async _fetchIdentity() {
|
||||
if (!this._identity || !this._identity.token) {
|
||||
return Promise.resolve();
|
||||
|
|
|
@ -46,7 +46,9 @@ class NylasComponentKit
|
|||
@load "FixedPopover", 'fixed-popover'
|
||||
@require "DatePickerPopover", 'date-picker-popover'
|
||||
@load "Modal", 'modal'
|
||||
@load "Webview", 'webview'
|
||||
@load "FeatureUsedUpModal", 'feature-used-up-modal'
|
||||
@load "BillingModal", 'billing-modal'
|
||||
@load "OpenIdentityPageButton", 'open-identity-page-button'
|
||||
@load "Flexbox", 'flexbox'
|
||||
@load "RetinaImg", 'retina-img'
|
||||
|
|
14
packages/client-app/static/components/billing-modal.less
Normal file
14
packages/client-app/static/components/billing-modal.less
Normal file
|
@ -0,0 +1,14 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.billing-modal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
cursor: default;
|
||||
|
||||
.webview-cover {
|
||||
background: linear-gradient( -10deg, #fff, #f5f6fd );
|
||||
}
|
||||
}
|
62
packages/client-app/static/components/webview.less
Normal file
62
packages/client-app/static/components/webview.less
Normal file
|
@ -0,0 +1,62 @@
|
|||
.webview-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
webview {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.webview-loading-spinner {
|
||||
position: absolute;
|
||||
right: 17px;
|
||||
top: 17px;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
transition-delay: 200ms;
|
||||
&.loading-true {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.webview-cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #F3F3F3;
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
.message {
|
||||
color: #444;
|
||||
opacity: 0;
|
||||
margin-top: 20px;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
.try-again {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
}
|
||||
.webview-cover.slow,
|
||||
.webview-cover.error {
|
||||
.message {
|
||||
opacity: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
.webview-cover.error {
|
||||
.spinner { visibility: hidden;}
|
||||
.try-again {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.webview-cover.ready {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
|
@ -48,3 +48,5 @@
|
|||
@import "components/search-bar";
|
||||
@import "components/code-snippet";
|
||||
@import "components/feature-used-up-modal";
|
||||
@import "components/webview";
|
||||
@import "components/billing-modal";
|
||||
|
|
Loading…
Reference in a new issue