mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 09:03:12 +03:00
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:
parent
182cd106e5
commit
aa35d99de4
@ -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">
|
||||
|
@ -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);
|
||||
|
@ -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>
|
@ -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
|
||||
}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -199,6 +199,7 @@
|
||||
|
||||
.modal-body p {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
|
@ -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;
|
||||
}
|
@ -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
|
||||
/* ---------------------------------------------------------- */
|
||||
|
1
ghost/admin/public/assets/icons/check.svg
Normal file
1
ghost/admin/public/assets/icons/check.svg
Normal 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 |
Loading…
Reference in New Issue
Block a user