Renamed products to tiers (#2372)

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

- this should allow us to remove the /products endpoint in v5

It avoids:

- `kg-product-card`, that really is meant to say product
- `product-cadence` on offers

Co-authored-by: Rishabh <zrishabhgarg@gmail.com>
This commit is contained in:
Hannah Wolfe 2022-05-11 18:11:54 +01:00 committed by GitHub
parent e852c29699
commit affe6743e5
102 changed files with 1148 additions and 1389 deletions

View File

@ -1088,3 +1088,51 @@ add|ember-template-lint|require-valid-alt-text|3|44|3|44|a7f0566c430150bae4153e0
add|ember-template-lint|require-valid-alt-text|8|20|8|20|9d0c591086dc9139ff38a7b385c3367a83438786|1652054400000|1662422400000|1665014400000|lib/koenig-editor/addon/components/koenig-card-embed/nft.hbs
add|ember-template-lint|require-input-label|10|12|10|12|8c3c0ea315ff4da828363989a45fa11256a78796|1652054400000|1662422400000|1665014400000|lib/koenig-editor/addon/components/koenig-card-image/selector-tenor.hbs
remove|ember-template-lint|require-valid-alt-text|5|4|5|4|527936d4c6b3d34855a99669f0b8ae690094bc8e|1652054400000|1662422400000|1665014400000|app/components/gh-member-avatar.hbs
add|ember-template-lint|no-action|38|107|38|107|79d2eeaed67e929e989261416abd3cd2dbbf4861|1652140800000|1662508800000|1667692800000|app/components/gh-membership-tiers-alpha.hbs
add|ember-template-lint|no-action|125|48|125|48|01f579966c325cf66d3a87799e8cea7dd1fded81|1652140800000|1662508800000|1667692800000|app/components/gh-portal-links.hbs
add|ember-template-lint|no-action|148|54|148|54|884d0df74365d794c99bb098e72dc267bd0fbc46|1652140800000|1662508800000|1667692800000|app/components/gh-portal-links.hbs
add|ember-template-lint|no-action|175|54|175|54|b51c80e0c3de828988f74017ab7e383552f48648|1652140800000|1662508800000|1667692800000|app/components/gh-portal-links.hbs
remove|ember-template-lint|no-action|125|48|125|48|c4ecbbcf9092307d8bb2cd5c9e386d2e549a9f26|1652054400000|1662422400000|1665014400000|app/components/gh-portal-links.hbs
remove|ember-template-lint|no-action|148|54|148|54|e7798f471d8eb8d396fa02de55fc9710ad783158|1652054400000|1662422400000|1665014400000|app/components/gh-portal-links.hbs
remove|ember-template-lint|no-action|175|54|175|54|0f158251c4558c601318d60d1237d5e2c3d2bd60|1652054400000|1662422400000|1665014400000|app/components/gh-portal-links.hbs
add|ember-template-lint|no-invalid-interactive|28|24|28|24|42a29ae16e22270f0590c9ce5caa7bfec541ca0b|1652140800000|1662508800000|1667692800000|app/components/modal-member-tier.hbs
add|ember-template-lint|no-action|88|77|88|77|437199817818c66e4539bbc8501bd73148b521fd|1652140800000|1662508800000|1667692800000|app/components/modal-portal-settings.hbs
remove|ember-template-lint|no-action|88|77|88|77|9e5e50275cb198c163f5da82335957b69255ac1a|1652054400000|1662422400000|1665014400000|app/components/modal-portal-settings.hbs
add|ember-template-lint|no-action|4|55|4|55|141d456b03124abca146e58e4ae15825fdd040bb|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|4|79|4|79|d465b362b15b90cf42a093e72895155f49cdf6f2|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|16|27|16|27|64b13853afb27bd49f8fc782b7d19786a502e8d1|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|26|27|26|27|8291585f81599303bc73e45cb65d725f054ae414|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|41|35|41|35|0d52869a7137b90cd482896151c974aff53287f8|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|56|35|56|35|9d61f56816a4b85a97cf9ba7af28bbcf08f29d05|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|65|34|65|34|40e7337ccf8501ef3b2fea94812d4de8435286da|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|78|71|78|71|141d456b03124abca146e58e4ae15825fdd040bb|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|80|8|80|8|d465b362b15b90cf42a093e72895155f49cdf6f2|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-down-event-binding|4|112|4|112|b3e87879e52edc06bb07f1ad0becc6ec762bbb39|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-down-event-binding|80|41|80|41|b3e87879e52edc06bb07f1ad0becc6ec762bbb39|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-passed-in-event-handlers|16|20|16|20|22bf28ec6b8ce5c52ffdb872a465b684319bb7c3|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-passed-in-event-handlers|26|20|26|20|4b2258e98403a4296a1796a6c7f2639f8bb5fb9c|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-passed-in-event-handlers|41|28|41|28|c74704c059987f5454146dd0efc9cbc9e0279b7c|1652140800000|1662508800000|1667692800000|app/components/modal-tier-price.hbs
add|ember-template-lint|no-action|21|39|21|39|c1c116347fcaf9f69d60fa7ae58d746e9994cdbd|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|36|39|36|39|7307d2c5d78fa732b959ced6823b3d82dcc07446|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|45|39|45|39|7307d2c5d78fa732b959ced6823b3d82dcc07446|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|73|52|73|52|fa36149ee581ce634903cef877ccfdc133aa67ec|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|85|47|85|47|dcc09bb23a476d5b83b273b693cd8cb2aba68365|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|95|47|95|47|a80dd18e18dda6fb6f1f97d87bef2b8c2ce3d847|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|135|47|135|47|73ac7d3892fcbcf15c3d5c44fca14dd21016daea|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|141|53|141|53|c76b92d7bdb6ed498238b647928748aa4146dc24|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|142|55|142|55|02efc45b808f4cfdbe5f7b72b784337aef4e98a7|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|143|56|143|56|4227b643fe44a6c343b819bbee7eecdfb7916ccc|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|144|57|144|57|115cc8adc4c0f98e2d93a59ff8efbee711541336|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|152|41|152|41|c76b92d7bdb6ed498238b647928748aa4146dc24|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|153|44|153|44|4227b643fe44a6c343b819bbee7eecdfb7916ccc|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|154|45|154|45|115cc8adc4c0f98e2d93a59ff8efbee711541336|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|269|71|269|71|141d456b03124abca146e58e4ae15825fdd040bb|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|271|8|271|8|d465b362b15b90cf42a093e72895155f49cdf6f2|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-down-event-binding|271|41|271|41|b3e87879e52edc06bb07f1ad0becc6ec762bbb39|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-passed-in-event-handlers|21|32|21|32|f69395e36c890a23e3f603ad3fd2cb384932af93|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-passed-in-event-handlers|36|32|36|32|86b5983929a27ca8d458ff051c95a50a406fbe57|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-passed-in-event-handlers|45|32|45|32|86b5983929a27ca8d458ff051c95a50a406fbe57|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-passed-in-event-handlers|85|40|85|40|dcb4785647a50814bcfce82f8d68ac8dd8f54ec2|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-passed-in-event-handlers|95|40|95|40|70487c008d7dda453fef82f0140699ee93c0055c|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|style-concatenation|205|54|205|54|23293f0c3838b23432d2b2daaf04b34504896d91|1652140800000|1662508800000|1667692800000|app/components/modal-tier.hbs
add|ember-template-lint|no-action|12|16|12|16|40e7337ccf8501ef3b2fea94812d4de8435286da|1652227200000|1662595200000|1667779200000|app/components/gh-tiers-price-billingperiod.hbs

View File

@ -2,7 +2,8 @@ import ApplicationAdapter from 'ghost-admin/adapters/application';
import classic from 'ember-classic-decorator';
@classic
export default class Product extends ApplicationAdapter {
export default class Tier extends ApplicationAdapter {
queryRecord(store, type, query) {
if (query && query.id) {
let {id} = query;

View File

@ -31,16 +31,16 @@ export default class DashboardMembersGraphs extends Component {
}
async loadMRRStats() {
const products = await this.store.query('product', {
const tiers = await this.store.query('tier', {
filter: 'type:paid', include: 'monthly_price,yearly_price', limit: 'all'
});
const defaultProduct = products?.firstObject;
const defaultTier = tiers?.firstObject;
this.mrrStatsLoading = true;
this.membersStats.fetchMRR().then((stats) => {
this.mrrStatsLoading = false;
const statsData = stats.data || [];
const defaultCurrency = defaultProduct?.monthlyPrice?.currency || 'usd';
const defaultCurrency = defaultTier?.monthlyPrice?.currency || 'usd';
let currencyStats = statsData.find((stat) => {
return stat.currency === defaultCurrency;
});

View File

@ -16,7 +16,7 @@
>
<GhTrimFocusInput
@shouldFocus={{this.benefitItem.last}}
@placeholder={{if this.isFreeProduct "Access to all public posts" "Expert analysis"}}
@placeholder={{if this.isFreeTier "Access to all public posts" "Expert analysis"}}
@value={{readonly this.name}}
@input={{action "updateLabel" value="target.value"}}
@keyPress={{action "clearLabelErrors"}}

View File

@ -81,17 +81,17 @@ export default class GhLaunchWizardConnectStripeComponent extends Component {
}
@task({drop: true})
*saveProduct() {
*saveTier() {
let pollTimeout = 0;
while (pollTimeout < RETRY_PRODUCT_SAVE_MAX_POLL) {
yield timeout(RETRY_PRODUCT_SAVE_POLL_LENGTH);
try {
const updatedProduct = yield this.product.save();
const updatedTier = yield this.tier.save();
yield this.settings.save();
return updatedProduct;
return updatedTier;
} catch (error) {
if (error.payload?.errors && error.payload.errors[0].code === 'STRIPE_NOT_CONFIGURED') {
pollTimeout += RETRY_PRODUCT_SAVE_POLL_LENGTH;
@ -102,7 +102,7 @@ export default class GhLaunchWizardConnectStripeComponent extends Component {
}
}
}
return this.product;
return this.tier;
}
@task({drop: true})
@ -161,12 +161,12 @@ export default class GhLaunchWizardConnectStripeComponent extends Component {
try {
yield this.settings.save();
const products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price,yearly_price'});
this.product = products.firstObject;
const tiers = yield this.store.query('tier', {filter: 'type:paid', include: 'monthly_price,yearly_price'});
this.tier = tiers.firstObject;
if (this.product) {
if (this.tier) {
const yearlyDiscount = this.calculateDiscount(5, 50);
this.product.set('monthlyPrice', {
this.tier.set('monthlyPrice', {
nickname: 'Monthly',
amount: 500,
active: 1,
@ -175,7 +175,7 @@ export default class GhLaunchWizardConnectStripeComponent extends Component {
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
this.tier.set('yearlyPrice', {
nickname: 'Yearly',
amount: 5000,
active: 1,
@ -184,7 +184,7 @@ export default class GhLaunchWizardConnectStripeComponent extends Component {
interval: 'year',
type: 'recurring'
});
yield this.saveProduct.perform();
yield this.saveTier.perform();
this.settings.set('portalPlans', ['free', 'monthly', 'yearly']);
yield this.settings.save();
}

View File

@ -15,10 +15,10 @@ export default class GhLaunchWizardFinaliseComponent extends Component {
this.settings.rollbackAttributes();
}
async saveProduct() {
async saveTier() {
const data = this.args.getData();
this.product = data?.product;
if (this.product) {
this.tier = data?.tier;
if (this.tier) {
const monthlyAmount = Math.round(data.monthlyAmount * 100);
const yearlyAmount = Math.round(data.yearlyAmount * 100);
const currency = data.currency;
@ -38,18 +38,18 @@ export default class GhLaunchWizardFinaliseComponent extends Component {
interval: 'year',
type: 'recurring'
};
this.product.set('monthlyPrice', monthlyPrice);
this.product.set('yearlyPrice', yearlyPrice);
const savedProduct = await this.product.save();
return savedProduct;
this.tier.set('monthlyPrice', monthlyPrice);
this.tier.set('yearlyPrice', yearlyPrice);
const savedTier = await this.tier.save();
return savedTier;
}
}
@task
*finaliseTask() {
const data = this.args.getData();
if (data?.product) {
yield this.saveProduct();
if (data?.tier) {
yield this.saveTier();
this.settings.set('editorIsLaunchComplete', true);
yield this.settings.save();
}

View File

@ -29,8 +29,8 @@ export default class GhLaunchWizardSetPricingComponent extends Component {
@tracked isMonthlyChecked = true;
@tracked isYearlyChecked = true;
@tracked stripePlanError = '';
@tracked product;
@tracked loadingProduct = false;
@tracked tier;
@tracked loadingTier = false;
get selectedCurrency() {
return this.currencies.findBy('value', this.currency);
@ -62,17 +62,17 @@ export default class GhLaunchWizardSetPricingComponent extends Component {
@action
setup() {
this.fetchDefaultProduct.perform();
this.fetchDefaultTier.perform();
this.updatePreviewUrl();
}
@action
backStep() {
const product = this.product;
const tier = this.tier;
const data = this.args.getData() || {};
this.args.storeData({
...data,
product,
tier,
isFreeChecked: this.isFreeChecked,
isMonthlyChecked: this.isMonthlyChecked,
isYearlyChecked: this.isYearlyChecked,
@ -136,11 +136,11 @@ export default class GhLaunchWizardSetPricingComponent extends Component {
if (this.stripePlanError) {
return false;
}
const product = this.product;
const tier = this.tier;
const data = this.args.getData() || {};
this.args.storeData({
...data,
product,
tier,
isFreeChecked: this.isFreeChecked,
isMonthlyChecked: this.isMonthlyChecked,
isYearlyChecked: this.isYearlyChecked,
@ -153,10 +153,10 @@ export default class GhLaunchWizardSetPricingComponent extends Component {
}
@task({drop: true})
*fetchDefaultProduct() {
*fetchDefaultTier() {
const storedData = this.args.getData();
if (storedData?.product) {
this.product = storedData.product;
if (storedData?.tier) {
this.tier = storedData.tier;
if (storedData.isMonthlyChecked !== undefined) {
this.isMonthlyChecked = storedData.isMonthlyChecked;
@ -173,8 +173,8 @@ export default class GhLaunchWizardSetPricingComponent extends Component {
this.stripeMonthlyAmount = storedData.monthlyAmount;
this.stripeYearlyAmount = storedData.yearlyAmount;
} else {
const products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price,yearly_price'});
this.product = products.firstObject;
const tiers = yield this.store.query('tier', {filter: 'type:paid', include: 'monthly_price,yearly_price'});
this.tier = tiers.firstObject;
let portalPlans = this.settings.get('portalPlans') || [];
@ -182,8 +182,8 @@ export default class GhLaunchWizardSetPricingComponent extends Component {
this.isYearlyChecked = portalPlans.includes('yearly');
this.isFreeChecked = portalPlans.includes('free');
const monthlyPrice = this.product.get('monthlyPrice');
const yearlyPrice = this.product.get('yearlyPrice');
const monthlyPrice = this.tier.get('monthlyPrice');
const yearlyPrice = this.tier.get('yearlyPrice');
if (monthlyPrice && monthlyPrice.amount) {
this.stripeMonthlyAmount = (monthlyPrice.amount / 100);
this.currency = monthlyPrice.currency;

View File

@ -103,9 +103,9 @@
{{#if this.isStripeConnected}}
<h4 class="gh-main-section-header small bn">Subscriptions</h4>
{{#unless this.products}}
{{#unless this.tiers}}
<div class="gh-main-section-content grey">
<div class="gh-cp-memberproduct-noproduct">
<div class="gh-cp-membertier-notier">
{{#unless this.isCreatingComplimentary}}
<div class="gh-members-no-data gh-members-no-subs">
<span class="lightgrey">{{svg-jar "no-data-subscription"}}</span>
@ -119,8 +119,8 @@
{{else}}
<button
type="button"
class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct"
{{on "click" (toggle-action "showMemberProductModal" this)}}
class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addtier"
{{on "click" (toggle-action "showMemberTierModal" this)}}
data-test-button="add-complimentary"
>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
@ -131,39 +131,39 @@
</div>
{{/unless}}
{{#each this.products as |product|}}
<div class="gh-main-section-content grey gh-member-product-container" data-test-product={{product.id}}>
<div class="gh-main-content-card gh-cp-memberproduct {{if (gt product.subscriptions.length 1) "multiple-subs" ""}}">
<h3 class="gh-memberproduct-name" data-test-text="product-name">
{{product.name}}
{{#if (gt product.subscriptions.length 1)}}
<span class="gh-memberproduct-subcount">{{product.subscriptions.length}} subscriptions</span>
{{#each this.tiers as |tier|}}
<div class="gh-main-section-content grey gh-member-tier-container" data-test-tier={{tier.id}}>
<div class="gh-main-content-card gh-cp-membertier {{if (gt tier.subscriptions.length 1) "multiple-subs" ""}}">
<h3 class="gh-membertier-name" data-test-text="tier-name">
{{tier.name}}
{{#if (gt tier.subscriptions.length 1)}}
<span class="gh-membertier-subcount">{{tier.subscriptions.length}} subscriptions</span>
{{/if}}
</h3>
{{#each product.subscriptions as |sub index|}}
<div class="gh-memberproduct-subscription" data-test-subscription={{index}}>
{{#each tier.subscriptions as |sub index|}}
<div class="gh-membertier-subscription" data-test-subscription={{index}}>
<div>
<div>
<span class="gh-cp-memberproduct-pricelabel">{{sub.price.nickname}}</span>
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span>
&ndash;
{{#if (eq sub.status "canceled")}}
<span class="gh-cp-memberproduct-renewal">Ended {{sub.validUntil}}</span>
<span class="gh-cp-membertier-renewal">Ended {{sub.validUntil}}</span>
<span class="gh-badge archived" data-test-text="member-subscription-status">Cancelled</span>
{{else if sub.cancel_at_period_end}}
<span class="gh-cp-memberproduct-renewal">Has access until {{sub.validUntil}}</span>
<span class="gh-cp-membertier-renewal">Has access until {{sub.validUntil}}</span>
<span class="gh-badge archived" data-test-text="member-subscription-status">Cancelled</span>
{{else}}
<span class="gh-cp-memberproduct-renewal">Renews {{sub.validUntil}}</span>
<span class="gh-cp-membertier-renewal">Renews {{sub.validUntil}}</span>
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
{{/if}}
</div>
{{#if sub.cancellationReason}}
<div class="gh-memberproduct-cancelreason"><span class="fw6">Cancellation reason:</span> {{sub.cancellationReason}}</div>
<div class="gh-membertier-cancelreason"><span class="fw6">Cancellation reason:</span> {{sub.cancellationReason}}</div>
{{/if}}
{{#if sub.offer}}
<div>
<span class="gh-cp-memberproduct-pricelabel"> {{sub.offer.name}} </span>
<span class="gh-cp-membertier-pricelabel"> {{sub.offer.name}} </span>
offer
{{#if (eq sub.offer.type 'fixed')}}
({{currency-symbol sub.offer.currency}}{{gh-price-amount sub.offer.amount}} off)
@ -173,13 +173,13 @@
applied to subscription
</div>
{{/if}}
<div class="gh-memberproduct-created">
<div class="gh-membertier-created">
Created on {{sub.startDate}}
</div>
</div>
<div class="gh-memberproduct-price-container">
<div class="gh-product-card-price">
<div class="gh-membertier-price-container">
<div class="gh-tier-card-price">
<div class="flex items-start">
<span class="currency-symbol">{{sub.price.currencySymbol}}</span>
<span class="amount">{{sub.price.nonDecimalAmount}}</span>
@ -203,12 +203,12 @@
<GhDropdown
@name="subscription-menu-complimentary"
@tagName="ul"
@classNames="product-actions-menu dropdown-menu dropdown-align-right"
@classNames="tier-actions-menu dropdown-menu dropdown-align-right"
>
<li>
<button
type="button"
{{on "click" (fn this.removeComplimentary (or product.id product.product_id))}}
{{on "click" (fn this.removeComplimentary (or tier.id tier.tier_id))}}
data-test-button="remove-complimentary"
>
<span class="red">Remove complimentary subscription</span>
@ -224,7 +224,7 @@
<span class="hidden">Subscription menu</span>
</span>
</GhDropdownButton>
<GhDropdown @name="subscription-menu-{{sub.id}}" @tagName="ul" @classNames="product-actions-menu dropdown-menu dropdown-align-right">
<GhDropdown @name="subscription-menu-{{sub.id}}" @tagName="ul" @classNames="tier-actions-menu dropdown-menu dropdown-align-right">
<li>
<a href="https://dashboard.stripe.com/customers/{{sub.customer.id}}" target="_blank" rel="noopener noreferrer">
View Stripe customer
@ -256,17 +256,17 @@
</div>
{{/each}}
{{#if (eq product.subscriptions.length 0)}}
<div class="gh-memberproduct-subscription">
{{#if (eq tier.subscriptions.length 0)}}
<div class="gh-membertier-subscription">
<div>
<div>
<span class="gh-cp-memberproduct-pricelabel">Complimentary</span>
<span class="gh-cp-membertier-pricelabel">Complimentary</span>
<span class="gh-badge active">Active</span>
</div>
<div class="gh-memberproduct-created">Created on</div>
<div class="gh-membertier-created">Created on</div>
</div>
<div class="flex items-center">
<div class="gh-product-card-price">
<div class="gh-tier-card-price">
<div class="flex items-start">
<span class="currency-symbol">$</span>
<span class="amount">0</span>
@ -280,9 +280,9 @@
<span class="hidden">Subscription menu</span>
</span>
</GhDropdownButton>
<GhDropdown @name="subscription-menu-complimentary" @tagName="ul" @classNames="product-actions-menu dropdown-menu dropdown-align-right">
<GhDropdown @name="subscription-menu-complimentary" @tagName="ul" @classNames="tier-actions-menu dropdown-menu dropdown-align-right">
<li>
<button type="button" {{on "click" (fn this.removeComplimentary product.id)}}>
<button type="button" {{on "click" (fn this.removeComplimentary tier.id)}}>
<span class="red">Remove complimentary subscription</span>
</button>
</li>
@ -295,15 +295,15 @@
</div>
{{/each}}
{{#if (and this.products this.isAddComplimentaryAllowed)}}
<div class="gh-memberproduct-list-footer {{if this.isCreatingComplimentary "min-height" ""}}">
{{#if (and this.tiers this.isAddComplimentaryAllowed)}}
<div class="gh-membertier-list-footer {{if this.isCreatingComplimentary "min-height" ""}}">
{{#if this.isCreatingComplimentary}}
<GhLoadingSpinner />
{{else}}
<button
type="button"
class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct"
{{on "click" (toggle-action "showMemberProductModal" this)}}
class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addtier"
{{on "click" (toggle-action "showMemberTierModal" this)}}
data-test-button="add-complimentary"
>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
@ -320,11 +320,11 @@
</div>
{{#if this.showMemberProductModal}}
<GhFullscreenModal @modifier="action wide member-product">
<ModalMemberProduct
{{#if this.showMemberTierModal}}
<GhFullscreenModal @modifier="action wide member-tier">
<ModalMemberTier
@model={{this.member}}
@confirm={{this.addProduct}}
@closeModal={{this.closeMemberProductModal}} />
@confirm={{this.addTier}}
@closeModal={{this.closeMemberTierModal}} />
</GhFullscreenModal>
{{/if}}

View File

@ -20,8 +20,8 @@ export default class extends Component {
this.scratchMember = this.args.scratchMember;
}
@tracked showMemberProductModal = false;
@tracked productsList;
@tracked showMemberTierModal = false;
@tracked tiersList;
@tracked newslettersList;
get canShowStripeInfo() {
@ -37,16 +37,16 @@ export default class extends Component {
return false;
}
if (this.member.get('products')?.length > 0) {
if (this.member.get('tiers')?.length > 0) {
return false;
}
// complimentary subscriptions are assigned to products so it only
// complimentary subscriptions are assigned to tiers so it only
// makes sense to show the "add complimentary" buttons when there's a
// product to assign the complimentary subscription to
const hasAnActivePaidProduct = !!this.productsList?.length;
// tier to assign the complimentary subscription to
const hasAnActivePaidTier = !!this.tiersList?.length;
return hasAnActivePaidProduct;
return hasAnActivePaidTier;
}
get hasSingleNewsletter() {
@ -64,15 +64,15 @@ export default class extends Component {
return this.args.isSaveRunning;
}
get products() {
get tiers() {
let subscriptions = this.member.get('subscriptions') || [];
// Create the products from `subscriptions.price.product`
let products = subscriptions
.map(subscription => (subscription.tier || subscription.price.product))
// Create the tiers from `subscriptions.price.tier`
let tiers = subscriptions
.map(subscription => (subscription.tier || subscription.price.tier))
.filter((value, index, self) => {
// Deduplicate by taking the first object by `id`
return typeof value.id !== 'undefined' && self.findIndex(element => (element.product_id || element.id) === (value.product_id || value.id)) === index;
return typeof value.id !== 'undefined' && self.findIndex(element => (element.tier_id || element.id) === (value.tier_id || value.id)) === index;
});
let subscriptionData = subscriptions.filter((sub) => {
@ -91,13 +91,13 @@ export default class extends Component {
isComplimentary: !sub.id
};
});
return products.map((product) => {
let productSubscriptions = subscriptionData.filter((subscription) => {
return subscription?.price?.product?.product_id === (product.product_id || product.id);
return tiers.map((tier) => {
let tierSubscriptions = subscriptionData.filter((subscription) => {
return subscription?.price?.tier?.tier_id === (tier.tier_id || tier.id);
});
return {
...product,
subscriptions: productSubscriptions
...tier,
subscriptions: tierSubscriptions
};
});
}
@ -131,7 +131,7 @@ export default class extends Component {
@action
setup() {
this.fetchProducts.perform();
this.fetchTiers.perform();
if (this.feature.get('multipleNewsletters')) {
this.fetchNewsletters.perform();
}
@ -153,8 +153,8 @@ export default class extends Component {
}
@action
closeMemberProductModal() {
this.showMemberProductModal = false;
closeMemberTierModal() {
this.showMemberTierModal = false;
}
@action
@ -163,8 +163,8 @@ export default class extends Component {
}
@action
removeComplimentary(productId) {
this.removeComplimentaryTask.perform(productId);
removeComplimentary(tierId) {
this.removeComplimentaryTask.perform(tierId);
}
@action
@ -187,20 +187,20 @@ export default class extends Component {
}
@task({drop: true})
*removeComplimentaryTask(productId) {
*removeComplimentaryTask(tierId) {
let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`);
let products = this.member.get('products') || [];
let tiers = this.member.get('tiers') || [];
const updatedProducts = products
.filter(product => product.id !== productId)
.map(product => ({id: product.id}));
const updatedTiers = tiers
.filter(tier => tier.id !== tierId)
.map(tier => ({id: tier.id}));
let response = yield this.ajax.put(url, {
data: {
members: [{
id: this.member.get('id'),
email: this.member.get('email'),
products: updatedProducts
tiers: updatedTiers
}]
}
});
@ -224,8 +224,8 @@ export default class extends Component {
}
@task({drop: true})
*fetchProducts() {
this.productsList = yield this.store.query('product', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price'});
*fetchTiers() {
this.tiersList = yield this.store.query('tier', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price'});
}
@task({drop: true})

View File

@ -3,9 +3,9 @@
<span class="gh-members-list-labels">{{this.labels}}</span>
</LinkTo>
{{else if (eq @filterColumn 'product')}}
{{else if (eq @filterColumn 'tier')}}
<LinkTo @route="member" @model={{@member}} class="gh-list-data wrap middarkgrey f8" data-test-table-data={{@filterColumn}}>
<span class="gh-members-list-labels">{{this.products}}</span>
<span class="gh-members-list-labels">{{this.tiers}}</span>
</LinkTo>
{{else if (eq @filterColumn 'status')}}

View File

@ -12,9 +12,9 @@ export default class GhMembersListItemColumn extends Component {
return labelData.map(label => label.name).join(', ');
}
get products() {
const productData = get(this.args.member, 'products') || [];
return productData.map(product => product.name).join(', ');
get tiers() {
const tierData = get(this.args.member, 'tiers') || [];
return tierData.map(tier => tier.name).join(', ');
}
get mostRecentSubscription() {

View File

@ -23,14 +23,14 @@
</div>
</LinkTo>
{{#if (feature "membersTableStatus")}}
{{#if this.hasMultipleProducts}}
{{#if this.hasMultipleTiers}}
<LinkTo @route="member" @model={{@member}} class="gh-list-data">
{{#if (not (is-empty @member.status))}}
<span>{{capitalize @member.status}}</span>
{{else}}
<span class="midlightgrey">-</span>
{{/if}}
<div class="midgrey">{{this.products}}</div>
<div class="midgrey">{{this.tiers}}</div>
</LinkTo>
{{else}}
<LinkTo @route="member" @model={{@member}} class="gh-list-data" data-test-table-data="status">

View File

@ -9,12 +9,12 @@ export default class GhMembersListItem extends Component {
super(...args);
}
get hasMultipleProducts() {
return this.store.peekAll('product')?.length > 1;
get hasMultipleTiers() {
return this.store.peekAll('tier')?.length > 1;
}
get products() {
const productData = get(this.args.member, 'products') || [];
return productData.map(product => product.name).join(', ');
get tiers() {
const tierData = get(this.args.member, 'tiers') || [];
return tierData.map(tier => tier.name).join(', ');
}
}

View File

@ -250,12 +250,12 @@ export default Component.extend({
});
},
saveProduct: task(function* () {
const products = yield this.store.query('product', {filter: 'type:paid', include: 'monthly_price, yearly_price'});
this.product = products.firstObject;
if (this.product) {
saveTier: task(function* () {
const tiers = yield this.store.query('tier', {filter: 'type:paid', include: 'monthly_price, yearly_price'});
this.tier = tiers.firstObject;
if (this.tier) {
const yearlyDiscount = this.calculateDiscount(5, 50);
this.product.set('monthlyPrice', {
this.tier.set('monthlyPrice', {
nickname: 'Monthly',
amount: 500,
active: 1,
@ -264,7 +264,7 @@ export default Component.extend({
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
this.tier.set('yearlyPrice', {
nickname: 'Yearly',
amount: 5000,
active: 1,
@ -275,13 +275,13 @@ export default Component.extend({
});
let pollTimeout = 0;
/** To allow Stripe config to be ready in backend, we poll the save product request */
/** To allow Stripe config to be ready in backend, we poll the save tier request */
while (pollTimeout < RETRY_PRODUCT_SAVE_MAX_POLL) {
yield timeout(RETRY_PRODUCT_SAVE_POLL_LENGTH);
try {
const updatedProduct = yield this.product.save();
return updatedProduct;
const updatedTier = yield this.tier.save();
return updatedTier;
} catch (error) {
if (error.payload?.errors && error.payload.errors[0].code === 'STRIPE_NOT_CONFIGURED') {
pollTimeout += RETRY_PRODUCT_SAVE_POLL_LENGTH;
@ -293,7 +293,7 @@ export default Component.extend({
}
}
}
return this.product;
return this.tier;
}),
saveStripeSettings: task(function* () {
@ -303,7 +303,7 @@ export default Component.extend({
try {
let response = yield this.settings.save();
yield this.saveProduct.perform();
yield this.saveTier.perform();
this.settings.set('portalPlans', ['free', 'monthly', 'yearly']);
response = yield this.settings.save();

View File

@ -151,26 +151,26 @@ export default class GhMembersRecipientSelect extends Component {
options.push(labelsGroup);
}
// fetch all products w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶
// fetch all tiers 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', {filter: 'type:paid', limit: 'all'});
const tiers = yield this.store.query('tier', {filter: 'type:paid', limit: 'all'});
if (products.length > 1) {
const productsGroup = {
if (tiers.length > 1) {
const tiersGroup = {
groupName: 'Tiers',
options: []
};
products.forEach((product) => {
productsGroup.options.push({
name: product.name,
segment: `product:${product.slug}`,
count: product.count?.members,
class: 'segment-product'
tiers.forEach((tier) => {
tiersGroup.options.push({
name: tier.name,
segment: `tier:${tier.slug}`,
count: tier.count?.members,
class: 'segment-tier'
});
});
options.push(productsGroup);
options.push(tiersGroup);
}
this.specificOptions = options;

View File

@ -74,28 +74,28 @@ export default class GhMembersSegmentSelect extends Component {
}
if (this.feature.get('multipleProducts')) {
// fetch all products w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶
// fetch all tiers 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', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'});
const tiers = yield this.store.query('tier', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'});
if (products.length > 0) {
const productsGroup = {
if (tiers.length > 0) {
const tiersGroup = {
groupName: 'Tiers',
options: []
};
products.forEach((product) => {
productsGroup.options.push({
name: product.name,
segment: `product:${product.slug}`,
count: product.count?.members,
class: 'segment-product'
tiers.forEach((tier) => {
tiersGroup.options.push({
name: tier.name,
segment: `tier:${tier.slug}`,
count: tier.count?.members,
class: 'segment-tier'
});
});
options.push(productsGroup);
if (this.args.selectDefaultProduct && !this.args.segment) {
this.args.onChange?.(productsGroup.options[0].segment);
options.push(tiersGroup);
if (this.args.selectDefaultTier && !this.args.segment) {
this.args.onChange?.(tiersGroup.options[0].segment);
}
}
}

View File

@ -18,37 +18,37 @@
</div>
</div>
</div>
<div class="gh-product-cards">
<div class="gh-tier-cards">
{{#if this.isEmptyList}}
<div class="gh-main-content-card gh-product-card-empty-state">
<div class="gh-main-content-card gh-tier-card-empty-state">
<p>You have no {{this.selectedType.value}} tiers.</p>
</div>
{{/if}}
{{#each this.products as |product|}}
<GhProductCard
@product={{product}}
@openEditProduct={{this.openEditProduct}}
{{#each this.tiers as |tier|}}
<GhTierCard
@tier={{tier}}
@openEditTier={{this.openEditTier}}
@onUnarchive={{this.onUnarchive}}
@onArchive={{this.onArchive}}
/>
{{/each}}
{{#if (eq this.type "active" )}}
<div class="gh-product-cards-footer">
<button class="gh-btn gh-btn-link gh-btn-text gh-btn-icon gh-btn-add-product green" type="button" {{action "openNewProduct" this.product}}>
<div class="gh-tier-cards-footer">
<button class="gh-btn gh-btn-link gh-btn-text gh-btn-icon gh-btn-add-tier green" type="button" {{action "openNewTier" this.tier}}>
<span>{{svg-jar "add-stroke" class="stroke-green"}}Add tier</span>
</button>
</div>
{{/if}}
</div>
{{#if this.showProductModal}}
{{#if this.showTierModal}}
<GhFullscreenModal
@modal="product"
@modal="tier"
@model={{hash
product=this.productModel
tier=this.tierModel
}}
@confirm={{this.confirmProductSave}}
@close={{this.closeProductModal}}
@modifier="edit-product action wide" />
@confirm={{this.confirmTierSave}}
@close={{this.closeTierModal}}
@modifier="edit-tier action wide" />
{{/if}}

View File

@ -18,16 +18,16 @@ export default class extends Component {
@service store;
@service config;
@tracked showProductModal = false;
@tracked productModel = null;
@tracked showTierModal = false;
@tracked tierModel = null;
@tracked type = 'active';
get products() {
return this.args.products.filter((product) => {
get tiers() {
return this.args.tiers.filter((tier) => {
if (this.type === 'active') {
return !!product.active;
return !!tier.active;
} else if (this.type === 'archived') {
return !product.active;
return !tier.active;
}
});
}
@ -43,7 +43,7 @@ export default class extends Component {
}
get isEmptyList() {
return this.products.length === 0;
return this.tiers.length === 0;
}
@action
@ -52,9 +52,9 @@ export default class extends Component {
}
@action
async openEditProduct(product) {
this.productModel = product;
this.showProductModal = true;
async openEditTier(tier) {
this.tierModel = tier;
this.showTierModal = true;
}
@action
@ -69,18 +69,18 @@ export default class extends Component {
}
@action
async openNewProduct() {
this.productModel = this.store.createRecord('product');
this.showProductModal = true;
async openNewTier() {
this.tierModel = this.store.createRecord('tier');
this.showTierModal = true;
}
@action
closeProductModal() {
this.showProductModal = false;
closeTierModal() {
this.showTierModal = false;
}
@action
confirmProductSave() {
this.args.confirmProductSave();
confirmTierSave() {
this.args.confirmTierSave();
}
}

View File

@ -1,4 +1,4 @@
<div class="gh-portal-links-container" {{did-insert (perform this.fetchProducts)}}>
<div class="gh-portal-links-container" {{did-insert (perform this.fetchTiers)}}>
<div class="gh-portal-links-main">
<h2>Links</h2>
<p>Use these {{if this.isLink "links" "data attributes"}} in your theme to show pages of Portal.</p>
@ -24,14 +24,14 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal"
type="text"
value="{{this.siteUrl}}/#/portal"
disabled="true"
aria-label="Default Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
value="data-portal"
type="text"
value="data-portal"
disabled="true"
aria-label="Default Portal data attribute">
{{/if}}
@ -55,13 +55,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signin"
type="text"
value="{{this.siteUrl}}/#/portal/signin"
disabled="true"
aria-label="Sign in Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="signin"'
disabled="true"
aria-label="Sign in Portal data attribute">
@ -82,13 +82,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signup"
type="text"
value="{{this.siteUrl}}/#/portal/signup"
disabled="true"
aria-label="Sign up Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="signup"'
disabled="true"
aria-label="Sign up Portal data attribute">
@ -103,7 +103,7 @@
</div>
</td>
</tr>
{{#if (and (feature "multipleProducts") (gt this.products.length 1))}}
{{#if (and (feature "multipleProducts") (gt this.tiers.length 1))}}
<tr>
<td colspan="2"><hr class="gh-portal-links-group-divider" /></td>
</tr>
@ -116,13 +116,13 @@
tabindex="0"
>
<OneWaySelect
@id="portal-product-link"
@name="portal[product-link]"
@options={{this.productOptions}}
@id="portal-tier-link"
@name="portal[tier-link]"
@options={{this.tierOptions}}
@optionValuePath="name"
@optionLabelPath="label"
@value={{this.selectedProduct}}
@update={{action "setSelectedProduct"}}
@value={{this.selectedTier}}
@update={{action "setSelectedTier"}}
/>
{{svg-jar "arrow-down-small"}}
</span>
@ -134,19 +134,19 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signup{{this.selectedProductIdPath}}/monthly"
type="text"
value="{{this.siteUrl}}/#/portal/signup{{this.selectedTierIdPath}}/monthly"
disabled="true"
aria-label="Monthly sign up Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
value='data-portal="signup{{this.selectedProductIdPath}}/monthly"'
type="text"
value='data-portal="signup{{this.selectedTierIdPath}}/monthly"'
disabled="true"
aria-label="Monthly sign up Portal data attribute">
{{/if}}
<button type="button" {{action (perform this.copyProductSignupLink "monthly")}} class="gh-portal-setting-copy">
{{#if (and this.copyProductSignupLink.isRunning (eq this.copiedSignupInterval "monthly"))}}
<button type="button" {{action (perform this.copyTierSignupLink "monthly")}} class="gh-portal-setting-copy">
{{#if (and this.copyTierSignupLink.isRunning (eq this.copiedSignupInterval "monthly"))}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-darkgrey"}} Copied
{{else}}
<span data-tooltip="Copy">{{svg-jar "copy" class="w4 v-mid fill-darkgrey"}}</span>
@ -161,19 +161,19 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signup{{this.selectedProductIdPath}}/yearly"
type="text"
value="{{this.siteUrl}}/#/portal/signup{{this.selectedTierIdPath}}/yearly"
disabled="true"
aria-label="Yearly sign up Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
value='data-portal="signup{{this.selectedProductIdPath}}/yearly"'
type="text"
value='data-portal="signup{{this.selectedTierIdPath}}/yearly"'
disabled="true"
aria-label="Yearly sign up Portal data attribute">
{{/if}}
<button type="button" {{action (perform this.copyProductSignupLink "yearly")}} class="gh-portal-setting-copy">
{{#if (and this.copyProductSignupLink.isRunning (eq this.copiedSignupInterval "yearly"))}}
<button type="button" {{action (perform this.copyTierSignupLink "yearly")}} class="gh-portal-setting-copy">
{{#if (and this.copyTierSignupLink.isRunning (eq this.copiedSignupInterval "yearly"))}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-darkgrey"}} Copied
{{else}}
<span data-tooltip="Copy">{{svg-jar "copy" class="w4 v-mid fill-darkgrey"}}</span>
@ -188,13 +188,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signup/free"
type="text"
value="{{this.siteUrl}}/#/portal/signup/free"
disabled="true"
aria-label="Free sign up Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="signup/free"'
disabled="true"
aria-label="Free sign up Portal data attribute">
@ -216,13 +216,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signup/monthly"
type="text"
value="{{this.siteUrl}}/#/portal/signup/monthly"
disabled="true"
aria-label="Monthly sign up Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="signup/monthly"'
disabled="true"
aria-label="Monthly sign up Portal data attribute">
@ -243,13 +243,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signup/yearly"
type="text"
value="{{this.siteUrl}}/#/portal/signup/yearly"
disabled="true"
aria-label="Yearly sign up Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="signup/yearly"'
disabled="true"
aria-label="Yearly sign up Portal data attribute">
@ -270,13 +270,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/signup/free"
type="text"
value="{{this.siteUrl}}/#/portal/signup/free"
disabled="true"
aria-label="Free sign up Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="signup/free"'
disabled="true"
aria-label="Free sign up Portal data attribute">
@ -301,13 +301,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/account"
type="text"
value="{{this.siteUrl}}/#/portal/account"
disabled="true"
aria-label="Account Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="account"'
disabled="true"
aria-label="Account Portal data attribute">
@ -328,13 +328,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/account/plans"
type="text"
value="{{this.siteUrl}}/#/portal/account/plans"
disabled="true"
aria-label="Account/Plans Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="account/plans"'
disabled="true"
aria-label="Account/Plans Portal data attribute">
@ -355,13 +355,13 @@
<div class="gh-portal-page-url-container">
{{#if this.isLink}}
<input class="gh-input page-url-field"
type="text"
value="{{this.siteUrl}}/#/portal/account/profile"
type="text"
value="{{this.siteUrl}}/#/portal/account/profile"
disabled="true"
aria-label="Account/Profile Portal link">
{{else}}
<input class="gh-input page-url-field"
type="text"
type="text"
value='data-portal="account/profile"'
disabled="true"
aria-label="Account/Profile Portal data attribute">

View File

@ -17,8 +17,8 @@ export default class GhPortalLinks extends Component {
prices = null;
copiedPrice = null;
copiedSignupInterval = null;
selectedProduct = null;
products = null;
selectedTier = null;
tiers = null;
@computed('isLink')
get toggleValue() {
@ -30,22 +30,22 @@ export default class GhPortalLinks extends Component {
return this.isLink ? 'Link' : 'Data attribute';
}
@computed('selectedProduct')
get selectedProductIdPath() {
const selectedProduct = this.selectedProduct;
if (selectedProduct) {
return `/${selectedProduct.name}`;
@computed('selectedTier')
get selectedTierIdPath() {
const selectedTier = this.selectedTier;
if (selectedTier) {
return `/${selectedTier.name}`;
}
return '';
}
@computed('products.[]')
get productOptions() {
if (this.products) {
return this.products.map((product) => {
@computed('tiers.[]')
get tierOptions() {
if (this.tiers) {
return this.tiers.map((tier) => {
return {
label: product.name,
name: product.id
label: tier.name,
name: tier.id
};
});
}
@ -63,21 +63,21 @@ export default class GhPortalLinks extends Component {
}
@action
setSelectedProduct(product) {
this.set('selectedProduct', product);
setSelectedTier(tier) {
this.set('selectedTier', tier);
}
@task(function* () {
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', {
name: products.firstObject.id,
label: products.firstObject.name
const tiers = yield this.store.query('tier', {filter: 'type:paid', include: 'monthly_price,yearly_price'}) || [];
this.set('tiers', tiers);
if (tiers.length > 0) {
this.set('selectedTier', {
name: tiers.firstObject.id,
label: tiers.firstObject.name
});
}
})
fetchProducts;
fetchTiers;
@task(function* (id) {
this.set('copiedPrice', id);
@ -97,13 +97,13 @@ export default class GhPortalLinks extends Component {
this.set('copiedSignupInterval', interval);
let data = '';
if (this.isLink) {
data = `#/portal/signup${this.selectedProductIdPath}/${interval}`;
data = `#/portal/signup${this.selectedTierIdPath}/${interval}`;
data = this.siteUrl + `/` + data;
} else {
data = `data-portal="signup${this.selectedProductIdPath}/${interval}"`;
data = `data-portal="signup${this.selectedTierIdPath}/${interval}"`;
}
copyTextToClipboard(data);
yield timeout(this.isTesting ? 50 : 3000);
})
copyProductSignupLink;
copyTierSignupLink;
}

View File

@ -9,7 +9,7 @@ export default class VisibilitySegmentSelect extends Component {
@service feature;
@tracked _options = [];
@tracked products = [];
@tracked tiers = [];
get renderInPlace() {
return this.args.renderInPlace === undefined ? false : this.args.renderInPlace;
@ -41,9 +41,9 @@ export default class VisibilitySegmentSelect extends Component {
}
get selectedOptions() {
const tierList = this.args.tiers.map((product) => {
return this.products.find((p) => {
return p.id === product.id;
const tierList = this.args.tiers.map((tier) => {
return this.tiers.find((p) => {
return p.id === tier.id;
});
}).filter(d => !!d);
const tierIdList = tierList.map(d => d.id);
@ -53,13 +53,13 @@ export default class VisibilitySegmentSelect extends Component {
@action
setSegment(options) {
let ids = options.mapBy('id').map((id) => {
let product = this.products.find((p) => {
let tier = this.tiers.find((p) => {
return p.id === id;
});
return {
id: product.id,
slug: product.slug,
name: product.name
id: tier.id,
slug: tier.slug,
name: tier.name
};
}) || [];
this.args.onChange?.(ids);
@ -70,29 +70,29 @@ export default class VisibilitySegmentSelect extends Component {
const options = yield [];
if (this.feature.get('multipleProducts')) {
// fetch all products with count
// fetch all tiers with count
// TODO: add `include: 'count.members` to query once API supports
const products = yield this.store.query('product', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'});
this.products = products;
const tiers = yield this.store.query('tier', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'});
this.tiers = tiers;
if (products.length > 0) {
const productsGroup = {
if (tiers.length > 0) {
const tiersGroup = {
groupName: 'Tiers',
options: []
};
products.forEach((product) => {
productsGroup.options.push({
name: product.name,
id: product.id,
count: product.count?.members,
class: 'segment-product'
tiers.forEach((tier) => {
tiersGroup.options.push({
name: tier.name,
id: tier.id,
count: tier.count?.members,
class: 'segment-tier'
});
});
options.push(productsGroup);
if (this.args.selectDefaultProduct && !this.args.tiers) {
this.setSegment([productsGroup.options[0]]);
options.push(tiersGroup);
if (this.args.selectDefaultTier && !this.args.tiers) {
this.setSegment([tiersGroup.options[0]]);
}
}
}

View File

@ -1,52 +0,0 @@
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;
get product() {
return this.args.product;
}
get showArchiveOption() {
return this.product.type === 'paid' && !!this.product.monthlyPrice;
}
get productCurrency() {
if (this.isFreeProduct) {
const firstPaidProduct = this.args.products.find((product) => {
return product.type === 'paid';
});
return firstPaidProduct?.monthlyPrice?.currency || 'usd';
} else {
return this.product?.monthlyPrice?.currency;
}
}
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

@ -1,66 +1,66 @@
<div class="gh-main-content-card gh-product-card" data-test-product-card={{@product.slug}}>
<div class="gh-product-card-block title-block">
<h3 class="gh-product-card-name" data-test-name>
{{@product.name}}
<div class="gh-main-content-card gh-tier-card" data-test-tier-card={{@tier.slug}}>
<div class="gh-tier-card-block title-block">
<h3 class="gh-tier-card-name" data-test-name>
{{@tier.name}}
</h3>
<p class="gh-product-card-description" data-test-description>
{{#if @product.description.length}}
{{@product.description}}
<p class="gh-tier-card-description" data-test-description>
{{#if @tier.description.length}}
{{@tier.description}}
{{else}}
No description added for this tier.
{{/if}}
</p>
</div>
<div class="gh-product-card-block benefits-block" data-test-benefits>
<h4>Benefits <span class="counter">({{or @product.benefits.length "0"}})</span></h4>
{{#if @product.benefits.length}}
<div class="gh-tier-card-block benefits-block" data-test-benefits>
<h4>Benefits <span class="counter">({{or @tier.benefits.length "0"}})</span></h4>
{{#if @tier.benefits.length}}
<ul class="benefits">
{{#each @product.benefits as |benefit|}}
{{#each @tier.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>
<p class="gh-tier-card-description">No benefits added for this tier.</p>
{{/if}}
</div>
{{#if (eq @product.type "free" )}}
<div class="gh-product-card-block">
<div class="gh-product-price-container">
<div class="gh-product-card-price" data-test-free-price>
{{#if (eq @tier.type "free" )}}
<div class="gh-tier-card-block">
<div class="gh-tier-price-container">
<div class="gh-tier-card-price" data-test-free-price>
<div class="flex items-start">
<span class="currency">{{currency-symbol this.productCurrency}}</span>
<span class="currency">{{currency-symbol this.tierCurrency}}</span>
<span class="amount">0</span>
</div>
</div>
</div>
</div>
{{/if}}
{{#if (eq @product.type "paid" )}}
<div class="gh-product-card-block">
<div class="gh-product-price-container">
<div class="gh-product-card-price" data-test-monthly-price>
{{#if (eq @tier.type "paid" )}}
<div class="gh-tier-card-block">
<div class="gh-tier-price-container">
<div class="gh-tier-card-price" data-test-monthly-price>
<div class="flex items-start">
<span class="currency">{{currency-symbol this.productCurrency}}</span>
<span class="amount">{{gh-price-amount @product.monthlyPrice.amount}}</span>
<span class="currency">{{currency-symbol this.tierCurrency}}</span>
<span class="amount">{{gh-price-amount @tier.monthlyPrice.amount}}</span>
</div>
<div class="period">Monthly</div>
</div>
<div class="gh-product-card-price" data-test-yearly-price>
<div class="gh-tier-card-price" data-test-yearly-price>
<div class="flex items-start">
<span class="currency">{{currency-symbol this.productCurrency}}</span>
<span class="amount">{{gh-price-amount @product.yearlyPrice.amount}}</span>
<span class="currency">{{currency-symbol this.tierCurrency}}</span>
<span class="amount">{{gh-price-amount @tier.yearlyPrice.amount}}</span>
</div>
<div class="period">Yearly</div>
</div>
</div>
</div>
{{/if}}
{{#if (eq @product.type "paid" )}}
<div class="gh-product-card-button-container">
{{#if (eq @tier.type "paid" )}}
<div class="gh-tier-card-button-container">
<span class="dropdown">
<GhDropdownButton
@dropdownName="tiers-actions-menu-{{@product.name}}"
@classNames="gh-btn gh-btn-action-icon gh-btn-icon gh-btn-outline gh-product-card-actions-button icon-only"
@dropdownName="tiers-actions-menu-{{@tier.name}}"
@classNames="gh-btn gh-btn-action-icon gh-btn-icon gh-btn-outline gh-tier-card-actions-button icon-only"
@title="Tiers Actions"
data-test-button="tiers-actions"
>
@ -70,19 +70,19 @@
</span>
</GhDropdownButton>
<GhDropdown
@name="tiers-actions-menu-{{@product.name}}"
@name="tiers-actions-menu-{{@tier.name}}"
@tagName="ul"
@classNames="gh-tier-actions-menu dropdown-menu dropdown-triangle-top-right"
>
<li>
<button class="mr2" type="button" {{on "click" (fn this.openEditProduct @product)}}>
<button class="mr2" type="button" {{on "click" (fn this.openEditTier @tier)}}>
<span>Edit</span>
</button>
</li>
{{#if this.showArchiveOption}}
<li>
<Settings::Members::ArchiveTier
@product={{@product}}
@tier={{@tier}}
@onUnarchive={{@onUnarchive}}
@onArchive={{@onArchive}}
/>
@ -92,8 +92,8 @@
</span>
</div>
{{else}}
<div class="gh-product-card-button-container">
<button type="button" {{on "click" (fn this.openEditProduct @product)}} class="gh-btn gh-btn-action-icon gh-btn-icon gh-btn-outline gh-product-card-edit-button icon-only" data-test-button="edit-product">
<div class="gh-tier-card-button-container">
<button type="button" {{on "click" (fn this.openEditTier @tier)}} class="gh-btn gh-btn-action-icon gh-btn-icon gh-btn-outline gh-tier-card-edit-button icon-only" data-test-button="edit-tier">
<span>
{{svg-jar "pen"}}
</span>

View File

@ -0,0 +1,52 @@
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 showTierModal = false;
get tier() {
return this.args.tier;
}
get showArchiveOption() {
return this.tier.type === 'paid' && !!this.tier.monthlyPrice;
}
get tierCurrency() {
if (this.isFreeTier) {
const firstPaidTier = this.args.tiers.find((tier) => {
return tier.type === 'paid';
});
return firstPaidTier?.monthlyPrice?.currency || 'usd';
} else {
return this.tier?.monthlyPrice?.currency;
}
}
get isPaidTier() {
return this.tier.type === 'paid';
}
get hasCurrencySymbol() {
const currencySymbol = getSymbol(this.tier?.monthlyPrice?.currency);
return currencySymbol?.length !== 3;
}
get isFreeTier() {
return this.tier.type === 'free';
}
@action
async openEditTier(tier) {
this.args.openEditTier(tier);
}
}

View File

@ -7,7 +7,7 @@ const PERIODS = [
{label: 'Yearly', period: 'year'}
];
export default class GhProductsPriceBillingPeriodComponent extends Component {
export default class GhTiersPriceBillingPeriodComponent extends Component {
@service feature;
@service session;
@service settings;

View File

@ -32,11 +32,11 @@
@allowEdit={{true}}
/>
{{else if (eq @filter.type 'product')}}
{{else if (eq @filter.type 'tier')}}
<div class="relative">
<Tiers::SegmentSelect
@onChange={{fn this.setProductsFilterValue @filter}}
@tiers={{this.productFilterValue}}
@onChange={{fn this.setTiersFilterValue @filter}}
@tiers={{this.tierFilterValue}}
@renderInPlace={{true}}
@hideOptionsWhenAllSelected={{true}}
/>

View File

@ -36,8 +36,8 @@ export default class MembersFilterValue extends Component {
this.filterValue = this.args.filter.value;
}
get productFilterValue() {
if (this.args.filter?.type === 'product') {
get tierFilterValue() {
if (this.args.filter?.type === 'tier') {
const tiers = this.args.filter?.value || [];
return tiers.map((tier) => {
return {
@ -75,7 +75,7 @@ export default class MembersFilterValue extends Component {
}
@action
setProductsFilterValue(filter, tiers) {
setTiersFilterValue(filter, tiers) {
this.args.setFilterValue(filter, tiers.map(tier => tier.slug));
}
}

View File

@ -18,7 +18,7 @@ const FILTER_PROPERTIES = [
{label: 'Created', name: 'created_at', group: 'Basic', valueType: 'date'},
// Member subscription
{label: 'Membership tier', name: 'product', group: 'Subscription', valueType: 'array', feature: 'multipleProducts'},
{label: 'Membership tier', name: 'tier', group: 'Subscription', valueType: 'array', feature: 'multipleProducts'},
{label: 'Member status', name: 'status', group: 'Subscription'},
{label: 'Billing period', name: 'subscriptions.plan_interval', group: 'Subscription'},
{label: 'Stripe subscription status', name: 'subscriptions.status', group: 'Subscription'},
@ -71,7 +71,7 @@ const FILTER_RELATIONS_OPTIONS = {
name: CONTAINS_RELATION_OPTIONS,
email: CONTAINS_RELATION_OPTIONS,
label: MATCH_RELATION_OPTIONS,
product: MATCH_RELATION_OPTIONS,
tier: MATCH_RELATION_OPTIONS,
subscribed: MATCH_RELATION_OPTIONS,
last_seen_at: DATE_RELATION_OPTIONS,
created_at: DATE_RELATION_OPTIONS,
@ -155,7 +155,7 @@ export default class MembersFilter extends Component {
get availableFilterProperties() {
let availableFilters = FILTER_PROPERTIES;
const hasMultipleProducts = this.store.peekAll('product').length > 1;
const hasMultipleTiers = this.store.peekAll('tier').length > 1;
// exclude any filters that are behind disabled feature flags
availableFilters = availableFilters.filter(prop => !prop.feature || this.feature[prop.feature]);
@ -163,7 +163,7 @@ export default class MembersFilter extends Component {
// exclude tiers filter if site has only single tier
availableFilters = availableFilters
.filter((filter) => {
return filter.name === 'product' ? hasMultipleProducts : true;
return filter.name === 'tier' ? hasMultipleTiers : true;
});
// exclude subscription filters if Stripe isn't connected
@ -190,7 +190,7 @@ export default class MembersFilter extends Component {
this.parseNqlFilter(this.args.defaultFilterParam);
}
this.fetchProducts.perform();
this.fetchTiers.perform();
}
@action
@ -436,7 +436,7 @@ export default class MembersFilter extends Component {
this.applySoftFilter();
}
if (newType !== 'product' && defaultValue) {
if (newType !== 'tier' && defaultValue) {
this.applySoftFilter();
}
}
@ -459,7 +459,7 @@ export default class MembersFilter extends Component {
if (filter.type === 'label') {
return filter.value?.length;
}
if (filter.type === 'product') {
if (filter.type === 'tier') {
return filter.value?.length;
}
return filter.value;
@ -471,7 +471,7 @@ export default class MembersFilter extends Component {
@action
applyFilter() {
const validFilters = this.filters.filter((filter) => {
if (['label', 'product'].includes(filter.type)) {
if (['label', 'tier'].includes(filter.type)) {
return filter.value?.length;
}
return filter.value;
@ -497,8 +497,8 @@ export default class MembersFilter extends Component {
}
@task({drop: true})
*fetchProducts() {
const response = yield this.store.query('product', {filter: 'type:paid'});
this.productsList = response;
*fetchTiers() {
const response = yield this.store.query('tier', {filter: 'type:paid'});
this.tiersList = response;
}
}

View File

@ -8,7 +8,7 @@
<form>
<div class="modal-body">
<div class="gh-main-section-block">
<div class="gh-main-section-content grey gh-product-priceform-block">
<div class="gh-main-section-content grey gh-tier-priceform-block">
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="name">
<label for="name" class="fw6">Portal display name</label>
<GhTextInput

View File

@ -1,4 +1,4 @@
<header class="modal-header" data-test-modal="member-product" {{did-insert this.setup}}>
<header class="modal-header" data-test-modal="member-tier" {{did-insert this.setup}}>
<h1>Add subscription</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" this.close}}>
@ -16,23 +16,23 @@
Adding a complimentary subscription cancels all existing subscriptions of this member.
</p>
{{/if}}
{{#if this.loadingProducts}}
{{#if this.loadingTiers}}
<div class="flex justify-center flex-auto">
<div class="gh-loading-spinner"> </div>
</div>
{{else}}
<div class="form-rich-radio">
{{#each this.products as |product|}}
{{#each this.tiers as |tier|}}
<div
class="gh-radio {{if (eq this.selectedProduct product.id) "active"}}"
{{on "click" (fn this.setProduct product.id)}}
data-test-tier-option={{product.id}}
class="gh-radio {{if (eq this.selectedTier tier.id) "active"}}"
{{on "click" (fn this.setTier tier.id)}}
data-test-tier-option={{tier.id}}
>
<div class="gh-radio-content">
<div class="gh-radio-label">
<div class="description" data-test-text="tier-desc">
<h4>{{product.name}}</h4>
<p>{{product.description}}</p>
<h4>{{tier.name}}</h4>
<p>{{tier.description}}</p>
</div>
{{svg-jar "check" class="check"}}
</div>
@ -61,7 +61,7 @@
<GhTaskButton @buttonText="Add subscription"
@successText={{"Added"}}
@task={{this.addProduct}}
@class="gh-btn gh-btn-green gh-btn-icon gh-btn-add-memberproduct"
data-test-button="save-comp-product" />
@task={{this.addTier}}
@class="gh-btn gh-btn-green gh-btn-icon gh-btn-add-membertier"
data-test-button="save-comp-tier" />
</div>

View File

@ -4,24 +4,24 @@ import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class ModalMemberProduct extends ModalComponent {
export default class ModalMemberTier extends ModalComponent {
@service store;
@service ghostPaths;
@service ajax;
@tracked price;
@tracked product;
@tracked products = [];
@tracked selectedProduct = null;
@tracked loadingProducts = false;
@tracked tier;
@tracked tiers = [];
@tracked selectedTier = null;
@tracked loadingTiers = false;
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price,benefits'});
*fetchTiers() {
this.tiers = yield this.store.query('tier', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price,benefits'});
this.loadingProducts = false;
if (this.products.length > 0) {
this.selectedProduct = this.products.firstObject.id;
this.loadingTiers = false;
if (this.tiers.length > 0) {
this.selectedTier = this.tiers.firstObject.id;
}
}
@ -42,13 +42,13 @@ export default class ModalMemberProduct extends ModalComponent {
@action
setup() {
this.loadingProducts = true;
this.fetchProducts.perform();
this.loadingTiers = true;
this.fetchTiers.perform();
}
@action
setProduct(productId) {
this.selectedProduct = productId;
setTier(tierId) {
this.selectedTier = tierId;
}
@action
@ -58,7 +58,7 @@ export default class ModalMemberProduct extends ModalComponent {
@action
confirmAction() {
return this.addProduct.perform();
return this.addTier.perform();
}
@action
@ -68,8 +68,8 @@ export default class ModalMemberProduct extends ModalComponent {
}
@task({drop: true})
*addProduct() {
const url = `${this.ghostPaths.url.api(`members/${this.member.get('id')}`)}?include=products`;
*addTier() {
const url = `${this.ghostPaths.url.api(`members/${this.member.get('id')}`)}?include=tiers`;
// Cancel existing active subscriptions for member
for (let i = 0; i < this.activeSubscriptions.length; i++) {
@ -87,8 +87,8 @@ export default class ModalMemberProduct extends ModalComponent {
members: [{
id: this.member.get('id'),
email: this.member.get('email'),
products: [{
id: this.selectedProduct
tiers: [{
id: this.selectedTier
}]
}]
}

View File

@ -69,26 +69,26 @@
<p>Free</p>
</label>
</div>
{{#each this.products as |product|}}
{{#each this.tiers as |tier|}}
<div class="form-group mb0 for-checkbox">
<label
class="checkbox"
for={{product.id}}
for={{tier.id}}
>
<input
type="checkbox"
id={{product.id}}
name={{product.id}}
checked={{product.checked}}
id={{tier.id}}
name={{tier.id}}
checked={{tier.checked}}
disabled={{or
(not this.membersUtils.isStripeEnabled)
(not-eq this.settings.membersSignupAccess "all")
}}
class="gh-input post-settings-featured"
{{on "click" (action "toggleProduct" product.id)}}
{{on "click" (action "toggleTier" tier.id)}}
>
<span class="input-toggle-component"></span>
<p>{{product.name}}</p>
<p>{{tier.name}}</p>
</label>
</div>
{{/each}}

View File

@ -24,7 +24,7 @@ export default ModalComponent.extend({
showLinksPage: false,
showLeaveSettingsModal: false,
isPreloading: true,
changedProducts: null,
changedTiers: null,
openSection: null,
portalPreviewGuid: 'modal-portal-settings',
@ -50,19 +50,19 @@ export default ModalComponent.extend({
return `data-portal`;
}),
portalPreviewUrl: computed('page', 'model.products.[]', 'changedProducts.[]', 'membersUtils.{isFreeChecked,isMonthlyChecked,isYearlyChecked}', 'settings.{portalName,portalButton,portalButtonIcon,portalButtonSignupText,portalButtonStyle,accentColor,portalPlans.[]}', function () {
portalPreviewUrl: computed('page', 'model.tiers.[]', 'changedTiers.[]', 'membersUtils.{isFreeChecked,isMonthlyChecked,isYearlyChecked}', 'settings.{portalName,portalButton,portalButtonIcon,portalButtonSignupText,portalButtonStyle,accentColor,portalPlans.[]}', function () {
const options = this.getProperties(['page']);
options.portalProducts = this.model.products?.filter((product) => {
return product.get('visibility') === 'public'
&& product.get('active') === true
&& product.get('type') === 'paid';
}).map((product) => {
return product.id;
options.portalTiers = this.model.tiers?.filter((tier) => {
return tier.get('visibility') === 'public'
&& tier.get('active') === true
&& tier.get('type') === 'paid';
}).map((tier) => {
return tier.id;
});
const freeProduct = this.model.products?.find((product) => {
return product.type === 'free';
const freeTier = this.model.tiers?.find((tier) => {
return tier.type === 'free';
});
options.isFreeChecked = freeProduct?.visibility === 'public';
options.isFreeChecked = freeTier?.visibility === 'public';
return this.membersUtils.getPortalPreviewUrl(options);
}),
@ -94,39 +94,39 @@ export default ModalComponent.extend({
const allowedPlans = this.settings.get('portalPlans') || [];
return (this.membersUtils.isStripeEnabled && allowedPlans.includes('yearly'));
}),
products: computed('model.products.[]', 'changedProducts.[]', 'isPreloading', function () {
const paidProducts = this.model.products?.filter(product => product.type === 'paid' && product.active === true);
if (this.isPreloading || !paidProducts?.length) {
tiers: computed('model.tiers.[]', 'changedTiers.[]', 'isPreloading', function () {
const paidTiers = this.model.tiers?.filter(tier => tier.type === 'paid' && tier.active === true);
if (this.isPreloading || !paidTiers?.length) {
return [];
}
const products = paidProducts.map((product) => {
const tiers = paidTiers.map((tier) => {
return {
id: product.id,
name: product.name,
checked: product.visibility === 'public'
id: tier.id,
name: tier.name,
checked: tier.visibility === 'public'
};
});
return products;
return tiers;
}),
showPortalTiers: computed('products', 'feature.multipleProducts', function () {
showPortalTiers: computed('tiers', 'feature.multipleProducts', function () {
if (this.feature.get('multipleProducts')) {
return true;
}
return false;
}),
showPortalPrices: computed('products', 'feature.multipleProducts', function () {
showPortalPrices: computed('tiers', 'feature.multipleProducts', function () {
if (!this.feature.get('multipleProducts')) {
return true;
}
const visibleProducts = this.model.products?.filter((product) => {
return product.visibility === 'public' && product.type === 'paid';
const visibleTiers = this.model.tiers?.filter((tier) => {
return tier.visibility === 'public' && tier.type === 'paid';
});
return !!visibleProducts?.length;
return !!visibleTiers?.length;
}),
init() {
@ -147,7 +147,7 @@ export default ModalComponent.extend({
label: 'Links'
}];
this.iconExtensions = ICON_EXTENSIONS;
this.changedProducts = [];
this.changedTiers = [];
this.set('supportAddress', this.parseEmailAddress(this.settings.get('membersSupportAddress')));
},
@ -163,8 +163,8 @@ export default ModalComponent.extend({
togglePlan(plan, event) {
this.updateAllowedPlan(plan, event.target.checked);
},
toggleProduct(productId, event) {
this.updateAllowedProduct(productId, event.target.checked);
toggleTier(tierId, event) {
this.updateAllowedTier(tierId, event.target.checked);
},
togglePortalButton(showButton) {
this.settings.set('portalButton', showButton);
@ -288,33 +288,33 @@ export default ModalComponent.extend({
updateAllowedPlan(plan, isChecked) {
const portalPlans = this.settings.get('portalPlans') || [];
const allowedPlans = [...portalPlans];
const freeProduct = this.model.products.find(p => p.type === 'free');
const freeTier = this.model.tiers.find(p => p.type === 'free');
if (!isChecked) {
this.settings.set('portalPlans', allowedPlans.filter(p => p !== plan));
if (plan === 'free') {
freeProduct.set('visibility', 'none');
freeTier.set('visibility', 'none');
}
} else {
allowedPlans.push(plan);
this.settings.set('portalPlans', allowedPlans);
if (plan === 'free') {
freeProduct.set('visibility', 'public');
freeTier.set('visibility', 'public');
}
}
},
updateAllowedProduct(productId, isChecked) {
const product = this.model.products.find(p => p.id === productId);
updateAllowedTier(tierId, isChecked) {
const tier = this.model.tiers.find(p => p.id === tierId);
if (!isChecked) {
product.set('visibility', 'none');
tier.set('visibility', 'none');
} else {
product.set('visibility', 'public');
tier.set('visibility', 'public');
}
let portalProducts = this.model.products.filter((p) => {
let portalTiers = this.model.tiers.filter((p) => {
return p.visibility === 'public';
}).map(p => p.id);
this.set('changedProducts', portalProducts);
this.set('changedTiers', portalTiers);
},
_validateSignupRedirect(url, type) {
@ -378,11 +378,11 @@ export default ModalComponent.extend({
// Save tier visibility if changed
yield Promise.all(
this.model.products.filter((product) => {
const changedAttrs = product.changedAttributes();
this.model.tiers.filter((tier) => {
const changedAttrs = tier.changedAttributes();
return !!changedAttrs.visibility;
}).map((product) => {
return product.save();
}).map((tier) => {
return tier.save();
})
);

View File

@ -8,7 +8,7 @@
<form>
<div class="modal-body">
<div class="gh-main-section-block">
<div class="gh-main-section-content grey gh-product-priceform-block">
<div class="gh-main-section-content grey gh-tier-priceform-block">
<GhFormGroup @errors={{this.errors}} @property="name">
<label for="name" class="fw6">Name</label>
<GhTextInput
@ -29,7 +29,7 @@
@class="gh-input" />
<GhErrorMessage @errors={{this.errors}} @property="description" />
</GhFormGroup>
<div class="gh-product-priceform-pricecurrency">
<div class="gh-tier-priceform-pricecurrency">
<GhFormGroup @errors={{this.errors}} @property="amount">
<label for="amount" class="fw6">Price</label>
<div class="flex items-center justify-center gh-labs-price-label">
@ -61,7 +61,7 @@
</div>
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="billing-period">
<label for="billing-period" class="fw6">Billing period</label>
<GhProductsPriceBillingperiod
<GhTiersPriceBillingperiod
@updatePeriod={{action "updatePeriod"}}
@triggerId="period-input"
@value={{this.price.interval}} @disabled={{this.isExistingPrice}}

View File

@ -8,7 +8,7 @@ import {tracked} from '@glimmer/tracking';
// TODO: update modals to work fully with Glimmer components
@classic
export default class ModalProductPrice extends ModalBase {
export default class ModalTierPrice extends ModalBase {
@tracked model;
@tracked price;
@tracked currencyVal;

View File

@ -2,57 +2,57 @@
{{svg-jar "close"}}
</button>
<div class="gh-product-modal-content" data-test-modal="edit-product">
<div class="gh-tier-modal-content" data-test-modal="edit-tier">
<header class="modal-header">
<h1 data-test-text="title">{{this.title}}</h1>
</header>
<form>
<div class="modal-body gh-form-edit-product">
<div class="modal-body gh-form-edit-tier">
<div class="gh-main-section columns-3">
<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">
{{#unless this.isFreeProduct}}
<div class="gh-main-section-content grey gh-tier-priceform-block">
{{#unless this.isFreeTier}}
<GhFormGroup @errors={{this.errors}} @property="name" data-test-formgroup="name">
<label for="name" class="fw6">Name</label>
<GhTextInput
@value={{readonly this.product.name}}
@input={{action (mut this.product.name) value="target.value"}}
@value={{readonly this.tier.name}}
@input={{action (mut this.tier.name) value="target.value"}}
@name="name"
@placeholder="Bronze"
@id="name"
@class="gh-input"
data-test-input="product-name" />
data-test-input="tier-name" />
<GhErrorMessage @errors={{this.errors}} @property="name" />
</GhFormGroup>
{{/unless}}
<GhFormGroup @errors={{this.errors}} @property="description" data-test-formgroup="description">
<label for="description" class="fw6">Description</label>
{{#if this.isFreeProduct}}
{{#if this.isFreeTier}}
<GhTextInput
@value={{readonly this.product.description}}
@input={{action (mut this.product.description) value="target.value"}}
@value={{readonly this.tier.description}}
@input={{action (mut this.tier.description) value="target.value"}}
@name="description"
@placeholder="Free preview of {{this.settings.title}}"
@id="description"
@class="gh-input"
data-test-input="free-product-description" />
data-test-input="free-tier-description" />
{{else}}
<GhTextInput
@value={{readonly this.product.description}}
@input={{action (mut this.product.description) value="target.value"}}
@value={{readonly this.tier.description}}
@input={{action (mut this.tier.description) value="target.value"}}
@name="description"
@placeholder="Full access to premium content"
@id="description"
@class="gh-input"
data-test-input="product-description" />
data-test-input="tier-description" />
{{/if}}
<GhErrorMessage @errors={{this.errors}} @property="description" />
</GhFormGroup>
{{#unless this.isFreeProduct}}
{{#unless this.isFreeTier}}
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="prices" data-test-formgroup="prices">
<div class="gh-settings-members-pricelabelcont">
<label for="monthlyPrice">Prices</label>
@ -109,13 +109,13 @@
<label for="welcomePage" class="fw6">Welcome page</label>
<GhUrlInput
@id="welcomePage"
@value={{readonly this.model.product.welcomePageURL}}
@value={{readonly this.model.tier.welcomePageURL}}
@baseUrl={{readonly this.siteUrl}}
@setResult={{this.setWelcomePageURL}}
@validateUrl={{this.validateWelcomePageURL}}
@placeholder={{readonly this.siteUrl}}
/>
{{#if this.isFreeProduct}}
{{#if this.isFreeTier}}
<p>Redirect to this URL after signup for a free membership</p>
{{else}}
<p>Redirect to this URL after signup for premium membership</p>
@ -126,8 +126,8 @@
</div>
<h4 class="gh-main-section-header small bn">Benefits</h4>
<div class="gh-main-section-content grey gh-product-form-benefits">
<div class="gh-product-benefits">
<div class="gh-main-section-content grey gh-tier-form-benefits">
<div class="gh-tier-benefits">
<div class="gh-blognav">
<SortableObjects
@sortableObjectList={{this.benefits}}
@ -147,7 +147,7 @@
{{/each}}
</SortableObjects>
<GhBenefitItem
@isFreeProduct={{this.isFreeProduct}}
@isFreeTier={{this.isFreeTier}}
@benefitItem={{this.newBenefit}}
@addItem={{action "addBenefit"}}
@deleteItem={{action "deleteBenefit"}}
@ -157,12 +157,12 @@
</div>
</div>
</div>
<div class="gh-main-section-block gh-product-form-tierpreview" data-test-tierpreview>
<div class="gh-product-form-tierpreview-content">
{{#if this.isFreeProduct}}
<div class="gh-main-section-block gh-tier-form-tierpreview" data-test-tierpreview>
<div class="gh-tier-form-tierpreview-content">
{{#if this.isFreeTier}}
<h4 class="gh-main-section-header small bn" data-test-tierpreview-title>Free Membership Preview</h4>
{{else}}
<div class="gh-product-form-tierpreivew-cadence">
<div class="gh-tier-form-tierpreivew-cadence">
<h4 class="gh-main-section-header small bn" data-test-tierpreview-title>Tier Preview</h4>
<div>
<button class="gh-btn {{if (eq this.previewCadence "monthly") "selected"}}" type="button" {{on "click" (fn this.changeCadence "monthly")}}><span>Monthly</span></button>
@ -171,22 +171,22 @@
</div>
{{/if}}
<div class="gh-main-section-content">
<div class="gh-portal-product-card-header">
{{#if this.product.name}}
<h4 class="gh-portal-product-name" data-test-tierpreview-name style={{this.accentColorStyle}}>{{this.product.name}}</h4>
<div class="gh-portal-tier-card-header">
{{#if this.tier.name}}
<h4 class="gh-portal-tier-name" data-test-tierpreview-name style={{this.accentColorStyle}}>{{this.tier.name}}</h4>
{{else}}
<h4 class="placeholder gh-portal-product-name" style={{this.accentColorStyle}} data-test-tierpreview-name>Bronze</h4>
<h4 class="placeholder gh-portal-tier-name" style={{this.accentColorStyle}} data-test-tierpreview-name>Bronze</h4>
{{/if}}
<div class="gh-portal-product-card-pricecontainer" data-test-tierpreview-price>
{{#if this.isFreeProduct}}
<div class="gh-portal-product-price">
<div class="gh-portal-tier-card-pricecontainer" data-test-tierpreview-price>
{{#if this.isFreeTier}}
<div class="gh-portal-tier-price">
<span class="currency-sign">{{currency-symbol this.currency}}</span>
<span class="amount">0</span>
</div>
{{else}}
{{#if this.stripeYearlyAmount}}
<div class="gh-portal-product-price">
<div class="gh-portal-tier-price">
<span class="currency-sign">{{currency-symbol this.currency}}</span>
{{#if (eq this.previewCadence "monthly")}}
<span class="amount">
@ -206,7 +206,7 @@
{{this.discountValue}}% discount</span>
{{/if}}
{{else}}
<div class="gh-portal-product-price placeholder">
<div class="gh-portal-tier-price placeholder">
<span class="currency-sign">{{currency-symbol this.currency}}</span>
<span class="amount">0</span>
<span class="billing-period">/year</span>
@ -217,36 +217,36 @@
</div>
</div>
<div class="gh-portal-product-card-details">
<div class="gh-portal-product-card-detaildata">
{{#if this.product.description}}
<div class="gh-portal-product-description" data-test-tierpreview-description>{{this.product.description}}</div>
<div class="gh-portal-tier-card-details">
<div class="gh-portal-tier-card-detaildata">
{{#if this.tier.description}}
<div class="gh-portal-tier-description" data-test-tierpreview-description>{{this.tier.description}}</div>
{{else}}
{{#if this.isFreeProduct}}
<div class="placeholder gh-portal-product-description" data-test-tierpreview-description>Free preview of {{this.settings.title}}</div>
{{#if this.isFreeTier}}
<div class="placeholder gh-portal-tier-description" data-test-tierpreview-description>Free preview of {{this.settings.title}}</div>
{{else}}
<div class="placeholder gh-portal-product-description" data-test-tierpreview-description>Full access to premium content</div>
<div class="placeholder gh-portal-tier-description" data-test-tierpreview-description>Full access to premium content</div>
{{/if}}
{{/if}}
{{#if this.benefits}}
<div class="gh-portal-product-benefits" data-test-tierpreview-benefits>
<div class="gh-portal-tier-benefits" data-test-tierpreview-benefits>
{{#each this.benefits as |benefitItem|}}
<div class="gh-portal-product-benefit">{{svg-jar "check-2"}}
<div class="gh-portal-tier-benefit">{{svg-jar "check-2"}}
<div class="gh-portal-benefit-title">{{benefitItem.name}}</div>
</div>
{{/each}}
</div>
{{else}}
<div class="placeholder gh-portal-product-benefits" data-test-tierpreview-benefits>
{{#if this.isFreeProduct}}
<div class="gh-portal-product-benefit">{{svg-jar "check-2"}}
<div class="placeholder gh-portal-tier-benefits" data-test-tierpreview-benefits>
{{#if this.isFreeTier}}
<div class="gh-portal-tier-benefit">{{svg-jar "check-2"}}
<div class="gh-portal-benefit-title">
Access to all public posts
</div>
</div>
{{else}}
<div class="gh-portal-product-benefit">
<div class="gh-portal-tier-benefit">
{{svg-jar "check-2"}}
<div class="gh-portal-benefit-title">Expert analysis</div>
</div>
@ -272,10 +272,10 @@
>
<span>Cancel</span>
</button>
<GhTaskButton @buttonText="{{if this.isExistingProduct "Save" "Add tier"}}"
<GhTaskButton @buttonText="{{if this.isExistingTier "Save" "Add tier"}}"
@successText={{this.successText}}
@task={{this.saveProduct}}
@task={{this.saveTier}}
@idleClass="gh-btn-primary"
@class="gh-btn {{if this.isExistingProduct "gh-btn-black" "gh-btn-green"}} gh-btn-icon"
data-test-button="save-product" />
@class="gh-btn {{if this.isExistingTier "gh-btn-black" "gh-btn-green"}} gh-btn-icon"
data-test-button="save-tier" />
</div>

View File

@ -1,6 +1,6 @@
import EmberObject, {action} from '@ember/object';
import ModalBase from 'ghost-admin/components/modal-base';
import ProductBenefitItem from '../models/product-benefit-item';
import TierBenefitItem from '../models/tier-benefit-item';
import classic from 'ember-classic-decorator';
import {currencies, getCurrencyOptions, getSymbol} from 'ghost-admin/utils/currency';
import {A as emberA} from '@ember/array';
@ -20,11 +20,11 @@ const CURRENCIES = currencies.map((currency) => {
// TODO: update modals to work fully with Glimmer components
@classic
export default class ModalProductPrice extends ModalBase {
export default class ModalTierPrice extends ModalBase {
@service settings;
@service config;
@tracked model;
@tracked product;
@tracked tier;
@tracked periodVal;
@tracked stripeMonthlyAmount = 5;
@tracked stripeYearlyAmount = 50;
@ -41,22 +41,22 @@ export default class ModalProductPrice extends ModalBase {
confirm() {}
get isFreeProduct() {
return this.product.type === 'free';
get isFreeTier() {
return this.tier.type === 'free';
}
get allCurrencies() {
return getCurrencyOptions();
}
get productCurrency() {
if (this.isFreeProduct) {
const firstPaidProduct = this.model.products?.find((product) => {
return product.type === 'paid';
get tierCurrency() {
if (this.isFreeTier) {
const firstPaidTier = this.model.tiers?.find((tier) => {
return tier.type === 'paid';
});
return firstPaidProduct?.monthlyPrice?.currency || 'usd';
return firstPaidTier?.monthlyPrice?.currency || 'usd';
} else {
return this.product?.monthlyPrice?.currency;
return this.tier?.monthlyPrice?.currency;
}
}
@ -66,18 +66,18 @@ export default class ModalProductPrice extends ModalBase {
init() {
super.init(...arguments);
this.product = this.model.product;
const monthlyPrice = this.product.get('monthlyPrice');
const yearlyPrice = this.product.get('yearlyPrice');
this.tier = this.model.tier;
const monthlyPrice = this.tier.get('monthlyPrice');
const yearlyPrice = this.tier.get('yearlyPrice');
if (monthlyPrice) {
this.stripeMonthlyAmount = (monthlyPrice.amount / 100);
}
if (yearlyPrice) {
this.stripeYearlyAmount = (yearlyPrice.amount / 100);
}
this.currency = this.productCurrency || 'usd';
this.benefits = this.product.get('benefits') || emberA([]);
this.newBenefit = ProductBenefitItem.create({
this.currency = this.tierCurrency || 'usd';
this.benefits = this.tier.get('benefits') || emberA([]);
this.newBenefit = TierBenefitItem.create({
isNew: true,
name: ''
});
@ -97,9 +97,9 @@ export default class ModalProductPrice extends ModalBase {
if (this.welcomePageURL.href.startsWith(siteUrl)) {
const path = this.welcomePageURL.href.replace(siteUrl, '');
this.model.product.welcomePageURL = path;
this.model.tier.welcomePageURL = path;
} else {
this.model.product.welcomePageURL = this.welcomePageURL.href;
this.model.tier.welcomePageURL = this.welcomePageURL.href;
}
}
@ -109,12 +109,12 @@ export default class ModalProductPrice extends ModalBase {
// eslint-disable-next-line no-dupe-class-members
get welcomePageURL() {
return this.model.product.welcomePageURL;
return this.model.tier.welcomePageURL;
}
get title() {
if (this.isExistingProduct) {
if (this.isFreeProduct) {
if (this.isExistingTier) {
if (this.isFreeTier) {
return `Edit free membership`;
}
return `Edit tier`;
@ -122,8 +122,8 @@ export default class ModalProductPrice extends ModalBase {
return 'New tier';
}
get isExistingProduct() {
return !this.model.product.isNew;
get isExistingTier() {
return !this.model.tier.isNew;
}
@action
@ -143,13 +143,13 @@ export default class ModalProductPrice extends ModalBase {
}
reset() {
this.newBenefit = ProductBenefitItem.create({isNew: true, name: ''});
const savedBenefits = this.product.benefits?.filter(benefit => !!benefit.id) || emberA([]);
this.product.set('benefits', savedBenefits);
this.newBenefit = TierBenefitItem.create({isNew: true, name: ''});
const savedBenefits = this.tier.benefits?.filter(benefit => !!benefit.id) || emberA([]);
this.tier.set('benefits', savedBenefits);
}
@task({drop: true})
*saveProduct() {
*saveTier() {
this.validatePrices();
if (!isEmpty(this.errors) && Object.keys(this.errors).length > 0) {
return;
@ -162,10 +162,10 @@ export default class ModalProductPrice extends ModalBase {
yield this.send('addBenefit', this.newBenefit);
}
if (!this.isFreeProduct) {
if (!this.isFreeTier) {
const monthlyAmount = Math.round(this.stripeMonthlyAmount * 100);
const yearlyAmount = Math.round(this.stripeYearlyAmount * 100);
this.product.set('monthlyPrice', {
this.tier.set('monthlyPrice', {
nickname: 'Monthly',
amount: monthlyAmount,
active: true,
@ -173,7 +173,7 @@ export default class ModalProductPrice extends ModalBase {
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
this.tier.set('yearlyPrice', {
nickname: 'Yearly',
amount: yearlyAmount,
active: true,
@ -182,8 +182,8 @@ export default class ModalProductPrice extends ModalBase {
type: 'recurring'
});
}
this.product.set('benefits', this.benefits.filter(benefit => !benefit.get('isBlank')));
yield this.product.save();
this.tier.set('benefits', this.benefits.filter(benefit => !benefit.get('isBlank')));
yield this.tier.save();
yield this.confirm();
this.send('closeModal');
@ -208,7 +208,7 @@ export default class ModalProductPrice extends ModalBase {
item.set('isNew', false);
this.benefits.pushObject(item);
this.newBenefit = ProductBenefitItem.create({isNew: true, name: ''});
this.newBenefit = TierBenefitItem.create({isNew: true, name: ''});
}
calculateDiscount() {
@ -254,7 +254,7 @@ export default class ModalProductPrice extends ModalBase {
this.benefits.removeObject(item);
},
reorderItems() {
this.product.set('benefits', this.benefits);
this.tier.set('benefits', this.benefits);
},
updateLabel(label, benefitItem) {
if (!benefitItem) {

View File

@ -7,7 +7,7 @@
<div class="modal-body">
<p>
Members will no longer be able to subscribe to <strong>{{@data.product.name}}</strong> and it will be removed from the list of available tiers in portal.
Members will no longer be able to subscribe to <strong>{{@data.tier.name}}</strong> and it will be removed from the list of available tiers in portal.
</p>
<p>
Existing members on this tier will remain unchanged.

View File

@ -7,20 +7,20 @@ export default class ArchiveTierModal extends Component {
@service router;
get isActive() {
const {product} = this.args.data;
return !!product.active;
const {tier} = this.args.data;
return !!tier.active;
}
@task({drop: true})
*archiveTierTask() {
const {product, onArchive} = this.args.data;
product.active = false;
product.visibility = 'none';
const {tier, onArchive} = this.args.data;
tier.active = false;
tier.visibility = 'none';
try {
yield product.save();
yield tier.save();
onArchive?.();
return product;
return tier;
} catch (error) {
if (error) {
this.notifications.showAPIError(error, {key: 'tier.archive.failed'});

View File

@ -7,10 +7,10 @@
<div class="modal-body">
<p>
Reactivating <strong>{{@data.product.name}}</strong> will re-enable it as an option in portal and allow new members to subscribe to this tier.
Reactivating <strong>{{@data.tier.name}}</strong> will re-enable it as an option in portal and allow new members to subscribe to this tier.
</p>
<p>
Existing members will remain unchanged.
Existing members will remain unchanged.
</p>
</div>

View File

@ -7,19 +7,19 @@ export default class UnarchiveTierModal extends Component {
@service router;
get isActive() {
const {product} = this.args.data;
return !!product.active;
const {tier} = this.args.data;
return !!tier.active;
}
@task({drop: true})
*unarchiveTask() {
const {product, onUnarchive} = this.args.data;
product.active = true;
const {tier, onUnarchive} = this.args.data;
tier.active = true;
try {
yield product.save();
yield tier.save();
onUnarchive?.();
return product;
return tier;
} catch (error) {
if (error) {
this.notifications.showAPIError(error, {key: 'tier.unarchive.failed'});

View File

@ -26,7 +26,7 @@
{{#if this.hasVisibilityFilter}}
<div class="mt2" data-test-default-post-access-tiers>
<GhPostSettingsMenu::VisibilitySegmentSelect
@selectDefaultProduct={{true}}
@selectDefaultTier={{true}}
@tiers={{this.visibilityTiers}}
@onChange={{action "setVisibility"}}
@renderInPlace={{true}}

View File

@ -62,11 +62,11 @@ export default class SettingsMembersDefaultPostAccess extends Component {
@action
setVisibility(segment) {
if (segment) {
const productIds = segment?.map((product) => {
return product.id;
const tierIds = segment?.map((tier) => {
return tier.id;
});
this.settings.set('defaultContentVisibility', 'tiers');
this.settings.set('defaultContentVisibilityTiers', productIds);
this.settings.set('defaultContentVisibilityTiers', tierIds);
this.showSegmentError = false;
} else {
this.settings.set('defaultContentVisibility', '');

View File

@ -1,5 +1,5 @@
{{#if this.product.active}}
{{#unless this.product.isNew}}
{{#if this.tier.active}}
{{#unless this.tier.isNew}}
<button
type="button"
{{on "click" this.handleArchiveTier}}

View File

@ -8,19 +8,19 @@ export default class ArchiveTierComponent extends Component {
@service modals;
get isActive() {
const {product} = this.args;
return !!product.active;
const {tier} = this.args;
return !!tier.active;
}
get product() {
return this.args.product;
get tier() {
return this.args.tier;
}
@action
handleArchiveTier() {
if (!this.product.isNew) {
if (!this.tier.isNew) {
this.modals.open('modals/tiers/archive', {
product: this.product,
tier: this.tier,
onArchive: this.args.onArchive
}, {
className: 'fullscreen-modal fullscreen-modal-action fullscreen-modal-wide'
@ -30,9 +30,9 @@ export default class ArchiveTierComponent extends Component {
@action
handleUnarchiveTier() {
if (!this.product.isNew) {
if (!this.tier.isNew) {
this.modals.open('modals/tiers/unarchive', {
product: this.product,
tier: this.tier,
onUnarchive: this.args.onUnarchive
}, {
className: 'fullscreen-modal fullscreen-modal-action fullscreen-modal-wide'

View File

@ -90,7 +90,7 @@
<LinkTo @route="settings.newsletters.edit-newsletter" @model={{newsletter.id}} class="gh-btn gh-btn-green" data-test-button="customize-newsletter"><span>Customize &rarr;</span></LinkTo>
{{else}}
<GhBasicDropdown @verticalPosition="below" @horizontalPosition="right" @renderInPlace={{true}} as |dd|>
<dd.Trigger class="gh-btn gh-btn-action-icon gh-btn-icon gh-btn-outline gh-product-card-actions-button icon-only">
<dd.Trigger class="gh-btn gh-btn-action-icon gh-btn-icon gh-btn-outline gh-tier-card-actions-button icon-only">
<span data-test-newsletter-menu-trigger>
{{svg-jar "dotdotdot"}}
<span class="hidden">Actions</span>

View File

@ -9,7 +9,7 @@ export default class TiersSegmentSelect extends Component {
@service feature;
@tracked _options = [];
@tracked products = [];
@tracked tiers = [];
get renderInPlace() {
return this.args.renderInPlace === undefined ? false : this.args.renderInPlace;
@ -41,9 +41,9 @@ export default class TiersSegmentSelect extends Component {
}
get selectedOptions() {
const tierList = (this.args.tiers || []).map((product) => {
return this.products.find((p) => {
return p.id === product.id || p.slug === product.slug;
const tierList = (this.args.tiers || []).map((tier) => {
return this.tiers.find((p) => {
return p.id === tier.id || p.slug === tier.slug;
});
}).filter(d => !!d);
const tierIdList = tierList.map(d => d.id);
@ -53,13 +53,13 @@ export default class TiersSegmentSelect extends Component {
@action
setSegment(options) {
let ids = options.mapBy('id').map((id) => {
let product = this.products.find((p) => {
let tier = this.tiers.find((p) => {
return p.id === id;
});
return {
id: product.id,
slug: product.slug,
name: product.name
id: tier.id,
slug: tier.slug,
name: tier.name
};
}) || [];
this.args.onChange?.(ids);
@ -70,29 +70,29 @@ export default class TiersSegmentSelect extends Component {
const options = yield [];
if (this.feature.get('multipleProducts')) {
// fetch all products with count
// fetch all tiers with count
// TODO: add `include: 'count.members` to query once API supports
const products = yield this.store.query('product', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'});
this.products = products;
const tiers = yield this.store.query('tier', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'});
this.tiers = tiers;
if (products.length > 0) {
const productsGroup = {
if (tiers.length > 0) {
const tiersGroup = {
groupName: 'Tiers',
options: []
};
products.forEach((product) => {
productsGroup.options.push({
name: product.name,
id: product.id,
count: product.count?.members,
class: 'segment-product'
tiers.forEach((tier) => {
tiersGroup.options.push({
name: tier.name,
id: tier.id,
count: tier.count?.members,
class: 'segment-tier'
});
});
options.push(productsGroup);
if (this.args.selectDefaultProduct && !this.args.tiers) {
this.setSegment([productsGroup.options[0]]);
options.push(tiersGroup);
if (this.args.selectDefaultTier && !this.args.tiers) {
this.setSegment([tiersGroup.options[0]]);
}
}
}

View File

@ -194,7 +194,7 @@ export default class MemberController extends Controller {
this.member = yield this.store.queryRecord('member', {
id: memberId,
include: 'products'
include: 'tiers'
});
this.isLoading = false;

View File

@ -163,7 +163,7 @@ export default class MembersController extends Controller {
get filterColumns() {
const defaultColumns = ['name', 'email', 'email_open_rate', 'created_at'];
if (this.feature.get('membersTableStatus')) {
defaultColumns.push('status', 'product');
defaultColumns.push('status', 'tier');
}
const availableFilters = this.filters.length ? this.filters : this.softFilters;
return availableFilters.map((filter) => {
@ -180,7 +180,7 @@ export default class MembersController extends Controller {
'subscriptions.status': 'Subscription Status',
'subscriptions.start_date': 'Paid start date',
'subscriptions.current_period_end': 'Next billing date',
product: 'Membership tier'
tier: 'Membership tier'
};
return this.filterColumns.map((d) => {
return {
@ -190,10 +190,10 @@ export default class MembersController extends Controller {
});
}
includeProductQuery() {
includeTierQuery() {
const availableFilters = this.filters.length ? this.filters : this.softFilters;
return availableFilters.some((f) => {
return f.type === 'product';
return f.type === 'tier';
});
}
@ -408,7 +408,7 @@ export default class MembersController extends Controller {
extraFilters: [`created_at:<='${moment.utc(this._startDate).format('YYYY-MM-DD HH:mm:ss')}'`]
});
const order = orderParam ? `${orderParam} desc` : `created_at desc`;
const includes = ['labels', 'products'];
const includes = ['labels', 'tiers'];
query = Object.assign({
include: includes.join(','),

View File

@ -23,7 +23,7 @@ export default class OffersController extends Controller {
@service notifications;
@tracked cadences = [];
@tracked products = [];
@tracked tiers = [];
@tracked portalPreviewUrl = '';
@tracked showUnsavedChangesModal = false;
@ -84,11 +84,11 @@ export default class OffersController extends Controller {
get cadence() {
if (this.offer.tier && this.offer.cadence) {
const product = this.products.findBy('id', this.offer.tier.id);
return `${this.offer.tier.id}-${this.offer.cadence}-${product?.monthlyPrice?.currency}`;
const tier = this.tiers.findBy('id', this.offer.tier.id);
return `${this.offer.tier.id}-${this.offer.cadence}-${tier?.monthlyPrice?.currency}`;
} else if (this.defaultProps) {
const product = this.products.findBy('id', this.defaultProps.tier.id);
return `${this.defaultProps.tier.id}-${this.defaultProps.cadence}-${product?.monthlyPrice?.currency}`;
const tier = this.tiers.findBy('id', this.defaultProps.tier.id);
return `${this.defaultProps.tier.id}-${this.defaultProps.cadence}-${tier?.monthlyPrice?.currency}`;
}
return '';
}
@ -100,33 +100,33 @@ export default class OffersController extends Controller {
// Tasks -------------------------------------------------------------------
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price'});
this.products = this.products.filter((d) => {
*fetchTiers() {
this.tiers = yield this.store.query('tier', {filter: 'type:paid+active:true', include: 'monthly_price,yearly_price'});
this.tiers = this.tiers.filter((d) => {
return d.monthlyPrice && d.yearlyPrice;
});
const cadences = [];
this.products.forEach((product) => {
this.tiers.forEach((tier) => {
let monthlyLabel;
let yearlyLabel;
const productCurrency = product.monthlyPrice.currency;
const productCurrencySymbol = productCurrency.toUpperCase();
const tierCurrency = tier.monthlyPrice.currency;
const tierCurrencySymbol = tierCurrency.toUpperCase();
if (this.feature.get('multipleProducts')) {
monthlyLabel = `${product.name} - Monthly (${ghPriceAmount(product.monthlyPrice.amount)} ${productCurrencySymbol})`;
yearlyLabel = `${product.name} - Yearly (${ghPriceAmount(product.yearlyPrice.amount)} ${productCurrencySymbol})`;
monthlyLabel = `${tier.name} - Monthly (${ghPriceAmount(tier.monthlyPrice.amount)} ${tierCurrencySymbol})`;
yearlyLabel = `${tier.name} - Yearly (${ghPriceAmount(tier.yearlyPrice.amount)} ${tierCurrencySymbol})`;
} else {
monthlyLabel = `Monthly (${ghPriceAmount(product.monthlyPrice.amount)} ${productCurrencySymbol})`;
yearlyLabel = `Yearly (${ghPriceAmount(product.yearlyPrice.amount)} ${productCurrencySymbol})`;
monthlyLabel = `Monthly (${ghPriceAmount(tier.monthlyPrice.amount)} ${tierCurrencySymbol})`;
yearlyLabel = `Yearly (${ghPriceAmount(tier.yearlyPrice.amount)} ${tierCurrencySymbol})`;
}
cadences.push({
label: monthlyLabel,
name: `${product.id}-month-${productCurrency}`
name: `${tier.id}-month-${tierCurrency}`
});
cadences.push({
label: yearlyLabel,
name: `${product.id}-year-${productCurrency}`
name: `${tier.id}-year-${tierCurrency}`
});
});
this.cadences = cadences;
@ -286,7 +286,7 @@ export default class OffersController extends Controller {
@action
setup() {
this.fetchProducts.perform();
this.fetchTiers.perform();
}
@action
@ -378,9 +378,9 @@ export default class OffersController extends Controller {
if (!tierId) {
return '$';
}
const product = this.products.findBy('id', tierId);
const productCurrency = product?.monthlyPrice?.currency || 'usd';
return getSymbol(productCurrency);
const tier = this.tiers.findBy('id', tierId);
const tierCurrency = tier?.monthlyPrice?.currency || 'usd';
return getSymbol(tierCurrency);
}
get currencyLength() {

View File

@ -18,7 +18,7 @@ export default class MembersController extends Controller {
@service router;
@tracked offers = [];
@tracked products = [];
@tracked tiers = [];
@tracked type = 'active';
queryParams = [
@ -32,16 +32,16 @@ export default class MembersController extends Controller {
get filteredOffers() {
return this.offers.filter((offer) => {
const product = this.products.find((p) => {
const tier = this.tiers.find((p) => {
return p.id === offer.tier.id;
});
const price = offer.cadence === 'month' ? product.monthlyPrice : product.yearlyPrice;
const price = offer.cadence === 'month' ? tier.monthlyPrice : tier.yearlyPrice;
return offer.status === this.type && !!price;
}).map((offer) => {
const product = this.products.find((p) => {
const tier = this.tiers.find((p) => {
return p.id === offer.tier.id;
});
const price = offer.cadence === 'month' ? product.monthlyPrice : product.yearlyPrice;
const price = offer.cadence === 'month' ? tier.monthlyPrice : tier.yearlyPrice;
offer.finalCurrency = offer.currency || price.currency;
offer.originalPrice = price.amount;
offer.updatedPrice = offer.type === 'fixed' ? (price.amount - offer.amount) : (price.amount - ((price.amount * offer.amount) / 100));
@ -73,7 +73,7 @@ export default class MembersController extends Controller {
@task({restartable: true})
*fetchOffersTask() {
this.products = yield this.store.query('product', {
this.tiers = yield this.store.query('tier', {
filter: 'type:paid', include: 'monthly_price,yearly_price'
});
this.offers = yield this.store.query('offer', {limit: 'all'});

View File

@ -26,11 +26,11 @@ export default class MembersAccessController extends Controller {
@tracked showLeaveRouteModal = false;
@tracked showPortalSettings = false;
@tracked showStripeConnect = false;
@tracked showProductModal = false;
@tracked showTierModal = false;
@tracked product = null;
@tracked products = null;
@tracked productModel = null;
@tracked tier = null;
@tracked tiers = null;
@tracked tierModel = null;
@tracked paidSignupRedirect;
@tracked freeSignupRedirect;
@tracked welcomePageURL;
@ -45,12 +45,12 @@ export default class MembersAccessController extends Controller {
queryParams = ['showPortalSettings'];
get freeProduct() {
return this.products?.find(product => product.type === 'free');
get freeTier() {
return this.tiers?.find(tier => tier.type === 'free');
}
get paidProducts() {
return this.products?.filter(product => product.type === 'paid');
get paidTiers() {
return this.tiers?.filter(tier => tier.type === 'paid');
}
get allCurrencies() {
@ -71,9 +71,9 @@ export default class MembersAccessController extends Controller {
}
get hasChangedPrices() {
if (this.product) {
const monthlyPrice = this.product.get('monthlyPrice');
const yearlyPrice = this.product.get('yearlyPrice');
if (this.tier) {
const monthlyPrice = this.tier.get('monthlyPrice');
const yearlyPrice = this.tier.get('yearlyPrice');
if (monthlyPrice?.amount && parseFloat(this.stripeMonthlyAmount) !== (monthlyPrice.amount / 100)) {
return true;
@ -88,7 +88,7 @@ export default class MembersAccessController extends Controller {
@action
setup() {
this.fetchProducts.perform();
this.fetchTiers.perform();
this.updatePortalPreview();
}
@ -169,9 +169,9 @@ export default class MembersAccessController extends Controller {
if (this.welcomePageURL.href.startsWith(siteUrl)) {
const path = this.welcomePageURL.href.replace(siteUrl, '');
this.freeProduct.welcomePageURL = path;
this.freeTier.welcomePageURL = path;
} else {
this.freeProduct.welcomePageURL = this.welcomePageURL.href;
this.freeTier.welcomePageURL = this.welcomePageURL.href;
}
}
@ -210,20 +210,20 @@ export default class MembersAccessController extends Controller {
}
@action
async openEditProduct(product) {
this.productModel = product;
this.showProductModal = true;
async openEditTier(tier) {
this.tierModel = tier;
this.showTierModal = true;
}
@action
async openNewProduct() {
this.productModel = this.store.createRecord('product');
this.showProductModal = true;
async openNewTier() {
this.tierModel = this.store.createRecord('tier');
this.showTierModal = true;
}
@action
closeProductModal() {
this.showProductModal = false;
closeTierModal() {
this.showTierModal = false;
}
@action
@ -266,20 +266,20 @@ export default class MembersAccessController extends Controller {
let isMonthlyChecked = portalPlans.includes('monthly');
let isYearlyChecked = portalPlans.includes('yearly');
const products = this.store.peekAll('product');
const portalProducts = products?.filter((product) => {
return product.get('visibility') === 'public'
&& product.get('active') === true
&& product.get('type') === 'paid';
}).map((product) => {
return product.id;
const tiers = this.store.peekAll('tier');
const portalTiers = tiers?.filter((tier) => {
return tier.get('visibility') === 'public'
&& tier.get('active') === true
&& tier.get('type') === 'paid';
}).map((tier) => {
return tier.id;
});
const newUrl = new URL(this.membersUtils.getPortalPreviewUrl({
button: false,
monthlyPrice,
yearlyPrice,
portalProducts,
portalTiers,
currency: this.currency,
isMonthlyChecked,
isYearlyChecked,
@ -325,9 +325,9 @@ export default class MembersAccessController extends Controller {
}
@action
confirmProductSave() {
confirmTierSave() {
this.updatePortalPreview({forceRefresh: true});
return this.fetchProducts.perform();
return this.fetchTiers.perform();
}
@task
@ -335,10 +335,10 @@ export default class MembersAccessController extends Controller {
return yield this.saveSettingsTask.perform({forceRefresh: true});
}
setupPortalProduct(product) {
if (product) {
const monthlyPrice = product.get('monthlyPrice');
const yearlyPrice = product.get('yearlyPrice');
setupPortalTier(tier) {
if (tier) {
const monthlyPrice = tier.get('monthlyPrice');
const yearlyPrice = tier.get('yearlyPrice');
if (monthlyPrice && monthlyPrice.amount) {
this.stripeMonthlyAmount = (monthlyPrice.amount / 100);
this.currency = monthlyPrice.currency;
@ -351,12 +351,12 @@ export default class MembersAccessController extends Controller {
}
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {
*fetchTiers() {
this.tiers = yield this.store.query('tier', {
include: 'monthly_price,yearly_price,benefits'
});
this.product = this.paidProducts.firstObject;
this.setupPortalProduct(this.product);
this.tier = this.paidTiers.firstObject;
this.setupPortalTier(this.tier);
}
@task({drop: true})
@ -372,7 +372,7 @@ export default class MembersAccessController extends Controller {
return;
}
yield this.saveProduct();
yield this.saveTier();
const result = yield this.settings.save();
this.updatePortalPreview(options);
@ -387,19 +387,19 @@ export default class MembersAccessController extends Controller {
return;
}
const result = yield this.settings.save();
yield this.freeProduct.save();
yield this.freeTier.save();
this.updatePortalPreview(options);
return result;
}
}
async saveProduct() {
async saveTier() {
const isStripeConnected = this.settings.get('stripeConnectAccountId');
if (this.product && isStripeConnected) {
if (this.tier && isStripeConnected) {
const monthlyAmount = Math.round(this.stripeMonthlyAmount * 100);
const yearlyAmount = Math.round(this.stripeYearlyAmount * 100);
this.product.set('monthlyPrice', {
this.tier.set('monthlyPrice', {
nickname: 'Monthly',
amount: monthlyAmount,
active: true,
@ -407,7 +407,7 @@ export default class MembersAccessController extends Controller {
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
this.tier.set('yearlyPrice', {
nickname: 'Yearly',
amount: yearlyAmount,
active: true,
@ -416,14 +416,14 @@ export default class MembersAccessController extends Controller {
type: 'recurring'
});
const savedProduct = await this.product.save();
return savedProduct;
const savedTier = await this.tier.save();
return savedTier;
}
}
resetPrices() {
const monthlyPrice = this.product.get('monthlyPrice');
const yearlyPrice = this.product.get('yearlyPrice');
const monthlyPrice = this.tier.get('monthlyPrice');
const yearlyPrice = this.tier.get('yearlyPrice');
this.stripeMonthlyAmount = monthlyPrice ? (monthlyPrice.amount / 100) : 5;
this.stripeYearlyAmount = yearlyPrice ? (yearlyPrice.amount / 100) : 50;

View File

@ -4,7 +4,7 @@ import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class ProductController extends Controller {
export default class TierController extends Controller {
@service config;
@service membersUtils;
@service settings;
@ -20,7 +20,7 @@ export default class ProductController extends Controller {
this.siteUrl = this.config.get('blogUrl');
}
get product() {
get tier() {
return this.model;
}
@ -41,7 +41,7 @@ export default class ProductController extends Controller {
}
get noOfPrices() {
return (this.product.stripePrices || []).length;
return (this.tier.stripePrices || []).length;
}
@action
@ -71,7 +71,7 @@ export default class ProductController extends Controller {
@action
leaveScreen() {
this.product.rollbackAttributes();
this.tier.rollbackAttributes();
return this.leaveScreenTransition.retry();
}
@ -126,7 +126,7 @@ export default class ProductController extends Controller {
@action
savePrice(price) {
const stripePrices = this.product.stripePrices.map((d) => {
const stripePrices = this.tier.stripePrices.map((d) => {
if (d.id === price.id) {
return EmberObject.create({
...price,
@ -144,7 +144,7 @@ export default class ProductController extends Controller {
active: !!price.active
}));
}
this.product.set('stripePrices', stripePrices);
this.tier.set('stripePrices', stripePrices);
this.saveTask.perform();
}
@ -166,15 +166,15 @@ export default class ProductController extends Controller {
@task({restartable: true})
*saveTask() {
this.send('validatePaidSignupRedirect');
this.product.validate();
if (this.product.get('errors').length !== 0) {
this.tier.validate();
if (this.tier.get('errors').length !== 0) {
return;
}
if (this.settings.get('errors').length !== 0) {
return;
}
yield this.settings.save();
const response = yield this.product.save();
const response = yield this.tier.save();
if (this.showPriceModal) {
this.closePriceModal();
}

View File

@ -4,7 +4,7 @@ import {htmlSafe} from '@ember/template';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class ProductsController extends Controller {
export default class TiersController extends Controller {
@service settings;
@service config;
@ -16,7 +16,7 @@ export default class ProductsController extends Controller {
this.iconStyle = this.setIconStyle();
}
get products() {
get tiers() {
return this.model.sortBy('name');
}

View File

@ -12,8 +12,6 @@ import NavItemValidator from 'ghost-admin/validators/nav-item';
import NewsletterValidator from 'ghost-admin/validators/newsletter';
import OfferValidator from 'ghost-admin/validators/offer';
import PostValidator from 'ghost-admin/validators/post';
import ProductBenefitItemValidator from 'ghost-admin/validators/product-benefit-item';
import ProductValidator from 'ghost-admin/validators/product';
import RSVP from 'rsvp';
import ResetValidator from 'ghost-admin/validators/reset';
import SettingValidator from 'ghost-admin/validators/setting';
@ -23,6 +21,8 @@ import SignupValidator from 'ghost-admin/validators/signup';
import SlackIntegrationValidator from 'ghost-admin/validators/slack-integration';
import SnippetValidator from 'ghost-admin/validators/snippet';
import TagSettingsValidator from 'ghost-admin/validators/tag-settings';
import TierBenefitItemValidator from 'ghost-admin/validators/tier-benefit-item';
import TierValidator from 'ghost-admin/validators/tier';
import UserValidator from 'ghost-admin/validators/user';
import WebhookValidator from 'ghost-admin/validators/webhook';
import {A as emberA, isArray as isEmberArray} from '@ember/array';
@ -60,7 +60,7 @@ export default Mixin.create({
customView: CustomViewValidator,
inviteUser: InviteUserValidator,
navItem: NavItemValidator,
productBenefitItem: ProductBenefitItemValidator,
tierBenefitItem: TierBenefitItemValidator,
post: PostValidator,
reset: ResetValidator,
setting: SettingValidator,
@ -75,7 +75,7 @@ export default Mixin.create({
webhook: WebhookValidator,
label: LabelValidator,
snippet: SnippetValidator,
product: ProductValidator,
tier: TierValidator,
offer: OfferValidator,
newsletter: NewsletterValidator
};

View File

@ -1,6 +1,6 @@
import EmberObject from '@ember/object';
export default EmberObject.extend({
name: 'Name of the product',
slug: 'Slug for the product'
name: 'Name of the tier',
slug: 'Slug for the tier'
});

View File

@ -21,7 +21,7 @@ export default Model.extend(ValidationEngine, {
emailOpenRate: attr('number'),
avatarImage: attr('string'),
products: attr('member-product'),
tiers: attr('member-tier'),
newsletters: hasMany('newsletter', {embedded: 'always', async: false}),
labels: hasMany('label', {embedded: 'always', async: false}),

View File

@ -145,7 +145,7 @@ export default Model.extend(Comparable, ValidationEngine, {
ogTitleScratch: boundOneWay('ogTitle'),
twitterDescriptionScratch: boundOneWay('twitterDescription'),
twitterTitleScratch: boundOneWay('twitterTitle'),
tiers: attr('member-product'),
tiers: attr('member-tier'),
emailSubjectScratch: boundOneWay('emailSubject'),
isPublished: equal('status', 'published'),
@ -192,7 +192,7 @@ export default Model.extend(Comparable, ValidationEngine, {
}
if (this.visibility === 'tiers' && this.tiers) {
let filter = this.tiers.map((tier) => {
return `product:${tier.slug}`;
return `tier:${tier.slug}`;
}).join(',');
return filter;
}

View File

@ -45,7 +45,7 @@ export default Model.extend(ValidationEngine, {
portalButton: attr('boolean'),
portalName: attr('boolean'),
portalPlans: attr('json-string'),
portalProducts: attr('json-string'),
portalTiers: attr('json-string'),
portalButtonStyle: attr('string'),
portalButtonIcon: attr('string'),
portalButtonSignupText: attr('string'),

View File

@ -6,7 +6,7 @@ export default EmberObject.extend(ValidationEngine, {
name: '',
isNew: false,
validationType: 'productBenefitItem',
validationType: 'tierBenefitItem',
isComplete: computed('name', function () {
let {name} = this;

View File

@ -2,7 +2,7 @@ import Model, {attr} from '@ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
export default Model.extend(ValidationEngine, {
validationType: 'product',
validationType: 'tier',
name: attr('string'),
description: attr('string'),
@ -13,5 +13,5 @@ export default Model.extend(ValidationEngine, {
type: attr('string', {defaultValue: 'paid'}),
monthlyPrice: attr('stripe-price'),
yearlyPrice: attr('stripe-price'),
benefits: attr('product-benefits')
benefits: attr('tier-benefits')
});

View File

@ -19,7 +19,7 @@ export default class MembersRoute extends AdminRoute {
this._requiresBackgroundRefresh = false;
if (params.member_id) {
return this.store.queryRecord('member', {id: params.member_id, include: 'products'});
return this.store.queryRecord('member', {id: params.member_id, include: 'tiers'});
} else {
return this.store.createRecord('member');
}

View File

@ -1,67 +0,0 @@
import AdminRoute from 'ghost-admin/routes/admin';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class ProductRoute extends AdminRoute {
@service store;
@service router;
_requiresBackgroundRefresh = true;
constructor() {
super(...arguments);
this.router.on('routeWillChange', (transition) => {
this.showUnsavedChangesModal(transition);
});
}
model(params) {
if (params.product_id) {
return this.store.queryRecord('product', {id: params.product_id, include: 'stripe_prices'});
} else {
return this.store.createRecord('product');
}
}
setupController(controller, product) {
super.setupController(...arguments);
if (this._requiresBackgroundRefresh) {
if (product.get('id')) {
return this.store.queryRecord('product', {id: product.get('id'), include: 'stripe_prices'});
}
}
}
deactivate() {
super.deactivate(...arguments);
// clean up newly created records and revert unsaved changes to existing
this.controller.product.rollbackAttributes();
this._requiresBackgroundRefresh = true;
}
@action
save() {
this.controller.save();
}
buildRouteInfoMetadata() {
return {
titleToken: 'Settings - Products'
};
}
showUnsavedChangesModal(transition) {
if (transition.from && transition.from.name === this.routeName && transition.targetName) {
let {controller} = this;
// product.changedAttributes is always true for new products but number of changed attrs is reliable
let isChanged = Object.keys(controller.product.changedAttributes()).length > 0;
if (!controller.product.isDeleted && isChanged) {
transition.abort();
controller.toggleUnsavedChangesModal(transition);
return;
}
}
}
}

View File

@ -1,6 +0,0 @@
import ProductRoute from '../product';
export default class NewProductRoute extends ProductRoute {
controllerName = 'settings.product';
templateName = 'settings.product';
}

View File

@ -1,16 +0,0 @@
import AdminRoute from 'ghost-admin/routes/admin';
import {inject as service} from '@ember/service';
export default class ProductsRoute extends AdminRoute {
@service store;
buildRouteInfoMetadata() {
return {
titleToken: 'Settings - Products'
};
}
model() {
return this.store.findAll('product', {include: 'stripe_prices'});
}
}

View File

@ -0,0 +1,6 @@
import TierRoute from '../tier';
export default class NewTierRoute extends TierRoute {
controllerName = 'settings.tier';
templateName = 'settings.tier';
}

View File

@ -1,6 +1,6 @@
import ApplicationSerializer from './application';
export default class ProductSerializer extends ApplicationSerializer {
export default class TierSerializer extends ApplicationSerializer {
serialize() {
let json = super.serialize(...arguments);

View File

@ -143,8 +143,8 @@ export default class DashboardStatsService extends Service {
*/
@tracked lastSeenFilterStatus = 'total';
paidProducts = null;
paidTiers = null;
/**
* @type {?MemberCounts}
*/
@ -193,7 +193,7 @@ export default class DashboardStatsService extends Service {
paid: stat.paid + stat.comped,
free: stat.free
};
}
}
}
// We don't have any statistic from more than x days ago.
@ -221,7 +221,7 @@ export default class DashboardStatsService extends Service {
const stat = this.mrrStats[index];
if (stat.date <= searchDate) {
return stat.mrr;
}
}
}
// We don't have any statistic from more than x days ago.
@ -293,14 +293,14 @@ export default class DashboardStatsService extends Service {
}
if (this.membersUtils.isStripeEnabled) {
yield this.loadPaidProducts();
yield this.loadPaidTiers();
}
const hasPaidTiers = this.membersUtils.isStripeEnabled && this.paidProducts && this.paidProducts.length > 0;
const hasPaidTiers = this.membersUtils.isStripeEnabled && this.paidTiers && this.paidTiers.length > 0;
this.siteStatus = {
hasPaidTiers,
hasMultipleTiers: hasPaidTiers && this.paidProducts.length > 1,
hasMultipleTiers: hasPaidTiers && this.paidTiers.length > 1,
newslettersEnabled: this.settings.get('editorDefaultEmailRecipients') !== 'disabled',
membersEnabled: this.membersUtils.isMembersEnabled
};
@ -358,15 +358,15 @@ export default class DashboardStatsService extends Service {
}, 0);
}
yield this.loadPaidProducts();
yield this.loadPaidTiers();
const paidMembersByTier = [];
for (const tier of result.meta.tiers) {
const product = this.paidProducts.find(x => x.id === tier);
const _tier = this.paidTiers.find(x => x.id === tier);
paidMembersByTier.push({
tier: {
name: product.name
name: _tier.name
},
members: result.meta.totals.reduce((sum, total) => {
if (total.tier !== tier) {
@ -536,24 +536,24 @@ export default class DashboardStatsService extends Service {
this.membersLastSeen7d = result7d;
}
loadPaidProducts() {
if (this.paidProducts !== null) {
loadPaidTiers() {
if (this.paidTiers !== null) {
return;
}
if (this._loadPaidProducts.isRunning) {
if (this._loadPaidTiers.isRunning) {
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
return this._loadPaidProducts.last;
return this._loadPaidTiers.last;
}
return this._loadPaidProducts.perform();
return this._loadPaidTiers.perform();
}
@task
*_loadPaidProducts() {
const data = yield this.store.query('product', {
*_loadPaidTiers() {
const data = yield this.store.query('tier', {
filter: 'type:paid+active:true',
limit: 'all'
});
this.paidProducts = data.toArray();
this.paidTiers = data.toArray();
}
loadNewsletterSubscribers() {
@ -573,7 +573,7 @@ export default class DashboardStatsService extends Service {
this.newsletterSubscribers = this.dashboardMocks.newsletterSubscribers;
return;
}
const [paid, free] = yield Promise.all([
this.membersCountCache.count('newsletters.status:active+status:-free'),
this.membersCountCache.count('newsletters.status:active+status:free')
@ -603,7 +603,7 @@ export default class DashboardStatsService extends Service {
this.emailsSent30d = this.dashboardMocks.emailsSent30d;
return;
}
const start30d = new Date(Date.now() - 30 * 86400 * 1000);
const result = yield this.store.query('email', {limit: 100, filter: 'submitted_at:>' + start30d.toISOString()});
this.emailsSent30d = result.reduce((c, email) => c + email.emailCount, 0);

View File

@ -89,14 +89,14 @@ export default class MembersUtilsService extends Service {
monthlyPrice,
yearlyPrice,
portalPlans = this.settings.get('portalPlans'),
portalProducts,
portalTiers,
currency,
membersSignupAccess = this.settings.get('membersSignupAccess')
} = overrides;
const tiers = this.store.peekAll('product') || [];
const tiers = this.store.peekAll('tier') || [];
portalProducts = portalProducts || tiers.filter((t) => {
portalTiers = portalTiers || tiers.filter((t) => {
return t.visibility === 'public' && t.type === 'paid';
}).map(t => t.id);
@ -121,8 +121,8 @@ export default class MembersUtilsService extends Service {
settingsParam.append('portalPrices', encodeURIComponent(portalPlans));
}
if (portalProducts && this.feature.get('multipleProducts')) {
settingsParam.append('portalProducts', encodeURIComponent(portalProducts));
if (portalTiers && this.feature.get('multipleProducts')) {
settingsParam.append('portalProducts', encodeURIComponent(portalTiers));
}
if (this.settings.get('accentColor') === '' || this.settings.get('accentColor')) {

View File

@ -70,7 +70,7 @@
@import "layouts/post-preview.css";
@import "layouts/dashboard.css";
@import "layouts/dashboard-v5.css";
@import "layouts/products.css";
@import "layouts/tiers.css";
@import "layouts/offers.css";
@ -179,7 +179,7 @@ input:focus,
.gh-mobile-nav-bar a.active {
background: #111213;
color: #fff;
}
}
}
.gh-nav-search .ember-power-select-trigger {
@ -856,12 +856,12 @@ input:focus,
background: var(--pink-d1);
}
.token-segment-product {
.token-segment-tier {
background: var(--darkgrey);
color: var(--white);
}
.token-segment-product svg path {
.token-segment-tier svg path {
stroke: var(--lightgrey);
fill: var(--lightgrey);
}

View File

@ -70,7 +70,7 @@
@import "layouts/post-preview.css";
@import "layouts/dashboard.css";
@import "layouts/dashboard-v5.css";
@import "layouts/products.css";
@import "layouts/tiers.css";
@import "layouts/offers.css"

View File

@ -116,11 +116,11 @@
margin: 2px !important;
}
.gh-filter-block .token-segment-product {
.gh-filter-block .token-segment-tier {
margin: 2px !important;
}
.gh-filter-block .token-segment-product .ember-power-select-multiple-remove-btn svg {
.gh-filter-block .token-segment-tier .ember-power-select-multiple-remove-btn svg {
margin-right: 0!important;
}

View File

@ -316,17 +316,17 @@
fill: var(--middarkgrey);
}
.token-segment-product {
.token-segment-tier {
background: var(--black);
color: var(--white);
}
.token-segment-status-free svg path,
.token-segment-status-paid svg path,
.token-segment-product svg path {
.token-segment-tier svg path {
stroke: var(--white);
fill: var(--white);
}
}
/* Inline input */
.ember-power-select-inline {

View File

@ -2042,7 +2042,7 @@ p.gh-members-import-errordetail:first-of-type {
}
}
/* Custom product member details */
/* Custom tier member details */
.gh-cp-member-email-name {
display: grid;
grid-template-columns: 1fr 1fr;
@ -2075,20 +2075,20 @@ p.gh-members-import-errordetail:first-of-type {
font-size: 1.3rem;
}
/* Member's product list */
.gh-member-product-container {
/* Member's tier list */
.gh-member-tier-container {
grid-row-gap: 24px;
}
.gh-member-settings .gh-member-product-container + .gh-member-product-container {
.gh-member-settings .gh-member-tier-container + .gh-member-tier-container {
padding-top: 0 !important;
}
.gh-cp-memberproduct {
.gh-cp-membertier {
margin-bottom: 0 !important;
}
.gh-memberproduct-name {
.gh-membertier-name {
display: flex;
justify-content: space-between;
font-size: 1.65rem !important;
@ -2096,97 +2096,97 @@ p.gh-members-import-errordetail:first-of-type {
margin-bottom: 2px !important;
}
.gh-cp-memberproduct.multiple-subs .gh-memberproduct-name {
.gh-cp-membertier.multiple-subs .gh-membertier-name {
margin-bottom: 8px !important;
}
.gh-memberproduct-subcount {
.gh-membertier-subcount {
font-size: 1.25rem;
font-weight: 400;
color: var(--midgrey);
}
.gh-memberproduct-list .gh-list-row:hover {
.gh-membertier-list .gh-list-row:hover {
background: none !important;
}
.gh-cp-memberproduct-pricelabel {
.gh-cp-membertier-pricelabel {
font-weight: 600;
}
.gh-memberproduct-subscription span.archived {
.gh-membertier-subscription span.archived {
background: var(--lightgrey-l2);
color: var(--midgrey);
font-size: 1.2rem;
}
.gh-cp-memberproduct.multiple-subs .gh-memberproduct-subscription {
.gh-cp-membertier.multiple-subs .gh-membertier-subscription {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--whitegrey);
}
.gh-memberproduct-created {
.gh-membertier-created {
color: var(--midgrey-d1);
font-size: 1.25rem;
}
.gh-memberproduct-archived .gh-memberproduct-name {
.gh-membertier-archived .gh-membertier-name {
opacity: 0.5;
}
.gh-memberproduct-list-footer {
.gh-membertier-list-footer {
position:relative;
margin-top: 8px;
padding-bottom: 24px;
}
.gh-memberproduct-list-footer.min-height {
.gh-membertier-list-footer.min-height {
min-height: 74px;
}
.gh-memberproduct-list-footer .gh-loading-content {
.gh-membertier-list-footer .gh-loading-content {
padding-bottom: unset;
padding-top: 12px;
}
.gh-memberproduct-cancelreason {
.gh-membertier-cancelreason {
line-height: 1.45em;
margin: 3px 0 5px;
max-width: 700px;
}
.gh-btn-addproduct svg {
.gh-btn-addtier svg {
width: .8em;
height: .8em;
}
.gh-btn-addproduct svg path {
.gh-btn-addtier svg path {
fill: var(--green);
}
.gh-member-product-memberdetails {
.gh-member-tier-memberdetails {
display: flex;
flex-direction: column;
align-items: center;
margin: 12px 0 24px;
}
.gh-member-product-memberdetails .gh-member-gravatar {
.gh-member-tier-memberdetails .gh-member-gravatar {
margin: 0;
}
.gh-member-product-memberdetails h3 {
.gh-member-tier-memberdetails h3 {
margin: 12px 0 0;
font-size: 1.9rem;
line-height: 1;
}
.gh-member-product-memberdetails p {
.gh-member-tier-memberdetails p {
margin: 0;
}
.gh-cp-memberproduct-noproduct {
.gh-cp-membertier-notier {
position: relative;
display: flex;
flex-direction: column;
@ -2196,73 +2196,73 @@ p.gh-members-import-errordetail:first-of-type {
font-size: 1.4rem;
}
.gh-cp-memberproduct-noproduct .gh-loading-content {
.gh-cp-membertier-notier .gh-loading-content {
padding-bottom: unset;
}
.gh-btn-add-memberproduct[disabled],
.gh-btn-add-memberproduct[disabled]:hover {
.gh-btn-add-membertier[disabled],
.gh-btn-add-membertier[disabled]:hover {
background: var(--lightgrey-l1) !important;
}
.gh-btn-add-memberproduct[disabled] span {
.gh-btn-add-membertier[disabled] span {
color: var(--midgrey);
}
.gh-memberproduct-subscription {
.gh-membertier-subscription {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
}
.gh-cp-memberproduct-renewal {
.gh-cp-membertier-renewal {
color: var(--darkgrey-l1);
}
.gh-memberproduct-price-container {
.gh-membertier-price-container {
display: flex;
align-items: flex-start;
}
.gh-cp-memberproduct:not(.multiple-subs) .gh-memberproduct-price-container {
.gh-cp-membertier:not(.multiple-subs) .gh-membertier-price-container {
margin-top: -19px;
}
.gh-cp-memberproduct .gh-product-card-price {
.gh-cp-membertier .gh-tier-card-price {
padding: 10px 18px;
}
.gh-cp-memberproduct:not(.multiple-subs) .gh-product-card-price {
.gh-cp-membertier:not(.multiple-subs) .gh-tier-card-price {
padding: 15px 18px;
}
.product-actions-menu {
.tier-actions-menu {
top: calc(100% - 36px);
right: 0px;
left: auto;
}
.gh-memberproduct-subscription .action-menu .gh-btn-subscription-action:not(:hover) {
.gh-membertier-subscription .action-menu .gh-btn-subscription-action:not(:hover) {
border: 1px solid var(--whitegrey);
background: var(--main-bg-color) !important;
box-shadow: none;
}
.gh-memberproduct-subscription .action-menu .gh-btn-subscription-action.open {
.gh-membertier-subscription .action-menu .gh-btn-subscription-action.open {
border: 1px solid var(--lightgrey-l1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.gh-memberproduct-subscription .action-menu > .gh-btn span {
.gh-membertier-subscription .action-menu > .gh-btn span {
height: 28px;
}
.gh-memberproduct-subscription .action-menu > .gh-btn svg {
.gh-membertier-subscription .action-menu > .gh-btn svg {
margin: 0;
}
.gh-member-product-form-block .form-group:last-of-type {
.gh-member-tier-form-block .form-group:last-of-type {
margin: 0;
}

View File

@ -1,12 +1,12 @@
/* Product list */
.gh-product-list {
/* Tier list */
.gh-tier-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 32px;
}
@media (max-width: 980px) {
.gh-product-list {
.gh-tier-list {
grid-template-columns: repeat(1, 1fr);
}
}
@ -21,11 +21,11 @@
color: #394047 !important;
}
.gh-product-cards {
.gh-tier-cards {
margin: 0 0 24px;
}
.gh-product-card {
.gh-tier-card {
position:relative;
display: flex;
align-items: flex-start;
@ -33,28 +33,28 @@
}
@media (max-width: 980px) {
.gh-product-card {
.gh-tier-card {
padding: 4vmin 48px;
}
}
.gh-product-card-button-container {
.gh-tier-card-button-container {
position: absolute;
right: 24px;
top: 24px;
margin-right: 0;
}
.gh-product-card-actions-button, .gh-product-card-edit-button {
.gh-tier-card-actions-button, .gh-tier-card-edit-button {
margin-right: 0;
cursor: pointer;
}
.gh-product-card-actions-button.gh-btn span, .gh-product-card-edit-button.gh-btn span {
.gh-tier-card-actions-button.gh-btn span, .gh-tier-card-edit-button.gh-btn span {
height: 24px;
}
.gh-product-card-edit-button svg {
.gh-tier-card-edit-button svg {
fill: #15171a;
color: transparent;
}
@ -65,64 +65,64 @@
right: 0;
}
.gh-product-card-block {
.gh-tier-card-block {
flex-basis: 30%;
}
.gh-product-card-block:not(:first-of-type) {
.gh-tier-card-block:not(:first-of-type) {
padding-left: 16px;
}
.gh-product-card-block h4 {
.gh-tier-card-block h4 {
font-size: 1.3rem;
font-weight: 500;
}
.gh-product-card-block h4 .counter {
.gh-tier-card-block h4 .counter {
font-weight: 400;
color: var(--midgrey);
}
.gh-product-card-name {
.gh-tier-card-name {
font-size: 1.8rem;
font-weight: 600;
margin: 0;
}
.gh-product-card-empty-state {
.gh-tier-card-empty-state {
display: flex;
justify-content: center;
align-items: center;
}
.gh-product-card-empty-state p {
.gh-tier-card-empty-state p {
margin-bottom: 0;
padding: 3.2rem;
color: var(--midgrey);
}
.gh-product-card-description {
.gh-tier-card-description {
font-size: 1.3rem;
line-height: 1.45em;
margin: 4px 20px 4px 0;
color: var(--midgrey);
}
.gh-product-card-block.title-block {
.gh-tier-card-block.title-block {
flex-basis: 40%;
}
.gh-product-card-block.benefits-block .gh-product-card-description {
.gh-tier-card-block.benefits-block .gh-tier-card-description {
margin-top: 9px;
}
.gh-product-card-block ul.benefits {
.gh-tier-card-block ul.benefits {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.gh-product-card-block ul.benefits li {
.gh-tier-card-block ul.benefits li {
display: flex;
align-items: flex-start;
font-size: 1.3rem;
@ -130,7 +130,7 @@
color: var(--middarkgrey);
}
.gh-product-card-block ul.benefits li svg {
.gh-tier-card-block ul.benefits li svg {
flex-basis: 18px;
width: 14px;
height: 14px;
@ -140,17 +140,17 @@
color: var(--black);
}
.gh-product-card-block ul.benefits li span {
.gh-tier-card-block ul.benefits li span {
flex-grow: 1;
}
.gh-product-price-container {
.gh-tier-price-container {
display: flex;
margin: 0 60px 0 20px;
justify-content: flex-end;
}
.gh-product-card-price {
.gh-tier-card-price {
display: flex;
flex-direction: column;
align-items: center;
@ -165,19 +165,19 @@
min-height: 66px;
}
.gh-product-card-price .currency-symbol,
.gh-product-card-price .amount,
.gh-product-card-price .currency,
.gh-product-card-price .currency-code {
.gh-tier-card-price .currency-symbol,
.gh-tier-card-price .amount,
.gh-tier-card-price .currency,
.gh-tier-card-price .currency-code {
font-weight: 600;
color: var(--darkgrey);
}
.gh-product-card-price .currency-symbol {
.gh-tier-card-price .currency-symbol {
margin-top: -3px;
}
.gh-product-card-price .amount {
.gh-tier-card-price .amount {
letter-spacing: -.2px;
line-height: 1;
margin-right: 2px;
@ -185,7 +185,7 @@
letter-spacing: 0.1px;
}
.gh-product-card-price .currency {
.gh-tier-card-price .currency {
text-transform: uppercase;
position: relative;
top: 2px;
@ -194,7 +194,7 @@
line-height: 1;
}
.gh-product-card-price .currency-code {
.gh-tier-card-price .currency-code {
text-transform: uppercase;
position: relative;
top: 0;
@ -204,14 +204,14 @@
letter-spacing: -.2px;
}
.gh-product-card-price .period {
.gh-tier-card-price .period {
font-size: 1.25rem;
text-transform: lowercase;
line-height: 1.2em;
margin-top: 2px;
}
.gh-product-cards-footer {
.gh-tier-cards-footer {
display: flex;
align-items: center;
margin-top: -7px;
@ -219,23 +219,23 @@
font-size: 1.35rem;
}
.gh-btn-add-product,
.gh-btn-add-product:hover {
.gh-btn-add-tier,
.gh-btn-add-tier:hover {
margin-right: 5px;
}
.gh-btn-add-product svg {
.gh-btn-add-tier svg {
width: 1rem;
height: 1rem;
margin: 1px 4px 0 0;
}
.gh-btn-icon.gh-btn-add-product svg path {
.gh-btn-icon.gh-btn-add-tier svg path {
stroke: var(--green-d1);
stroke-width: 3;
}
.gh-product-list-icon {
.gh-tier-list-icon {
display: flex;
align-items: flex-end;
justify-content: center;
@ -244,12 +244,12 @@
height: 72px;
}
.gh-product-list-icon svg {
.gh-tier-list-icon svg {
width: 60px;
height: 60px;
}
.gh-product-list-siteicon {
.gh-tier-list-siteicon {
width: 54px;
height: 54px;
background-color: transparent;
@ -258,32 +258,32 @@
margin-bottom: 6px;
}
.gh-product-list-icon svg circle,
.gh-product-list-icon svg path {
.gh-tier-list-icon svg circle,
.gh-tier-list-icon svg path {
stroke-width: 1px !important;
}
/* Product details */
.gh-product-details {
/* Tier details */
.gh-tier-details {
display: grid;
grid-template-columns: 1fr;
grid-gap: 32px;
margin-bottom: 3vw;
}
.gh-product-details-form {
.gh-tier-details-form {
display: flex;
align-items: flex-start;
padding-top: 20px !important;
}
.gh-product-icon-container {
.gh-tier-icon-container {
width: unset;
padding-bottom: 0;
margin-bottom: 0;
}
.gh-product-icon {
.gh-tier-icon {
display: flex;
align-items: center;
justify-content: center;
@ -295,32 +295,32 @@
border-radius: 3px;
}
.gh-product-details-fields {
.gh-tier-details-fields {
width: 100%;
}
.gh-product-details-fields .max-width {
.gh-tier-details-fields .max-width {
max-width: 840px;
}
.gh-product-details-fields .form-group:last-of-type {
.gh-tier-details-fields .form-group:last-of-type {
padding-bottom: 0;
margin-bottom: 0;
}
.gh-product-details section {
.gh-tier-details section {
display: flex;
flex-direction: column;
justify-content: stretch;
}
/* Product stats */
.gh-product-stat-container {
/* Tier stats */
.gh-tier-stat-container {
display: flex;
flex-direction: column;
}
.gh-product-stat-details .data {
.gh-tier-stat-details .data {
white-space: nowrap;
font-size: 3.1rem;
line-height: 1em;
@ -330,13 +330,13 @@
padding: 0;
}
.gh-product-stat-details .info {
.gh-tier-stat-details .info {
color: var(--midgrey);
margin: 0 0 10px;
padding: 0;
}
.gh-product-chart {
.gh-tier-chart {
color: var(--whitegrey);
border: 1px solid var(--whitegrey);
border-top-color: transparent;
@ -428,67 +428,67 @@
width: 80px;
}
.product-actions-menu.fade-out {
.tier-actions-menu.fade-out {
animation-duration: 0.01s;
pointer-events: none;
}
/* Add/edit product modal */
.fullscreen-modal-edit-product {
/* Add/edit tier modal */
.fullscreen-modal-edit-tier {
max-width: 1080px;
}
.gh-product-modal-content {
.gh-tier-modal-content {
margin: -32px -32px 0;
padding: 32px 32px 0;
max-height: calc(100vh - 16vw);
overflow-y: auto;
}
.gh-form-edit-product .gh-main-section {
.gh-form-edit-tier .gh-main-section {
margin-bottom: 32px;
grid-template-columns: 1fr 0.8fr 1.2fr;
}
.gh-form-edit-product .gh-main-section-block {
.gh-form-edit-tier .gh-main-section-block {
display: flex;
flex-direction: column;
margin-bottom: 0;
}
.gh-form-edit-product .gh-main-section-content {
.gh-form-edit-tier .gh-main-section-content {
padding-top: 16px;
margin-bottom: 0;
}
.gh-product-priceform-block {
.gh-tier-priceform-block {
margin-bottom: 32px;
}
.gh-product-priceform-block .form-group:last-of-type {
.gh-tier-priceform-block .form-group:last-of-type {
margin-bottom: 0;
}
.gh-product-priceform-pricecurrency {
.gh-tier-priceform-pricecurrency {
display: grid;
grid-template-columns: 1fr 2fr;
grid-gap: 20px;
}
.gh-form-edit-product .gh-main-section-content.gh-product-form-benefits {
.gh-form-edit-tier .gh-main-section-content.gh-tier-form-benefits {
padding-left: 8px;
margin-bottom: 0;
}
.gh-product-benefits .gh-input {
.gh-tier-benefits .gh-input {
padding: 6px 28px 6px 30px;
}
.gh-product-benefits .gh-blognav-line {
.gh-tier-benefits .gh-blognav-line {
position: relative;
}
.gh-product-benefits .gh-blognav-line svg {
.gh-tier-benefits .gh-blognav-line svg {
position: absolute;
width: 12px;
height: 12px;
@ -496,20 +496,20 @@
left: 11px;
}
.gh-product-benefits .gh-blognav-line.placeholder {
.gh-tier-benefits .gh-blognav-line.placeholder {
color: var(--midlightgrey);
}
.gh-product-benefits .gh-blognav-line svg path {
.gh-tier-benefits .gh-blognav-line svg path {
stroke-width: 3px;
}
.gh-product-benefits .gh-blognav-item {
.gh-tier-benefits .gh-blognav-item {
position: relative;
align-items: center;
}
.gh-product-benefits .gh-blognav-item.gh-blognav-item--error {
.gh-tier-benefits .gh-blognav-item.gh-blognav-item--error {
align-items: flex-start;
}
@ -517,42 +517,42 @@
margin-top: 12px;
}
.gh-product-benefits .gh-blognav-label {
.gh-tier-benefits .gh-blognav-label {
margin-right: 0;
}
.gh-product-benefits .gh-blognav-label .response {
.gh-tier-benefits .gh-blognav-label .response {
position: relative;
font-size: 1.25rem;
margin: 2px 0 6px;
}
.gh-product-benefits .gh-blognav-delete {
.gh-tier-benefits .gh-blognav-delete {
position: absolute;
top: 4px;
right: 8px;
opacity: 0;
}
.gh-product-benefits .gh-blognav-add {
.gh-tier-benefits .gh-blognav-add {
margin-top: 2px;
}
.gh-product-benefits .gh-blognav-grab {
.gh-tier-benefits .gh-blognav-grab {
text-indent: 0px;
opacity: 0;
}
.gh-product-benefits .gh-blognav-item:hover .gh-blognav-delete,
.gh-product-benefits .gh-blognav-item:hover .gh-blognav-grab {
.gh-tier-benefits .gh-blognav-item:hover .gh-blognav-delete,
.gh-tier-benefits .gh-blognav-item:hover .gh-blognav-grab {
opacity: 1;
}
.gh-product-benefits .gh-blognav-item:not(.gh-blognav-item--sortable):not(:last-of-type) {
.gh-tier-benefits .gh-blognav-item:not(.gh-blognav-item--sortable):not(:last-of-type) {
margin-bottom: 16px;
}
.gh-product-benefit-hint {
.gh-tier-benefit-hint {
color: var(--midgrey-d2);
font-size: 1.25rem !important;
font-weight: 400;
@ -560,13 +560,13 @@
margin-top: -12px;
}
.gh-product-form-tierpreview-content {
.gh-tier-form-tierpreview-content {
position: sticky;
top: 45px;
height: max-content;
}
.gh-product-form-tierpreview .gh-main-section-content {
.gh-tier-form-tierpreview .gh-main-section-content {
flex: 1;
max-width: 420px;
min-width: 320px;
@ -583,12 +583,12 @@
letter-spacing: normal;
}
.gh-portal-product-card-header {
.gh-portal-tier-card-header {
width: 100%;
min-height: 56px;
}
.gh-product-form-tierpreview .gh-main-section-content .gh-portal-product-name {
.gh-tier-form-tierpreview .gh-main-section-content .gh-portal-tier-name {
font-size: 1.8rem;
font-weight: 600;
line-height: 1.3em;
@ -599,7 +599,7 @@
width: 100%;
}
.gh-product-form-tierpreview .gh-main-section-content .gh-portal-product-description {
.gh-tier-form-tierpreview .gh-main-section-content .gh-portal-tier-description {
font-size: 1.55rem;
font-weight: 600;
line-height: 1.4em;
@ -608,7 +608,7 @@
color: #3d3d3d;
}
.gh-portal-product-card-pricecontainer {
.gh-portal-tier-card-pricecontainer {
display: flex;
flex-direction: row;
align-items: flex-end;
@ -620,38 +620,38 @@
margin-top: 16px;
}
.gh-portal-product-price {
.gh-portal-tier-price {
display: flex;
justify-content: center;
color: #1d1d1d;
}
.gh-portal-product-card-details {
.gh-portal-tier-card-details {
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
}
.gh-portal-product-card-detaildata {
.gh-portal-tier-card-detaildata {
flex: 1;
}
.gh-portal-product-price .amount {
.gh-portal-tier-price .amount {
font-size: 3.4rem;
font-weight: 700;
line-height: 1em;
letter-spacing: -1.3px;
}
.gh-product-form-tierpreivew-cadence {
.gh-tier-form-tierpreivew-cadence {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.gh-product-form-tierpreivew-cadence .gh-btn,
.gh-product-form-tierpreivew-cadence .gh-btn span {
.gh-tier-form-tierpreivew-cadence .gh-btn,
.gh-tier-form-tierpreivew-cadence .gh-btn span {
background: transparent !important;
padding: 0;
line-height: 1em;
@ -663,16 +663,16 @@
overflow: unset;
}
.gh-product-form-tierpreivew-cadence .gh-btn:hover span {
.gh-tier-form-tierpreivew-cadence .gh-btn:hover span {
color: var(--middarkgrey);
}
.gh-product-form-tierpreivew-cadence .gh-btn.selected span {
.gh-tier-form-tierpreivew-cadence .gh-btn.selected span {
font-weight: 500;
color: var(--darkgrey);
}
.gh-product-form-tierpreview .monthly-price {
.gh-tier-form-tierpreview .monthly-price {
display: flex;
align-items: baseline;
font-size: 3.3rem;
@ -681,7 +681,7 @@
color: #3d3d3d;
}
.gh-product-form-tierpreview .currency-sign {
.gh-tier-form-tierpreview .currency-sign {
align-self: flex-start;
font-size: 2.7rem;
font-weight: 700;
@ -689,7 +689,7 @@
text-transform: uppercase;
}
.gh-product-form-tierpreview .billing-period {
.gh-tier-form-tierpreview .billing-period {
align-self: flex-end;
font-size: 1.5rem;
line-height: 1.4em;
@ -727,21 +727,21 @@
opacity: 0.2;
}
.gh-product-form-tierpreview .gh-portal-product-benefits {
.gh-tier-form-tierpreview .gh-portal-tier-benefits {
font-size: 1.5rem;
line-height: 1.4em;
width: 100%;
margin-top: 16px;
}
.gh-product-form-tierpreview .gh-portal-product-benefit {
.gh-tier-form-tierpreview .gh-portal-tier-benefit {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
color: #3d3d3d;
}
.gh-portal-product-benefit svg {
.gh-portal-tier-benefit svg {
width: 14px;
height: 14px;
min-width: 14px;
@ -749,16 +749,16 @@
overflow: visible;
}
.gh-product-form-tierpreview .gh-portal-product-benefit polyline,
.gh-product-form-tierpreview .gh-portal-product-benefit g,
.gh-product-form-tierpreview .gh-portal-product-benefit path {
.gh-tier-form-tierpreview .gh-portal-tier-benefit polyline,
.gh-tier-form-tierpreview .gh-portal-tier-benefit g,
.gh-tier-form-tierpreview .gh-portal-tier-benefit path {
stroke-width: 3px;
}
.gh-product-form-tierpreview .gh-portal-benefit-title {
.gh-tier-form-tierpreview .gh-portal-benefit-title {
letter-spacing: normal;
}
.gh-product-form-tierpreview .placeholder {
.gh-tier-form-tierpreview .placeholder {
opacity: 0.35;
}
}

View File

@ -92,10 +92,10 @@
{{#liquid-if this.freeOpen}}
<div class="gh-setting-content-extended" data-test-free-settings-expanded>
{{#if (feature "multipleProducts")}}
<GhProductCard
@product={{this.freeProduct}}
@products={{this.products}}
@openEditProduct={{this.openEditProduct}}
<GhTierCard
@tier={{this.freeTier}}
@tiers={{this.tiers}}
@openEditTier={{this.openEditTier}}
/>
{{/if}}
{{#if (or (not (feature "tierWelcomePages")) (not (feature "multipleProducts")))}}
@ -121,7 +121,7 @@
<label for="welcomePage" class="fw6">Welcome page</label>
<GhUrlInput
@id="welcomePage"
@value={{readonly this.freeProduct.welcomePageURL}}
@value={{readonly this.freeTier.welcomePageURL}}
@baseUrl={{readonly this.siteUrl}}
@setResult={{this.setWelcomePageURL}}
@validateUrl={{this.validateWelcomePageURL}}
@ -164,14 +164,14 @@
<div class="gh-expandable-content">
{{#liquid-if this.paidOpen}}
<div class="gh-setting-content-extended">
{{#if this.fetchDefaultProduct.isRunning}}
{{#if this.fetchDefaultTier.isRunning}}
Loading...
{{else}}
{{#if (feature "multipleProducts")}}
<GhMembershipProductsAlpha
<GhMembershipTiersAlpha
@updatePortalPreview={{this.updatePortalPreview}}
@products={{this.paidProducts}}
@confirmProductSave={{this.confirmProductSave}}
@tiers={{this.paidTiers}}
@confirmTierSave={{this.confirmTierSave}}
/>
{{else}}
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="prices">
@ -266,7 +266,7 @@
@model={{hash
preloadTask=this.saveSettingsTask
openStripeSettings=this.openStripeConnect
products=this.products
tiers=this.tiers
}}
@close={{this.closePortalSettings}}
@modifier="full-overlay portal-settings" />
@ -287,15 +287,15 @@
@close={{this.closeStripeConnect}}
@modifier="action wide stripe-connect" />
{{/if}}
{{#if this.showProductModal}}
{{#if this.showTierModal}}
<GhFullscreenModal
@modal="product"
@modal="tier"
@model={{hash
product=this.productModel
products=this.products
tier=this.tierModel
tiers=this.tiers
}}
@confirm={{this.confirmProductSave}}
@close={{this.closeProductModal}}
@modifier="edit-product action wide" />
@confirm={{this.confirmTierSave}}
@close={{this.closeTierModal}}
@modifier="edit-tier action wide" />
{{/if}}
</section>

View File

@ -1,158 +0,0 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.products">Products</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
{{#if this.product.name}}
{{this.product.name}}
{{else}}
<span class="midlightgrey">New product</span>
{{/if}}
</h2>
<section class="view-actions">
<GhTaskButton @buttonText="Save product"
@task={{this.saveTask}}
@successText="Saved"
@runningText="Saving"
@class="gh-btn gh-btn-primary gh-btn-icon"
data-test-button="save-settings"
/>
</section>
</GhCanvasHeader>
<form>
<section class="gh-main-section">
<div class="gh-main-section-block">
<h4 class="gh-main-section-header small bn">Product details</h4>
<div class="gh-main-section-content grey gh-product-details-form">
<div class="gh-product-details-fields">
<GhFormGroup @errors={{this.product.errors}} @hasValidated={{this.product.hasValidated}} @property="name" @classNames="max-width">
<label for="product-name">Product name</label>
<GhTextInput data-test-input="product-name" @id="product-name" @value={{this.product.name}} />
<GhErrorMessage @errors={{this.product.errors}} @property="name" />
</GhFormGroup>
<GhFormGroup @property="description" @classNames="max-width">
<label for="product-description">Description</label>
<GhTextInput data-test-input="product-description" @value={{this.product.description}} />
<GhErrorMessage @property="description" />
</GhFormGroup>
<GhFormGroup @property="_welcome-page" @classNames="max-width">
<label for="_welcome-page">Welcome page</label>
<GhUrlInput
@value={{readonly this.settings.membersPaidSignupRedirect}}
@baseUrl={{readonly this.siteUrl}}
@setResult={{action "setPaidSignupRedirect"}}
@validateUrl={{action "validatePaidSignupRedirect"}}
@placeholder={{readonly this.siteUrl}}
/>
<p>Redirect to this URL after signup for this product</p>
<GhErrorMessage
@errors={{this.settings.errors}}
@property="membersPaidSignupRedirect"
/>
</GhFormGroup>
</div>
</div>
</div>
</section>
</form>
<div class="gh-main-section">
<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({{this.noOfPrices}})</div>
<div class="gh-list-header">Price</div>
<div class="gh-list-header"></div>
</li>
{{#unless this.stripePrices}}
<tr class="gh-list-row {{if this.price.active "" "gh-price-list-archived"}}">
<td colspan="4" class="gh-list-data">
<div class="gh-price-list-noprices">
<div class="mb2">There are no prices for this product</div>
{{#if this.membersUtils.isStripeEnabled}}
{{#unless this.product.isNew}}
<button type="button" class="gh-btn gh-btn-green" {{action "openNewPrice"}}
disabled={{this.saveTask.isRunning}} >
<span>Add price</span>
</button>
{{/unless}}
{{else}}
You need to <button class="b gh-setting-group" type="button" {{on "click" this.openStripeConnect}}>connect to Stripe</button> to add prices
{{/if}}
</div>
</td>
</tr>
{{/unless}}
{{#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">
<span class="name">{{price.nickname}}</span>
{{#unless price.active}}
<span class="gh-badge archived">Archived</span>
{{/unless}}
</h3>
<p class="ma0 pa0 f8 midgrey gh-price-list-description">
{{price.description}}
</p>
</div>
<div class="gh-list-data gh-price-list-price">
<span>{{currency-symbol price.currency}}{{price.amount}} / {{price.interval}}</span>
</div>
<div class="gh-list-data gh-price-list-actions">
<div class="gh-price-list-actionlist">
<button class="gh-btn gh-btn-link" type="button" {{action "openEditPrice" price}}>
<span>Edit</span>
</button>
{{#if price.active}}
<button class="gh-btn gh-btn-link gh-btn-archive-toggle archived" disabled={{this.saveTask.isRunning}} type="button" {{action "archivePrice" price}}>
<span>Archive</span>
</button>
{{else}}
<button class="gh-btn gh-btn-link gh-btn-archive-toggle" disabled={{this.saveTask.isRunning}} type="button" {{action "activatePrice" price}}>
<span>Activate</span>
</button>
{{/if}}
</div>
</div>
</li>
{{/each}}
</ol>
{{#if this.stripePrices}}
{{#unless this.product.isNew}}
<button type="button" class="gh-btn gh-btn-green" {{action "openNewPrice"}}>
<span>Add price</span>
</button>
{{/unless}}
{{/if}}
</div>
</div>
{{#if this.showPriceModal}}
<GhFullscreenModal
@modal="product-price"
@model={{hash
price=this.priceModel
}}
@confirm={{action "savePrice"}}
@close={{this.closePriceModal}}
@modifier="action wide product-ssprice" />
{{/if}}
{{#if this.showUnsavedChangesModal}}
<GhFullscreenModal
@modal="leave-settings"
@confirm={{this.leaveScreen}}
@close={{this.toggleUnsavedChangesModal}}
@modifier="action wide" />
{{/if}}
</section>

View File

@ -1,48 +0,0 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Products
</h2>
</GhCanvasHeader>
<section class="view-container">
<div class="gh-product-list">
<div class="gh-product-card">
<span class="gh-product-list-icon">{{svg-jar "members"}}</span>
<h3 class="gh-product-card-name">
Free membership
</h3>
<p class="gh-product-card-description">
{{this.settings.membersFreePriceDescription}}
</p>
<LinkTo @route="settings.products" class="gh-btn" {{action (toggle "showFreeMembershipModal" this)}}>
<span>Customize</span>
</LinkTo>
</div>
{{#each this.products as |product|}}
<div class="gh-product-card">
<span class="gh-product-list-icon"><div class="gh-product-list-siteicon" style={{this.iconStyle}}></div></span>
<h3 class="gh-product-card-name">
{{product.name}}
</h3>
<p class="gh-product-card-description">
{{product.description}}
</p>
<LinkTo @route="settings.product" @model={{product}} class="gh-btn">
<span>Customize</span>
</LinkTo>
</div>
{{/each}}
</div>
</section>
</section>
{{#if this.showFreeMembershipModal}}
<GhFullscreenModal
@modal="free-membership-settings"
@close={{this.closeFreeMembershipModal}}
@modifier="action wide product-ssprice" />
{{/if}}

View File

@ -1,14 +1,14 @@
import MemberProduct from 'ghost-admin/models/member-product';
import MemberTier from 'ghost-admin/models/member-tier';
import Transform from '@ember-data/serializer/transform';
import {A as emberA, isArray as isEmberArray} from '@ember/array';
export default class MemberProductTransform extends Transform {
export default class MemberTierTransform extends Transform {
deserialize(serialized) {
let subscriptions, subscriptionArray;
subscriptionArray = serialized || [];
subscriptions = subscriptionArray.map(itemDetails => MemberProduct.create(itemDetails));
subscriptions = subscriptionArray.map(itemDetails => MemberTier.create(itemDetails));
return emberA(subscriptions);
}
@ -27,4 +27,3 @@ export default class MemberProductTransform extends Transform {
return subscriptionArray;
}
}

View File

@ -1,15 +1,15 @@
import ProductBenefitItem from '../models/product-benefit-item';
import TierBenefitItem from '../models/tier-benefit-item';
import Transform from '@ember-data/serializer/transform';
import {A as emberA, isArray as isEmberArray} from '@ember/array';
export default class ProductBenefits extends Transform {
export default class TierBenefits extends Transform {
deserialize(serialized) {
let benefitsItems, benefitsArray;
benefitsArray = serialized || [];
benefitsItems = benefitsArray.map((itemDetails) => {
return ProductBenefitItem.create(itemDetails);
return TierBenefitItem.create(itemDetails);
});
return emberA(benefitsItems);

View File

@ -12,7 +12,6 @@ import mockNewsletters from './config/newsletters';
import mockOffers from './config/offers';
import mockPages from './config/pages';
import mockPosts from './config/posts';
import mockProducts from './config/products';
import mockRoles from './config/roles';
import mockSettings from './config/settings';
import mockSite from './config/site';
@ -20,6 +19,7 @@ import mockSlugs from './config/slugs';
import mockSnippets from './config/snippets';
import mockTags from './config/tags';
import mockThemes from './config/themes';
import mockTiers from './config/tiers';
import mockUploads from './config/uploads';
import mockUsers from './config/users';
import mockWebhooks from './config/webhooks';
@ -77,7 +77,7 @@ export function testConfig() {
mockUploads(this);
mockUsers(this);
mockWebhooks(this);
mockProducts(this);
mockTiers(this);
mockOffers(this);
mockSnippets(this);
mockNewsletters(this);

View File

@ -81,8 +81,8 @@ export default function mockMembers(server) {
replacement: 'labels.slug'
},
{
key: 'product',
replacement: 'products.slug'
key: 'tier',
replacement: 'tiers.slug'
}
]
});
@ -97,7 +97,7 @@ export default function mockMembers(server) {
});
// similar deal for associated models
['labels', 'products', 'subscriptions', 'newsletters'].forEach((association) => {
['labels', 'tiers', 'subscriptions', 'newsletters'].forEach((association) => {
serializedMember[association] = [];
member[association].models.forEach((associatedModel) => {
@ -177,22 +177,22 @@ export default function mockMembers(server) {
});
});
server.put('/members/:id/', function ({members, products, subscriptions}, {params}) {
server.put('/members/:id/', function ({members, tiers, subscriptions}, {params}) {
const attrs = this.normalizedRequestAttrs();
const member = members.find(params.id);
// API accepts `products: [{id: 'x'}]` which isn't handled natively by mirage
if (attrs.products.length > 0) {
attrs.products.forEach((p) => {
const product = products.find(p.id);
// API accepts `tiers: [{id: 'x'}]` which isn't handled natively by mirage
if (attrs.tiers.length > 0) {
attrs.tiers.forEach((p) => {
const tier = tiers.find(p.id);
if (!member.products.includes(product)) {
// TODO: serialize products through _active_ subscriptions
member.products.add(product);
if (!member.tiers.includes(tier)) {
// TODO: serialize tiers through _active_ subscriptions
member.tiers.add(tier);
subscriptions.create({
member,
product,
tier,
comped: true,
plan: {
id: '',
@ -215,9 +215,9 @@ export default function mockMembers(server) {
interval: 'year',
type: 'recurring',
currency: 'USD',
product: {
tier: {
id: '',
product_id: product.id
tier_id: tier.id
}
},
offer: null
@ -228,20 +228,20 @@ export default function mockMembers(server) {
});
}
const productIds = (attrs.products || []).map(p => p.id);
const tierIds = (attrs.tiers || []).map(p => p.id);
member.products.models.forEach((product) => {
if (!productIds.includes(product.id)) {
member.subscriptions.models.filter(sub => sub.product.id === product.id).forEach((sub) => {
member.tiers.models.forEach((tier) => {
if (!tierIds.includes(tier.id)) {
member.subscriptions.models.filter(sub => sub.tier.id === tier.id).forEach((sub) => {
member.subscriptions.remove(sub);
});
member.products.remove(product);
member.tiers.remove(tier);
}
});
// these are read-only properties so make sure we don't overwrite data
delete attrs.products;
delete attrs.tiers;
delete attrs.subscriptions;
return member.update(attrs);

View File

@ -7,9 +7,9 @@ export default function mockOffers(server) {
server.get('/offers/:id/', function ({offers}, {params}) {
let {id} = params;
let product = offers.find(id);
let tier = offers.find(id);
return product || new Response(404, {}, {
return tier || new Response(404, {}, {
errors: [{
type: 'NotFoundError',
message: 'Offer not found.'

View File

@ -1,44 +0,0 @@
import {paginatedResponse} from '../utils';
export default function mockProducts(server) {
server.post('/products/');
server.get('/products/', paginatedResponse('products'));
server.get('/products/:id/', function ({products}, {params}) {
let {id} = params;
let product = products.find(id);
return product || new Response(404, {}, {
errors: [{
type: 'NotFoundError',
message: 'Product not found.'
}]
});
});
server.put('/products/:id/', function ({products, productBenefits}, {params}) {
const attrs = this.normalizedRequestAttrs();
const product = products.find(params.id);
const benefitAttrs = attrs.benefits;
delete attrs.benefits;
product.update(attrs);
benefitAttrs.forEach((benefit) => {
if (benefit.id) {
const productBenefit = productBenefits.find(benefit.id);
productBenefit.product = product;
productBenefit.save();
} else {
product.createProductBenefit(benefit);
product.save();
}
});
return product.save();
});
server.del('/products/:id/');
}

View File

@ -0,0 +1,44 @@
import {paginatedResponse} from '../utils';
export default function mockTiers(server) {
server.post('/tiers/');
server.get('/tiers/', paginatedResponse('tiers'));
server.get('/tiers/:id/', function ({tiers}, {params}) {
let {id} = params;
let tier = tiers.find(id);
return tier || new Response(404, {}, {
errors: [{
type: 'NotFoundError',
message: 'Tier not found.'
}]
});
});
server.put('/tiers/:id/', function ({tiers, tierBenefits}, {params}) {
const attrs = this.normalizedRequestAttrs();
const tier = tiers.find(params.id);
const benefitAttrs = attrs.benefits;
delete attrs.benefits;
tier.update(attrs);
benefitAttrs.forEach((benefit) => {
if (benefit.id) {
const tierBenefit = tierBenefits.find(benefit.id);
tierBenefit.tier = tier;
tierBenefit.save();
} else {
tier.createTierBenefit(benefit);
tier.save();
}
});
return tier.save();
});
server.del('/tiers/:id/');
}

View File

@ -1,10 +1,10 @@
import {Factory} from 'miragejs';
export default Factory.extend({
name(i) { return `Product ${i}`; },
description(i) { return `Description for product ${i}`; },
name(i) { return `Tier ${i}`; },
description(i) { return `Description for tier ${i}`; },
active: true,
slug(i) { return `product-${i}`;},
slug(i) { return `tier-${i}`;},
type: 'paid',
visibility: 'none',
monthly_price() {

View File

@ -177,7 +177,7 @@ export default [
id: 23,
group: 'members',
key: 'members_subscription_settings',
value: '{"allowSelfSignup":true,"fromAddress":"noreply","paymentProcessors":[{"adapter":"stripe","config":{"secret_token":"","public_token":"","product":{"name":"Ghost Subscription"},"plans":[{"name":"Monthly","currency":"usd","interval":"month","amount":""},{"name":"Yearly","currency":"usd","interval":"year","amount":""}]}}]}',
value: '{"allowSelfSignup":true,"fromAddress":"noreply","paymentProcessors":[{"adapter":"stripe","config":{"secret_token":"","public_token":"","tier":{"name":"Ghost Subscription"},"plans":[{"name":"Monthly","currency":"usd","interval":"month","amount":""},{"name":"Yearly","currency":"usd","interval":"year","amount":""}]}}]}',
created_at: '2019-10-09T09:49:00.000Z',
created_by: 1,
updated_at: '2019-10-09T09:49:00.000Z',

View File

@ -22,8 +22,8 @@ export default [
createdAt: '2022-02-04T13:11:40.000Z',
description: null,
monthlyPrice: null,
name: 'Default Product',
slug: 'default-product',
name: 'Default Tier',
slug: 'default-tier',
type: 'paid',
visibility: 'public',
updatedAt: '2022-02-04T13:11:40.000Z',

View File

@ -3,7 +3,7 @@ import {Model, hasMany} from 'miragejs';
export default Model.extend({
labels: hasMany(),
emailRecipients: hasMany(),
products: hasMany(),
tiers: hasMany(),
newsletters: hasMany(),
subscriptions: hasMany()
});

View File

@ -2,5 +2,5 @@ import {Model, belongsTo} from 'miragejs';
export default Model.extend({
member: belongsTo(),
product: belongsTo()
tier: belongsTo()
});

View File

@ -1,5 +1,5 @@
import {Model, belongsTo} from 'miragejs';
export default Model.extend({
product: belongsTo('product')
tier: belongsTo('tier')
});

View File

@ -3,6 +3,6 @@ import {Model, hasMany} from 'miragejs';
export default Model.extend({
// ran into odd relationship bugs when called `benefits`
// serializer will rename to `benefits`
productBenefits: hasMany(),
tierBenefits: hasMany(),
members: hasMany()
});

View File

@ -7,13 +7,13 @@ export default BaseSerializer.extend({
include(/*request*/) {
let includes = [];
includes.push('product');
includes.push('tier');
return includes;
},
keyForEmbeddedRelationship(relationshipName) {
if (relationshipName === 'product') {
if (relationshipName === 'tier') {
return 'tier';
}

View File

@ -7,13 +7,13 @@ export default BaseSerializer.extend({
include(/*request*/) {
let includes = [];
includes.push('productBenefits');
includes.push('tierBenefits');
return includes;
},
keyForEmbeddedRelationship(relationshipName) {
if (relationshipName === 'productBenefits') {
if (relationshipName === 'tierBenefits') {
return 'benefits';
}

View File

@ -13,7 +13,7 @@ describe('Acceptance: Member details', function () {
setupMirage(hooks);
let clock;
let product;
let tier;
beforeEach(async function () {
this.server.loadFixtures('configs');
@ -25,8 +25,8 @@ describe('Acceptance: Member details', function () {
enableStripe(this.server);
enableNewsletters(this.server, true);
// add a default product that complimentary plans can be assigned to
product = this.server.create('product', {
// add a default tier that complimentary plans can be assigned to
tier = this.server.create('tier', {
id: '6213b3f6cb39ebdb03ebd810',
name: 'Ghost Subscription',
slug: 'ghost-subscription',
@ -82,17 +82,17 @@ describe('Acceptance: Member details', function () {
interval: 'month',
type: 'recurring',
currency: 'USD',
product: {
tier: {
id: 'prod_LFmAAmCnnbzrvL',
name: 'Ghost Subscription',
product_id: product.id
tier_id: tier.id
}
},
offer: null
}),
this.server.create('subscription', {
id: 'sub_1KZGi6EGb07FFvyNDjZq98g8',
product,
tier,
customer: {
id: 'cus_LFmGicpX4BkQKH',
name: '123',
@ -119,17 +119,17 @@ describe('Acceptance: Member details', function () {
interval: 'month',
type: 'recurring',
currency: 'USD',
product: {
tier: {
id: 'prod_LFmAAmCnnbzrvL',
name: 'Ghost Subscription',
product_id: product.id
tier_id: tier.id
}
},
offer: null
})
],
products: [
product
tiers: [
tier
]
});
@ -149,7 +149,7 @@ describe('Acceptance: Member details', function () {
subscriptions: [
this.server.create('subscription', {
id: 'sub_1KZGcmEGb07FFvyN9jwrwbKu',
product,
tier,
customer: {
id: 'cus_LFmBWoSkB84lnr',
name: 'test',
@ -176,16 +176,16 @@ describe('Acceptance: Member details', function () {
interval: 'month',
type: 'recurring',
currency: 'USD',
product: {
tier: {
id: 'prod_LFmAAmCnnbzrvL',
name: 'Ghost Subscription',
product_id: '6213b3f6cb39ebdb03ebd810'
tier_id: '6213b3f6cb39ebdb03ebd810'
}
},
offer: null
})
],
products: []
tiers: []
});
await visit(`/members/${member.id}`);
@ -205,17 +205,17 @@ describe('Acceptance: Member details', function () {
.to.equal(1);
await click('[data-test-button="add-complimentary"]');
expect(find('[data-test-modal="member-product"]'), 'select product modal').to.exist;
expect(find('[data-test-modal="member-tier"]'), 'select tier modal').to.exist;
expect(find('[data-test-text="select-tier-desc"]')).to.contain.text('Comp Member Test');
expect(find('[data-test-tier-option="6213b3f6cb39ebdb03ebd810"]')).to.have.exist;
expect(find('[data-test-tier-option="6213b3f6cb39ebdb03ebd810"]')).to.have.class('active');
await click('[data-test-button="save-comp-product"]');
await click('[data-test-button="save-comp-tier"]');
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after add comped')
.to.equal(1);
await click('[data-test-product="6213b3f6cb39ebdb03ebd810"] [data-test-button="subscription-actions"]');
await click('[data-test-product="6213b3f6cb39ebdb03ebd810"] [data-test-button="remove-complimentary"]');
await click('[data-test-tier="6213b3f6cb39ebdb03ebd810"] [data-test-button="subscription-actions"]');
await click('[data-test-tier="6213b3f6cb39ebdb03ebd810"] [data-test-button="remove-complimentary"]');
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after remove comped')
.to.equal(0);
@ -226,13 +226,13 @@ describe('Acceptance: Member details', function () {
name: 'Comped for canceled sub test',
subscriptions: [
this.server.create('subscription', {
// product, // _Not_ included as `tier` when subscription is canceled
// tier, // _Not_ included as `tier` when subscription is canceled
status: 'canceled',
price: {
id: 'price_1',
product: {
tier: {
id: 'prod_1',
product_id: product.id
tier_id: tier.id
}
}
})
@ -245,7 +245,7 @@ describe('Acceptance: Member details', function () {
.to.equal(1);
await click('[data-test-button="add-complimentary"]');
await click('[data-test-button="save-comp-product"]');
await click('[data-test-button="save-comp-tier"]');
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after add comped')
.to.equal(2);
@ -253,10 +253,10 @@ describe('Acceptance: Member details', function () {
.to.equal(0);
});
it('handles multiple products', async function () {
const product2 = this.server.create('product', {
name: 'Second product',
slug: 'second-product',
it('handles multiple tiers', async function () {
const tier2 = this.server.create('tier', {
name: 'Second tier',
slug: 'second-tier',
created_at: '2022-02-21T16:47:02.000Z',
updated_at: '2022-03-03T15:37:02.000Z',
description: null,
@ -267,32 +267,32 @@ describe('Acceptance: Member details', function () {
welcome_page_url: '/'
});
const member = this.server.create('member', {name: 'Multiple product test'});
const member = this.server.create('member', {name: 'Multiple tier test'});
this.server.create('subscription', {member, product, status: 'canceled', price: {id: '1', product: {product_id: product.id}}});
this.server.create('subscription', {member, product, status: 'canceled', price: {id: '1', product: {product_id: product.id}}});
this.server.create('subscription', {member, product: product2, status: 'canceled', price: {id: '1', product: {product_id: product2.id}}});
this.server.create('subscription', {member, tier, status: 'canceled', price: {id: '1', tier: {tier_id: tier.id}}});
this.server.create('subscription', {member, tier, status: 'canceled', price: {id: '1', tier: {tier_id: tier.id}}});
this.server.create('subscription', {member, tier: tier2, status: 'canceled', price: {id: '1', tier: {tier_id: tier2.id}}});
await visit(`/members/${member.id}`);
// all products and subscriptions are shown
expect(findAll('[data-test-product]').length, '# of product blocks').to.equal(2);
// all tiers and subscriptions are shown
expect(findAll('[data-test-tier]').length, '# of tier blocks').to.equal(2);
const p1 = `[data-test-product="${product.id}"]`;
const p2 = `[data-test-product="${product2.id}"]`;
const p1 = `[data-test-tier="${tier.id}"]`;
const p2 = `[data-test-tier="${tier2.id}"]`;
expect(find(`${p1} [data-test-text="product-name"]`)).to.contain.text('Ghost Subscription');
expect(findAll(`${p1} [data-test-subscription]`).length, '# of product 1 subscription blocks').to.equal(2);
expect(find(`${p1} [data-test-text="tier-name"]`)).to.contain.text('Ghost Subscription');
expect(findAll(`${p1} [data-test-subscription]`).length, '# of tier 1 subscription blocks').to.equal(2);
expect(find(`${p2} [data-test-text="product-name"]`)).to.contain.text('Second product');
expect(findAll(`${p2} [data-test-subscription]`).length, '# of product 2 subscription blocks').to.equal(1);
expect(find(`${p2} [data-test-text="tier-name"]`)).to.contain.text('Second tier');
expect(findAll(`${p2} [data-test-subscription]`).length, '# of tier 2 subscription blocks').to.equal(1);
// can add complimentary
expect(findAll('[data-test-button="add-complimentary"]').length, '# of add-complimentary buttons').to.equal(1);
await click('[data-test-button="add-complimentary"]');
await click(`[data-test-tier-option="${product2.id}"]`);
await click('[data-test-button="save-comp-product"]');
await click(`[data-test-tier-option="${tier2.id}"]`);
await click('[data-test-button="save-comp-tier"]');
expect(findAll(`${p2} [data-test-subscription]`).length, '# of product 2 subscription blocks after comp added').to.equal(2);
expect(findAll(`${p2} [data-test-subscription]`).length, '# of tier 2 subscription blocks after comp added').to.equal(2);
});
});

View File

@ -116,11 +116,11 @@ describe('Acceptance: Members filtering', function () {
it('can filter by tier', async function () {
// add some labels to test the selection dropdown
const newsletter = this.server.create('newsletter', {status: 'active'});
this.server.createList('product', 4);
this.server.createList('tier', 4);
// add a labelled member so we can test the filter includes correctly
const product = this.server.create('product');
this.server.createList('member', 3, {products: [product], newsletters: [newsletter]});
const tier = this.server.create('tier');
this.server.createList('member', 3, {tiers: [tier], newsletters: [newsletter]});
// add some non-labelled members so we can see the filter excludes correctly
this.server.createList('member', 4, {newsletters: [newsletter]});
@ -133,7 +133,7 @@ describe('Acceptance: Members filtering', function () {
const filterSelector = `[data-test-members-filter="0"]`;
await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'product');
await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'tier');
// has the right operators
const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`);
@ -146,14 +146,14 @@ describe('Acceptance: Members filtering', function () {
expect(findAll(`${filterSelector} [data-test-tiers-segment]`).length, '# of label options').to.equal(5);
// selecting a value updates table
await selectChoose(`${filterSelector} .gh-tier-token-input`, product.name);
await selectChoose(`${filterSelector} .gh-tier-token-input`, tier.name);
expect(findAll('[data-test-list="members-list-item"]').length, `# of filtered member rows - ${product.name}`)
expect(findAll('[data-test-list="members-list-item"]').length, `# of filtered member rows - ${tier.name}`)
.to.equal(3);
// table shows labels column+data
expect(find('[data-test-table-column="product"]')).to.exist;
expect(findAll('[data-test-table-data="product"]').length).to.equal(3);
expect(find('[data-test-table-data="product"]')).to.contain.text(product.name);
expect(find('[data-test-table-column="tier"]')).to.exist;
expect(findAll('[data-test-table-data="tier"]').length).to.equal(3);
expect(find('[data-test-table-data="tier"]')).to.contain.text(tier.name);
// can delete filter
await click('[data-test-delete-members-filter="0"]');

Some files were not shown because too many files have changed in this diff Show More