(core) complete light sign-up flow for appsumo, and customize summaries

Summary:
Current appsumo sign-up flow doesn't reach the billing pages.
This diff nudges user on through that extra step.

It also tweaks plan summaries to say what special appsumo
features are in effect (member count prepaid for).

Test Plan: manual

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2882
This commit is contained in:
Paul Fitzpatrick 2021-06-24 19:32:37 -04:00
parent 36d5e7870e
commit dca3abec1d
3 changed files with 139 additions and 82 deletions

View File

@ -129,17 +129,27 @@ export class BillingPage extends Disposable {
), ),
css.summaryBlock( css.summaryBlock(
css.billingHeader('Billing Summary'), css.billingHeader('Billing Summary'),
dom.maybe(this._model.subscription, sub => { this.buildSubscriptionSummary(),
)
);
}
public buildSubscriptionSummary() {
return dom.maybe(this._model.subscription, sub => {
const plans = this._model.plans.get(); const plans = this._model.plans.get();
const moneyPlan = sub.upcomingPlan || sub.activePlan; const moneyPlan = sub.upcomingPlan || sub.activePlan;
const changingPlan = sub.upcomingPlan && sub.upcomingPlan.amount > 0; const changingPlan = sub.upcomingPlan && sub.upcomingPlan.amount > 0;
const cancellingPlan = sub.upcomingPlan && sub.upcomingPlan.amount === 0; const cancellingPlan = sub.upcomingPlan && sub.upcomingPlan.amount === 0;
const validPlan = sub.isValidPlan; const validPlan = sub.isValidPlan;
const planId = validPlan ? sub.activePlan.id : sub.lastPlanId; const planId = validPlan ? sub.activePlan.id : sub.lastPlanId;
// If on a "Tier" coupon, present information differently, emphasizing the coupon
// name and minimizing the plan.
const tier = sub.discountName && sub.discountName.includes(' Tier ');
const planName = tier ? sub.discountName! : sub.activePlan.nickname;
return [ return [
css.summaryFeatures( css.summaryFeatures(
validPlan ? [ validPlan ? [
makeSummaryFeature(['You are subscribed to the ', sub.activePlan.nickname, ' plan']), makeSummaryFeature(['You are subscribed to the ', planName, ' plan']),
] : [ ] : [
makeSummaryFeature(['This team site is not in good standing'], makeSummaryFeature(['This team site is not in good standing'],
{isBad: true}), {isBad: true}),
@ -160,9 +170,12 @@ export class BillingPage extends Disposable {
moneyPlan.amount ? [ moneyPlan.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`, makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
` member${sub.userCount > 1 ? 's' : ''}`]), ` member${sub.userCount > 1 ? 's' : ''}`]),
makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `, tier ? this.buildAppSumoPlanNotes(sub.discountName!) : null,
getPriceString(moneyPlan.amount * sub.userCount)]), // Currently the subtotal is misleading and scary when tiers are in effect.
sub.discountName ? makeSummaryFeature([`You receive the `, sub.discountName]) : null, // In this case, for now, just report what will be invoiced.
!tier ? makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `,
getPriceString(moneyPlan.amount * sub.userCount)]) : null,
(sub.discountName && !tier) ? makeSummaryFeature([`You receive the `, sub.discountName]) : null,
// When on a free trial, Stripe reports trialEnd time, but it seems to always // When on a free trial, Stripe reports trialEnd time, but it seems to always
// match periodEnd for a trialing subscription, so we just use that. // match periodEnd for a trialing subscription, so we just use that.
sub.isInTrial ? makeSummaryFeature(['Your free trial ends on ', dateFmtFull(sub.periodEnd)]) : null, sub.isInTrial ? makeSummaryFeature(['Your free trial ends on ', dateFmtFull(sub.periodEnd)]) : null,
@ -184,7 +197,8 @@ export class BillingPage extends Disposable {
) : ) :
null null
), ),
(moneyPlan.amount === 0 && planId) ? css.billingTextBtn({ style: 'margin: 10px 0;' }, (moneyPlan.amount === 0 && planId) ? css.billingTextBtn(
{ style: 'margin: 10px 0;' },
// If the plan was cancellled, make the text indicate that changing the plan will // If the plan was cancellled, make the text indicate that changing the plan will
// renew the subscription (abort the cancellation). // renew the subscription (abort the cancellation).
css.billingIcon('Settings'), 'Renew subscription', css.billingIcon('Settings'), 'Renew subscription',
@ -198,7 +212,8 @@ export class BillingPage extends Disposable {
testId('update-plan') testId('update-plan')
) : null, ) : null,
// Do not show the cancel subscription option if it was already cancelled. // Do not show the cancel subscription option if it was already cancelled.
plans.length > 0 && moneyPlan.amount > 0 ? css.billingTextBtn({ style: 'margin: 10px 0;' }, plans.length > 0 && moneyPlan.amount > 0 ? css.billingTextBtn(
{ style: 'margin: 10px 0;' },
css.billingIcon('Settings'), 'Cancel subscription', css.billingIcon('Settings'), 'Cancel subscription',
urlState().setLinkUrl({ urlState().setLinkUrl({
billing: 'payment', billing: 'payment',
@ -210,9 +225,29 @@ export class BillingPage extends Disposable {
testId('cancel-subscription') testId('cancel-subscription')
) : null ) : null
]; ];
}) });
) }
);
public buildAppSumoPlanNotes(name: string) {
// TODO: move AppSumo plan knowledge elsewhere.
let users = 0;
switch (name) {
case 'AppSumo Tier 1':
users = 1;
break;
case 'AppSumo Tier 2':
users = 3;
break;
case 'AppSumo Tier 3':
users = 8;
break;
}
if (users > 0) {
return makeSummaryFeature([`Your AppSumo plan covers `,
`${users}`,
` member${users > 1 ? 's' : ''}`]);
}
return null;
} }
public buildPlansPage() { public buildPlansPage() {
@ -561,6 +596,8 @@ export class BillingPage extends Disposable {
private _buildPaymentSummary(task: BillingTask) { private _buildPaymentSummary(task: BillingTask) {
if (task === 'signUp' || task === 'updatePlan') { if (task === 'signUp' || task === 'updatePlan') {
return dom.maybe(this._model.signupPlan, _plan => this._buildPlanPaymentSummary(_plan, task)); return dom.maybe(this._model.signupPlan, _plan => this._buildPlanPaymentSummary(_plan, task));
} else if (task === 'signUpLite') {
return this.buildSubscriptionSummary();
} else if (task === 'addCard' || task === 'updateCard') { } else if (task === 'addCard' || task === 'updateCard') {
return makeSummaryFeature('You are updating the default payment method'); return makeSummaryFeature('You are updating the default payment method');
} else if (task === 'updateAddress') { } else if (task === 'updateAddress') {

View File

@ -41,6 +41,8 @@ export function checkSubdomainValidity(subdomain: string): void {
} }
// 'docs-*' is reserved for personal orgs. // 'docs-*' is reserved for personal orgs.
if (subdomain.startsWith('docs-')) { throw new Error('Domain cannot use reserved prefix "docs-".'); } if (subdomain.startsWith('docs-')) { throw new Error('Domain cannot use reserved prefix "docs-".'); }
// 'o-*' is reserved for automatic org domains.
if (subdomain.startsWith('o-')) { throw new Error('Domain cannot use reserved prefix "o-".'); }
// 'doc-worker-*' is reserved for doc workers. // 'doc-worker-*' is reserved for doc workers.
if (subdomain.startsWith('doc-worker-')) { throw new Error('Domain cannot use reserved prefix "doc-worker-".'); } if (subdomain.startsWith('doc-worker-')) { throw new Error('Domain cannot use reserved prefix "doc-worker-".'); }
// special subdomains like _domainkey. // special subdomains like _domainkey.

View File

@ -668,6 +668,24 @@ export class FlexServer implements GristServer {
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : '';
return res.redirect(`${prefix}/welcome/user`); return res.redirect(`${prefix}/welcome/user`);
} }
if (mreq.org && mreq.org.startsWith('o-')) {
// We are on a team site without a custom subdomain.
// If the user is a billing manager for the org, and the org
// is supposed to have a custom subdomain, forward the user
// to a page to set it.
// TODO: this is more or less a hack for AppSumo signup flow,
// and could be removed if/when signup flow is revamped.
// If "welcomeNewUser" is ever added to billing pages, we'd need
// to avoid a redirect loop.
const orgInfo = this.dbManager.unwrapQueryResult(await this.dbManager.getOrg({userId: user.id}, mreq.org));
if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.product.features.vanityDomain) {
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : '';
return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`);
}
}
next(); next();
}); });