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:
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user