0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00

Added UI for handling multiple tiers in membership settings

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

Adds new modal and component to handle managing a list of products(tiers) in Admin behind the developer experiments flag. Also adds a new helper for stripe prices to convert amount from decimal value.
This commit is contained in:
Rishabh 2021-06-04 13:07:47 +05:30 committed by Rishabh Garg
parent c10f8d014a
commit 6165441c30
7 changed files with 435 additions and 51 deletions

View file

@ -0,0 +1,54 @@
{{#each this.products as |product|}}
<div class="gh-product-card" style="flex-direction: row;align-items: stretch">
<div style="display: flex;flex-grow: 1;flex-shrink: 0;flex-basis: 50%">
<div style="display: flex;flex-direction:column;align-items: flex-start;flex-grow: 1">
<h3 class="gh-product-card-name">
{{product.name}}
</h3>
<p class="gh-product-card-description">
{{product.description}}
</p>
</div>
</div>
<div style="display: flex;flex-direction:column;align-items: flex-start;flex-grow: 1; flex-shrink: 0; border-left: 1px solid #c4cad2;padding-left: 12px;flex-basis: 50%">
<h3 class="gh-product-card-name" style="margin-bottom: 12px">
Pricing
</h3>
<p class="gh-product-card-description">
<span class="ttu" style="font-weight: bold">{{product.monthlyPrice.currency}}</span>
<span style="font-weight: bold">{{gh-price-amount product.monthlyPrice.amount}}</span>/month
</p>
<p class="gh-product-card-description" style="margin-bottom: 0">
<span class="ttu" style="font-weight: bold">{{product.monthlyPrice.currency}}</span>
<span style="font-weight: bold">{{gh-price-amount product.yearlyPrice.amount}}</span>/year
</p>
</div>
<div>
</div>
<div style="display: flex;align-items: center">
<div>
<button class="gh-btn gh-btn-link" {{action "openEditProduct" product}}>
<span>Edit</span>
</button>
</div>
</div>
</div>
{{/each}}
<div class="gh-product-card" {{action "openNewProduct" product}} style="cursor: pointer">
<div style="display: flex;flex-direction:column;align-items: flex-start;flex-grow: 1">
<h3 class="gh-product-card-name">
+ New tier
</h3>
</div>
</div>
{{#if this.showProductModal}}
<GhFullscreenModal
@modal="product"
@model={{hash
product=this.productModel
}}
@confirm={{this.confirmProductSave}}
@close={{this.closeProductModal}}
@modifier="action wide" />
{{/if}}

View file

@ -0,0 +1,41 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
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 products() {
return this.args.products;
}
@action
async openEditProduct(product) {
this.productModel = product;
this.showProductModal = true;
}
@action
async openNewProduct() {
this.productModel = this.store.createRecord('product');
this.showProductModal = true;
}
@action
closeProductModal() {
this.showProductModal = false;
}
@action
confirmProductSave() {
this.args.confirmProductSave();
}
}

View file

@ -0,0 +1,106 @@
<header class="modal-header" data-test-modal="webhook-form">
<h1 data-test-text="title">{{this.title}}</h1>
</header>
<button class="close" href title="Close" {{action "closeModal"}} {{action (optional this.noop) on="mouseDown"}}>
{{svg-jar "close"}}
</button>
<form>
<div class="modal-body">
<div class="gh-main-section-block">
<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"
@id="name"
@class="gh-input" />
<GhErrorMessage @errors={{this.errors}} @property="name" />
</GhFormGroup>
<GhFormGroup @errors={{this.errors}} @property="description">
<label for="description" class="fw6">Description</label>
<GhTextInput
@value={{readonly this.product.description}}
@input={{action (mut this.product.description) value="target.value"}}
@name="description"
@id="description"
@class="gh-input" />
<GhErrorMessage @errors={{this.errors}} @property="description" />
</GhFormGroup>
<div class="">
<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 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>
<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>
</div>
</div>
</div>
</div>
</form>
<div class="modal-footer">
<button
class="gh-btn"
{{action "closeModal"}}
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
{{action (optional this.noop) on="mouseDown"}}
data-test-button="cancel-webhook"
>
<span>Cancel</span>
</button>
<GhTaskButton @buttonText="Save"
@successText={{this.successText}}
@task={{this.saveProduct}}
@idleClass="gh-btn-primary"
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="save-product" />
</div>

View file

@ -0,0 +1,161 @@
import EmberObject, {action} from '@ember/object';
import ModalBase from 'ghost-admin/components/modal-base';
import classic from 'ember-classic-decorator';
import {currencies, getCurrencyOptions, getSymbol} from 'ghost-admin/utils/currency';
import {isEmpty} from '@ember/utils';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
const CURRENCIES = currencies.map((currency) => {
return {
value: currency.isoCode.toLowerCase(),
label: `${currency.isoCode} - ${currency.name}`,
isoCode: currency.isoCode
};
});
// TODO: update modals to work fully with Glimmer components
@classic
export default class ModalProductPrice extends ModalBase {
@service settings;
@tracked model;
@tracked product;
@tracked periodVal;
@tracked stripeMonthlyAmount = 5;
@tracked stripeYearlyAmount = 50;
@tracked currency = 'usd';
@tracked errors = EmberObject.create();
@tracked stripePlanError = '';
confirm() {}
get allCurrencies() {
return getCurrencyOptions();
}
get selectedCurrency() {
return CURRENCIES.findBy('value', this.currency);
}
init() {
super.init(...arguments);
this.product = this.model.product;
const monthlyPrice = this.product.get('monthlyPrice');
const yearlyPrice = this.product.get('yearlyPrice');
if (monthlyPrice) {
this.stripeMonthlyAmount = (monthlyPrice.amount / 100);
this.currency = monthlyPrice.currency;
}
if (yearlyPrice) {
this.stripeYearlyAmount = (yearlyPrice.amount / 100);
}
}
get title() {
if (this.isExistingProduct) {
return `Product - ${this.product.name || 'No Name'}`;
}
return 'New Product';
}
get isExistingProduct() {
return !!this.model.product;
}
// TODO: rename to confirm() when modals have full Glimmer support
@action
confirmAction() {
this.saveProduct.perform();
}
@action
close(event) {
event?.preventDefault?.();
this.closeModal();
}
@action
setCurrency(event) {
const newCurrency = event.value;
this.currency = newCurrency;
}
@task({drop: true})
*saveProduct() {
this.validatePrices();
if (!isEmpty(this.errors) && Object.keys(this.errors).length > 0) {
return;
}
if (this.stripePlanError) {
return;
}
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'
});
const savedProduct = yield this.product.save();
yield this.confirm(savedProduct);
this.send('closeModal');
}
validatePrices() {
this.stripePlanError = undefined;
try {
const yearlyAmount = this.stripeYearlyAmount;
const monthlyAmount = this.stripeMonthlyAmount;
const symbol = getSymbol(this.currency);
if (!yearlyAmount || yearlyAmount < 1 || !monthlyAmount || monthlyAmount < 1) {
throw new TypeError(`Subscription amount must be at least ${symbol}1.00`);
}
} catch (err) {
this.stripePlanError = err.message;
}
}
actions = {
confirm() {
this.confirmAction(...arguments);
},
setAmount(amount) {
this.price.amount = !isNaN(amount) ? parseInt(amount) : 0;
},
setCurrency(event) {
const newCurrency = event.value;
this.currency = newCurrency;
},
validateStripePlans() {
this.stripePlanError = undefined;
try {
const yearlyAmount = this.stripeYearlyAmount;
const monthlyAmount = this.stripeMonthlyAmount;
const symbol = getSymbol(this.currency);
if (!yearlyAmount || yearlyAmount < 1 || !monthlyAmount || monthlyAmount < 1) {
throw new TypeError(`Subscription amount must be at least ${symbol}1.00`);
}
} catch (err) {
this.stripePlanError = err.message;
}
},
// needed because ModalBase uses .send() for keyboard events
closeModal() {
this.close();
}
}
}

View file

@ -0,0 +1,13 @@
import {helper} from '@ember/component/helper';
export function ghPriceAmount(amount) {
if (amount) {
return Math.round(amount / 100);
}
return 0;
}
// like {{pluralize}} but formats the number according to current locale
export default helper(function ([amount]) {
return ghPriceAmount(amount);
});

View file

@ -19,7 +19,9 @@
text-align: center;
border: 1px solid var(--whitegrey);
border-radius: 2px;
padding: 8vmin 48px;
padding: 24px 48px;
background: white;
margin-bottom: 12px;
}
@media (max-width: 980px) {
@ -251,4 +253,4 @@
display: grid;
grid-template-columns: 1fr 2fr;
grid-gap: 20px;
}
}

View file

@ -139,58 +139,65 @@
{{#if this.fetchDefaultProduct.isRunning}}
Loading...
{{else}}
<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={{this.setStripePlansCurrency}}
{{#if (enable-developer-experiments)}}
<GhMembershipProductsAlpha
@products={{this.products}}
@confirmProductSave={{this.confirmProductSave}}
/>
{{else}}
<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={{this.setStripePlansCurrency}}
/>
</span>
</div>
</div>
<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>
<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>
<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>
<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}}
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="paid-welcome-page">
<label for="paidWelcomePage">Welcome page</label>
<GhUrlInput