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:
parent
c10f8d014a
commit
6165441c30
7 changed files with 435 additions and 51 deletions
54
ghost/admin/app/components/gh-membership-products-alpha.hbs
Normal file
54
ghost/admin/app/components/gh-membership-products-alpha.hbs
Normal 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}}
|
41
ghost/admin/app/components/gh-membership-products-alpha.js
Normal file
41
ghost/admin/app/components/gh-membership-products-alpha.js
Normal 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();
|
||||
}
|
||||
}
|
106
ghost/admin/app/components/modal-product.hbs
Normal file
106
ghost/admin/app/components/modal-product.hbs
Normal 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>
|
161
ghost/admin/app/components/modal-product.js
Normal file
161
ghost/admin/app/components/modal-product.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
13
ghost/admin/app/helpers/gh-price-amount.js
Normal file
13
ghost/admin/app/helpers/gh-price-amount.js
Normal 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);
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue