From 75182926d4ef75b564e89df10c84114422405f8d Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:31:10 +0100 Subject: [PATCH] [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 --- .../individual-billing-routing.module.ts | 17 +- .../individual/individual-billing.module.ts | 2 + .../premium/premium-vnext.component.html | 68 +++++ .../premium/premium-vnext.component.ts | 182 +++++++++++++ .../individual/premium/premium.component.html | 257 +++++++++--------- .../individual/subscription.component.html | 4 +- .../unified-upgrade-dialog.component.ts | 17 ++ apps/web/src/locales/en/messages.json | 9 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../pricing-card/pricing-card.component.html | 4 +- 10 files changed, 421 insertions(+), 141 deletions(-) create mode 100644 apps/web/src/app/billing/individual/premium/premium-vnext.component.html create mode 100644 apps/web/src/app/billing/individual/premium/premium-vnext.component.ts diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index bb0ca60b677..0bc6b1effbb 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -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, diff --git a/apps/web/src/app/billing/individual/individual-billing.module.ts b/apps/web/src/app/billing/individual/individual-billing.module.ts index 20f2a6cc143..56c40002f1d 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -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, diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.html b/apps/web/src/app/billing/individual/premium/premium-vnext.component.html new file mode 100644 index 00000000000..bf5d0f60861 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.html @@ -0,0 +1,68 @@ +
+ +
+
+ + {{ "bitwardenFreeplanMessage" | i18n }} + +
+ +

+ {{ "upgradeCompleteSecurity" | i18n }} +

+

+ {{ "individualUpgradeDescriptionMessage" | i18n }} +

+
+ + +
+ +
+ @if (premiumCardData$ | async; as premiumData) { + +

{{ "premium" | i18n }}

+
+ } +
+ + +
+ @if (familiesCardData$ | async; as familiesData) { + +

{{ "families" | i18n }}

+
+ } +
+
+ + +
+

+ {{ "individualUpgradeTaxInformationMessage" | i18n }} +

+ + {{ "viewbusinessplans" | i18n }} + + +
+
+
diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts new file mode 100644 index 00000000000..9de9c22d3c3 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -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; + protected hasPremiumPersonally$: Observable; + protected shouldShowNewDesign$: Observable; + protected personalPricingTiers$: Observable; + 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 { + 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(); + } + }); + } +} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index 0a3762a1e41..d08b942ff8b 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -1,137 +1,132 @@ - -

{{ "goPremium" | i18n }}

- - {{ "alreadyPremiumFromOrg" | i18n }} - - -

{{ "premiumUpgradeUnlockFeatures" | i18n }}

-
    -
  • - - {{ "premiumSignUpStorage" | i18n }} -
  • -
  • - - {{ "premiumSignUpTwoStepOptions" | i18n }} -
  • -
  • - - {{ "premiumSignUpEmergency" | i18n }} -
  • -
  • - - {{ "premiumSignUpReports" | i18n }} -
  • -
  • - - {{ "premiumSignUpTotp" | i18n }} -
  • -
  • - - {{ "premiumSignUpSupport" | i18n }} -
  • -
  • - - {{ "premiumSignUpFuture" | i18n }} -
  • -
-

- {{ - "premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount - }} + + +

{{ "goPremium" | i18n }}

+ + {{ "alreadyPremiumFromOrg" | i18n }} + + +

{{ "premiumUpgradeUnlockFeatures" | i18n }}

+
    +
  • + + {{ "premiumSignUpStorage" | i18n }} +
  • +
  • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
  • +
  • + + {{ "premiumSignUpEmergency" | i18n }} +
  • +
  • + + {{ "premiumSignUpReports" | i18n }} +
  • +
  • + + {{ "premiumSignUpTotp" | i18n }} +
  • +
  • + + {{ "premiumSignUpSupport" | i18n }} +
  • +
  • + + {{ "premiumSignUpFuture" | i18n }} +
  • +
+

+ {{ + "premiumPriceWithFamilyPlan" + | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount + }} + + {{ "bitwardenFamiliesPlan" | i18n }} + +

- {{ "bitwardenFamiliesPlan" | i18n }} + {{ "purchasePremium" | i18n }} -

- - {{ "purchasePremium" | i18n }} - -
-
- - - -
- -

{{ "addons" | i18n }}

-
- - {{ "additionalStorageGb" | i18n }} - - {{ - "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) - }} - -
+
- -

{{ "summary" | i18n }}

- {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × - {{ storageGBPrice | currency: "$" }} = - {{ additionalStorageCost | currency: "$" }} -
+ + - -

{{ "paymentInformation" | i18n }}

-
- - - - -
-
-
- {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} - {{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }} + + +

{{ "addons" | i18n }}

+
+ + {{ "additionalStorageGb" | i18n }} + + {{ + "additionalStorageIntervalDesc" + | i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) + }} +
-
-
-

- {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }} -

- - - + + +

{{ "summary" | i18n }}

+ {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
+ {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × + {{ storageGBPrice | currency: "$" }} = + {{ additionalStorageCost | currency: "$" }} +
+
+ +

{{ "paymentInformation" | i18n }}

+
+ + + + +
+
+
+ {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} + {{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }} +
+
+
+

+ {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }} +

+ +
+ + diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index f9a46cf56ad..4cbec4b4338 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -8,6 +8,4 @@ - - - + diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index e46c534ebdd..5a84856225d 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -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, @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]); + } } /** diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 06d0c92ac75..b2d32cbb804 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e2d4b000626..f6090c01d2b 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -28,6 +28,7 @@ export enum FeatureFlag { PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", + PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -106,6 +107,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, + [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html index d0c1ad4a2bb..8eae7088ac9 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.html +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -14,7 +14,7 @@
-
+

{{ tagline() }}

@@ -25,7 +25,7 @@
{{ - priceValue.amount | currency: "USD" : "symbol" + priceValue.amount | currency: "$" }} / {{ priceValue.cadence }}