1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-29 14:43:31 +00:00

[PM 29079]Remove code for pm-24033-updat-premium-subscription-page (#17905)

* Remove the feature flag

* delete and rename CloudHostedPremiumVNextComponent
This commit is contained in:
cyprain-okeke
2025-12-18 17:35:48 +01:00
committed by GitHub
parent 735af3c890
commit ef7b66ad0d
7 changed files with 252 additions and 668 deletions

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -1,68 +0,0 @@
<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 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]="'advancedOnlineSecurity' | 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: ('startFreeFamiliesTrial' | 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

@@ -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<boolean>;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
protected shouldShowUpgradeDialogOnInit$: 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;
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<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
private navigateToIndividualVault = (): Promise<boolean> => this.router.navigate(["/vault"]);
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,141 +1,68 @@
@if (isLoadingPrices$ | async) {
<ng-container>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
} @else {
<bit-container>
<bit-section>
<h2 bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
<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 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]="'advancedOnlineSecurity' | 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: ('startFreeFamiliesTrial' | 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"
>
{{ "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>
{{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
</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" class="tw-mb-0">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
</bit-callout>
</bit-section>
<form [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
: `${(providedStorageGb$ | async)} GB`
: (storagePrice$ | async | currency: "$")
: ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storagePrice$ | async | currency: "$" }} =
{{ storageCost$ | async | 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"
[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$ | async | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ tax$ | async | 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$ | async | currency: "USD $" }}/{{
"year" | i18n
}}
</p>
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
[disabled]="!(hasEnoughAccountCredit$ | async)"
>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>
}
{{ "viewbusinessplans" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-section>
</div>

View File

@@ -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<boolean>;
protected hasEnoughAccountCredit$: Observable<boolean>;
protected formGroup = new FormGroup({
additionalStorage: new FormControl<number>(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<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
protected shouldShowUpgradeDialogOnInit$: 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;
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<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
private navigateToIndividualVault = (): Promise<boolean> => 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<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
submitPayment = async (): Promise<void> => {
if (this.formGroup.invalid) {
protected async openUpgradeDialog(planType: "Premium" | "Families"): Promise<void> {
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();
}
});
}
}

View File

@@ -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,