mirror of
https://github.com/bitwarden/browser
synced 2025-01-11 01:48:12 +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:
parent
32903a21f9
commit
2a1d9b7f31
@ -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,
|
||||
|
@ -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 {}
|
||||
|
@ -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 ×
|
||||
{{ 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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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",
|
@ -52,6 +52,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
|
||||
UpdateLicenseDialogComponent,
|
||||
OffboardingSurveyComponent,
|
||||
VerifyBankAccountComponent,
|
||||
PaymentV2Component,
|
||||
],
|
||||
})
|
||||
export class BillingSharedModule {}
|
||||
|
Loading…
Reference in New Issue
Block a user