mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 09:03:12 +03:00
Wired products data to Product settings screen (#1927)
refs https://github.com/TryGhost/Team/issues/627 Wires the real Products data from API to the mock Product settings screen including Product Prices list as well as opening of edit/new price modals. The new Product setting is still behind the developer experiment flag as is under active development, the changes in this commit only allows readonly data but not save/edit upstream. Co-authored-by: Fabien O'Carroll <fabien@allou.is>
This commit is contained in:
parent
9855197e07
commit
a544005c1f
@ -1,5 +1,7 @@
|
||||
<span class="gh-select">
|
||||
<OneWaySelect
|
||||
<OneWaySelect
|
||||
@value={{this.value}}
|
||||
@disabled={{this.disabled}}
|
||||
@options={{this.availablePeriods}}
|
||||
@optionValuePath="period"
|
||||
@optionLabelPath="label"
|
||||
|
@ -1,13 +1,26 @@
|
||||
import Component from '@ember/component';
|
||||
import Component from '@glimmer/component';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
const PERIODS = [
|
||||
{label: 'Monthly', period: 'monthly'},
|
||||
{label: 'Yearly', period: 'yearly'}
|
||||
{label: 'Monthly', period: 'month'},
|
||||
{label: 'Yearly', period: 'year'}
|
||||
];
|
||||
|
||||
export default Component.extend({
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
export default class GhPostsListItemComponent extends Component {
|
||||
@service feature;
|
||||
@service session;
|
||||
@service settings;
|
||||
|
||||
get value() {
|
||||
const {value} = this.args;
|
||||
return value;
|
||||
}
|
||||
get disabled() {
|
||||
const {disabled} = this.args;
|
||||
return disabled || false;
|
||||
}
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.availablePeriods = PERIODS;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<header class="modal-header" data-test-modal="webhook-form">
|
||||
<h1 data-test-text="title">New price</h1>
|
||||
<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"}}
|
||||
@ -13,7 +13,7 @@
|
||||
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="name">
|
||||
<label for="name" class="fw6">Name</label>
|
||||
<GhTextInput
|
||||
@value=''
|
||||
@value={{this.price.nickname}}
|
||||
@name="name"
|
||||
@id="name"
|
||||
@class="gh-input" />
|
||||
@ -31,18 +31,19 @@
|
||||
<div class="gh-product-priceform-period">
|
||||
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="billing-period">
|
||||
<label for="billing-period" class="fw6">Billing period</label>
|
||||
<GhProductsPriceBillingperiod @triggerId="period-input" />
|
||||
<GhProductsPriceBillingperiod @triggerId="period-input" @value={{this.price.interval}} @disabled={{this.isExistingPrice}} />
|
||||
</GhFormGroup>
|
||||
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="price">
|
||||
<label for="price" class="fw6">Price</label>
|
||||
<div class="flex items-center justify-center gh-input-group gh-labs-price-label">
|
||||
<GhTextInput
|
||||
@value=''
|
||||
@value={{this.price.amount}}
|
||||
@type="number"
|
||||
@disabled={{this.isExistingPrice}}
|
||||
{{!-- @input={{action (mut this._scratchStripeMonthlyAmount) value="target.value"}} --}}
|
||||
{{!-- @focus-out={{action "validateStripePlans"}} --}}
|
||||
/>
|
||||
<span class="gh-input-append"><span class="ttu">USD</span>/month</span>
|
||||
<span class="gh-input-append"><span class="ttu">{{capitalize (or this.price.currency 'usd')}} </span>/{{or this.price.interval 'month'}}</span>
|
||||
</div>
|
||||
<GhErrorMessage @errors={{this.price.errors}} @property="price" />
|
||||
</GhFormGroup>
|
||||
|
@ -1,3 +1,54 @@
|
||||
import ModalComponent from 'ghost-admin/components/modal-base';
|
||||
import ModalBase from 'ghost-admin/components/modal-base';
|
||||
import classic from 'ember-classic-decorator';
|
||||
import {action} from '@ember/object';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default ModalComponent.extend({});
|
||||
// TODO: update modals to work fully with Glimmer components
|
||||
@classic
|
||||
export default class ModalProductPrice extends ModalBase {
|
||||
@tracked model;
|
||||
|
||||
get title() {
|
||||
if (this.isExistingPrice) {
|
||||
return `Price - ${this.price.nickname || 'No Name'}`;
|
||||
}
|
||||
return 'New Price';
|
||||
}
|
||||
|
||||
get price() {
|
||||
return this.model.price || {};
|
||||
}
|
||||
|
||||
get isExistingPrice() {
|
||||
return !!this.model.price;
|
||||
}
|
||||
|
||||
// TODO: rename to confirm() when modals have full Glimmer support
|
||||
@action
|
||||
confirmAction() {
|
||||
this.confirm(this.role);
|
||||
this.close();
|
||||
}
|
||||
|
||||
@action
|
||||
close(event) {
|
||||
event?.preventDefault?.();
|
||||
this.closeModal();
|
||||
}
|
||||
|
||||
// @action
|
||||
// setRoleFromModel() {
|
||||
// this.role = this.model;
|
||||
// }
|
||||
|
||||
actions = {
|
||||
confirm() {
|
||||
this.confirmAction(...arguments);
|
||||
},
|
||||
|
||||
// needed because ModalBase uses .send() for keyboard events
|
||||
closeModal() {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,25 @@ export default class ProductController extends Controller {
|
||||
|
||||
@tracked showLeaveSettingsModal = false;
|
||||
@tracked showPriceModal = false;
|
||||
@tracked priceModel = null;
|
||||
|
||||
get product() {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
get stripePrices() {
|
||||
const stripePrices = this.model.stripePrices || [];
|
||||
return stripePrices.map((d) => {
|
||||
return {
|
||||
...d,
|
||||
amount: d.amount / 100
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get noOfPrices() {
|
||||
return (this.product.stripePrices || []).length;
|
||||
}
|
||||
|
||||
leaveRoute(transition) {
|
||||
if (this.settings.get('hasDirtyAttributes')) {
|
||||
@ -18,6 +37,18 @@ export default class ProductController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async openEditPrice(price) {
|
||||
this.priceModel = price;
|
||||
this.showPriceModal = true;
|
||||
}
|
||||
|
||||
@action
|
||||
async openNewPrice() {
|
||||
this.priceModel = null;
|
||||
this.showPriceModal = true;
|
||||
}
|
||||
|
||||
@action
|
||||
async confirmLeave() {
|
||||
this.settings.rollbackAttributes();
|
||||
|
@ -1,3 +1,7 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class ProductsController extends Controller {}
|
||||
export default class ProductsController extends Controller {
|
||||
get products() {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
11
ghost/admin/app/helpers/currency-symbol.js
Normal file
11
ghost/admin/app/helpers/currency-symbol.js
Normal file
@ -0,0 +1,11 @@
|
||||
import Helper from '@ember/component/helper';
|
||||
import {getSymbol} from 'ghost-admin/utils/currency';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class CurrencySymbolHelper extends Helper {
|
||||
@service feature;
|
||||
|
||||
compute([currency]) {
|
||||
return getSymbol(currency);
|
||||
}
|
||||
}
|
10
ghost/admin/app/models/product.js
Normal file
10
ghost/admin/app/models/product.js
Normal file
@ -0,0 +1,10 @@
|
||||
import Model, {attr} from '@ember-data/model';
|
||||
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
|
||||
|
||||
export default Model.extend(ValidationEngine, {
|
||||
validationType: 'product',
|
||||
|
||||
name: attr('string'),
|
||||
slug: attr('string'),
|
||||
stripePrices: attr('stripe-price')
|
||||
});
|
12
ghost/admin/app/models/stripe-price.js
Normal file
12
ghost/admin/app/models/stripe-price.js
Normal file
@ -0,0 +1,12 @@
|
||||
import EmberObject from '@ember/object';
|
||||
|
||||
export default EmberObject.extend({
|
||||
id: 'ID in Ghost',
|
||||
stripe_price_id: 'ID of the Stripe Price',
|
||||
stripe_product_id: 'ID of the Stripe Product the Stripe Price is associated with',
|
||||
nickname: 'price nickname e.g. "Monthly"',
|
||||
amount: 'amount in smallest denomination e.g. cents, so value for 5 dollars would be 500',
|
||||
currency: 'e.g. usd',
|
||||
type: 'either one_time or recurring',
|
||||
interval: 'will be `null` if type is one_time, otherwise how often price charges e.g "month", "year"'
|
||||
});
|
@ -54,7 +54,8 @@ Router.map(function () {
|
||||
this.route('settings.code-injection', {path: '/settings/code-injection'});
|
||||
|
||||
this.route('settings.products', {path: '/settings/products'});
|
||||
this.route('settings.product', {path: '/settings/product'});
|
||||
this.route('settings.product.new', {path: '/settings/product/new'});
|
||||
this.route('settings.product', {path: '/settings/product/:product_id'});
|
||||
|
||||
this.route('settings.theme', {path: '/settings/theme'}, function () {
|
||||
this.route('uploadtheme');
|
||||
|
@ -2,10 +2,14 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class ProductRoute extends AuthenticatedRoute {
|
||||
@service settings;
|
||||
@service store
|
||||
|
||||
model() {
|
||||
this.settings.reload();
|
||||
model(params) {
|
||||
if (params.product_id) {
|
||||
return this.store.queryRecord('product', {id: params.product_id, include: 'stripe_prices'});
|
||||
} else {
|
||||
return this.store.createRecord('product');
|
||||
}
|
||||
}
|
||||
|
||||
actions = {
|
||||
|
6
ghost/admin/app/routes/settings/product/new.js
Normal file
6
ghost/admin/app/routes/settings/product/new.js
Normal file
@ -0,0 +1,6 @@
|
||||
import ProductRoute from '../product';
|
||||
|
||||
export default class NewProductRoute extends ProductRoute {
|
||||
controllerName = 'settings.product';
|
||||
templateName = 'settings.product';
|
||||
}
|
@ -1,9 +1,16 @@
|
||||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class ProductsRoute extends AuthenticatedRoute {
|
||||
@service store
|
||||
|
||||
buildRouteInfoMetadata() {
|
||||
return {
|
||||
titleToken: 'Settings - Products'
|
||||
};
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.findAll('product');
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
<span>{{svg-jar "arrow-right"}}</span>
|
||||
<LinkTo @route="settings.products">Products</LinkTo>
|
||||
<span>{{svg-jar "arrow-right"}}</span>
|
||||
Product name
|
||||
{{product.name}}
|
||||
</h2>
|
||||
<section class="view-actions">
|
||||
<span class="dropdown">
|
||||
@ -45,8 +45,8 @@
|
||||
</GhFormGroup> --}}
|
||||
<div class="gh-product-details-fields">
|
||||
<GhFormGroup @property="name" @classNames="max-width">
|
||||
<label for="member-name">Product name</label>
|
||||
<GhTextInput data-test-input="member-name" />
|
||||
<label for="product-name">Product name</label>
|
||||
<GhTextInput data-test-input="product-name" @id="product-name" @value={{product.name}}/>
|
||||
<GhErrorMessage @property="name" />
|
||||
</GhFormGroup>
|
||||
|
||||
@ -76,95 +76,60 @@
|
||||
<div class="gh-main-section-block p0">
|
||||
<ol class="gh-price-list gh-list">
|
||||
<li class="gh-list-row header">
|
||||
<div class="gh-list-header">Prices(3)</div>
|
||||
<div class="gh-list-header">Prices({{this.noOfPrices}})</div>
|
||||
<div class="gh-list-header">Price</div>
|
||||
<div class="gh-list-header">Subscriptions</div>
|
||||
<div class="gh-list-header"></div>
|
||||
</li>
|
||||
<li class="gh-list-row">
|
||||
{{#each this.stripePrices as |price|}}
|
||||
<li class="gh-list-row {{if price.active "" "gh-price-list-archived"}}">
|
||||
<div class="gh-list-data gh-price-list-title">
|
||||
<h3 class="gh-price-list-name">
|
||||
Monthly
|
||||
{{price.nickname}}
|
||||
</h3>
|
||||
<p class="ma0 pa0 f8 midgrey gh-price-list-description">
|
||||
Full access
|
||||
TODO: Description
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-price">
|
||||
<span>$5 USD / month</span>
|
||||
<span>{{currency-symbol price.currency}}{{price.amount}} / {{price.interval}}</span>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-subscriptions">
|
||||
<span>91</span>
|
||||
<span>TODO</span>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-actions">
|
||||
<div class="gh-price-list-actionlist">
|
||||
<LinkTo @route="settings.product">Edit</LinkTo>
|
||||
<button class="gh-btn gh-btn-link archive"><span>Archive</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="gh-list-row">
|
||||
<div class="gh-list-data gh-price-list-title">
|
||||
<h3 class="gh-price-list-name">
|
||||
Yearly
|
||||
</h3>
|
||||
<p class="ma0 pa0 f8 midgrey gh-price-list-description">
|
||||
37% discount
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-price">
|
||||
<span>$50 USD / year</span>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-subscriptions">
|
||||
<span>76</span>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-actions">
|
||||
<div class="gh-price-list-actionlist">
|
||||
<LinkTo @route="settings.product">Edit</LinkTo>
|
||||
<button class="gh-btn gh-btn-link archive"><span>Archive</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="gh-list-row gh-price-list-archived">
|
||||
<div class="gh-list-data gh-price-list-title">
|
||||
<h3 class="gh-price-list-name">
|
||||
Complimentary <span class="gh-badge archived">Archived</span>
|
||||
</h3>
|
||||
<p class="ma0 pa0 f8 midgrey gh-price-list-description">
|
||||
Free of charge subscription
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-price">
|
||||
<span>$0 USD</span>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-subscriptions">
|
||||
<span>18</span>
|
||||
</div>
|
||||
|
||||
<div class="gh-list-data gh-price-list-actions">
|
||||
<div class="gh-price-list-actionlist">
|
||||
<button class="gh-btn gh-btn-link"><span>Activate</span></button>
|
||||
<button class="gh-btn gh-btn-link archive" {{action "openEditPrice" price}}>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
{{#if price.active}}
|
||||
<button class="gh-btn gh-btn-link archive">
|
||||
<span>Archive</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="gh-btn gh-btn-link"><span>Activate</span></button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
|
||||
<button type="button" class="gh-btn gh-btn-green" {{action (toggle "showPriceModal" this)}}>
|
||||
<button type="button" class="gh-btn gh-btn-green" {{action "openNewPrice"}}>
|
||||
<span>New price</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.showPriceModal}}
|
||||
<GhFullscreenModal @modal="product-price"
|
||||
<GhFullscreenModal
|
||||
@modal="product-price"
|
||||
@model={{hash
|
||||
price=this.priceModel
|
||||
}}
|
||||
@close={{this.closePriceModal}}
|
||||
@modifier="action wide body-scrolling product-price" />
|
||||
{{/if}}
|
||||
|
@ -6,7 +6,7 @@
|
||||
Products
|
||||
</h2>
|
||||
<section class="view-actions">
|
||||
<LinkTo @route="settings.products" class="gh-btn gh-btn-primary" data-test-new-member-button="true"><span>New product</span></LinkTo>
|
||||
<LinkTo @route="settings.product.new" class="gh-btn gh-btn-primary" data-test-new-member-button="true"><span>New product</span></LinkTo>
|
||||
</section>
|
||||
</GhCanvasHeader>
|
||||
|
||||
@ -17,23 +17,25 @@
|
||||
<div class="gh-list-header gh-list-cellwidth-70"></div>
|
||||
<div class="gh-list-header gh-list-cellwidth-30"></div>
|
||||
</li>
|
||||
{{#each this.products as |product|}}
|
||||
<li class="gh-list-row">
|
||||
<LinkTo @route="settings.product" class="gh-list-data gh-list-cellwidth-70 gh-product-list-title">
|
||||
<LinkTo @route="settings.product" @model={{product}} class="gh-list-data gh-list-cellwidth-70 gh-product-list-title">
|
||||
<h3 class="gh-product-list-name">
|
||||
Product name
|
||||
{{product.name}}
|
||||
</h3>
|
||||
<p class="ma0 pa0 f8 midgrey gh-product-list-description">
|
||||
Product description
|
||||
TODO: Product description
|
||||
</p>
|
||||
</LinkTo>
|
||||
|
||||
<LinkTo @route="settings.product" class="gh-list-data gh-list-cellwidth-30 gh-product-list-chevron">
|
||||
<LinkTo @route="settings.product" @model={{product}} class="gh-list-data gh-list-cellwidth-30 gh-product-list-chevron">
|
||||
<div class="gh-product-chevron">
|
||||
{{!-- <span>330 members</span> --}}
|
||||
<span>{{svg-jar "arrow-right" class="w6 h6 fill-midgrey pa1"}}</span>
|
||||
</div>
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
|
19
ghost/admin/app/transforms/stripe-price.js
Normal file
19
ghost/admin/app/transforms/stripe-price.js
Normal file
@ -0,0 +1,19 @@
|
||||
import StripePrice from 'ghost-admin/models/stripe-price';
|
||||
import Transform from '@ember-data/serializer/transform';
|
||||
import {A as emberA, isArray as isEmberArray} from '@ember/array';
|
||||
|
||||
export default Transform.extend({
|
||||
deserialize(serialized = []) {
|
||||
return emberA(serialized.map(StripePrice.create.bind(StripePrice)));
|
||||
},
|
||||
|
||||
serialize(deserialized) {
|
||||
if (isEmberArray(deserialized)) {
|
||||
return deserialized.map((item) => {
|
||||
return item;
|
||||
}).compact();
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue
Block a user