mirror of
https://github.com/bitwarden/browser
synced 2026-01-21 11:53:34 +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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
|
||||
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart } from "@bitwarden/pricing";
|
||||
import {
|
||||
BitwardenSubscription,
|
||||
Storage,
|
||||
SubscriptionStatus,
|
||||
SubscriptionStatuses,
|
||||
} from "@bitwarden/subscription";
|
||||
|
||||
export class BitwardenSubscriptionResponse extends BaseResponse {
|
||||
status: SubscriptionStatus;
|
||||
cart: Cart;
|
||||
storage: Storage;
|
||||
cancelAt?: Date;
|
||||
canceled?: Date;
|
||||
nextCharge?: Date;
|
||||
suspension?: Date;
|
||||
gracePeriod?: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const status = this.getResponseProperty("Status");
|
||||
if (
|
||||
status !== SubscriptionStatuses.Incomplete &&
|
||||
status !== SubscriptionStatuses.IncompleteExpired &&
|
||||
status !== SubscriptionStatuses.Trialing &&
|
||||
status !== SubscriptionStatuses.Active &&
|
||||
status !== SubscriptionStatuses.PastDue &&
|
||||
status !== SubscriptionStatuses.Canceled &&
|
||||
status !== SubscriptionStatuses.Unpaid
|
||||
) {
|
||||
throw new Error(`Failed to parse invalid subscription status: ${status}`);
|
||||
}
|
||||
this.status = status;
|
||||
|
||||
this.cart = new CartResponse(this.getResponseProperty("Cart"));
|
||||
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
|
||||
|
||||
const suspension = this.getResponseProperty("Suspension");
|
||||
if (suspension) {
|
||||
this.suspension = new Date(suspension);
|
||||
}
|
||||
|
||||
const gracePeriod = this.getResponseProperty("GracePeriod");
|
||||
if (gracePeriod) {
|
||||
this.gracePeriod = gracePeriod;
|
||||
}
|
||||
|
||||
const nextCharge = this.getResponseProperty("NextCharge");
|
||||
if (nextCharge) {
|
||||
this.nextCharge = new Date(nextCharge);
|
||||
}
|
||||
|
||||
const cancelAt = this.getResponseProperty("CancelAt");
|
||||
if (cancelAt) {
|
||||
this.cancelAt = new Date(cancelAt);
|
||||
}
|
||||
|
||||
const canceled = this.getResponseProperty("Canceled");
|
||||
if (canceled) {
|
||||
this.canceled = new Date(canceled);
|
||||
}
|
||||
}
|
||||
|
||||
toDomain = (): BitwardenSubscription => {
|
||||
switch (this.status) {
|
||||
case SubscriptionStatuses.Incomplete:
|
||||
case SubscriptionStatuses.IncompleteExpired:
|
||||
case SubscriptionStatuses.PastDue:
|
||||
case SubscriptionStatuses.Unpaid: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
suspension: this.suspension!,
|
||||
gracePeriod: this.gracePeriod!,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Trialing:
|
||||
case SubscriptionStatuses.Active: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
nextCharge: this.nextCharge!,
|
||||
cancelAt: this.cancelAt,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Canceled: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
canceled: this.canceled!,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
|
||||
|
||||
import { DiscountResponse } from "./discount.response";
|
||||
|
||||
export class CartItemResponse extends BaseResponse implements CartItem {
|
||||
translationKey: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.translationKey = this.getResponseProperty("TranslationKey");
|
||||
this.quantity = this.getResponseProperty("Quantity");
|
||||
this.cost = this.getResponseProperty("Cost");
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalStorage = this.getResponseProperty("AdditionalStorage");
|
||||
if (additionalStorage) {
|
||||
this.additionalStorage = new CartItemResponse(additionalStorage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SecretsManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
|
||||
if (additionalServiceAccounts) {
|
||||
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CartResponse extends BaseResponse implements Cart {
|
||||
passwordManager: {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
};
|
||||
cadence: SubscriptionCadence;
|
||||
discount?: Discount;
|
||||
estimatedTax: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.passwordManager = new PasswordManagerCartItemResponse(
|
||||
this.getResponseProperty("PasswordManager"),
|
||||
);
|
||||
|
||||
const secretsManager = this.getResponseProperty("SecretsManager");
|
||||
if (secretsManager) {
|
||||
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
|
||||
}
|
||||
|
||||
const cadence = this.getResponseProperty("Cadence");
|
||||
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
|
||||
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
|
||||
}
|
||||
this.cadence = cadence;
|
||||
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = new DiscountResponse(discount);
|
||||
}
|
||||
|
||||
this.estimatedTax = this.getResponseProperty("EstimatedTax");
|
||||
}
|
||||
}
|
||||
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
export class DiscountResponse extends BaseResponse implements Discount {
|
||||
type: DiscountType;
|
||||
value: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const type = this.getResponseProperty("Type");
|
||||
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
|
||||
throw new Error(`Failed to parse invalid discount type: ${type}`);
|
||||
}
|
||||
this.type = type;
|
||||
this.value = this.getResponseProperty("Value");
|
||||
}
|
||||
}
|
||||
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Storage } from "@bitwarden/subscription";
|
||||
|
||||
export class StorageResponse extends BaseResponse implements Storage {
|
||||
available: number;
|
||||
used: number;
|
||||
readableUsed: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.available = this.getResponseProperty("Available");
|
||||
this.used = this.getResponseProperty("Used");
|
||||
this.readableUsed = this.getResponseProperty("ReadableUsed");
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export enum FeatureFlag {
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
PM26462_Milestone_3 = "pm-26462-milestone-3",
|
||||
PM23341_Milestone_2 = "pm-23341-milestone-2",
|
||||
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
|
||||
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -137,6 +139,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
[FeatureFlag.PM23341_Milestone_2]: FALSE,
|
||||
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
|
||||
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
|
||||
@@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new PaymentResponse(r);
|
||||
}
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
postReinstatePremium(): Promise<any> {
|
||||
return this.send("POST", "/accounts/reinstate-premium", null, true, false);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="tw-flex-1">
|
||||
@let passwordManagerSeats = cart.passwordManager.seats;
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x
|
||||
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x
|
||||
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
@@ -63,7 +63,7 @@
|
||||
<div id="additional-storage" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x
|
||||
{{ additionalStorage.cost | currency: "USD" : "symbol" }} /
|
||||
{{ term }}
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@
|
||||
<!-- Secrets Manager Members -->
|
||||
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x
|
||||
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x
|
||||
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/ {{ term }}
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalServiceAccounts.quantity }}
|
||||
{{ additionalServiceAccounts.name | i18n }} x
|
||||
{{ additionalServiceAccounts.translationKey | i18n }} x
|
||||
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
|
||||
@@ -67,7 +67,7 @@ The component uses the following Cart and CartItem data structures:
|
||||
|
||||
```typescript
|
||||
export type CartItem = {
|
||||
name: string; // Display name for i18n lookup
|
||||
translationKey: string; // Translation key for i18n lookup
|
||||
quantity: number; // Number of items
|
||||
cost: number; // Cost per item
|
||||
discount?: Discount; // Optional item-level discount
|
||||
@@ -92,7 +92,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
|
||||
|
||||
export type Discount = {
|
||||
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
|
||||
active: boolean; // Whether discount is currently applied
|
||||
value: number; // Dollar amount or percentage (20 for 20%)
|
||||
};
|
||||
```
|
||||
@@ -108,7 +107,7 @@ The cart summary component provides flexibility through its structured Cart inpu
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
@@ -124,12 +123,12 @@ The cart summary component provides flexibility through its structured Cart inpu
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
@@ -145,14 +144,13 @@ The cart summary component provides flexibility through its structured Cart inpu
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
discount: {
|
||||
type: 'percent-off',
|
||||
active: true,
|
||||
value: 20
|
||||
},
|
||||
estimatedTax: 8.00
|
||||
@@ -188,7 +186,7 @@ Show cart with yearly subscription:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 500.00
|
||||
}
|
||||
},
|
||||
@@ -211,12 +209,12 @@ Show cart with password manager and additional storage:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
@@ -239,14 +237,14 @@ Show cart with password manager and secrets manager seats only:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
}
|
||||
},
|
||||
@@ -269,19 +267,19 @@ Show cart with password manager, secrets manager seats, and additional service a
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
translationKey: 'additionalServiceAccounts',
|
||||
cost: 6.00
|
||||
}
|
||||
},
|
||||
@@ -304,24 +302,24 @@ Show a cart with all available products:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
translationKey: 'additionalServiceAccounts',
|
||||
cost: 6.00
|
||||
}
|
||||
},
|
||||
@@ -344,19 +342,18 @@ Show cart with percentage-based discount:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
discount: {
|
||||
type: 'percent-off',
|
||||
active: true,
|
||||
value: 20
|
||||
},
|
||||
estimatedTax: 10.40
|
||||
@@ -377,21 +374,20 @@ Show cart with fixed amount discount:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
discount: {
|
||||
type: 'amount-off',
|
||||
active: true,
|
||||
value: 50.00
|
||||
},
|
||||
estimatedTax: 95.00
|
||||
@@ -431,7 +427,7 @@ Show cart with premium plan:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: 'premiumMembership',
|
||||
translationKey: 'premiumMembership',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
@@ -454,7 +450,7 @@ Show cart with families plan:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: 'familiesMembership',
|
||||
translationKey: 'familiesMembership',
|
||||
cost: 40.00
|
||||
}
|
||||
},
|
||||
@@ -488,8 +484,7 @@ Show cart with families plan:
|
||||
- Use consistent naming and formatting for cart items
|
||||
- Include clear quantity and unit pricing information
|
||||
- Ensure tax estimates are accurate and clearly labeled
|
||||
- Set `active: true` on discounts that should be displayed
|
||||
- Use localized strings for CartItem names (for i18n lookup)
|
||||
- Use valid translation keys for CartItem translationKey (for i18n lookup)
|
||||
- Provide complete Cart object with all required fields
|
||||
- Use "annually" or "monthly" for cadence (not "year" or "month")
|
||||
|
||||
|
||||
@@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "secretsManagerSeats",
|
||||
translationKey: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
},
|
||||
},
|
||||
@@ -270,7 +270,6 @@ describe("CartSummaryComponent", () => {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
@@ -296,7 +295,6 @@ describe("CartSummaryComponent", () => {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 50.0,
|
||||
},
|
||||
};
|
||||
@@ -315,33 +313,12 @@ describe("CartSummaryComponent", () => {
|
||||
expect(discountAmount.nativeElement.textContent).toContain("-$50.00");
|
||||
});
|
||||
|
||||
it("should not display discount when discount is inactive", () => {
|
||||
// Arrange
|
||||
const cartWithInactiveDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithInactiveDiscount);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act / Assert
|
||||
const discountSection = fixture.debugElement.query(
|
||||
By.css('[data-testid="discount-section"]'),
|
||||
);
|
||||
expect(discountSection).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should apply discount to total calculation", () => {
|
||||
// Arrange
|
||||
const cartWithDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
@@ -382,24 +359,24 @@ describe("CartSummaryComponent - Custom Header Template", () => {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "secretsManagerSeats",
|
||||
translationKey: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
@@ -98,12 +98,12 @@ export const WithAdditionalStorage: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -120,7 +120,7 @@ export const PasswordManagerYearlyCadence: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 500.0,
|
||||
},
|
||||
},
|
||||
@@ -137,14 +137,14 @@ export const SecretsManagerSeatsOnly: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
},
|
||||
@@ -161,19 +161,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
},
|
||||
},
|
||||
@@ -190,24 +190,24 @@ export const AllProducts: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
},
|
||||
},
|
||||
@@ -223,7 +223,7 @@ export const FamiliesPlan: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "familiesMembership",
|
||||
translationKey: "familiesMembership",
|
||||
cost: 40.0,
|
||||
},
|
||||
},
|
||||
@@ -239,7 +239,7 @@ export const PremiumPlan: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "premiumMembership",
|
||||
translationKey: "premiumMembership",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -255,7 +255,7 @@ export const CustomHeaderTemplate: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "premiumMembership",
|
||||
translationKey: "premiumMembership",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -296,19 +296,18 @@ export const WithPercentDiscount: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
estimatedTax: 10.4,
|
||||
@@ -322,21 +321,20 @@ export const WithAmountDiscount: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 50.0,
|
||||
},
|
||||
estimatedTax: 95.0,
|
||||
|
||||
@@ -116,7 +116,7 @@ export class CartSummaryComponent {
|
||||
*/
|
||||
readonly discountAmount = computed<number>(() => {
|
||||
const { discount } = this.cart();
|
||||
if (!discount || !discount.active) {
|
||||
if (!discount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export class CartSummaryComponent {
|
||||
*/
|
||||
readonly discountLabel = computed<string>(() => {
|
||||
const { discount } = this.cart();
|
||||
if (!discount || !discount.active) {
|
||||
if (!discount) {
|
||||
return "";
|
||||
}
|
||||
return getLabel(this.i18nService, discount);
|
||||
|
||||
@@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
|
||||
type Discount = {
|
||||
/** The type of discount */
|
||||
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
|
||||
/** Whether the discount is currently active */
|
||||
active: boolean;
|
||||
/** The discount value (percentage or amount depending on type) */
|
||||
value: number;
|
||||
};
|
||||
@@ -47,8 +45,7 @@ type Discount = {
|
||||
|
||||
## Behavior
|
||||
|
||||
- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is
|
||||
greater than 0.
|
||||
- The badge is only displayed when `discount` is provided and `value` is greater than 0.
|
||||
- For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1
|
||||
(e.g., `0.2` for 20%).
|
||||
- For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places.
|
||||
@@ -62,7 +59,3 @@ type Discount = {
|
||||
### Amount Discount
|
||||
|
||||
<Canvas of={DiscountBadgeStories.AmountDiscount} />
|
||||
|
||||
### Inactive Discount
|
||||
|
||||
<Canvas of={DiscountBadgeStories.InactiveDiscount} />
|
||||
|
||||
@@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => {
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when discount is inactive", () => {
|
||||
it("should return true when discount has percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.display()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with amount-off", () => {
|
||||
it("should return true when discount has amount-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return false when value is 0 (percent-off)", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return false when value is 0 (amount-off)", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return percentage text when type is percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should convert decimal value to percentage for percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0.15,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return amount text when type is amount-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -40,7 +40,6 @@ export const PercentDiscount: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
} as Discount,
|
||||
},
|
||||
@@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0.15, // 15% in decimal format
|
||||
} as Discount,
|
||||
},
|
||||
@@ -68,7 +66,6 @@ export const AmountDiscount: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
} as Discount,
|
||||
},
|
||||
@@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 99.99,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
export const InactiveDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -23,7 +23,7 @@ export class DiscountBadgeComponent {
|
||||
if (!discount) {
|
||||
return false;
|
||||
}
|
||||
return discount.active && discount.value > 0;
|
||||
return discount.value > 0;
|
||||
});
|
||||
|
||||
readonly label = computed<Maybe<string>>(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Discount } from "@bitwarden/pricing";
|
||||
|
||||
export type CartItem = {
|
||||
name: string;
|
||||
translationKey: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
|
||||
@@ -9,7 +9,6 @@ export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes];
|
||||
|
||||
export type Discount = {
|
||||
type: DiscountType;
|
||||
active: boolean;
|
||||
value: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('download-license')"
|
||||
[disabled]="downloadLicenseDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.DownloadLicense)"
|
||||
>
|
||||
{{ "downloadLicense" | i18n }}
|
||||
</button>
|
||||
@@ -22,8 +22,8 @@
|
||||
bitButton
|
||||
buttonType="danger"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('cancel-subscription')"
|
||||
[disabled]="cancelSubscriptionDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.CancelSubscription)"
|
||||
>
|
||||
{{ "cancelSubscription" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,8 @@ subscription actions.
|
||||
- [Examples](#examples)
|
||||
- [Default](#default)
|
||||
- [Actions Disabled](#actions-disabled)
|
||||
- [Download License Disabled](#download-license-disabled)
|
||||
- [Cancel Subscription Disabled](#cancel-subscription-disabled)
|
||||
- [Features](#features)
|
||||
- [Do's and Don'ts](#dos-and-donts)
|
||||
- [Accessibility](#accessibility)
|
||||
@@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription";
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
| ----------------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. |
|
||||
| Input | Type | Description |
|
||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------- |
|
||||
| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. |
|
||||
| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. |
|
||||
|
||||
### Outputs
|
||||
|
||||
@@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations):
|
||||
|
||||
```html
|
||||
<billing-additional-options-card
|
||||
[callsToActionDisabled]="true"
|
||||
[downloadLicenseDisabled]="true"
|
||||
[cancelSubscriptionDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
|
||||
downloading the license or processing subscription cancellation.
|
||||
**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control
|
||||
button states during async operations like downloading the license or processing subscription
|
||||
cancellation.
|
||||
|
||||
### Download License Disabled
|
||||
|
||||
Component with only the download license button disabled:
|
||||
|
||||
<Canvas of={AdditionalOptionsCardStories.DownloadLicenseDisabled} />
|
||||
|
||||
```html
|
||||
<billing-additional-options-card
|
||||
[downloadLicenseDisabled]="true"
|
||||
[cancelSubscriptionDisabled]="false"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
### Cancel Subscription Disabled
|
||||
|
||||
Component with only the cancel subscription button disabled:
|
||||
|
||||
<Canvas of={AdditionalOptionsCardStories.CancelSubscriptionDisabled} />
|
||||
|
||||
```html
|
||||
<billing-additional-options-card
|
||||
[downloadLicenseDisabled]="false"
|
||||
[cancelSubscriptionDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
@@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation.
|
||||
|
||||
- Handle both `download-license` and `cancel-subscription` events in parent components
|
||||
- Show appropriate confirmation dialogs before executing destructive actions (cancel subscription)
|
||||
- Disable buttons or show loading states during async operations
|
||||
- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during
|
||||
operations
|
||||
- Provide clear user feedback after action completion
|
||||
- Consider adding additional safety measures for subscription cancellation
|
||||
- Control button states independently based on business logic
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
|
||||
@@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("callsToActionDisabled", () => {
|
||||
it("should disable both buttons when callsToActionDisabled is true", () => {
|
||||
fixture.componentRef.setInput("callsToActionDisabled", true);
|
||||
describe("button disabled states", () => {
|
||||
it("should enable both buttons by default", () => {
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable download license button when downloadLicenseDisabled is true", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => {
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should disable both buttons independently", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", true);
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
@@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => {
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable both buttons when callsToActionDisabled is false", () => {
|
||||
fixture.componentRef.setInput("callsToActionDisabled", false);
|
||||
it("should allow download enabled while cancel disabled", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", false);
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable both buttons by default", () => {
|
||||
it("should allow cancel enabled while download disabled", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", true);
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,23 @@ export const Default: Story = {
|
||||
export const ActionsDisabled: Story = {
|
||||
name: "Actions Disabled",
|
||||
args: {
|
||||
callsToActionDisabled: true,
|
||||
downloadLicenseDisabled: true,
|
||||
cancelSubscriptionDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DownloadLicenseDisabled: Story = {
|
||||
name: "Download License Disabled",
|
||||
args: {
|
||||
downloadLicenseDisabled: true,
|
||||
cancelSubscriptionDisabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const CancelSubscriptionDisabled: Story = {
|
||||
name: "Cancel Subscription Disabled",
|
||||
args: {
|
||||
downloadLicenseDisabled: false,
|
||||
cancelSubscriptionDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core
|
||||
import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription";
|
||||
export const AdditionalOptionsCardActions = {
|
||||
DownloadLicense: "download-license",
|
||||
CancelSubscription: "cancel-subscription",
|
||||
} as const;
|
||||
|
||||
export type AdditionalOptionsCardAction =
|
||||
(typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions];
|
||||
|
||||
@Component({
|
||||
selector: "billing-additional-options-card",
|
||||
@@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript
|
||||
imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe],
|
||||
})
|
||||
export class AdditionalOptionsCardComponent {
|
||||
readonly callsToActionDisabled = input<boolean>(false);
|
||||
readonly downloadLicenseDisabled = input<boolean>(false);
|
||||
readonly cancelSubscriptionDisabled = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<AdditionalOptionsCardAction>();
|
||||
|
||||
protected readonly actions = AdditionalOptionsCardActions;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('add-storage')"
|
||||
[disabled]="addStorageDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.AddStorage)"
|
||||
>
|
||||
{{ "addStorage" | i18n }}
|
||||
</button>
|
||||
@@ -30,8 +30,8 @@
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled() || !canRemoveStorage()"
|
||||
(click)="callToActionClicked.emit('remove-storage')"
|
||||
[disabled]="removeStorageDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.RemoveStorage)"
|
||||
>
|
||||
{{ "removeStorage" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -30,6 +30,8 @@ full).
|
||||
- [Large Storage Pool (1TB)](#large-storage-pool-1tb)
|
||||
- [Small Storage Pool (1GB)](#small-storage-pool-1gb)
|
||||
- [Actions Disabled](#actions-disabled)
|
||||
- [Add Storage Disabled](#add-storage-disabled)
|
||||
- [Remove Storage Disabled](#remove-storage-disabled)
|
||||
- [Features](#features)
|
||||
- [Do's and Don'ts](#dos-and-donts)
|
||||
- [Accessibility](#accessibility)
|
||||
@@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription";
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
| ----------------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| `storage` | `Storage` | **Required.** Storage data including available, used, and readable |
|
||||
| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. |
|
||||
| Input | Type | Description |
|
||||
| ----------------------- | --------- | ------------------------------------------------------------------------ |
|
||||
| `storage` | `Storage` | **Required.** Storage data including available, used, and readable |
|
||||
| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. |
|
||||
| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. |
|
||||
|
||||
### Outputs
|
||||
|
||||
@@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage:
|
||||
Key behaviors:
|
||||
|
||||
- Progress bar color changes from blue (primary) to red (danger) when full
|
||||
- Remove storage button is disabled when storage is full
|
||||
- Button disabled states are controlled independently via `addStorageDisabled` and
|
||||
`removeStorageDisabled` inputs
|
||||
- Title changes to "Storage full" when at capacity
|
||||
- Description provides context-specific messaging
|
||||
|
||||
@@ -123,7 +127,7 @@ Storage with no files uploaded:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 0,
|
||||
readableUsed: '0 GB'
|
||||
readableUsed: '0 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -141,7 +145,7 @@ Storage with partial usage (50%):
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB'
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 5,
|
||||
readableUsed: '5 GB'
|
||||
readableUsed: '5 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns
|
||||
red.
|
||||
**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled
|
||||
independently via the `addStorageDisabled` and `removeStorageDisabled` inputs.
|
||||
|
||||
### Low Usage (10%)
|
||||
|
||||
@@ -180,7 +184,7 @@ Minimal storage usage:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 0.5,
|
||||
readableUsed: '500 MB'
|
||||
readableUsed: '500 MB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -198,7 +202,7 @@ Substantial storage usage:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 3.75,
|
||||
readableUsed: '3.75 GB'
|
||||
readableUsed: '3.75 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -216,7 +220,7 @@ Storage approaching capacity:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 4.75,
|
||||
readableUsed: '4.75 GB'
|
||||
readableUsed: '4.75 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -234,7 +238,7 @@ Enterprise-level storage allocation:
|
||||
[storage]="{
|
||||
available: 1000,
|
||||
used: 734,
|
||||
readableUsed: '734 GB'
|
||||
readableUsed: '734 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -252,7 +256,7 @@ Minimal storage allocation:
|
||||
[storage]="{
|
||||
available: 1,
|
||||
used: 0.8,
|
||||
readableUsed: '800 MB'
|
||||
readableUsed: '800 MB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations):
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB'
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
[callsToActionDisabled]="true"
|
||||
[addStorageDisabled]="true"
|
||||
[removeStorageDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
|
||||
adding or removing storage.
|
||||
**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button
|
||||
states during async operations like adding or removing storage.
|
||||
|
||||
### Add Storage Disabled
|
||||
|
||||
Storage card with only the add button disabled:
|
||||
|
||||
<Canvas of={StorageCardStories.AddStorageDisabled} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
[addStorageDisabled]="true"
|
||||
[removeStorageDisabled]="false"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Remove Storage Disabled
|
||||
|
||||
Storage card with only the remove button disabled:
|
||||
|
||||
<Canvas of={StorageCardStories.RemoveStorageDisabled} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
[addStorageDisabled]="false"
|
||||
[removeStorageDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
@@ -304,13 +349,14 @@ adding or removing storage.
|
||||
- Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed`
|
||||
- Keep `used` value less than or equal to `available` under normal circumstances
|
||||
- Update storage data in real-time when user adds or removes storage
|
||||
- Disable UI interactions when storage operations are in progress
|
||||
- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations
|
||||
- Show loading states during async storage operations
|
||||
- Control button states independently based on business logic
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
- Omit the `readableUsed` field - it's required for display
|
||||
- Use inconsistent units between `available` and `used` (both should be in GB)
|
||||
- Omit the `readableUsed` field - it's required
|
||||
- Use inconsistent units between `available` and `used` (all should be in GB)
|
||||
- Allow negative values for storage amounts
|
||||
- Ignore the `callToActionClicked` events - they require handling
|
||||
- Display inaccurate or stale storage information
|
||||
|
||||
@@ -163,18 +163,6 @@ describe("StorageCardComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canRemoveStorage", () => {
|
||||
it("should return true when storage is not full", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
expect(component.canRemoveStorage()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when storage is full", () => {
|
||||
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
|
||||
expect(component.canRemoveStorage()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("button rendering", () => {
|
||||
it("should render both buttons", () => {
|
||||
setupComponent(baseStorage);
|
||||
@@ -182,25 +170,46 @@ describe("StorageCardComponent", () => {
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should enable remove button when storage is not full", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
it("should enable add button by default", () => {
|
||||
setupComponent(baseStorage);
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const addButton = buttons[0].nativeElement;
|
||||
expect(addButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable add button when addStorageDisabled is true", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const addButton = buttons[0];
|
||||
expect(addButton.attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable remove button by default", () => {
|
||||
setupComponent(baseStorage);
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const removeButton = buttons[1].nativeElement;
|
||||
expect(removeButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable remove button when storage is full", () => {
|
||||
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
|
||||
it("should disable remove button when removeStorageDisabled is true", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const removeButton = buttons[1];
|
||||
expect(removeButton.attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callsToActionDisabled", () => {
|
||||
it("should disable both buttons when callsToActionDisabled is true", () => {
|
||||
describe("independent button disabled states", () => {
|
||||
it("should disable both buttons independently", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("callsToActionDisabled", true);
|
||||
fixture.componentRef.setInput("addStorageDisabled", true);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
@@ -208,9 +217,10 @@ describe("StorageCardComponent", () => {
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
fixture.componentRef.setInput("callsToActionDisabled", false);
|
||||
it("should enable both buttons when both disabled inputs are false", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", false);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
@@ -218,15 +228,27 @@ describe("StorageCardComponent", () => {
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => {
|
||||
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
|
||||
fixture.componentRef.setInput("callsToActionDisabled", false);
|
||||
it("should allow add button enabled while remove button disabled", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", false);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should allow remove button enabled while add button disabled", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", true);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("button click events", () => {
|
||||
@@ -243,7 +265,7 @@ describe("StorageCardComponent", () => {
|
||||
});
|
||||
|
||||
it("should emit remove-storage action when remove button is clicked", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
setupComponent(baseStorage);
|
||||
|
||||
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
|
||||
|
||||
|
||||
@@ -143,6 +143,33 @@ export const ActionsDisabled: Story = {
|
||||
used: 2.5,
|
||||
readableUsed: "2.5 GB",
|
||||
} satisfies Storage,
|
||||
callsToActionDisabled: true,
|
||||
addStorageDisabled: true,
|
||||
removeStorageDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const AddStorageDisabled: Story = {
|
||||
name: "Add Storage Disabled",
|
||||
args: {
|
||||
storage: {
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: "2.5 GB",
|
||||
} satisfies Storage,
|
||||
addStorageDisabled: true,
|
||||
removeStorageDisabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const RemoveStorageDisabled: Story = {
|
||||
name: "Remove Storage Disabled",
|
||||
args: {
|
||||
storage: {
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: "2.5 GB",
|
||||
} satisfies Storage,
|
||||
addStorageDisabled: false,
|
||||
removeStorageDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { Storage } from "../../types/storage";
|
||||
|
||||
export type StorageCardAction = "add-storage" | "remove-storage";
|
||||
export const StorageCardActions = {
|
||||
AddStorage: "add-storage",
|
||||
RemoveStorage: "remove-storage",
|
||||
} as const;
|
||||
|
||||
export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions];
|
||||
|
||||
@Component({
|
||||
selector: "billing-storage-card",
|
||||
@@ -25,7 +30,8 @@ export class StorageCardComponent {
|
||||
|
||||
readonly storage = input.required<Storage>();
|
||||
|
||||
readonly callsToActionDisabled = input<boolean>(false);
|
||||
readonly addStorageDisabled = input<boolean>(false);
|
||||
readonly removeStorageDisabled = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<StorageCardAction>();
|
||||
|
||||
@@ -64,5 +70,5 @@ export class StorageCardComponent {
|
||||
return this.isFull() ? "danger" : "primary";
|
||||
});
|
||||
|
||||
readonly canRemoveStorage = computed<boolean>(() => !this.isFull());
|
||||
protected readonly actions = StorageCardActions;
|
||||
}
|
||||
|
||||
@@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub
|
||||
|
||||
### Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
| --------------------- | ---------------- | ---------------------------------------------------------- |
|
||||
| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout |
|
||||
| Output | Type | Description |
|
||||
| --------------------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout |
|
||||
|
||||
**PlanCardAction Type:**
|
||||
**SubscriptionCardAction Type:**
|
||||
|
||||
```typescript
|
||||
type PlanCardAction =
|
||||
type SubscriptionCardAction =
|
||||
| "contact-support"
|
||||
| "manage-invoices"
|
||||
| "reinstate-subscription"
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -103,7 +103,7 @@ export const Active: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export const Trial: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -212,7 +212,7 @@ export const Incomplete: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -239,7 +239,7 @@ export const IncompleteExpired: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -266,7 +266,7 @@ export const PastDue: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -293,7 +293,7 @@ export const PendingCancellation: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -320,7 +320,7 @@ export const Unpaid: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -346,7 +346,7 @@ export const Canceled: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -372,31 +372,30 @@ export const Enterprise: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 7,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 0.5,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 13,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 5,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 1,
|
||||
},
|
||||
},
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0.25,
|
||||
value: 25,
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 6.4,
|
||||
|
||||
@@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing";
|
||||
import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
export type PlanCardAction =
|
||||
| "contact-support"
|
||||
| "manage-invoices"
|
||||
| "reinstate-subscription"
|
||||
| "update-payment"
|
||||
| "upgrade-plan";
|
||||
export const SubscriptionCardActions = {
|
||||
ContactSupport: "contact-support",
|
||||
ManageInvoices: "manage-invoices",
|
||||
ReinstateSubscription: "reinstate-subscription",
|
||||
UpdatePayment: "update-payment",
|
||||
UpgradePlan: "upgrade-plan",
|
||||
} as const;
|
||||
|
||||
export type SubscriptionCardAction =
|
||||
(typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions];
|
||||
|
||||
type Badge = { text: string; variant: BadgeVariant };
|
||||
|
||||
@@ -33,7 +37,7 @@ type Callout = Maybe<{
|
||||
callsToAction?: {
|
||||
text: string;
|
||||
buttonType: ButtonType;
|
||||
action: PlanCardAction;
|
||||
action: SubscriptionCardAction;
|
||||
}[];
|
||||
}>;
|
||||
|
||||
@@ -64,7 +68,7 @@ export class SubscriptionCardComponent {
|
||||
|
||||
readonly showUpgradeButton = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<PlanCardAction>();
|
||||
readonly callToActionClicked = output<SubscriptionCardAction>();
|
||||
|
||||
readonly badge = computed<Badge>(() => {
|
||||
const subscription = this.subscription();
|
||||
@@ -136,12 +140,12 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("updatePayment"),
|
||||
buttonType: "unstyled",
|
||||
action: "update-payment",
|
||||
action: SubscriptionCardActions.UpdatePayment,
|
||||
},
|
||||
{
|
||||
text: this.i18nService.t("contactSupportShort"),
|
||||
buttonType: "unstyled",
|
||||
action: "contact-support",
|
||||
action: SubscriptionCardActions.ContactSupport,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -155,7 +159,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("contactSupportShort"),
|
||||
buttonType: "unstyled",
|
||||
action: "contact-support",
|
||||
action: SubscriptionCardActions.ContactSupport,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -172,7 +176,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("reinstateSubscription"),
|
||||
buttonType: "unstyled",
|
||||
action: "reinstate-subscription",
|
||||
action: SubscriptionCardActions.ReinstateSubscription,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -189,7 +193,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("upgradeNow"),
|
||||
buttonType: "unstyled",
|
||||
action: "upgrade-plan",
|
||||
action: SubscriptionCardActions.UpgradePlan,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -208,7 +212,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("manageInvoices"),
|
||||
buttonType: "unstyled",
|
||||
action: "manage-invoices",
|
||||
action: SubscriptionCardActions.ManageInvoices,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -225,7 +229,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("manageInvoices"),
|
||||
buttonType: "unstyled",
|
||||
action: "manage-invoices",
|
||||
action: SubscriptionCardActions.ManageInvoices,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ export const SubscriptionStatuses = {
|
||||
Unpaid: "unpaid",
|
||||
} as const;
|
||||
|
||||
export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses];
|
||||
|
||||
type HasCart = {
|
||||
cart: Cart;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const MAX_STORAGE_GB = 100;
|
||||
|
||||
export type Storage = {
|
||||
available: number;
|
||||
readableUsed: string;
|
||||
|
||||
Reference in New Issue
Block a user