diff --git a/app/components/billing-status-badge/member-badge.hbs b/app/components/billing-status-badge/member-badge.hbs index 8d6a709825..abf32b2657 100644 --- a/app/components/billing-status-badge/member-badge.hbs +++ b/app/components/billing-status-badge/member-badge.hbs @@ -1,4 +1,4 @@ - + Member diff --git a/app/components/header/account-dropdown.ts b/app/components/header/account-dropdown.ts index 59d46772b3..6cdaff1dc9 100644 --- a/app/components/header/account-dropdown.ts +++ b/app/components/header/account-dropdown.ts @@ -52,7 +52,7 @@ export default class AccountDropdownComponent extends Component { @action async handleManageSubscriptionClick(dropdownActions: { close: () => void }) { dropdownActions.close(); - this.router.transitionTo('membership'); + this.router.transitionTo('settings.billing'); } @action diff --git a/app/components/membership-page/actions-section.hbs b/app/components/membership-page/actions-section.hbs deleted file mode 100644 index 92bef9579f..0000000000 --- a/app/components/membership-page/actions-section.hbs +++ /dev/null @@ -1,26 +0,0 @@ - - {{#if @subscription.isInactive}} - - Start Membership - - {{/if}} - - - Update Payment Method - - -
- Questions? Write to us at - hello@codecrafters.io - and we'll help sort things out. -
-
\ No newline at end of file diff --git a/app/components/membership-page/actions-section.js b/app/components/membership-page/actions-section.js deleted file mode 100644 index 2359f4db90..0000000000 --- a/app/components/membership-page/actions-section.js +++ /dev/null @@ -1,23 +0,0 @@ -import { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; -import Component from '@glimmer/component'; -import window from 'ember-window-mock'; - -export default class ActionsSectionComponent extends Component { - @tracked isCreatingPaymentMethodUpdateRequest = false; - @service router; - @service store; - - @action - async handleStartMembershipButtonClick() { - this.router.transitionTo('pay'); - } - - @action - async handleUpdatePaymentMethodButtonClick() { - this.isCreatingPaymentMethodUpdateRequest = true; - const paymentMethodUpdateRequest = await this.store.createRecord('individual-payment-method-update-request').save(); - window.location.href = paymentMethodUpdateRequest.url; - } -} diff --git a/app/components/membership-page/membership-plan-section.hbs b/app/components/membership-page/membership-plan-section.hbs deleted file mode 100644 index e1e18dc610..0000000000 --- a/app/components/membership-page/membership-plan-section.hbs +++ /dev/null @@ -1,29 +0,0 @@ -{{! @glint-nocheck: not typesafe yet }} - -
- {{#if @subscription.isActive}} - {{#if @subscription.cancelAt}} -

- Your CodeCrafters membership is valid until - {{date-format @subscription.cancelAt format="PPPp"}}. -

-

Your membership doesn’t renew automatically. To restart your membership, make a new one-time payment.

- {{else}} - You are currently subscribed to the - {{@subscription.pricingPlanName}} - plan. - {{/if}} - {{else if @subscription.isInactive}} - Your CodeCrafters membership is - currently inactive. - {{/if}} -
-
- {{#if (and @subscription.user.isVip @subscription.user.vipStatusExpiresAt)}} - πŸŽ‰ You have VIP access to all CodeCrafters content, valid until - {{date-format @subscription.user.vipStatusExpiresAt format="PPPp"}}. - {{else if @subscription.user.isVip}} - πŸŽ‰ You have VIP access to all CodeCrafters content. - {{/if}} -
-
\ No newline at end of file diff --git a/app/components/membership-page/membership-plan-section.js b/app/components/membership-page/membership-plan-section.js deleted file mode 100644 index 799d149ba9..0000000000 --- a/app/components/membership-page/membership-plan-section.js +++ /dev/null @@ -1,3 +0,0 @@ -import Component from '@glimmer/component'; - -export default class MembershipPlanSectionComponent extends Component {} diff --git a/app/components/membership-page/payment-method-section.hbs b/app/components/membership-page/payment-method-section.hbs deleted file mode 100644 index 85686837bf..0000000000 --- a/app/components/membership-page/payment-method-section.hbs +++ /dev/null @@ -1,9 +0,0 @@ -{{! @glint-nocheck: not typesafe yet }} - -
- The payment method linked to your account is a - Mastercard - ending in - 1234. -
-
\ No newline at end of file diff --git a/app/components/membership-page/payment-method-section.js b/app/components/membership-page/payment-method-section.js deleted file mode 100644 index 12b4b9ede8..0000000000 --- a/app/components/membership-page/payment-method-section.js +++ /dev/null @@ -1,3 +0,0 @@ -import Component from '@glimmer/component'; - -export default class PaymentMethodSectionComponent extends Component {} diff --git a/app/components/membership-page/recent-payments-section.hbs b/app/components/membership-page/recent-payments-section.hbs deleted file mode 100644 index cf14bb8802..0000000000 --- a/app/components/membership-page/recent-payments-section.hbs +++ /dev/null @@ -1,51 +0,0 @@ - - {{#if this.isLoading}} -
- Loading icon - Loading recent payments... -
- {{else if (gt this.charges.length 0)}} -
- {{! Header }} -
Date
-
Amount
-
- - {{#each this.charges as |charge|}} -
{{date-format charge.createdAt format="PPP"}}
- -
- {{charge.displayString}} - {{#if (gt charge.amountRefunded 0)}} - {{#if charge.isFullyRefunded}} - (refunded) - {{else}} - ({{charge.refundedAmountDisplayString}} - refunded) - {{/if}} - {{/if}} -
- -
- {{#if (and charge.invoiceId charge.statusIsSucceeded)}} - - Download Invoice - - {{else if charge.statusIsFailed}} - Payment failed - {{/if}} -
- {{/each}} -
- {{else}} -
- No recent payments found. -
- {{/if}} -
\ No newline at end of file diff --git a/app/components/membership-page/recent-payments-section.js b/app/components/membership-page/recent-payments-section.js deleted file mode 100644 index 7f48fc1989..0000000000 --- a/app/components/membership-page/recent-payments-section.js +++ /dev/null @@ -1,20 +0,0 @@ -import { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; -import fade from 'ember-animated/transitions/fade'; -import Component from '@glimmer/component'; -import rippleSpinnerImage from '/assets/images/icons/ripple-spinner.svg'; - -export default class RecentPaymentsSectionComponent extends Component { - rippleSpinnerImage = rippleSpinnerImage; - @tracked charges = []; - @tracked isLoading = true; - @service store; - transition = fade; - - @action - async handleDidInsert() { - this.charges = await this.store.findAll('charge'); - this.isLoading = false; - } -} diff --git a/app/components/membership-page/section.hbs b/app/components/membership-page/section.hbs deleted file mode 100644 index 63ee6cb38d..0000000000 --- a/app/components/membership-page/section.hbs +++ /dev/null @@ -1,10 +0,0 @@ -{{! @glint-nocheck: not typesafe yet }} -
-
-
- {{@title}} -
-
- - {{yield}} -
\ No newline at end of file diff --git a/app/components/membership-page/section.js b/app/components/membership-page/section.js deleted file mode 100644 index 322f02f8f3..0000000000 --- a/app/components/membership-page/section.js +++ /dev/null @@ -1,3 +0,0 @@ -import Component from '@glimmer/component'; - -export default class SectionComponent extends Component {} diff --git a/app/components/membership-page/upcoming-payment-section.hbs b/app/components/membership-page/upcoming-payment-section.hbs deleted file mode 100644 index da3328e1b9..0000000000 --- a/app/components/membership-page/upcoming-payment-section.hbs +++ /dev/null @@ -1,17 +0,0 @@ - - {{#if this.isLoading}} -
- Loading icon - Loading upcoming payments... -
- {{else if this.nextInvoicePreview}} - Your payment method will be charged - ${{if this.isLoading "β‹―" this.nextInvoicePreview.amountDueInDollars}} - on - {{if this.isLoading "β‹―" (date-format this.nextInvoicePreview.createdAt format="PPP")}}. - {{else}} -
- No upcoming payment. -
- {{/if}} -
\ No newline at end of file diff --git a/app/components/membership-page/upcoming-payment-section.js b/app/components/membership-page/upcoming-payment-section.js deleted file mode 100644 index 4f8660319c..0000000000 --- a/app/components/membership-page/upcoming-payment-section.js +++ /dev/null @@ -1,19 +0,0 @@ -import { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; -import Component from '@glimmer/component'; -import rippleSpinnerImage from '/assets/images/icons/ripple-spinner.svg'; - -export default class UpcomingPaymentSectionComponent extends Component { - rippleSpinnerImage = rippleSpinnerImage; - - @tracked isLoading = true; - @tracked nextInvoicePreview; - @service authenticator; - - @action - async handleDidInsert() { - this.nextInvoicePreview = await this.authenticator.currentUser.fetchNextInvoicePreview(); - this.isLoading = false; - } -} diff --git a/app/components/settings/billing-page/membership-section.hbs b/app/components/settings/billing-page/membership-section.hbs new file mode 100644 index 0000000000..14b61c52a9 --- /dev/null +++ b/app/components/settings/billing-page/membership-section.hbs @@ -0,0 +1,68 @@ + + {{#if @user.hasActiveSubscription}} + + Membership active + +
+ {{#if (and @user.isVip (gt @user.vipStatusExpiresAt @user.activeSubscription.cancelAt))}} +

+ You have access to all CodeCrafters content, valid until + {{date-format @user.activeSubscription.cancelAt format="PPPp"}}. +

+

+ πŸŽ‰ You have VIP access to all CodeCrafters content, valid until + {{date-format @user.vipStatusExpiresAt format="PPPp"}}. +

+ {{else}} +

+ You have access to all CodeCrafters content, valid until + {{date-format @user.activeSubscription.cancelAt format="PPPp"}}. +

+ {{/if}} +
+ {{else if @user.isVip}} + + VIP Access + +
+

+ {{#if @user.vipStatusExpiresAt}} + πŸŽ‰ You have VIP access to all CodeCrafters content, valid until + {{date-format @user.vipStatusExpiresAt format="PPPp"}}. + {{else}} + πŸŽ‰ You have VIP access to all CodeCrafters content. + {{/if}} +

+
+ {{else if @user.expiredSubscription}} + + Membership inactive + +
+

+ Your CodeCrafters membership is + currently inactive. +

+

+ Start a new membership to get access to + membership benefits. +

+
+ + Start membership β†’ + + {{else}} + + No membership found + +
+

+ You don't have a CodeCrafters membership. Start one to get access to + membership benefits. +

+
+ + Start membership β†’ + + {{/if}} +
\ No newline at end of file diff --git a/app/components/settings/billing-page/membership-section.ts b/app/components/settings/billing-page/membership-section.ts new file mode 100644 index 0000000000..fb9de128a3 --- /dev/null +++ b/app/components/settings/billing-page/membership-section.ts @@ -0,0 +1,18 @@ +import Component from '@glimmer/component'; +import type UserModel from 'codecrafters-frontend/models/user'; + +interface Signature { + Element: HTMLDivElement; + + Args: { + user: UserModel; + }; +} + +export default class MembershipSectionComponent extends Component {} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'Settings::BillingPage::MembershipSection': typeof MembershipSectionComponent; + } +} diff --git a/app/components/settings/billing-page/membership-section/status-pill.hbs b/app/components/settings/billing-page/membership-section/status-pill.hbs new file mode 100644 index 0000000000..ea718ccdc6 --- /dev/null +++ b/app/components/settings/billing-page/membership-section/status-pill.hbs @@ -0,0 +1,20 @@ +
+
+
+ {{yield}} +
+
\ No newline at end of file diff --git a/app/components/settings/billing-page/membership-section/status-pill.ts b/app/components/settings/billing-page/membership-section/status-pill.ts new file mode 100644 index 0000000000..25c9216955 --- /dev/null +++ b/app/components/settings/billing-page/membership-section/status-pill.ts @@ -0,0 +1,19 @@ +import Component from '@glimmer/component'; + +interface Signature { + Element: HTMLDivElement; + Args: { + variant: 'teal' | 'red' | 'yellow'; + }; + Blocks: { + default: []; + }; +} + +export default class StatusPillComponent extends Component {} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'Settings::BillingPage::MembershipSection::StatusPill': typeof StatusPillComponent; + } +} diff --git a/app/components/settings/billing-page/payment-history-section.hbs b/app/components/settings/billing-page/payment-history-section.hbs new file mode 100644 index 0000000000..5b269690a8 --- /dev/null +++ b/app/components/settings/billing-page/payment-history-section.hbs @@ -0,0 +1,64 @@ + + {{#if this.isLoading}} +
+ Loading icon + Loading payment history... +
+ {{else if this.errorMessage}} +
+
+
+ {{svg-jar "x-circle" class="h-5 w-5 text-red-700 dark:text-red-300"}} +
+

{{this.errorMessage}}

+
+
+ {{else if (gt this.charges.length 0)}} +
+
Date
+
Amount
+
+ + {{#each this.charges as |charge|}} +
+ {{date-format charge.createdAt format="PPP"}} +
+
+ {{charge.displayString}} + {{#if (gt charge.amountRefunded 0)}} + {{#if charge.isFullyRefunded}} + (refunded) + {{else}} + ({{charge.refundedAmountDisplayString}} + refunded) + {{/if}} + {{/if}} +
+
+ {{#if (and charge.invoiceId charge.statusIsSucceeded)}} + + Download Invoice + + {{else if charge.statusIsFailed}} + Payment failed + {{/if}} +
+ {{/each}} +
+ {{else}} +
+ No payment history found. +
+ {{/if}} +
\ No newline at end of file diff --git a/app/components/settings/billing-page/payment-history-section.ts b/app/components/settings/billing-page/payment-history-section.ts new file mode 100644 index 0000000000..65961257b9 --- /dev/null +++ b/app/components/settings/billing-page/payment-history-section.ts @@ -0,0 +1,54 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import * as Sentry from '@sentry/ember'; +import rippleSpinnerImage from '/assets/images/icons/ripple-spinner.svg'; +import type Store from '@ember-data/store'; +import type UserModel from 'codecrafters-frontend/models/user'; +import type ChargeModel from 'codecrafters-frontend/models/charge'; + +interface Signature { + Element: HTMLDivElement; + Args: { + user: UserModel; + }; +} + +export default class PaymentHistorySectionComponent extends Component { + @service declare store: Store; + + rippleSpinnerImage = rippleSpinnerImage; + @tracked charges: ChargeModel[] = []; + @tracked errorMessage: string | null = null; + @tracked isLoading = true; + + constructor(owner: unknown, args: Signature['Args']) { + super(owner, args); + this.loadCharges(); + } + + async loadCharges() { + this.isLoading = true; + this.errorMessage = null; + + try { + const result = await this.store.query('charge', { + filter: { user_id: this.args.user.id }, + }); + this.charges = result.toArray(); + } catch (error) { + console.error('Failed to fetch charges:', error); + this.errorMessage = 'Failed to load payment history. Please contact us at hello@codecrafters.io if this error persists.'; + Sentry.captureException(error); + this.charges = []; + } finally { + this.isLoading = false; + } + } +} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'Settings::BillingPage::PaymentHistorySection': typeof PaymentHistorySectionComponent; + } +} diff --git a/app/components/settings/billing-page/renewal-section.hbs b/app/components/settings/billing-page/renewal-section.hbs new file mode 100644 index 0000000000..953545b906 --- /dev/null +++ b/app/components/settings/billing-page/renewal-section.hbs @@ -0,0 +1,12 @@ + +
+
+
+ Auto-renew disabled +
+
+ +
+ Your membership does not renew automatically. Once your membership expires, you'll be able to make a new one-time payment. +
+
\ No newline at end of file diff --git a/app/components/settings/billing-page/renewal-section.ts b/app/components/settings/billing-page/renewal-section.ts new file mode 100644 index 0000000000..7022713939 --- /dev/null +++ b/app/components/settings/billing-page/renewal-section.ts @@ -0,0 +1,13 @@ +import Component from '@glimmer/component'; + +interface Signature { + Element: HTMLDivElement; +} + +export default class RenewalSectionComponent extends Component {} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'Settings::BillingPage::RenewalSection': typeof RenewalSectionComponent; + } +} diff --git a/app/components/settings/billing-page/support-section.hbs b/app/components/settings/billing-page/support-section.hbs new file mode 100644 index 0000000000..5e374a24fe --- /dev/null +++ b/app/components/settings/billing-page/support-section.hbs @@ -0,0 +1,18 @@ + + + + Get help + + +
+

+ Questions? Click the button above or write to us at + hello@codecrafters.io + and we'll help sort things out. +

+
+
\ No newline at end of file diff --git a/app/components/settings/billing-page/support-section.ts b/app/components/settings/billing-page/support-section.ts new file mode 100644 index 0000000000..4295dea986 --- /dev/null +++ b/app/components/settings/billing-page/support-section.ts @@ -0,0 +1,17 @@ +import Component from '@glimmer/component'; +import type UserModel from 'codecrafters-frontend/models/user'; + +interface Signature { + Element: HTMLDivElement; + Args: { + user: UserModel; + }; +} + +export default class SupportSectionComponent extends Component {} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + 'Settings::BillingPage::SupportSection': typeof SupportSectionComponent; + } +} diff --git a/app/controllers/settings.ts b/app/controllers/settings.ts index f2ff73b2a2..05f7fdf3b6 100644 --- a/app/controllers/settings.ts +++ b/app/controllers/settings.ts @@ -15,6 +15,7 @@ export default class SettingsController extends Controller { get tabs() { return [ { route: 'settings.profile', label: 'Profile' }, + { route: 'settings.billing', label: 'Billing' }, { route: 'settings.account', label: 'Account' }, ]; } diff --git a/app/controllers/settings/billing.ts b/app/controllers/settings/billing.ts new file mode 100644 index 0000000000..35966972cd --- /dev/null +++ b/app/controllers/settings/billing.ts @@ -0,0 +1,11 @@ +import Controller from '@ember/controller'; +import type ChargeModel from 'codecrafters-frontend/models/charge'; +import type { ModelType as SettingsModelType } from 'codecrafters-frontend/routes/settings'; + +interface BillingModelType extends SettingsModelType { + charges: ChargeModel[]; +} + +export default class BillingController extends Controller { + declare model: BillingModelType; +} diff --git a/app/router.ts b/app/router.ts index 88872f955e..a20d933352 100644 --- a/app/router.ts +++ b/app/router.ts @@ -81,6 +81,7 @@ Router.map(function () { this.route('settings', function () { this.route('profile'); + this.route('billing'); this.route('account'); }); diff --git a/app/routes/membership.js b/app/routes/membership.js deleted file mode 100644 index 5c7dcd5491..0000000000 --- a/app/routes/membership.js +++ /dev/null @@ -1,21 +0,0 @@ -import BaseRoute from 'codecrafters-frontend/utils/base-route'; -import { inject as service } from '@ember/service'; - -export default class MembershipRoute extends BaseRoute { - @service store; - @service router; - @service authenticator; - - afterModel() { - // Force a sync of subscriptions - this.store.findAll('subscription'); - } - - async model() { - await this.authenticator.authenticate(); - - if (this.authenticator.currentUser && this.authenticator.currentUser.subscriptions.length === 0) { - this.router.transitionTo('pay'); - } - } -} diff --git a/app/routes/pay.ts b/app/routes/pay.ts index d772b999a1..5ed052e798 100644 --- a/app/routes/pay.ts +++ b/app/routes/pay.ts @@ -28,7 +28,7 @@ export default class PayRoute extends BaseRoute { await this.authenticator.authenticate(); if (this.authenticator.currentUser && this.authenticator.currentUser.hasActiveSubscription) { - this.router.transitionTo('membership'); + this.router.transitionTo('settings.billing'); return; } diff --git a/app/templates/membership.hbs b/app/templates/membership.hbs deleted file mode 100644 index 116b4a595b..0000000000 --- a/app/templates/membership.hbs +++ /dev/null @@ -1,25 +0,0 @@ -{{page-title "Manage Membership"}} - -
-
Manage Membership
- -
-
- - - - - {{!-- - - - --}} - -
- - -
-
\ No newline at end of file diff --git a/app/templates/settings/billing.hbs b/app/templates/settings/billing.hbs new file mode 100644 index 0000000000..a8d5ff8b52 --- /dev/null +++ b/app/templates/settings/billing.hbs @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/acceptance/course-page/view-course-stages-test.js b/tests/acceptance/course-page/view-course-stages-test.js index 78b101dadd..928e1d643c 100644 --- a/tests/acceptance/course-page/view-course-stages-test.js +++ b/tests/acceptance/course-page/view-course-stages-test.js @@ -747,7 +747,7 @@ module('Acceptance | course-page | view-course-stages-test', function (hooks) { }); }); - test('member badge redirects to /membership', async function (assert) { + test('member badge redirects to /settings/billing', async function (assert) { testScenario(this.server); signInAsSubscriber(this.owner, this.server); @@ -756,6 +756,6 @@ module('Acceptance | course-page | view-course-stages-test', function (hooks) { await courseOverviewPage.clickOnStartCourse(); await coursePage.header.memberBadge.click(); - assert.strictEqual(currentURL(), '/membership', 'expect to be redirected to membership page'); + assert.strictEqual(currentURL(), '/settings/billing', 'expect to be redirected to settings billing page'); }); }); diff --git a/tests/acceptance/header-test.js b/tests/acceptance/header-test.js index afc8044ac0..1c6f0a6f0b 100644 --- a/tests/acceptance/header-test.js +++ b/tests/acceptance/header-test.js @@ -50,13 +50,13 @@ module('Acceptance | header-test', function (hooks) { }); }); - test('member badge redirects to /membership', async function (assert) { + test('member badge redirects to /settings/billing', async function (assert) { testScenario(this.server); signInAsSubscriber(this.owner, this.server); await catalogPage.visit(); await catalogPage.header.memberBadge.click(); - assert.strictEqual(currentURL(), '/membership', 'expect to be redirected to membership page'); + assert.strictEqual(currentURL(), '/settings/billing', 'expect to be redirected to settings billing page'); }); }); diff --git a/tests/acceptance/manage-membership-test.js b/tests/acceptance/manage-membership-test.js deleted file mode 100644 index dfbd798e5f..0000000000 --- a/tests/acceptance/manage-membership-test.js +++ /dev/null @@ -1,106 +0,0 @@ -import { module, test } from 'qunit'; -import { setupApplicationTest } from 'codecrafters-frontend/tests/helpers'; -import { setupWindowMock } from 'ember-window-mock/test-support'; -import { signInAsSubscriber } from 'codecrafters-frontend/tests/support/authentication-helpers'; -import catalogPage from 'codecrafters-frontend/tests/pages/catalog-page'; -import membershipPage from 'codecrafters-frontend/tests/pages/membership-page'; -import testScenario from 'codecrafters-frontend/mirage/scenarios/test'; -import { currentURL } from '@ember/test-helpers'; - -module('Acceptance | manage-membership-test', function (hooks) { - setupApplicationTest(hooks); - setupWindowMock(hooks); - - test('subscriber can manage membership', async function (assert) { - testScenario(this.server); - signInAsSubscriber(this.owner, this.server); - - await catalogPage.visit(); - await catalogPage.accountDropdown.toggle(); - await catalogPage.accountDropdown.clickOnLink('Manage Membership'); - - assert.strictEqual(currentURL(), '/membership'); - }); - - test('subscriber that is a partner has correct membership plan copy', async function (assert) { - testScenario(this.server); - - const user = this.server.schema.users.first(); - user.update('isVip', true); - - signInAsSubscriber(this.owner, this.server, user); - - await catalogPage.visit(); - await catalogPage.accountDropdown.toggle(); - await catalogPage.accountDropdown.clickOnLink('Manage Membership'); - - assert.dom('[data-test-membership-plan-section] div:nth-of-type(3)').includesText('πŸŽ‰ You have VIP access to all CodeCrafters content.'); - }); - - test('subscriber that is a partner with expiry has correct membership plan copy', async function (assert) { - testScenario(this.server); - - const expiryDate = new Date(new Date().getTime() + 24 * 60 * 60 * 1000); - const user = this.server.schema.users.first(); - user.update('isVip', true); - user.update('vipStatusExpiresAt', expiryDate); - - signInAsSubscriber(this.owner, this.server, user); - - await catalogPage.visit(); - await catalogPage.accountDropdown.toggle(); - await catalogPage.accountDropdown.clickOnLink('Manage Membership'); - - assert - .dom('[data-test-membership-plan-section] div:nth-of-type(3)') - .includesText('πŸŽ‰ You have VIP access to all CodeCrafters content, valid until'); - }); - - test('subscriber can view recent payments', async function (assert) { - testScenario(this.server); - signInAsSubscriber(this.owner, this.server); - - let subscription = this.server.schema.subscriptions.first(); - - this.server.schema.charges.create({ - user: subscription.user, - amount: 7900, - amountRefunded: 0, - currency: 'usd', - createdAt: new Date(), - invoiceId: 'invoice-id', - status: 'succeeded', - }); - - this.server.schema.charges.create({ - user: subscription.user, - amount: 3500, - amountRefunded: 0, - currency: 'usd', - createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 7), - invoiceId: 'invoice-id', - status: 'succeeded', - }); - - await membershipPage.visit(); - - assert.strictEqual(membershipPage.recentPaymentsSection.downloadInvoiceLinks.length, 2); - }); - - test('subscriber can view upcoming payments', async function (assert) { - testScenario(this.server); - signInAsSubscriber(this.owner, this.server); - - await membershipPage.visit(); - assert.strictEqual(1, 1); - }); - - test('subscriber can update payment method', async function (assert) { - testScenario(this.server); - signInAsSubscriber(this.owner, this.server); - - await membershipPage.visit(); - await membershipPage.clickOnUpdatePaymentMethodButton(); - assert.strictEqual(1, 1); // Dummy test - }); -}); diff --git a/tests/acceptance/pay-test.js b/tests/acceptance/pay-test.js index c28b7e0634..5cad7c7534 100644 --- a/tests/acceptance/pay-test.js +++ b/tests/acceptance/pay-test.js @@ -222,12 +222,12 @@ module('Acceptance | pay-test', function (hooks) { assert.strictEqual(currentURL(), '/catalog'); }); - test('user should be redirected to /membership if user is authenticated and has an active subscription', async function (assert) { + test('user should be redirected to /settings/billing if user is authenticated and has an active subscription', async function (assert) { testScenario(this.server); signInAsSubscriber(this.owner, this.server); await visit('/pay'); - assert.strictEqual(currentURL(), '/membership'); + assert.strictEqual(currentURL(), '/settings/billing'); }); }); diff --git a/tests/acceptance/settings-page/billing-test.js b/tests/acceptance/settings-page/billing-test.js new file mode 100644 index 0000000000..6aa6a13eed --- /dev/null +++ b/tests/acceptance/settings-page/billing-test.js @@ -0,0 +1,152 @@ +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'codecrafters-frontend/tests/helpers'; +import { signIn, signInAsSubscriber, signInAsVipUser } from 'codecrafters-frontend/tests/support/authentication-helpers'; +import testScenario from 'codecrafters-frontend/mirage/scenarios/test'; +import billingPage from 'codecrafters-frontend/tests/pages/settings/billing-page'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupWindowMock } from 'ember-window-mock/test-support'; +import percySnapshot from '@percy/ember'; + +module('Acceptance | settings-page | billing-test', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + setupWindowMock(hooks); + + test('membership section shows correct plan for subscriber with active subscription', async function (assert) { + testScenario(this.server); + signInAsSubscriber(this.owner, this.server); + const subscription = this.server.schema.subscriptions.first(); + subscription.update('pricingPlanName', 'Yearly Plan'); + + await billingPage.visit(); + + assert.ok(billingPage.membershipSection.isVisible, 'membership section is visible'); + assert.ok(billingPage.membershipSection.text.includes('Membership active'), 'shows active plan'); + + await percySnapshot('Billing Page - Active Subscription'); + }); + + test('membership section shows correct plan for non-subscriber', async function (assert) { + testScenario(this.server); + signIn(this.owner, this.server); + + await billingPage.visit(); + + assert.ok(billingPage.membershipSection.isVisible, 'membership section is visible'); + assert.notOk(billingPage.membershipSection.text.includes('Membership active'), 'does not show active plan'); + await percySnapshot('Billing Page - No Active Subscription'); + }); + + test('membership section shows VIP access for subscriber with VIP access', async function (assert) { + testScenario(this.server); + signInAsVipUser(this.owner, this.server); + + await billingPage.visit(); + + assert.ok(billingPage.membershipSection.isVisible, 'membership section is visible'); + assert.ok(billingPage.membershipSection.text.includes('VIP Access'), 'shows VIP access'); + await percySnapshot('Billing Page - VIP Access'); + }); + + test('support section is visible', async function (assert) { + testScenario(this.server); + signInAsSubscriber(this.owner, this.server); + + await billingPage.visit(); + + assert.ok(billingPage.supportSection.isVisible, 'support section is visible'); + assert.strictEqual( + billingPage.supportSection.contactButtonHref, + 'mailto:hello@codecrafters.io?subject=Billing help (account: rohitpaulk)', + 'contact button href is correct', + ); + }); + + test('payment history section shows empty state initially', async function (assert) { + testScenario(this.server); + signInAsSubscriber(this.owner, this.server); + + await billingPage.visit(); + + assert.ok(billingPage.paymentHistorySection.isVisible, 'payment history section is visible'); + assert.strictEqual(billingPage.paymentHistorySection.charges.length, 0, 'shows no charges initially'); + assert.dom('[data-test-payment-history-section] > div:last-child').hasText('No payment history found.', 'shows empty state text'); + }); + + test('payment history section shows charges after creation', async function (assert) { + testScenario(this.server); + const user = signInAsSubscriber(this.owner, this.server); + user.update({ + id: '63c51e91-e448-4ea9-821b-a80415f266d3', + email: 'test@example.com', + name: 'Test User', + createdAt: new Date(), + updatedAt: new Date(), + }); + + this.server.create('charge', { + id: 'charge-1', + user: user, + amount: 12000, + amountRefunded: 0, + currency: 'usd', + createdAt: new Date(), + invoiceId: 'invoice-1', + status: 'failed', + }); + + this.server.create('charge', { + id: 'charge-2', + user: user, + amount: 12000, + amountRefunded: 0, + currency: 'usd', + createdAt: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 7), + invoiceId: 'invoice-2', + status: 'succeeded', + }); + + await billingPage.visit(); + + assert.ok(billingPage.paymentHistorySection.isVisible, 'payment history section is visible'); + assert.strictEqual(billingPage.paymentHistorySection.charges.length, 2, 'shows two charges after creation'); + assert.strictEqual(billingPage.paymentHistorySection.charges[0].amount, '$120', 'shows correct amount for first charge'); + assert.ok(billingPage.paymentHistorySection.charges[0].failed, 'shows failed status for first charge'); + assert.strictEqual(billingPage.paymentHistorySection.charges[1].amount, '$120', 'shows correct amount for second charge'); + assert.notOk(billingPage.paymentHistorySection.charges[1].failed, 'shows succeeded status for second charge'); + + await percySnapshot('Billing Page - Payment History with Multiple Charges'); + }); + + test('payment history section shows refunded charges correctly', async function (assert) { + testScenario(this.server); + const user = signInAsSubscriber(this.owner, this.server); + + // Fully refunded charge + this.server.create('charge', { + user: user, + amount: 12000, + amountRefunded: 12000, + currency: 'usd', + createdAt: new Date(), + status: 'succeeded', + }); + + // Partially refunded charge + this.server.create('charge', { + user: user, + amount: 12000, + amountRefunded: 6000, + currency: 'usd', + createdAt: new Date(), + status: 'succeeded', + }); + + await billingPage.visit(); + + assert.strictEqual(billingPage.paymentHistorySection.charges.length, 2, 'shows two charges'); + assert.dom('[data-test-refund-text]').exists({ count: 2 }, 'shows refund text for both charges'); + + await percySnapshot('Billing Page - Payment History with Refunded Charges'); + }); +}); diff --git a/tests/pages/membership-page.js b/tests/pages/membership-page.js deleted file mode 100644 index 4281486867..0000000000 --- a/tests/pages/membership-page.js +++ /dev/null @@ -1,33 +0,0 @@ -import { clickOnText, clickable, collection, create, fillable, hasClass, text, visitable } from 'ember-cli-page-object'; -import AccountDropdown from 'codecrafters-frontend/tests/pages/components/account-dropdown'; -import Header from 'codecrafters-frontend/tests/pages/components/header'; - -export default create({ - accountDropdown: AccountDropdown, - - cancelSubscriptionModal: { - selectReason: clickOnText('label'), - cancelButtonIsDisabled: hasClass('cursor-not-allowed', '[data-test-cancel-subscription-button]'), - cancelButtonText: text('[data-test-cancel-subscription-button]'), - clickOnCancelSubscriptionButton: clickable('[data-test-cancel-subscription-button]'), - fillInReasonDescription: fillable('[data-test-reason-description-input]'), - scope: '[data-test-cancel-subscription-modal]', - }, - - clickOnCancelSubscriptionButton: clickable('[data-test-cancel-subscription-button]'), - clickOnCancelTrialButton: clickable('[data-test-cancel-trial-button]'), - clickOnUpdatePaymentMethodButton: clickable('[data-test-update-payment-method-button]'), - - membershipPlanSection: { - descriptionText: text('[data-test-membership-plan-description]'), - scope: '[data-test-membership-plan-section]', - }, - - recentPaymentsSection: { - downloadInvoiceLinks: collection('[data-test-download-invoice-link]'), - scope: '[data-test-recent-payments-section]', - }, - - header: Header, - visit: visitable('/membership'), -}); diff --git a/tests/pages/settings/billing-page.ts b/tests/pages/settings/billing-page.ts new file mode 100644 index 0000000000..f43d816151 --- /dev/null +++ b/tests/pages/settings/billing-page.ts @@ -0,0 +1,23 @@ +import { hasClass, visitable, collection, text, attribute } from 'ember-cli-page-object'; +import createPage from 'codecrafters-frontend/tests/support/create-page'; + +export default createPage({ + membershipSection: { + scope: '[data-test-membership-section]', + }, + + paymentHistorySection: { + charges: collection('[data-test-payment-history-item]', { + amount: text('[data-test-amount]'), + failed: hasClass('text-red-600'), + }), + scope: '[data-test-payment-history-section]', + }, + + supportSection: { + scope: '[data-test-support-section]', + contactButtonHref: attribute('href', '[data-test-support-contact-button]'), + }, + + visit: visitable('/settings/billing'), +}); diff --git a/tests/support/authentication-helpers.js b/tests/support/authentication-helpers.js index f95df44759..323e62ec54 100644 --- a/tests/support/authentication-helpers.js +++ b/tests/support/authentication-helpers.js @@ -60,6 +60,14 @@ export function signInAsSubscriber(owner, server, user) { return signIn(owner, server, user); } +export function signInAsVipUser(owner, server, user) { + user = user || server.schema.users.find('63c51e91-e448-4ea9-821b-a80415f266d3'); + user.update('isVip', true); + user.update('vipStatusExpiresAt', new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 30)); // 30 days from now + + return signIn(owner, server, user); +} + export function signInAsTeamAdmin(owner, server, user) { user = user || server.schema.users.find('63c51e91-e448-4ea9-821b-a80415f266d3'); const team = server.create('team', { id: 'dummy-team-id', name: 'Dummy Team' }); diff --git a/vercel.json b/vercel.json index b9f2b52a5f..02a283a4c2 100644 --- a/vercel.json +++ b/vercel.json @@ -19,6 +19,11 @@ "source": "/vote/challenge-extension-ideas", "destination": "/vote/challenge-extensions", "permanent": true + }, + { + "source": "/membership", + "destination": "/settings/billing", + "permanent": true } ], "rewrites": [