1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 11:43:51 +00:00

feat(billing): Add upgrade from free account dialog

This commit is contained in:
Stephon Brown
2025-09-17 18:31:14 -04:00
parent d52762ec76
commit b384050fa7
3 changed files with 345 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
<bit-dialog dialogSize="large" [loading]="loading">
<div bitDialogContent>
<header class="tw-flex tw-text-center tw-flex-col !tw-my-0">
<h1 class="tw-font-semibold">{{ "individualUpgradeWelcomeMessage" | i18n }}</h1>
<p bitTypography="body1" class="tw-text-muted tw-mb-8">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</header>
<div class="tw-flex tw-flex-col lg:tw-flex-row tw-gap-6 tw-mb-4">
@if (premiumCardDetails) {
<billing-pricing-card
class="tw-flex-1 tw-basis-0"
[tagline]="premiumCardDetails.tagline"
[price]="premiumCardDetails.price"
[button]="premiumCardDetails.button"
[features]="premiumCardDetails.features"
(buttonClick)="onProceedClick(premiumPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ premiumCardDetails.title }}
</h3>
</billing-pricing-card>
}
@if (familiesCardDetails) {
<billing-pricing-card
class="tw-flex-1 tw-basis-0"
[tagline]="familiesCardDetails.tagline"
[price]="familiesCardDetails.price"
[button]="familiesCardDetails.button"
[features]="familiesCardDetails.features"
(buttonClick)="onProceedClick(familiesPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ familiesCardDetails.title }}
</h3>
</billing-pricing-card>
}
</div>
<div class="tw-text-center tw-w-full">
<p bitTypography="helper" class="tw-text-muted tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<button bitLink linkType="primary" type="button" (click)="onCloseClick()">
{{ "continueWithoutUpgrading" | i18n }}
</button>
</div>
</div>
</bit-dialog>

View File

@@ -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<UpgradeAccountDialogComponent>;
const mockDialogRef = mock<DialogRef<UpgradeAccountDialogResult>>();
const mockI18nService = mock<I18nService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingService>();
const mockDialogService = mock<DialogService>();
// 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);
});
});
});

View File

@@ -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<typeof UpgradeAccountDialogStatus>;
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<UpgradeAccountDialogResult>,
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<UpgradeAccountDialogResult> {
return dialogService.open<UpgradeAccountDialogResult>(UpgradeAccountDialogComponent);
}
}