diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.html new file mode 100644 index 00000000000..30b00d7f8ec --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.html @@ -0,0 +1,50 @@ + + + + {{ "individualUpgradeWelcomeMessage" | i18n }} + + {{ "individualUpgradeDescriptionMessage" | i18n }} + + + + + @if (premiumCardDetails) { + + + {{ premiumCardDetails.title }} + + + } + + @if (familiesCardDetails) { + + + {{ familiesCardDetails.title }} + + + } + + + + {{ "individualUpgradeTaxInformationMessage" | i18n }} + + + {{ "continueWithoutUpgrading" | i18n }} + + + + diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.spec.ts new file mode 100644 index 00000000000..515c204e8f6 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.spec.ts @@ -0,0 +1,159 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +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 { DialogRef, DialogService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { BillingServicesModule } from "../../../services"; +import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "../../../types/subscription-pricing-tier"; + +import { + UpgradeAccountDialogComponent, + UpgradeAccountDialogResult, + UpgradeAccountDialogStatus, +} from "./upgrade-account-dialog.component"; + +describe("UpgradeAccountDialogComponent", () => { + let sut: UpgradeAccountDialogComponent; + let fixture: ComponentFixture; + const mockDialogRef = mock>(); + const mockI18nService = mock(); + const mockSubscriptionPricingService = mock(); + const mockDialogService = mock(); + + // Mock pricing tiers data + const mockPricingTiers: PersonalSubscriptionPricingTier[] = [ + { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "premium", // Name changed to match i18n key expectation + description: "Premium plan for individuals", + passwordManager: { + annualPrice: 10, + features: [{ value: "Feature 1" }, { value: "Feature 2" }, { value: "Feature 3" }], + }, + } as PersonalSubscriptionPricingTier, + { + id: PersonalSubscriptionPricingTierIds.Families, + name: "planNameFamilies", // Name changed to match i18n key expectation + description: "Family plan for up to 6 users", + passwordManager: { + annualPrice: 40, + features: [{ value: "Feature A" }, { value: "Feature B" }, { value: "Feature C" }], + users: 6, + }, + } as PersonalSubscriptionPricingTier, + ]; + + beforeEach(async () => { + jest.resetAllMocks(); + + mockI18nService.t.mockImplementation((key) => key); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of(mockPricingTiers), + ); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UpgradeAccountDialogComponent, PricingCardComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: I18nService, useValue: mockI18nService }, + { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + ], + }) + .overrideComponent(UpgradeAccountDialogComponent, { + // Remove BillingServicesModule to avoid conflicts with mocking SubscriptionPricingService dependencies + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(UpgradeAccountDialogComponent); + sut = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(sut).toBeTruthy(); + }); + + it("should set up pricing tier details properly", () => { + expect(sut["premiumCardDetails"]).toBeDefined(); + expect(sut["familiesCardDetails"]).toBeDefined(); + }); + + it("should create premium card details correctly", () => { + // Because the i18n service is mocked to return the key itself + expect(sut["premiumCardDetails"].title).toBe("premium"); + expect(sut["premiumCardDetails"].tagline).toBe("Premium plan for individuals"); + expect(sut["premiumCardDetails"].price.amount).toBe(10 / 12); + expect(sut["premiumCardDetails"].price.cadence).toBe("monthly"); + expect(sut["premiumCardDetails"].button.type).toBe("primary"); + expect(sut["premiumCardDetails"].button.text).toBe("upgradeToPremium"); + expect(sut["premiumCardDetails"].features).toEqual(["Feature 1", "Feature 2", "Feature 3"]); + }); + + it("should create families card details correctly", () => { + // Because the i18n service is mocked to return the key itself + expect(sut["familiesCardDetails"].title).toBe("planNameFamilies"); + expect(sut["familiesCardDetails"].tagline).toBe("Family plan for up to 6 users"); + expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12); + expect(sut["familiesCardDetails"].price.cadence).toBe("monthly"); + expect(sut["familiesCardDetails"].button.type).toBe("secondary"); + expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies"); + expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]); + }); + + it("should call dialogRef.close with proceeded-to-payment status and premium pricing tier when premium plan is selected", () => { + sut["onProceedClick"](PersonalSubscriptionPricingTierIds.Premium); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Premium, + }); + }); + + it("should call dialogRef.close with proceeded-to-payment status and families pricing tier when families plan is selected", () => { + sut["onProceedClick"](PersonalSubscriptionPricingTierIds.Families); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan: PersonalSubscriptionPricingTierIds.Families, + }); + }); + + it("should call dialogRef.close with closed status when dialog is closed", () => { + sut["onCloseClick"](); + + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: UpgradeAccountDialogStatus.Closed, + plan: null, + }); + }); + + it("should return a DialogRef when open static method is called", () => { + mockDialogService.open.mockReturnValue(mockDialogRef); + + const result = UpgradeAccountDialogComponent.open(mockDialogService); + + expect(mockDialogService.open).toHaveBeenCalledWith(UpgradeAccountDialogComponent); + expect(result).toBe(mockDialogRef); + }); + + describe("isFamiliesPlan", () => { + it("should return true for families plan", () => { + const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Families); + expect(result).toBe(true); + }); + + it("should return false for premium plan", () => { + const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Premium); + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.ts new file mode 100644 index 00000000000..6173cdfd744 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account-dialog/upgrade-account-dialog.component.ts @@ -0,0 +1,136 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +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, DialogRef, DialogService } 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, + SubscriptionCadence, + SubscriptionCadenceIds, +} from "../../../types/subscription-pricing-tier"; + +export const UpgradeAccountDialogStatus = { + Closed: "closed", + ProceededToPayment: "proceeded-to-payment", +} as const; + +export type UpgradeAccountDialogStatus = UnionOfValues; + +export type UpgradeAccountDialogResult = { + status: UpgradeAccountDialogStatus; + plan: PersonalSubscriptionPricingTierId | null; +}; + +type CardDetails = { + title: string; + tagline: string; + price: { amount: number; cadence: SubscriptionCadence }; + button: { text: string; type: ButtonType }; + features: string[]; +}; + +@Component({ + selector: "app-upgrade-account-dialog", + imports: [DialogModule, SharedModule, BillingServicesModule, PricingCardComponent], + templateUrl: "./upgrade-account-dialog.component.html", +}) +export class UpgradeAccountDialogComponent implements OnInit { + protected premiumCardDetails!: CardDetails; + protected familiesCardDetails!: CardDetails; + + protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families; + protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; + protected loading = true; + + constructor( + private dialogRef: DialogRef, + private i18nService: I18nService, + private subscriptionPricingService: SubscriptionPricingService, + private destroyRef: DestroyRef, + ) {} + + ngOnInit(): void { + this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((plans) => { + this.setupCardDetails(plans); + this.loading = false; + }); + } + + /** Setup card details for the pricing tiers. + * This can be extended in the future for business plans, etc. + */ + private setupCardDetails(plans: PersonalSubscriptionPricingTier[]): void { + const premiumTier = plans.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + const familiesTier = plans.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Families, + ); + + if (premiumTier) { + this.premiumCardDetails = this.createCardDetails(premiumTier, "primary"); + } + + if (familiesTier) { + this.familiesCardDetails = this.createCardDetails(familiesTier, "secondary"); + } + } + + private createCardDetails( + tier: PersonalSubscriptionPricingTier, + buttonType: ButtonType, + ): CardDetails { + return { + title: tier.name, + tagline: tier.description, + price: { + amount: tier.passwordManager.annualPrice / 12, + cadence: SubscriptionCadenceIds.Monthly, + }, + button: { + text: this.i18nService.t( + this.isFamiliesPlan(tier.id) ? "upgradeToFamilies" : "upgradeToPremium", + ), + type: buttonType, + }, + features: tier.passwordManager.features.map((f: any) => f.value), + }; + } + + protected onProceedClick(plan: PersonalSubscriptionPricingTierId): void { + this.close({ + status: UpgradeAccountDialogStatus.ProceededToPayment, + plan, + }); + } + + private isFamiliesPlan(plan: PersonalSubscriptionPricingTierId): boolean { + return plan === PersonalSubscriptionPricingTierIds.Families; + } + + protected onCloseClick(): void { + this.close({ + status: UpgradeAccountDialogStatus.Closed, + plan: null, + }); + } + + private close(result: UpgradeAccountDialogResult): void { + this.dialogRef.close(result); + } + + static open(dialogService: DialogService): DialogRef { + return dialogService.open(UpgradeAccountDialogComponent); + } +}
+ {{ "individualUpgradeDescriptionMessage" | i18n }} +
+ {{ "individualUpgradeTaxInformationMessage" | i18n }} +