From 2a1d9b7f31e83ed7d14bbeecc952fdceffd21ef2 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:20:23 -0400 Subject: [PATCH] [AC-2963] Add premium-v2.component for individual users (#10885) * Add premium-v2.component * (No Logic) Move existing premium.component into new premium folder * Add new premium-v2.component to /premium route behind FF --- .../individual-billing-routing.module.ts | 20 ++- .../individual/individual-billing.module.ts | 4 +- .../premium/premium-v2.component.html | 144 +++++++++++++++ .../premium/premium-v2.component.ts | 164 ++++++++++++++++++ .../{ => premium}/premium.component.html | 0 .../{ => premium}/premium.component.ts | 2 +- .../billing/shared/billing-shared.module.ts | 1 + 7 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/billing/individual/premium/premium-v2.component.html create mode 100644 apps/web/src/app/billing/individual/premium/premium-v2.component.ts rename apps/web/src/app/billing/individual/{ => premium}/premium.component.html (100%) rename apps/web/src/app/billing/individual/{ => premium}/premium.component.ts (98%) 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 ff45ca75ac..585d9b418c 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,10 +1,14 @@ 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 { PaymentMethodComponent } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; -import { PremiumComponent } from "./premium.component"; +import { PremiumV2Component } from "./premium/premium-v2.component"; +import { PremiumComponent } from "./premium/premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -20,11 +24,15 @@ const routes: Routes = [ component: UserSubscriptionComponent, data: { titleId: "premiumMembership" }, }, - { - path: "premium", - component: PremiumComponent, - data: { titleId: "goPremium" }, - }, + ...featureFlaggedRoute({ + defaultComponent: PremiumComponent, + flaggedComponent: PremiumV2Component, + featureFlag: FeatureFlag.AC2476_DeprecateStripeSourcesAPI, + routeOptions: { + path: "premium", + data: { titleId: "goPremium" }, + }, + }), { path: "payment-method", component: PaymentMethodComponent, 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 dbae28858f..0dbbc8c683 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -5,7 +5,8 @@ import { BillingSharedModule } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { IndividualBillingRoutingModule } from "./individual-billing-routing.module"; -import { PremiumComponent } from "./premium.component"; +import { PremiumV2Component } from "./premium/premium-v2.component"; +import { PremiumComponent } from "./premium/premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -16,6 +17,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component"; BillingHistoryViewComponent, UserSubscriptionComponent, PremiumComponent, + PremiumV2Component, ], }) export class IndividualBillingModule {} diff --git a/apps/web/src/app/billing/individual/premium/premium-v2.component.html b/apps/web/src/app/billing/individual/premium/premium-v2.component.html new file mode 100644 index 0000000000..bdf6ff87d1 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/premium-v2.component.html @@ -0,0 +1,144 @@ + +

{{ "goPremium" | i18n }}

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

{{ "premiumUpgradeUnlockFeatures" | i18n }}

+ +

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

+ + {{ "purchasePremium" | i18n }} + +
+
+ +

{{ "uploadLicenseFilePremium" | i18n }}

+
+ + {{ "licenseFile" | i18n }} +
+ + {{ + licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n) + }} +
+ + {{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }} +
+ +
+
+
+ +

{{ "addons" | i18n }}

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

{{ "summary" | i18n }}

+ {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
+ {{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.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/premium/premium-v2.component.ts b/apps/web/src/app/billing/individual/premium/premium-v2.component.ts new file mode 100644 index 0000000000..cf66dac2f7 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/premium-v2.component.ts @@ -0,0 +1,164 @@ +import { Component, ViewChild } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, concatMap, from, Observable, of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +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 { ToastService } from "@bitwarden/components"; + +import { PaymentV2Component } from "../../shared/payment/payment-v2.component"; +import { TaxInfoComponent } from "../../shared/tax-info.component"; + +@Component({ + templateUrl: "./premium-v2.component.html", +}) +export class PremiumV2Component { + @ViewChild(PaymentV2Component) paymentComponent: PaymentV2Component; + @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; + + protected hasPremiumFromAnyOrganization$: Observable; + + protected addOnFormGroup = new FormGroup({ + additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), + }); + + protected licenseFormGroup = new FormGroup({ + file: new FormControl(null, [Validators.required]), + }); + + protected cloudWebVaultURL: string; + protected isSelfHost = false; + + protected readonly familyPlanMaxUserCount = 6; + protected readonly premiumPrice = 10; + protected readonly storageGBPrice = 4; + + constructor( + private activatedRoute: ActivatedRoute, + private apiService: ApiService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private router: Router, + private syncService: SyncService, + private toastService: ToastService, + private tokenService: TokenService, + ) { + this.isSelfHost = this.platformUtilsService.isSelfHost(); + + this.hasPremiumFromAnyOrganization$ = + this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$; + + combineLatest([ + this.billingAccountProfileStateService.hasPremiumPersonally$, + this.environmentService.cloudWebVaultUrl$, + ]) + .pipe( + takeUntilDestroyed(), + concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => { + if (hasPremiumPersonally) { + return from(this.navigateToSubscriptionPage()); + } + + this.cloudWebVaultURL = cloudWebVaultURL; + return of(true); + }), + ) + .subscribe(); + } + + finalizeUpgrade = async () => { + await this.apiService.refreshIdentityToken(); + await this.syncService.fullSync(true); + 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 }); + + onLicenseFileSelected = (event: Event): void => { + const element = event.target as HTMLInputElement; + this.licenseFormGroup.value.file = element.files.length > 0 ? element.files[0] : null; + }; + + submitPremiumLicense = async (): Promise => { + this.licenseFormGroup.markAllAsTouched(); + + if (this.licenseFormGroup.invalid) { + return this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("selectFile"), + }); + } + + const emailVerified = await this.tokenService.getEmailVerified(); + if (!emailVerified) { + return this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("verifyEmailFirst"), + }); + } + + const formData = new FormData(); + formData.append("license", this.licenseFormGroup.value.file); + + await this.apiService.postAccountLicense(formData); + await this.finalizeUpgrade(); + }; + + submitPayment = async (): Promise => { + this.taxInfoComponent.taxFormGroup.markAllAsTouched(); + if (this.taxInfoComponent.taxFormGroup.invalid) { + return; + } + + const { type, token } = await this.paymentComponent.tokenize(); + + const formData = new FormData(); + formData.append("paymentMethodType", type.toString()); + formData.append("paymentToken", token); + formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString()); + formData.append("country", this.taxInfoComponent.country); + formData.append("postalCode", this.taxInfoComponent.postalCode); + + await this.apiService.postPremium(formData); + await this.finalizeUpgrade(); + }; + + protected get additionalStorageCost(): number { + return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage; + } + + protected get estimatedTax(): number { + return this.taxInfoComponent?.taxRate != null + ? (this.taxInfoComponent.taxRate / 100) * this.subtotal + : 0; + } + + protected get premiumURL(): string { + return `${this.cloudWebVaultURL}/#/settings/subscription/premium`; + } + + protected get subtotal(): number { + return this.premiumPrice + this.additionalStorageCost; + } + + protected get total(): number { + return this.subtotal + this.estimatedTax; + } +} diff --git a/apps/web/src/app/billing/individual/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html similarity index 100% rename from apps/web/src/app/billing/individual/premium.component.html rename to apps/web/src/app/billing/individual/premium/premium.component.html diff --git a/apps/web/src/app/billing/individual/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts similarity index 98% rename from apps/web/src/app/billing/individual/premium.component.ts rename to apps/web/src/app/billing/individual/premium/premium.component.ts index 79a5c5e2ed..c45b6b882d 100644 --- a/apps/web/src/app/billing/individual/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -13,7 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; -import { PaymentComponent, TaxInfoComponent } from "../shared"; +import { PaymentComponent, TaxInfoComponent } from "../../shared"; @Component({ templateUrl: "premium.component.html", diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index c9b3f2de85..b966729c1d 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -52,6 +52,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac UpdateLicenseDialogComponent, OffboardingSurveyComponent, VerifyBankAccountComponent, + PaymentV2Component, ], }) export class BillingSharedModule {}