mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-17 23:44:39 -05:00
✨ Added canceled subscriptions in member detail screen (#2287)
refs https://github.com/TryGhost/Team/issues/1141 Showing canceled subscriptions provide a more complete picture of the activity of a member. - Given there is no `member.product` object when a subscription is canceled, use the `member.subscriptions.price.product` objects instead of `member.products`. - applied boy-scout rule for linter errors and and code formatting - removed `multipleTiers` flag conditionals as it's now GA - set up subscriptions as a separate mirage resource so they are easier to work with - updated `PUT /members/:id/` endpoint to match real API's complimentary subscription behaviour - modified mirage member serializer to match API output Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk> Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
This commit is contained in:
parent
2fd52f009d
commit
a47b61c1d4
15 changed files with 680 additions and 194 deletions
|
@ -1198,3 +1198,21 @@ remove|ember-template-lint|no-invalid-interactive|38|68|38|68|1849f49e96b9809e3b
|
|||
remove|ember-template-lint|no-action|421|15|421|15|86d78f77ffe339a3beabf8cd2690e383fe8faace|1646611200000|1649199600000|1651791600000|app/templates/settings/labs.hbs
|
||||
remove|ember-template-lint|no-duplicate-landmark-elements|131|24|131|24|9eb7d301f1f50334e793aafab8f6b9e8905125ab|1646611200000|1649199600000|1651791600000|app/components/modal-product.hbs
|
||||
remove|ember-template-lint|no-nested-landmark|131|24|131|24|9eb7d301f1f50334e793aafab8f6b9e8905125ab|1646611200000|1649199600000|1651791600000|app/components/modal-product.hbs
|
||||
remove|ember-template-lint|no-action|12|43|12|43|c658d7c0cbef78ba9818c8a70219b7e1087dea69|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|20|43|20|43|b9a67e97651b70f148a37ae0875d555af81ab6f3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|28|38|28|38|d858959ab1d793b92dae81a046f33d93c84d68fb|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|40|74|40|74|c5e1748caba85674441672f3175ba17db7ed8bc3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|86|129|86|129|82dff20e50ca34a8c05ecd45a8ca2434349cc333|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|91|129|91|129|0856da9acd782baf5792ac652771757db9fd0ad8|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|164|78|164|78|35530afd5940b30c02b6e8d9b6bc887b27bfe8c3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|193|78|193|78|10bccb0d99ddf58e4b54d01c6634fcda0fac91e8|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|197|78|197|78|d45e015de9916af6c70ef01db2c2b4ea7d7098b4|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|235|70|235|70|35530afd5940b30c02b6e8d9b6bc887b27bfe8c3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|251|129|251|129|0856da9acd782baf5792ac652771757db9fd0ad8|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|270|36|270|36|82dff20e50ca34a8c05ecd45a8ca2434349cc333|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
|
||||
remove|ember-template-lint|no-action|4|53|4|53|463c25195e9aee40a1db8ac9a7e2545080791402|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
|
||||
remove|ember-template-lint|no-action|46|71|46|71|463c25195e9aee40a1db8ac9a7e2545080791402|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
|
||||
remove|ember-template-lint|no-action|48|8|48|8|48bafc3d5a5d7e6dc9e7318bb679ec77035c2916|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
|
||||
remove|ember-template-lint|no-down-event-binding|48|41|48|41|b3e87879e52edc06bb07f1ad0becc6ec762bbb39|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
|
||||
remove|ember-template-lint|no-invalid-interactive|26|95|26|95|defed375e0064745e03179bf7687ca9aa13f6ac7|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
|
||||
add|ember-template-lint|no-invalid-interactive|28|24|28|24|d7193af2cc72230c56b34863b6f8c0e7fbb1b41e|1647475200000|1650063600000|1652655600000|app/components/modal-member-product.hbs
|
||||
|
|
|
@ -8,16 +8,31 @@
|
|||
<div class="gh-cp-member-email-name">
|
||||
<GhFormGroup @errors={{this.member.errors}} @hasValidated={{this.member.hasValidated}} @property="name" @classNames="max-width">
|
||||
<label for="member-name">Name</label>
|
||||
<GhTextInput @id="member-name" @name="name" @value={{this.scratchMember.name}} @tabindex="1" @shouldFocus="{{if this.member.isNew true}}"
|
||||
@focus-out={{action "setProperty" "name" this.scratchMember.name}} data-test-input="member-name" />
|
||||
<GhTextInput
|
||||
@id="member-name"
|
||||
@name="name"
|
||||
@value={{this.scratchMember.name}}
|
||||
@tabindex="1"
|
||||
@shouldFocus="{{if this.member.isNew true}}"
|
||||
@focus-out={{fn this.setProperty "name" this.scratchMember.name}}
|
||||
data-test-input="member-name"
|
||||
/>
|
||||
<GhErrorMessage @errors={{this.member.errors}} @property="name" />
|
||||
</GhFormGroup>
|
||||
|
||||
<GhFormGroup @errors={{this.member.errors}} @hasValidated={{this.member.hasValidated}} @property="email" @classNames="max-width">
|
||||
<label for="member-email">Email</label>
|
||||
<GhTextInput @value={{this.scratchMember.email}} @id="member-email" @name="email" @tabindex="2"
|
||||
@autocapitalize="off" @autocorrect="off" @autocomplete="off"
|
||||
@focus-out={{action "setProperty" "email" this.scratchMember.email}} data-test-input="member-email"/>
|
||||
<GhTextInput
|
||||
@value={{this.scratchMember.email}}
|
||||
@id="member-email"
|
||||
@name="email"
|
||||
@tabindex="2"
|
||||
@autocapitalize="off"
|
||||
@autocorrect="off"
|
||||
@autocomplete="off"
|
||||
@focus-out={{fn this.setProperty "email" this.scratchMember.email}}
|
||||
data-test-input="member-email"
|
||||
/>
|
||||
<GhErrorMessage @errors={{this.member.errors}} @property="email" />
|
||||
</GhFormGroup>
|
||||
</div>
|
||||
|
@ -25,7 +40,7 @@
|
|||
<GhFormGroup @classNames="gh-member-labels">
|
||||
<label for="label-input">Labels</label>
|
||||
<GhMemberLabelInput
|
||||
@onChange={{action "setLabels"}}
|
||||
@onChange={{this.setLabels}}
|
||||
@allowEdit={{true}}
|
||||
@onLabelEdit={{@onLabelEdit}}
|
||||
@labels={{this.member.labels}}
|
||||
|
@ -36,8 +51,15 @@
|
|||
|
||||
<GhFormGroup @errors={{this.member.errors}} @hasValidated={{this.member.hasValidated}} @property="note" @classNames="mb0 gh-member-note">
|
||||
<label for="member-note">Note <span class="midgrey-d1 fw4">(not visible to member)</span></label>
|
||||
<GhTextarea @id="member-note" @name="note" @class="gh-member-details-textarea" @tabindex="3"
|
||||
@value={{this.scratchMember.note}} @focus-out={{action "setProperty" "note" this.scratchMember.note}} data-test-input="member-note" />
|
||||
<GhTextarea
|
||||
@id="member-note"
|
||||
@name="note"
|
||||
@class="gh-member-details-textarea"
|
||||
@tabindex="3"
|
||||
@value={{this.scratchMember.note}}
|
||||
@focus-out={{fn this.setProperty "note" this.scratchMember.note}}
|
||||
data-test-input="member-note"
|
||||
/>
|
||||
<GhErrorMessage @errors={{this.member.errors}} @property="note" />
|
||||
<p> Maximum: <b>500</b> characters. You’ve used
|
||||
{{gh-count-down-characters this.scratchMember.note 500}}</p>
|
||||
|
@ -52,8 +74,13 @@
|
|||
</div>
|
||||
<div class="for-switch">
|
||||
<label class="switch" for="subscribed-checkbox">
|
||||
<Input @checked={{this.member.subscribed}} @type="checkbox" id="subscribed-checkbox"
|
||||
name="subscribed" data-test-checkbox="member-subscribed" />
|
||||
<Input
|
||||
@checked={{this.member.subscribed}}
|
||||
@type="checkbox"
|
||||
id="subscribed-checkbox"
|
||||
name="subscribed"
|
||||
data-test-checkbox="member-subscribed"
|
||||
/>
|
||||
<span class="input-toggle-component"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
@ -70,57 +97,55 @@
|
|||
<div class="gh-main-section-content grey">
|
||||
<div class="gh-cp-memberproduct-noproduct">
|
||||
{{#unless this.isCreatingComplimentary}}
|
||||
<div class="gh-members-no-data gh-members-no-subs">
|
||||
<span class="lightgrey">{{svg-jar "no-data-subscription"}}</span>
|
||||
<h4>No subscriptions</h4>
|
||||
</div>
|
||||
<div class="gh-members-no-data gh-members-no-subs">
|
||||
<span class="lightgrey">{{svg-jar "no-data-subscription"}}</span>
|
||||
<h4>No subscriptions</h4>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
{{#unless this.member.isNew}}
|
||||
{{#if this.isAddComplimentaryAllowed}}
|
||||
{{#if this.isCreatingComplimentary}}
|
||||
<GhLoadingSpinner />
|
||||
{{else}}
|
||||
{{#if (feature "multipleProducts")}}
|
||||
{{!-- {{if has multiple products!}} --}}
|
||||
<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}} --}}
|
||||
{{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}}
|
||||
{{/if}}
|
||||
{{#if this.isAddComplimentaryAllowed}}
|
||||
{{#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)}}
|
||||
data-test-button="add-complimentary"
|
||||
>
|
||||
<span>{{svg-jar "add"}} Add complimentary subscription</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
{{#each this.products as |product|}}
|
||||
<div class="gh-main-section-content grey gh-member-product-container">
|
||||
<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">
|
||||
<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>
|
||||
<span class="gh-memberproduct-subcount">{{product.subscriptions.length}} subscriptions</span>
|
||||
{{/if}}
|
||||
</h3>
|
||||
|
||||
{{#each product.subscriptions as |sub|}}
|
||||
<div class="gh-memberproduct-subscription">
|
||||
{{#each product.subscriptions as |sub index|}}
|
||||
<div class="gh-memberproduct-subscription" data-test-subscription={{index}}>
|
||||
<div>
|
||||
<div>
|
||||
<span class="gh-cp-memberproduct-pricelabel">{{sub.price.nickname}}</span>
|
||||
–
|
||||
{{#if sub.cancel_at_period_end}}
|
||||
{{#if (eq sub.status "canceled")}}
|
||||
<span class="gh-cp-memberproduct-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-badge archived">Cancelled</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-badge active">Active</span>
|
||||
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if sub.cancellationReason}}
|
||||
|
@ -151,23 +176,36 @@
|
|||
</div>
|
||||
<div class="period">{{if (eq sub.price.interval "year") "yearly" "monthly"}}</div>
|
||||
</div>
|
||||
|
||||
{{#if sub.isComplimentary}}
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton @dropdownName="subscription-menu-complimentary" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
|
||||
<GhDropdownButton
|
||||
@dropdownName="subscription-menu-complimentary"
|
||||
@classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only"
|
||||
@title="Actions"
|
||||
data-test-button="subscription-actions"
|
||||
>
|
||||
<span>
|
||||
{{svg-jar "dotdotdot"}}
|
||||
<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="product-actions-menu dropdown-menu dropdown-align-right"
|
||||
>
|
||||
<li>
|
||||
<button type="button" {{action "removeComplimentary" product.id}}>
|
||||
<button
|
||||
type="button"
|
||||
{{on "click" (fn this.removeComplimentary (or product.id product.product_id))}}
|
||||
data-test-button="remove-complimentary"
|
||||
>
|
||||
<span class="red">Remove complimentary subscription</span>
|
||||
</button>
|
||||
</li>
|
||||
</GhDropdown>
|
||||
</span>
|
||||
|
||||
{{else}}
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton @dropdownName="subscription-menu-{{sub.id}}" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
|
||||
|
@ -189,14 +227,16 @@
|
|||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{{#if sub.cancel_at_period_end}}
|
||||
<button type="button" {{action "continueSubscription" sub.id}}>
|
||||
<span>Continue subscription</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" {{action "cancelSubscription" sub.id}}>
|
||||
<span class="red">Cancel subscription</span>
|
||||
</button>
|
||||
{{#if (not-eq sub.status "canceled")}}
|
||||
{{#if sub.cancel_at_period_end}}
|
||||
<button type="button" {{on "click" (fn this.continueSubscription sub.id)}}>
|
||||
<span>Continue subscription</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" {{on "click" (fn this.cancelSubscription sub.id)}}>
|
||||
<span class="red">Cancel subscription</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</li>
|
||||
</GhDropdown>
|
||||
|
@ -206,74 +246,60 @@
|
|||
</div>
|
||||
{{/each}}
|
||||
|
||||
{{#if (and (feature "multipleProducts") (eq product.subscriptions.length 0))}}
|
||||
<div class="gh-memberproduct-subscription">
|
||||
<div>
|
||||
{{#if (eq product.subscriptions.length 0)}}
|
||||
<div class="gh-memberproduct-subscription">
|
||||
<div>
|
||||
<span class="gh-cp-memberproduct-pricelabel">Complimentary</span>
|
||||
<span class="gh-badge active">Active</span>
|
||||
</div>
|
||||
<div class="gh-memberproduct-created">Created on</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="gh-product-card-price">
|
||||
<div class="flex items-start">
|
||||
<span class="currency-symbol">$</span>
|
||||
<span class="amount">0</span>
|
||||
<div>
|
||||
<span class="gh-cp-memberproduct-pricelabel">Complimentary</span>
|
||||
<span class="gh-badge active">Active</span>
|
||||
</div>
|
||||
<div class="period">yearly</div>
|
||||
<div class="gh-memberproduct-created">Created on</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="gh-product-card-price">
|
||||
<div class="flex items-start">
|
||||
<span class="currency-symbol">$</span>
|
||||
<span class="amount">0</span>
|
||||
</div>
|
||||
<div class="period">yearly</div>
|
||||
</div>
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton @dropdownName="subscription-menu-complimentary" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
|
||||
<span>
|
||||
{{svg-jar "dotdotdot"}}
|
||||
<span class="hidden">Subscription menu</span>
|
||||
</span>
|
||||
</GhDropdownButton>
|
||||
<GhDropdown @name="subscription-menu-complimentary" @tagName="ul" @classNames="product-actions-menu dropdown-menu dropdown-align-right">
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn this.removeComplimentary product.id)}}>
|
||||
<span class="red">Remove complimentary subscription</span>
|
||||
</button>
|
||||
</li>
|
||||
</GhDropdown>
|
||||
</span>
|
||||
</div>
|
||||
<span class="action-menu">
|
||||
<GhDropdownButton @dropdownName="subscription-menu-complimentary" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
|
||||
<span>
|
||||
{{svg-jar "dotdotdot"}}
|
||||
<span class="hidden">Subscription menu</span>
|
||||
</span>
|
||||
</GhDropdownButton>
|
||||
<GhDropdown @name="subscription-menu-complimentary" @tagName="ul" @classNames="product-actions-menu dropdown-menu dropdown-align-right">
|
||||
<li>
|
||||
<button type="button" {{action "removeComplimentary" product.id}}>
|
||||
<span class="red">Remove complimentary subscription</span>
|
||||
</button>
|
||||
</li>
|
||||
</GhDropdown>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (not (feature "multipleProducts"))}}
|
||||
{{#if this.isAddComplimentaryAllowed}}
|
||||
<div class="gh-memberproduct-list-footer bt b--whitegrey pt2 {{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 (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"
|
||||
{{on "click" (toggle-action "showMemberProductModal" this)}}
|
||||
data-test-button="add-complimentary"
|
||||
>
|
||||
<span>{{svg-jar "add"}} Add complimentary subscription</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
|
|
|
@ -32,21 +32,37 @@ export default class extends Component {
|
|||
return false;
|
||||
}
|
||||
|
||||
let products = this.member.get('products');
|
||||
if (products && products.length > 0) {
|
||||
if (this.member.get('isNew')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.feature.get('multipleProducts')) {
|
||||
return !!this.productsList?.length;
|
||||
if (this.member.get('products')?.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// complimentary subscriptions are assigned to products 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;
|
||||
|
||||
return hasAnActivePaidProduct;
|
||||
}
|
||||
|
||||
get isCreatingComplimentary() {
|
||||
return this.args.isSaveRunning;
|
||||
}
|
||||
|
||||
get products() {
|
||||
let products = this.member.get('products') || [];
|
||||
let subscriptions = this.member.get('subscriptions') || [];
|
||||
|
||||
// Create the products from `subscriptions.price.product`
|
||||
let products = subscriptions
|
||||
.map(subscription => (subscription.tier || subscription.price.product))
|
||||
.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;
|
||||
});
|
||||
|
||||
let subscriptionData = subscriptions.filter((sub) => {
|
||||
return !!sub.price;
|
||||
}).map((sub) => {
|
||||
|
@ -66,10 +82,7 @@ export default class extends Component {
|
|||
|
||||
for (let product of products) {
|
||||
let productSubscriptions = subscriptionData.filter((subscription) => {
|
||||
if (subscription.status === 'canceled') {
|
||||
return false;
|
||||
}
|
||||
return subscription?.price?.product?.product_id === product.id;
|
||||
return subscription?.price?.product?.product_id === (product.product_id || product.id);
|
||||
});
|
||||
product.subscriptions = productSubscriptions;
|
||||
}
|
||||
|
@ -99,10 +112,6 @@ export default class extends Component {
|
|||
this.fetchProducts.perform();
|
||||
}
|
||||
|
||||
get isCreatingComplimentary() {
|
||||
return this.args.isSaveRunning;
|
||||
}
|
||||
|
||||
@action
|
||||
setProperty(property, value) {
|
||||
this.args.setProperty(property, value);
|
||||
|
@ -133,12 +142,6 @@ export default class extends Component {
|
|||
this.continueSubscriptionTask.perform(subscriptionId);
|
||||
}
|
||||
|
||||
@action
|
||||
addCompedSubscription() {
|
||||
this.args.setProperty('comped', true);
|
||||
this.args.saveMember();
|
||||
}
|
||||
|
||||
@task({drop: true})
|
||||
*cancelSubscriptionTask(subscriptionId) {
|
||||
let url = this.ghostPaths.url.api('members', this.member.get('id'), 'subscriptions', subscriptionId);
|
||||
|
@ -157,7 +160,10 @@ export default class extends Component {
|
|||
*removeComplimentaryTask(productId) {
|
||||
let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`);
|
||||
let products = this.member.get('products') || [];
|
||||
const updatedProducts = products.filter(product => product.id !== productId).map(product => ({id: product.id}));
|
||||
|
||||
const updatedProducts = products
|
||||
.filter(product => product.id !== productId)
|
||||
.map(product => ({id: product.id}));
|
||||
|
||||
let response = yield this.ajax.put(url, {
|
||||
data: {
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
<header class="modal-header" data-test-modal="delete-user" {{did-insert this.setup}}>
|
||||
<header class="modal-header" data-test-modal="member-product" {{did-insert this.setup}}>
|
||||
<h1>Add subscription</h1>
|
||||
</header>
|
||||
<a class="close" href="" role="button" title="Close" {{action "closeModal" }}>
|
||||
<button type="button" class="close" title="Close" {{on "click" this.close}}>
|
||||
{{svg-jar "close"}}<span class="hidden">Close</span>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<form>
|
||||
<div class="modal-body">
|
||||
<p class="gh-member-addcomp-subhed">
|
||||
<p class="gh-member-addcomp-subhed" data-test-text="select-tier-desc">
|
||||
Select a tier for <strong>{{or this.member.name this.member.email}}</strong>'s
|
||||
complimentary subscription.
|
||||
</p>
|
||||
{{#if this.activeSubscriptions.length}}
|
||||
<p class="gh-member-addcomp-warning">
|
||||
<p class="gh-member-addcomp-warning" data-test-text="sub-cancel-warning">
|
||||
Adding a complimentary subscription cancels all existing subscriptions of this member.
|
||||
</p>
|
||||
{{/if}}
|
||||
|
@ -23,10 +23,14 @@
|
|||
{{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 {{if (eq this.selectedProduct product.id) "active"}}"
|
||||
{{on "click" (fn this.setProduct product.id)}}
|
||||
data-test-tier-option={{product.id}}
|
||||
>
|
||||
<div class="gh-radio-content">
|
||||
<div class="gh-radio-label">
|
||||
<div class="description">
|
||||
<div class="description" data-test-text="tier-desc">
|
||||
<h4>{{product.name}}</h4>
|
||||
<p>{{product.description}}</p>
|
||||
</div>
|
||||
|
@ -43,9 +47,14 @@
|
|||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
class="gh-btn" data-test-button="cancel-webhook" type="button" {{action "closeModal" }}
|
||||
class="gh-btn"
|
||||
type="button"
|
||||
{{on "click" this.close}}
|
||||
{{!-- disable mouseDown so it does not trigger focus-out validations --}}
|
||||
{{action (optional this.noop) on="mouseDown" }}>
|
||||
{{!-- template-lint-disable no-down-event-binding --}}
|
||||
{{on "mousedown" (optional this.noop)}}
|
||||
data-test-button="cancel-webhook"
|
||||
>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
|
||||
|
|
|
@ -69,7 +69,8 @@ export default class ModalMemberProduct extends ModalComponent {
|
|||
|
||||
@task({drop: true})
|
||||
*addProduct() {
|
||||
let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`);
|
||||
const url = `${this.ghostPaths.url.api(`members/${this.member.get('id')}`)}?include=products`;
|
||||
|
||||
// Cancel existing active subscriptions for member
|
||||
for (let i = 0; i < this.activeSubscriptions.length; i++) {
|
||||
const subscription = this.activeSubscriptions[i];
|
||||
|
@ -80,7 +81,8 @@ export default class ModalMemberProduct extends ModalComponent {
|
|||
}
|
||||
});
|
||||
}
|
||||
let response = yield this.ajax.put(url, {
|
||||
|
||||
const response = yield this.ajax.put(url, {
|
||||
data: {
|
||||
members: [{
|
||||
id: this.member.get('id'),
|
||||
|
|
|
@ -577,8 +577,8 @@ p.gh-members-list-email {
|
|||
}
|
||||
|
||||
@media (max-width: 620px),
|
||||
(min-width: 800px) and (max-width: 960px),
|
||||
(min-width: 1080px) and (max-width: 1440px) {
|
||||
(min-width: 800px) and (max-width: 960px),
|
||||
(min-width: 1080px) and (max-width: 1440px) {
|
||||
.gh-members-help-card .thumbnail {
|
||||
max-width: unset;
|
||||
margin-top: 2rem;
|
||||
|
@ -610,8 +610,8 @@ p.gh-members-list-email {
|
|||
}
|
||||
|
||||
@media (max-width: 620px),
|
||||
(min-width: 800px) and (max-width: 960px),
|
||||
(min-width: 1080px) and (max-width: 1440px) {
|
||||
(min-width: 800px) and (max-width: 960px),
|
||||
(min-width: 1080px) and (max-width: 1440px) {
|
||||
.gh-members-help-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
@ -640,12 +640,12 @@ p.gh-members-list-email {
|
|||
}
|
||||
|
||||
@media (max-width: 620px),
|
||||
(min-width: 800px) and (max-width: 960px),
|
||||
(min-width: 1080px) and (max-width: 1440px) {
|
||||
(min-width: 800px) and (max-width: 960px),
|
||||
(min-width: 1080px) and (max-width: 1440px) {
|
||||
.gh-members-help-content .text {
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
|
||||
.gh-members-help-content .text p {
|
||||
margin-bottom: 2.8em;
|
||||
}
|
||||
|
@ -909,7 +909,7 @@ textarea.gh-member-details-textarea {
|
|||
float: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.gh-member-details {
|
||||
position: relative;
|
||||
top: unset;
|
||||
|
@ -2052,6 +2052,10 @@ p.gh-members-import-errordetail:first-of-type {
|
|||
grid-row-gap: 24px;
|
||||
}
|
||||
|
||||
.gh-member-settings .gh-member-product-container + .gh-member-product-container {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.gh-cp-memberproduct {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
@ -2105,8 +2109,8 @@ p.gh-members-import-errordetail:first-of-type {
|
|||
|
||||
.gh-memberproduct-list-footer {
|
||||
position:relative;
|
||||
margin-top: 12px;
|
||||
margin-bottom: -8px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.gh-memberproduct-list-footer.min-height {
|
||||
|
@ -2211,10 +2215,6 @@ p.gh-members-import-errordetail:first-of-type {
|
|||
left: auto;
|
||||
}
|
||||
|
||||
.gh-cp-memberproduct.multiple-subs .product-actions-menu {
|
||||
top: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.gh-memberproduct-subscription .action-menu .gh-btn-subscription-action:not(:hover) {
|
||||
border: 1px solid var(--whitegrey);
|
||||
background: var(--main-bg-color) !important;
|
||||
|
@ -2263,4 +2263,4 @@ p.gh-members-import-errordetail:first-of-type {
|
|||
.gh-members-filter-builder {
|
||||
width: calc(100% - 100px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,7 +117,7 @@ module.exports = function (defaults) {
|
|||
includePolyfill: false
|
||||
},
|
||||
'ember-composable-helpers': {
|
||||
only: ['optional', 'toggle']
|
||||
only: ['optional', 'toggle', 'toggle-action']
|
||||
},
|
||||
'ember-promise-modals': {
|
||||
excludeCSS: true
|
||||
|
|
|
@ -96,24 +96,17 @@ export default function mockMembers(server) {
|
|||
serializedMember[underscore(key)] = member.attrs[key];
|
||||
});
|
||||
|
||||
// similar deal for associated label models
|
||||
serializedMember.labels = [];
|
||||
member.labels.models.forEach((label) => {
|
||||
const serializedLabel = {};
|
||||
Object.keys(label.attrs).forEach((key) => {
|
||||
serializedLabel[underscore(key)] = label.attrs[key];
|
||||
});
|
||||
serializedMember.labels.push(serializedLabel);
|
||||
});
|
||||
// similar deal for associated models
|
||||
['labels', 'products', 'subscriptions'].forEach((association) => {
|
||||
serializedMember[association] = [];
|
||||
|
||||
// similar deal for associated product models
|
||||
serializedMember.products = [];
|
||||
member.products.models.forEach((product) => {
|
||||
const serializedProduct = {};
|
||||
Object.keys(product.attrs).forEach((key) => {
|
||||
serializedProduct[underscore(key)] = product.attrs[key];
|
||||
member[association].models.forEach((associatedModel) => {
|
||||
const serializedAssociation = {};
|
||||
Object.keys(associatedModel.attrs).forEach((key) => {
|
||||
serializedAssociation[underscore(key)] = associatedModel.attrs[key];
|
||||
});
|
||||
serializedMember[association].push(serializedAssociation);
|
||||
});
|
||||
serializedMember.products.push(serializedProduct);
|
||||
});
|
||||
|
||||
return nqlFilter.queryJSON(serializedMember);
|
||||
|
@ -184,7 +177,75 @@ export default function mockMembers(server) {
|
|||
});
|
||||
});
|
||||
|
||||
server.put('/members/:id/');
|
||||
server.put('/members/:id/', function ({members, products, 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);
|
||||
|
||||
if (!member.products.includes(product)) {
|
||||
// TODO: serialize products through _active_ subscriptions
|
||||
member.products.add(product);
|
||||
|
||||
subscriptions.create({
|
||||
member,
|
||||
product,
|
||||
comped: true,
|
||||
plan: {
|
||||
id: '',
|
||||
nickname: 'Complimentary',
|
||||
interval: 'year',
|
||||
currency: 'USD',
|
||||
amount: 0
|
||||
},
|
||||
status: 'active',
|
||||
startDate: moment().toISOString(),
|
||||
defaultPaymentCardLast4: '****',
|
||||
cancelAtPeriodEnd: false,
|
||||
cancellationReason: null,
|
||||
currentPeriodEnd: moment().add(1, 'year').toISOString(),
|
||||
price: {
|
||||
id: '',
|
||||
price_id: '',
|
||||
nickname: 'Complimentary',
|
||||
amount: 0,
|
||||
interval: 'year',
|
||||
type: 'recurring',
|
||||
currency: 'USD',
|
||||
product: {
|
||||
id: '',
|
||||
product_id: product.id
|
||||
}
|
||||
},
|
||||
offer: null
|
||||
});
|
||||
|
||||
member.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const productIds = (attrs.products || []).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.subscriptions.remove(sub);
|
||||
});
|
||||
|
||||
member.products.remove(product);
|
||||
}
|
||||
});
|
||||
|
||||
// these are read-only properties so make sure we don't overwrite data
|
||||
delete attrs.products;
|
||||
delete attrs.subscriptions;
|
||||
|
||||
return member.update(attrs);
|
||||
});
|
||||
|
||||
server.del('/members/:id/');
|
||||
|
||||
|
|
4
ghost/admin/mirage/factories/subscription.js
Normal file
4
ghost/admin/mirage/factories/subscription.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import {Factory} from 'miragejs';
|
||||
|
||||
export default Factory.extend({
|
||||
});
|
|
@ -3,5 +3,6 @@ import {Model, hasMany} from 'miragejs';
|
|||
export default Model.extend({
|
||||
labels: hasMany(),
|
||||
emailRecipients: hasMany(),
|
||||
products: hasMany()
|
||||
products: hasMany(),
|
||||
subscriptions: hasMany()
|
||||
});
|
||||
|
|
6
ghost/admin/mirage/models/subscription.js
Normal file
6
ghost/admin/mirage/models/subscription.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import {Model, belongsTo} from 'miragejs';
|
||||
|
||||
export default Model.extend({
|
||||
member: belongsTo(),
|
||||
product: belongsTo()
|
||||
});
|
|
@ -9,7 +9,25 @@ export default BaseSerializer.extend({
|
|||
|
||||
// embedded records that are included by default in the API
|
||||
includes.add('labels');
|
||||
includes.add('subscriptions');
|
||||
|
||||
return Array.from(includes);
|
||||
},
|
||||
|
||||
serialize() {
|
||||
const serialized = BaseSerializer.prototype.serialize.call(this, ...arguments);
|
||||
|
||||
// comped subscriptions are returned with a blank ID
|
||||
// (we use `.comped` internally because mirage resources require an ID)
|
||||
(serialized.members || [serialized.member]).forEach((member) => {
|
||||
member.subscriptions.forEach((sub) => {
|
||||
if (sub.comped) {
|
||||
sub.id = '';
|
||||
}
|
||||
delete sub.comped;
|
||||
});
|
||||
});
|
||||
|
||||
return serialized;
|
||||
}
|
||||
});
|
||||
|
|
27
ghost/admin/mirage/serializers/subscription.js
Normal file
27
ghost/admin/mirage/serializers/subscription.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import BaseSerializer from './application';
|
||||
import {underscore} from '@ember/string';
|
||||
|
||||
export default BaseSerializer.extend({
|
||||
embed: true,
|
||||
|
||||
include(/*request*/) {
|
||||
let includes = [];
|
||||
|
||||
includes.push('product');
|
||||
|
||||
return includes;
|
||||
},
|
||||
|
||||
keyForEmbeddedRelationship(relationshipName) {
|
||||
if (relationshipName === 'product') {
|
||||
return 'tier';
|
||||
}
|
||||
|
||||
return underscore(relationshipName);
|
||||
}
|
||||
|
||||
// NOTE: serialize() is not called for embedded records, serialization happens
|
||||
// on the primary resource, in this case `member`
|
||||
// TODO: extract subscription serialization and call it here too if we start
|
||||
// to treat subscriptions as their own non-embedded resource
|
||||
});
|
308
ghost/admin/tests/acceptance/members/details-test.js
Normal file
308
ghost/admin/tests/acceptance/members/details-test.js
Normal file
|
@ -0,0 +1,308 @@
|
|||
import {authenticateSession} from 'ember-simple-auth/test-support';
|
||||
import {click, currentURL, find, findAll} from '@ember/test-helpers';
|
||||
import {enableLabsFlag} from '../../helpers/labs-flag';
|
||||
import {expect} from 'chai';
|
||||
import {setupApplicationTest} from 'ember-mocha';
|
||||
import {setupMirage} from 'ember-cli-mirage/test-support';
|
||||
import {visit} from '../../helpers/visit';
|
||||
|
||||
describe('Acceptance: Member details', function () {
|
||||
let hooks = setupApplicationTest();
|
||||
setupMirage(hooks);
|
||||
|
||||
let clock;
|
||||
let product;
|
||||
|
||||
beforeEach(async function () {
|
||||
this.server.loadFixtures('configs');
|
||||
this.server.loadFixtures('settings');
|
||||
enableLabsFlag(this.server, 'membersLastSeenFilter');
|
||||
enableLabsFlag(this.server, 'membersTimeFilters');
|
||||
enableLabsFlag(this.server, 'multipleProducts');
|
||||
|
||||
// test with stripe connected and email turned on
|
||||
// TODO: add these settings to default fixtures
|
||||
this.server.db.settings.find({key: 'stripe_connect_account_id'})
|
||||
? this.server.db.settings.update({key: 'stripe_connect_account_id'}, {value: 'stripe_account_id'})
|
||||
: this.server.create('setting', {key: 'stripe_connect_account_id', value: 'stripe_account_id', group: 'members'});
|
||||
// needed for membersUtils.isStripeEnabled
|
||||
this.server.db.settings.find({key: 'stripe_connect_secret_key'})
|
||||
? this.server.db.settings.update({key: 'stripe_connect_secret_key'}, {value: 'stripe_secret_key'})
|
||||
: this.server.create('setting', {key: 'stripe_connect_secret_key', value: 'stripe_secret_key', group: 'members'});
|
||||
this.server.db.settings.find({key: 'stripe_connect_publishable_key'})
|
||||
? this.server.db.settings.update({key: 'stripe_connect_publishable_key'}, {value: 'stripe_secret_key'})
|
||||
: this.server.create('setting', {key: 'stripe_connect_publishable_key', value: 'stripe_secret_key', group: 'members'});
|
||||
|
||||
this.server.db.settings.find({key: 'editor_default_email_recipients'})
|
||||
? this.server.db.settings.update({key: 'editor_default_email_recipients'}, {value: 'visibility'})
|
||||
: this.server.create('setting', {key: 'editor_default_email_recipients', value: 'visibility', group: 'editor'});
|
||||
|
||||
// add a default product that complimentary plans can be assigned to
|
||||
product = this.server.create('product', {
|
||||
id: '6213b3f6cb39ebdb03ebd810',
|
||||
name: 'Ghost Subscription',
|
||||
slug: 'ghost-subscription',
|
||||
created_at: '2022-02-21T16:47:02.000Z',
|
||||
updated_at: '2022-03-03T15:37:02.000Z',
|
||||
description: null,
|
||||
monthly_price_id: '6220df272fee0571b5dd0a0a',
|
||||
yearly_price_id: '6220df272fee0571b5dd0a0b',
|
||||
type: 'paid',
|
||||
active: true,
|
||||
welcome_page_url: '/'
|
||||
});
|
||||
|
||||
let role = this.server.create('role', {name: 'Owner'});
|
||||
this.server.create('user', {roles: [role]});
|
||||
|
||||
return await authenticateSession();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
clock?.restore();
|
||||
});
|
||||
|
||||
it('has a known base-state', async function () {
|
||||
const member = this.server.create('member', {
|
||||
id: 1,
|
||||
subscriptions: [
|
||||
this.server.create('subscription', {
|
||||
id: 'sub_1KZGcmEGb07FFvyN9jwrwbKu',
|
||||
customer: {
|
||||
id: 'cus_LFmBWoSkB84lnr',
|
||||
name: 'test',
|
||||
email: 'test@ghost.org'
|
||||
},
|
||||
plan: {
|
||||
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
|
||||
nickname: 'Monthly',
|
||||
amount: 500,
|
||||
interval: 'month',
|
||||
currency: 'USD'
|
||||
},
|
||||
status: 'canceled',
|
||||
start_date: '2022-03-03T15:31:27.000Z',
|
||||
default_payment_card_last4: '4242',
|
||||
cancel_at_period_end: false,
|
||||
cancellation_reason: null,
|
||||
current_period_end: '2022-04-03T15:31:27.000Z',
|
||||
price: {
|
||||
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
|
||||
price_id: '6220df272fee0571b5dd0a0a',
|
||||
nickname: 'Monthly',
|
||||
amount: 500,
|
||||
interval: 'month',
|
||||
type: 'recurring',
|
||||
currency: 'USD',
|
||||
product: {
|
||||
id: 'prod_LFmAAmCnnbzrvL',
|
||||
name: 'Ghost Subscription',
|
||||
product_id: product.id
|
||||
}
|
||||
},
|
||||
offer: null
|
||||
}),
|
||||
this.server.create('subscription', {
|
||||
id: 'sub_1KZGi6EGb07FFvyNDjZq98g8',
|
||||
product,
|
||||
customer: {
|
||||
id: 'cus_LFmGicpX4BkQKH',
|
||||
name: '123',
|
||||
email: 'test@ghost.org'
|
||||
},
|
||||
plan: {
|
||||
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
|
||||
nickname: 'Monthly',
|
||||
amount: 500,
|
||||
interval: 'month',
|
||||
currency: 'USD'
|
||||
},
|
||||
status: 'active',
|
||||
start_date: '2022-03-03T15:36:58.000Z',
|
||||
default_payment_card_last4: '4242',
|
||||
cancel_at_period_end: false,
|
||||
cancellation_reason: null,
|
||||
current_period_end: '2022-04-03T15:36:58.000Z',
|
||||
price: {
|
||||
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
|
||||
price_id: '6220df272fee0571b5dd0a0a',
|
||||
nickname: 'Monthly',
|
||||
amount: 500,
|
||||
interval: 'month',
|
||||
type: 'recurring',
|
||||
currency: 'USD',
|
||||
product: {
|
||||
id: 'prod_LFmAAmCnnbzrvL',
|
||||
name: 'Ghost Subscription',
|
||||
product_id: product.id
|
||||
}
|
||||
},
|
||||
offer: null
|
||||
})
|
||||
],
|
||||
products: [
|
||||
product
|
||||
]
|
||||
});
|
||||
|
||||
await visit(`/members/${member.id}`);
|
||||
|
||||
expect(currentURL()).to.equal(`/members/${member.id}`);
|
||||
|
||||
expect(findAll('[data-test-subscription]').length, 'displays all member subscriptions')
|
||||
.to.equal(2);
|
||||
});
|
||||
|
||||
it('displays correctly one canceled subscription', async function () {
|
||||
const member = this.server.create('member', {
|
||||
id: 1,
|
||||
subscriptions: [
|
||||
this.server.create('subscription', {
|
||||
id: 'sub_1KZGcmEGb07FFvyN9jwrwbKu',
|
||||
product,
|
||||
customer: {
|
||||
id: 'cus_LFmBWoSkB84lnr',
|
||||
name: 'test',
|
||||
email: 'test@ghost.org'
|
||||
},
|
||||
plan: {
|
||||
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
|
||||
nickname: 'Monthly',
|
||||
amount: 500,
|
||||
interval: 'month',
|
||||
currency: 'USD'
|
||||
},
|
||||
status: 'canceled',
|
||||
start_date: '2022-03-03T15:31:27.000Z',
|
||||
default_payment_card_last4: '4242',
|
||||
cancel_at_period_end: false,
|
||||
cancellation_reason: null,
|
||||
current_period_end: '2022-04-03T15:31:27.000Z',
|
||||
price: {
|
||||
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
|
||||
price_id: '6220df272fee0571b5dd0a0a',
|
||||
nickname: 'Monthly',
|
||||
amount: 500,
|
||||
interval: 'month',
|
||||
type: 'recurring',
|
||||
currency: 'USD',
|
||||
product: {
|
||||
id: 'prod_LFmAAmCnnbzrvL',
|
||||
name: 'Ghost Subscription',
|
||||
product_id: '6213b3f6cb39ebdb03ebd810'
|
||||
}
|
||||
},
|
||||
offer: null
|
||||
})
|
||||
],
|
||||
products: []
|
||||
});
|
||||
|
||||
await visit(`/members/${member.id}`);
|
||||
|
||||
expect(currentURL()).to.equal(`/members/${member.id}`);
|
||||
|
||||
expect(findAll('[data-test-subscription]').length, 'displays all member subscriptions')
|
||||
.to.equal(1);
|
||||
});
|
||||
|
||||
it('can add and remove complimentary subscription', async function () {
|
||||
const member = this.server.create('member', {name: 'Comp Member Test'});
|
||||
|
||||
await visit(`/members/${member.id}`);
|
||||
|
||||
expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons')
|
||||
.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-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"]');
|
||||
|
||||
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"]');
|
||||
|
||||
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after remove comped')
|
||||
.to.equal(0);
|
||||
});
|
||||
|
||||
it('can add complimentary subscription when member has canceled subscriptions', async function () {
|
||||
const member = this.server.create('member', {
|
||||
name: 'Comped for canceled sub test',
|
||||
subscriptions: [
|
||||
this.server.create('subscription', {
|
||||
// product, // _Not_ included as `tier` when subscription is canceled
|
||||
status: 'canceled',
|
||||
price: {
|
||||
id: 'price_1',
|
||||
product: {
|
||||
id: 'prod_1',
|
||||
product_id: product.id
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
await visit(`/members/${member.id}`);
|
||||
|
||||
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-button="save-comp-product"]');
|
||||
|
||||
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after add comped')
|
||||
.to.equal(2);
|
||||
expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons - after add comped')
|
||||
.to.equal(0);
|
||||
});
|
||||
|
||||
it('handles multiple products', async function () {
|
||||
const product2 = this.server.create('product', {
|
||||
name: 'Second product',
|
||||
slug: 'second-product',
|
||||
created_at: '2022-02-21T16:47:02.000Z',
|
||||
updated_at: '2022-03-03T15:37:02.000Z',
|
||||
description: null,
|
||||
monthly_price_id: '6220df272fee0571b5dd0a0a',
|
||||
yearly_price_id: '6220df272fee0571b5dd0a0b',
|
||||
type: 'paid',
|
||||
active: true,
|
||||
welcome_page_url: '/'
|
||||
});
|
||||
|
||||
const member = this.server.create('member', {name: 'Multiple product 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}}});
|
||||
|
||||
await visit(`/members/${member.id}`);
|
||||
|
||||
// all products and subscriptions are shown
|
||||
expect(findAll('[data-test-product]').length, '# of product blocks').to.equal(2);
|
||||
|
||||
const p1 = `[data-test-product="${product.id}"]`;
|
||||
const p2 = `[data-test-product="${product2.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(`${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);
|
||||
|
||||
// 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"]');
|
||||
|
||||
expect(findAll(`${p2} [data-test-subscription]`).length, '# of product 2 subscription blocks after comp added').to.equal(2);
|
||||
});
|
||||
});
|
|
@ -265,8 +265,8 @@ describe('Acceptance: Members filtering', function () {
|
|||
|
||||
it('can filter by billing period', async function () {
|
||||
// add some members to filter
|
||||
this.server.createList('member', 3, {subscriptions: [{plan_interval: 'month'}]});
|
||||
this.server.createList('member', 4, {subscriptions: [{plan_interval: 'year'}]});
|
||||
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, planInterval: 'month'}));
|
||||
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, planInterval: 'year'}));
|
||||
|
||||
await visit('/members');
|
||||
|
||||
|
@ -310,8 +310,8 @@ describe('Acceptance: Members filtering', function () {
|
|||
|
||||
it('can filter by stripe subscription status', async function () {
|
||||
// add some members to filter
|
||||
this.server.createList('member', 3, {subscriptions: [{status: 'active'}]});
|
||||
this.server.createList('member', 4, {subscriptions: [{status: 'trialing'}]});
|
||||
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, status: 'active'}));
|
||||
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, status: 'trialing'}));
|
||||
|
||||
await visit('/members');
|
||||
|
||||
|
@ -764,9 +764,9 @@ describe('Acceptance: Members filtering', function () {
|
|||
});
|
||||
|
||||
// add some members to filter
|
||||
this.server.createList('member', 3, {subscriptions: [{start_date: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
|
||||
this.server.createList('member', 4, {subscriptions: [{start_date: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
|
||||
this.server.createList('member', 2, {subscriptions: []});
|
||||
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, startDate: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
|
||||
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, startDate: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
|
||||
this.server.createList('member', 2);
|
||||
|
||||
await visit('/members');
|
||||
|
||||
|
@ -1085,9 +1085,9 @@ describe('Acceptance: Members filtering', function () {
|
|||
});
|
||||
|
||||
// add some members to filter
|
||||
this.server.createList('member', 3, {subscriptions: [{current_period_end: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
|
||||
this.server.createList('member', 4, {subscriptions: [{current_period_end: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
|
||||
this.server.createList('member', 2, {subscriptions: []});
|
||||
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, currentPeriodEnd: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
|
||||
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, currentPeriodEnd: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
|
||||
this.server.createList('member', 2);
|
||||
|
||||
await visit('/members');
|
||||
|
||||
|
@ -1160,9 +1160,9 @@ describe('Acceptance: Members filtering', function () {
|
|||
|
||||
it('can handle multiple filters', async function () {
|
||||
// add some members to filter
|
||||
this.server.createList('member', 1, {subscriptions: [{status: 'active'}]});
|
||||
this.server.createList('member', 2, {subscriptions: [{status: 'trialing'}]});
|
||||
this.server.createList('member', 3, {emailOpenRate: 50, subscriptions: [{status: 'trialing'}]});
|
||||
this.server.createList('member', 1).forEach(member => this.server.create('subscription', {member, status: 'active'}));
|
||||
this.server.createList('member', 2).forEach(member => this.server.create('subscription', {member, status: 'trialing'}));
|
||||
this.server.createList('member', 3, {emailOpenRate: 50}).forEach(member => this.server.create('subscription', {member, status: 'trialing'}));
|
||||
this.server.createList('member', 4, {emailOpenRate: 100});
|
||||
|
||||
await visit('/members');
|
||||
|
@ -1216,7 +1216,7 @@ describe('Acceptance: Members filtering', function () {
|
|||
});
|
||||
|
||||
it('has a no-match state', async function () {
|
||||
this.server.createList('member', 5, {subscriptions: [{status: 'active'}]});
|
||||
this.server.createList('member', 5).forEach(member => this.server.create('subscription', {member, status: 'active'}));
|
||||
|
||||
await visit('/members');
|
||||
|
||||
|
@ -1259,7 +1259,7 @@ describe('Acceptance: Members filtering', function () {
|
|||
// meaning you could have an "is-greater" operator applied to an
|
||||
// "is/is-not" filter type
|
||||
|
||||
this.server.createList('member', 3, {subscriptions: [{status: 'active'}]});
|
||||
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, status: 'active'}));
|
||||
this.server.createList('member', 4, {emailCount: 10});
|
||||
|
||||
await visit('/members');
|
||||
|
@ -1414,7 +1414,7 @@ describe('Acceptance: Members filtering', function () {
|
|||
});
|
||||
|
||||
it('can search + filter', async function () {
|
||||
this.server.create('member', {name: 'A', email: 'a@aaa.aaa', subscriptions: [{status: 'active'}]});
|
||||
this.server.create('member', {name: 'A', email: 'a@aaa.aaa', subscriptions: [this.server.create('subscription', {status: 'active'})]});
|
||||
|
||||
await visit('/members');
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue