mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05: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
8 changed files with 144 additions and 115 deletions
|
@ -197,20 +197,39 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#if this.isAddComplimentaryAllowed}}
|
{{#if (not (feature "multipleProducts"))}}
|
||||||
<div class="gh-memberproduct-list-footer {{if this.isCreatingComplimentary "min-height" ""}}">
|
{{#if this.isAddComplimentaryAllowed}}
|
||||||
{{#if this.isCreatingComplimentary}}
|
<div class="gh-memberproduct-list-footer {{if this.isCreatingComplimentary "min-height" ""}}">
|
||||||
<GhLoadingSpinner />
|
{{#if this.isCreatingComplimentary}}
|
||||||
{{else}}
|
<GhLoadingSpinner />
|
||||||
<button type="button" class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct" {{action "addCompedSubscription"}}>
|
{{else}}
|
||||||
<span>{{svg-jar "add"}} Add complimentary subscription</span>
|
<button type="button" class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct" {{action "addCompedSubscription"}}>
|
||||||
</button>
|
<span>{{svg-jar "add"}} Add complimentary subscription</span>
|
||||||
{{/if}}
|
</button>
|
||||||
</div>
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/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>
|
||||||
<div class="gh-main-section-block">
|
<div class="gh-main-section-block">
|
||||||
<div class="gh-main-section-content bordered">
|
<div class="gh-main-section-content bordered">
|
||||||
|
|
|
@ -15,6 +15,8 @@ export default class extends Component {
|
||||||
ajax
|
ajax
|
||||||
@service
|
@service
|
||||||
store
|
store
|
||||||
|
@service
|
||||||
|
feature
|
||||||
|
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
@ -33,6 +35,9 @@ export default class extends Component {
|
||||||
if (!this.membersUtils.isStripeEnabled) {
|
if (!this.membersUtils.isStripeEnabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (this.feature.get('multipleProducts')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let subscriptions = this.member.get('subscriptions') || [];
|
let subscriptions = this.member.get('subscriptions') || [];
|
||||||
const hasZeroPriceSub = subscriptions.filter((sub) => {
|
const hasZeroPriceSub = subscriptions.filter((sub) => {
|
||||||
return ['active', 'trialing', 'unpaid', 'past_due'].includes(sub.status);
|
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>
|
<h1>Add subscription</h1>
|
||||||
</header>
|
</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>
|
<form>
|
||||||
<div class="modal-body">
|
<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>
|
<p class="gh-member-addcomp-subhed">Select a product for <strong>{{or
|
||||||
<div class="form-rich-radio">
|
this.member.name this.member.email}}</strong>'s complimentary
|
||||||
<div class="gh-radio active">
|
subscription.</p>
|
||||||
<div class="gh-radio-content">
|
{{#if this.activeSubscriptions.length}}
|
||||||
<div class="gh-radio-label">
|
<p class="gh-member-addcomp-warning">
|
||||||
<div class="description">
|
Adding a complimentary subscription cancels all existing subscriptions of this member.
|
||||||
<h4>Bronze</h4>
|
</p>
|
||||||
<p>Only the hottest marketing news</p>
|
{{/if}}
|
||||||
</div>
|
{{#if this.loadingProducts}}
|
||||||
</div>
|
<div class="flex justify-center flex-auto">
|
||||||
|
<div class="gh-loading-spinner"> </div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gh-radio-button"></div>
|
{{else}}
|
||||||
</div>
|
<div class="form-rich-radio">
|
||||||
<div class="gh-radio">
|
{{#each this.products as |product|}}
|
||||||
<div class="gh-radio-content">
|
<div class="gh-radio {{if (eq this.selectedProduct product.id) "active"}}" {{on "click" (fn this.setProduct product.id)}}>
|
||||||
<div class="gh-radio-label">
|
<div class="gh-radio-content">
|
||||||
<div class="description">
|
<div class="gh-radio-label">
|
||||||
<h4>Silver</h4>
|
<div class="description">
|
||||||
<p>Extra weekly newsletter</p>
|
<h4>{{product.name}}</h4>
|
||||||
|
<p>{{product.description}}</p>
|
||||||
|
</div>
|
||||||
|
{{svg-jar "check" class="check"}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gh-radio-button"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
<div class="gh-radio-button"></div>
|
{{/if}}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
class="gh-btn"
|
class="gh-btn"
|
||||||
{{action "closeModal"}}
|
{{action "closeModal" }}
|
||||||
{{!-- disable mouseDown so it does not trigger focus-out validations --}}
|
{{!-- disable mouseDown so it does not trigger focus-out validations
|
||||||
{{action (optional this.noop) on="mouseDown"}}
|
--}}
|
||||||
data-test-button="cancel-webhook"
|
{{action (optional this.noop) on="mouseDown" }}
|
||||||
>
|
data-test-button="cancel-webhook">
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</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}}
|
<GhTaskButton @buttonText="Add subscription"
|
||||||
@task={{this.addPriceTask}}
|
@successText={{"Added"}}
|
||||||
@disabled={{this.cannotAddPrice}}
|
@task={{this.addProduct}}
|
||||||
@class="gh-btn gh-btn-green gh-btn-icon gh-btn-add-memberproduct"
|
@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>
|
</div>
|
|
@ -1,6 +1,5 @@
|
||||||
import ModalComponent from 'ghost-admin/components/modal-base';
|
import ModalComponent from 'ghost-admin/components/modal-base';
|
||||||
import {action} from '@ember/object';
|
import {action} from '@ember/object';
|
||||||
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
|
|
||||||
import {inject as service} from '@ember/service';
|
import {inject as service} from '@ember/service';
|
||||||
import {task} from 'ember-concurrency-decorators';
|
import {task} from 'ember-concurrency-decorators';
|
||||||
import {tracked} from '@glimmer/tracking';
|
import {tracked} from '@glimmer/tracking';
|
||||||
|
@ -24,15 +23,26 @@ export default class ModalMemberProduct extends ModalComponent {
|
||||||
@tracked
|
@tracked
|
||||||
products = []
|
products = []
|
||||||
|
|
||||||
constructor(...args) {
|
@tracked
|
||||||
super(...args);
|
selectedProduct = null;
|
||||||
this.fetchProducts();
|
|
||||||
|
@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() {
|
get activeSubscriptions() {
|
||||||
this.products = await this.store.query('product', {include: 'stripe_prices'});
|
const subscriptions = this.member.get('subscriptions') || [];
|
||||||
this.product = this.products.firstObject;
|
return subscriptions.filter((sub) => {
|
||||||
this.price = this.prices ? this.prices[0] : null;
|
return ['active', 'trialing', 'unpaid', 'past_due'].includes(sub.status);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get member() {
|
get member() {
|
||||||
|
@ -43,45 +53,15 @@ export default class ModalMemberProduct extends ModalComponent {
|
||||||
return !this.price || this.price.amount !== 0;
|
return !this.price || this.price.amount !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get prices() {
|
@action
|
||||||
if (!this.products || !this.products.length) {
|
setup() {
|
||||||
return [];
|
this.loadingProducts = true;
|
||||||
}
|
this.fetchProducts.perform();
|
||||||
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
|
@action
|
||||||
setProduct(product) {
|
setProduct(productId) {
|
||||||
this.product = product;
|
this.selectedProduct = productId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -92,12 +72,27 @@ export default class ModalMemberProduct extends ModalComponent {
|
||||||
@task({
|
@task({
|
||||||
drop: true
|
drop: true
|
||||||
})
|
})
|
||||||
*addPriceTask() {
|
*addProduct() {
|
||||||
let url = this.ghostPaths.url.api('members', this.member.get('id'), 'subscriptions');
|
let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`);
|
||||||
|
// Cancel existing active subscriptions for member
|
||||||
let response = yield this.ajax.post(url, {
|
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: {
|
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 {
|
.modal-body p {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
|
|
|
@ -1787,3 +1787,7 @@ p.gh-members-import-errordetail:first-of-type {
|
||||||
.gh-member-product-form-block .form-group:last-of-type {
|
.gh-member-product-form-block .form-group:last-of-type {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gh-member-addcomp-warning {
|
||||||
|
margin-top: -16px;
|
||||||
|
}
|
|
@ -795,6 +795,7 @@ textarea {
|
||||||
padding: 12px 12px 12px 14px;
|
padding: 12px 12px 12px 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-rich-radio .gh-radio-label .description h4 {
|
.form-rich-radio .gh-radio-label .description h4 {
|
||||||
|
@ -806,13 +807,29 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-rich-radio .gh-radio-label .description p {
|
.form-rich-radio .gh-radio-label .description p {
|
||||||
font-size: 1.25rem !important;
|
font-size: 1.3rem !important;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.45em;
|
line-height: 1.45em;
|
||||||
margin: 2px 0 0;
|
margin: 0;
|
||||||
padding: 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
|
/* 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…
Add table
Reference in a new issue