1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-24033]Implement Subscription Settings UI with Premium and Families Cards (#16822)

* Add initial changes for thenew premium design

* Add the messages

* Add the new dialog modal

* Resolve the flag issue

* Added changes for redirect

* Fix the unitest errors

* Resolve the badge issue

* refactor the code base pr comments
This commit is contained in:
cyprain-okeke
2025-10-15 19:31:10 +01:00
committed by GitHub
parent aa9a276591
commit 75182926d4
10 changed files with 421 additions and 141 deletions

View File

@@ -1,9 +1,12 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PremiumVNextComponent } from "./premium/premium-vnext.component";
import { PremiumComponent } from "./premium/premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -20,11 +23,15 @@ const routes: Routes = [
component: UserSubscriptionComponent,
data: { titleId: "premiumMembership" },
},
{
path: "premium",
component: PremiumComponent,
data: { titleId: "goPremium" },
},
...featureFlaggedRoute({
defaultComponent: PremiumComponent,
flaggedComponent: PremiumVNextComponent,
featureFlag: FeatureFlag.PM24033PremiumUpgradeNewDesign,
routeOptions: {
data: { titleId: "goPremium" },
path: "premium",
},
}),
{
path: "payment-details",
component: AccountPaymentDetailsComponent,

View File

@@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";
import { PricingCardComponent } from "@bitwarden/pricing";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
@@ -21,6 +22,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
HeaderModule,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
PricingCardComponent,
],
declarations: [
SubscriptionComponent,

View File

@@ -0,0 +1,68 @@
<div class="tw-max-w-3xl tw-mx-auto">
<bit-section *ngIf="shouldShowNewDesign$ | async">
<div class="tw-text-center">
<div class="tw-mt-8 tw-mb-6">
<span bitBadge variant="secondary" [truncate]="false">
{{ "bitwardenFreeplanMessage" | i18n }}
</span>
</div>
<h2 *ngIf="!isSelfHost" class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h2>
<p class="tw-text-muted tw-mb-6 tw-mt-4">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</div>
<!-- Two-Card Layout -->
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 tw-gap-6 tw-mt-6 tw-justify-center">
<!-- Premium Card -->
<div>
@if (premiumCardData$ | async; as premiumData) {
<billing-pricing-card
[tagline]="'planDescPremium' | i18n"
[price]="{ amount: premiumData.price, cadence: 'monthly' }"
[button]="{ type: 'primary', text: ('upgradeToPremium' | i18n) }"
[features]="premiumData.features"
(buttonClick)="openUpgradeDialog('Premium')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "premium" | i18n }}</h3>
</billing-pricing-card>
}
</div>
<!-- Families Card -->
<div>
@if (familiesCardData$ | async; as familiesData) {
<billing-pricing-card
[tagline]="'planDescFamiliesV2' | i18n"
[price]="{ amount: familiesData.price, cadence: 'monthly' }"
[button]="{ type: 'secondary', text: ('upgradeToFamilies' | i18n) }"
[features]="familiesData.features"
(buttonClick)="openUpgradeDialog('Families')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "families" | i18n }}</h3>
</billing-pricing-card>
}
</div>
</div>
<!-- Business Plans Link -->
<div class="tw-text-center tw-mt-6">
<p class="tw-text-muted tw-mb-2 tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<a
bitLink
linkType="primary"
href="https://bitwarden.com/pricing/business/"
target="_blank"
rel="noopener noreferrer"
>
{{ "viewbusinessplans" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-section>
</div>

View File

@@ -0,0 +1,182 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
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 { 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 {
DialogService,
ToastService,
SectionComponent,
BadgeModule,
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,
UnifiedUpgradeDialogResult,
UnifiedUpgradeDialogStatus,
UnifiedUpgradeDialogStep,
} from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
@Component({
templateUrl: "./premium-vnext.component.html",
standalone: true,
imports: [
CommonModule,
SectionComponent,
BadgeModule,
TypographyModule,
LinkModule,
I18nPipe,
PricingCardComponent,
],
})
export class PremiumVNextComponent {
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
protected personalPricingTiers$: Observable<PersonalSubscriptionPricingTier[]>;
protected premiumCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected familiesCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected subscriber!: BitwardenSubscriber;
protected isSelfHost = false;
private destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
private i18nService: I18nService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private toastService: ToastService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
this.hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
: of(false),
),
);
this.accountService.activeAccount$
.pipe(mapAccountToSubscriber, takeUntilDestroyed(this.destroyRef))
.subscribe((subscriber) => {
this.subscriber = subscriber;
});
this.shouldShowNewDesign$ = combineLatest([
this.hasPremiumFromAnyOrganization$,
this.hasPremiumPersonally$,
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
this.personalPricingTiers$ =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
this.premiumCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
return {
tier,
price:
tier?.passwordManager.type === "standalone"
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.familiesCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Families);
return {
tier,
price:
tier?.passwordManager.type === "packaged"
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
};
protected async openUpgradeDialog(planType: "Premium" | "Families"): Promise<void> {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const selectedPlan =
planType === "Premium"
? PersonalSubscriptionPricingTierIds.Premium
: PersonalSubscriptionPricingTierIds.Families;
const dialogParams: UnifiedUpgradeDialogParams = {
account,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: selectedPlan,
redirectOnCompletion: true,
};
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: dialogParams,
});
dialogRef.closed
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: UnifiedUpgradeDialogResult | undefined) => {
if (
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
) {
void this.finalizeUpgrade();
}
});
}
}

View File

@@ -1,137 +1,132 @@
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "bitwardenFamiliesPlan" | i18n }}
{{ "purchasePremium" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-callout>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
[showAccountCredit]="true"
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
[disabled]="!(hasEnoughAccountCredit$ | async)"
>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>

View File

@@ -8,6 +8,4 @@
</bit-tab-nav-bar>
</app-header>
<bit-container>
<router-outlet></router-outlet>
</bit-container>
<router-outlet></router-outlet>

View File

@@ -1,6 +1,7 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
@@ -50,6 +51,7 @@ export type UnifiedUpgradeDialogResult = {
* @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any.
* @property {string | null} [dialogTitleMessageOverride] - Optional custom i18n key to override the default dialog title.
* @property {boolean} [hideContinueWithoutUpgradingButton] - Whether to hide the "Continue without upgrading" button.
* @property {boolean} [redirectOnCompletion] - Whether to redirect after successful upgrade. Premium upgrades redirect to subscription settings, Families upgrades redirect to organization vault.
*/
export type UnifiedUpgradeDialogParams = {
account: Account;
@@ -57,6 +59,7 @@ export type UnifiedUpgradeDialogParams = {
selectedPlan?: PersonalSubscriptionPricingTierId | null;
planSelectionStepTitleOverride?: string | null;
hideContinueWithoutUpgradingButton?: boolean;
redirectOnCompletion?: boolean;
};
@Component({
@@ -86,6 +89,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
constructor(
private dialogRef: DialogRef<UnifiedUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams,
private router: Router,
) {}
ngOnInit(): void {
@@ -142,7 +146,20 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
default:
status = UnifiedUpgradeDialogStatus.Closed;
}
this.close({ status, organizationId: result.organizationId });
if (
this.params.redirectOnCompletion &&
(status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
status === UnifiedUpgradeDialogStatus.UpgradedToFamilies)
) {
const redirectUrl =
status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
? `/organizations/${result.organizationId}/vault`
: "/settings/subscription/user-subscription";
void this.router.navigate([redirectUrl]);
}
}
/**

View File

@@ -11851,5 +11851,14 @@
},
"upgradeErrorMessage": {
"message": "We encountered an error while processing your upgrade. Please try again."
},
"bitwardenFreeplanMessage": {
"message": "You have the Bitwarden Free plan"
},
"upgradeCompleteSecurity": {
"message": "Upgrade for complete security"
},
"viewbusinessplans": {
"message": "View business plans"
}
}