1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-26 06:13:46 +00:00

feat(billing): add new premium org card

This commit is contained in:
Stephon Brown
2026-01-23 14:20:47 -06:00
parent 486eff3192
commit 021e4b4f0f
3 changed files with 448 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
@if (!loading()) {
<section
class="tw-w-screen tw-max-h-screen tw-min-w-[332px] md:tw-max-w-6xl tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
<div class="tw-px-14 tw-py-8">
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
<h1 class="tw-font-medium tw-text-[32px]">
{{ "upgradeYourPlan" | i18n }}
</h1>
<p bitTypography="body1" class="tw-text-muted">
{{ "upgradeShareEvenMore" | i18n }}
</p>
</div>
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 lg:tw-grid-cols-3 tw-gap-5 tw-mb-4">
@if (familiesCardDetails) {
<billing-pricing-card
class="tw-w-full tw-min-w-[216px]"
[tagline]="familiesCardDetails.tagline"
[price]="familiesCardDetails.price"
[button]="familiesCardDetails.button"
[features]="familiesCardDetails.features"
(buttonClick)="planSelected.emit(familiesPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ familiesCardDetails.title }}
</h3>
</billing-pricing-card>
}
@if (teamsCardDetails) {
<billing-pricing-card
class="tw-w-full tw-min-w-[216px]"
[tagline]="teamsCardDetails.tagline"
[price]="teamsCardDetails.price"
[button]="teamsCardDetails.button"
[features]="teamsCardDetails.features"
(buttonClick)="planSelected.emit(teamsPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ teamsCardDetails.title }}
</h3>
</billing-pricing-card>
}
@if (enterpriseCardDetails) {
<billing-pricing-card
class="tw-w-full tw-min-w-[216px]"
[tagline]="enterpriseCardDetails.tagline"
[price]="enterpriseCardDetails.price"
[button]="enterpriseCardDetails.button"
[features]="enterpriseCardDetails.features"
(buttonClick)="planSelected.emit(enterprisePlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ enterpriseCardDetails.title }}
</h3>
</billing-pricing-card>
}
</div>
<div class="tw-text-center tw-w-full">
<p bitTypography="helper" class="tw-text-muted tw-italic">
{{ "organizationUpgradeTaxInformationMessage" | i18n }}
</p>
</div>
</div>
</section>
}

View File

@@ -0,0 +1,221 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { of, throwError } from "rxjs";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierIds,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { BillingServicesModule } from "../../../services";
import { PremiumOrgUpgradeComponent } from "./premium-org-upgrade.component";
describe("PremiumOrgUpgradeComponent", () => {
let sut: PremiumOrgUpgradeComponent;
let fixture: ComponentFixture<PremiumOrgUpgradeComponent>;
const mockI18nService = mock<I18nService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
const mockToastService = mock<ToastService>();
// Mock pricing tiers data
const mockPersonalPricingTiers: PersonalSubscriptionPricingTier[] = [
{
id: PersonalSubscriptionPricingTierIds.Families,
name: "planNameFamilies",
description: "Family plan for up to 6 users",
passwordManager: {
type: "packaged",
annualPrice: 40,
features: [
{ key: "feature1", value: "Feature A" },
{ key: "feature2", value: "Feature B" },
{ key: "feature3", value: "Feature C" },
],
users: 6,
},
} as PersonalSubscriptionPricingTier,
];
const mockBusinessPricingTiers: BusinessSubscriptionPricingTier[] = [
{
id: BusinessSubscriptionPricingTierIds.Teams,
name: "planNameTeams",
description: "Teams plan for growing businesses",
passwordManager: {
type: "scalable",
annualPricePerUser: 48,
features: [
{ key: "teamFeature1", value: "Teams Feature 1" },
{ key: "teamFeature2", value: "Teams Feature 2" },
{ key: "teamFeature3", value: "Teams Feature 3" },
],
},
} as BusinessSubscriptionPricingTier,
{
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: "planNameEnterprise",
description: "Enterprise plan for large organizations",
passwordManager: {
type: "scalable",
annualPricePerUser: 72,
features: [
{ key: "entFeature1", value: "Enterprise Feature 1" },
{ key: "entFeature2", value: "Enterprise Feature 2" },
{ key: "entFeature3", value: "Enterprise Feature 3" },
],
},
} as BusinessSubscriptionPricingTier,
];
beforeEach(async () => {
jest.resetAllMocks();
mockI18nService.t.mockImplementation((key) => key);
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of(mockPersonalPricingTiers),
);
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
of(mockBusinessPricingTiers),
);
await TestBed.configureTestingModule({
imports: [PremiumOrgUpgradeComponent, PricingCardComponent, CdkTrapFocus],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
{ provide: ToastService, useValue: mockToastService },
],
})
.overrideComponent(PremiumOrgUpgradeComponent, {
remove: { imports: [BillingServicesModule] },
})
.compileComponents();
fixture = TestBed.createComponent(PremiumOrgUpgradeComponent);
sut = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(sut).toBeTruthy();
});
it("should set loading to false after pricing tiers are loaded", () => {
expect(sut["loading"]()).toBe(false);
});
it("should set up pricing tier details for all three plans", () => {
expect(sut["familiesCardDetails"]).toBeDefined();
expect(sut["teamsCardDetails"]).toBeDefined();
expect(sut["enterpriseCardDetails"]).toBeDefined();
});
describe("card details creation", () => {
it("should create families card details correctly", () => {
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("primary");
expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies");
expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]);
});
it("should create teams card details correctly", () => {
expect(sut["teamsCardDetails"].title).toBe("planNameTeams");
expect(sut["teamsCardDetails"].tagline).toBe("Teams plan for growing businesses");
expect(sut["teamsCardDetails"].price.amount).toBe(48 / 12);
expect(sut["teamsCardDetails"].price.cadence).toBe("monthly");
expect(sut["teamsCardDetails"].button.type).toBe("secondary");
expect(sut["teamsCardDetails"].button.text).toBe("upgradeToTeams");
expect(sut["teamsCardDetails"].features).toEqual([
"Teams Feature 1",
"Teams Feature 2",
"Teams Feature 3",
]);
});
it("should create enterprise card details correctly", () => {
expect(sut["enterpriseCardDetails"].title).toBe("planNameEnterprise");
expect(sut["enterpriseCardDetails"].tagline).toBe("Enterprise plan for large organizations");
expect(sut["enterpriseCardDetails"].price.amount).toBe(72 / 12);
expect(sut["enterpriseCardDetails"].price.cadence).toBe("monthly");
expect(sut["enterpriseCardDetails"].button.type).toBe("secondary");
expect(sut["enterpriseCardDetails"].button.text).toBe("upgradeToEnterprise");
expect(sut["enterpriseCardDetails"].features).toEqual([
"Enterprise Feature 1",
"Enterprise Feature 2",
"Enterprise Feature 3",
]);
});
});
describe("plan selection", () => {
it("should emit planSelected with families pricing tier when families plan is selected", () => {
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// The first PricingCardComponent corresponds to the families plan
const familiesCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[0];
familiesCard.triggerEventHandler("buttonClick", {});
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Families);
});
it("should emit planSelected with teams pricing tier when teams plan is selected", () => {
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// The second PricingCardComponent corresponds to the teams plan
const teamsCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[1];
teamsCard.triggerEventHandler("buttonClick", {});
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Teams);
});
it("should emit planSelected with enterprise pricing tier when enterprise plan is selected", () => {
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// The third PricingCardComponent corresponds to the enterprise plan
const enterpriseCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[2];
enterpriseCard.triggerEventHandler("buttonClick", {});
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Enterprise);
});
});
describe("error handling", () => {
it("should show toast and set loading to false on error", () => {
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
throwError(() => new Error("API Error")),
);
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
of(mockBusinessPricingTiers),
);
fixture = TestBed.createComponent(PremiumOrgUpgradeComponent);
sut = fixture.componentInstance;
fixture.detectChanges();
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: "unexpectedError",
});
expect(sut["loading"]()).toBe(false);
expect(sut["familiesCardDetails"]).toBeUndefined();
expect(sut["teamsCardDetails"]).toBeUndefined();
expect(sut["enterpriseCardDetails"]).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,156 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, catchError, of } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
BusinessSubscriptionPricingTierIds,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonType, DialogModule, ToastService } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { SharedModule } from "../../../../shared";
import { BillingServicesModule } from "../../../services";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-premium-org-upgrade",
imports: [
CommonModule,
DialogModule,
SharedModule,
BillingServicesModule,
PricingCardComponent,
CdkTrapFocus,
],
templateUrl: "./premium-org-upgrade.component.html",
})
export class PremiumOrgUpgradeComponent implements OnInit {
planSelected = output<PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId>();
protected readonly loading = signal(true);
protected familiesCardDetails!: SubscriptionPricingCardDetails;
protected teamsCardDetails!: SubscriptionPricingCardDetails;
protected enterpriseCardDetails!: SubscriptionPricingCardDetails;
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
protected teamsPlanType = BusinessSubscriptionPricingTierIds.Teams;
protected enterprisePlanType = BusinessSubscriptionPricingTierIds.Enterprise;
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private destroyRef: DestroyRef,
) {}
ngOnInit(): void {
combineLatest([
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(),
this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(),
])
.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(
([personalPlans, businessPlans]: [
PersonalSubscriptionPricingTier[],
BusinessSubscriptionPricingTier[],
]) => {
this.setupCardDetails(personalPlans, businessPlans);
this.loading.set(false);
},
);
}
private setupCardDetails(
personalPlans: PersonalSubscriptionPricingTier[],
businessPlans: BusinessSubscriptionPricingTier[],
): void {
const familiesTier = personalPlans.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Families,
);
const teamsTier = businessPlans.find(
(tier) => tier.id === BusinessSubscriptionPricingTierIds.Teams,
);
const enterpriseTier = businessPlans.find(
(tier) => tier.id === BusinessSubscriptionPricingTierIds.Enterprise,
);
if (familiesTier) {
this.familiesCardDetails = this.createCardDetails(familiesTier, "primary");
}
if (teamsTier) {
this.teamsCardDetails = this.createCardDetails(teamsTier, "secondary");
}
if (enterpriseTier) {
this.enterpriseCardDetails = this.createCardDetails(enterpriseTier, "secondary");
}
}
private createCardDetails(
tier: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
buttonType: ButtonType,
): SubscriptionPricingCardDetails {
let buttonText: string;
switch (tier.id) {
case PersonalSubscriptionPricingTierIds.Families:
buttonText = "upgradeToFamilies";
break;
case BusinessSubscriptionPricingTierIds.Teams:
buttonText = "upgradeToTeams";
break;
case BusinessSubscriptionPricingTierIds.Enterprise:
buttonText = "upgradeToEnterprise";
break;
default:
buttonText = "";
}
let priceAmount: number | undefined;
if ("annualPrice" in tier.passwordManager) {
priceAmount = tier.passwordManager.annualPrice;
} else if ("annualPricePerUser" in tier.passwordManager) {
priceAmount = tier.passwordManager.annualPricePerUser;
}
return {
title: tier.name,
tagline: tier.description,
price:
priceAmount && priceAmount > 0
? {
amount: priceAmount / 12,
cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: {
text: this.i18nService.t(buttonText),
type: buttonType,
},
features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value),
};
}
}