Added new free tier card with custom description/benefits (#2203)

refs https://github.com/TryGhost/Team/issues/1037

Adds new free tier card with option to add custom description and benefits for free tier, behind the tiers beta flag. Also:

- updates formatting of tier prices
- changes "Free" section to "Default"
- updates price formatting of membership tiers in admin
- updates currency code handling for product card
- updates default paid product handling

Co-authored-by: Djordje Vlaisavljevic <dzvlais@gmail.com>
Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
This commit is contained in:
Rishabh Garg 2022-01-18 00:23:43 +05:30 committed by GitHub
parent eb2109499a
commit 86b55b0f81
22 changed files with 295 additions and 164 deletions

View File

@ -166,8 +166,9 @@ export default class GhLaunchWizardConnectStripeComponent extends Component {
try {
yield this.settings.save();
const products = yield this.store.query('product', {include: 'monthly_price,yearly_price'});
const products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price,yearly_price'});
this.product = products.firstObject;
if (this.product) {
const yearlyDiscount = this.calculateDiscount(5, 50);
this.product.set('monthlyPrice', {

View File

@ -173,8 +173,9 @@ export default class GhLaunchWizardSetPricingComponent extends Component {
this.stripeMonthlyAmount = storedData.monthlyAmount;
this.stripeYearlyAmount = storedData.yearlyAmount;
} else {
const products = yield this.store.query('product', {include: 'monthly_price,yearly_price'});
const products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price,yearly_price'});
this.product = products.firstObject;
let portalPlans = this.settings.get('portalPlans') || [];
this.isMonthlyChecked = portalPlans.includes('monthly');

View File

@ -251,7 +251,7 @@ export default Component.extend({
},
saveProduct: task(function* () {
const products = yield this.store.query('product', {include: 'monthly_price, yearly_price'});
const products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price, yearly_price'});
this.product = products.firstObject;
if (this.product) {
const yearlyDiscount = this.calculateDiscount(5, 50);

View File

@ -148,7 +148,7 @@ export default class GhMembersRecipientSelect extends Component {
if (this.feature.get('multipleProducts')) {
// fetch all products w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶
// TODO: add `include: 'count.members` to query once API supports
const products = yield this.store.query('product', {limit: 'all'});
const products = yield this.store.query('product', {filter: 'type:paid', limit: 'all'});
if (products.length > 1) {
const productsGroup = {

View File

@ -97,7 +97,7 @@ export default class GhMembersSegmentSelect extends Component {
if (this.feature.get('multipleProducts')) {
// fetch all products w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶
// TODO: add `include: 'count.members` to query once API supports
const products = yield this.store.query('product', {limit: 'all', include: 'monthly_price,yearly_price,benefits'});
const products = yield this.store.query('product', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'});
if (products.length > 0) {
const productsGroup = {

View File

@ -1,54 +1,14 @@
<label>Tiers</label>
<div class="gh-product-cards">
{{#each this.products as |product productIdx|}}
<div class="gh-main-content-card gh-product-card">
<button class="gh-product-card-editbutton gh-btn gh-btn-text gh-btn-link green" {{action "openEditProduct" product}}>
<span>Edit</span>
</button>
<div class="gh-product-card-block">
<h3 class="gh-product-card-name">
{{product.name}}
</h3>
<p class="gh-product-card-description">
{{product.description}}
</p>
</div>
<div class="gh-product-card-block benefits-block">
<h4>Benefits <span class="counter">({{if product.benefits.length product.benefits.length "0"}})</span></h4>
{{#if product.benefits.length}}
<ul class="benefits">
{{#each product.benefits as |benefit|}}
<li>{{svg-jar "check"}} {{benefit.name}} </li>
{{/each}}
</ul>
{{else}}
<p class="gh-product-card-description">No benefits added for this tier.</p>
{{/if}}
</div>
<div class="gh-product-card-block">
<div class="gh-product-price-container">
<div class="gh-product-card-price">
<div class="flex items-baseline">
<span class="amount">{{gh-price-amount product.monthlyPrice.amount}}</span>
<span class="currency">{{product.monthlyPrice.currency}}</span>
</div>
<div class="period">Monthly</div>
</div>
<div class="gh-product-card-price">
<div class="flex items-baseline">
<span class="amount">{{gh-price-amount product.yearlyPrice.amount}}</span>
<span class="currency">{{product.monthlyPrice.currency}}</span>
</div>
<div class="period">Yearly</div>
</div>
</div>
</div>
</div>
<GhProductCard
@product={{product}}
@openEditProduct={{this.openEditProduct}}
/>
{{/each}}
<div class="gh-product-cards-footer">
<button class="gh-btn gh-btn-link gh-btn-text gh-btn-icon gh-btn-add-product green" {{action "openNewProduct" product}}><span>{{svg-jar "add-stroke" class="stroke-green"}}Add tier</span></button>
{{!-- <span>&ndash; Advanced (<a href="javascript:">learn more</a>)</span> --}}
</div>
</div>

View File

@ -57,7 +57,7 @@ export default Component.extend({
}
},
fetchProducts: task(function* () {
const products = yield this.store.query('product', {include: 'monthly_price,yearly_price'}) || [];
const products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price,yearly_price'}) || [];
this.set('products', products);
if (products.length > 0) {
this.set('selectedProduct', {

View File

@ -0,0 +1,57 @@
<div class="gh-main-content-card gh-product-card">
<button class="gh-product-card-editbutton gh-btn gh-btn-text gh-btn-link green" {{action "openEditProduct" this.product}}>
<span>Edit</span>
</button>
<div class="gh-product-card-block title-block">
<h3 class="gh-product-card-name">
{{this.product.name}}
</h3>
<p class="gh-product-card-description">
{{this.product.description}}
</p>
</div>
<div class="gh-product-card-block benefits-block">
<h4>Benefits <span class="counter">({{if this.product.benefits.length product.benefits.length "0"}})</span></h4>
{{#if this.product.benefits.length}}
<ul class="benefits">
{{#each this.product.benefits as |benefit|}}
<li>{{svg-jar "check"}} {{benefit.name}} </li>
{{/each}}
</ul>
{{else}}
<p class="gh-product-card-description">No benefits added for this tier.</p>
{{/if}}
</div>
{{#if (eq this.product.type "free" )}}
<div class="gh-product-card-block">
<div class="gh-product-price-container">
<div class="gh-product-card-price">
<div class="flex items-start">
<span class="currency">{{currency-symbol this.product.monthlyPrice.currency}}</span>
<span class="amount">0</span>
</div>
</div>
</div>
</div>
{{/if}}
{{#if (eq this.product.type "paid" )}}
<div class="gh-product-card-block">
<div class="gh-product-price-container">
<div class="gh-product-card-price">
<div class="flex items-start">
<span class="currency">{{currency-symbol this.product.monthlyPrice.currency}}</span>
<span class="amount">{{gh-price-amount this.product.monthlyPrice.amount}}</span>
</div>
<div class="period">Monthly</div>
</div>
<div class="gh-product-card-price">
<div class="flex items-start">
<span class="currency">{{currency-symbol this.product.monthlyPrice.currency}}</span>
<span class="amount">{{gh-price-amount this.product.yearlyPrice.amount}}</span>
</div>
<div class="period">Yearly</div>
</div>
</div>
</div>
{{/if}}
</div>

View File

@ -0,0 +1,37 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {getSymbol} from 'ghost-admin/utils/currency';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class extends Component {
@service membersUtils;
@service ghostPaths;
@service ajax;
@service store;
@service config;
@tracked showProductModal = false;
@tracked productModel = null;
get product() {
return this.args.product;
}
get isPaidProduct() {
return this.product.type === 'paid';
}
get hasCurrencySymbol() {
const currencySymbol = getSymbol(this.product?.monthlyPrice?.currency);
return currencySymbol?.length !== 3;
}
get isFreeProduct() {
return this.product.type === 'free';
}
@action
async openEditProduct(product) {
this.args.openEditProduct(product);
}
}

View File

@ -17,7 +17,8 @@ export default class ModalMemberProduct extends ModalComponent {
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {include: 'monthly_price,yearly_price,benefits'});
this.products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price,yearly_price,benefits'});
this.loadingProducts = false;
if (this.products.length > 0) {
this.selectedProduct = this.products.firstObject.id;

View File

@ -72,11 +72,12 @@ export default ModalComponent.extend({
return (this.membersUtils.isStripeEnabled && allowedPlans.includes('yearly'));
}),
products: computed('model.products.[]', 'settings.portalProducts.[]', 'isPreloading', function () {
if (this.isPreloading || !this.model.products) {
const paidProducts = this.model.products?.filter(product => product.type === 'paid');
if (this.isPreloading || !paidProducts?.length) {
return [];
}
const portalProducts = this.settings.get('portalProducts') || [];
const products = this.model.products.map((product) => {
const products = paidProducts.map((product) => {
return {
id: product.id,
name: product.name,

View File

@ -13,17 +13,19 @@
<div class="gh-main-section-block span-2">
<h4 class="gh-main-section-header small bn">Basic</h4>
<div class="gh-main-section-content grey gh-product-priceform-block">
<GhFormGroup @errors={{this.errors}} @property="name">
<label for="name" class="fw6">Name</label>
<GhTextInput
@value={{readonly this.product.name}}
@input={{action (mut this.product.name) value="target.value"}}
@name="name"
@placeholder="Bronze"
@id="name"
@class="gh-input" />
<GhErrorMessage @errors={{this.errors}} @property="name" />
</GhFormGroup>
{{#if (not this.isFreeProduct)}}
<GhFormGroup @errors={{this.errors}} @property="name">
<label for="name" class="fw6">Name</label>
<GhTextInput
@value={{readonly this.product.name}}
@input={{action (mut this.product.name) value="target.value"}}
@name="name"
@placeholder="Bronze"
@id="name"
@class="gh-input" />
<GhErrorMessage @errors={{this.errors}} @property="name" />
</GhFormGroup>
{{/if}}
<GhFormGroup @errors={{this.errors}} @property="description">
<label for="description" class="fw6">Description</label>
<GhTextInput
@ -35,57 +37,59 @@
@class="gh-input" />
<GhErrorMessage @errors={{this.errors}} @property="description" />
</GhFormGroup>
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="prices">
<div class="gh-settings-members-pricelabelcont">
<label for="monthlyPrice">Prices</label>
<span></span>
<div>
<span class="gh-setting-members-currency gh-select">
<div class="gh-setting-members-currencylabel">
<span>{{this.currency}}</span>
{{svg-jar "arrow-down-small"}}
</div>
<OneWaySelect
@value={{this.selectedCurrency}}
id="currency"
name="currency"
@options={{readonly this.allCurrencies}}
@optionValuePath="value"
@optionLabelPath="label"
@update={{action "setCurrency"}}
/>
</span>
{{#if (not this.isFreeProduct)}}
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="prices">
<div class="gh-settings-members-pricelabelcont">
<label for="monthlyPrice">Prices</label>
<span></span>
<div>
<span class="gh-setting-members-currency gh-select">
<div class="gh-setting-members-currencylabel">
<span>{{this.currency}}</span>
{{svg-jar "arrow-down-small"}}
</div>
<OneWaySelect
@value={{this.selectedCurrency}}
id="currency"
name="currency"
@options={{readonly this.allCurrencies}}
@optionValuePath="value"
@optionLabelPath="label"
@update={{action "setCurrency"}}
/>
</span>
</div>
</div>
</div>
<div class="gh-setting-members-prices">
<div class="gh-setting-members-prices">
<div class="gh-input-group">
<GhTextInput
@id="monthlyPrice"
@value={{readonly this.stripeMonthlyAmount}}
@type="number"
@input={{action (mut this.stripeMonthlyAmount) value="target.value"}}
@focus-out={{action "validateStripePlans"}}
/>
<span class="gh-input-append"><span class="ttu">{{this.currency}}</span>/month</span>
<div class="gh-input-group">
<GhTextInput
@id="monthlyPrice"
@value={{readonly this.stripeMonthlyAmount}}
@type="number"
@input={{action (mut this.stripeMonthlyAmount) value="target.value"}}
@focus-out={{action "validateStripePlans"}}
/>
<span class="gh-input-append"><span class="ttu">{{this.currency}}</span>/month</span>
</div>
<div class="gh-input-group">
<GhTextInput
@id="yearlyPrice"
@value={{readonly this.stripeYearlyAmount}}
@type="number"
@input={{action (mut this.stripeYearlyAmount) value="target.value"}}
@focus-out={{this.validateStripePlans}}
@placeholder=''
data-test-title-input={{true}}
/>
<span class="gh-input-append"><span class="ttu">{{this.currency}}</span>/year</span>
</div>
</div>
<div class="gh-input-group">
<GhTextInput
@id="yearlyPrice"
@value={{readonly this.stripeYearlyAmount}}
@type="number"
@input={{action (mut this.stripeYearlyAmount) value="target.value"}}
@focus-out={{this.validateStripePlans}}
@placeholder=''
data-test-title-input={{true}}
/>
<span class="gh-input-append"><span class="ttu">{{this.currency}}</span>/year</span>
</div>
</div>
{{#if this.stripePlanError}}
<p class="response w-100"><span class="red">{{this.stripePlanError}}</span></p>
{{/if}}
</GhFormGroup>
{{#if this.stripePlanError}}
<p class="response w-100"><span class="red">{{this.stripePlanError}}</span></p>
{{/if}}
</GhFormGroup>
{{/if}}
</div>
<h4 class="gh-main-section-header small bn">Benefits</h4>
@ -125,7 +129,7 @@
<h4 class="gh-main-section-header small bn">Tier Preview</h4>
<div class="gh-main-section-content" style="border-color: {{this.settings.accentColor}}">
<span class="checkmark" style="background-color: {{this.settings.accentColor}}"></span>
{{#if this.product.name}}
<h4>{{this.product.name}}</h4>
{{else}}
@ -149,27 +153,32 @@
<li>{{svg-jar "check-2"}} <span>Expert analysis</span></li>
</ul>
{{/if}}
<div class="price">
{{#if this.stripeMonthlyAmount}}
<span class="monthly-price">
<span class="currency">{{currency-symbol this.currency}}</span>
{{this.stripeMonthlyAmount}}
<span class="period">/month</span>
</span>
{{else}}
<span class="monthly-price placeholder">
<span class="currency">{{currency-symbol this.currency}}</span>
0
<span class="period">/month</span>
</span>
{{/if}}
{{#if (not this.isFreeProduct)}}
{{#if this.stripeMonthlyAmount}}
<span class="monthly-price">
<span class="currency">{{currency-symbol this.currency}}</span>
{{format-number this.stripeMonthlyAmount}}
<span class="period">/month</span>
</span>
{{else}}
<span class="monthly-price placeholder">
<span class="currency">{{currency-symbol this.currency}}</span>
0
<span class="period">/month</span>
</span>
{{/if}}
{{#if this.stripeYearlyAmount}}
<span class="yearly-price">{{currency-symbol this.currency}}{{this.stripeYearlyAmount}}/year</span>
{{#if this.stripeYearlyAmount}}
<span class="yearly-price">{{currency-symbol this.currency}}{{format-number this.stripeYearlyAmount}}/year</span>
{{else}}
<span class="yearly-price placeholder">0<span class="currency">{{this.currency}}</span>/year</span>
{{/if}}
{{else}}
<span class="yearly-price placeholder">0<span class="currency">{{this.currency}}</span>/year</span>
<span class="monthly-price">
<span class="currency">{{currency-symbol this.currency}}</span>
0
</span>
{{/if}}
</div>
</div>

View File

@ -34,6 +34,10 @@ export default class ModalProductPrice extends ModalBase {
confirm() {}
get isFreeProduct() {
return this.product.type === 'free';
}
get allCurrencies() {
return getCurrencyOptions();
}
@ -104,24 +108,26 @@ export default class ModalProductPrice extends ModalBase {
yield this.send('addBenefit', this.newBenefit);
}
const monthlyAmount = this.stripeMonthlyAmount * 100;
const yearlyAmount = this.stripeYearlyAmount * 100;
this.product.set('monthlyPrice', {
nickname: 'Monthly',
amount: monthlyAmount,
active: true,
currency: this.currency,
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
nickname: 'Yearly',
amount: yearlyAmount,
active: true,
currency: this.currency,
interval: 'year',
type: 'recurring'
});
if (!this.isFreeProduct) {
const monthlyAmount = this.stripeMonthlyAmount * 100;
const yearlyAmount = this.stripeYearlyAmount * 100;
this.product.set('monthlyPrice', {
nickname: 'Monthly',
amount: monthlyAmount,
active: true,
currency: this.currency,
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
nickname: 'Yearly',
amount: yearlyAmount,
active: true,
currency: this.currency,
interval: 'year',
type: 'recurring'
});
}
this.product.set('benefits', this.benefits);
yield this.product.save();

View File

@ -54,7 +54,9 @@ export default class DashboardController extends Controller {
}
async loadMRRStats() {
const products = await this.store.query('product', {include: 'monthly_price,yearly_price', limit: 'all'});
const products = await this.store.query('product', {
filter: 'type:paid', include: 'monthly_price,yearly_price', limit: 'all'
});
const defaultProduct = products?.firstObject;
this.mrrStatsLoading = true;

View File

@ -99,7 +99,7 @@ export default class OffersController extends Controller {
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {include: 'monthly_price,yearly_price'});
this.products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price,yearly_price'});
this.products = this.products.filter((d) => {
return d.monthlyPrice && d.yearlyPrice;
});

View File

@ -70,7 +70,9 @@ export default class MembersController extends Controller {
@task({restartable: true})
*fetchOffersTask() {
this.products = yield this.store.query('product', {include: 'monthly_price,yearly_price'});
this.products = yield this.store.query('product', {
filter: 'type:paid', include: 'monthly_price,yearly_price'
});
this.offers = yield this.store.query('offer', {limit: 'all'});
return this.offers;
}

View File

@ -44,6 +44,14 @@ export default class MembersAccessController extends Controller {
queryParams = ['showPortalSettings'];
get freeProduct() {
return this.products?.find(product => product.type === 'free');
}
get paidProducts() {
return this.products?.filter(product => product.type === 'paid');
}
get allCurrencies() {
return getCurrencyOptions();
}
@ -311,7 +319,9 @@ export default class MembersAccessController extends Controller {
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {include: 'monthly_price,yearly_price,benefits'});
this.products = yield this.store.query('product', {
filter: 'type:paid', include: 'monthly_price,yearly_price,benefits'
});
this.product = this.products.firstObject;
this.setupPortalProduct(this.product);
}

View File

@ -1,12 +1,13 @@
import {formatNumber} from './format-number';
import {helper} from '@ember/component/helper';
export function ghPriceAmount(amount) {
if (amount) {
let price = amount / 100;
if (price % 1 === 0) {
return price;
return formatNumber(price);
} else {
return (Math.round(price * 100) / 100).toFixed(2);
return formatNumber(Math.round(price * 100) / 100).toFixed(2);
}
}
return 0;

View File

@ -7,6 +7,7 @@ export default Model.extend(ValidationEngine, {
name: attr('string'),
description: attr('string'),
slug: attr('string'),
type: attr('string', {defaultValue: 'paid'}),
monthlyPrice: attr('stripe-price'),
yearlyPrice: attr('stripe-price'),
benefits: attr('product-benefits')

View File

@ -35,7 +35,7 @@
}
.gh-product-card-block {
flex-basis: 50%;
flex-basis: 30%;
}
.gh-product-card-block:not(:first-of-type) {
@ -65,6 +65,10 @@
color: var(--midgrey);
}
.gh-product-card-block.title-block {
flex-basis: 40%;
}
.gh-product-card-block.benefits-block .gh-product-card-description {
margin-top: 9px;
}
@ -99,13 +103,15 @@
.gh-product-price-container {
display: flex;
margin: 0 40px 0 20px;
margin: 0 60px 0 20px;
justify-content: flex-end;
}
.gh-product-card-price {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0 12px 2px 0;
font-size: 1.3rem;
color: var(--midgrey);
@ -113,11 +119,13 @@
border: 1px solid var(--whitegrey);
border-radius: 3px;
min-width: 90px;
min-height: 66px;
}
.gh-product-card-price .currency-symbol,
.gh-product-card-price .amount,
.gh-product-card-price .currency {
.gh-product-card-price .currency,
.gh-product-card-price .currency-code {
font-weight: 600;
color: var(--darkgrey);
}
@ -127,22 +135,39 @@
}
.gh-product-card-price .amount {
font-size: 2.1rem;
letter-spacing: -0.2px;
letter-spacing: -.2px;
line-height: 1;
margin-right: 2px;
font-size: 2.2rem;
font-weight: 500;
letter-spacing: 0.1px;
}
.gh-product-card-price .currency {
font-size: 1.25rem;
letter-spacing: -0.2px;
text-transform: uppercase;
position: relative;
top: 2px;
font-size: 1.4rem;
font-weight: 500;
letter-spacing: 0.4px;
line-height: 1;
}
.gh-product-card-price .currency-code {
text-transform: uppercase;
position: relative;
top: 0;
font-weight: 600;
line-height: 1;
font-size: 1.25rem;
letter-spacing: -.2px;
}
.gh-product-card-price .period {
font-size: 1.25rem;
text-transform: lowercase;
line-height: 1.2em;
margin-top: 2px;
}
.gh-product-cards-footer {

View File

@ -81,7 +81,7 @@
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Free</h4>
<h4 class="gh-expandable-title">Default</h4>
<p class="gh-expandable-description">Free member sign up settings</p>
</div>
<button type="button" class="gh-btn" {{on "click" (toggle "freeOpen" this)}} data-test-toggle-pub-info><span>{{if this.freeOpen "Close" "Expand"}}</span></button>
@ -89,6 +89,10 @@
<div class="gh-expandable-content">
{{#liquid-if this.freeOpen}}
<div class="gh-setting-content-extended">
<GhProductCard
@product={{this.freeProduct}}
@openEditProduct={{this.openEditProduct}}
/>
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="free-welcome-page">
<label for="freeWelcomePage">Welcome page</label>
<GhUrlInput
@ -143,7 +147,7 @@
{{else}}
{{#if (feature "multipleProducts")}}
<GhMembershipProductsAlpha
@products={{this.products}}
@products={{this.paidProducts}}
@confirmProductSave={{this.confirmProductSave}}
/>
{{else}}
@ -259,4 +263,14 @@
@close={{this.closeStripeConnect}}
@modifier="action wide stripe-connect" />
{{/if}}
{{#if this.showProductModal}}
<GhFullscreenModal
@modal="product"
@model={{hash
product=this.productModel
}}
@confirm={{this.confirmProductSave}}
@close={{this.closeProductModal}}
@modifier="edit-product action wide" />
{{/if}}
</section>

View File

@ -121,6 +121,9 @@ export const currencies = [
];
export function getSymbol(currency) {
if (!currency) {
return '';
}
return Intl.NumberFormat('en', {currency, style: 'currency'}).format(0).replace(/[\d\s.]/g, '');
}