diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index 256a06b3ead..e520e70bf70 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -1,6 +1,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; +import { BitwardenSubscription } from "@bitwarden/subscription"; import { BillingAddress, @@ -11,13 +13,22 @@ import { @Injectable() export class AccountBillingClient { private endpoint = "/account/billing/vnext"; - private apiService: ApiService; - constructor(apiService: ApiService) { - this.apiService = apiService; - } + constructor(private apiService: ApiService) {} - purchasePremiumSubscription = async ( + getLicense = async (): Promise => { + const path = `${this.endpoint}/license`; + return this.apiService.send("GET", path, null, true, true); + }; + + getSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription`; + const json = await this.apiService.send("GET", path, null, true, true); + const response = new BitwardenSubscriptionResponse(json); + return response.toDomain(); + }; + + purchaseSubscription = async ( paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, billingAddress: Pick, ): Promise => { @@ -29,6 +40,17 @@ export class AccountBillingClient { const request = isTokenizedPayment ? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress } : { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; + await this.apiService.send("POST", path, request, true, true); }; + + reinstateSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription/reinstate`; + await this.apiService.send("POST", path, null, true, false); + }; + + updateSubscriptionStorage = async (additionalStorageGb: number): Promise => { + const path = `${this.endpoint}/subscription/storage`; + await this.apiService.send("PUT", path, { additionalStorageGb }, true, false); + }; } diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index fbaf65d1839..f85dab54fe7 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,9 +1,12 @@ import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component"; +import { AccountSubscriptionComponent } from "@bitwarden/web-vault/app/billing/individual/subscription/account-subscription.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; @@ -17,11 +20,15 @@ const routes: Routes = [ data: { titleId: "subscription" }, children: [ { path: "", pathMatch: "full", redirectTo: "premium" }, - { - path: "user-subscription", - component: UserSubscriptionComponent, - data: { titleId: "premiumMembership" }, - }, + ...featureFlaggedRoute({ + defaultComponent: UserSubscriptionComponent, + flaggedComponent: AccountSubscriptionComponent, + featureFlag: FeatureFlag.PM29594_UpdateIndividualSubscriptionPage, + routeOptions: { + path: "user-subscription", + data: { titleId: "premiumMembership" }, + }, + }), /** * Two-Route Matching Strategy for /premium: * diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.html b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html new file mode 100644 index 00000000000..9bb788c1f36 --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html @@ -0,0 +1,50 @@ +@if (subscriptionLoading()) { + + + {{ "loading" | i18n }} + +} @else { + @if (subscription.value(); as subscription) { + +
+

{{ "youHavePremium" | i18n }}

+

+ {{ "viewAndManagePremiumSubscription" | i18n }} +

+
+ + +
+ + + + + @if (subscription.storage; as storage) { + + } + + + +
+ } +} diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts new file mode 100644 index 00000000000..183f4f82666 --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts @@ -0,0 +1,291 @@ +import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, lastValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService, TypographyModule } from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { + AdditionalOptionsCardAction, + AdditionalOptionsCardActions, + AdditionalOptionsCardComponent, + MAX_STORAGE_GB, + Storage, + StorageCardAction, + StorageCardActions, + StorageCardComponent, + SubscriptionCardAction, + SubscriptionCardActions, + SubscriptionCardComponent, + SubscriptionStatuses, +} from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + AdjustAccountSubscriptionStorageDialogComponent, + AdjustAccountSubscriptionStorageDialogParams, +} from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component"; +import { + OffboardingSurveyDialogResultType, + openOffboardingSurvey, +} from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component"; + +@Component({ + templateUrl: "./account-subscription.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AdditionalOptionsCardComponent, + I18nPipe, + JslibModule, + StorageCardComponent, + SubscriptionCardComponent, + TypographyModule, + ], + providers: [AccountBillingClient], +}) +export class AccountSubscriptionComponent { + private accountService = inject(AccountService); + private activatedRoute = inject(ActivatedRoute); + private accountBillingClient = inject(AccountBillingClient); + private billingAccountProfileStateService = inject(BillingAccountProfileStateService); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); + private fileDownloadService = inject(FileDownloadService); + private i18nService = inject(I18nService); + private router = inject(Router); + private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction); + private toastService = inject(ToastService); + + readonly subscription = resource({ + loader: async () => { + const redirectToPremiumPage = async (): Promise => { + await this.router.navigate(["/settings/subscription/premium"]); + return null; + }; + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return await redirectToPremiumPage(); + } + const hasPremiumPersonally = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), + ); + if (!hasPremiumPersonally) { + return await redirectToPremiumPage(); + } + return await this.accountBillingClient.getSubscription(); + }, + }); + + readonly subscriptionLoading = computed(() => this.subscription.isLoading()); + + readonly subscriptionTerminal = computed>(() => { + const subscription = this.subscription.value(); + if (subscription) { + return ( + subscription.status === SubscriptionStatuses.IncompleteExpired || + subscription.status === SubscriptionStatuses.Canceled || + subscription.status === SubscriptionStatuses.Unpaid + ); + } + }); + + readonly subscriptionPendingCancellation = computed>(() => { + const subscription = this.subscription.value(); + if (subscription) { + return ( + (subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active) && + !!subscription.cancelAt + ); + } + }); + + readonly storage = computed>(() => { + const subscription = this.subscription.value(); + return subscription?.storage; + }); + + readonly purchasedStorage = computed(() => { + const subscription = this.subscription.value(); + return subscription?.cart.passwordManager.additionalStorage?.quantity; + }); + + readonly premiumPlan = toSignal( + this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe( + map((tiers) => + tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium), + ), + ), + ); + + readonly premiumStoragePrice = computed>(() => { + const premiumPlan = this.premiumPlan(); + return premiumPlan?.passwordManager.annualPricePerAdditionalStorageGB; + }); + + readonly premiumProvidedStorage = computed>(() => { + const premiumPlan = this.premiumPlan(); + return premiumPlan?.passwordManager.providedStorageGB; + }); + + readonly canAddStorage = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + const storage = this.storage(); + const premiumProvidedStorage = this.premiumProvidedStorage(); + if (storage && premiumProvidedStorage) { + const maxAttainableStorage = MAX_STORAGE_GB - premiumProvidedStorage; + return storage.available < maxAttainableStorage; + } + }); + + readonly canRemoveStorage = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + const purchasedStorage = this.purchasedStorage(); + if (!purchasedStorage || purchasedStorage === 0) { + return false; + } + const storage = this.storage(); + if (storage) { + return storage.available > storage.used; + } + }); + + readonly canCancelSubscription = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + return !this.subscriptionPendingCancellation(); + }); + + readonly premiumToOrganizationUpgradeEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade), + { initialValue: false }, + ); + + onSubscriptionCardAction = async (action: SubscriptionCardAction) => { + switch (action) { + case SubscriptionCardActions.ContactSupport: + window.open("https://bitwarden.com/contact/", "_blank"); + break; + case SubscriptionCardActions.ManageInvoices: + await this.router.navigate(["../billing-history"], { relativeTo: this.activatedRoute }); + break; + case SubscriptionCardActions.ReinstateSubscription: { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "reinstateSubscription" }, + content: { key: "reinstateConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + await this.accountBillingClient.reinstateSubscription(); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("reinstated"), + }); + this.subscription.reload(); + break; + } + case SubscriptionCardActions.UpdatePayment: + await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }); + break; + case SubscriptionCardActions.UpgradePlan: + // TODO: Implement upgrade plan navigation + break; + } + }; + + onStorageCardAction = async (action: StorageCardAction) => { + const data = this.getAdjustStorageDialogParams(action); + const dialogReference = AdjustAccountSubscriptionStorageDialogComponent.open( + this.dialogService, + { + data, + }, + ); + const result = await lastValueFrom(dialogReference.closed); + if (result === "submitted") { + this.subscription.reload(); + } + }; + + onAdditionalOptionsCardAction = async (action: AdditionalOptionsCardAction) => { + switch (action) { + case AdditionalOptionsCardActions.DownloadLicense: { + const license = await this.accountBillingClient.getLicense(); + const json = JSON.stringify(license, null, 2); + this.fileDownloadService.download({ + fileName: "bitwarden_premium_license.json", + blobData: json, + }); + break; + } + case AdditionalOptionsCardActions.CancelSubscription: { + const dialogReference = openOffboardingSurvey(this.dialogService, { + data: { + type: "User", + }, + }); + + const result = await lastValueFrom(dialogReference.closed); + + if (result === OffboardingSurveyDialogResultType.Closed) { + return; + } + + this.subscription.reload(); + } + } + }; + + getAdjustStorageDialogParams = ( + action: StorageCardAction, + ): Maybe => { + const purchasedStorage = this.purchasedStorage(); + const storagePrice = this.premiumStoragePrice(); + const providedStorage = this.premiumProvidedStorage(); + + switch (action) { + case StorageCardActions.AddStorage: { + if (storagePrice && providedStorage) { + return { + type: "add", + price: storagePrice, + provided: providedStorage, + cadence: "annually", + existing: purchasedStorage, + }; + } + break; + } + case StorageCardActions.RemoveStorage: { + if (purchasedStorage) { + return { + type: "remove", + existing: purchasedStorage, + }; + } + break; + } + } + }; +} diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html new file mode 100644 index 00000000000..1d3172837ad --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html @@ -0,0 +1,43 @@ +@let content = this.content(); +
+ + +

{{ content.body }}

+
+ + {{ content.label }} + + @if (action() === "add") { + + + {{ "total" | i18n }} + {{ formGroup.value.amount }} GB × {{ price() | currency: "$" }} = + {{ price() * formGroup.value.amount | currency: "$" }} / + {{ term() | i18n }} + + } + +
+
+ + + + +
+
diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts new file mode 100644 index 00000000000..f1350cda49e --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts @@ -0,0 +1,182 @@ +import { CurrencyPipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + AsyncActionsModule, + ButtonModule, + DIALOG_DATA, + DialogConfig, + DialogModule, + DialogRef, + DialogService, + FormFieldModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { MAX_STORAGE_GB } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; + +type RemoveStorage = { + type: "remove"; + existing: number; +}; + +type AddStorage = { + type: "add"; + price: number; + provided: number; + cadence: SubscriptionCadence; + existing?: number; +}; + +export type AdjustAccountSubscriptionStorageDialogParams = RemoveStorage | AddStorage; + +type AdjustAccountSubscriptionStorageDialogResult = "closed" | "submitted"; + +@Component({ + templateUrl: "./adjust-account-subscription-storage-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [AccountBillingClient], + imports: [ + AsyncActionsModule, + ButtonModule, + CurrencyPipe, + DialogModule, + FormFieldModule, + I18nPipe, + ReactiveFormsModule, + TypographyModule, + ], +}) +export class AdjustAccountSubscriptionStorageDialogComponent { + private readonly accountBillingClient = inject(AccountBillingClient); + private readonly dialogParams = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + + readonly action = computed<"add" | "remove">(() => this.dialogParams.type); + + readonly price = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.price; + } + }); + + readonly provided = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.provided; + } + }); + + readonly term = computed>(() => { + if (this.dialogParams.type === "add") { + switch (this.dialogParams.cadence) { + case "annually": + return this.i18nService.t("year"); + case "monthly": + return this.i18nService.t("month"); + } + } + }); + + readonly existing = computed>(() => this.dialogParams.existing); + + readonly content = computed<{ + title: string; + body: string; + label: string; + }>(() => { + const action = this.action(); + switch (action) { + case "add": + return { + title: this.i18nService.t("addStorage"), + body: this.i18nService.t("storageAddNote"), + label: this.i18nService.t("gbStorageAdd"), + }; + case "remove": + return { + title: this.i18nService.t("removeStorage"), + body: this.i18nService.t("whenYouRemoveStorage"), + label: this.i18nService.t("gbStorageRemove"), + }; + } + }); + + readonly maxPurchasable = computed>(() => { + const provided = this.provided(); + if (provided) { + return MAX_STORAGE_GB - provided; + } + }); + + readonly maxValidatorValue = computed(() => { + const maxPurchasable = this.maxPurchasable() ?? MAX_STORAGE_GB; + const existing = this.existing(); + const action = this.action(); + + switch (action) { + case "add": { + return existing ? maxPurchasable - existing : maxPurchasable; + } + case "remove": { + return existing ? existing : 0; + } + } + }); + + formGroup = new FormGroup({ + amount: new FormControl(1, { + nonNullable: true, + validators: [ + Validators.required, + Validators.min(1), + Validators.max(this.maxValidatorValue()), + ], + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + if (!this.formGroup.valid || !this.formGroup.value.amount) { + return; + } + + const action = this.action(); + const existing = this.existing(); + const amount = this.formGroup.value.amount; + + switch (action) { + case "add": { + await this.accountBillingClient.updateSubscriptionStorage(amount + (existing ?? 0)); + break; + } + case "remove": { + await this.accountBillingClient.updateSubscriptionStorage(existing! - amount); + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("adjustedStorage", amount), + }); + + this.dialogRef.close("submitted"); + }; + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig, + ) => + dialogService.open( + AdjustAccountSubscriptionStorageDialogComponent, + dialogConfig, + ); +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 81169d719b6..83440646b48 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -478,13 +478,13 @@ describe("UpgradePaymentService", () => { describe("upgradeToPremium", () => { it("should call accountBillingClient to purchase premium subscription and refresh data", async () => { // Arrange - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( mockTokenizedPaymentMethod, mockBillingAddress, ); @@ -496,13 +496,13 @@ describe("UpgradePaymentService", () => { const accountCreditPaymentMethod: NonTokenizedPaymentMethod = { type: NonTokenizablePaymentMethods.accountCredit, }; - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( accountCreditPaymentMethod, mockBillingAddress, ); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index ae18ab4c629..b8d5637e471 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -143,7 +143,7 @@ export class UpgradePaymentService { ): Promise { this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); - await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress); + await this.accountBillingClient.purchaseSubscription(paymentMethod, billingAddress); await this.refreshAndSync(); } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 34362b4be3e..77ae3b31837 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -142,7 +142,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { if (!this.selectedPlan()) { return { passwordManager: { - seats: { name: "", cost: 0, quantity: 0 }, + seats: { translationKey: "", cost: 0, quantity: 0 }, }, cadence: "annually", estimatedTax: 0, @@ -152,7 +152,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { return { passwordManager: { seats: { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + translationKey: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0, quantity: 1, }, diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 2fc39218cf8..5034b21d03d 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -30,6 +30,7 @@ import { import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component"; import { UpdateLicenseDialogResult } from "../shared/update-license-types"; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -256,8 +257,8 @@ export class UserSubscriptionComponent implements OnInit { return null; } return discount.amountOff - ? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff } - : { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff }; + ? { type: DiscountTypes.AmountOff, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, value: discount.percentOff }; } get isSubscriptionActive(): boolean { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1ec92241671..716f5895e5a 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12614,5 +12614,11 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" } -} \ No newline at end of file +} diff --git a/libs/common/src/billing/models/response/bitwarden-subscription.response.ts b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts new file mode 100644 index 00000000000..870c4de7e3a --- /dev/null +++ b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts @@ -0,0 +1,102 @@ +import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response"; +import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart } from "@bitwarden/pricing"; +import { + BitwardenSubscription, + Storage, + SubscriptionStatus, + SubscriptionStatuses, +} from "@bitwarden/subscription"; + +export class BitwardenSubscriptionResponse extends BaseResponse { + status: SubscriptionStatus; + cart: Cart; + storage: Storage; + cancelAt?: Date; + canceled?: Date; + nextCharge?: Date; + suspension?: Date; + gracePeriod?: number; + + constructor(response: any) { + super(response); + + const status = this.getResponseProperty("Status"); + if ( + status !== SubscriptionStatuses.Incomplete && + status !== SubscriptionStatuses.IncompleteExpired && + status !== SubscriptionStatuses.Trialing && + status !== SubscriptionStatuses.Active && + status !== SubscriptionStatuses.PastDue && + status !== SubscriptionStatuses.Canceled && + status !== SubscriptionStatuses.Unpaid + ) { + throw new Error(`Failed to parse invalid subscription status: ${status}`); + } + this.status = status; + + this.cart = new CartResponse(this.getResponseProperty("Cart")); + this.storage = new StorageResponse(this.getResponseProperty("Storage")); + + const suspension = this.getResponseProperty("Suspension"); + if (suspension) { + this.suspension = new Date(suspension); + } + + const gracePeriod = this.getResponseProperty("GracePeriod"); + if (gracePeriod) { + this.gracePeriod = gracePeriod; + } + + const nextCharge = this.getResponseProperty("NextCharge"); + if (nextCharge) { + this.nextCharge = new Date(nextCharge); + } + + const cancelAt = this.getResponseProperty("CancelAt"); + if (cancelAt) { + this.cancelAt = new Date(cancelAt); + } + + const canceled = this.getResponseProperty("Canceled"); + if (canceled) { + this.canceled = new Date(canceled); + } + } + + toDomain = (): BitwardenSubscription => { + switch (this.status) { + case SubscriptionStatuses.Incomplete: + case SubscriptionStatuses.IncompleteExpired: + case SubscriptionStatuses.PastDue: + case SubscriptionStatuses.Unpaid: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + suspension: this.suspension!, + gracePeriod: this.gracePeriod!, + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + nextCharge: this.nextCharge!, + cancelAt: this.cancelAt, + }; + } + case SubscriptionStatuses.Canceled: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + canceled: this.canceled!, + }; + } + } + }; +} diff --git a/libs/common/src/billing/models/response/cart.response.ts b/libs/common/src/billing/models/response/cart.response.ts new file mode 100644 index 00000000000..c1a1d17521a --- /dev/null +++ b/libs/common/src/billing/models/response/cart.response.ts @@ -0,0 +1,97 @@ +import { + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart, CartItem, Discount } from "@bitwarden/pricing"; + +import { DiscountResponse } from "./discount.response"; + +export class CartItemResponse extends BaseResponse implements CartItem { + translationKey: string; + quantity: number; + cost: number; + discount?: Discount; + + constructor(response: any) { + super(response); + + this.translationKey = this.getResponseProperty("TranslationKey"); + this.quantity = this.getResponseProperty("Quantity"); + this.cost = this.getResponseProperty("Cost"); + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = discount; + } + } +} + +class PasswordManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalStorage?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalStorage = this.getResponseProperty("AdditionalStorage"); + if (additionalStorage) { + this.additionalStorage = new CartItemResponse(additionalStorage); + } + } +} + +class SecretsManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalServiceAccounts?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts"); + if (additionalServiceAccounts) { + this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts); + } + } +} + +export class CartResponse extends BaseResponse implements Cart { + passwordManager: { + seats: CartItem; + additionalStorage?: CartItem; + }; + secretsManager?: { + seats: CartItem; + additionalServiceAccounts?: CartItem; + }; + cadence: SubscriptionCadence; + discount?: Discount; + estimatedTax: number; + + constructor(response: any) { + super(response); + + this.passwordManager = new PasswordManagerCartItemResponse( + this.getResponseProperty("PasswordManager"), + ); + + const secretsManager = this.getResponseProperty("SecretsManager"); + if (secretsManager) { + this.secretsManager = new SecretsManagerCartItemResponse(secretsManager); + } + + const cadence = this.getResponseProperty("Cadence"); + if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) { + throw new Error(`Failed to parse invalid cadence: ${cadence}`); + } + this.cadence = cadence; + + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = new DiscountResponse(discount); + } + + this.estimatedTax = this.getResponseProperty("EstimatedTax"); + } +} diff --git a/libs/common/src/billing/models/response/discount.response.ts b/libs/common/src/billing/models/response/discount.response.ts new file mode 100644 index 00000000000..03460a10df8 --- /dev/null +++ b/libs/common/src/billing/models/response/discount.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing"; + +export class DiscountResponse extends BaseResponse implements Discount { + type: DiscountType; + value: number; + + constructor(response: any) { + super(response); + + const type = this.getResponseProperty("Type"); + if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) { + throw new Error(`Failed to parse invalid discount type: ${type}`); + } + this.type = type; + this.value = this.getResponseProperty("Value"); + } +} diff --git a/libs/common/src/billing/models/response/storage.response.ts b/libs/common/src/billing/models/response/storage.response.ts new file mode 100644 index 00000000000..7e270ccc934 --- /dev/null +++ b/libs/common/src/billing/models/response/storage.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Storage } from "@bitwarden/subscription"; + +export class StorageResponse extends BaseResponse implements Storage { + available: number; + used: number; + readableUsed: string; + + constructor(response: any) { + super(response); + + this.available = this.getResponseProperty("Available"); + this.used = this.getResponseProperty("Used"); + this.readableUsed = this.getResponseProperty("ReadableUsed"); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 048b0147a6f..ab8fe5decd8 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -31,6 +31,8 @@ export enum FeatureFlag { PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", PM23341_Milestone_2 = "pm-23341-milestone-2", + PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page", + PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -137,6 +139,8 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, [FeatureFlag.PM23341_Milestone_2]: FALSE, + [FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE, + [FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 10f349fbec7..8839ea8ca50 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction { return new PaymentResponse(r); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page postReinstatePremium(): Promise { return this.send("POST", "/accounts/reinstate-premium", null, true, false); } diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index e2fe7d80dc0..e916de3995d 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -46,7 +46,7 @@
@let passwordManagerSeats = cart.passwordManager.seats;
- {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x + {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x {{ passwordManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }} @@ -63,7 +63,7 @@
- {{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x + {{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x {{ additionalStorage.cost | currency: "USD" : "symbol" }} / {{ term }}
@@ -86,7 +86,7 @@
- {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x + {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x {{ secretsManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }}
@@ -105,7 +105,7 @@
{{ additionalServiceAccounts.quantity }} - {{ additionalServiceAccounts.name | i18n }} x + {{ additionalServiceAccounts.translationKey | i18n }} x {{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }} / {{ term }} diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx index 02e705276bc..d327d5658fe 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx @@ -67,7 +67,7 @@ The component uses the following Cart and CartItem data structures: ```typescript export type CartItem = { - name: string; // Display name for i18n lookup + translationKey: string; // Translation key for i18n lookup quantity: number; // Number of items cost: number; // Cost per item discount?: Discount; // Optional item-level discount @@ -92,7 +92,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; export type Discount = { type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - active: boolean; // Whether discount is currently applied value: number; // Dollar amount or percentage (20 for 20%) }; ``` @@ -108,7 +107,7 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, @@ -124,12 +123,12 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -145,14 +144,13 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 8.00 @@ -188,7 +186,7 @@ Show cart with yearly subscription: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 500.00 } }, @@ -211,12 +209,12 @@ Show cart with password manager and additional storage: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -239,14 +237,14 @@ Show cart with password manager and secrets manager seats only: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, @@ -269,19 +267,19 @@ Show cart with password manager, secrets manager seats, and additional service a passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -304,24 +302,24 @@ Show a cart with all available products: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -344,19 +342,18 @@ Show cart with percentage-based discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 10.40 @@ -377,21 +374,20 @@ Show cart with fixed amount discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, cadence: 'annually', discount: { type: 'amount-off', - active: true, value: 50.00 }, estimatedTax: 95.00 @@ -431,7 +427,7 @@ Show cart with premium plan: passwordManager: { seats: { quantity: 1, - name: 'premiumMembership', + translationKey: 'premiumMembership', cost: 10.00 } }, @@ -454,7 +450,7 @@ Show cart with families plan: passwordManager: { seats: { quantity: 1, - name: 'familiesMembership', + translationKey: 'familiesMembership', cost: 40.00 } }, @@ -488,8 +484,7 @@ Show cart with families plan: - Use consistent naming and formatting for cart items - Include clear quantity and unit pricing information - Ensure tax estimates are accurate and clearly labeled -- Set `active: true` on discounts that should be displayed -- Use localized strings for CartItem names (for i18n lookup) +- Use valid translation keys for CartItem translationKey (for i18n lookup) - Provide complete Cart object with all required fields - Use "annually" or "monthly" for cadence (not "year" or "month") diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts index f019322e4db..10975585899 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, @@ -270,7 +270,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -296,7 +295,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, }; @@ -315,33 +313,12 @@ describe("CartSummaryComponent", () => { expect(discountAmount.nativeElement.textContent).toContain("-$50.00"); }); - it("should not display discount when discount is inactive", () => { - // Arrange - const cartWithInactiveDiscount: Cart = { - ...mockCart, - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - }, - }; - fixture.componentRef.setInput("cart", cartWithInactiveDiscount); - fixture.detectChanges(); - - // Act / Assert - const discountSection = fixture.debugElement.query( - By.css('[data-testid="discount-section"]'), - ); - expect(discountSection).toBeFalsy(); - }); - it("should apply discount to total calculation", () => { // Arrange const cartWithDiscount: Cart = { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -382,24 +359,24 @@ describe("CartSummaryComponent - Custom Header Template", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts index aed23c54a30..581e363ab24 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts @@ -71,7 +71,7 @@ export default { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, @@ -98,12 +98,12 @@ export const WithAdditionalStorage: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, @@ -120,7 +120,7 @@ export const PasswordManagerYearlyCadence: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 500.0, }, }, @@ -137,14 +137,14 @@ export const SecretsManagerSeatsOnly: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, @@ -161,19 +161,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -190,24 +190,24 @@ export const AllProducts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -223,7 +223,7 @@ export const FamiliesPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "familiesMembership", + translationKey: "familiesMembership", cost: 40.0, }, }, @@ -239,7 +239,7 @@ export const PremiumPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -255,7 +255,7 @@ export const CustomHeaderTemplate: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -296,19 +296,18 @@ export const WithPercentDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, cadence: "monthly", discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, estimatedTax: 10.4, @@ -322,21 +321,20 @@ export const WithAmountDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, cadence: "annually", discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, estimatedTax: 95.0, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index b92a465169c..ef35f0ded33 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -116,7 +116,7 @@ export class CartSummaryComponent { */ readonly discountAmount = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return 0; } @@ -136,7 +136,7 @@ export class CartSummaryComponent { */ readonly discountLabel = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return ""; } return getLabel(this.i18nService, discount); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx index f9b9ba85619..8988f79ea07 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx @@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; type Discount = { /** The type of discount */ type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - /** Whether the discount is currently active */ - active: boolean; /** The discount value (percentage or amount depending on type) */ value: number; }; @@ -47,8 +45,7 @@ type Discount = { ## Behavior -- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is - greater than 0. +- The badge is only displayed when `discount` is provided and `value` is greater than 0. - For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%). - For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places. @@ -62,7 +59,3 @@ type Discount = { ### Amount Discount - -### Inactive Discount - - diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts index 6f8e7ab9e74..540ae48adb4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts @@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => { expect(component.display()).toBe(false); }); - it("should return false when discount is inactive", () => { + it("should return true when discount has percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: false, - value: 20, - }); - fixture.detectChanges(); - expect(component.display()).toBe(false); - }); - - it("should return true when discount is active with percent-off", () => { - fixture.componentRef.setInput("discount", { - type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); expect(component.display()).toBe(true); }); - it("should return true when discount is active with amount-off", () => { + it("should return true when discount has amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); @@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (percent-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (amount-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => { it("should return percentage text when type is percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); @@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => { it("should convert decimal value to percentage for percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0.15, }); fixture.detectChanges(); @@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => { it("should return amount text when type is amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts index 1d2d15e84c5..610e7b815a8 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts @@ -40,7 +40,6 @@ export const PercentDiscount: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, } as Discount, }, @@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 0.15, // 15% in decimal format } as Discount, }, @@ -68,7 +66,6 @@ export const AmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 10.99, } as Discount, }, @@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 99.99, } as Discount, }, }; -export const InactiveDiscount: Story = { - render: (args) => ({ - props: args, - template: ``, - }), - args: { - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - } as Discount, - }, -}; - export const NoDiscount: Story = { render: (args) => ({ props: args, diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.ts index 17204be85ff..8937ea274d4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.ts @@ -23,7 +23,7 @@ export class DiscountBadgeComponent { if (!discount) { return false; } - return discount.active && discount.value > 0; + return discount.value > 0; }); readonly label = computed>(() => { diff --git a/libs/pricing/src/types/cart.ts b/libs/pricing/src/types/cart.ts index d27a867b785..ed5108edee8 100644 --- a/libs/pricing/src/types/cart.ts +++ b/libs/pricing/src/types/cart.ts @@ -1,7 +1,7 @@ import { Discount } from "@bitwarden/pricing"; export type CartItem = { - name: string; + translationKey: string; quantity: number; cost: number; discount?: Discount; diff --git a/libs/pricing/src/types/discount.ts b/libs/pricing/src/types/discount.ts index c12998ef609..afea56fce0a 100644 --- a/libs/pricing/src/types/discount.ts +++ b/libs/pricing/src/types/discount.ts @@ -9,7 +9,6 @@ export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes]; export type Discount = { type: DiscountType; - active: boolean; value: number; }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html index 851ae32ddb3..c4d3d291b26 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html @@ -13,8 +13,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('download-license')" + [disabled]="downloadLicenseDisabled()" + (click)="callToActionClicked.emit(actions.DownloadLicense)" > {{ "downloadLicense" | i18n }} @@ -22,8 +22,8 @@ bitButton buttonType="danger" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('cancel-subscription')" + [disabled]="cancelSubscriptionDisabled()" + (click)="callToActionClicked.emit(actions.CancelSubscription)" > {{ "cancelSubscription" | i18n }} diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx index 4519d19a530..3162e740cb0 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -21,6 +21,8 @@ subscription actions. - [Examples](#examples) - [Default](#default) - [Actions Disabled](#actions-disabled) + - [Download License Disabled](#download-license-disabled) + - [Cancel Subscription Disabled](#cancel-subscription-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ---------------------------- | --------- | ----------------------------------------------------------------------------- | +| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. | +| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. | ### Outputs @@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations): ```html ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -downloading the license or processing subscription cancellation. +**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control +button states during async operations like downloading the license or processing subscription +cancellation. + +### Download License Disabled + +Component with only the download license button disabled: + + + +```html + + +``` + +### Cancel Subscription Disabled + +Component with only the cancel subscription button disabled: + + + +```html + + +``` ## Features @@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation. - Handle both `download-license` and `cancel-subscription` events in parent components - Show appropriate confirmation dialogs before executing destructive actions (cancel subscription) -- Disable buttons or show loading states during async operations +- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during + operations - Provide clear user feedback after action completion - Consider adding additional safety measures for subscription cancellation +- Control button states independently based on business logic ### ❌ Don't diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts index 345de037fd3..3346c287beb 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => { }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { - fixture.componentRef.setInput("callsToActionDisabled", true); + describe("button disabled states", () => { + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should disable download license button when downloadLicenseDisabled is true", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => { + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable both buttons independently", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false", () => { - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow download enabled while cancel disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", false); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); - expect(buttons[1].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons by default", () => { + it("should allow cancel enabled while download disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", false); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); - expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); expect(buttons[1].nativeElement.disabled).toBe(false); }); }); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts index 66c151f536f..7dd7a5375fe 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -44,6 +44,23 @@ export const Default: Story = { export const ActionsDisabled: Story = { name: "Actions Disabled", args: { - callsToActionDisabled: true, + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: true, + }, +}; + +export const DownloadLicenseDisabled: Story = { + name: "Download License Disabled", + args: { + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: false, + }, +}; + +export const CancelSubscriptionDisabled: Story = { + name: "Cancel Subscription Disabled", + args: { + downloadLicenseDisabled: false, + cancelSubscriptionDisabled: true, }, }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts index a962a167ec6..6c633a43d93 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +export const AdditionalOptionsCardActions = { + DownloadLicense: "download-license", + CancelSubscription: "cancel-subscription", +} as const; + +export type AdditionalOptionsCardAction = + (typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions]; @Component({ selector: "billing-additional-options-card", @@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], }) export class AdditionalOptionsCardComponent { - readonly callsToActionDisabled = input(false); + readonly downloadLicenseDisabled = input(false); + readonly cancelSubscriptionDisabled = input(false); + readonly callToActionClicked = output(); + + protected readonly actions = AdditionalOptionsCardActions; } diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html index c11f1917176..f8ac4b18604 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.html +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -21,8 +21,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('add-storage')" + [disabled]="addStorageDisabled()" + (click)="callToActionClicked.emit(actions.AddStorage)" > {{ "addStorage" | i18n }} @@ -30,8 +30,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled() || !canRemoveStorage()" - (click)="callToActionClicked.emit('remove-storage')" + [disabled]="removeStorageDisabled()" + (click)="callToActionClicked.emit(actions.RemoveStorage)" > {{ "removeStorage" | i18n }} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx index 43215cb863c..7e06fa23553 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.mdx +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -30,6 +30,8 @@ full). - [Large Storage Pool (1TB)](#large-storage-pool-1tb) - [Small Storage Pool (1GB)](#small-storage-pool-1gb) - [Actions Disabled](#actions-disabled) + - [Add Storage Disabled](#add-storage-disabled) + - [Remove Storage Disabled](#remove-storage-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ----------------------- | --------- | ------------------------------------------------------------------------ | +| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | +| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. | +| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. | ### Outputs @@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage: Key behaviors: - Progress bar color changes from blue (primary) to red (danger) when full -- Remove storage button is disabled when storage is full +- Button disabled states are controlled independently via `addStorageDisabled` and + `removeStorageDisabled` inputs - Title changes to "Storage full" when at capacity - Description provides context-specific messaging @@ -123,7 +127,7 @@ Storage with no files uploaded: [storage]="{ available: 5, used: 0, - readableUsed: '0 GB' + readableUsed: '0 GB', }" (callToActionClicked)="handleAction($event)" > @@ -141,7 +145,7 @@ Storage with partial usage (50%): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" (callToActionClicked)="handleAction($event)" > @@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button: [storage]="{ available: 5, used: 5, - readableUsed: '5 GB' + readableUsed: '5 GB', }" (callToActionClicked)="handleAction($event)" > ``` -**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns -red. +**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled +independently via the `addStorageDisabled` and `removeStorageDisabled` inputs. ### Low Usage (10%) @@ -180,7 +184,7 @@ Minimal storage usage: [storage]="{ available: 5, used: 0.5, - readableUsed: '500 MB' + readableUsed: '500 MB', }" (callToActionClicked)="handleAction($event)" > @@ -198,7 +202,7 @@ Substantial storage usage: [storage]="{ available: 5, used: 3.75, - readableUsed: '3.75 GB' + readableUsed: '3.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -216,7 +220,7 @@ Storage approaching capacity: [storage]="{ available: 5, used: 4.75, - readableUsed: '4.75 GB' + readableUsed: '4.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -234,7 +238,7 @@ Enterprise-level storage allocation: [storage]="{ available: 1000, used: 734, - readableUsed: '734 GB' + readableUsed: '734 GB', }" (callToActionClicked)="handleAction($event)" > @@ -252,7 +256,7 @@ Minimal storage allocation: [storage]="{ available: 1, used: 0.8, - readableUsed: '800 MB' + readableUsed: '800 MB', }" (callToActionClicked)="handleAction($event)" > @@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" - [callsToActionDisabled]="true" + [addStorageDisabled]="true" + [removeStorageDisabled]="true" (callToActionClicked)="handleAction($event)" > ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -adding or removing storage. +**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button +states during async operations like adding or removing storage. + +### Add Storage Disabled + +Storage card with only the add button disabled: + + + +```html + + +``` + +### Remove Storage Disabled + +Storage card with only the remove button disabled: + + + +```html + + +``` ## Features @@ -304,13 +349,14 @@ adding or removing storage. - Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed` - Keep `used` value less than or equal to `available` under normal circumstances - Update storage data in real-time when user adds or removes storage -- Disable UI interactions when storage operations are in progress +- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations - Show loading states during async storage operations +- Control button states independently based on business logic ### ❌ Don't -- Omit the `readableUsed` field - it's required for display -- Use inconsistent units between `available` and `used` (both should be in GB) +- Omit the `readableUsed` field - it's required +- Use inconsistent units between `available` and `used` (all should be in GB) - Allow negative values for storage amounts - Ignore the `callToActionClicked` events - they require handling - Display inaccurate or stale storage information diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts index ae0d7ad9dcb..fe2223f1449 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -163,18 +163,6 @@ describe("StorageCardComponent", () => { }); }); - describe("canRemoveStorage", () => { - it("should return true when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - expect(component.canRemoveStorage()).toBe(true); - }); - - it("should return false when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - expect(component.canRemoveStorage()).toBe(false); - }); - }); - describe("button rendering", () => { it("should render both buttons", () => { setupComponent(baseStorage); @@ -182,25 +170,46 @@ describe("StorageCardComponent", () => { expect(buttons.length).toBe(2); }); - it("should enable remove button when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + it("should enable add button by default", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0].nativeElement; + expect(addButton.disabled).toBe(false); + }); + + it("should disable add button when addStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0]; + expect(addButton.attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable remove button by default", () => { + setupComponent(baseStorage); const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1].nativeElement; expect(removeButton.disabled).toBe(false); }); - it("should disable remove button when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + it("should disable remove button when removeStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("removeStorageDisabled", true); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1]; expect(removeButton.attributes["aria-disabled"]).toBe("true"); }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { + describe("independent button disabled states", () => { + it("should disable both buttons independently", () => { setupComponent(baseStorage); - fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -208,9 +217,10 @@ describe("StorageCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should enable both buttons when both disabled inputs are false", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", false); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -218,15 +228,27 @@ describe("StorageCardComponent", () => { expect(buttons[1].nativeElement.disabled).toBe(false); }); - it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow add button enabled while remove button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); + + it("should allow remove button enabled while add button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); }); describe("button click events", () => { @@ -243,7 +265,7 @@ describe("StorageCardComponent", () => { }); it("should emit remove-storage action when remove button is clicked", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + setupComponent(baseStorage); const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts index 8c2070e59f9..2afbaf0d0b1 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -143,6 +143,33 @@ export const ActionsDisabled: Story = { used: 2.5, readableUsed: "2.5 GB", } satisfies Storage, - callsToActionDisabled: true, + addStorageDisabled: true, + removeStorageDisabled: true, + }, +}; + +export const AddStorageDisabled: Story = { + name: "Add Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: true, + removeStorageDisabled: false, + }, +}; + +export const RemoveStorageDisabled: Story = { + name: "Remove Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: false, + removeStorageDisabled: true, }, }; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts index 988f4a0ec60..483649434ff 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { Storage } from "../../types/storage"; -export type StorageCardAction = "add-storage" | "remove-storage"; +export const StorageCardActions = { + AddStorage: "add-storage", + RemoveStorage: "remove-storage", +} as const; + +export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions]; @Component({ selector: "billing-storage-card", @@ -25,7 +30,8 @@ export class StorageCardComponent { readonly storage = input.required(); - readonly callsToActionDisabled = input(false); + readonly addStorageDisabled = input(false); + readonly removeStorageDisabled = input(false); readonly callToActionClicked = output(); @@ -64,5 +70,5 @@ export class StorageCardComponent { return this.isFull() ? "danger" : "primary"; }); - readonly canRemoveStorage = computed(() => !this.isFull()); + protected readonly actions = StorageCardActions; } diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx index 0f605f0f05e..c9cc6df7263 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub ### Outputs -| Output | Type | Description | -| --------------------- | ---------------- | ---------------------------------------------------------- | -| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | +| Output | Type | Description | +| --------------------- | ------------------------ | ---------------------------------------------------------- | +| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout | -**PlanCardAction Type:** +**SubscriptionCardAction Type:** ```typescript -type PlanCardAction = +type SubscriptionCardAction = | "contact-support" | "manage-invoices" | "reinstate-subscription" diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts index 3485f2a493a..cdb85360c74 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, }, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts index abe5789382b..32976c89cc2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -103,7 +103,7 @@ export const Active: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -157,7 +157,7 @@ export const Trial: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -212,7 +212,7 @@ export const Incomplete: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -239,7 +239,7 @@ export const IncompleteExpired: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -266,7 +266,7 @@ export const PastDue: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -293,7 +293,7 @@ export const PendingCancellation: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -320,7 +320,7 @@ export const Unpaid: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -346,7 +346,7 @@ export const Canceled: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -372,31 +372,30 @@ export const Enterprise: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 7, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 0.5, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 13, }, additionalServiceAccounts: { quantity: 5, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 1, }, }, discount: { type: DiscountTypes.PercentOff, - active: true, - value: 0.25, + value: 25, }, cadence: "monthly", estimatedTax: 6.4, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts index f52127a0104..ebfb41df6c2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing"; import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription"; import { I18nPipe } from "@bitwarden/ui-common"; -export type PlanCardAction = - | "contact-support" - | "manage-invoices" - | "reinstate-subscription" - | "update-payment" - | "upgrade-plan"; +export const SubscriptionCardActions = { + ContactSupport: "contact-support", + ManageInvoices: "manage-invoices", + ReinstateSubscription: "reinstate-subscription", + UpdatePayment: "update-payment", + UpgradePlan: "upgrade-plan", +} as const; + +export type SubscriptionCardAction = + (typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions]; type Badge = { text: string; variant: BadgeVariant }; @@ -33,7 +37,7 @@ type Callout = Maybe<{ callsToAction?: { text: string; buttonType: ButtonType; - action: PlanCardAction; + action: SubscriptionCardAction; }[]; }>; @@ -64,7 +68,7 @@ export class SubscriptionCardComponent { readonly showUpgradeButton = input(false); - readonly callToActionClicked = output(); + readonly callToActionClicked = output(); readonly badge = computed(() => { const subscription = this.subscription(); @@ -136,12 +140,12 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("updatePayment"), buttonType: "unstyled", - action: "update-payment", + action: SubscriptionCardActions.UpdatePayment, }, { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -155,7 +159,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -172,7 +176,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("reinstateSubscription"), buttonType: "unstyled", - action: "reinstate-subscription", + action: SubscriptionCardActions.ReinstateSubscription, }, ], }; @@ -189,7 +193,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("upgradeNow"), buttonType: "unstyled", - action: "upgrade-plan", + action: SubscriptionCardActions.UpgradePlan, }, ], }; @@ -208,7 +212,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; @@ -225,7 +229,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts index 15bf64d03aa..5c43ed20590 100644 --- a/libs/subscription/src/types/bitwarden-subscription.ts +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -12,6 +12,8 @@ export const SubscriptionStatuses = { Unpaid: "unpaid", } as const; +export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses]; + type HasCart = { cart: Cart; }; diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts index beb187250dd..35df54cb4f2 100644 --- a/libs/subscription/src/types/storage.ts +++ b/libs/subscription/src/types/storage.ts @@ -1,3 +1,5 @@ +export const MAX_STORAGE_GB = 100; + export type Storage = { available: number; readableUsed: string;