1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-11 10:10:25 +01:00

[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
This commit is contained in:
Alex Morask 2024-09-05 11:20:23 -04:00 committed by GitHub
parent 32903a21f9
commit 2a1d9b7f31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 327 additions and 8 deletions

View File

@ -1,10 +1,14 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router"; 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 { PaymentMethodComponent } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component"; 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 { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component";
@ -20,11 +24,15 @@ const routes: Routes = [
component: UserSubscriptionComponent, component: UserSubscriptionComponent,
data: { titleId: "premiumMembership" }, data: { titleId: "premiumMembership" },
}, },
{ ...featureFlaggedRoute({
defaultComponent: PremiumComponent,
flaggedComponent: PremiumV2Component,
featureFlag: FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
routeOptions: {
path: "premium", path: "premium",
component: PremiumComponent,
data: { titleId: "goPremium" }, data: { titleId: "goPremium" },
}, },
}),
{ {
path: "payment-method", path: "payment-method",
component: PaymentMethodComponent, component: PaymentMethodComponent,

View File

@ -5,7 +5,8 @@ import { BillingSharedModule } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { IndividualBillingRoutingModule } from "./individual-billing-routing.module"; 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 { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component";
@ -16,6 +17,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
BillingHistoryViewComponent, BillingHistoryViewComponent,
UserSubscriptionComponent, UserSubscriptionComponent,
PremiumComponent, PremiumComponent,
PremiumV2Component,
], ],
}) })
export class IndividualBillingModule {} export class IndividualBillingModule {}

View File

@ -0,0 +1,144 @@
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseFormGroup" [bitSubmit]="submitPremiumLicense">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button type="button" bitButton buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{
licenseFormGroup.value.file ? licenseFormGroup.value.file.name : ("noFileChosen" | i18n)
}}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="onLicenseFileSelected($event)"
hidden
class="tw-hidden"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</form>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="addOnFormGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<app-payment-v2 [showBankAccount]="false"></app-payment-v2>
<app-tax-info></app-tax-info>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<!-- TODO: Currently incorrect - https://bitwarden.atlassian.net/browse/PM-11525 -->
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>

View File

@ -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<boolean>;
protected addOnFormGroup = new FormGroup({
additionalStorage: new FormControl<number>(0, [Validators.min(0), Validators.max(99)]),
});
protected licenseFormGroup = new FormGroup({
file: new FormControl<File>(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<boolean> =>
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<void> => {
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<void> => {
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;
}
}

View File

@ -13,7 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
import { PaymentComponent, TaxInfoComponent } from "../shared"; import { PaymentComponent, TaxInfoComponent } from "../../shared";
@Component({ @Component({
templateUrl: "premium.component.html", templateUrl: "premium.component.html",

View File

@ -52,6 +52,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
UpdateLicenseDialogComponent, UpdateLicenseDialogComponent,
OffboardingSurveyComponent, OffboardingSurveyComponent,
VerifyBankAccountComponent, VerifyBankAccountComponent,
PaymentV2Component,
], ],
}) })
export class BillingSharedModule {} export class BillingSharedModule {}