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 cdccaaab8ab..fbaf65d1839 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,15 +1,11 @@ import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { map } from "rxjs"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; -import { CloudHostedPremiumVNextComponent } from "./premium/cloud-hosted-premium-vnext.component"; import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -27,20 +23,15 @@ const routes: Routes = [ data: { titleId: "premiumMembership" }, }, /** - * Three-Route Matching Strategy for /premium: + * Two-Route Matching Strategy for /premium: * * Routes are evaluated in order using canMatch guards. The first route that matches will be selected. * * 1. Self-Hosted Environment → SelfHostedPremiumComponent * - Matches when platformUtilsService.isSelfHost() === true * - * 2. Cloud-Hosted + Feature Flag Enabled → CloudHostedPremiumVNextComponent - * - Only evaluated if Route 1 doesn't match (not self-hosted) - * - Matches when PM24033PremiumUpgradeNewDesign feature flag === true - * - * 3. Cloud-Hosted + Feature Flag Disabled → CloudHostedPremiumComponent (Fallback) - * - No canMatch guard, so this always matches as the fallback route - * - Used when neither Route 1 nor Route 2 match + * 2. Cloud-Hosted (default) → CloudHostedPremiumComponent + * - Evaluated when Route 1 doesn't match (not self-hosted) */ // Route 1: Self-Hosted -> SelfHostedPremiumComponent { @@ -54,22 +45,7 @@ const routes: Routes = [ }, ], }, - // Route 2: Cloud Hosted + FF -> CloudHostedPremiumVNextComponent - { - path: "premium", - component: CloudHostedPremiumVNextComponent, - data: { titleId: "goPremium" }, - canMatch: [ - () => { - const configService = inject(ConfigService); - - return configService - .getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign) - .pipe(map((flagValue) => flagValue === true)); - }, - ], - }, - // Route 3: Cloud Hosted + FF Disabled -> CloudHostedPremiumComponent (Fallback) + // Route 2: Cloud Hosted (default) -> CloudHostedPremiumComponent { path: "premium", component: CloudHostedPremiumComponent, 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 2a529d43416..35c08aa40a2 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -12,7 +12,6 @@ import { BillingSharedModule } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { IndividualBillingRoutingModule } from "./individual-billing-routing.module"; -import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -26,11 +25,6 @@ import { UserSubscriptionComponent } from "./user-subscription.component"; PricingCardComponent, BaseCardComponent, ], - declarations: [ - SubscriptionComponent, - BillingHistoryViewComponent, - UserSubscriptionComponent, - CloudHostedPremiumComponent, - ], + declarations: [SubscriptionComponent, BillingHistoryViewComponent, UserSubscriptionComponent], }) export class IndividualBillingModule {} diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html deleted file mode 100644 index e182659acbb..00000000000 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html +++ /dev/null @@ -1,68 +0,0 @@ -
- -
-
- - {{ "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/cloud-hosted-premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts deleted file mode 100644 index aac7fd3156f..00000000000 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Router } from "@angular/router"; -import { - combineLatest, - firstValueFrom, - from, - map, - Observable, - of, - shareReplay, - switchMap, - take, -} 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 { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierIds, -} from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { - BadgeModule, - DialogService, - LinkModule, - SectionComponent, - TypographyModule, -} from "@bitwarden/components"; -import { PricingCardComponent } from "@bitwarden/pricing"; -import { I18nPipe } from "@bitwarden/ui-common"; - -import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types"; -import { - UnifiedUpgradeDialogComponent, - UnifiedUpgradeDialogParams, - UnifiedUpgradeDialogResult, - UnifiedUpgradeDialogStatus, - UnifiedUpgradeDialogStep, -} from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; - -const RouteParams = { - callToAction: "callToAction", -} as const; -const RouteParamValues = { - upgradeToPremium: "upgradeToPremium", -} as const; - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - templateUrl: "./cloud-hosted-premium-vnext.component.html", - standalone: true, - imports: [ - CommonModule, - SectionComponent, - BadgeModule, - TypographyModule, - LinkModule, - I18nPipe, - PricingCardComponent, - ], -}) -export class CloudHostedPremiumVNextComponent { - protected hasPremiumFromAnyOrganization$: Observable; - protected hasPremiumPersonally$: Observable; - protected shouldShowNewDesign$: Observable; - protected shouldShowUpgradeDialogOnInit$: 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; - private destroyRef = inject(DestroyRef); - - constructor( - private accountService: AccountService, - private apiService: ApiService, - private dialogService: DialogService, - private syncService: SyncService, - private billingAccountProfileStateService: BillingAccountProfileStateService, - private subscriptionPricingService: SubscriptionPricingServiceAbstraction, - private router: Router, - private activatedRoute: ActivatedRoute, - ) { - 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)); - - // redirect to user subscription page if they already have premium personally - // redirect to individual vault if they already have premium from an org - combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$]) - .pipe( - takeUntilDestroyed(this.destroyRef), - switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => { - if (hasPremiumPersonally) { - return from(this.navigateToSubscriptionPage()); - } - if (hasPremiumFromOrg) { - return from(this.navigateToIndividualVault()); - } - return of(true); - }), - ) - .subscribe(); - - this.shouldShowUpgradeDialogOnInit$ = combineLatest([ - this.hasPremiumFromAnyOrganization$, - this.hasPremiumPersonally$, - this.activatedRoute.queryParams, - ]).pipe( - map(([hasOrgPremium, hasPersonalPremium, queryParams]) => { - const cta = queryParams[RouteParams.callToAction]; - return !hasOrgPremium && !hasPersonalPremium && cta === RouteParamValues.upgradeToPremium; - }), - ); - - 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" && tier.passwordManager.annualPrice - ? 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" && tier.passwordManager.annualPrice - ? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) - : 0, - features: tier?.passwordManager.features.map((f) => f.value) || [], - }; - }), - shareReplay({ bufferSize: 1, refCount: true }), - ); - - this.shouldShowUpgradeDialogOnInit$ - .pipe( - take(1), - switchMap((shouldShowUpgradeDialogOnInit) => { - if (shouldShowUpgradeDialogOnInit) { - return from(this.openUpgradeDialog("Premium")); - } - // Return an Observable that completes immediately when dialog should not be shown - return of(void 0); - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(); - } - - private navigateToSubscriptionPage = (): Promise => - this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); - - private navigateToIndividualVault = (): Promise => this.router.navigate(["/vault"]); - - 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/cloud-hosted-premium.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html index 33e89f21fc0..e182659acbb 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html @@ -1,141 +1,68 @@ -@if (isLoadingPrices$ | async) { - - - {{ "loading" | i18n }} - -} @else { - - -

{{ "goPremium" | i18n }}

- + +
+
+ + {{ "bitwardenFreeplanMessage" | i18n }} + +
+ +

+ {{ "upgradeCompleteSecurity" | i18n }} +

+

+ {{ "individualUpgradeDescriptionMessage" | i18n }} +

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

{{ "premium" | i18n }}

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

{{ "families" | i18n }}

+
+ } +
+
+ + +
+

+ {{ "individualUpgradeTaxInformationMessage" | i18n }} +

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

{{ "premiumUpgradeUnlockFeatures" | i18n }}

-
    -
  • - - {{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }} -
  • -
  • - - {{ "premiumSignUpTwoStepOptions" | i18n }} -
  • -
  • - - {{ "premiumSignUpEmergency" | i18n }} -
  • -
  • - - {{ "premiumSignUpReports" | i18n }} -
  • -
  • - - {{ "premiumSignUpTotp" | i18n }} -
  • -
  • - - {{ "premiumSignUpSupport" | i18n }} -
  • -
  • - - {{ "premiumSignUpFuture" | i18n }} -
  • -
-

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

- - -
- -

{{ "addons" | i18n }}

-
- - {{ "additionalStorageGb" | i18n }} - - {{ - "additionalStorageIntervalDesc" - | i18n - : `${(providedStorageGb$ | async)} GB` - : (storagePrice$ | async | currency: "$") - : ("year" | i18n) - }} - -
-
- -

{{ "summary" | i18n }}

- {{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × - {{ storagePrice$ | async | currency: "$" }} = - {{ storageCost$ | async | currency: "$" }} -
-
- -

{{ "paymentInformation" | i18n }}

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

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

- -
-
- -} + {{ "viewbusinessplans" | i18n }} + + +
+
+ diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts index 86a508d2701..7e219c44d90 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts @@ -1,243 +1,242 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, ViewChild } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { - catchError, combineLatest, - concatMap, - filter, + firstValueFrom, from, map, Observable, of, shareReplay, - startWith, switchMap, + take, } from "rxjs"; -import { debounceTime } from "rxjs/operators"; 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 { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { ToastService } from "@bitwarden/components"; -import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients"; import { - EnterBillingAddressComponent, - EnterPaymentMethodComponent, - getBillingAddressFromForm, -} from "@bitwarden/web-vault/app/billing/payment/components"; + BadgeModule, + DialogService, + LinkModule, + SectionComponent, + TypographyModule, +} from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types"; import { - NonTokenizablePaymentMethods, - tokenizablePaymentMethodToLegacyEnum, -} from "@bitwarden/web-vault/app/billing/payment/types"; -import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogParams, + UnifiedUpgradeDialogResult, + UnifiedUpgradeDialogStatus, + UnifiedUpgradeDialogStep, +} from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; + +const RouteParams = { + callToAction: "callToAction", +} as const; +const RouteParamValues = { + upgradeToPremium: "upgradeToPremium", +} as const; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./cloud-hosted-premium.component.html", - standalone: false, - providers: [SubscriberBillingClient, TaxClient], + standalone: true, + imports: [ + CommonModule, + SectionComponent, + BadgeModule, + TypographyModule, + LinkModule, + I18nPipe, + PricingCardComponent, + ], }) export class CloudHostedPremiumComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; - protected hasPremiumFromAnyOrganization$: Observable; - protected hasEnoughAccountCredit$: Observable; - - protected formGroup = new FormGroup({ - additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), - paymentMethod: EnterPaymentMethodComponent.getFormGroup(), - billingAddress: EnterBillingAddressComponent.getFormGroup(), - }); - - premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe( - map((tiers) => { - const premiumPlan = tiers.find( - (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, - ); - - if (!premiumPlan) { - throw new Error("Could not find Premium plan"); - } - - return { - seat: premiumPlan.passwordManager.annualPrice, - storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB, - providedStorageGb: premiumPlan.passwordManager.providedStorageGB, - }; - }), - shareReplay({ bufferSize: 1, refCount: true }), - ); - - premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat)); - - storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage)); - - providedStorageGb$ = this.premiumPrices$.pipe(map((prices) => prices.providedStorageGb)); - - protected isLoadingPrices$ = this.premiumPrices$.pipe( - map(() => false), - startWith(true), - catchError(() => of(false)), - ); - - storageCost$ = combineLatest([ - this.storagePrice$, - this.formGroup.controls.additionalStorage.valueChanges.pipe( - startWith(this.formGroup.value.additionalStorage), - ), - ]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage)); - - subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe( - map(([premiumPrice, storageCost]) => premiumPrice + storageCost), - ); - - tax$ = this.formGroup.valueChanges.pipe( - filter(() => this.formGroup.valid), - debounceTime(1000), - switchMap(async () => { - const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( - this.formGroup.value.additionalStorage, - billingAddress, - ); - return taxAmounts.tax; - }), - startWith(0), - ); - - total$ = combineLatest([this.subtotal$, this.tax$]).pipe( - map(([subtotal, tax]) => subtotal + tax), - ); - - protected cloudWebVaultURL: string; - protected readonly familyPlanMaxUserCount = 6; + protected hasPremiumPersonally$: Observable; + protected shouldShowNewDesign$: Observable; + protected shouldShowUpgradeDialogOnInit$: 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; + private destroyRef = inject(DestroyRef); constructor( - private activatedRoute: ActivatedRoute, - private apiService: ApiService, - private billingAccountProfileStateService: BillingAccountProfileStateService, - private environmentService: EnvironmentService, - private i18nService: I18nService, - private router: Router, - private syncService: SyncService, - private toastService: ToastService, private accountService: AccountService, - private subscriberBillingClient: SubscriberBillingClient, - private taxClient: TaxClient, + private apiService: ApiService, + private dialogService: DialogService, + private syncService: SyncService, + private billingAccountProfileStateService: BillingAccountProfileStateService, private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private router: Router, + private activatedRoute: ActivatedRoute, ) { this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( switchMap((account) => - this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id), + account + ? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id) + : of(false), ), ); - const accountCredit$ = this.accountService.activeAccount$.pipe( - mapAccountToSubscriber, - switchMap((account) => this.subscriberBillingClient.getCredit(account)), + this.hasPremiumPersonally$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id) + : of(false), + ), ); - this.hasEnoughAccountCredit$ = combineLatest([ - accountCredit$, - this.total$, - this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe( - startWith(this.formGroup.value.paymentMethod.type), - ), - ]).pipe( - map(([credit, total, paymentMethod]) => { - if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) { - return true; - } - return credit >= total; - }), - ); + this.accountService.activeAccount$ + .pipe(mapAccountToSubscriber, takeUntilDestroyed(this.destroyRef)) + .subscribe((subscriber) => { + this.subscriber = subscriber; + }); - combineLatest([ - this.accountService.activeAccount$.pipe( - switchMap((account) => - this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), - ), - ), - this.environmentService.cloudWebVaultUrl$, - ]) + this.shouldShowNewDesign$ = combineLatest([ + this.hasPremiumFromAnyOrganization$, + this.hasPremiumPersonally$, + ]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium)); + + // redirect to user subscription page if they already have premium personally + // redirect to individual vault if they already have premium from an org + combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$]) .pipe( - takeUntilDestroyed(), - concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => { + takeUntilDestroyed(this.destroyRef), + switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => { if (hasPremiumPersonally) { return from(this.navigateToSubscriptionPage()); } - - this.cloudWebVaultURL = cloudWebVaultURL; + if (hasPremiumFromOrg) { + return from(this.navigateToIndividualVault()); + } return of(true); }), ) .subscribe(); + + this.shouldShowUpgradeDialogOnInit$ = combineLatest([ + this.hasPremiumFromAnyOrganization$, + this.hasPremiumPersonally$, + this.activatedRoute.queryParams, + ]).pipe( + map(([hasOrgPremium, hasPersonalPremium, queryParams]) => { + const cta = queryParams[RouteParams.callToAction]; + return !hasOrgPremium && !hasPersonalPremium && cta === RouteParamValues.upgradeToPremium; + }), + ); + + 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" && tier.passwordManager.annualPrice + ? 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" && tier.passwordManager.annualPrice + ? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) + : 0, + features: tier?.passwordManager.features.map((f) => f.value) || [], + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.shouldShowUpgradeDialogOnInit$ + .pipe( + take(1), + switchMap((shouldShowUpgradeDialogOnInit) => { + if (shouldShowUpgradeDialogOnInit) { + return from(this.openUpgradeDialog("Premium")); + } + // Return an Observable that completes immediately when dialog should not be shown + return of(void 0); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); } + private navigateToSubscriptionPage = (): Promise => + this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); + + private navigateToIndividualVault = (): Promise => this.router.navigate(["/vault"]); + finalizeUpgrade = async () => { await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); }; - postFinalizeUpgrade = async () => { - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("premiumUpdated"), - }); - await this.navigateToSubscriptionPage(); - }; - - navigateToSubscriptionPage = (): Promise => - this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); - - submitPayment = async (): Promise => { - if (this.formGroup.invalid) { + protected async openUpgradeDialog(planType: "Premium" | "Families"): Promise { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { return; } - // Check if account credit is selected - const selectedPaymentType = this.formGroup.value.paymentMethod.type; + const selectedPlan = + planType === "Premium" + ? PersonalSubscriptionPricingTierIds.Premium + : PersonalSubscriptionPricingTierIds.Families; - let paymentMethodType: number; - let paymentToken: string; + const dialogParams: UnifiedUpgradeDialogParams = { + account, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: selectedPlan, + redirectOnCompletion: true, + }; - if (selectedPaymentType === NonTokenizablePaymentMethods.accountCredit) { - // Account credit doesn't need tokenization - paymentMethodType = PaymentMethodType.Credit; - paymentToken = ""; - } else { - // Tokenize for card, bank account, or PayPal - const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); - paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); - paymentToken = paymentMethod.token; - } + const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: dialogParams, + }); - const formData = new FormData(); - formData.append("paymentMethodType", paymentMethodType.toString()); - formData.append("paymentToken", paymentToken); - formData.append( - "additionalStorageGb", - (this.formGroup.value.additionalStorage ?? 0).toString(), - ); - formData.append("country", this.formGroup.value.billingAddress.country); - formData.append("postalCode", this.formGroup.value.billingAddress.postalCode); - - await this.apiService.postPremium(formData); - await this.finalizeUpgrade(); - await this.postFinalizeUpgrade(); - }; + 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/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 7357e73a89e..15618ab3279 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -30,7 +30,6 @@ 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", PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", @@ -140,7 +139,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, - [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE,