Add UI for complimentary products with multiple products feature (#2008)

* Added v1 comped subscription handling

* Cleanup

* Added cancellation for existing subscriptions

* Added loader for fetching products

* Refined complimentary popup

* Added default product selection

* Updated add complimentary for multiple products

* Updated products add comped button

Co-authored-by: Peter Zimon <zimo@ghost.org>
This commit is contained in:
Rishabh Garg 2021-06-17 13:09:43 +05:30 committed by GitHub
parent 182cd106e5
commit aa35d99de4
8 changed files with 144 additions and 115 deletions

View File

@ -197,20 +197,39 @@
</span>
</div>
{{/each}}
{{#if this.isAddComplimentaryAllowed}}
<div class="gh-memberproduct-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" {{action "addCompedSubscription"}}>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
</div>
{{#if (not (feature "multipleProducts"))}}
{{#if this.isAddComplimentaryAllowed}}
<div class="gh-memberproduct-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" {{action "addCompedSubscription"}}>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
</div>
{{/if}}
{{/if}}
</div>
</div>
{{/each}}
{{#if (feature "multipleProducts")}}
{{#if (and this.products this.isAddComplimentaryAllowed)}}
<div class="gh-memberproduct-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"
{{action (toggle "showMemberProductModal" this)}}
>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
</div>
{{/if}}
{{/if}}
</div>
<div class="gh-main-section-block">
<div class="gh-main-section-content bordered">

View File

@ -15,6 +15,8 @@ export default class extends Component {
ajax
@service
store
@service
feature
constructor(...args) {
super(...args);
@ -33,6 +35,9 @@ export default class extends Component {
if (!this.membersUtils.isStripeEnabled) {
return false;
}
if (this.feature.get('multipleProducts')) {
return true;
}
let subscriptions = this.member.get('subscriptions') || [];
const hasZeroPriceSub = subscriptions.filter((sub) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(sub.status);

View File

@ -1,72 +1,59 @@
<header class="modal-header" data-test-modal="delete-user">
<header class="modal-header" data-test-modal="delete-user" {{did-insert this.setup}}>
<h1>Add subscription</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<a class="close" href="" role="button" title="Close" {{action "closeModal" }}>{{svg-jar
"close"}}<span class="hidden">Close</span></a>
<form>
<div class="modal-body">
<p class="gh-member-addcomp-subhed">Select a product for <strong>{{or this.member.name this.member.email}}</strong>'s complimentary subscription.</p>
<div class="form-rich-radio">
<div class="gh-radio active">
<div class="gh-radio-content">
<div class="gh-radio-label">
<div class="description">
<h4>Bronze</h4>
<p>Only the hottest marketing news</p>
</div>
</div>
<div class="modal-body">
<p class="gh-member-addcomp-subhed">Select a product for <strong>{{or
this.member.name this.member.email}}</strong>'s complimentary
subscription.</p>
{{#if this.activeSubscriptions.length}}
<p class="gh-member-addcomp-warning">
Adding a complimentary subscription cancels all existing subscriptions of this member.
</p>
{{/if}}
{{#if this.loadingProducts}}
<div class="flex justify-center flex-auto">
<div class="gh-loading-spinner"> </div>
</div>
<div class="gh-radio-button"></div>
</div>
<div class="gh-radio">
<div class="gh-radio-content">
<div class="gh-radio-label">
<div class="description">
<h4>Silver</h4>
<p>Extra weekly newsletter</p>
{{else}}
<div class="form-rich-radio">
{{#each this.products as |product|}}
<div class="gh-radio {{if (eq this.selectedProduct product.id) "active"}}" {{on "click" (fn this.setProduct product.id)}}>
<div class="gh-radio-content">
<div class="gh-radio-label">
<div class="description">
<h4>{{product.name}}</h4>
<p>{{product.description}}</p>
</div>
{{svg-jar "check" class="check"}}
</div>
</div>
<div class="gh-radio-button"></div>
</div>
</div>
{{/each}}
</div>
<div class="gh-radio-button"></div>
</div>
<div class="gh-radio">
<div class="gh-radio-content">
<div class="gh-radio-label">
<div class="description">
<h4>Gold</h4>
<p>All-in-one supporter pack!</p>
</div>
</div>
</div>
<div class="gh-radio-button"></div>
</div>
{{/if}}
</div>
</div>
</form>
<div class="modal-footer">
<button
class="gh-btn"
{{action "closeModal"}}
{{!-- disable mouseDown so it does not trigger focus-out validations --}}
{{action (optional this.noop) on="mouseDown"}}
data-test-button="cancel-webhook"
>
{{action "closeModal" }}
{{!-- disable mouseDown so it does not trigger focus-out validations
--}}
{{action (optional this.noop) on="mouseDown" }}
data-test-button="cancel-webhook">
<span>Cancel</span>
</button>
<button
class="gh-btn gh-btn-green"
{{action "closeModal"}}
{{!-- disable mouseDown so it does not trigger focus-out validations --}}
{{action (optional this.noop) on="mouseDown"}}
>
<span>Add subscription</span>
</button>
{{!-- <GhTaskButton @buttonText="Add subscription"
@successText={{this.successText}}
@task={{this.addPriceTask}}
@disabled={{this.cannotAddPrice}}
<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-webhook" /> --}}
data-test-button="save-comp-product" />
</div>

View File

@ -1,6 +1,5 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {action} from '@ember/object';
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
@ -24,15 +23,26 @@ export default class ModalMemberProduct extends ModalComponent {
@tracked
products = []
constructor(...args) {
super(...args);
this.fetchProducts();
@tracked
selectedProduct = null;
@tracked
loadingProducts = false;
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {include: 'monthly_price,yearly_price'});
this.loadingProducts = false;
if (this.products.length > 0) {
this.selectedProduct = this.products.firstObject.id;
}
}
async fetchProducts() {
this.products = await this.store.query('product', {include: 'stripe_prices'});
this.product = this.products.firstObject;
this.price = this.prices ? this.prices[0] : null;
get activeSubscriptions() {
const subscriptions = this.member.get('subscriptions') || [];
return subscriptions.filter((sub) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(sub.status);
});
}
get member() {
@ -43,45 +53,15 @@ export default class ModalMemberProduct extends ModalComponent {
return !this.price || this.price.amount !== 0;
}
get prices() {
if (!this.products || !this.products.length) {
return [];
}
if (this.product) {
let subscriptions = this.member.get('subscriptions') || [];
let activeCurrency;
if (subscriptions.length > 0) {
activeCurrency = subscriptions[0].price?.currency;
}
const product = this.products.find((_product) => {
return _product.id === this.product.id;
});
return product.stripePrices.sort((a, b) => {
return a.amount - b.amount;
}).filter((price) => {
return price.active;
}).filter((price) => {
if (activeCurrency) {
return price.currency?.toLowerCase() === activeCurrency.toLowerCase();
}
return true;
}).sort((a, b) => {
return a.currency.localeCompare(b.currency, undefined, {ignorePunctuation: true});
}).map((price) => {
return {
...price,
label: `${price.nickname} (${getSymbol(price.currency)}${getNonDecimal(price.amount)}/${price.interval})`
};
});
} else {
return [];
}
@action
setup() {
this.loadingProducts = true;
this.fetchProducts.perform();
}
@action
setProduct(product) {
this.product = product;
setProduct(productId) {
this.selectedProduct = productId;
}
@action
@ -92,12 +72,27 @@ export default class ModalMemberProduct extends ModalComponent {
@task({
drop: true
})
*addPriceTask() {
let url = this.ghostPaths.url.api('members', this.member.get('id'), 'subscriptions');
let response = yield this.ajax.post(url, {
*addProduct() {
let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`);
// Cancel existing active subscriptions for member
for (let i = 0; i < this.activeSubscriptions.length; i++) {
const subscription = this.activeSubscriptions[i];
const cancelUrl = this.ghostPaths.url.api(`members/${this.member.get('id')}/subscriptions/${subscription.id}`);
yield this.ajax.put(cancelUrl, {
data: {
status: 'canceled'
}
});
}
let response = yield this.ajax.put(url, {
data: {
stripe_price_id: this.price.stripe_price_id
members: [{
id: this.member.get('id'),
email: this.member.get('email'),
products: [{
id: this.selectedProduct
}]
}]
}
});

View File

@ -199,6 +199,7 @@
.modal-body p {
font-size: 1.4rem;
line-height: 1.5em;
}
.modal-footer {

View File

@ -1787,3 +1787,7 @@ p.gh-members-import-errordetail:first-of-type {
.gh-member-product-form-block .form-group:last-of-type {
margin: 0;
}
.gh-member-addcomp-warning {
margin-top: -16px;
}

View File

@ -795,6 +795,7 @@ textarea {
padding: 12px 12px 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.form-rich-radio .gh-radio-label .description h4 {
@ -806,13 +807,29 @@ textarea {
}
.form-rich-radio .gh-radio-label .description p {
font-size: 1.25rem !important;
font-size: 1.3rem !important;
font-weight: 400;
line-height: 1.45em;
margin: 2px 0 0;
margin: 0;
padding: 0;
}
.form-rich-radio .check {
color: var(--darkgrey);
width: 14px;
height: 14px;
margin-right: 4px;
overflow: visible;
}
.form-rich-radio .check path {
stroke-width: 2px;
}
.form-rich-radio .gh-radio:not(.active) .check {
display: none;
}
/* FFF: Fucking Firefox Fixes
/* ---------------------------------------------------------- */

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><title>check-1</title><path class="a" d="M23.25.749,8.158,22.308a2.2,2.2,0,0,1-3.569.059L.75,17.249"/></svg>

After

Width:  |  Height:  |  Size: 292 B