mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 00:23:17 +00:00
[PM-29604] [PM-29605] [PM-29606] Premium subscription page redesign (#18300)
* refactor(subscription-card): Correctly name card action * feat(storage-card): Switch 'callsToActionDisabled' into 1 input per CTA * refactor(additional-options-card): Switch 'callsToActionDisabled' into 1 input per CTA * feat(contract-alignment): Remove 'active' property from Discount * feat(contract-alignment): Rename 'name' to 'translationKey' in Cart model * feat(response-models): Add DiscountResponse * feat(response-models): Add StorageResponse * feat(response-models): Add CartResponse * feat(response-models): Add BitwardenSubscriptionResponse * feat(clients): Add new endpoint invocations * feat(redesign): Add feature flags * feat(redesign): Add AdjustAccountSubscriptionStorageDialogComponent * feat(redesign): Add AccountSubscriptionComponent * feat(redesign): Pivot subscription component on FF * docs: Note FF removal POIs * fix(subscription-card): Resolve compilation error in stories * fix(upgrade-payment.service): Fix failing tests
This commit is contained in:
committed by
jaasen-livefront
parent
0bac1e8c16
commit
77c1ec1251
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response";
|
||||
import { BitwardenSubscription } from "@bitwarden/subscription";
|
||||
|
||||
import {
|
||||
BillingAddress,
|
||||
@@ -11,13 +13,22 @@ import {
|
||||
@Injectable()
|
||||
export class AccountBillingClient {
|
||||
private endpoint = "/account/billing/vnext";
|
||||
private apiService: ApiService;
|
||||
|
||||
constructor(apiService: ApiService) {
|
||||
this.apiService = apiService;
|
||||
}
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
purchasePremiumSubscription = async (
|
||||
getLicense = async (): Promise<string> => {
|
||||
const path = `${this.endpoint}/license`;
|
||||
return this.apiService.send("GET", path, null, true, true);
|
||||
};
|
||||
|
||||
getSubscription = async (): Promise<BitwardenSubscription> => {
|
||||
const path = `${this.endpoint}/subscription`;
|
||||
const json = await this.apiService.send("GET", path, null, true, true);
|
||||
const response = new BitwardenSubscriptionResponse(json);
|
||||
return response.toDomain();
|
||||
};
|
||||
|
||||
purchaseSubscription = async (
|
||||
paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod,
|
||||
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
|
||||
): Promise<void> => {
|
||||
@@ -29,6 +40,17 @@ export class AccountBillingClient {
|
||||
const request = isTokenizedPayment
|
||||
? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }
|
||||
: { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress };
|
||||
|
||||
await this.apiService.send("POST", path, request, true, true);
|
||||
};
|
||||
|
||||
reinstateSubscription = async (): Promise<void> => {
|
||||
const path = `${this.endpoint}/subscription/reinstate`;
|
||||
await this.apiService.send("POST", path, null, true, false);
|
||||
};
|
||||
|
||||
updateSubscriptionStorage = async (additionalStorageGb: number): Promise<void> => {
|
||||
const path = `${this.endpoint}/subscription/storage`;
|
||||
await this.apiService.send("PUT", path, { additionalStorageGb }, true, false);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { inject, 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 { 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 { AccountSubscriptionComponent } from "@bitwarden/web-vault/app/billing/individual/subscription/account-subscription.component";
|
||||
|
||||
import { BillingHistoryViewComponent } from "./billing-history-view.component";
|
||||
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
|
||||
@@ -17,11 +20,15 @@ const routes: Routes = [
|
||||
data: { titleId: "subscription" },
|
||||
children: [
|
||||
{ path: "", pathMatch: "full", redirectTo: "premium" },
|
||||
{
|
||||
path: "user-subscription",
|
||||
component: UserSubscriptionComponent,
|
||||
data: { titleId: "premiumMembership" },
|
||||
},
|
||||
...featureFlaggedRoute({
|
||||
defaultComponent: UserSubscriptionComponent,
|
||||
flaggedComponent: AccountSubscriptionComponent,
|
||||
featureFlag: FeatureFlag.PM29594_UpdateIndividualSubscriptionPage,
|
||||
routeOptions: {
|
||||
path: "user-subscription",
|
||||
data: { titleId: "premiumMembership" },
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Two-Route Matching Strategy for /premium:
|
||||
*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
@if (subscriptionLoading()) {
|
||||
<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 {
|
||||
@if (subscription.value(); as subscription) {
|
||||
<!-- Page Header -->
|
||||
<div
|
||||
class="tw-flex tw-flex-col tw-gap-3 tw-items-center tw-text-center tw-pb-8 tw-pt-12 tw-px-14"
|
||||
>
|
||||
<h1 bitTypography="h1" class="tw-m-0">{{ "youHavePremium" | i18n }}</h1>
|
||||
<p bitTypography="body1" class="tw-m-0 tw-text-muted">
|
||||
{{ "viewAndManagePremiumSubscription" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="tw-flex tw-flex-col tw-gap-10 tw-mx-auto tw-max-w-[800px] tw-pb-8">
|
||||
<!-- Premium Membership Card -->
|
||||
<billing-subscription-card
|
||||
[title]="'premiumMembership' | i18n"
|
||||
[subscription]="subscription"
|
||||
[showUpgradeButton]="premiumToOrganizationUpgradeEnabled()"
|
||||
(callToActionClicked)="onSubscriptionCardAction($event)"
|
||||
/>
|
||||
|
||||
<!-- Storage Card -->
|
||||
@if (subscription.storage; as storage) {
|
||||
<billing-storage-card
|
||||
[storage]="storage"
|
||||
[addStorageDisabled]="!canAddStorage()"
|
||||
[removeStorageDisabled]="!canRemoveStorage()"
|
||||
(callToActionClicked)="onStorageCardAction($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Additional Options Card -->
|
||||
<billing-additional-options-card
|
||||
[downloadLicenseDisabled]="subscriptionTerminal()"
|
||||
[cancelSubscriptionDisabled]="!canCancelSubscription()"
|
||||
(callToActionClicked)="onAdditionalOptionsCardAction($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, lastValueFrom, map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
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 { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService, TypographyModule } from "@bitwarden/components";
|
||||
import { Maybe } from "@bitwarden/pricing";
|
||||
import {
|
||||
AdditionalOptionsCardAction,
|
||||
AdditionalOptionsCardActions,
|
||||
AdditionalOptionsCardComponent,
|
||||
MAX_STORAGE_GB,
|
||||
Storage,
|
||||
StorageCardAction,
|
||||
StorageCardActions,
|
||||
StorageCardComponent,
|
||||
SubscriptionCardAction,
|
||||
SubscriptionCardActions,
|
||||
SubscriptionCardComponent,
|
||||
SubscriptionStatuses,
|
||||
} from "@bitwarden/subscription";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
import {
|
||||
AdjustAccountSubscriptionStorageDialogComponent,
|
||||
AdjustAccountSubscriptionStorageDialogParams,
|
||||
} from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component";
|
||||
import {
|
||||
OffboardingSurveyDialogResultType,
|
||||
openOffboardingSurvey,
|
||||
} from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./account-subscription.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AdditionalOptionsCardComponent,
|
||||
I18nPipe,
|
||||
JslibModule,
|
||||
StorageCardComponent,
|
||||
SubscriptionCardComponent,
|
||||
TypographyModule,
|
||||
],
|
||||
providers: [AccountBillingClient],
|
||||
})
|
||||
export class AccountSubscriptionComponent {
|
||||
private accountService = inject(AccountService);
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private accountBillingClient = inject(AccountBillingClient);
|
||||
private billingAccountProfileStateService = inject(BillingAccountProfileStateService);
|
||||
private configService = inject(ConfigService);
|
||||
private dialogService = inject(DialogService);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
private i18nService = inject(I18nService);
|
||||
private router = inject(Router);
|
||||
private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
readonly subscription = resource({
|
||||
loader: async () => {
|
||||
const redirectToPremiumPage = async (): Promise<null> => {
|
||||
await this.router.navigate(["/settings/subscription/premium"]);
|
||||
return null;
|
||||
};
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!account) {
|
||||
return await redirectToPremiumPage();
|
||||
}
|
||||
const hasPremiumPersonally = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumPersonally$(account.id),
|
||||
);
|
||||
if (!hasPremiumPersonally) {
|
||||
return await redirectToPremiumPage();
|
||||
}
|
||||
return await this.accountBillingClient.getSubscription();
|
||||
},
|
||||
});
|
||||
|
||||
readonly subscriptionLoading = computed<boolean>(() => this.subscription.isLoading());
|
||||
|
||||
readonly subscriptionTerminal = computed<Maybe<boolean>>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
if (subscription) {
|
||||
return (
|
||||
subscription.status === SubscriptionStatuses.IncompleteExpired ||
|
||||
subscription.status === SubscriptionStatuses.Canceled ||
|
||||
subscription.status === SubscriptionStatuses.Unpaid
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
readonly subscriptionPendingCancellation = computed<Maybe<boolean>>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
if (subscription) {
|
||||
return (
|
||||
(subscription.status === SubscriptionStatuses.Trialing ||
|
||||
subscription.status === SubscriptionStatuses.Active) &&
|
||||
!!subscription.cancelAt
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
readonly storage = computed<Maybe<Storage>>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
return subscription?.storage;
|
||||
});
|
||||
|
||||
readonly purchasedStorage = computed<number | undefined>(() => {
|
||||
const subscription = this.subscription.value();
|
||||
return subscription?.cart.passwordManager.additionalStorage?.quantity;
|
||||
});
|
||||
|
||||
readonly premiumPlan = toSignal(
|
||||
this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(
|
||||
map((tiers) =>
|
||||
tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
readonly premiumStoragePrice = computed<Maybe<number>>(() => {
|
||||
const premiumPlan = this.premiumPlan();
|
||||
return premiumPlan?.passwordManager.annualPricePerAdditionalStorageGB;
|
||||
});
|
||||
|
||||
readonly premiumProvidedStorage = computed<Maybe<number>>(() => {
|
||||
const premiumPlan = this.premiumPlan();
|
||||
return premiumPlan?.passwordManager.providedStorageGB;
|
||||
});
|
||||
|
||||
readonly canAddStorage = computed<Maybe<boolean>>(() => {
|
||||
if (this.subscriptionTerminal()) {
|
||||
return false;
|
||||
}
|
||||
const storage = this.storage();
|
||||
const premiumProvidedStorage = this.premiumProvidedStorage();
|
||||
if (storage && premiumProvidedStorage) {
|
||||
const maxAttainableStorage = MAX_STORAGE_GB - premiumProvidedStorage;
|
||||
return storage.available < maxAttainableStorage;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canRemoveStorage = computed<Maybe<boolean>>(() => {
|
||||
if (this.subscriptionTerminal()) {
|
||||
return false;
|
||||
}
|
||||
const purchasedStorage = this.purchasedStorage();
|
||||
if (!purchasedStorage || purchasedStorage === 0) {
|
||||
return false;
|
||||
}
|
||||
const storage = this.storage();
|
||||
if (storage) {
|
||||
return storage.available > storage.used;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canCancelSubscription = computed<Maybe<boolean>>(() => {
|
||||
if (this.subscriptionTerminal()) {
|
||||
return false;
|
||||
}
|
||||
return !this.subscriptionPendingCancellation();
|
||||
});
|
||||
|
||||
readonly premiumToOrganizationUpgradeEnabled = toSignal(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
onSubscriptionCardAction = async (action: SubscriptionCardAction) => {
|
||||
switch (action) {
|
||||
case SubscriptionCardActions.ContactSupport:
|
||||
window.open("https://bitwarden.com/contact/", "_blank");
|
||||
break;
|
||||
case SubscriptionCardActions.ManageInvoices:
|
||||
await this.router.navigate(["../billing-history"], { relativeTo: this.activatedRoute });
|
||||
break;
|
||||
case SubscriptionCardActions.ReinstateSubscription: {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "reinstateSubscription" },
|
||||
content: { key: "reinstateConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.accountBillingClient.reinstateSubscription();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("reinstated"),
|
||||
});
|
||||
this.subscription.reload();
|
||||
break;
|
||||
}
|
||||
case SubscriptionCardActions.UpdatePayment:
|
||||
await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute });
|
||||
break;
|
||||
case SubscriptionCardActions.UpgradePlan:
|
||||
// TODO: Implement upgrade plan navigation
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onStorageCardAction = async (action: StorageCardAction) => {
|
||||
const data = this.getAdjustStorageDialogParams(action);
|
||||
const dialogReference = AdjustAccountSubscriptionStorageDialogComponent.open(
|
||||
this.dialogService,
|
||||
{
|
||||
data,
|
||||
},
|
||||
);
|
||||
const result = await lastValueFrom(dialogReference.closed);
|
||||
if (result === "submitted") {
|
||||
this.subscription.reload();
|
||||
}
|
||||
};
|
||||
|
||||
onAdditionalOptionsCardAction = async (action: AdditionalOptionsCardAction) => {
|
||||
switch (action) {
|
||||
case AdditionalOptionsCardActions.DownloadLicense: {
|
||||
const license = await this.accountBillingClient.getLicense();
|
||||
const json = JSON.stringify(license, null, 2);
|
||||
this.fileDownloadService.download({
|
||||
fileName: "bitwarden_premium_license.json",
|
||||
blobData: json,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case AdditionalOptionsCardActions.CancelSubscription: {
|
||||
const dialogReference = openOffboardingSurvey(this.dialogService, {
|
||||
data: {
|
||||
type: "User",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(dialogReference.closed);
|
||||
|
||||
if (result === OffboardingSurveyDialogResultType.Closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscription.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getAdjustStorageDialogParams = (
|
||||
action: StorageCardAction,
|
||||
): Maybe<AdjustAccountSubscriptionStorageDialogParams> => {
|
||||
const purchasedStorage = this.purchasedStorage();
|
||||
const storagePrice = this.premiumStoragePrice();
|
||||
const providedStorage = this.premiumProvidedStorage();
|
||||
|
||||
switch (action) {
|
||||
case StorageCardActions.AddStorage: {
|
||||
if (storagePrice && providedStorage) {
|
||||
return {
|
||||
type: "add",
|
||||
price: storagePrice,
|
||||
provided: providedStorage,
|
||||
cadence: "annually",
|
||||
existing: purchasedStorage,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StorageCardActions.RemoveStorage: {
|
||||
if (purchasedStorage) {
|
||||
return {
|
||||
type: "remove",
|
||||
existing: purchasedStorage,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@let content = this.content();
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog [title]="content.title">
|
||||
<ng-container bitDialogContent>
|
||||
<p bitTypography="body1">{{ content.body }}</p>
|
||||
<div class="tw-grid tw-grid-cols-12">
|
||||
<bit-form-field class="tw-col-span-7">
|
||||
<bit-label>{{ content.label }}</bit-label>
|
||||
<input bitInput type="number" [formControl]="formGroup.controls.amount" />
|
||||
@if (action() === "add") {
|
||||
<bit-hint>
|
||||
<!-- Total: 10 GB × $0.50 = $5.00 /month -->
|
||||
<strong>{{ "total" | i18n }}</strong>
|
||||
{{ formGroup.value.amount }} GB × {{ price() | currency: "$" }} =
|
||||
{{ price() * formGroup.value.amount | currency: "$" }} /
|
||||
{{ term() | i18n }}
|
||||
</bit-hint>
|
||||
}
|
||||
</bit-form-field>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
type="submit"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "submit" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[bitDialogClose]="'closed'"
|
||||
>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -0,0 +1,182 @@
|
||||
import { CurrencyPipe } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core";
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
|
||||
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { Maybe } from "@bitwarden/pricing";
|
||||
import { MAX_STORAGE_GB } from "@bitwarden/subscription";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
|
||||
type RemoveStorage = {
|
||||
type: "remove";
|
||||
existing: number;
|
||||
};
|
||||
|
||||
type AddStorage = {
|
||||
type: "add";
|
||||
price: number;
|
||||
provided: number;
|
||||
cadence: SubscriptionCadence;
|
||||
existing?: number;
|
||||
};
|
||||
|
||||
export type AdjustAccountSubscriptionStorageDialogParams = RemoveStorage | AddStorage;
|
||||
|
||||
type AdjustAccountSubscriptionStorageDialogResult = "closed" | "submitted";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./adjust-account-subscription-storage-dialog.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [AccountBillingClient],
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CurrencyPipe,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
I18nPipe,
|
||||
ReactiveFormsModule,
|
||||
TypographyModule,
|
||||
],
|
||||
})
|
||||
export class AdjustAccountSubscriptionStorageDialogComponent {
|
||||
private readonly accountBillingClient = inject(AccountBillingClient);
|
||||
private readonly dialogParams = inject<AdjustAccountSubscriptionStorageDialogParams>(DIALOG_DATA);
|
||||
private readonly dialogRef = inject(DialogRef<AdjustAccountSubscriptionStorageDialogResult>);
|
||||
private readonly i18nService = inject(I18nService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
readonly action = computed<"add" | "remove">(() => this.dialogParams.type);
|
||||
|
||||
readonly price = computed<Maybe<number>>(() => {
|
||||
if (this.dialogParams.type === "add") {
|
||||
return this.dialogParams.price;
|
||||
}
|
||||
});
|
||||
|
||||
readonly provided = computed<Maybe<number>>(() => {
|
||||
if (this.dialogParams.type === "add") {
|
||||
return this.dialogParams.provided;
|
||||
}
|
||||
});
|
||||
|
||||
readonly term = computed<Maybe<string>>(() => {
|
||||
if (this.dialogParams.type === "add") {
|
||||
switch (this.dialogParams.cadence) {
|
||||
case "annually":
|
||||
return this.i18nService.t("year");
|
||||
case "monthly":
|
||||
return this.i18nService.t("month");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
readonly existing = computed<Maybe<number>>(() => this.dialogParams.existing);
|
||||
|
||||
readonly content = computed<{
|
||||
title: string;
|
||||
body: string;
|
||||
label: string;
|
||||
}>(() => {
|
||||
const action = this.action();
|
||||
switch (action) {
|
||||
case "add":
|
||||
return {
|
||||
title: this.i18nService.t("addStorage"),
|
||||
body: this.i18nService.t("storageAddNote"),
|
||||
label: this.i18nService.t("gbStorageAdd"),
|
||||
};
|
||||
case "remove":
|
||||
return {
|
||||
title: this.i18nService.t("removeStorage"),
|
||||
body: this.i18nService.t("whenYouRemoveStorage"),
|
||||
label: this.i18nService.t("gbStorageRemove"),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
readonly maxPurchasable = computed<Maybe<number>>(() => {
|
||||
const provided = this.provided();
|
||||
if (provided) {
|
||||
return MAX_STORAGE_GB - provided;
|
||||
}
|
||||
});
|
||||
|
||||
readonly maxValidatorValue = computed<number>(() => {
|
||||
const maxPurchasable = this.maxPurchasable() ?? MAX_STORAGE_GB;
|
||||
const existing = this.existing();
|
||||
const action = this.action();
|
||||
|
||||
switch (action) {
|
||||
case "add": {
|
||||
return existing ? maxPurchasable - existing : maxPurchasable;
|
||||
}
|
||||
case "remove": {
|
||||
return existing ? existing : 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
formGroup = new FormGroup({
|
||||
amount: new FormControl<number>(1, {
|
||||
nonNullable: true,
|
||||
validators: [
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.max(this.maxValidatorValue()),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
if (!this.formGroup.valid || !this.formGroup.value.amount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = this.action();
|
||||
const existing = this.existing();
|
||||
const amount = this.formGroup.value.amount;
|
||||
|
||||
switch (action) {
|
||||
case "add": {
|
||||
await this.accountBillingClient.updateSubscriptionStorage(amount + (existing ?? 0));
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
await this.accountBillingClient.updateSubscriptionStorage(existing! - amount);
|
||||
}
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("adjustedStorage", amount),
|
||||
});
|
||||
|
||||
this.dialogRef.close("submitted");
|
||||
};
|
||||
|
||||
static open = (
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<AdjustAccountSubscriptionStorageDialogParams>,
|
||||
) =>
|
||||
dialogService.open<AdjustAccountSubscriptionStorageDialogResult>(
|
||||
AdjustAccountSubscriptionStorageDialogComponent,
|
||||
dialogConfig,
|
||||
);
|
||||
}
|
||||
@@ -478,13 +478,13 @@ describe("UpgradePaymentService", () => {
|
||||
describe("upgradeToPremium", () => {
|
||||
it("should call accountBillingClient to purchase premium subscription and refresh data", async () => {
|
||||
// Arrange
|
||||
mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue();
|
||||
mockAccountBillingClient.purchaseSubscription.mockResolvedValue();
|
||||
|
||||
// Act
|
||||
await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress);
|
||||
|
||||
// Assert
|
||||
expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith(
|
||||
expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith(
|
||||
mockTokenizedPaymentMethod,
|
||||
mockBillingAddress,
|
||||
);
|
||||
@@ -496,13 +496,13 @@ describe("UpgradePaymentService", () => {
|
||||
const accountCreditPaymentMethod: NonTokenizedPaymentMethod = {
|
||||
type: NonTokenizablePaymentMethods.accountCredit,
|
||||
};
|
||||
mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue();
|
||||
mockAccountBillingClient.purchaseSubscription.mockResolvedValue();
|
||||
|
||||
// Act
|
||||
await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress);
|
||||
|
||||
// Assert
|
||||
expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith(
|
||||
expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith(
|
||||
accountCreditPaymentMethod,
|
||||
mockBillingAddress,
|
||||
);
|
||||
|
||||
@@ -143,7 +143,7 @@ export class UpgradePaymentService {
|
||||
): Promise<void> {
|
||||
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
|
||||
|
||||
await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress);
|
||||
await this.accountBillingClient.purchaseSubscription(paymentMethod, billingAddress);
|
||||
|
||||
await this.refreshAndSync();
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
if (!this.selectedPlan()) {
|
||||
return {
|
||||
passwordManager: {
|
||||
seats: { name: "", cost: 0, quantity: 0 },
|
||||
seats: { translationKey: "", cost: 0, quantity: 0 },
|
||||
},
|
||||
cadence: "annually",
|
||||
estimatedTax: 0,
|
||||
@@ -152,7 +152,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
return {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
translationKey: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0,
|
||||
quantity: 1,
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component";
|
||||
import { UpdateLicenseDialogResult } from "../shared/update-license-types";
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -256,8 +257,8 @@ export class UserSubscriptionComponent implements OnInit {
|
||||
return null;
|
||||
}
|
||||
return discount.amountOff
|
||||
? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff }
|
||||
: { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff };
|
||||
? { type: DiscountTypes.AmountOff, value: discount.amountOff }
|
||||
: { type: DiscountTypes.PercentOff, value: discount.percentOff };
|
||||
}
|
||||
|
||||
get isSubscriptionActive(): boolean {
|
||||
|
||||
@@ -12614,5 +12614,11 @@
|
||||
},
|
||||
"storageFullDescription": {
|
||||
"message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage."
|
||||
},
|
||||
"whenYouRemoveStorage": {
|
||||
"message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill."
|
||||
},
|
||||
"youHavePremium": {
|
||||
"message": "You have Premium"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user