mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add product for new personal plan
Summary: Adds the new personal plan as a product that will be available in the future. Can be enabled along with other plan-related via an environment variable. Test Plan: Browser tests and existing tests. Reviewers: jarek Reviewed By: jarek Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3533
This commit is contained in:
parent
5c8211c61d
commit
aeba738f7c
@ -139,7 +139,7 @@ const cssButton = styled(icon, `
|
|||||||
|
|
||||||
const cssExpandButton = styled(cssButton, `
|
const cssExpandButton = styled(cssButton, `
|
||||||
&-expanded {
|
&-expanded {
|
||||||
-webkit-mask-image: var(--icon-DropdownUp);
|
-webkit-mask-image: var(--icon-DropdownUp) !important;
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
@ -4,11 +4,9 @@ import {docListHeader} from 'app/client/ui/DocMenuCss';
|
|||||||
import {infoTooltip} from 'app/client/ui/tooltips';
|
import {infoTooltip} from 'app/client/ui/tooltips';
|
||||||
import {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
|
import {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
|
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
|
||||||
import {Features, isFreeProduct} from 'app/common/Features';
|
import {Features, isFreePlan} from 'app/common/Features';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
|
||||||
import {capitalizeFirstWord} from 'app/common/gutil';
|
import {capitalizeFirstWord} from 'app/common/gutil';
|
||||||
import {canUpgradeOrg} from 'app/common/roles';
|
import {canUpgradeOrg} from 'app/common/roles';
|
||||||
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
|
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
|
||||||
@ -168,8 +166,12 @@ export class DocumentUsage extends Disposable {
|
|||||||
buildLimitStatusMessage(status, product?.features, {
|
buildLimitStatusMessage(status, product?.features, {
|
||||||
disableRawDataLink: true
|
disableRawDataLink: true
|
||||||
}),
|
}),
|
||||||
(product && isFreeProduct(product)
|
(product && isFreePlan(product.name)
|
||||||
? [' ', buildUpgradeMessage(canUpgradeOrg(org))]
|
? [' ', buildUpgradeMessage(
|
||||||
|
canUpgradeOrg(org),
|
||||||
|
'long',
|
||||||
|
() => this._docPageModel.appModel.showUpgradeModal()
|
||||||
|
)]
|
||||||
: null
|
: null
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@ -236,21 +238,25 @@ export function buildLimitStatusMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUpgradeMessage(canUpgrade: boolean, variant: 'short' | 'long' = 'long') {
|
export function buildUpgradeMessage(
|
||||||
|
canUpgrade: boolean,
|
||||||
|
variant: 'short' | 'long',
|
||||||
|
onUpgrade: () => void,
|
||||||
|
) {
|
||||||
if (!canUpgrade) { return 'Contact the site owner to upgrade the plan to raise limits.'; }
|
if (!canUpgrade) { return 'Contact the site owner to upgrade the plan to raise limits.'; }
|
||||||
|
|
||||||
const upgradeLinkText = 'start your 30-day free trial of the Pro plan.';
|
const upgradeLinkText = 'start your 30-day free trial of the Pro plan.';
|
||||||
return [
|
return [
|
||||||
variant === 'short' ? null : 'For higher limits, ',
|
variant === 'short' ? null : 'For higher limits, ',
|
||||||
buildUpgradeLink(variant === 'short' ? capitalizeFirstWord(upgradeLinkText) : upgradeLinkText),
|
buildUpgradeLink(
|
||||||
|
variant === 'short' ? capitalizeFirstWord(upgradeLinkText) : upgradeLinkText,
|
||||||
|
() => onUpgrade(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUpgradeLink(linkText: string) {
|
function buildUpgradeLink(linkText: string, onClick: () => void) {
|
||||||
return cssUnderlinedLink(linkText, {
|
return cssUnderlinedLink(linkText, dom.on('click', () => onClick()));
|
||||||
href: commonUrls.plans,
|
|
||||||
target: '_blank',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRawDataPageLink(linkText: string) {
|
function buildRawDataPageLink(linkText: string) {
|
||||||
@ -356,7 +362,8 @@ const cssHeader = styled(docListHeader, `
|
|||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssUnderlinedLink = styled(cssLink, `
|
const cssUnderlinedLink = styled('span', `
|
||||||
|
cursor: pointer;
|
||||||
color: unset;
|
color: unset;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
||||||
|
@ -4,8 +4,9 @@ import {reportError, setErrorNotifier} from 'app/client/models/errors';
|
|||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {Notifier} from 'app/client/models/NotifyModel';
|
import {Notifier} from 'app/client/models/NotifyModel';
|
||||||
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
|
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
|
||||||
|
import {buildNewSiteModal, buildUpgradeModal, NEW_DEAL} from 'app/client/ui/ProductUpgrades';
|
||||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||||
import {Features} from 'app/common/Features';
|
import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
||||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import {LocalPlugin} from 'app/common/plugin';
|
import {LocalPlugin} from 'app/common/plugin';
|
||||||
@ -16,7 +17,6 @@ import {getGristConfig} from 'app/common/urlUtils';
|
|||||||
import {getOrgName, Organization, OrgError, SUPPORT_EMAIL, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
import {getOrgName, Organization, OrgError, SUPPORT_EMAIL, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
||||||
import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
|
import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
|
||||||
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
||||||
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
|
|
||||||
|
|
||||||
export {reportError} from 'app/client/models/errors';
|
export {reportError} from 'app/client/models/errors';
|
||||||
|
|
||||||
@ -70,9 +70,11 @@ export interface AppModel {
|
|||||||
currentOrgUsage: Observable<OrgUsageSummary|null>;
|
currentOrgUsage: Observable<OrgUsageSummary|null>;
|
||||||
isPersonal: boolean; // Is it a personal site?
|
isPersonal: boolean; // Is it a personal site?
|
||||||
isTeamSite: boolean; // Is it a team site?
|
isTeamSite: boolean; // Is it a team site?
|
||||||
|
isLegacySite: boolean; // Is it a legacy site?
|
||||||
orgError?: OrgError; // If currentOrg is null, the error that caused it.
|
orgError?: OrgError; // If currentOrg is null, the error that caused it.
|
||||||
|
|
||||||
currentFeatures: Features; // features of the current org's product.
|
currentProduct: Product|null; // The current org's product.
|
||||||
|
currentFeatures: Features; // Features of the current org's product.
|
||||||
userPrefsObs: Observable<UserPrefs>;
|
userPrefsObs: Observable<UserPrefs>;
|
||||||
|
|
||||||
pageType: Observable<PageType>;
|
pageType: Observable<PageType>;
|
||||||
@ -199,11 +201,14 @@ export class AppModelImpl extends Disposable implements AppModel {
|
|||||||
|
|
||||||
public readonly currentOrgUsage: Observable<OrgUsageSummary|null> = Observable.create(this, null);
|
public readonly currentOrgUsage: Observable<OrgUsageSummary|null> = Observable.create(this, null);
|
||||||
|
|
||||||
|
public readonly currentProduct = this.currentOrg?.billingAccount?.product ?? null;
|
||||||
|
public readonly currentFeatures = this.currentProduct?.features ?? {};
|
||||||
|
|
||||||
public readonly isPersonal = Boolean(this.currentOrg?.owner);
|
public readonly isPersonal = Boolean(this.currentOrg?.owner);
|
||||||
public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;
|
public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;
|
||||||
|
// TODO: the `NEW_DEAL` observable can be removed after new deal is released.
|
||||||
public readonly currentFeatures = (this.currentOrg && this.currentOrg.billingAccount) ?
|
public readonly isLegacySite = Boolean(
|
||||||
this.currentOrg.billingAccount.product.features : {};
|
NEW_DEAL().get() && this.currentProduct && isLegacyPlan(this.currentProduct.name));
|
||||||
|
|
||||||
public readonly userPrefsObs = getUserPrefsObs(this);
|
public readonly userPrefsObs = getUserPrefsObs(this);
|
||||||
|
|
||||||
@ -224,7 +229,7 @@ export class AppModelImpl extends Disposable implements AppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public get planName() {
|
public get planName() {
|
||||||
return this.currentOrg?.billingAccount?.product.name ?? null;
|
return this.currentProduct?.name ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async showUpgradeModal() {
|
public async showUpgradeModal() {
|
||||||
|
@ -171,8 +171,8 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
|||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
if (this._options.appModel) {
|
if (this._options.appModel) {
|
||||||
const features = this._options.appModel.currentFeatures;
|
const product = this._options.appModel.currentProduct;
|
||||||
this._shareAnnotator = new ShareAnnotator(features, initData);
|
this._shareAnnotator = new ShareAnnotator(product, initData);
|
||||||
}
|
}
|
||||||
this.annotate();
|
this.annotate();
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,11 @@ export class AppHeader extends Disposable {
|
|||||||
productPill(currentOrg),
|
productPill(currentOrg),
|
||||||
this._orgName && cssDropdownIcon('Dropdown'),
|
this._orgName && cssDropdownIcon('Dropdown'),
|
||||||
menu(() => [
|
menu(() => [
|
||||||
menuSubHeader(`${this._appModel.isTeamSite ? 'Team' : 'Personal'} Site`, testId('orgmenu-title')),
|
menuSubHeader(
|
||||||
|
`${this._appModel.isTeamSite ? 'Team' : 'Personal'} Site`
|
||||||
|
+ (this._appModel.isLegacySite ? ' (Legacy)' : ''),
|
||||||
|
testId('orgmenu-title'),
|
||||||
|
),
|
||||||
menuItemLink(urlState().setLinkUrl({}), 'Home Page', testId('orgmenu-home-page')),
|
menuItemLink(urlState().setLinkUrl({}), 'Home Page', testId('orgmenu-home-page')),
|
||||||
|
|
||||||
// Show 'Organization Settings' when on a home page of a valid org.
|
// Show 'Organization Settings' when on a home page of a valid org.
|
||||||
|
@ -10,7 +10,7 @@ import {BillingPlanManagers} from 'app/client/ui/BillingPlanManagers';
|
|||||||
import {createForbiddenPage} from 'app/client/ui/errorPages';
|
import {createForbiddenPage} from 'app/client/ui/errorPages';
|
||||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||||
import {NEW_DEAL, showTeamUpgradeConfirmation} from 'app/client/ui/ProductUpgrades';
|
import {showTeamUpgradeConfirmation} from 'app/client/ui/ProductUpgrades';
|
||||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||||
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
|
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
|
||||||
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
@ -45,9 +45,6 @@ export class BillingPage extends Disposable {
|
|||||||
|
|
||||||
constructor(private _appModel: AppModel) {
|
constructor(private _appModel: AppModel) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// TODO: remove once NEW_DEAL is there. Execute for side effect
|
|
||||||
void NEW_DEAL();
|
|
||||||
this._appModel.refreshOrgUsage().catch(reportError);
|
this._appModel.refreshOrgUsage().catch(reportError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,10 +71,11 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
|||||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||||
home;
|
home;
|
||||||
return [
|
return [
|
||||||
// Hide the sort option only when showing intro.
|
buildPrefs(
|
||||||
((showIntro && page === 'all') ? css.prefSelectors(upgradeButton.showUpgradeButton()) :
|
viewSettings,
|
||||||
// This is float:right element
|
// Hide the sort and view options when showing the intro.
|
||||||
buildPrefs(viewSettings, {hideSort: showIntro}, upgradeButton.showUpgradeButton())
|
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
||||||
|
['all', 'workspace'].includes(page) ? upgradeButton.showUpgradeButton() : null,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
|
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
|
||||||
@ -297,12 +298,16 @@ function buildOtherSites(home: HomeModel) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the widget for selecting sort and view mode options.
|
* Build the widget for selecting sort and view mode options.
|
||||||
* If hideSort is true, will hide the sort dropdown: it has no effect on the list of examples, so
|
*
|
||||||
* best to hide when those are the only docs shown.
|
* Options hideSort and hideView control which options are shown; they should have no effect
|
||||||
|
* on the list of examples, so best to hide when those are the only docs shown.
|
||||||
*/
|
*/
|
||||||
function buildPrefs(
|
function buildPrefs(
|
||||||
viewSettings: ViewSettings,
|
viewSettings: ViewSettings,
|
||||||
options: {hideSort: boolean},
|
options: {
|
||||||
|
hideSort: boolean,
|
||||||
|
hideView: boolean,
|
||||||
|
},
|
||||||
...args: DomArg<HTMLElement>[]): DomContents {
|
...args: DomArg<HTMLElement>[]): DomContents {
|
||||||
return css.prefSelectors(
|
return css.prefSelectors(
|
||||||
// The Sort selector.
|
// The Sort selector.
|
||||||
@ -317,7 +322,7 @@ function buildPrefs(
|
|||||||
),
|
),
|
||||||
|
|
||||||
// The View selector.
|
// The View selector.
|
||||||
buttonSelect<ViewPref>(viewSettings.currentView, [
|
options.hideView ? null : buttonSelect<ViewPref>(viewSettings.currentView, [
|
||||||
{value: 'icons', icon: 'TypeTable'},
|
{value: 'icons', icon: 'TypeTable'},
|
||||||
{value: 'list', icon: 'TypeCardList'},
|
{value: 'list', icon: 'TypeCardList'},
|
||||||
],
|
],
|
||||||
|
@ -191,7 +191,7 @@ function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[
|
|||||||
dom.cls('disabled', (use) => !roles.canEdit(orgAccess) || !use(home.available)),
|
dom.cls('disabled', (use) => !roles.canEdit(orgAccess) || !use(home.available)),
|
||||||
testId("dm-new-workspace")
|
testId("dm-new-workspace")
|
||||||
),
|
),
|
||||||
upgradeText(needUpgrade),
|
upgradeText(needUpgrade, () => home.app.showUpgradeModal()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,8 +225,12 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
|
|||||||
testId('dm-delete-workspace')),
|
testId('dm-delete-workspace')),
|
||||||
upgradableMenuItem(needUpgrade, manageWorkspaceUsers,
|
upgradableMenuItem(needUpgrade, manageWorkspaceUsers,
|
||||||
roles.canEditAccess(ws.access) ? "Manage Users" : "Access Details",
|
roles.canEditAccess(ws.access) ? "Manage Users" : "Access Details",
|
||||||
|
// TODO: Personal plans can't currently share workspaces, but that restriction
|
||||||
|
// should formally be documented and defined in `Features`, with this check updated
|
||||||
|
// to look there instead.
|
||||||
|
dom.cls('disabled', () => home.app.isPersonal),
|
||||||
testId('dm-workspace-access')),
|
testId('dm-workspace-access')),
|
||||||
upgradeText(needUpgrade),
|
upgradeText(needUpgrade, () => home.app.showUpgradeModal()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,9 +19,13 @@ function buildAction(action: NotifyAction, item: Notification, options: IBeaconO
|
|||||||
const appModel = options.appModel;
|
const appModel = options.appModel;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'upgrade':
|
case 'upgrade':
|
||||||
return dom('a', cssToastAction.cls(''), 'Upgrade Plan', {target: '_blank'},
|
if (appModel) {
|
||||||
{href: commonUrls.plans});
|
return cssToastAction('Upgrade Plan', dom.on('click', () =>
|
||||||
|
appModel.showUpgradeModal()));
|
||||||
|
} else {
|
||||||
|
return dom('a', cssToastAction.cls(''), 'Upgrade Plan', {target: '_blank'},
|
||||||
|
{href: commonUrls.plans});
|
||||||
|
}
|
||||||
case 'renew':
|
case 'renew':
|
||||||
// If already on the billing page, nothing to return.
|
// If already on the billing page, nothing to return.
|
||||||
if (urlState().state.get().billing === 'billing') { return null; }
|
if (urlState().state.get().billing === 'billing') { return null; }
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Command } from 'app/client/components/commands';
|
import { Command } from 'app/client/components/commands';
|
||||||
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
|
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
|
||||||
|
import { textButton } from 'app/client/ui2018/buttons';
|
||||||
import { cssCheckboxSquare, cssLabel, cssLabelText } from 'app/client/ui2018/checkbox';
|
import { cssCheckboxSquare, cssLabel, cssLabelText } from 'app/client/ui2018/checkbox';
|
||||||
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
|
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
|
||||||
import { IconName } from 'app/client/ui2018/IconList';
|
import { IconName } from 'app/client/ui2018/IconList';
|
||||||
import { icon } from 'app/client/ui2018/icons';
|
import { icon } from 'app/client/ui2018/icons';
|
||||||
import { cssSelectBtn } from 'app/client/ui2018/select';
|
import { cssSelectBtn } from 'app/client/ui2018/select';
|
||||||
import { commonUrls } from 'app/common/gristUrls';
|
|
||||||
import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
|
import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
|
||||||
MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs';
|
MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs';
|
||||||
import * as weasel from 'popweasel';
|
import * as weasel from 'popweasel';
|
||||||
@ -299,10 +299,10 @@ export function upgradableMenuItem(needUpgrade: boolean, action: () => void, ...
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upgradeText(needUpgrade: boolean) {
|
export function upgradeText(needUpgrade: boolean, onClick: () => void) {
|
||||||
if (!needUpgrade) { return null; }
|
if (!needUpgrade) { return null; }
|
||||||
return menuText(dom('span', '* Workspaces are available on team plans. ',
|
return menuText(dom('span', '* Workspaces are available on team plans. ',
|
||||||
dom('a', {href: commonUrls.plans}, 'Upgrade now')));
|
cssUpgradeTextButton('Upgrade now', dom.on('click', () => onClick()))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -584,3 +584,7 @@ const cssCheckboxText = styled(cssLabelText, `
|
|||||||
color: ${colors.dark};
|
color: ${colors.dark};
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssUpgradeTextButton = styled(textButton, `
|
||||||
|
font-size: ${vars.smallFontSize};
|
||||||
|
`);
|
||||||
|
@ -68,16 +68,55 @@ export function canAddOrgMembers(features: Features): boolean {
|
|||||||
return features.maxWorkspacesPerOrg !== 1;
|
return features.maxWorkspacesPerOrg !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FREE_PERSONAL_PLAN = 'starter';
|
|
||||||
|
export const PERSONAL_LEGACY_PLAN = 'starter';
|
||||||
|
export const PERSONAL_FREE_PLAN = 'personalFree';
|
||||||
export const TEAM_FREE_PLAN = 'teamFree';
|
export const TEAM_FREE_PLAN = 'teamFree';
|
||||||
export const TEAM_PLAN = 'team';
|
export const TEAM_PLAN = 'team';
|
||||||
|
|
||||||
export const displayPlanName: { [key: string]: string } = {
|
export const displayPlanName: { [key: string]: string } = {
|
||||||
|
[PERSONAL_LEGACY_PLAN]: 'Free Personal (Legacy)',
|
||||||
|
[PERSONAL_FREE_PLAN]: 'Free Personal',
|
||||||
[TEAM_FREE_PLAN]: 'Team Free',
|
[TEAM_FREE_PLAN]: 'Team Free',
|
||||||
[TEAM_PLAN]: 'Team'
|
[TEAM_PLAN]: 'Team'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Returns true if `product` is free.
|
// Returns true if `planName` is for a personal product.
|
||||||
export function isFreeProduct(product: Product): boolean {
|
export function isPersonalPlan(planName: string): boolean {
|
||||||
return [FREE_PERSONAL_PLAN, TEAM_FREE_PLAN, 'Free'].includes(product?.name);
|
return isFreePersonalPlan(planName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if `planName` is for a free personal product.
|
||||||
|
export function isFreePersonalPlan(planName: string): boolean {
|
||||||
|
return [PERSONAL_LEGACY_PLAN, PERSONAL_FREE_PLAN].includes(planName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if `planName` is for a legacy product.
|
||||||
|
export function isLegacyPlan(planName: string): boolean {
|
||||||
|
return isFreeLegacyPlan(planName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if `planName` is for a free legacy product.
|
||||||
|
export function isFreeLegacyPlan(planName: string): boolean {
|
||||||
|
return [PERSONAL_LEGACY_PLAN].includes(planName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if `planName` is for a team product.
|
||||||
|
export function isTeamPlan(planName: string): boolean {
|
||||||
|
return !isPersonalPlan(planName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if `planName` is for a free team product.
|
||||||
|
export function isFreeTeamPlan(planName: string): boolean {
|
||||||
|
return [TEAM_FREE_PLAN].includes(planName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if `planName` is for a free product.
|
||||||
|
export function isFreePlan(planName: string): boolean {
|
||||||
|
return (
|
||||||
|
isFreePersonalPlan(planName) ||
|
||||||
|
isFreeTeamPlan(planName) ||
|
||||||
|
isFreeLegacyPlan(planName) ||
|
||||||
|
planName === 'Free'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { isTeamPlan, Product } from 'app/common/Features';
|
||||||
import { normalizeEmail } from 'app/common/emails';
|
import { normalizeEmail } from 'app/common/emails';
|
||||||
import { Features } from 'app/common/Features';
|
|
||||||
import { PermissionData, PermissionDelta } from 'app/common/UserAPI';
|
import { PermissionData, PermissionDelta } from 'app/common/UserAPI';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +33,9 @@ export interface ShareAnnotations {
|
|||||||
* current shares in place.
|
* current shares in place.
|
||||||
*/
|
*/
|
||||||
export class ShareAnnotator {
|
export class ShareAnnotator {
|
||||||
constructor(private _features: Features, private _state: PermissionData) {
|
private _features = this._product?.features ?? {};
|
||||||
|
|
||||||
|
constructor(private _product: Product|null, private _state: PermissionData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateState(state: PermissionData) {
|
public updateState(state: PermissionData) {
|
||||||
@ -43,7 +45,7 @@ export class ShareAnnotator {
|
|||||||
public annotateChanges(change: PermissionDelta): ShareAnnotations {
|
public annotateChanges(change: PermissionDelta): ShareAnnotations {
|
||||||
const features = this._features;
|
const features = this._features;
|
||||||
const annotations: ShareAnnotations = {
|
const annotations: ShareAnnotations = {
|
||||||
hasTeam: features.maxWorkspacesPerOrg !== 1,
|
hasTeam: !this._product || isTeamPlan(this._product.name),
|
||||||
users: new Map(),
|
users: new Map(),
|
||||||
};
|
};
|
||||||
if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) {
|
if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) {
|
||||||
|
@ -539,6 +539,9 @@ export interface GristLoadConfig {
|
|||||||
|
|
||||||
// String to append to the end of the HTML document.title
|
// String to append to the end of the HTML document.title
|
||||||
pageTitleSuffix?: string;
|
pageTitleSuffix?: string;
|
||||||
|
|
||||||
|
// TODO: can be removed once new deal is released.
|
||||||
|
newDeal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");
|
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import {Features, FREE_PERSONAL_PLAN, Product as IProduct, TEAM_FREE_PLAN, TEAM_PLAN} from 'app/common/Features';
|
import {Features, Product as IProduct, PERSONAL_FREE_PLAN, PERSONAL_LEGACY_PLAN, TEAM_FREE_PLAN,
|
||||||
|
TEAM_PLAN} from 'app/common/Features';
|
||||||
import {nativeValues} from 'app/gen-server/lib/values';
|
import {nativeValues} from 'app/gen-server/lib/values';
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
|
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
|
||||||
import {BaseEntity, Column, Connection, Entity, OneToMany, PrimaryGeneratedColumn} from 'typeorm';
|
import {BaseEntity, Column, Connection, Entity, OneToMany, PrimaryGeneratedColumn} from 'typeorm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A summary of features used in 'starter' plans.
|
* A summary of features available in legacy personal sites.
|
||||||
*/
|
*/
|
||||||
export const starterFeatures: Features = {
|
export const personalLegacyFeatures: Features = {
|
||||||
workspaces: true,
|
workspaces: true,
|
||||||
// no vanity domain
|
// no vanity domain
|
||||||
maxDocsPerOrg: 10,
|
maxDocsPerOrg: 10,
|
||||||
@ -27,14 +28,27 @@ export const teamFeatures: Features = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A summary of features available in free team sites.
|
* A summary of features available in free team sites.
|
||||||
* At time of writing, this is a placeholder, as free sites are fleshed out.
|
|
||||||
*/
|
*/
|
||||||
export const teamFreeFeatures: Features = {
|
export const teamFreeFeatures: Features = {
|
||||||
workspaces: true,
|
workspaces: true,
|
||||||
vanityDomain: true,
|
vanityDomain: true,
|
||||||
maxSharesPerWorkspace: 0, // all workspace shares need to be org members.
|
maxSharesPerWorkspace: 0, // all workspace shares need to be org members.
|
||||||
maxSharesPerDoc: 2,
|
maxSharesPerDoc: 2,
|
||||||
maxDocsPerOrg: 20,
|
snapshotWindow: { count: 30, unit: 'days' },
|
||||||
|
baseMaxRowsPerDocument: 5000,
|
||||||
|
baseMaxApiUnitsPerDocumentPerDay: 5000,
|
||||||
|
baseMaxDataSizePerDocument: 5000 * 2 * 1024, // 2KB per row
|
||||||
|
baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024, // 1GB
|
||||||
|
gracePeriodDays: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A summary of features available in free personal sites.
|
||||||
|
*/
|
||||||
|
export const personalFreeFeatures: Features = {
|
||||||
|
workspaces: true,
|
||||||
|
maxSharesPerWorkspace: 0, // workspace sharing is disabled.
|
||||||
|
maxSharesPerDoc: 2,
|
||||||
snapshotWindow: { count: 30, unit: 'days' },
|
snapshotWindow: { count: 30, unit: 'days' },
|
||||||
baseMaxRowsPerDocument: 5000,
|
baseMaxRowsPerDocument: 5000,
|
||||||
baseMaxApiUnitsPerDocumentPerDay: 5000,
|
baseMaxApiUnitsPerDocumentPerDay: 5000,
|
||||||
@ -96,12 +110,10 @@ export const PRODUCTS: IProduct[] = [
|
|||||||
name: 'stub',
|
name: 'stub',
|
||||||
features: {},
|
features: {},
|
||||||
},
|
},
|
||||||
|
// This is a product for legacy personal accounts/orgs.
|
||||||
// These are products set up in stripe.
|
|
||||||
// TODO: this is not true anymore
|
|
||||||
{
|
{
|
||||||
name: FREE_PERSONAL_PLAN,
|
name: PERSONAL_LEGACY_PLAN,
|
||||||
features: starterFeatures,
|
features: personalLegacyFeatures,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'professional', // deprecated, can be removed once no longer referred to in stripe.
|
name: 'professional', // deprecated, can be removed once no longer referred to in stripe.
|
||||||
@ -122,6 +134,10 @@ export const PRODUCTS: IProduct[] = [
|
|||||||
name: TEAM_FREE_PLAN,
|
name: TEAM_FREE_PLAN,
|
||||||
features: teamFreeFeatures
|
features: teamFreeFeatures
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: PERSONAL_FREE_PLAN,
|
||||||
|
features: personalFreeFeatures,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
@ -130,11 +146,18 @@ export const PRODUCTS: IProduct[] = [
|
|||||||
*/
|
*/
|
||||||
export function getDefaultProductNames() {
|
export function getDefaultProductNames() {
|
||||||
const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;
|
const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;
|
||||||
|
// TODO: can be removed once new deal is released.
|
||||||
|
const personalFreePlan = process.env.NEW_DEAL === 'true'
|
||||||
|
? PERSONAL_FREE_PLAN : PERSONAL_LEGACY_PLAN;
|
||||||
return {
|
return {
|
||||||
personal: defaultProduct || FREE_PERSONAL_PLAN, // Personal site start off on a functional plan.
|
// Personal site start off on a functional plan.
|
||||||
teamInitial: defaultProduct || 'stub', // Team site starts off on a limited plan, requiring subscription.
|
personal: defaultProduct || personalFreePlan,
|
||||||
teamCancel: 'suspended', // Team site that has been 'turned off'.
|
// Team site starts off on a limited plan, requiring subscription.
|
||||||
team: defaultProduct || TEAM_PLAN, // Functional team site.
|
teamInitial: defaultProduct || 'stub',
|
||||||
|
// Team site that has been 'turned off'.
|
||||||
|
teamCancel: 'suspended',
|
||||||
|
// Functional team site.
|
||||||
|
team: defaultProduct || TEAM_PLAN,
|
||||||
teamFree: defaultProduct || TEAM_FREE_PLAN,
|
teamFree: defaultProduct || TEAM_FREE_PLAN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,8 @@ import {Group} from "app/gen-server/entity/Group";
|
|||||||
import {Login} from "app/gen-server/entity/Login";
|
import {Login} from "app/gen-server/entity/Login";
|
||||||
import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization";
|
import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization";
|
||||||
import {Pref} from "app/gen-server/entity/Pref";
|
import {Pref} from "app/gen-server/entity/Pref";
|
||||||
import {getDefaultProductNames, Product, starterFeatures} from "app/gen-server/entity/Product";
|
import {getDefaultProductNames, personalFreeFeatures, personalLegacyFeatures,
|
||||||
|
Product} from "app/gen-server/entity/Product";
|
||||||
import {Secret} from "app/gen-server/entity/Secret";
|
import {Secret} from "app/gen-server/entity/Secret";
|
||||||
import {User} from "app/gen-server/entity/User";
|
import {User} from "app/gen-server/entity/User";
|
||||||
import {Workspace} from "app/gen-server/entity/Workspace";
|
import {Workspace} from "app/gen-server/entity/Workspace";
|
||||||
@ -811,7 +812,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
id: 0,
|
id: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
domain: this.mergedOrgDomain(),
|
domain: this.mergedOrgDomain(),
|
||||||
name: 'Anonymous',
|
name: 'Anonymous',
|
||||||
owner: this.makeFullUser(this.getAnonymousUser()),
|
owner: this.makeFullUser(this.getAnonymousUser()),
|
||||||
access: 'viewers',
|
access: 'viewers',
|
||||||
@ -820,7 +821,8 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
individual: true,
|
individual: true,
|
||||||
product: {
|
product: {
|
||||||
name: 'anonymous',
|
name: 'anonymous',
|
||||||
features: starterFeatures,
|
features: process.env.NEW_DEAL === 'true'
|
||||||
|
? personalFreeFeatures : personalLegacyFeatures,
|
||||||
},
|
},
|
||||||
isManager: false,
|
isManager: false,
|
||||||
inGoodStanding: true,
|
inGoodStanding: true,
|
||||||
|
@ -52,6 +52,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
|
|||||||
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
|
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
|
||||||
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
|
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
|
||||||
activation: (mreq as RequestWithLogin|undefined)?.activation,
|
activation: (mreq as RequestWithLogin|undefined)?.activation,
|
||||||
|
newDeal: process.env.NEW_DEAL === 'true',
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -105,6 +105,17 @@ export const exampleOrgs = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Charonland',
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
name: 'Home',
|
||||||
|
docs: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Some tests check behavior on new free personal plans.
|
||||||
|
product: 'personalFree',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Chimpyland',
|
name: 'Chimpyland',
|
||||||
workspaces: [
|
workspaces: [
|
||||||
@ -122,6 +133,17 @@ export const exampleOrgs = [
|
|||||||
name: 'Kiwiland',
|
name: 'Kiwiland',
|
||||||
workspaces: []
|
workspaces: []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Hamland',
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
name: 'Home',
|
||||||
|
docs: []
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Some tests check behavior on legacy free personal plans.
|
||||||
|
product: 'starter',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'EmptyWsOrg',
|
name: 'EmptyWsOrg',
|
||||||
domain: 'blanky',
|
domain: 'blanky',
|
||||||
@ -223,6 +245,7 @@ const exampleUsers: {[user: string]: {[org: string]: string}} = {
|
|||||||
Fish: 'editors'
|
Fish: 'editors'
|
||||||
},
|
},
|
||||||
Charon: {
|
Charon: {
|
||||||
|
Charonland: 'owners',
|
||||||
NASA: 'guests',
|
NASA: 'guests',
|
||||||
Horizon: 'guests',
|
Horizon: 'guests',
|
||||||
Pluto: 'viewers',
|
Pluto: 'viewers',
|
||||||
@ -231,7 +254,9 @@ const exampleUsers: {[user: string]: {[org: string]: string}} = {
|
|||||||
Abyss: 'owners',
|
Abyss: 'owners',
|
||||||
},
|
},
|
||||||
// User Ham has two-factor authentication enabled on staging/prod.
|
// User Ham has two-factor authentication enabled on staging/prod.
|
||||||
Ham: {},
|
Ham: {
|
||||||
|
Hamland: 'owners',
|
||||||
|
},
|
||||||
// User support@ owns a workspace "Examples & Templates" in its personal org. It can be shared
|
// User support@ owns a workspace "Examples & Templates" in its personal org. It can be shared
|
||||||
// with everyone@ to let all users see it (this is not done here to avoid impacting all tests).
|
// with everyone@ to let all users see it (this is not done here to avoid impacting all tests).
|
||||||
Support: { Supportland: 'owners' },
|
Support: { Supportland: 'owners' },
|
||||||
|
@ -2435,10 +2435,6 @@ export async function setWidgetUrl(url: string) {
|
|||||||
await waitForServer();
|
await waitForServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleNewDeal(on = true) {
|
|
||||||
await driver.executeScript(`NEW_DEAL.set(${on ? 'true' : 'false'});`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // end of namespace gristUtils
|
} // end of namespace gristUtils
|
||||||
|
|
||||||
stackWrapOwnMethods(gristUtils);
|
stackWrapOwnMethods(gristUtils);
|
||||||
|
Loading…
Reference in New Issue
Block a user