From c0fce757564f95c2b88c14d7c62448ab428363d5 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:19:02 -0500 Subject: [PATCH 01/28] feature flag --- libs/common/src/enums/feature-flag.enum.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8045a7b55f09..11f5db719408 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -28,6 +28,7 @@ export enum FeatureFlag { PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", + PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -105,6 +106,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, + [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, From 3b4e33d7a5294c31417b6fdf74ae784ebf09f2ea Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:27:09 -0500 Subject: [PATCH 02/28] new upgrade dialog component and moved pricing service into libs first draft --- apps/browser/src/_locales/en/messages.json | 3 + apps/desktop/src/locales/en/messages.json | 3 + .../unified-upgrade-dialog.component.ts | 2 +- .../upgrade-account.component.spec.ts | 17 +- .../upgrade-account.component.ts | 14 +- .../services/upgrade-payment.service.spec.ts | 2 +- .../upgrade-payment.component.ts | 17 +- .../src/services/jslib-services.module.ts | 7 + ...ubscription-pricing.service.abstraction.ts | 26 +++ .../premium-upgrade-dialog.component.html | 43 +++++ .../premium-upgrade-dialog.component.spec.ts | 153 ++++++++++++++++++ ...remium-upgrade-dialog.component.stories.ts | 83 ++++++++++ .../premium-upgrade-dialog.component.ts | 103 ++++++++++++ libs/pricing/src/index.ts | 8 + .../subscription-pricing.service.spec.ts | 20 ++- .../services/subscription-pricing.service.ts | 9 +- .../src}/types/subscription-pricing-tier.ts | 0 17 files changed, 473 insertions(+), 37 deletions(-) create mode 100644 libs/pricing/src/abstractions/subscription-pricing.service.abstraction.ts create mode 100644 libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html create mode 100644 libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts create mode 100644 libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts create mode 100644 libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts rename {apps/web/src/app/billing => libs/pricing/src}/services/subscription-pricing.service.spec.ts (98%) rename {apps/web/src/app/billing => libs/pricing/src}/services/subscription-pricing.service.ts (97%) rename {apps/web/src/app/billing => libs/pricing/src}/types/subscription-pricing-tier.ts (100%) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d91a33c67968..d0d4691ad7c9 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5694,5 +5694,8 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "upgradeNow": { + "message": "Upgrade now" } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 4d1bf8e15fcb..f4b8e15e574f 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4172,5 +4172,8 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "upgradeNow": { + "message": "Upgrade now" } } diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index e46c534ebdd5..84a3c7711984 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -11,10 +11,10 @@ import { DialogRef, DialogService, } from "@bitwarden/components"; +import { PersonalSubscriptionPricingTierId } from "@bitwarden/pricing"; import { AccountBillingClient, TaxClient } from "../../../clients"; import { BillingServicesModule } from "../../../services"; -import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier"; import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component"; import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; import { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index 27e69fcf0d4b..e7ab005381e5 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -5,14 +5,14 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PricingCardComponent } from "@bitwarden/pricing"; - -import { BillingServicesModule } from "../../../services"; -import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, -} from "../../../types/subscription-pricing-tier"; + PricingCardComponent, + SubscriptionPricingServiceAbstraction, +} from "@bitwarden/pricing"; + +import { BillingServicesModule } from "../../../services"; import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component"; @@ -20,7 +20,7 @@ describe("UpgradeAccountComponent", () => { let sut: UpgradeAccountComponent; let fixture: ComponentFixture; const mockI18nService = mock(); - const mockSubscriptionPricingService = mock(); + const mockSubscriptionPricingService = mock(); // Mock pricing tiers data const mockPricingTiers: PersonalSubscriptionPricingTier[] = [ @@ -57,7 +57,10 @@ describe("UpgradeAccountComponent", () => { imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus], providers: [ { provide: I18nService, useValue: mockI18nService }, - { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, ], }) .overrideComponent(UpgradeAccountComponent, { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index a9d9b959282e..c306a6f4232c 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -6,18 +6,18 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonType, DialogModule } from "@bitwarden/components"; -import { PricingCardComponent } from "@bitwarden/pricing"; - -import { SharedModule } from "../../../../shared"; -import { BillingServicesModule } from "../../../services"; -import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, + PricingCardComponent, SubscriptionCadence, SubscriptionCadenceIds, -} from "../../../types/subscription-pricing-tier"; + SubscriptionPricingServiceAbstraction, +} from "@bitwarden/pricing"; + +import { SharedModule } from "../../../../shared"; +import { BillingServicesModule } from "../../../services"; export const UpgradeAccountStatus = { Closed: "closed", @@ -70,7 +70,7 @@ export class UpgradeAccountComponent implements OnInit { constructor( private i18nService: I18nService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private destroyRef: DestroyRef, ) {} 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 653a77dccdc0..43ee227cdb23 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 @@ -14,10 +14,10 @@ import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/pricing"; import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients"; import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types"; -import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier"; import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; 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 33568435d013..43a71f3acd9f 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 @@ -17,18 +17,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { CartSummaryComponent, LineItem } from "@bitwarden/pricing"; +import { + CartSummaryComponent, + LineItem, + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, + SubscriptionPricingServiceAbstraction, +} from "@bitwarden/pricing"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { EnterPaymentMethodComponent } from "../../../payment/components"; import { BillingServicesModule } from "../../../services"; -import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; import { BitwardenSubscriber } from "../../../types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierId, - PersonalSubscriptionPricingTierIds, -} from "../../../types/subscription-pricing-tier"; import { PlanDetails, UpgradePaymentService } from "./services/upgrade-payment.service"; @@ -97,7 +98,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { constructor( private i18nService: I18nService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private toastService: ToastService, private logService: LogService, private destroyRef: DestroyRef, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c66c74a3ea9a..4909a6db228b 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -330,6 +330,8 @@ import { UserAsymmetricKeysRegenerationApiService, UserAsymmetricKeysRegenerationService, } from "@bitwarden/key-management"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/pricing/abstractions/subscription-pricing.service.abstraction"; +import { DefaultSubscriptionPricingService } from "@bitwarden/pricing/services/subscription-pricing.service"; import { ActiveUserStateProvider, DerivedStateProvider, @@ -1426,6 +1428,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultBillingAccountProfileStateService, deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction], }), + safeProvider({ + provide: SubscriptionPricingServiceAbstraction, + useClass: DefaultSubscriptionPricingService, + deps: [ApiServiceAbstraction, I18nServiceAbstraction, LogService, ToastService], + }), safeProvider({ provide: OrganizationManagementPreferencesService, useClass: DefaultOrganizationManagementPreferencesService, diff --git a/libs/pricing/src/abstractions/subscription-pricing.service.abstraction.ts b/libs/pricing/src/abstractions/subscription-pricing.service.abstraction.ts new file mode 100644 index 000000000000..b99fe18d33fe --- /dev/null +++ b/libs/pricing/src/abstractions/subscription-pricing.service.abstraction.ts @@ -0,0 +1,26 @@ +import { Observable } from "rxjs"; + +import { + BusinessSubscriptionPricingTier, + PersonalSubscriptionPricingTier, +} from "../types/subscription-pricing-tier"; + +export abstract class SubscriptionPricingServiceAbstraction { + /** + * Gets personal subscription pricing tiers (Premium and Families). + * @returns An observable of an array of personal subscription pricing tiers. + */ + abstract getPersonalSubscriptionPricingTiers$(): Observable; + + /** + * Gets business subscription pricing tiers (Teams, Enterprise, and Custom). + * @returns An observable of an array of business subscription pricing tiers. + */ + abstract getBusinessSubscriptionPricingTiers$(): Observable; + + /** + * Gets developer subscription pricing tiers (Free, Teams, and Enterprise). + * @returns An observable of an array of business subscription pricing tiers for developers. + */ + abstract getDeveloperSubscriptionPricingTiers$(): Observable; +} diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html new file mode 100644 index 000000000000..5f09a48da8a8 --- /dev/null +++ b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -0,0 +1,43 @@ +@if (!(loading$ | async)) { +
+
+ +
+
+
+

{{ "upgradeToPremium" | i18n }}

+

+ {{ "planDescPremium" | i18n }} +

+
+ +
+ @if (cardDetails$ | async; as cardDetails) { + +

+ {{ cardDetails.title }} +

+
+ } +
+
+
+} diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts new file mode 100644 index 000000000000..c7b2353a05f8 --- /dev/null +++ b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -0,0 +1,153 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { firstValueFrom, of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogRef } from "@bitwarden/components"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, + SubscriptionPricingServiceAbstraction, + PremiumUpgradeDialogComponent, +} from "@bitwarden/pricing"; + +describe("PremiumUpgradeDialogComponent", () => { + let component: PremiumUpgradeDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: jest.Mocked; + let mockSubscriptionPricingService: jest.Mocked; + let mockI18nService: jest.Mocked; + + const mockPremiumTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Advanced features for power users", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "feature1", value: "Feature 1" }, + { key: "feature2", value: "Feature 2" }, + { key: "feature3", value: "Feature 3" }, + ], + }, + }; + + const mockFamiliesTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Families, + name: "Families", + description: "Family plan", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "packaged", + users: 6, + annualPrice: 40, + annualPricePerAdditionalStorageGB: 4, + features: [{ key: "featureA", value: "Feature A" }], + }, + }; + + beforeEach(async () => { + mockDialogRef = { + close: jest.fn(), + } as any; + + mockSubscriptionPricingService = { + getPersonalSubscriptionPricingTiers$: jest.fn(), + } as any; + + mockI18nService = { + t: jest.fn((key: string) => key), + } as any; + + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of([mockPremiumTier, mockFamiliesTier]), + ); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PremiumUpgradeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should emit cardDetails$ observable with Premium tier data", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled(); + expect(cardDetails).toBeDefined(); + expect(cardDetails?.title).toBe("Premium"); + }); + + it("should filter to Premium tier only", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(cardDetails?.title).toBe("Premium"); + expect(cardDetails?.title).not.toBe("Families"); + }); + + it("should map Premium tier to card details correctly", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(cardDetails?.title).toBe("Premium"); + expect(cardDetails?.tagline).toBe("Advanced features for power users"); + expect(cardDetails?.price.amount).toBe(10 / 12); + expect(cardDetails?.price.cadence).toBe("monthly"); + expect(cardDetails?.button.text).toBe("upgradeNow"); + expect(cardDetails?.button.type).toBe("primary"); + expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]); + }); + + it("should use i18nService for button text", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow"); + expect(cardDetails?.button.text).toBe("upgradeNow"); + }); + + it("should emit loading$ observable that starts with true and changes to false", async () => { + // Create a new component to observe the loading state from start + const newFixture = TestBed.createComponent(PremiumUpgradeDialogComponent); + const newComponent = newFixture.componentInstance; + + const loadingValues: boolean[] = []; + newComponent["loading$"].subscribe((loading) => loadingValues.push(loading)); + + // Wait for the observable to emit + await firstValueFrom(newComponent["cardDetails$"]); + + expect(loadingValues.length).toBeGreaterThanOrEqual(2); + expect(loadingValues[0]).toBe(true); + expect(loadingValues[loadingValues.length - 1]).toBe(false); + }); + + it("should close dialog when upgrade button clicked", () => { + component["onUpgradeClick"](); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it("should close dialog when close button clicked", () => { + component["onCloseClick"](); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); +}); diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts new file mode 100644 index 000000000000..24aa89a9cddd --- /dev/null +++ b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -0,0 +1,83 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components"; + +import { SubscriptionPricingServiceAbstraction } from "../../abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "../../types/subscription-pricing-tier"; +import { PricingCardComponent } from "../pricing-card/pricing-card.component"; + +import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; + +const mockPremiumTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Complete online security", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "builtInAuthenticator", value: "Built-in authenticator" }, + { key: "secureFileStorage", value: "Secure file storage" }, + { key: "emergencyAccess", value: "Emergency access" }, + { key: "breachMonitoring", value: "Breach monitoring" }, + { key: "andMoreFeatures", value: "And more!" }, + ], + }, +}; + +export default { + title: "Billing/Premium Upgrade Dialog", + component: PremiumUpgradeDialogComponent, + description: "A dialog for upgrading to Premium subscription", + decorators: [ + moduleMetadata({ + imports: [DialogModule, ButtonModule, TypographyModule, PricingCardComponent], + providers: [ + { + provide: DialogRef, + useValue: { + close: () => {}, + }, + }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: { + getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]), + }, + }, + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "upgradeNow": + return "Upgrade Now"; + case "month": + return "month"; + default: + return key; + } + }, + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=931-17785&t=xOhvwjYLpjoMPgND-1", + }, + }, +} as Meta; + +type Story = StoryObj; +export const Default: Story = {}; diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts new file mode 100644 index 000000000000..e30fa179d33a --- /dev/null +++ b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -0,0 +1,103 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { map, Observable, startWith } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + ButtonType, + DialogConfig, + DialogModule, + DialogRef, + DialogService, + IconButtonModule, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { SubscriptionPricingServiceAbstraction } from "../../abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "../../types/subscription-pricing-tier"; +import { PricingCardComponent } from "../pricing-card/pricing-card.component"; + +type CardDetails = { + title: string; + tagline: string; + price: { amount: number; cadence: string }; + button: { text: string; type: ButtonType }; + features: string[]; +}; + +@Component({ + selector: "billing-premium-upgrade-dialog", + imports: [ + CommonModule, + DialogModule, + ButtonModule, + IconButtonModule, + TypographyModule, + PricingCardComponent, + CdkTrapFocus, + I18nPipe, + ], + templateUrl: "./premium-upgrade-dialog.component.html", +}) +export class PremiumUpgradeDialogComponent { + protected cardDetails$: Observable = this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe( + map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), + map((tier) => this.mapPremiumTierToCardDetails(tier!)), + ); + + protected loading$: Observable = this.cardDetails$.pipe( + map(() => false), + startWith(true), + ); + + constructor( + private dialogRef: DialogRef, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private i18nService: I18nService, + ) {} + + protected onUpgradeClick(): void { + // todo: redirect to web vault upgrade path + this.dialogRef.close(); + } + + protected onCloseClick(): void { + this.dialogRef.close(); + } + + private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails { + return { + title: tier.name, + tagline: tier.description, + price: { + amount: tier.passwordManager.annualPrice / 12, + cadence: SubscriptionCadenceIds.Monthly, + }, + button: { + text: this.i18nService.t("upgradeNow"), + type: "primary", + }, + features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value), + }; + } + + /** + * Opens the premium upgrade dialog. + * + * @param dialogService - The dialog service used to open the component + * @param dialogConfig - Optional configuration for the dialog + * @returns A dialog reference object + */ + static open(dialogService: DialogService, dialogConfig?: DialogConfig): DialogRef { + return dialogService.open(PremiumUpgradeDialogComponent, dialogConfig); + } +} diff --git a/libs/pricing/src/index.ts b/libs/pricing/src/index.ts index d7c7772bfcbd..c17783ce2854 100644 --- a/libs/pricing/src/index.ts +++ b/libs/pricing/src/index.ts @@ -1,3 +1,11 @@ // Components export * from "./components/pricing-card/pricing-card.component"; export * from "./components/cart-summary/cart-summary.component"; +export * from "./components/premium-upgrade-dialog/premium-upgrade-dialog.component"; + +// Services +export * from "./abstractions/subscription-pricing.service.abstraction"; +export * from "./services/subscription-pricing.service"; + +// Types +export * from "./types/subscription-pricing-tier"; diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/libs/pricing/src/services/subscription-pricing.service.spec.ts similarity index 98% rename from apps/web/src/app/billing/services/subscription-pricing.service.spec.ts rename to libs/pricing/src/services/subscription-pricing.service.spec.ts index 0fb33020bc3c..7cf0fb787e50 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts +++ b/libs/pricing/src/services/subscription-pricing.service.spec.ts @@ -14,10 +14,10 @@ import { SubscriptionCadenceIds, } from "../types/subscription-pricing-tier"; -import { SubscriptionPricingService } from "./subscription-pricing.service"; +import { DefaultSubscriptionPricingService } from "./subscription-pricing.service"; -describe("SubscriptionPricingService", () => { - let service: SubscriptionPricingService; +describe("DefaultSubscriptionPricingService", () => { + let service: DefaultSubscriptionPricingService; let apiService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; @@ -326,7 +326,11 @@ describe("SubscriptionPricingService", () => { TestBed.configureTestingModule({ providers: [ - SubscriptionPricingService, + { + provide: DefaultSubscriptionPricingService, + useClass: DefaultSubscriptionPricingService, + deps: [ApiService, I18nService, LogService, ToastService], + }, { provide: ApiService, useValue: apiService }, { provide: I18nService, useValue: i18nService }, { provide: LogService, useValue: logService }, @@ -334,7 +338,7 @@ describe("SubscriptionPricingService", () => { ], }); - service = TestBed.inject(SubscriptionPricingService); + service = TestBed.inject(DefaultSubscriptionPricingService); }); describe("getPersonalSubscriptionPricingTiers$", () => { @@ -421,7 +425,7 @@ describe("SubscriptionPricingService", () => { return key; }); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorApiService, errorI18nService, errorLogService, @@ -606,7 +610,7 @@ describe("SubscriptionPricingService", () => { return key; }); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorApiService, errorI18nService, errorLogService, @@ -846,7 +850,7 @@ describe("SubscriptionPricingService", () => { return key; }); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorApiService, errorI18nService, errorLogService, diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/libs/pricing/src/services/subscription-pricing.service.ts similarity index 97% rename from apps/web/src/app/billing/services/subscription-pricing.service.ts rename to libs/pricing/src/services/subscription-pricing.service.ts index 82ec9f180b9a..f7fdca42f387 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/libs/pricing/src/services/subscription-pricing.service.ts @@ -1,4 +1,3 @@ -import { Injectable } from "@angular/core"; import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs"; import { catchError } from "rxjs/operators"; @@ -9,17 +8,17 @@ import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { BillingServicesModule } from "@bitwarden/web-vault/app/billing/services/billing-services.module"; + +import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction"; import { BusinessSubscriptionPricingTier, BusinessSubscriptionPricingTierIds, PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, SubscriptionCadenceIds, -} from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; +} from "../types/subscription-pricing-tier"; -@Injectable({ providedIn: BillingServicesModule }) -export class SubscriptionPricingService { +export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction { constructor( private apiService: ApiService, private i18nService: I18nService, diff --git a/apps/web/src/app/billing/types/subscription-pricing-tier.ts b/libs/pricing/src/types/subscription-pricing-tier.ts similarity index 100% rename from apps/web/src/app/billing/types/subscription-pricing-tier.ts rename to libs/pricing/src/types/subscription-pricing-tier.ts From d1be2eae35c726a9ac7ed0341a172771216dae8e Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:57:40 -0500 Subject: [PATCH 03/28] moved pricing service to libs/common removed toast service from the pricing service and implemented error handling in calling components # Conflicts: # apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts --- .../unified-upgrade-dialog.component.ts | 2 +- .../upgrade-account.component.spec.ts | 8 +- .../upgrade-account.component.ts | 27 ++-- .../services/upgrade-payment.service.spec.ts | 2 +- .../upgrade-payment.component.ts | 48 +++++--- .../src/services/jslib-services.module.ts | 6 +- ...ubscription-pricing.service.abstraction.ts | 0 .../subscription-pricing.service.spec.ts | 115 ++++++------------ .../services/subscription-pricing.service.ts | 27 ++-- .../types/subscription-pricing-tier.ts | 0 .../premium-upgrade-dialog.component.spec.ts | 10 +- ...remium-upgrade-dialog.component.stories.ts | 10 +- .../premium-upgrade-dialog.component.ts | 26 ++-- libs/pricing/src/index.ts | 7 -- 14 files changed, 128 insertions(+), 160 deletions(-) rename libs/{pricing/src => common/src/billing}/abstractions/subscription-pricing.service.abstraction.ts (100%) rename libs/{pricing/src => common/src/billing}/services/subscription-pricing.service.spec.ts (90%) rename libs/{pricing/src => common/src/billing}/services/subscription-pricing.service.ts (95%) rename libs/{pricing/src => common/src/billing}/types/subscription-pricing-tier.ts (100%) diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 84a3c7711984..a47c328a1b3b 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, Inject, OnInit, signal } from "@angular/core"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, @@ -11,7 +12,6 @@ import { DialogRef, DialogService, } from "@bitwarden/components"; -import { PersonalSubscriptionPricingTierId } from "@bitwarden/pricing"; import { AccountBillingClient, TaxClient } from "../../../clients"; import { BillingServicesModule } from "../../../services"; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index e7ab005381e5..8f5862d2b770 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, - PricingCardComponent, - SubscriptionPricingServiceAbstraction, -} from "@bitwarden/pricing"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PricingCardComponent } from "@bitwarden/pricing"; import { BillingServicesModule } from "../../../services"; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index c306a6f4232c..33e454523f9c 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -2,19 +2,20 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { catchError, of } from "rxjs"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; -import { ButtonType, DialogModule } from "@bitwarden/components"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, - PricingCardComponent, SubscriptionCadence, SubscriptionCadenceIds, - SubscriptionPricingServiceAbstraction, -} from "@bitwarden/pricing"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonType, DialogModule, ToastService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; import { SharedModule } from "../../../../shared"; import { BillingServicesModule } from "../../../services"; @@ -71,13 +72,25 @@ export class UpgradeAccountComponent implements OnInit { constructor( private i18nService: I18nService, private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private toastService: ToastService, private destroyRef: DestroyRef, ) {} ngOnInit(): void { this.subscriptionPricingService .getPersonalSubscriptionPricingTiers$() - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) .subscribe((plans) => { this.setupCardDetails(plans); this.loading.set(false); 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 43ee227cdb23..759a61a7fa26 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 @@ -11,10 +11,10 @@ import { OrganizationResponse } from "@bitwarden/common/admin-console/models/res import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; -import { PersonalSubscriptionPricingTierIds } from "@bitwarden/pricing"; import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients"; import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types"; 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 43a71f3acd9f..470aaea3f963 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 @@ -10,21 +10,20 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { debounceTime, Observable } from "rxjs"; +import { catchError, debounceTime, Observable, of } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; -import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; -import { LogService } from "@bitwarden/logging"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { - CartSummaryComponent, - LineItem, PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, - SubscriptionPricingServiceAbstraction, -} from "@bitwarden/pricing"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { CartSummaryComponent, LineItem } from "@bitwarden/pricing"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { EnterPaymentMethodComponent } from "../../../payment/components"; @@ -115,15 +114,28 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); - this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => { - const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); - - if (planDetails) { - this.selectedPlan = { - tier: this.selectedPlanId(), - details: planDetails, - }; - this.passwordManager = { + this.pricingTiers$ + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((plans) => { + const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); + + if (planDetails) { + this.selectedPlan = { + tier: this.selectedPlanId(), + details: planDetails, + }; + this.passwordManager = { name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", cost: this.selectedPlan.details.passwordManager.annualPrice, quantity: 1, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 4909a6db228b..2f240cfa25f4 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -148,6 +148,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; @@ -155,6 +156,7 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; +import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; import { DefaultKeyGenerationService, @@ -330,8 +332,6 @@ import { UserAsymmetricKeysRegenerationApiService, UserAsymmetricKeysRegenerationService, } from "@bitwarden/key-management"; -import { SubscriptionPricingServiceAbstraction } from "@bitwarden/pricing/abstractions/subscription-pricing.service.abstraction"; -import { DefaultSubscriptionPricingService } from "@bitwarden/pricing/services/subscription-pricing.service"; import { ActiveUserStateProvider, DerivedStateProvider, @@ -1431,7 +1431,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SubscriptionPricingServiceAbstraction, useClass: DefaultSubscriptionPricingService, - deps: [ApiServiceAbstraction, I18nServiceAbstraction, LogService, ToastService], + deps: [ApiServiceAbstraction, I18nServiceAbstraction, LogService], }), safeProvider({ provide: OrganizationManagementPreferencesService, diff --git a/libs/pricing/src/abstractions/subscription-pricing.service.abstraction.ts b/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts similarity index 100% rename from libs/pricing/src/abstractions/subscription-pricing.service.abstraction.ts rename to libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts diff --git a/libs/pricing/src/services/subscription-pricing.service.spec.ts b/libs/common/src/billing/services/subscription-pricing.service.spec.ts similarity index 90% rename from libs/pricing/src/services/subscription-pricing.service.spec.ts rename to libs/common/src/billing/services/subscription-pricing.service.spec.ts index 7cf0fb787e50..5ca5f35556ff 100644 --- a/libs/pricing/src/services/subscription-pricing.service.spec.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.spec.ts @@ -1,11 +1,9 @@ -import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; import { @@ -21,7 +19,6 @@ describe("DefaultSubscriptionPricingService", () => { let apiService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; - let toastService: MockProxy; const mockFamiliesPlan = { type: PlanType.FamiliesAnnually, @@ -220,7 +217,6 @@ describe("DefaultSubscriptionPricingService", () => { beforeAll(() => { i18nService = mock(); logService = mock(); - toastService = mock(); i18nService.t.mockImplementation((key: string, ...args: any[]) => { switch (key) { @@ -311,8 +307,6 @@ describe("DefaultSubscriptionPricingService", () => { return "Boost productivity"; case "seamlessIntegration": return "Seamless integration"; - case "unexpectedError": - return "An unexpected error has occurred."; default: return key; } @@ -324,21 +318,7 @@ describe("DefaultSubscriptionPricingService", () => { apiService.getPlans.mockResolvedValue(mockPlansResponse); - TestBed.configureTestingModule({ - providers: [ - { - provide: DefaultSubscriptionPricingService, - useClass: DefaultSubscriptionPricingService, - deps: [ApiService, I18nService, LogService, ToastService], - }, - { provide: ApiService, useValue: apiService }, - { provide: I18nService, useValue: i18nService }, - { provide: LogService, useValue: logService }, - { provide: ToastService, useValue: toastService }, - ], - }); - - service = TestBed.inject(DefaultSubscriptionPricingService); + service = new DefaultSubscriptionPricingService(apiService, i18nService, logService); }); describe("getPersonalSubscriptionPricingTiers$", () => { @@ -409,42 +389,33 @@ describe("DefaultSubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorApiService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorApiService.getPlans.mockRejectedValue(testError); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); const errorService = new DefaultSubscriptionPricingService( errorApiService, errorI18nService, errorLogService, - errorToastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -594,42 +565,33 @@ describe("DefaultSubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorApiService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorApiService.getPlans.mockRejectedValue(testError); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); const errorService = new DefaultSubscriptionPricingService( errorApiService, errorI18nService, errorLogService, - errorToastService, ); errorService.getBusinessSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load business subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -834,42 +796,33 @@ describe("DefaultSubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorApiService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorApiService.getPlans.mockRejectedValue(testError); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); const errorService = new DefaultSubscriptionPricingService( errorApiService, errorI18nService, errorLogService, - errorToastService, ); errorService.getDeveloperSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load developer subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); diff --git a/libs/pricing/src/services/subscription-pricing.service.ts b/libs/common/src/billing/services/subscription-pricing.service.ts similarity index 95% rename from libs/pricing/src/services/subscription-pricing.service.ts rename to libs/common/src/billing/services/subscription-pricing.service.ts index f7fdca42f387..fff3f5277779 100644 --- a/libs/pricing/src/services/subscription-pricing.service.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.ts @@ -1,4 +1,4 @@ -import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs"; +import { combineLatest, from, map, Observable, of, shareReplay, throwError } from "rxjs"; import { catchError } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -6,7 +6,6 @@ import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction"; @@ -23,33 +22,29 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer private apiService: ApiService, private i18nService: I18nService, private logService: LogService, - private toastService: ToastService, ) {} getPersonalSubscriptionPricingTiers$ = (): Observable => combineLatest([this.premium$, this.families$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load personal subscription pricing tiers", error); + return throwError(() => error); }), ); getBusinessSubscriptionPricingTiers$ = (): Observable => combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load business subscription pricing tiers", error); + return throwError(() => error); }), ); getDeveloperSubscriptionPricingTiers$ = (): Observable => combineLatest([this.free$, this.teams$, this.enterprise$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load developer subscription pricing tiers", error); + return throwError(() => error); }), ); @@ -232,14 +227,6 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer ), ); - private showUnexpectedErrorToast() { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("unexpectedError"), - }); - } - private featureTranslations = { builtInAuthenticator: () => ({ key: "builtInAuthenticator", diff --git a/libs/pricing/src/types/subscription-pricing-tier.ts b/libs/common/src/billing/types/subscription-pricing-tier.ts similarity index 100% rename from libs/pricing/src/types/subscription-pricing-tier.ts rename to libs/common/src/billing/types/subscription-pricing-tier.ts diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index c7b2353a05f8..437b770af478 100644 --- a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -3,15 +3,15 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { firstValueFrom, of } from "rxjs"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogRef } from "@bitwarden/components"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, SubscriptionCadenceIds, - SubscriptionPricingServiceAbstraction, - PremiumUpgradeDialogComponent, -} from "@bitwarden/pricing"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogRef } from "@bitwarden/components"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/pricing"; describe("PremiumUpgradeDialogComponent", () => { let component: PremiumUpgradeDialogComponent; diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts index 24aa89a9cddd..e14658f66135 100644 --- a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts +++ b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -1,15 +1,15 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { of } from "rxjs"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components"; - -import { SubscriptionPricingServiceAbstraction } from "../../abstractions/subscription-pricing.service.abstraction"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, SubscriptionCadenceIds, -} from "../../types/subscription-pricing-tier"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components"; + import { PricingCardComponent } from "../pricing-card/pricing-card.component"; import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts index e30fa179d33a..7f716bc4d847 100644 --- a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts +++ b/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -1,8 +1,14 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; -import { map, Observable, startWith } from "rxjs"; +import { catchError, map, Observable, of, startWith } from "rxjs"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule, @@ -12,16 +18,11 @@ import { DialogRef, DialogService, IconButtonModule, + ToastService, TypographyModule, } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -import { SubscriptionPricingServiceAbstraction } from "../../abstractions/subscription-pricing.service.abstraction"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierIds, - SubscriptionCadenceIds, -} from "../../types/subscription-pricing-tier"; import { PricingCardComponent } from "../pricing-card/pricing-card.component"; type CardDetails = { @@ -47,11 +48,19 @@ type CardDetails = { templateUrl: "./premium-upgrade-dialog.component.html", }) export class PremiumUpgradeDialogComponent { - protected cardDetails$: Observable = this.subscriptionPricingService + protected cardDetails$: Observable = this.subscriptionPricingService .getPersonalSubscriptionPricingTiers$() .pipe( map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), map((tier) => this.mapPremiumTierToCardDetails(tier!)), + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + return of(null); + }), ); protected loading$: Observable = this.cardDetails$.pipe( @@ -63,6 +72,7 @@ export class PremiumUpgradeDialogComponent { private dialogRef: DialogRef, private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private i18nService: I18nService, + private toastService: ToastService, ) {} protected onUpgradeClick(): void { diff --git a/libs/pricing/src/index.ts b/libs/pricing/src/index.ts index c17783ce2854..2794d46a5824 100644 --- a/libs/pricing/src/index.ts +++ b/libs/pricing/src/index.ts @@ -2,10 +2,3 @@ export * from "./components/pricing-card/pricing-card.component"; export * from "./components/cart-summary/cart-summary.component"; export * from "./components/premium-upgrade-dialog/premium-upgrade-dialog.component"; - -// Services -export * from "./abstractions/subscription-pricing.service.abstraction"; -export * from "./services/subscription-pricing.service"; - -// Types -export * from "./types/subscription-pricing-tier"; From 10cd5d48b1a771baaecf71cecc54825c7e697e19 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:08:05 -0500 Subject: [PATCH 04/28] moved new premium upgrade dialog component to libs/angular --- libs/angular/src/billing/components/index.ts | 1 + .../premium-upgrade-dialog.component.html | 0 .../premium-upgrade-dialog.component.spec.ts | 3 ++- .../premium-upgrade-dialog.component.stories.ts | 3 +-- .../premium-upgrade-dialog/premium-upgrade-dialog.component.ts | 3 +-- libs/pricing/src/index.ts | 1 - 6 files changed, 5 insertions(+), 6 deletions(-) rename libs/{pricing/src => angular/src/billing}/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html (100%) rename libs/{pricing/src => angular/src/billing}/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts (98%) rename libs/{pricing/src => angular/src/billing}/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts (97%) rename libs/{pricing/src => angular/src/billing}/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts (97%) diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts index 34e1d27c1edd..574ed01e0736 100644 --- a/libs/angular/src/billing/components/index.ts +++ b/libs/angular/src/billing/components/index.ts @@ -1 +1,2 @@ export * from "./premium.component"; +export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component"; diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html similarity index 100% rename from libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html rename to libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts similarity index 98% rename from libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts rename to libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index 437b770af478..bcf0a9ff1013 100644 --- a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -11,7 +11,8 @@ import { } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogRef } from "@bitwarden/components"; -import { PremiumUpgradeDialogComponent } from "@bitwarden/pricing"; + +import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; describe("PremiumUpgradeDialogComponent", () => { let component: PremiumUpgradeDialogComponent; diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts similarity index 97% rename from libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts rename to libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts index e14658f66135..f858f2ae38a2 100644 --- a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -9,8 +9,7 @@ import { } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components"; - -import { PricingCardComponent } from "../pricing-card/pricing-card.component"; +import { PricingCardComponent } from "@bitwarden/pricing"; import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; diff --git a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts similarity index 97% rename from libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts rename to libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts index 7f716bc4d847..b582376c7afe 100644 --- a/libs/pricing/src/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -21,10 +21,9 @@ import { ToastService, TypographyModule, } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; import { I18nPipe } from "@bitwarden/ui-common"; -import { PricingCardComponent } from "../pricing-card/pricing-card.component"; - type CardDetails = { title: string; tagline: string; diff --git a/libs/pricing/src/index.ts b/libs/pricing/src/index.ts index 2794d46a5824..d7c7772bfcbd 100644 --- a/libs/pricing/src/index.ts +++ b/libs/pricing/src/index.ts @@ -1,4 +1,3 @@ // Components export * from "./components/pricing-card/pricing-card.component"; export * from "./components/cart-summary/cart-summary.component"; -export * from "./components/premium-upgrade-dialog/premium-upgrade-dialog.component"; From 31f49b07a2707c201423b4f53d91eac93cd97330 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:00:57 -0500 Subject: [PATCH 05/28] badge opens new dialog in browser extension --- .../browser-premium-upgrade-prompt.service.ts | 22 +++++++++++++++---- .../services/upgrade-payment.service.ts | 10 ++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts index 2909e3b3bd61..53f7ffd5f5ae 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts @@ -1,18 +1,32 @@ import { inject } from "@angular/core"; import { Router } from "@angular/router"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; /** * This class handles the premium upgrade process for the browser extension. */ export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService { private router = inject(Router); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); async promptForPremium() { - /** - * Navigate to the premium update screen. - */ - await this.router.navigate(["/premium"]); + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + if (showNewDialog) { + PremiumUpgradeDialogComponent.open(this.dialogService); + } else { + /** + * Navigate to the premium update screen. + */ + await this.router.navigate(["/premium"]); + } } } 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 11dd10d4bb81..bb923f6ed16f 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 @@ -12,6 +12,11 @@ import { SubscriptionInformation, } from "@bitwarden/common/billing/abstractions"; import { PlanType } from "@bitwarden/common/billing/enums"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { LogService } from "@bitwarden/logging"; @@ -26,11 +31,6 @@ import { tokenizablePaymentMethodToLegacyEnum, TokenizedPaymentMethod, } from "../../../../payment/types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierId, - PersonalSubscriptionPricingTierIds, -} from "../../../../types/subscription-pricing-tier"; export type PlanDetails = { tier: PersonalSubscriptionPricingTierId; From eff96f9cc179426d3bdc73c9384b97d35110baea Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:44:55 -0500 Subject: [PATCH 06/28] adds new dialog to desktop and fixes tests --- ...ser-premium-upgrade-prompt.service.spec.ts | 48 ++++++++++++++++++- ...top-premium-upgrade-prompt.service.spec.ts | 42 +++++++++++++++- .../desktop-premium-upgrade-prompt.service.ts | 16 ++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts index 9a00bacd6b0e..bf63cf1f668a 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts @@ -2,25 +2,69 @@ import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogService } from "@bitwarden/components"; + import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service"; describe("BrowserPremiumUpgradePromptService", () => { let service: BrowserPremiumUpgradePromptService; let router: MockProxy; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { router = mock(); + configService = mock(); + dialogService = mock(); + await TestBed.configureTestingModule({ - providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }], + providers: [ + BrowserPremiumUpgradePromptService, + { provide: Router, useValue: router }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + ], }).compileComponents(); service = TestBed.inject(BrowserPremiumUpgradePromptService); }); describe("promptForPremium", () => { - it("navigates to the premium update screen", async () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("opens the new premium upgrade dialog when feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + expect(openSpy).toHaveBeenCalledWith(dialogService); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("navigates to the premium update screen when feature flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + expect(openSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts index 3b33116ea5ae..1eee4cd54f60 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts @@ -1,20 +1,31 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { DialogService } from "@bitwarden/components"; import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service"; describe("DesktopPremiumUpgradePromptService", () => { let service: DesktopPremiumUpgradePromptService; let messager: MockProxy; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { messager = mock(); + configService = mock(); + dialogService = mock(); + await TestBed.configureTestingModule({ providers: [ DesktopPremiumUpgradePromptService, { provide: MessagingService, useValue: messager }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); @@ -22,9 +33,38 @@ describe("DesktopPremiumUpgradePromptService", () => { }); describe("promptForPremium", () => { - it("navigates to the premium update screen", async () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("opens the new premium upgrade dialog when feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + expect(openSpy).toHaveBeenCalledWith(dialogService); + expect(messager.send).not.toHaveBeenCalled(); + }); + + it("sends openPremium message when feature flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); expect(messager.send).toHaveBeenCalledWith("openPremium"); + expect(openSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts index f2375ecfebb7..5004e5ed547e 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts @@ -1,15 +1,29 @@ import { inject } from "@angular/core"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; /** * This class handles the premium upgrade process for the desktop. */ export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService { private messagingService = inject(MessagingService); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); async promptForPremium() { - this.messagingService.send("openPremium"); + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + if (showNewDialog) { + PremiumUpgradeDialogComponent.open(this.dialogService); + } else { + this.messagingService.send("openPremium"); + } } } From 86c72a32a787124c37d41ee4e1d4d1eb762120c7 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:56:12 -0500 Subject: [PATCH 07/28] updates send dropdown to use premium prompt service --- .../new-send-dropdown.component.html | 12 ++------- .../new-send-dropdown.component.ts | 26 ++++++++++++------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html index 1d5629cfd48a..bcced7e012b6 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html @@ -3,19 +3,11 @@ {{ (hideIcon ? "createSend" : "new") | i18n }} - + {{ "sendTypeText" | i18n }} - +
{{ "sendTypeFile" | i18n }} diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index b553a343cdd4..e1885499dd67 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; -import { RouterLink } from "@angular/router"; +import { Router, RouterLink } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; @@ -8,6 +8,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; @Component({ @@ -26,6 +27,8 @@ export class NewSendDropdownComponent implements OnInit { constructor( private billingAccountProfileStateService: BillingAccountProfileStateService, private accountService: AccountService, + private router: Router, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit() { @@ -40,18 +43,21 @@ export class NewSendDropdownComponent implements OnInit { )); } - buildRouterLink(type: SendType) { - if (this.hasNoPremium && type === SendType.File) { - return "/premium"; - } else { - return "/add-send"; - } + buildRouterLink() { + return "/add-send"; } buildQueryParams(type: SendType) { - if (this.hasNoPremium && type === SendType.File) { - return null; - } return { type: type, isNew: true }; } + + async sendFileClick() { + if (this.hasNoPremium) { + await this.premiumUpgradePromptService.promptForPremium(); + } else { + await this.router.navigate([this.buildRouterLink()], { + queryParams: this.buildQueryParams(SendType.File), + }); + } + } } From 9d039eeb38617317f7f3790f7f6d3911db31abcc Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:34:40 -0500 Subject: [PATCH 08/28] styling and copy updates --- apps/browser/src/_locales/en/messages.json | 21 ++++++++++ .../premium-upgrade-dialog.component.html | 41 ++++++++----------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d0d4691ad7c9..3064b905b980 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5697,5 +5697,26 @@ }, "upgradeNow": { "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" } } diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html index 5f09a48da8a8..301c80b647b1 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -1,6 +1,6 @@ @if (!(loading$ | async)) {
@@ -14,30 +14,21 @@ (click)="onCloseClick()" > -
-
-

{{ "upgradeToPremium" | i18n }}

-

- {{ "planDescPremium" | i18n }} -

-
- -
- @if (cardDetails$ | async; as cardDetails) { - -

- {{ cardDetails.title }} -

-
- } -
+
+ @if (cardDetails$ | async; as cardDetails) { + +

+ {{ "upgradeToPremium" | i18n }} +

+
+ }
} From c6b2830239101cb2c9fd7ff62c1c4c9f613cf4da Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:25:09 -0500 Subject: [PATCH 09/28] implement in web and desktop --- .../open-attachments.component.ts | 4 ++- apps/browser/tailwind.config.js | 1 + .../src/app/tools/send/add-edit.component.ts | 11 +++++++ apps/desktop/src/locales/en/messages.json | 21 +++++++++++++ apps/desktop/tailwind.config.js | 1 + apps/web/src/app/core/core.module.ts | 2 +- ...web-premium-upgrade-prompt.service.spec.ts | 14 +++++++++ .../web-premium-upgrade-prompt.service.ts | 28 ++++++++++++++++- .../premium-badge/premium-badge.component.ts | 11 +++++-- .../premium-upgrade-dialog.component.html | 12 ++++--- .../premium-upgrade-dialog.component.spec.ts | 6 ++-- ...remium-upgrade-dialog.component.stories.ts | 31 ++++++++++++++++++- .../premium-upgrade-dialog.component.ts | 31 ++++++++++++------- .../src/tools/send/add-edit.component.ts | 20 ++++++++---- .../pricing-card/pricing-card.component.html | 6 +++- .../pricing-card/pricing-card.component.mdx | 18 ++++++----- .../pricing-card/pricing-card.component.ts | 6 ++-- 17 files changed, 181 insertions(+), 42 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts index 26410a46187a..c5816cc9c2df 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -19,6 +19,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components"; import { CipherFormContainer } from "@bitwarden/vault"; @@ -63,6 +64,7 @@ export class OpenAttachmentsComponent implements OnInit { private filePopoutUtilsService: FilePopoutUtilsService, private accountService: AccountService, private cipherFormContainer: CipherFormContainer, + private premiumUpgradeService: PremiumUpgradePromptService, ) { this.accountService.activeAccount$ .pipe( @@ -111,7 +113,7 @@ export class OpenAttachmentsComponent implements OnInit { /** Routes the user to the attachments screen, if available */ async openAttachments() { if (!this.canAccessAttachments) { - await this.router.navigate(["/premium"]); + await this.premiumUpgradeService.promptForPremium(); return; } diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index 1ad56562bb3d..134001bbf134 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -10,6 +10,7 @@ config.content = [ "../../libs/vault/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}", + "../../libs/pricing/src/**/*.{html,ts}", ]; module.exports = config; diff --git a/apps/desktop/src/app/tools/send/add-edit.component.ts b/apps/desktop/src/app/tools/send/add-edit.component.ts index 025bab665398..406733fe3df4 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -17,12 +17,21 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CalloutModule, DialogService, ToastService } from "@bitwarden/components"; +import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; + @Component({ selector: "app-send-add-edit", templateUrl: "add-edit.component.html", imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule], + providers: [ + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + ], }) export class AddEditComponent extends BaseAddEditComponent { constructor( @@ -41,6 +50,7 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, toastService: ToastService, + premiumUpgradePromptService: PremiumUpgradePromptService, ) { super( i18nService, @@ -58,6 +68,7 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService, accountService, toastService, + premiumUpgradePromptService, ); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f4b8e15e574f..4120a7f7b872 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4175,5 +4175,26 @@ }, "upgradeNow": { "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" } } diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index bf65ae8d7cbd..e67c0c38010a 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -9,6 +9,7 @@ config.content = [ "../../libs/key-management-ui/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts,mdx}", + "../../libs/pricing/src/**/*.{html,ts}", ]; module.exports = config; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 29b84ddc3820..9d01a99f8400 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -408,7 +408,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService, - deps: [DialogService, Router], + deps: [DialogService, ConfigService, AccountService, Router], }), ]; diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts index 1f34b823aec2..6056955cbf10 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts @@ -2,6 +2,8 @@ import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; import { lastValueFrom, of } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogRef, DialogService } from "@bitwarden/components"; @@ -14,12 +16,22 @@ describe("WebVaultPremiumUpgradePromptService", () => { let dialogServiceMock: jest.Mocked; let routerMock: jest.Mocked; let dialogRefMock: jest.Mocked>; + let configServiceMock: jest.Mocked; + let accountServiceMock: jest.Mocked; beforeEach(() => { dialogServiceMock = { openSimpleDialog: jest.fn(), } as unknown as jest.Mocked; + configServiceMock = { + getFeatureFlag: jest.fn().mockReturnValue(false), + } as unknown as jest.Mocked; + + accountServiceMock = { + activeAccount$: of({ id: "user-123" }), + } as unknown as jest.Mocked; + routerMock = { navigate: jest.fn(), } as unknown as jest.Mocked; @@ -34,6 +46,8 @@ describe("WebVaultPremiumUpgradePromptService", () => { { provide: DialogService, useValue: dialogServiceMock }, { provide: Router, useValue: routerMock }, { provide: DialogRef, useValue: dialogRefMock }, + { provide: ConfigService, useValue: configServiceMock }, + { provide: AccountService, useValue: accountServiceMock }, ], }); diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts index 87fcdc345d81..69fe01c24191 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts @@ -1,10 +1,14 @@ import { Injectable, Optional } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject } from "rxjs"; +import { firstValueFrom, Subject } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogRef, DialogService } from "@bitwarden/components"; +import { UnifiedUpgradeDialogComponent } from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component"; @@ -15,6 +19,8 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt constructor( private dialogService: DialogService, + private configService: ConfigService, + private accountService: AccountService, private router: Router, @Optional() private dialog?: DialogRef, ) {} @@ -23,6 +29,26 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt * Prompts the user for a premium upgrade. */ async promptForPremium(organizationId?: OrganizationId) { + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + // Per conversation in PM-23713, retain the existing upgrade org flow for now, will be addressed later + if (showNewDialog && !organizationId) { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { + account, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + return; + } + let confirmed = false; let route: string[] | null = null; diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts index a4a1d76d1d6c..8ad43c0f72bb 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts @@ -8,7 +8,13 @@ import { BadgeModule } from "@bitwarden/components"; selector: "app-premium-badge", standalone: true, template: ` - `, @@ -19,7 +25,8 @@ export class PremiumBadgeComponent { constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {} - async promptForPremium() { + async promptForPremium(event: Event) { + event.stopPropagation(); await this.premiumUpgradePromptService.promptForPremium(this.organizationId()); } } diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html index 301c80b647b1..15262d70b5bd 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -1,20 +1,20 @@ @if (!(loading$ | async)) {
-
+
-
+
@if (cardDetails$ | async; as cardDetails) {

{{ "upgradeToPremium" | i18n }} diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index bcf0a9ff1013..d091e5b2cd5c 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -140,14 +140,14 @@ describe("PremiumUpgradeDialogComponent", () => { expect(loadingValues[loadingValues.length - 1]).toBe(false); }); - it("should close dialog when upgrade button clicked", () => { - component["onUpgradeClick"](); + it("should close dialog when upgrade button clicked", async () => { + await component["upgrade"](); expect(mockDialogRef.close).toHaveBeenCalled(); }); it("should close dialog when close button clicked", () => { - component["onCloseClick"](); + component["close"](); expect(mockDialogRef.close).toHaveBeenCalled(); }); diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts index f858f2ae38a2..33fbf4a5c9e6 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -7,8 +7,17 @@ import { PersonalSubscriptionPricingTierIds, SubscriptionCadenceIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonModule, + DialogModule, + DialogRef, + ToastOptions, + ToastService, + TypographyModule, +} from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; @@ -52,6 +61,24 @@ export default { getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]), }, }, + { + provide: ToastService, + useValue: { + showToast: (options: ToastOptions) => {}, + }, + }, + { + provide: EnvironmentService, + useValue: { + cloudWebVaultUrl$: of("https://vault.bitwarden.com"), + }, + }, + { + provide: PlatformUtilsService, + useValue: { + launchUri: (uri: string) => {}, + }, + }, { provide: I18nService, useValue: { @@ -61,6 +88,8 @@ export default { return "Upgrade Now"; case "month": return "month"; + case "upgradeToPremium": + return "Upgrade To Premium"; default: return key; } diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts index b582376c7afe..97706f8f5f3c 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -1,19 +1,21 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; -import { catchError, map, Observable, of, startWith } from "rxjs"; +import { catchError, firstValueFrom, map, Observable, of, startWith } from "rxjs"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, + SubscriptionCadence, SubscriptionCadenceIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ButtonModule, ButtonType, - DialogConfig, DialogModule, DialogRef, DialogService, @@ -27,8 +29,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; type CardDetails = { title: string; tagline: string; - price: { amount: number; cadence: string }; - button: { text: string; type: ButtonType }; + price: { amount: number; cadence: SubscriptionCadence }; + button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } }; features: string[]; }; @@ -52,7 +54,7 @@ export class PremiumUpgradeDialogComponent { .pipe( map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), map((tier) => this.mapPremiumTierToCardDetails(tier!)), - catchError((error: unknown) => { + catchError(() => { this.toastService.showToast({ variant: "error", title: "", @@ -72,14 +74,19 @@ export class PremiumUpgradeDialogComponent { private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private i18nService: I18nService, private toastService: ToastService, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, ) {} - protected onUpgradeClick(): void { - // todo: redirect to web vault upgrade path + protected async upgrade(): Promise { + const vaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$); + this.platformUtilsService.launchUri( + vaultUrl + "/#/settings/subscription/premium?callToAction=upgradeToPremium", + ); this.dialogRef.close(); } - protected onCloseClick(): void { + protected close(): void { this.dialogRef.close(); } @@ -94,8 +101,9 @@ export class PremiumUpgradeDialogComponent { button: { text: this.i18nService.t("upgradeNow"), type: "primary", + icon: { type: "bwi-external-link", position: "after" }, }, - features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value), + features: tier.passwordManager.features.map((f) => f.value), }; } @@ -103,10 +111,9 @@ export class PremiumUpgradeDialogComponent { * Opens the premium upgrade dialog. * * @param dialogService - The dialog service used to open the component - * @param dialogConfig - Optional configuration for the dialog * @returns A dialog reference object */ - static open(dialogService: DialogService, dialogConfig?: DialogConfig): DialogRef { - return dialogService.open(PremiumUpgradeDialogComponent, dialogConfig); + static open(dialogService: DialogService): DialogRef { + return dialogService.open(PremiumUpgradeDialogComponent); } } diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 221b751528a5..521d45b8f626 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -11,6 +11,7 @@ import { BehaviorSubject, concatMap, switchMap, + tap, } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService, ToastService } from "@bitwarden/components"; // Value = hours @@ -134,6 +136,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, protected toastService: ToastService, + protected premiumUpgradePromptService: PremiumUpgradePromptService, ) { this.typeOptions = [ { name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true }, @@ -182,10 +185,15 @@ export class AddEditComponent implements OnInit, OnDestroy { } }); - this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => { - this.type = val; - this.typeChanged(); - }); + this.formGroup.controls.type.valueChanges + .pipe( + tap((val) => { + this.type = val; + }), + switchMap(() => this.typeChanged()), + takeUntil(this.destroy$), + ) + .subscribe(); this.formGroup.controls.selectedDeletionDatePreset.valueChanges .pipe(takeUntil(this.destroy$)) @@ -409,11 +417,11 @@ export class AddEditComponent implements OnInit, OnDestroy { return false; } - typeChanged() { + async typeChanged() { if (this.type === SendType.File && !this.alertShown) { if (!this.canAccessPremium) { this.alertShown = true; - this.messagingService.send("premiumRequired"); + await this.premiumUpgradePromptService.promptForPremium(); } else if (!this.emailVerified) { this.alertShown = true; this.messagingService.send("emailVerificationRequired"); diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html index d0c1ad4a2bb1..cd365e314edf 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.html +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -1,5 +1,9 @@
diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx index 355ca71eb808..4a1968d90f06 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx @@ -26,6 +26,8 @@ import { PricingCardComponent } from "@bitwarden/pricing"; [button]="{ text: 'Choose Premium', type: 'primary' }" [features]="features" [activeBadge]="{ text: 'Active plan', show: true }" + [disableVerticalPadding]="true" + [disableCardBorder]="true" (buttonClick)="onPlanSelected()" >

Premium Plan

@@ -36,13 +38,15 @@ import { PricingCardComponent } from "@bitwarden/pricing"; ### Inputs -| Input | Type | Description | -| ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) | -| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | -| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" | -| `features` | `string[]` | **Optional.** List of features with checkmarks | -| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown | +| Input | Type | Description | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) | +| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | +| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" | +| `features` | `string[]` | **Optional.** List of features with checkmarks | +| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown | +| `disableVerticalPadding` | `boolean` | **Optional.** Will not apply vertical padding styles when true. (Default: false) | +| `disableCardBorder` | `boolean` | **Optional.** Will not apply card border styles when true. (Default: false) | ### Content Slots diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index 022653aa9e49..b065eea4d4bd 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -1,4 +1,4 @@ -import { CurrencyPipe } from "@angular/common"; +import { CurrencyPipe, NgClass } from "@angular/common"; import { Component, EventEmitter, input, Output } from "@angular/core"; import { @@ -18,7 +18,7 @@ import { @Component({ selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", - imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe], + imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, NgClass], }) export class PricingCardComponent { tagline = input.required(); @@ -31,6 +31,8 @@ export class PricingCardComponent { }>(); features = input(); activeBadge = input<{ text: string; variant?: BadgeVariant }>(); + disableVerticalPadding = input(false); + disableCardBorder = input(false); @Output() buttonClick = new EventEmitter(); From 54c5b750b48d2aae5dccc6b13417605071a9384a Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:18:09 -0500 Subject: [PATCH 10/28] unit tests --- .../open-attachments.component.spec.ts | 3 +- .../upgrade-account.component.spec.ts | 5 +- .../premium-upgrade-dialog.component.spec.ts | 25 +++++++++- .../pricing-card.component.spec.ts | 48 +++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index a2045736ce2d..459b328c44e8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -155,11 +155,12 @@ describe("OpenAttachmentsComponent", () => { }); it("routes the user to the premium page when they cannot access premium features", async () => { + const premiumUpgradeService = TestBed.inject(PremiumUpgradePromptService); hasPremiumFromAnySource$.next(false); await component.openAttachments(); - expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled(); }); it("disables attachments when the edit form is disabled", () => { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index 8f5862d2b770..c8cc731a03b7 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -173,7 +173,10 @@ describe("UpgradeAccountComponent", () => { ], providers: [ { provide: I18nService, useValue: mockI18nService }, - { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, ], }) .overrideComponent(UpgradeAccountComponent, { diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index d091e5b2cd5c..000b4cee4c5d 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -9,8 +9,10 @@ import { PersonalSubscriptionPricingTierIds, SubscriptionCadenceIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogRef } from "@bitwarden/components"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogRef, ToastService } from "@bitwarden/components"; import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; @@ -20,6 +22,9 @@ describe("PremiumUpgradeDialogComponent", () => { let mockDialogRef: jest.Mocked; let mockSubscriptionPricingService: jest.Mocked; let mockI18nService: jest.Mocked; + let mockToastService: jest.Mocked; + let mockEnvironmentService: jest.Mocked; + let mockPlatformUtilsService: jest.Mocked; const mockPremiumTier: PersonalSubscriptionPricingTier = { id: PersonalSubscriptionPricingTierIds.Premium, @@ -65,6 +70,18 @@ describe("PremiumUpgradeDialogComponent", () => { t: jest.fn((key: string) => key), } as any; + mockToastService = { + showToast: jest.fn(), + } as any; + + mockEnvironmentService = { + cloudWebVaultUrl$: of("https://vault.bitwarden.com"), + } as any; + + mockPlatformUtilsService = { + launchUri: jest.fn(), + } as any; + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( of([mockPremiumTier, mockFamiliesTier]), ); @@ -78,6 +95,9 @@ describe("PremiumUpgradeDialogComponent", () => { useValue: mockSubscriptionPricingService, }, { provide: I18nService, useValue: mockI18nService }, + { provide: ToastService, useValue: mockToastService }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, ], }).compileComponents(); @@ -143,6 +163,9 @@ describe("PremiumUpgradeDialogComponent", () => { it("should close dialog when upgrade button clicked", async () => { await component["upgrade"](); + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium", + ); expect(mockDialogRef.close).toHaveBeenCalled(); }); diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts index ed2c28d8cb36..43b7971b6927 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -14,6 +14,8 @@ import { PricingCardComponent } from "./pricing-card.component"; [button]="button" [features]="features" [activeBadge]="activeBadge" + [disableVerticalPadding]="disableVerticalPadding" + [disableCardBorder]="disableCardBorder" (buttonClick)="onButtonClick()" > @@ -47,6 +49,8 @@ class TestHostComponent { features = ["Feature 1", "Feature 2", "Feature 3"]; titleLevel: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" = "h3"; activeBadge: { text: string; variant?: string } | undefined = undefined; + disableVerticalPadding = false; + disableCardBorder = false; onButtonClick() { // Test method @@ -191,4 +195,48 @@ describe("PricingCardComponent", () => { expect(cardContainer.classList).toContain("tw-size-full"); expect(cardContainer.classList).not.toContain("tw-block"); // Should not have conflicting display property }); + + it("should apply full padding by default when disableVerticalPadding is false", () => { + hostComponent.disableVerticalPadding = false; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const cardContainer = compiled.querySelector("div"); + + expect(cardContainer.classList).toContain("tw-p-8"); + expect(cardContainer.classList).not.toContain("tw-px-8"); + }); + + it("should apply horizontal-only padding when disableVerticalPadding is true", () => { + hostComponent.disableVerticalPadding = true; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const cardContainer = compiled.querySelector("div"); + + expect(cardContainer.classList).toContain("tw-px-8"); + expect(cardContainer.classList).not.toContain("tw-p-8"); + }); + + it("should apply border and shadow classes by default when disableCardBorder is false", () => { + hostComponent.disableCardBorder = false; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const cardContainer = compiled.querySelector("div"); + + expect(cardContainer.classList).toContain("tw-rounded-3xl"); + expect(cardContainer.classList).toContain("tw-shadow-sm"); + expect(cardContainer.classList).toContain("tw-border"); + expect(cardContainer.classList).toContain("tw-border-secondary-100"); + }); + + it("should remove border and shadow classes when disableCardBorder is true", () => { + hostComponent.disableCardBorder = true; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const cardContainer = compiled.querySelector("div"); + + expect(cardContainer.classList).not.toContain("tw-rounded-3xl"); + expect(cardContainer.classList).not.toContain("tw-shadow-sm"); + expect(cardContainer.classList).not.toContain("tw-border"); + expect(cardContainer.classList).not.toContain("tw-border-secondary-100"); + }); }); From 93be31995a2f892c623a930e7b81d96c1ea1362a Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:59:00 -0500 Subject: [PATCH 11/28] converting premium reports to use premium badge, and some cleanup --- apps/desktop/src/app/app.component.ts | 12 ------ apps/web/src/app/app.component.ts | 12 ------ .../emergency-access.component.ts | 9 ----- .../two-factor/two-factor-setup.component.ts | 8 ---- .../app/billing/guards/has-premium.guard.ts | 14 +++---- .../unified-upgrade-dialog.component.spec.ts | 8 ++-- .../report-card/report-card.component.html | 16 ++++---- .../report-card/report-card.component.ts | 4 ++ .../shared/report-card/report-card.stories.ts | 38 ++++++++++++++++++- .../reports/shared/reports-shared.module.ts | 4 +- .../vault-item-dialog.component.spec.ts | 2 + .../vault/individual-vault/vault.component.ts | 4 +- .../premium-badge/premium-badge.component.ts | 1 + .../premium-badge/premium-badge.stories.ts | 13 ------- 14 files changed, 68 insertions(+), 77 deletions(-) diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 1c2d3aa464df..c098bbe9df09 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -348,18 +348,6 @@ export class AppComponent implements OnInit, OnDestroy { }); break; } - case "premiumRequired": { - const premiumConfirmed = await this.dialogService.openSimpleDialog({ - title: { key: "premiumRequired" }, - content: { key: "premiumRequiredDesc" }, - acceptButtonText: { key: "learnMore" }, - type: "success", - }); - if (premiumConfirmed) { - await this.openModal(PremiumComponent, this.premiumRef); - } - break; - } case "emailVerificationRequired": { const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({ title: { key: "emailVerificationRequired" }, diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 2fc81fe2119b..9d8c1477ae64 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -145,18 +145,6 @@ export class AppComponent implements OnDestroy, OnInit { } break; } - case "premiumRequired": { - const premiumConfirmed = await this.dialogService.openSimpleDialog({ - title: { key: "premiumRequired" }, - content: { key: "premiumRequiredDesc" }, - acceptButtonText: { key: "upgrade" }, - type: "success", - }); - if (premiumConfirmed) { - await this.router.navigate(["settings/subscription/premium"]); - } - break; - } case "emailVerificationRequired": { const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({ title: { key: "emailVerificationRequired" }, diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index de30205e6fe1..df9f574ec0a0 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -94,15 +94,6 @@ export class EmergencyAccessComponent implements OnInit { this.loaded = true; } - async premiumRequired() { - const canAccessPremium = await firstValueFrom(this.canAccessPremium$); - - if (!canAccessPremium) { - this.messagingService.send("premiumRequired"); - return; - } - } - edit = async (details: GranteeEmergencyAccess) => { const canAccessPremium = await firstValueFrom(this.canAccessPremium$); const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, { diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index 043c27998cd9..0eab4138b93a 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -3,7 +3,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { first, - firstValueFrom, lastValueFrom, Observable, Subject, @@ -262,13 +261,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } } - async premiumRequired() { - if (!(await firstValueFrom(this.canAccessPremium$))) { - this.messagingService.send("premiumRequired"); - return; - } - } - protected getTwoFactorProviders() { return this.apiService.getTwoFactorProviders(); } diff --git a/apps/web/src/app/billing/guards/has-premium.guard.ts b/apps/web/src/app/billing/guards/has-premium.guard.ts index 61853b25cb8a..0d40995a25b1 100644 --- a/apps/web/src/app/billing/guards/has-premium.guard.ts +++ b/apps/web/src/app/billing/guards/has-premium.guard.ts @@ -1,9 +1,9 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, - RouterStateSnapshot, - Router, CanActivateFn, + Router, + RouterStateSnapshot, UrlTree, } from "@angular/router"; import { Observable, of } from "rxjs"; @@ -11,11 +11,11 @@ import { switchMap, tap } from "rxjs/operators"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; /** - * CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired" - * message and blocks navigation. + * CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade + * flow and blocks navigation. */ export function hasPremiumGuard(): CanActivateFn { return ( @@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn { _state: RouterStateSnapshot, ): Observable => { const router = inject(Router); - const messagingService = inject(MessagingService); + const premiumUpgradePromptService = inject(PremiumUpgradePromptService); const billingAccountProfileStateService = inject(BillingAccountProfileStateService); const accountService = inject(AccountService); @@ -35,7 +35,7 @@ export function hasPremiumGuard(): CanActivateFn { ), tap((userHasPremium: boolean) => { if (!userHasPremium) { - messagingService.send("premiumRequired"); + return premiumUpgradePromptService.promptForPremium(); } }), // Prevent trapping the user on the login page, since that's an awful UX flow diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index 505a4b9b7e4b..a943f2bdf17d 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserId } from "@bitwarden/common/types/guid"; -import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; - import { PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, -} from "../../../types/subscription-pricing-tier"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; + import { UpgradeAccountComponent, UpgradeAccountStatus, diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index 8db0db3b5e66..1a0c59a8f7d6 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -15,14 +15,12 @@

{{ title }}

{{ description }}

- - {{ "premium" | i18n }} - {{ "upgrade" | i18n }} - + @if (requiresPremium) { + + } @else if (requiresUpgrade) { + + {{ "upgrade" | i18n }} + + }
diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index e8ffcd01068f..b734fe2fc091 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -25,4 +25,8 @@ export class ReportCardComponent { protected get requiresPremium() { return this.variant == ReportVariant.RequiresPremium; } + + protected get requiresUpgrade() { + return this.variant == ReportVariant.RequiresUpgrade; + } } diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 76951bf9451d..f5e13db9245a 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -1,10 +1,15 @@ import { importProvidersFrom } from "@angular/core"; import { RouterTestingModule } from "@angular/router/testing"; import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { of } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { BadgeModule, IconModule } from "@bitwarden/components"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { BadgeModule, I18nMockService, IconModule } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; import { ReportVariant } from "../models/report-variant"; @@ -17,6 +22,37 @@ export default { decorators: [ moduleMetadata({ imports: [JslibModule, BadgeModule, IconModule, RouterTestingModule, PremiumBadgeComponent], + providers: [ + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "123", + }), + }, + }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + premium: "Premium", + upgrade: "Upgrade", + }); + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: () => of(false), + }, + }, + { + provide: PremiumUpgradePromptService, + useValue: { + promptForPremium: (orgId?: string) => {}, + }, + }, + ], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts index cad5d06d7988..509e8550c895 100644 --- a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts +++ b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts @@ -1,13 +1,15 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; + import { SharedModule } from "../../../shared/shared.module"; import { ReportCardComponent } from "./report-card/report-card.component"; import { ReportListComponent } from "./report-list/report-list.component"; @NgModule({ - imports: [CommonModule, SharedModule], + imports: [CommonModule, SharedModule, PremiumBadgeComponent], declarations: [ReportCardComponent, ReportListComponent], exports: [ReportCardComponent, ReportListComponent], }) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index e45a82d82ba1..ee633c55b233 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -6,6 +6,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -73,6 +74,7 @@ describe("VaultItemDialogComponent", () => { { provide: LogService, useValue: {} }, { provide: CipherService, useValue: {} }, { provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } }, + { provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } }, { provide: Router, useValue: {} }, { provide: ActivatedRoute, useValue: {} }, { diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b507991606fc..92db157f0b96 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -63,6 +63,7 @@ import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -308,6 +309,7 @@ export class VaultComponent implements OnInit, OnDestr private cipherArchiveService: CipherArchiveService, private organizationWarningsService: OrganizationWarningsService, private unifiedUpgradePromptService: UnifiedUpgradePromptService, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit() { @@ -713,7 +715,7 @@ export class VaultComponent implements OnInit, OnDestr } if (cipher.organizationId == null && !this.canAccessPremium) { - this.messagingService.send("premiumRequired"); + await this.premiumUpgradePromptService.promptForPremium(); return; } else if (cipher.organizationId != null) { const org = await firstValueFrom( diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts index 8ad43c0f72bb..17729b16963d 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts @@ -27,6 +27,7 @@ export class PremiumBadgeComponent { async promptForPremium(event: Event) { event.stopPropagation(); + event.preventDefault(); await this.premiumUpgradePromptService.promptForPremium(this.organizationId()); } } diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts index 08259358f301..bf50d16d3c41 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts @@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessageSender } from "@bitwarden/common/platform/messaging"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, I18nMockService } from "@bitwarden/components"; import { PremiumBadgeComponent } from "./premium-badge.component"; -class MockMessagingService implements MessageSender { - send = () => { - alert("Clicked on badge"); - }; -} - export default { title: "Billing/Premium Badge", component: PremiumBadgeComponent, @@ -40,12 +33,6 @@ export default { }); }, }, - { - provide: MessageSender, - useFactory: () => { - return new MockMessagingService(); - }, - }, { provide: BillingAccountProfileStateService, useValue: { From b5164a4855f9cb8506a5fa88cdd1307236b4af30 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 16 Oct 2025 18:26:06 -0500 Subject: [PATCH 12/28] fixes issue after merge --- .../premium/premium-vnext.component.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts index 9de9c22d3c3c..cdb8806e80d6 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -6,26 +6,26 @@ import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switch import { ApiService } from "@bitwarden/common/abstractions/api.service"; 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 { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { + BadgeModule, DialogService, - ToastService, + LinkModule, SectionComponent, - BadgeModule, + ToastService, TypographyModule, - LinkModule, } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; import { I18nPipe } from "@bitwarden/ui-common"; -import { SubscriptionPricingService } from "../../services/subscription-pricing.service"; import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierIds, -} from "../../types/subscription-pricing-tier"; import { UnifiedUpgradeDialogComponent, UnifiedUpgradeDialogParams, @@ -76,7 +76,7 @@ export class PremiumVNextComponent { private syncService: SyncService, private toastService: ToastService, private billingAccountProfileStateService: BillingAccountProfileStateService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); From 6549c376aac4eb645f8dea465910475a76b6f5a9 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:29:18 -0500 Subject: [PATCH 13/28] linter errors --- .../premium-upgrade-dialog.component.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html index 15262d70b5bd..8d8095158114 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -4,7 +4,9 @@ cdkTrapFocus cdkTrapFocusAutoCapture > -
+
- @if (cardDetails$ | async; as cardDetails) { - -

- {{ "upgradeToPremium" | i18n }} -

-
- } + +

+ {{ "upgradeToPremium" | i18n }} +

+

+} @else { + + {{ "loading" | i18n }} } diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index a1ba9c0fc85b..92165b53f44d 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -16,6 +16,7 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogRef, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; @@ -28,6 +29,7 @@ describe("PremiumUpgradeDialogComponent", () => { let mockToastService: jest.Mocked; let mockEnvironmentService: jest.Mocked; let mockPlatformUtilsService: jest.Mocked; + let mockLogService: jest.Mocked; const mockPremiumTier: PersonalSubscriptionPricingTier = { id: PersonalSubscriptionPricingTierIds.Premium, @@ -92,6 +94,10 @@ describe("PremiumUpgradeDialogComponent", () => { of([mockPremiumTier, mockFamiliesTier]), ); + mockLogService = { + error: jest.fn(), + } as any; + await TestBed.configureTestingModule({ imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus], providers: [ @@ -104,6 +110,7 @@ describe("PremiumUpgradeDialogComponent", () => { { provide: ToastService, useValue: mockToastService }, { provide: EnvironmentService, useValue: mockEnvironmentService }, { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: LogService, useValue: mockLogService }, ], }).compileComponents(); @@ -150,23 +157,6 @@ describe("PremiumUpgradeDialogComponent", () => { expect(cardDetails?.button.text).toBe("upgradeNow"); }); - it("should emit loading$ observable that starts with true and changes to false", async () => { - // Create a new component to observe the loading state from start - const newFixture = TestBed.createComponent(PremiumUpgradeDialogComponent); - const newComponent = newFixture.componentInstance; - - const loadingValues: boolean[] = []; - const sub = newComponent["loading$"].subscribe((loading) => loadingValues.push(loading)); - - // Wait for the observable to emit - await firstValueFrom(newComponent["cardDetails$"]); - sub.unsubscribe(); - - expect(loadingValues.length).toBeGreaterThanOrEqual(2); - expect(loadingValues[0]).toBe(true); - expect(loadingValues[loadingValues.length - 1]).toBe(false); - }); - describe("upgrade()", () => { it("should launch URI with query parameter for cloud-hosted environments", async () => { mockEnvironmentService.environment$ = of({ @@ -237,43 +227,5 @@ describe("PremiumUpgradeDialogComponent", () => { }); expect(cardDetails).toBeNull(); }); - - it("should handle error and still allow loading$ to complete", async () => { - const error = new Error("Service error"); - mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( - throwError(() => error), - ); - - const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent); - const errorComponent = errorFixture.componentInstance; - - const loadingValues: boolean[] = []; - const sub = errorComponent["loading$"].subscribe((loading) => loadingValues.push(loading)); - - await firstValueFrom(errorComponent["cardDetails$"]); - sub.unsubscribe(); - - expect(loadingValues.length).toBeGreaterThanOrEqual(2); - expect(loadingValues[0]).toBe(true); - expect(loadingValues[loadingValues.length - 1]).toBe(false); - }); - }); - - describe("dialog close during loading", () => { - it("should allow closing dialog while in loading state", () => { - const newFixture = TestBed.createComponent(PremiumUpgradeDialogComponent); - const newComponent = newFixture.componentInstance; - - let currentLoadingState: boolean | undefined; - const sub = newComponent["loading$"].subscribe((loading) => { - currentLoadingState = loading; - }); - - newComponent["close"](); - sub.unsubscribe(); - - expect(mockDialogRef.close).toHaveBeenCalled(); - expect(currentLoadingState).toBeDefined(); - }); }); }); diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts index 33fbf4a5c9e6..eab7b67d4fba 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -9,6 +9,7 @@ import { } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ButtonModule, @@ -96,6 +97,12 @@ export default { }, }, }, + { + provide: LogService, + useValue: { + error: {}, + }, + }, ], }), ], diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts index 76c6f03d7a12..491d58715264 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -1,8 +1,9 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; -import { catchError, firstValueFrom, map, Observable, of, startWith } from "rxjs"; +import { catchError, firstValueFrom, map, Observable, of } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, @@ -26,8 +27,8 @@ import { ToastService, TypographyModule, } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; import { PricingCardComponent } from "@bitwarden/pricing"; -import { I18nPipe } from "@bitwarden/ui-common"; type CardDetails = { title: string; @@ -47,7 +48,7 @@ type CardDetails = { TypographyModule, PricingCardComponent, CdkTrapFocus, - I18nPipe, + JslibModule, ], templateUrl: "./premium-upgrade-dialog.component.html", }) @@ -57,21 +58,17 @@ export class PremiumUpgradeDialogComponent { .pipe( map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), map((tier) => this.mapPremiumTierToCardDetails(tier!)), - catchError(() => { + catchError((error: unknown) => { this.toastService.showToast({ variant: "error", title: this.i18nService.t("error"), message: this.i18nService.t("unexpectedError"), }); + this.logService.error("Error fetching and mapping pricing tiers", error); return of(null); }), ); - protected loading$: Observable = this.cardDetails$.pipe( - map(() => false), - startWith(true), - ); - constructor( private dialogRef: DialogRef, private subscriptionPricingService: SubscriptionPricingServiceAbstraction, @@ -79,6 +76,7 @@ export class PremiumUpgradeDialogComponent { private toastService: ToastService, private environmentService: EnvironmentService, private platformUtilsService: PlatformUtilsService, + private logService: LogService, ) {} protected async upgrade(): Promise {