From 7754d7af36a640ac5771f31ce5b06b9ab1035ecd Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 25 Nov 2024 23:58:12 +0700 Subject: [PATCH 01/16] Add subscription data section in account settings --- .../operator-mode/profile-info-popover.gts | 158 +++++------------ .../profile/profile-settings-modal.gts | 2 + .../profile/profile-subscription.gts | 167 ++++++++++++++++++ .../app/components/with-subscription-data.gts | 163 +++++++++++++++++ packages/host/app/config/environment.d.ts | 2 + packages/host/app/services/billing-service.ts | 35 ++++ packages/host/config/environment.js | 6 + packages/host/package.json | 1 + .../operator-mode-acceptance-test.gts | 72 +++++--- pnpm-lock.yaml | 3 + 10 files changed, 473 insertions(+), 136 deletions(-) create mode 100644 packages/host/app/components/operator-mode/profile/profile-subscription.gts create mode 100644 packages/host/app/components/with-subscription-data.gts diff --git a/packages/host/app/components/operator-mode/profile-info-popover.gts b/packages/host/app/components/operator-mode/profile-info-popover.gts index 7af1d2004c..a5802f3f61 100644 --- a/packages/host/app/components/operator-mode/profile-info-popover.gts +++ b/packages/host/app/components/operator-mode/profile-info-popover.gts @@ -4,16 +4,10 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import Component from '@glimmer/component'; -import { task } from 'ember-concurrency'; - -import { - Avatar, - BoxelButton, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; +import { Avatar, BoxelButton } from '@cardstack/boxel-ui/components'; import { cn } from '@cardstack/boxel-ui/helpers'; -import { IconHexagon } from '@cardstack/boxel-ui/icons'; +import WithSubscriptionData from '@cardstack/host/components/with-subscription-data'; import config from '@cardstack/host/config/environment'; import BillingService from '@cardstack/host/services/billing-service'; import MatrixService from '@cardstack/host/services/matrix-service'; @@ -140,125 +134,57 @@ export default class ProfileInfoPopover extends Component {{! TODO: Remove config.APP.stripeBillingEnabled once the API integration for credit info is completed. }} {{#if config.APP.stripeBillingEnabled}} -
-
- Membership Tier - - {{#if this.isLoading}} - - {{else}} - {{this.plan}} - {{/if}} - -
- Upgrade Plan -
- Monthly Credit - - {{#if this.isLoading}} - - {{else}} - - {{this.monthlyCreditText}} - {{/if}} - -
-
- Additional Credit - {{#if this.isLoading}} - - {{else}} - - {{this.extraCreditsAvailableInBalance}} - {{/if}} -
-
+ +
+
+ Membership Tier + {{subscriptionData.plan}} +
Buy more credits + @kind='secondary-light' + @size='small' + @disabled={{subscriptionData.isLoading}} + data-test-upgrade-plan-button + {{on 'click' this.billingService.managePlan}} + >Upgrade Plan +
+ Monthly Credit + {{subscriptionData.monthlyCredit}} +
+
+ Additional Credit + {{subscriptionData.additionalCredit}} +
+
+ Buy more credits +
-
+ {{/if}}
- constructor(...args: [any, any]) { - super(...args); - this.fetchCreditInfo.perform(); - } - - @service private declare billingService: BillingService; @service declare matrixService: MatrixService; - - private fetchCreditInfo = task(async () => { - await this.billingService.fetchSubscriptionData(); - }); + @service declare billingService: BillingService; @action private logout() { this.matrixService.logout(); } - - private get isLoading() { - return this.billingService.fetchingSubscriptionData; - } - - private get plan() { - return this.billingService.subscriptionData?.plan; - } - - private get creditsIncludedInPlanAllowance() { - return this.billingService.subscriptionData?.creditsIncludedInPlanAllowance; - } - - private get creditsAvailableInPlanAllowance() { - return this.billingService.subscriptionData - ?.creditsAvailableInPlanAllowance; - } - - private get extraCreditsAvailableInBalance() { - return this.billingService.subscriptionData?.extraCreditsAvailableInBalance; - } - - private get monthlyCreditText() { - return this.creditsAvailableInPlanAllowance != null && - this.creditsIncludedInPlanAllowance != null - ? `${this.creditsAvailableInPlanAllowance} of ${this.creditsIncludedInPlanAllowance} left` - : null; - } - - private get isOutOfCredit() { - return ( - this.isOutOfPlanCreditAllowance && - (this.extraCreditsAvailableInBalance == null || - this.extraCreditsAvailableInBalance == 0) - ); - } - - private get isOutOfPlanCreditAllowance() { - return ( - this.creditsAvailableInPlanAllowance == null || - this.creditsIncludedInPlanAllowance == null || - this.creditsAvailableInPlanAllowance <= 0 - ); - } } export class ProfileInfo extends Component { diff --git a/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts b/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts index 4a5251d3a5..56c07a37a4 100644 --- a/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts +++ b/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts @@ -28,6 +28,7 @@ import { isValidPassword } from '@cardstack/host/lib/matrix-utils'; import MatrixService from '@cardstack/host/services/matrix-service'; import ProfileEmail from './profile-email'; +import ProfileSubscription from './profile-subscription'; interface Signature { Args: { @@ -126,6 +127,7 @@ export default class ProfileSettingsModal extends Component { @changeEmailComplete={{this.completeEmail}} /> {{/if}} + {{#if (or (bool this.displayNameError) (bool this.error))}}
diff --git a/packages/host/app/components/operator-mode/profile/profile-subscription.gts b/packages/host/app/components/operator-mode/profile/profile-subscription.gts new file mode 100644 index 0000000000..95cc66df9a --- /dev/null +++ b/packages/host/app/components/operator-mode/profile/profile-subscription.gts @@ -0,0 +1,167 @@ +import { fn } from '@ember/helper'; + +import { on } from '@ember/modifier'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import { trackedFunction } from 'ember-resources/util/function'; + +import { + BoxelButton, + FieldContainer, + LoadingIndicator, +} from '@cardstack/boxel-ui/components'; +import { IconHexagon } from '@cardstack/boxel-ui/icons'; + +import WithSubscriptionData from '@cardstack/host/components/with-subscription-data'; +import BillingService from '@cardstack/host/services/billing-service'; + +interface Signature { + Args: {}; + Element: HTMLElement; +} + +export default class ProfileSubscription extends Component { + + + @service private declare billingService: BillingService; + + private get paymentLinks() { + return this.fetchPaymentLinks.value ?? []; + } + + private fetchPaymentLinks = trackedFunction(this, async () => { + return await this.billingService.fetchPaymentLinks(); + }); + + private pay(paymentLinkURL: string) { + window.open(paymentLinkURL); + } +} diff --git a/packages/host/app/components/with-subscription-data.gts b/packages/host/app/components/with-subscription-data.gts new file mode 100644 index 0000000000..02e5096dca --- /dev/null +++ b/packages/host/app/components/with-subscription-data.gts @@ -0,0 +1,163 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { hash } from '@ember/helper'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import { task } from 'ember-concurrency'; + +import { LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { cn } from '@cardstack/boxel-ui/helpers'; +import { IconHexagon } from '@cardstack/boxel-ui/icons'; + +import BillingService from '../services/billing-service'; + +import type { ComponentLike } from '@glint/template'; + +interface ValueSignature { + Args: { + tag: string; + value: string | number | null; + isOutOfCredit: boolean; + isLoading: boolean; + displayCreditIcon: boolean; + }; + Element: HTMLElement; +} + +const Value: TemplateOnlyComponent = ; + +interface WithSubscriptionDataSignature { + Args: {}; + Blocks: { + default: [ + { + plan: ComponentLike; + monthlyCredit: ComponentLike; + additionalCredit: ComponentLike; + isOutOfCredit: boolean; + isLoading: boolean; + }, + ]; + }; +} + +export default class WithSubscriptionData extends Component { + @service declare billingService: BillingService; + + constructor(...args: [any, any]) { + super(...args); + this.fetchCreditInfo.perform(); + } + + private fetchCreditInfo = task(async () => { + await this.billingService.fetchSubscriptionData(); + }); + + private get isLoading() { + return ( + this.fetchCreditInfo.isRunning || + this.billingService.fetchingSubscriptionData + ); + } + + private get plan() { + return this.billingService.subscriptionData?.plan; + } + + private get creditsIncludedInPlanAllowance() { + return this.billingService.subscriptionData?.creditsIncludedInPlanAllowance; + } + + private get creditsAvailableInPlanAllowance() { + return this.billingService.subscriptionData + ?.creditsAvailableInPlanAllowance; + } + + private get extraCreditsAvailableInBalance() { + return this.billingService.subscriptionData?.extraCreditsAvailableInBalance; + } + + private get monthlyCreditText() { + return this.creditsAvailableInPlanAllowance != null && + this.creditsIncludedInPlanAllowance != null + ? `${this.creditsAvailableInPlanAllowance} of ${this.creditsIncludedInPlanAllowance} left` + : null; + } + + private get isOutOfCredit() { + return ( + this.isOutOfPlanCreditAllowance && + (this.extraCreditsAvailableInBalance == null || + this.extraCreditsAvailableInBalance == 0) + ); + } + + private get isOutOfPlanCreditAllowance() { + return ( + this.creditsAvailableInPlanAllowance == null || + this.creditsIncludedInPlanAllowance == null || + this.creditsAvailableInPlanAllowance <= 0 + ); + } + + +} diff --git a/packages/host/app/config/environment.d.ts b/packages/host/app/config/environment.d.ts index f7ba4b08a8..ebbffb39c8 100644 --- a/packages/host/app/config/environment.d.ts +++ b/packages/host/app/config/environment.d.ts @@ -28,4 +28,6 @@ declare const config: { sqlSchema: string; assetsURL: string; featureFlags?: {}; + stripeCustomerPortalLink: string; + stripeApiKey: string; }; diff --git a/packages/host/app/services/billing-service.ts b/packages/host/app/services/billing-service.ts index a5741abe41..247d515051 100644 --- a/packages/host/app/services/billing-service.ts +++ b/packages/host/app/services/billing-service.ts @@ -5,12 +5,18 @@ import { tracked } from '@glimmer/tracking'; import { dropTask } from 'ember-concurrency'; +import Stripe from 'stripe'; + import { SupportedMimeType } from '@cardstack/runtime-common'; +import environment from '../config/environment'; + import NetworkService from './network'; import RealmServerService from './realm-server'; import ResetService from './reset'; +const stripe = new Stripe(environment.stripeApiKey); + interface SubscriptionData { plan: string | null; creditsAvailableInPlanAllowance: number | null; @@ -18,8 +24,14 @@ interface SubscriptionData { extraCreditsAvailableInBalance: number | null; } +interface PaymentLink { + url: string; + creditReloadAmount: number; +} + export default class BillingService extends Service { @tracked private _subscriptionData: SubscriptionData | null = null; + private _paymentLinks: PaymentLink[] | null = null; @service private declare realmServer: RealmServerService; @service private declare network: NetworkService; @@ -36,6 +48,29 @@ export default class BillingService extends Service { resetState() { this._subscriptionData = null; + this._paymentLinks = null; + } + + managePlan() { + window.open(environment.stripeCustomerPortalLink); + } + + async fetchPaymentLinks() { + if (!this._paymentLinks) { + let response = await stripe.paymentLinks.list(); + this._paymentLinks = response.data + .filter((data) => data.metadata.credit_reload_amount) + .map((data) => ({ + url: data.url, + creditReloadAmount: Number(data.metadata.credit_reload_amount), + })) + .sort( + (paymentLinkA, paymentLinkB) => + paymentLinkA.creditReloadAmount - paymentLinkB.creditReloadAmount, + ); + } + + return this._paymentLinks; } get subscriptionData() { diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index 44f2dfb8cf..df713b58df 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -46,6 +46,12 @@ module.exports = function (environment) { resolvedBaseRealmURL: process.env.RESOLVED_BASE_REALM_URL || 'http://localhost:4201/base/', featureFlags: {}, + stripeCustomerPortalLink: + process.env.STRIPE_CUSTOMER_PORTAL_LINK || + 'https://billing.stripe.com/p/login/test_cN216h3BlbML3FS144', + stripeApiKey: + process.env.STRIPE_API_KEY || + 'sk_test_51Q8hkbPUHhctoJxanM9uVZban3mK5he2g1s7r1gKvBXTpejGxUdJHSjWLlmB8LzIaffiQj9Bj96ag0OxTwNl5BGk00Ov4gjcBC', }; if (environment === 'development') { diff --git a/packages/host/package.json b/packages/host/package.json index d9f60793da..b14c9e920b 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -165,6 +165,7 @@ "safe-stable-stringify": "^2.4.3", "start-server-and-test": "^1.14.0", "stream-browserify": "^3.0.0", + "stripe": "^17.2.1", "super-fast-md5": "^1.0.1", "testem": "3.10.1", "testem-multi-reporter": "^1.2.0", diff --git a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts index 7de1a43647..1444c04098 100644 --- a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts +++ b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts @@ -883,11 +883,19 @@ module('Acceptance | operator mode tests', function (hooks) { await click('[data-test-profile-icon-button]'); assert.dom('[data-test-profile-popover]').exists(); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('1000 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasNoClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('100'); - assert.dom('[data-test-additional-credit]').hasNoClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('1000 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasNoClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('100'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); assert.dom('[data-test-upgrade-plan-button]').exists(); assert.dom('[data-test-buy-more-credits]').exists(); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); @@ -918,11 +926,19 @@ module('Acceptance | operator mode tests', function (hooks) { }); await click('[data-test-profile-icon-button]'); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('0 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('100'); - assert.dom('[data-test-additional-credit]').hasNoClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('0 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('100'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); await click('[data-test-profile-icon-button]'); @@ -933,11 +949,19 @@ module('Acceptance | operator mode tests', function (hooks) { body: JSON.stringify({ eventType: 'billing-notification' }), }); await click('[data-test-profile-icon-button]'); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('0 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('0'); - assert.dom('[data-test-additional-credit]').hasClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('0 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('0'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasClass('out-of-credit'); await click('[data-test-profile-icon-button]'); @@ -948,11 +972,19 @@ module('Acceptance | operator mode tests', function (hooks) { body: JSON.stringify({ eventType: 'billing-notification' }), }); await click('[data-test-profile-icon-button]'); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('1000 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasNoClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('0'); - assert.dom('[data-test-additional-credit]').hasNoClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('1000 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasNoClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('0'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee7c1bb03c..1ef7b84464 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1635,6 +1635,9 @@ importers: stream-browserify: specifier: ^3.0.0 version: 3.0.0 + stripe: + specifier: ^17.2.1 + version: 17.2.1 super-fast-md5: specifier: ^1.0.1 version: 1.0.1 From 8ae86cdab1b34fc9a134f46d35ba5714a47e6f6e Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 26 Nov 2024 11:38:02 +0700 Subject: [PATCH 02/16] Add assertions --- .../profile/profile-subscription.gts | 36 +++++++++----- packages/host/app/services/billing-service.ts | 33 ++++++++----- .../operator-mode-acceptance-test.gts | 48 ++++++++++++++++--- 3 files changed, 87 insertions(+), 30 deletions(-) diff --git a/packages/host/app/components/operator-mode/profile/profile-subscription.gts b/packages/host/app/components/operator-mode/profile/profile-subscription.gts index 95cc66df9a..8d31b0be1b 100644 --- a/packages/host/app/components/operator-mode/profile/profile-subscription.gts +++ b/packages/host/app/components/operator-mode/profile/profile-subscription.gts @@ -1,10 +1,13 @@ import { fn } from '@ember/helper'; import { on } from '@ember/modifier'; +import Owner from '@ember/owner'; import { service } from '@ember/service'; import Component from '@glimmer/component'; -import { trackedFunction } from 'ember-resources/util/function'; +import { task } from 'ember-concurrency'; + +import window from 'ember-window-mock'; import { BoxelButton, @@ -60,22 +63,25 @@ export default class ProfileSubscription extends Component {
Buy more credits
@@ -103,6 +109,7 @@ export default class ProfileSubscription extends Component { gap: var(--boxel-sp-xs); padding-left: var(--boxel-sp-sm); border-left: 5px solid #c6c6c6; + min-height: 40px; } .credit-info__label { font: var(--boxel-font-xs); @@ -148,16 +155,21 @@ export default class ProfileSubscription extends Component { --icon-color: var(--boxel-teal); --boxel-loading-indicator-size: var(--boxel-icon-xs); } + :deep(.boxel-loading-indicator) { + width: 100%; + text-align: center; + } @service private declare billingService: BillingService; - private get paymentLinks() { - return this.fetchPaymentLinks.value ?? []; + constructor(owner: Owner, args: any) { + super(owner, args); + this.fetchPaymentLinks.perform(); } - private fetchPaymentLinks = trackedFunction(this, async () => { + private fetchPaymentLinks = task(async () => { return await this.billingService.fetchPaymentLinks(); }); diff --git a/packages/host/app/services/billing-service.ts b/packages/host/app/services/billing-service.ts index 247d515051..e22eedf77c 100644 --- a/packages/host/app/services/billing-service.ts +++ b/packages/host/app/services/billing-service.ts @@ -5,6 +5,7 @@ import { tracked } from '@glimmer/tracking'; import { dropTask } from 'ember-concurrency'; +import window from 'ember-window-mock'; import Stripe from 'stripe'; import { SupportedMimeType } from '@cardstack/runtime-common'; @@ -31,7 +32,7 @@ interface PaymentLink { export default class BillingService extends Service { @tracked private _subscriptionData: SubscriptionData | null = null; - private _paymentLinks: PaymentLink[] | null = null; + @tracked private _paymentLinks: PaymentLink[] | null = null; @service private declare realmServer: RealmServerService; @service private declare network: NetworkService; @@ -57,22 +58,30 @@ export default class BillingService extends Service { async fetchPaymentLinks() { if (!this._paymentLinks) { - let response = await stripe.paymentLinks.list(); - this._paymentLinks = response.data - .filter((data) => data.metadata.credit_reload_amount) - .map((data) => ({ - url: data.url, - creditReloadAmount: Number(data.metadata.credit_reload_amount), - })) - .sort( - (paymentLinkA, paymentLinkB) => - paymentLinkA.creditReloadAmount - paymentLinkB.creditReloadAmount, - ); + await this.fetchPaymentLinksTask.perform(); } return this._paymentLinks; } + get paymentLinks() { + return this._paymentLinks; + } + + private fetchPaymentLinksTask = dropTask(async () => { + let response = await stripe.paymentLinks.list(); + this._paymentLinks = response.data + .filter((data) => data.metadata.credit_reload_amount) + .map((data) => ({ + url: data.url, + creditReloadAmount: Number(data.metadata.credit_reload_amount), + })) + .sort( + (paymentLinkA, paymentLinkB) => + paymentLinkA.creditReloadAmount - paymentLinkB.creditReloadAmount, + ); + }); + get subscriptionData() { return this._subscriptionData; } diff --git a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts index 1444c04098..2722199f7f 100644 --- a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts +++ b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts @@ -867,6 +867,25 @@ module('Acceptance | operator mode tests', function (hooks) { }); test(`displays credit info in account popover`, async function (assert) { + let countWindowOpenCalled = 0; + let mockWindow = { + closeCalled: false, + close() { + this.closeCalled = true; + }, + }; + + const originalWindowOpen = window.open; + window.open = (url, _target, _feature) => { + assert.true( + typeof url === 'string' + ? url.includes('stripe') + : url?.href.includes('stripe'), + ); + countWindowOpenCalled++; + return mockWindow as unknown as Window; + }; + await visitOperatorMode({ submode: 'interact', codePath: `${testRealmURL}employee.gts`, @@ -901,19 +920,35 @@ module('Acceptance | operator mode tests', function (hooks) { assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); await click('[data-test-upgrade-plan-button]'); - assert.dom('[data-test-profile-popover]').doesNotExist(); - assert - .dom('[data-test-boxel-card-container]') - .hasClass('profile-settings'); - await click('[aria-label="close modal"]'); - await click('[data-test-profile-icon-button]'); assert.dom('[data-test-profile-popover]').exists(); await click('[data-test-buy-more-credits] button'); assert.dom('[data-test-profile-popover]').doesNotExist(); assert .dom('[data-test-boxel-card-container]') .hasClass('profile-settings'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('1000 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasNoClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('100'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); + await waitFor('[data-test-payment-link]'); + assert.dom('[data-test-payment-link]').exists({ count: 3 }); + await click('[data-test-manage-plan-button]'); + await click('[data-test-pay-button]'); + assert.strictEqual( + countWindowOpenCalled, + 3, + 'Correct number of times window.open was called', + ); // out of credit await click('[aria-label="close modal"]'); @@ -986,6 +1021,7 @@ module('Acceptance | operator mode tests', function (hooks) { .dom('[data-test-subscription-data="additional-credit"]') .hasNoClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); + window.open = originalWindowOpen; }); }); }); From fdeeb5a415b538574426fc777ee1804256256507 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 26 Nov 2024 11:42:56 +0700 Subject: [PATCH 03/16] Fix css --- .../components/operator-mode/profile/profile-subscription.gts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/app/components/operator-mode/profile/profile-subscription.gts b/packages/host/app/components/operator-mode/profile/profile-subscription.gts index 8d31b0be1b..0c1a700332 100644 --- a/packages/host/app/components/operator-mode/profile/profile-subscription.gts +++ b/packages/host/app/components/operator-mode/profile/profile-subscription.gts @@ -155,7 +155,7 @@ export default class ProfileSubscription extends Component { --icon-color: var(--boxel-teal); --boxel-loading-indicator-size: var(--boxel-icon-xs); } - :deep(.boxel-loading-indicator) { + :deep(.buy-more-credits .boxel-loading-indicator) { width: 100%; text-align: center; } From b8ece73b06fda8b366a896c8ed4a38c025767644 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 27 Nov 2024 13:03:06 +0700 Subject: [PATCH 04/16] Consume stripe links from realm server endpoint --- .../operator-mode/profile-info-popover.gts | 17 ++++- .../profile/profile-subscription.gts | 34 +++++++-- packages/host/app/config/environment.d.ts | 2 - packages/host/app/services/billing-service.ts | 75 ++++++++++++------- packages/host/config/environment.js | 6 -- packages/host/package.json | 1 - .../operator-mode-acceptance-test.gts | 3 +- packages/host/tests/helpers/index.gts | 45 +++++++++++ pnpm-lock.yaml | 3 - 9 files changed, 133 insertions(+), 53 deletions(-) diff --git a/packages/host/app/components/operator-mode/profile-info-popover.gts b/packages/host/app/components/operator-mode/profile-info-popover.gts index a5802f3f61..70341a88b6 100644 --- a/packages/host/app/components/operator-mode/profile-info-popover.gts +++ b/packages/host/app/components/operator-mode/profile-info-popover.gts @@ -4,8 +4,12 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import Component from '@glimmer/component'; +import { restartableTask } from 'ember-concurrency'; + +import perform from 'ember-concurrency/helpers/perform'; + import { Avatar, BoxelButton } from '@cardstack/boxel-ui/components'; -import { cn } from '@cardstack/boxel-ui/helpers'; +import { cn, or } from '@cardstack/boxel-ui/helpers'; import WithSubscriptionData from '@cardstack/host/components/with-subscription-data'; import config from '@cardstack/host/config/environment'; @@ -143,9 +147,12 @@ export default class ProfileInfoPopover extends ComponentUpgrade Plan
Monthly Credit @@ -185,6 +192,10 @@ export default class ProfileInfoPopover extends Component { + await this.billingService.managePlan(); + }); } export class ProfileInfo extends Component { diff --git a/packages/host/app/components/operator-mode/profile/profile-subscription.gts b/packages/host/app/components/operator-mode/profile/profile-subscription.gts index 0c1a700332..bbf0c69772 100644 --- a/packages/host/app/components/operator-mode/profile/profile-subscription.gts +++ b/packages/host/app/components/operator-mode/profile/profile-subscription.gts @@ -5,8 +5,9 @@ import Owner from '@ember/owner'; import { service } from '@ember/service'; import Component from '@glimmer/component'; -import { task } from 'ember-concurrency'; +import { restartableTask, task } from 'ember-concurrency'; +import perform from 'ember-concurrency/helpers/perform'; import window from 'ember-window-mock'; import { @@ -14,6 +15,7 @@ import { FieldContainer, LoadingIndicator, } from '@cardstack/boxel-ui/components'; +import { or } from '@cardstack/boxel-ui/helpers'; import { IconHexagon } from '@cardstack/boxel-ui/icons'; import WithSubscriptionData from '@cardstack/host/components/with-subscription-data'; @@ -43,8 +45,12 @@ export default class ProfileSubscription extends Component { Manage Plan
@@ -63,11 +69,11 @@ export default class ProfileSubscription extends Component {
Buy more credits