[PM-29608] [PM-29609] Premium subscription redesign cards (#18145)

* refactor(pricing): misc

- Remove unused test file

* refactor(pricing): discount-badge.component

- Introduce new Discount union type
- Introduce Maybe type helper for T | null | undefined
- Use Discount type in the discount-badge.component
- Update the user-subscription.component to pass Discount type into the discount-badge.component
- Update spec, stories and mdx

* refactor(pricing): pricing-card.component

- Support changeDetection: ChangeDetectionStrategy.OnPush
- Update spec and mdx files

* refactor(pricing): cart-summary.component

- Introduce new Cart type
- Use Cart type as main input in cart-summary.component
- Support optional custom header template in cart-summary.component
- Support optional cart-level Discount type in cart-summary.component
- Update upgrade-payment.component to pass in new Cart type to cart-summary.component
- Update spec file, stories and mdx file

* feat(subscription): misc

- Remove unused test file
- Update jest.config.js
- Add test.setup.ts

* feat(subscription): subscription-card.component

- Add BitwardenSubscription type
- Add subscription-card.component
- Add translations
- Add spec file, stories and MDX file

* feat(subscription): storage-card.component

- Add standalone Storage type
- Add storage-card.component
- Add spec file, stories and MDX file

* feat(subscription): additional-options-card.component

- Add additional-options-card.component
- Add spec file, stories and MDX file

* fix(pricing): cart-summary.component.stories.ts lint

* fix(pricing): discount-badge.component.stories.ts lint

* fix(web): Resolve estimatedTax$ toSignal for use in cart on upgrade-payment.component

* feedback(design): Fix design issues

* Kyle's feedback

* Kyle's feedback

* cleanup: Use SubscriptionStatuses instead of string values

* feat: Add CTA disabling input to storage-card.component

* feat: Add CTA disabling input to additional-options-card.component
This commit is contained in:
Alex Morask 2026-01-07 10:54:32 -06:00 committed by GitHub
parent ba89a3dd70
commit 1f763f470a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 4627 additions and 609 deletions

View file

@ -12,6 +12,8 @@ const config: StorybookConfig = {
"../libs/dirt/card/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/pricing/src/**/*.mdx",
"../libs/pricing/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/subscription/src/**/*.mdx",
"../libs/subscription/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/tools/send/send-ui/src/**/*.mdx",
"../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/vault/src/**/*.mdx",

View file

@ -50,11 +50,7 @@
</section>
<section>
<billing-cart-summary
#cartSummaryComponent
[passwordManager]="passwordManager()"
[estimatedTax]="estimatedTax$ | async"
></billing-cart-summary>
<billing-cart-summary #cartSummaryComponent [cart]="cart()"></billing-cart-summary>
@if (isFamiliesPlan) {
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}

View file

@ -9,7 +9,7 @@ import {
signal,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import {
debounceTime,
@ -22,6 +22,7 @@ import {
combineLatest,
map,
shareReplay,
defer,
} from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
@ -35,7 +36,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { CartSummaryComponent } from "@bitwarden/pricing";
import { Cart, CartSummaryComponent } from "@bitwarden/pricing";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
@ -118,23 +119,48 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
protected readonly selectedPlan = signal<PlanDetails | null>(null);
protected readonly loading = signal(true);
protected readonly upgradeToMessage = signal("");
// Cart Summary data
protected readonly passwordManager = computed(() => {
if (!this.selectedPlan()) {
return { name: "", cost: 0, quantity: 0, cadence: "year" as const };
}
return {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan()!.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year" as const,
};
});
protected hasEnoughAccountCredit$!: Observable<boolean>;
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
protected estimatedTax$!: Observable<number>;
// Use defer to lazily create the observable when subscribed to
protected estimatedTax$ = defer(() =>
this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
debounceTime(1000),
switchMap(() => this.refreshSalesTax$()),
),
);
// Convert estimatedTax$ to signal for use in computed cart
protected readonly estimatedTax = toSignal(this.estimatedTax$, {
initialValue: this.INITIAL_TAX_VALUE,
});
// Cart Summary data
protected readonly cart = computed<Cart>(() => {
if (!this.selectedPlan()) {
return {
passwordManager: {
seats: { name: "", cost: 0, quantity: 0 },
},
cadence: "annually",
estimatedTax: 0,
};
}
return {
passwordManager: {
seats: {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0,
quantity: 1,
},
},
cadence: "annually",
estimatedTax: this.estimatedTax() ?? 0,
};
});
constructor(
private i18nService: I18nService,
@ -186,13 +212,6 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
});
this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
debounceTime(1000),
// Only proceed when form has required values
switchMap(() => this.refreshSalesTax$()),
);
this.loading.set(false);
}

View file

@ -65,7 +65,7 @@
}}
</span>
<billing-discount-badge
[discount]="getDiscountInfo(sub?.customerDiscount)"
[discount]="getDiscount(sub?.customerDiscount)"
></billing-discount-badge>
</div>
</ng-container>

View file

@ -17,7 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { DiscountInfo } from "@bitwarden/pricing";
import { Discount, DiscountTypes, Maybe } from "@bitwarden/pricing";
import {
AdjustStorageDialogComponent,
@ -251,15 +251,13 @@ export class UserSubscriptionComponent implements OnInit {
}
}
getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null {
getDiscount(discount: BillingCustomerDiscount | null): Maybe<Discount> {
if (!discount) {
return null;
}
return {
active: discount.active,
percentOff: discount.percentOff,
amountOff: discount.amountOff,
};
return discount.amountOff
? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff }
: { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff };
}
get isSubscriptionActive(): boolean {

View file

@ -1188,7 +1188,7 @@
"message": "Me"
},
"myItems": {
"message": "My items"
"message": "My Items"
},
"myVault": {
"message": "My vault"
@ -3275,7 +3275,7 @@
"nextChargeHeader": {
"message": "Next Charge"
},
"plan": {
"plan": {
"message": "Plan"
},
"details": {
@ -3673,9 +3673,6 @@
"defaultCollection": {
"message": "Default collection"
},
"myItems": {
"message": "My Items"
},
"getHelp": {
"message": "Get help"
},
@ -4500,7 +4497,6 @@
"updateBrowser": {
"message": "Update browser"
},
"generatingYourAccessIntelligence": {
"message": "Generating your Access Intelligence..."
},
@ -5888,22 +5884,22 @@
"message": "credential lifecycle",
"description": "This will be used as a hyperlink"
},
"organizationDataOwnershipWarningTitle":{
"organizationDataOwnershipWarningTitle": {
"message": "Are you sure you want to proceed?"
},
"organizationDataOwnershipWarning1":{
"organizationDataOwnershipWarning1": {
"message": "will remain accessible to members"
},
"organizationDataOwnershipWarning2":{
"organizationDataOwnershipWarning2": {
"message": "will not be automatically selected when creating new items"
},
"organizationDataOwnershipWarning3":{
"organizationDataOwnershipWarning3": {
"message": "cannot be managed from the Admin Console until the user is offboarded"
},
"organizationDataOwnershipWarningContentTop":{
"organizationDataOwnershipWarningContentTop": {
"message": "By turning this policy off, the default collection: "
},
"organizationDataOwnershipWarningContentBottom":{
"organizationDataOwnershipWarningContentBottom": {
"message": "Learn more about the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'"
},
@ -6027,7 +6023,7 @@
"uriMatchDetectionOptionsLabel": {
"message": "Default URI match detection"
},
"invalidUriMatchDefaultPolicySetting": {
"invalidUriMatchDefaultPolicySetting": {
"message": "Please select a valid URI match detection option.",
"description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection."
},
@ -8937,7 +8933,7 @@
}
},
"accessedSecret": {
"message": "Accessed secret $SECRET_ID$.",
"message": "Accessed secret $SECRET_ID$.",
"placeholders": {
"secret_id": {
"content": "$1",
@ -8945,7 +8941,7 @@
}
}
},
"editedSecretWithId": {
"editedSecretWithId": {
"message": "Edited a secret with identifier: $SECRET_ID$",
"placeholders": {
"secret_id": {
@ -8954,7 +8950,7 @@
}
}
},
"deletedSecretWithId": {
"deletedSecretWithId": {
"message": "Deleted a secret with identifier: $SECRET_ID$",
"placeholders": {
"secret_id": {
@ -8972,7 +8968,7 @@
}
}
},
"restoredSecretWithId": {
"restoredSecretWithId": {
"message": "Restored a secret with identifier: $SECRET_ID$",
"placeholders": {
"secret_id": {
@ -8981,7 +8977,7 @@
}
}
},
"createdSecretWithId": {
"createdSecretWithId": {
"message": "Created a new secret with identifier: $SECRET_ID$",
"placeholders": {
"secret_id": {
@ -8991,7 +8987,7 @@
}
},
"accessedProjectWithIdentifier": {
"message": "Accessed a project with identifier: $PROJECT_ID$.",
"message": "Accessed a project with identifier: $PROJECT_ID$.",
"placeholders": {
"project_id": {
"content": "$1",
@ -9000,7 +8996,7 @@
}
},
"nameUnavailableProjectDeleted": {
"message": "Deleted project Id: $PROJECT_ID$",
"message": "Deleted project Id: $PROJECT_ID$",
"placeholders": {
"project_id": {
"content": "$1",
@ -9009,7 +9005,7 @@
}
},
"nameUnavailableSecretDeleted": {
"message": "Deleted secret Id: $SECRET_ID$",
"message": "Deleted secret Id: $SECRET_ID$",
"placeholders": {
"secret_id": {
"content": "$1",
@ -9018,7 +9014,7 @@
}
},
"nameUnavailableServiceAccountDeleted": {
"message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$",
"message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$",
"placeholders": {
"service_account_id": {
"content": "$1",
@ -9026,7 +9022,7 @@
}
}
},
"editedProjectWithId": {
"editedProjectWithId": {
"message": "Edited a project with identifier: $PROJECT_ID$",
"placeholders": {
"project_id": {
@ -9105,7 +9101,7 @@
}
}
},
"deletedProjectWithId": {
"deletedProjectWithId": {
"message": "Deleted a project with identifier: $PROJECT_ID$",
"placeholders": {
"project_id": {
@ -9114,7 +9110,7 @@
}
}
},
"createdProjectWithId": {
"createdProjectWithId": {
"message": "Created a new project with identifier: $PROJECT_ID$",
"placeholders": {
"project_id": {
@ -9832,15 +9828,15 @@
"message": "Common formats",
"description": "Label indicating the most common import formats"
},
"uriMatchDefaultStrategyHint": {
"uriMatchDefaultStrategyHint": {
"message": "URI match detection is how Bitwarden identifies autofill suggestions.",
"description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item."
},
"regExAdvancedOptionWarning": {
"regExAdvancedOptionWarning": {
"message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.",
"description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy"
},
"startsWithAdvancedOptionWarning": {
"startsWithAdvancedOptionWarning": {
"message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.",
"description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy"
},
@ -9848,11 +9844,11 @@
"message": "More about match detection",
"description": "Link to match detection docs on warning dialog for advance match strategy"
},
"uriAdvancedOption":{
"uriAdvancedOption": {
"message": "Advanced options",
"description": "Advanced option placeholder for uri option component"
},
"warningCapitalized": {
"warningCapitalized": {
"message": "Warning",
"description": "Warning (should maintain locale-relevant capitalization)"
},
@ -12193,9 +12189,6 @@
"updateYourEncryptionSettings": {
"message": "Update your encryption settings"
},
"updateSettings": {
"message": "Update settings"
},
"algorithm": {
"message": "Algorithm"
},
@ -12266,7 +12259,7 @@
}
}
},
"removeMasterPasswordForOrgUserKeyConnector":{
"removeMasterPasswordForOrgUserKeyConnector": {
"message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain."
},
"continueWithLogIn": {
@ -12284,10 +12277,10 @@
"verifyYourOrganization": {
"message": "Verify your organization to log in"
},
"organizationVerified":{
"organizationVerified": {
"message": "Organization verified"
},
"domainVerified":{
"domainVerified": {
"message": "Domain verified"
},
"leaveOrganizationContent": {
@ -12421,7 +12414,7 @@
}
}
},
"howToManageMyVault": {
"howToManageMyVault": {
"message": "How do I manage my vault?"
},
"transferItemsToOrganizationTitle": {
@ -12451,7 +12444,7 @@
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
},
"youHaveBitwardenPremium": {
"youHaveBitwardenPremium": {
"message": "You have Bitwarden Premium"
},
"viewAndManagePremiumSubscription": {
@ -12469,7 +12462,7 @@
}
}
},
"uploadLicenseFile": {
"uploadLicenseFile": {
"message": "Upload license file"
},
"uploadYourLicenseFile": {
@ -12487,7 +12480,7 @@
}
}
},
"alreadyHaveSubscriptionQuestion": {
"alreadyHaveSubscriptionQuestion": {
"message": "Already have a subscription?"
},
"alreadyHaveSubscriptionSelfHostedMessage": {
@ -12496,7 +12489,81 @@
"viewAllPlans": {
"message": "View all plans"
},
"planDescPremium":{
"planDescPremium": {
"message": "Complete online security"
},
"updatePayment": {
"message": "Update payment"
},
"weCouldNotProcessYourPayment": {
"message": "We could not process your payment. Please update your payment method or contact the support team for assistance."
},
"yourSubscriptionHasExpired": {
"message": "Your subscription has expired. Please contact the support team for assistance."
},
"yourSubscriptionIsScheduledToCancel": {
"message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.",
"placeholders": {
"date": {
"content": "$1",
"example": "Dec. 22, 2025"
}
}
},
"premiumShareEvenMore": {
"message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise."
},
"youHaveAGracePeriod": {
"message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.",
"placeholders": {
"days": {
"content": "$1",
"example": "14"
},
"date": {
"content": "$2",
"example": "Dec. 22, 2025"
}
}
},
"manageInvoices": {
"message": "Manage invoices"
},
"yourNextChargeIsFor": {
"message": "Your next charge is for"
},
"dueOn": {
"message": "due on"
},
"yourSubscriptionWillBeSuspendedOn": {
"message": "Your subscription will be suspended on"
},
"yourSubscriptionWasSuspendedOn": {
"message": "Your subscription was suspended on"
},
"yourSubscriptionWillBeCanceledOn": {
"message": "Your subscription will be canceled on"
},
"yourSubscriptionWasCanceledOn": {
"message": "Your subscription was canceled on"
},
"storageFull": {
"message": "Storage full"
},
"storageUsedDescription": {
"message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.",
"placeholders": {
"used": {
"content": "$1",
"example": "1"
},
"available": {
"content": "$2",
"example": "5"
}
}
},
"storageFullDescription": {
"message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage."
}
}

View file

@ -1,21 +1,23 @@
@let passwordManager = this.passwordManager();
@let additionalStorage = this.additionalStorage();
@let secretsManager = this.secretsManager();
@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts;
@let cart = this.cart();
@let term = this.term();
<div class="tw-size-full">
<div class="tw-flex tw-items-center tw-pb-2">
<div class="tw-flex tw-items-center">
<h2
bitTypography="h4"
id="purchase-summary-heading"
class="!tw-m-0"
data-testid="purchase-summary-heading-total"
>
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
</h2>
<span bitTypography="h3">&nbsp;</span>
<span bitTypography="body1" class="tw-text-main">/ {{ passwordManager.cadence | i18n }}</span>
@if (this.header(); as header) {
<ng-container *ngTemplateOutlet="header; context: { total: total() }" />
} @else {
<h2
bitTypography="h4"
id="purchase-summary-heading"
class="!tw-m-0"
data-testid="purchase-summary-heading-total"
>
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
</h2>
<span bitTypography="h3">&nbsp;</span>
<span bitTypography="body1" class="tw-text-main">/ {{ term }}</span>
}
</div>
<button
type="button"
@ -42,26 +44,28 @@
<!-- Password Manager Members -->
<div id="password-manager-members" class="tw-flex tw-justify-between">
<div class="tw-flex-1">
@let passwordManagerSeats = cart.passwordManager.seats;
<div bitTypography="body1" class="tw-text-muted">
{{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x
{{ passwordManager.cost | currency: "USD" : "symbol" }}
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ passwordManager.cadence | i18n }}
{{ term }}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="password-manager-total">
{{ passwordManagerTotal() | currency: "USD" : "symbol" }}
{{ passwordManagerSeatsTotal() | currency: "USD" : "symbol" }}
</div>
</div>
<!-- Additional Storage -->
@let additionalStorage = cart.passwordManager.additionalStorage;
@if (additionalStorage) {
<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.cost | currency: "USD" : "symbol" }} /
{{ additionalStorage.cadence | i18n }}
{{ term }}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="additional-storage-total">
@ -72,7 +76,8 @@
</div>
<!-- Secrets Manager Section -->
@if (secretsManager) {
@let secretsManagerSeats = cart.secretsManager?.seats;
@if (secretsManagerSeats) {
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
<div class="tw-flex tw-justify-between">
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
@ -81,9 +86,9 @@
<!-- Secrets Manager Members -->
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
{{ secretsManager.seats.quantity }} {{ secretsManager.seats.name | i18n }} x
{{ secretsManager.seats.cost | currency: "USD" : "symbol" }}
/ {{ secretsManager.seats.cadence | i18n }}
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
</div>
<div
bitTypography="body1"
@ -95,6 +100,7 @@
</div>
<!-- Additional Service Accounts -->
@let additionalServiceAccounts = cart.secretsManager?.additionalServiceAccounts;
@if (additionalServiceAccounts) {
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
@ -102,7 +108,7 @@
{{ additionalServiceAccounts.name | i18n }} x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ additionalServiceAccounts.cadence | i18n }}
{{ term }}
</div>
<div
bitTypography="body1"
@ -116,6 +122,20 @@
</div>
}
<!-- Discount -->
@if (discountAmount() > 0) {
<div
id="discount-section"
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
data-testid="discount-section"
>
<h3 bitTypography="h5" class="tw-text-success-600">{{ discountLabel() }}</h3>
<div bitTypography="body1" class="tw-text-success-600" data-testid="discount-amount">
-{{ discountAmount() | currency: "USD" : "symbol" }}
</div>
</div>
}
<!-- Estimated Tax -->
<div
id="estimated-tax-section"
@ -131,7 +151,7 @@
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
{{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }}
{{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }}
</div>
</div>
</div>

View file

@ -25,8 +25,11 @@ behavior across Bitwarden applications.
- [With Secrets Manager](#with-secrets-manager)
- [With Secrets Manager and Additional Service Accounts](#with-secrets-manager-and-additional-service-accounts)
- [All Products](#all-products)
- [With Percent Discount](#with-percent-discount)
- [With Amount Discount](#with-amount-discount)
- [Custom Header Template](#custom-header-template)
- [Premium Plan](#premium-plan)
- [Family Plan](#family-plan)
- [Families Plan](#families-plan)
- [Features](#features)
- [Do's and Don'ts](#dos-and-donts)
- [Accessibility](#accessibility)
@ -34,32 +37,24 @@ behavior across Bitwarden applications.
## Usage
The cart summary component is designed to be used in checkout and subscription interfaces to display
order details, prices, and totals.
order details, prices, totals, and discounts.
```ts
import { CartSummaryComponent, LineItem } from "@bitwarden/pricing";
import { CartSummaryComponent, Cart } from "@bitwarden/pricing";
```
```html
<billing-cart-summary
[passwordManager]="passwordManagerItem"
[additionalStorage]="additionalStorageItem"
[secretsManager]="secretsManagerItems"
[estimatedTax]="taxAmount"
>
</billing-cart-summary>
<billing-cart-summary [cart]="cart"> </billing-cart-summary>
```
## API
### Inputs
| Input | Type | Description |
| ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------- |
| `passwordManager` | `LineItem` | **Required.** The Password Manager product line item |
| `additionalStorage` | `LineItem \| undefined` | **Optional.** Additional storage line item, if applicable |
| `secretsManager` | `{ seats: LineItem; additionalServiceAccounts?: LineItem } \| undefined` | **Optional.** Secrets Manager related line items, if applicable |
| `estimatedTax` | `number` | **Required.** Estimated tax amount |
| Input | Type | Description |
| -------- | ------------------------ | ------------------------------------------------------------------------------- |
| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence |
| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header |
### Events
@ -68,49 +63,100 @@ collapsing and expanding the view.
## Data Structure
The component uses the following LineItem data structure:
The component uses the following Cart and CartItem data structures:
```typescript
export type LineItem = {
export type CartItem = {
name: string; // Display name for i18n lookup
quantity: number; // Number of items
name: string; // Display name of the item
cost: number; // Cost of each item
cadence: "month" | "year"; // Billing period
cost: number; // Cost per item
discount?: Discount; // Optional item-level discount
};
export type Cart = {
passwordManager: {
seats: CartItem; // Required PM seats
additionalStorage?: CartItem; // Optional additional storage
};
secretsManager?: {
// Optional SM section
seats: CartItem; // SM seats
additionalServiceAccounts?: CartItem; // Optional service accounts
};
cadence: "annually" | "monthly"; // Billing period for entire cart
discount?: Discount; // Optional cart-level discount
estimatedTax: number; // Tax amount
};
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%)
};
```
## Flexibility
The cart summary component provides flexibility through its structured input properties:
The cart summary component provides flexibility through its structured Cart input:
```html
<!-- Basic usage with only Password Manager -->
<billing-cart-summary
[passwordManager]="{
quantity: 5,
name: 'members',
cost: 50.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
}
},
cadence: 'monthly',
estimatedTax: 9.60
}"
[estimatedTax]="9.60"
>
</billing-cart-summary>
<!-- With Additional Storage -->
<billing-cart-summary
[passwordManager]="{
quantity: 5,
name: 'members',
cost: 50.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'monthly',
estimatedTax: 12.00
}"
[additionalStorage]="{
quantity: 2,
name: 'additionalStorageGB',
cost: 10.00,
cadence: 'month'
>
</billing-cart-summary>
<!-- With Discount -->
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
}
},
cadence: 'monthly',
discount: {
type: 'percent-off',
active: true,
value: 20
},
estimatedTax: 8.00
}"
[estimatedTax]="12.00"
>
</billing-cart-summary>
```
@ -138,13 +184,17 @@ Show cart with yearly subscription:
```html
<billing-cart-summary
[passwordManager]="{
quantity: 5,
name: 'members',
cost: 500.00,
cadence: 'year'
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 500.00
}
},
cadence: 'annually',
estimatedTax: 120.00
}"
[estimatedTax]="120.00"
>
</billing-cart-summary>
```
@ -157,19 +207,22 @@ Show cart with password manager and additional storage:
```html
<billing-cart-summary
[passwordManager]="{
quantity: 5,
name: 'members',
cost: 50.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'monthly',
estimatedTax: 12.00
}"
[additionalStorage]="{
quantity: 2,
name: 'additionalStorageGB',
cost: 10.00,
cadence: 'month'
}"
[estimatedTax]="12.00"
>
</billing-cart-summary>
```
@ -182,21 +235,24 @@ Show cart with password manager and secrets manager seats only:
```html
<billing-cart-summary
[passwordManager]="{
quantity: 5,
name: 'members',
cost: 50.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
}
},
secretsManager: {
seats: {
quantity: 3,
name: 'members',
cost: 30.00
}
},
cadence: 'monthly',
estimatedTax: 16.00
}"
[secretsManager]="{
seats: {
quantity: 3,
name: 'members',
cost: 30.00,
cadence: 'month'
}
}"
[estimatedTax]="16.00"
>
</billing-cart-summary>
```
@ -209,27 +265,29 @@ Show cart with password manager, secrets manager seats, and additional service a
```html
<billing-cart-summary
[passwordManager]="{
quantity: 5,
name: 'members',
cost: 50.00,
cadence: 'month'
}"
[secretsManager]="{
seats: {
quantity: 3,
name: 'members',
cost: 30.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
}
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
cost: 6.00,
cadence: 'month'
}
secretsManager: {
seats: {
quantity: 3,
name: 'members',
cost: 30.00
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
cost: 6.00
}
},
cadence: 'monthly',
estimatedTax: 16.00
}"
[estimatedTax]="16.00"
>
</billing-cart-summary>
```
@ -242,51 +300,144 @@ Show a cart with all available products:
```html
<billing-cart-summary
[passwordManager]="{
quantity: 5,
name: 'members',
cost: 50.00,
cadence: 'month'
}"
[additionalStorage]="{
quantity: 2,
name: 'additionalStorageGB',
cost: 10.00,
cadence: 'month'
}"
[secretsManager]="{
seats: {
quantity: 3,
name: 'members',
cost: 30.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
cost: 10.00
}
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
cost: 6.00,
cadence: 'month'
}
secretsManager: {
seats: {
quantity: 3,
name: 'members',
cost: 30.00
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
cost: 6.00
}
},
cadence: 'monthly',
estimatedTax: 19.20
}"
[estimatedTax]="19.20"
>
</billing-cart-summary>
```
### With Percent Discount
Show cart with percentage-based discount:
<Canvas of={CartSummaryStories.WithPercentDiscount} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'monthly',
discount: {
type: 'percent-off',
active: true,
value: 20
},
estimatedTax: 10.40
}"
>
</billing-cart-summary>
```
### With Amount Discount
Show cart with fixed amount discount:
<Canvas of={CartSummaryStories.WithAmountDiscount} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
name: 'members',
cost: 50.00
}
},
secretsManager: {
seats: {
quantity: 3,
name: 'members',
cost: 30.00
}
},
cadence: 'annually',
discount: {
type: 'amount-off',
active: true,
value: 50.00
},
estimatedTax: 95.00
}"
>
</billing-cart-summary>
```
### Custom Header Template
Show cart with custom header template:
<Canvas of={CartSummaryStories.CustomHeaderTemplate} />
```html
<billing-cart-summary [cart]="cartData" [header]="customHeader">
<ng-template #customHeader let-total="total">
<div class="tw-flex tw-flex-col tw-gap-1">
<h3 bitTypography="h3" class="!tw-m-0 tw-text-primary">
Your Total: {{ total | currency: 'USD' : 'symbol' }}
</h3>
<p bitTypography="body2" class="!tw-m-0 tw-text-muted">Custom header with enhanced styling</p>
</div>
</ng-template>
</billing-cart-summary>
```
### Premium Plan
Show cart with premium plan:
<Canvas of={CartSummaryStories.PremiumPlan} />
```html
<billing-cart-summary
[passwordManager]="{
quantity: 1,
name: 'premiumMembership',
cost: 10.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 1,
name: 'premiumMembership',
cost: 10.00
}
},
cadence: 'annually',
estimatedTax: 2.71
}"
[estimatedTax]="2.71"
>
</billing-cart-summary>
```
@ -296,15 +447,20 @@ Show cart with premium plan:
Show cart with families plan:
<Canvas of={CartSummaryStories.FamiliesPlan} />
```html
<billing-cart-summary
[passwordManager]="{
quantity: 1,
name: 'familiesMembership',
cost: 40.00,
cadence: 'month'
[cart]="{
passwordManager: {
seats: {
quantity: 1,
name: 'familiesMembership',
cost: 40.00
}
},
cadence: 'annually',
estimatedTax: 4.67
}"
[estimatedTax]="4.67"
>
</billing-cart-summary>
```
@ -314,31 +470,37 @@ Show cart with families plan:
- **Collapsible Interface**: Users can toggle between a summary view showing only the total and a
detailed view showing all line items
- **Line Item Grouping**: Organizes items by product category (Password Manager, Secrets Manager)
- **Dynamic Calculations**: Automatically calculates and displays subtotals and totals using Angular
signals and computed values
- **Flexible Structure**: Accommodates different combinations of products and add-ons
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, taxes, and totals using
Angular signals and computed values
- **Discount Support**: Displays both percentage-based and fixed-amount discounts with green success
styling
- **Custom Header Templates**: Optional header input allows for custom header designs while
maintaining cart functionality
- **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts
- **Consistent Formatting**: Maintains uniform display of prices, quantities, and cadence
- **Modern Angular Patterns**: Uses `@let` to efficiently store and reuse signal values in the
template
- **Modern Angular Patterns**: Uses `@let` to efficiently store and reuse signal values, OnPush
change detection, and Angular 17+ control flow
## Do's and Don'ts
### ✅ Do
- Use consistent naming and formatting for line items
- Use consistent naming and formatting for cart items
- Include clear quantity and unit pricing information
- Ensure tax estimates are accurate and clearly labeled
- Maintain consistent cadence formats across related items
- Use the same cadence for all items within a single cart
- Use localized strings for LineItem names
- Set `active: true` on discounts that should be displayed
- Use localized strings for CartItem names (for i18n lookup)
- Provide complete Cart object with all required fields
- Use "annually" or "monthly" for cadence (not "year" or "month")
### ❌ Don't
- Mix monthly and yearly cadences within the same cart
- Omit required inputs (passwordManager, estimatedTax)
- Modify the component's internal calculations
- Omit required Cart fields (passwordManager.seats, cadence, estimatedTax)
- Use old "month"/"year" cadence values (use "monthly"/"annually")
- Modify the component's internal calculations or totals
- Use inconsistent formatting for monetary values
- Override the default styles and layout
- Override the default styles without considering accessibility
- Mix different cadences - the cart uses a single cadence for all items
## Accessibility

View file

@ -1,51 +1,49 @@
import { CurrencyPipe } from "@angular/common";
import { ChangeDetectionStrategy, Component, TemplateRef, viewChild } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing";
import { CartSummaryComponent, LineItem } from "./cart-summary.component";
import { Cart } from "../../types/cart";
describe("CartSummaryComponent", () => {
let component: CartSummaryComponent;
let fixture: ComponentFixture<CartSummaryComponent>;
const mockPasswordManager: LineItem = {
quantity: 5,
name: "members",
cost: 50,
cadence: "month",
};
const mockAdditionalStorage: LineItem = {
quantity: 2,
name: "additionalStorageGB",
cost: 10,
cadence: "month",
};
const mockSecretsManager = {
seats: {
quantity: 3,
name: "secretsManagerSeats",
cost: 30,
cadence: "month",
const mockCart: Cart = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 10,
},
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6,
cadence: "month",
secretsManager: {
seats: {
quantity: 3,
name: "secretsManagerSeats",
cost: 30,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6,
},
},
cadence: "monthly",
estimatedTax: 9.6,
};
const mockEstimatedTax = 9.6;
function setupComponent() {
// Set input values
fixture.componentRef.setInput("passwordManager", mockPasswordManager);
fixture.componentRef.setInput("additionalStorage", mockAdditionalStorage);
fixture.componentRef.setInput("secretsManager", mockSecretsManager);
fixture.componentRef.setInput("estimatedTax", mockEstimatedTax);
fixture.componentRef.setInput("cart", mockCart);
fixture.detectChanges();
}
@ -89,6 +87,8 @@ describe("CartSummaryComponent", () => {
return "Families membership";
case "premiumMembership":
return "Premium membership";
case "discount":
return "discount";
default:
return key;
}
@ -161,7 +161,9 @@ describe("CartSummaryComponent", () => {
// Arrange
const pmSection = fixture.debugElement.query(By.css('[id="password-manager"]'));
const pmHeading = pmSection.query(By.css("h3"));
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-muted"));
const pmLineItem = pmSection.query(
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
);
const pmTotal = pmSection.query(By.css("[data-testid='password-manager-total']"));
// Act/ Assert
@ -225,4 +227,258 @@ describe("CartSummaryComponent", () => {
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
describe("Default Header (without custom template)", () => {
it("should render default header when no custom template is provided", () => {
// Arrange / Act
const defaultHeader = fixture.debugElement.query(
By.css('[data-testid="purchase-summary-heading-total"]'),
);
// Assert
expect(defaultHeader).toBeTruthy();
expect(defaultHeader.nativeElement.textContent).toContain("Total:");
expect(defaultHeader.nativeElement.textContent).toContain("$381.60");
});
it("should display term (month/year) in default header", () => {
// Arrange / Act
const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-main"));
// Find the span that contains the term
const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/"));
// Assert
expect(termElement).toBeTruthy();
expect(termElement!.nativeElement.textContent.trim()).toBe("/ month");
});
});
describe("Discount Display", () => {
it("should not display discount section when no discount is present", () => {
// Arrange / Act
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
// Assert
expect(discountSection).toBeFalsy();
});
it("should display percent-off discount correctly", () => {
// Arrange
const cartWithDiscount: Cart = {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
},
};
fixture.componentRef.setInput("cart", cartWithDiscount);
fixture.detectChanges();
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
const discountLabel = discountSection.query(By.css("h3"));
const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]'));
// Act / Assert
expect(discountSection).toBeTruthy();
expect(discountLabel.nativeElement.textContent.trim()).toBe("20% discount");
// Subtotal = 250 + 20 + 90 + 12 = 372, 20% of 372 = 74.4
expect(discountAmount.nativeElement.textContent).toContain("-$74.40");
});
it("should display amount-off discount correctly", () => {
// Arrange
const cartWithDiscount: Cart = {
...mockCart,
discount: {
type: DiscountTypes.AmountOff,
active: true,
value: 50.0,
},
};
fixture.componentRef.setInput("cart", cartWithDiscount);
fixture.detectChanges();
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
const discountLabel = discountSection.query(By.css("h3"));
const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]'));
// Act / Assert
expect(discountSection).toBeTruthy();
expect(discountLabel.nativeElement.textContent.trim()).toBe("$50.00 discount");
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,
},
};
fixture.componentRef.setInput("cart", cartWithDiscount);
fixture.detectChanges();
// Subtotal = 372, discount = 74.4, tax = 9.6
// Total = 372 - 74.4 + 9.6 = 307.2
const expectedTotal = "$307.20";
const topTotal = fixture.debugElement.query(By.css("h2"));
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
// Act / Assert
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
});
describe("CartSummaryComponent - Custom Header Template", () => {
@Component({
template: `
<billing-cart-summary [cart]="cart" [header]="customHeader">
<ng-template #customHeader let-total="total">
<div data-testid="custom-header">
<h2>Custom Total: {{ total | currency: "USD" : "symbol" }}</h2>
</div>
</ng-template>
</billing-cart-summary>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CartSummaryComponent, CurrencyPipe],
})
class TestHostComponent {
readonly customHeaderTemplate =
viewChild.required<TemplateRef<{ total: number }>>("customHeader");
cart: Cart = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 10,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "secretsManagerSeats",
cost: 30,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6,
},
},
cadence: "monthly",
estimatedTax: 9.6,
};
}
let hostFixture: ComponentFixture<TestHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "year":
return "year";
case "members":
return "Members";
case "additionalStorageGB":
return "Additional storage GB";
case "additionalServiceAccountsV2":
return "Additional machine accounts";
case "secretsManagerSeats":
return "Secrets Manager seats";
case "passwordManager":
return "Password Manager";
case "secretsManager":
return "Secrets Manager";
case "additionalStorage":
return "Additional Storage";
case "estimatedTax":
return "Estimated tax";
case "total":
return "Total";
case "expandPurchaseDetails":
return "Expand purchase details";
case "collapsePurchaseDetails":
return "Collapse purchase details";
case "discount":
return "discount";
default:
return key;
}
},
},
},
],
}).compileComponents();
hostFixture = TestBed.createComponent(TestHostComponent);
hostFixture.detectChanges();
});
it("should render custom header template when provided", () => {
// Arrange / Act
const customHeader = hostFixture.debugElement.query(By.css('[data-testid="custom-header"]'));
const defaultHeader = hostFixture.debugElement.query(
By.css('[data-testid="purchase-summary-heading-total"]'),
);
// Assert
expect(customHeader).toBeTruthy();
expect(defaultHeader).toBeFalsy();
});
it("should pass correct total value to custom header template", () => {
// Arrange
const expectedTotal = "$381.60"; // 250 + 20 + 90 + 12 + 9.6
const customHeader = hostFixture.debugElement.query(By.css('[data-testid="custom-header"]'));
// Act / Assert
expect(customHeader.nativeElement.textContent).toContain("Custom Total:");
expect(customHeader.nativeElement.textContent).toContain(expectedTotal);
});
});

View file

@ -1,9 +1,12 @@
import { DatePipe } from "@angular/common";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IconButtonModule, TypographyModule } from "@bitwarden/components";
import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { CartSummaryComponent } from "./cart-summary.component";
import { Cart } from "../../types/cart";
export default {
title: "Billing/Cart Summary",
@ -11,9 +14,10 @@ export default {
description: "A summary of the items in the cart, including pricing details.",
decorators: [
moduleMetadata({
imports: [TypographyModule, IconButtonModule],
imports: [TypographyModule, IconButtonModule, I18nPipe],
// Return the same value for all keys for simplicity
providers: [
DatePipe,
{
provide: I18nService,
useValue: {
@ -49,6 +53,10 @@ export default {
return "Families membership";
case "premiumMembership":
return "Premium membership";
case "yourNextChargeIsFor":
return "Your next charge is for";
case "dueOn":
return "due on";
default:
return key;
}
@ -59,13 +67,17 @@ export default {
}),
],
args: {
passwordManager: {
quantity: 5,
name: "members",
cost: 50.0,
cadence: "month",
},
estimatedTax: 9.6,
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50.0,
},
},
cadence: "monthly",
estimatedTax: 9.6,
} satisfies Cart,
},
parameters: {
design: {
@ -76,116 +88,258 @@ export default {
} as Meta<CartSummaryComponent>;
type Story = StoryObj<CartSummaryComponent>;
export const Default: Story = {};
export const Default: Story = {
name: "Default (Password Manager Only)",
};
export const WithAdditionalStorage: Story = {
args: {
...Default.args,
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 10.0,
cadence: "month",
},
estimatedTax: 12.0,
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 10.0,
},
},
cadence: "monthly",
estimatedTax: 12.0,
} satisfies Cart,
},
};
export const PasswordManagerYearlyCadence: Story = {
name: "Password Manager (Annual Billing)",
args: {
passwordManager: {
quantity: 5,
name: "members",
cost: 500.0,
cadence: "year",
},
estimatedTax: 120.0,
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 500.0,
},
},
cadence: "annually",
estimatedTax: 120.0,
} satisfies Cart,
},
};
export const SecretsManagerSeatsOnly: Story = {
name: "With Secrets Manager Seats",
args: {
...Default.args,
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 30.0,
cadence: "month",
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50.0,
},
},
},
estimatedTax: 16.0,
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 30.0,
},
},
cadence: "monthly",
estimatedTax: 16.0,
} satisfies Cart,
},
};
export const SecretsManagerSeatsAndServiceAccounts: Story = {
name: "With Secrets Manager + Service Accounts",
args: {
...Default.args,
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 30.0,
cadence: "month",
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50.0,
},
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6.0,
cadence: "month",
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 30.0,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6.0,
},
},
},
estimatedTax: 16.0,
cadence: "monthly",
estimatedTax: 16.0,
} satisfies Cart,
},
};
export const AllProducts: Story = {
name: "All Products (Complete Cart)",
args: {
...Default.args,
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 10.0,
cadence: "month",
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 30.0,
cadence: "month",
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 10.0,
},
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6.0,
cadence: "month",
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 30.0,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6.0,
},
},
},
estimatedTax: 19.2,
cadence: "monthly",
estimatedTax: 19.2,
} satisfies Cart,
},
};
export const FamiliesPlan: Story = {
args: {
passwordManager: {
quantity: 1,
name: "familiesMembership",
cost: 40.0,
cadence: "year",
},
estimatedTax: 4.67,
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "familiesMembership",
cost: 40.0,
},
},
cadence: "annually",
estimatedTax: 4.67,
} satisfies Cart,
},
};
export const PremiumPlan: Story = {
args: {
passwordManager: {
quantity: 1,
name: "premiumMembership",
cost: 10.0,
cadence: "year",
},
estimatedTax: 2.71,
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "premiumMembership",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
} satisfies Cart,
},
};
export const CustomHeaderTemplate: Story = {
args: {
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "premiumMembership",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
} satisfies Cart,
},
render: (args) => ({
props: {
...args,
nextChargeDate: new Date("2025-06-04"),
},
template: `
<div>
<ng-template #customHeader let-total="total">
<h2
bitTypography="h4"
class="!tw-m-0"
id="cart-summary-header-custom"
data-test-id="cart-summary-header-custom"
>
{{ "yourNextChargeIsFor" | i18n }}
<span class="tw-font-bold">{{ total | currency: "USD" : "symbol" }} USD</span>
{{ "dueOn" | i18n }}
<span class="tw-font-bold">{{ nextChargeDate | date: "MMM. d, y" }}</span>
</h2>
</ng-template>
<billing-cart-summary [cart]="cart" [header]="customHeader" />
</div>
`,
}),
};
export const WithPercentDiscount: Story = {
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 10.0,
},
},
cadence: "monthly",
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
},
estimatedTax: 10.4,
} satisfies Cart,
},
};
export const WithAmountDiscount: Story = {
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50.0,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 30.0,
},
},
cadence: "annually",
discount: {
type: DiscountTypes.AmountOff,
active: true,
value: 50.0,
},
estimatedTax: 95.0,
} satisfies Cart,
},
};

View file

@ -1,76 +1,153 @@
import { CurrencyPipe } from "@angular/common";
import { Component, computed, input, signal } from "@angular/core";
import { CurrencyPipe, NgTemplateOutlet } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
TemplateRef,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { TypographyModule, IconButtonModule } from "@bitwarden/components";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IconButtonModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
export type LineItem = {
quantity: number;
name: string;
cost: number;
cadence: "month" | "year";
};
import { Cart } from "../../types/cart";
import { DiscountTypes, getLabel } from "../../types/discount";
/**
* A reusable UI-only component that displays a cart summary with line items.
* This component has no external dependencies and performs minimal logic -
* it only displays data and allows expanding/collapsing of line items.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "billing-cart-summary",
templateUrl: "./cart-summary.component.html",
imports: [TypographyModule, IconButtonModule, CurrencyPipe, I18nPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TypographyModule, IconButtonModule, CurrencyPipe, I18nPipe, NgTemplateOutlet],
})
export class CartSummaryComponent {
private i18nService = inject(I18nService);
// Required inputs
readonly passwordManager = input.required<LineItem>();
readonly additionalStorage = input<LineItem>();
readonly secretsManager = input<{ seats: LineItem; additionalServiceAccounts?: LineItem }>();
readonly estimatedTax = input.required<number>();
readonly cart = input.required<Cart>();
// Optional inputs
readonly header = input<TemplateRef<{ total: number }>>();
// UI state
readonly isExpanded = signal(true);
/**
* Calculates total for password manager line item
* Calculates total for Password Manager seats
*/
readonly passwordManagerTotal = computed<number>(() => {
return this.passwordManager().quantity * this.passwordManager().cost;
readonly passwordManagerSeatsTotal = computed<number>(() => {
const {
passwordManager: { seats },
} = this.cart();
return seats.quantity * seats.cost;
});
/**
* Calculates total for additional storage line item if present
* Calculates total for additional storage
*/
readonly additionalStorageTotal = computed<number>(() => {
const storage = this.additionalStorage();
return storage ? storage.quantity * storage.cost : 0;
const {
passwordManager: { additionalStorage },
} = this.cart();
if (!additionalStorage) {
return 0;
}
return additionalStorage.quantity * additionalStorage.cost;
});
/**
* Calculates total for secrets manager seats if present
* Calculates total for Secrets Manager seats
*/
readonly secretsManagerSeatsTotal = computed<number>(() => {
const sm = this.secretsManager();
return sm?.seats ? sm.seats.quantity * sm.seats.cost : 0;
const { secretsManager } = this.cart();
if (!secretsManager) {
return 0;
}
return secretsManager.seats.quantity * secretsManager.seats.cost;
});
/**
* Calculates total for secrets manager service accounts if present
*/
readonly additionalServiceAccountsTotal = computed<number>(() => {
const sm = this.secretsManager();
return sm?.additionalServiceAccounts
? sm.additionalServiceAccounts.quantity * sm.additionalServiceAccounts.cost
: 0;
const { secretsManager } = this.cart();
if (!secretsManager || !secretsManager.additionalServiceAccounts) {
return 0;
}
return (
secretsManager.additionalServiceAccounts.quantity *
secretsManager.additionalServiceAccounts.cost
);
});
readonly estimatedTax = computed<number>(() => this.cart().estimatedTax);
readonly term = computed<string>(() => {
const { cadence } = this.cart();
switch (cadence) {
case "annually":
return this.i18nService.t("year");
case "monthly":
return this.i18nService.t("month");
}
});
/**
* Calculates the total of all line items
* Calculates the subtotal before discount and tax
*/
readonly total = computed<number>(() => this.getTotalCost());
readonly subtotal = computed<number>(
() =>
this.passwordManagerSeatsTotal() +
this.additionalStorageTotal() +
this.secretsManagerSeatsTotal() +
this.additionalServiceAccountsTotal(),
);
/**
* Calculates the discount amount based on the cart discount
*/
readonly discountAmount = computed<number>(() => {
const { discount } = this.cart();
if (!discount || !discount.active) {
return 0;
}
const subtotal = this.subtotal();
switch (discount.type) {
case DiscountTypes.PercentOff: {
const percentage = discount.value < 1 ? discount.value : discount.value / 100;
return subtotal * percentage;
}
case DiscountTypes.AmountOff:
return discount.value;
}
});
/**
* Gets the discount label for display
*/
readonly discountLabel = computed<string>(() => {
const { discount } = this.cart();
if (!discount || !discount.active) {
return "";
}
return getLabel(this.i18nService, discount);
});
/**
* Calculates the total of all line items including discount and tax
*/
readonly total = computed<number>(
() => this.subtotal() - this.discountAmount() + this.estimatedTax(),
);
/**
* Observable of computed total value
@ -83,18 +160,4 @@ export class CartSummaryComponent {
toggleExpanded(): void {
this.isExpanded.update((value: boolean) => !value);
}
/**
* Gets the total cost of all line items in the cart
* @returns The total cost as a number
*/
private getTotalCost(): number {
return (
this.passwordManagerTotal() +
this.additionalStorageTotal() +
this.secretsManagerSeatsTotal() +
this.additionalServiceAccountsTotal() +
this.estimatedTax()
);
}
}

View file

@ -1,10 +1,5 @@
<span
*ngIf="hasDiscount()"
bitBadge
variant="success"
class="tw-w-fit"
role="status"
[attr.aria-label]="getDiscountText()"
>
{{ getDiscountText() }}
</span>
@if (display()) {
<span bitBadge variant="success" class="tw-w-fit" role="status" [attr.aria-label]="label()">
{{ label() }}
</span>
}

View file

@ -5,8 +5,7 @@ import * as DiscountBadgeStories from "./discount-badge.component.stories";
# Discount Badge
A reusable UI component for displaying discount information (percentage or fixed amount) in a badge
format.
A reusable UI component for displaying a discount (percentage or fixed amount) in a badge format.
<Canvas of={DiscountBadgeStories.PercentDiscount} />
@ -16,41 +15,43 @@ The discount badge component is designed to be used in billing and subscription
display discount information.
```ts
import { DiscountBadgeComponent, DiscountInfo } from "@bitwarden/pricing";
import { DiscountBadgeComponent, Discount } from "@bitwarden/pricing";
```
```html
<billing-discount-badge [discount]="discountInfo"></billing-discount-badge>
<billing-discount-badge [discount]="discount"></billing-discount-badge>
```
## API
### Inputs
| Input | Type | Description |
| ---------- | ---------------------- | -------------------------------------------------------------------------------- |
| `discount` | `DiscountInfo \| null` | **Optional.** Discount information object. If null or inactive, badge is hidden. |
| Input | Type | Description |
| ---------- | ------------------------------- | -------------------------------------------------------------------------------- |
| `discount` | `Discount \| null \| undefined` | **Optional.** Discount object. If null, undefined, or inactive, badge is hidden. |
### DiscountInfo Interface
### Discount Type
```ts
interface DiscountInfo {
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;
/** Percentage discount (0-100 or 0-1 scale) */
percentOff?: number;
/** Fixed amount discount in the base currency */
amountOff?: number;
}
/** The discount value (percentage or amount depending on type) */
value: number;
};
```
## Behavior
- The badge is only displayed when `discount` is provided, `active` is `true`, and either
`percentOff` or `amountOff` is greater than 0.
- If both `percentOff` and `amountOff` are provided, `percentOff` takes precedence.
- Percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%).
- Amount values are formatted as currency (USD) with 2 decimal places.
- The badge is only displayed when `discount` is provided, `active` is `true`, 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.
## Examples

View file

@ -1,8 +1,7 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DiscountBadgeComponent } from "./discount-badge.component";
import { DiscountBadgeComponent, DiscountTypes } from "@bitwarden/pricing";
describe("DiscountBadgeComponent", () => {
let component: DiscountBadgeComponent;
@ -29,80 +28,104 @@ describe("DiscountBadgeComponent", () => {
expect(component).toBeTruthy();
});
describe("hasDiscount", () => {
describe("display", () => {
it("should return false when discount is null", () => {
fixture.componentRef.setInput("discount", null);
fixture.detectChanges();
expect(component.hasDiscount()).toBe(false);
expect(component.display()).toBe(false);
});
it("should return false when discount is inactive", () => {
fixture.componentRef.setInput("discount", { active: false, percentOff: 20 });
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: false,
value: 20,
});
fixture.detectChanges();
expect(component.hasDiscount()).toBe(false);
expect(component.display()).toBe(false);
});
it("should return true when discount is active with percentOff", () => {
fixture.componentRef.setInput("discount", { active: true, percentOff: 20 });
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.hasDiscount()).toBe(true);
expect(component.display()).toBe(true);
});
it("should return true when discount is active with amountOff", () => {
fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 });
it("should return true when discount is active with amount-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
active: true,
value: 10.99,
});
fixture.detectChanges();
expect(component.hasDiscount()).toBe(true);
expect(component.display()).toBe(true);
});
it("should return false when percentOff is 0", () => {
fixture.componentRef.setInput("discount", { active: true, percentOff: 0 });
it("should return false when value is 0 (percent-off)", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: true,
value: 0,
});
fixture.detectChanges();
expect(component.hasDiscount()).toBe(false);
expect(component.display()).toBe(false);
});
it("should return false when amountOff is 0", () => {
fixture.componentRef.setInput("discount", { active: true, amountOff: 0 });
it("should return false when value is 0 (amount-off)", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
active: true,
value: 0,
});
fixture.detectChanges();
expect(component.hasDiscount()).toBe(false);
expect(component.display()).toBe(false);
});
});
describe("getDiscountText", () => {
it("should return null when discount is null", () => {
describe("label", () => {
it("should return undefined when discount is null", () => {
fixture.componentRef.setInput("discount", null);
fixture.detectChanges();
expect(component.getDiscountText()).toBeNull();
expect(component.label()).toBeUndefined();
});
it("should return percentage text when percentOff is provided", () => {
fixture.componentRef.setInput("discount", { active: true, percentOff: 20 });
it("should return percentage text when type is percent-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
});
fixture.detectChanges();
const text = component.getDiscountText();
const text = component.label();
expect(text).toContain("20%");
expect(text).toContain("discount");
});
it("should convert decimal percentOff to percentage", () => {
fixture.componentRef.setInput("discount", { active: true, percentOff: 0.15 });
it("should convert decimal value to percentage for percent-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: true,
value: 0.15,
});
fixture.detectChanges();
const text = component.getDiscountText();
const text = component.label();
expect(text).toContain("15%");
});
it("should return amount text when amountOff is provided", () => {
fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 });
it("should return amount text when type is amount-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
active: true,
value: 10.99,
});
fixture.detectChanges();
const text = component.getDiscountText();
const text = component.label();
expect(text).toContain("$10.99");
expect(text).toContain("discount");
});
it("should prefer percentOff over amountOff", () => {
fixture.componentRef.setInput("discount", { active: true, percentOff: 25, amountOff: 10.99 });
fixture.detectChanges();
const text = component.getDiscountText();
expect(text).toContain("25%");
expect(text).not.toContain("$10.99");
});
});
});

View file

@ -2,8 +2,7 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BadgeModule } from "@bitwarden/components";
import { DiscountBadgeComponent, DiscountInfo } from "./discount-badge.component";
import { Discount, DiscountBadgeComponent, DiscountTypes } from "@bitwarden/pricing";
export default {
title: "Billing/Discount Badge",
@ -40,9 +39,10 @@ export const PercentDiscount: Story = {
}),
args: {
discount: {
type: DiscountTypes.PercentOff,
active: true,
percentOff: 20,
} as DiscountInfo,
value: 20,
} as Discount,
},
};
@ -53,9 +53,10 @@ export const PercentDiscountDecimal: Story = {
}),
args: {
discount: {
type: DiscountTypes.PercentOff,
active: true,
percentOff: 0.15, // 15% in decimal format
} as DiscountInfo,
value: 0.15, // 15% in decimal format
} as Discount,
},
};
@ -66,9 +67,10 @@ export const AmountDiscount: Story = {
}),
args: {
discount: {
type: DiscountTypes.AmountOff,
active: true,
amountOff: 10.99,
} as DiscountInfo,
value: 10.99,
} as Discount,
},
};
@ -79,9 +81,10 @@ export const LargeAmountDiscount: Story = {
}),
args: {
discount: {
type: DiscountTypes.AmountOff,
active: true,
amountOff: 99.99,
} as DiscountInfo,
value: 99.99,
} as Discount,
},
};
@ -92,9 +95,10 @@ export const InactiveDiscount: Story = {
}),
args: {
discount: {
type: DiscountTypes.PercentOff,
active: false,
percentOff: 20,
} as DiscountInfo,
value: 20,
} as Discount,
},
};
@ -107,17 +111,3 @@ export const NoDiscount: Story = {
discount: null,
},
};
export const PercentAndAmountPreferPercent: Story = {
render: (args) => ({
props: args,
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
}),
args: {
discount: {
active: true,
percentOff: 25,
amountOff: 10.99,
} as DiscountInfo,
},
};

View file

@ -1,70 +1,35 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BadgeModule } from "@bitwarden/components";
/**
* Interface for discount information that can be displayed in the discount badge.
* This is abstracted from the response class to avoid tight coupling.
*/
export interface DiscountInfo {
/** Whether the discount is currently active */
active: boolean;
/** Percentage discount (0-100 or 0-1 scale) */
percentOff?: number;
/** Fixed amount discount in the base currency */
amountOff?: number;
}
import { Discount, getLabel } from "../../types/discount";
import { Maybe } from "../../types/maybe";
@Component({
selector: "billing-discount-badge",
templateUrl: "./discount-badge.component.html",
standalone: true,
imports: [CommonModule, BadgeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, BadgeModule],
})
export class DiscountBadgeComponent {
readonly discount = input<DiscountInfo | null>(null);
private i18nService = inject(I18nService);
getDiscountText(): string | null {
const discount = this.discount();
if (!discount) {
return null;
}
readonly discount = input<Maybe<Discount>>(null);
if (discount.percentOff != null && discount.percentOff > 0) {
const percentValue =
discount.percentOff < 1 ? discount.percentOff * 100 : discount.percentOff;
return `${Math.round(percentValue)}% ${this.i18nService.t("discount")}`;
}
if (discount.amountOff != null && discount.amountOff > 0) {
const formattedAmount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(discount.amountOff);
return `${formattedAmount} ${this.i18nService.t("discount")}`;
}
return null;
}
hasDiscount(): boolean {
readonly display = computed<boolean>(() => {
const discount = this.discount();
if (!discount) {
return false;
}
if (!discount.active) {
return false;
return discount.active && discount.value > 0;
});
readonly label = computed<Maybe<string>>(() => {
const discount = this.discount();
if (discount) {
return getLabel(this.i18nService, discount);
}
return (
(discount.percentOff != null && discount.percentOff > 0) ||
(discount.amountOff != null && discount.amountOff > 0)
);
}
});
}

View file

@ -43,7 +43,7 @@
[buttonType]="buttonConfig.type"
[block]="true"
[disabled]="buttonConfig.disabled"
(click)="onButtonClick()"
(click)="buttonClick.emit()"
type="button"
>
@if (buttonConfig.icon?.position === "before") {

View file

@ -69,10 +69,6 @@ The title slot allows complete control over the heading element and styling:
<h2 slot="title" class="tw-m-0 tw-text-primary-600" bitTypography="h2">Featured Plan</h2>
```
| Output | Type | Description |
| ------------- | ------ | --------------------------------------- |
| `buttonClick` | `void` | Emitted when the plan button is clicked |
## Design
The component follows the Bitwarden design system with:

View file

@ -1,13 +1,10 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ButtonType, IconModule, TypographyModule } from "@bitwarden/components";
import { BadgeVariant, ButtonType, IconModule, TypographyModule } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { PricingCardComponent } from "./pricing-card.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: `
<billing-pricing-card
@ -18,22 +15,30 @@ import { PricingCardComponent } from "./pricing-card.component";
[activeBadge]="activeBadge"
(buttonClick)="onButtonClick()"
>
<ng-container [ngSwitch]="titleLevel">
<h1 *ngSwitchCase="'h1'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h1>
<h2 *ngSwitchCase="'h2'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h2>
<h3 *ngSwitchCase="'h3'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h3>
<h4 *ngSwitchCase="'h4'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h4>
<h5 *ngSwitchCase="'h5'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h5>
<h6 *ngSwitchCase="'h6'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h6>
</ng-container>
@switch (titleLevel) {
@case ("h1") {
<h1 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h1>
}
@case ("h2") {
<h2 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h2>
}
@case ("h3") {
<h3 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h3>
}
@case ("h4") {
<h4 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h4>
}
@case ("h5") {
<h5 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h5>
}
@case ("h6") {
<h6 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h6>
}
}
</billing-pricing-card>
`,
imports: [PricingCardComponent, CommonModule, TypographyModule],
imports: [PricingCardComponent, TypographyModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestHostComponent {
titleText = "Test Plan";
@ -48,7 +53,7 @@ class TestHostComponent {
};
features = ["Feature 1", "Feature 2", "Feature 3"];
titleLevel: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" = "h3";
activeBadge: { text: string; variant?: string } | undefined = undefined;
activeBadge: { text: string; variant?: BadgeVariant } | undefined = undefined;
onButtonClick() {
// Test method
@ -186,11 +191,10 @@ describe("PricingCardComponent", () => {
it("should have proper layout structure with flexbox", () => {
hostFixture.detectChanges();
const compiled = hostFixture.nativeElement;
const cardContainer = compiled.querySelector("div");
const cardContainer = compiled.querySelector("bit-card");
expect(cardContainer.classList).toContain("tw-flex");
expect(cardContainer.classList).toContain("tw-flex-col");
expect(cardContainer.classList).toContain("tw-size-full");
expect(cardContainer.classList).not.toContain("tw-block"); // Should not have conflicting display property
});
});

View file

@ -1,5 +1,5 @@
import { CurrencyPipe } from "@angular/common";
import { Component, EventEmitter, input, Output } from "@angular/core";
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import {
BadgeModule,
@ -16,11 +16,10 @@ import {
* This component has no external dependencies and performs no logic - it only displays data
* and emits events when the button is clicked.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "billing-pricing-card",
templateUrl: "./pricing-card.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, CardComponent],
})
export class PricingCardComponent {
@ -39,14 +38,5 @@ export class PricingCardComponent {
readonly features = input<string[]>();
readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() buttonClick = new EventEmitter<void>();
/**
* Handles button click events and emits the buttonClick event
*/
onButtonClick(): void {
this.buttonClick.emit();
}
readonly buttonClick = output<void>();
}

View file

@ -2,3 +2,8 @@
export * from "./components/pricing-card/pricing-card.component";
export * from "./components/cart-summary/cart-summary.component";
export * from "./components/discount-badge/discount-badge.component";
// Types
export * from "./types/cart";
export * from "./types/discount";
export * from "./types/maybe";

View file

@ -1,8 +0,0 @@
import * as lib from "./index";
describe("pricing", () => {
// This test will fail until something is exported from index.ts
it("should work", () => {
expect(lib).toBeDefined();
});
});

View file

@ -0,0 +1,22 @@
import { Discount } from "@bitwarden/pricing";
export type CartItem = {
name: string;
quantity: number;
cost: number;
discount?: Discount;
};
export type Cart = {
passwordManager: {
seats: CartItem;
additionalStorage?: CartItem;
};
secretsManager?: {
seats: CartItem;
additionalServiceAccounts?: CartItem;
};
cadence: "annually" | "monthly";
discount?: Discount;
estimatedTax: number;
};

View file

@ -0,0 +1,32 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
export const DiscountTypes = {
AmountOff: "amount-off",
PercentOff: "percent-off",
} as const;
export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes];
export type Discount = {
type: DiscountType;
active: boolean;
value: number;
};
export const getLabel = (i18nService: I18nService, discount: Discount): string => {
switch (discount.type) {
case DiscountTypes.AmountOff: {
const formattedAmount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(discount.value);
return `${formattedAmount} ${i18nService.t("discount")}`;
}
case DiscountTypes.PercentOff: {
const percentValue = discount.value < 1 ? discount.value * 100 : discount.value;
return `${Math.round(percentValue)}% ${i18nService.t("discount")}`;
}
}
};

View file

@ -0,0 +1 @@
export type Maybe<T> = T | null | undefined;

View file

@ -1,10 +1,16 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../../tsconfig.base");
const sharedConfig = require("../../libs/shared/jest.config.angular");
/** @type {import('jest').Config} */
module.exports = {
displayName: "subscription",
preset: "../../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
...sharedConfig,
displayName: "libs/subscription tests",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
coverageDirectory: "../../coverage/libs/subscription",
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/../../",
}),
};

View file

@ -0,0 +1,31 @@
<bit-card class="tw-size-full tw-flex tw-flex-col">
<!-- Title -->
<h3 bitTypography="h3" class="tw-m-0 tw-mb-4">{{ "additionalOptions" | i18n }}</h3>
<!-- Description -->
<p bitTypography="body1" class="tw-m-0 tw-mb-4 tw-text-main">
{{ "additionalOptionsDesc" | i18n }}
</p>
<!-- Action Buttons -->
<div class="tw-flex tw-gap-4">
<button
bitButton
buttonType="secondary"
type="button"
[disabled]="callsToActionDisabled()"
(click)="callToActionClicked.emit('download-license')"
>
{{ "downloadLicense" | i18n }}
</button>
<button
bitButton
buttonType="danger"
type="button"
[disabled]="callsToActionDisabled()"
(click)="callToActionClicked.emit('cancel-subscription')"
>
{{ "cancelSubscription" | i18n }}
</button>
</div>
</bit-card>

View file

@ -0,0 +1,159 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
import * as AdditionalOptionsCardStories from "./additional-options-card.component.stories";
<Meta of={AdditionalOptionsCardStories} />
# Additional Options Card
A UI component for displaying additional subscription management options with action buttons for
downloading license and canceling subscription. The component provides quick access to important
subscription actions.
<Canvas of={AdditionalOptionsCardStories.Default} />
## Table of Contents
- [Usage](#usage)
- [API](#api)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [Design](#design)
- [Examples](#examples)
- [Default](#default)
- [Actions Disabled](#actions-disabled)
- [Features](#features)
- [Do's and Don'ts](#dos-and-donts)
- [Accessibility](#accessibility)
## Usage
The additional options card component displays important subscription management actions on billing
pages and subscription dashboards. It provides quick access to download license and cancel
subscription actions.
```ts
import { AdditionalOptionsCardComponent } from "@bitwarden/subscription";
```
```html
<billing-additional-options-card (callToActionClicked)="handleAction($event)">
</billing-additional-options-card>
```
## API
### Inputs
| Input | Type | Description |
| ----------------------- | --------- | ---------------------------------------------------------------------- |
| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. |
### Outputs
| Output | Type | Description |
| --------------------- | ----------------------------- | ------------------------------------------- |
| `callToActionClicked` | `AdditionalOptionsCardAction` | Emitted when a user clicks an action button |
**AdditionalOptionsCardAction Type:**
```typescript
type AdditionalOptionsCardAction = "download-license" | "cancel-subscription";
```
## Design
The component follows the Bitwarden design system with:
- **Simple Card Layout**: Clean card design with title and description
- **Action Buttons**: Two prominent buttons for key subscription actions
- **Modern Angular**: Standalone component with signal-based outputs
- **OnPush Change Detection**: Optimized performance
- **Typography**: Uses `bitTypography` directives for consistent text styling
- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling
- **Button Variants**: Secondary button for download, danger button for cancel
- **Internationalization**: All text uses i18n service for translation support
## Examples
### Default
Standard display with download license and cancel subscription buttons:
<Canvas of={AdditionalOptionsCardStories.Default} />
```html
<billing-additional-options-card (callToActionClicked)="handleAction($event)">
</billing-additional-options-card>
```
**Handler example:**
```typescript
handleAction(action: AdditionalOptionsCardAction) {
switch (action) {
case "download-license":
// Handle license download
break;
case "cancel-subscription":
// Handle subscription cancellation
break;
}
}
```
### Actions Disabled
Component with action buttons disabled (useful during async operations):
<Canvas of={AdditionalOptionsCardStories.ActionsDisabled} />
```html
<billing-additional-options-card
[callsToActionDisabled]="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.
## Features
- **Download License**: Provides quick access to download subscription license
- **Cancel Subscription**: Provides quick access to cancel subscription with danger styling
- **Event Emission**: Emits typed events for handling user actions
- **Internationalization**: All text uses i18n service for translation support
- **Type Safety**: Strong TypeScript typing for action events
- **Accessible**: Proper button semantics and keyboard navigation
## Do's and Don'ts
### ✅ Do
- 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
- Provide clear user feedback after action completion
- Consider adding additional safety measures for subscription cancellation
### ❌ Don't
- Ignore the `callToActionClicked` events - they require handling
- Execute subscription cancellation without user confirmation
- Display this component to users who don't have permission to perform these actions
- Allow multiple simultaneous action executions
- Forget to handle error cases when actions fail
## Accessibility
The component includes:
- **Semantic HTML**: Proper heading hierarchy with `<h3>` and `<p>` tags
- **Button Accessibility**: Proper `type="button"` attributes on all buttons
- **Button Variants**: Clear visual distinction between secondary and danger actions
- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation
- **Focus Management**: Clear focus indicators on interactive elements
- **Screen Reader Support**: Descriptive button text for all actions
- **Color Differentiation**: Danger button uses red color to indicate destructive action
- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes

View file

@ -0,0 +1,116 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { AdditionalOptionsCardComponent } from "@bitwarden/subscription";
describe("AdditionalOptionsCardComponent", () => {
let component: AdditionalOptionsCardComponent;
let fixture: ComponentFixture<AdditionalOptionsCardComponent>;
let i18nService: jest.Mocked<I18nService>;
beforeEach(async () => {
i18nService = {
t: jest.fn((key: string) => {
const translations: Record<string, string> = {
additionalOptions: "Additional options",
additionalOptionsDesc:
"For additional help in managing your subscription, please contact Customer Support.",
downloadLicense: "Download license",
cancelSubscription: "Cancel subscription",
};
return translations[key] || key;
}),
} as any;
await TestBed.configureTestingModule({
imports: [AdditionalOptionsCardComponent],
providers: [{ provide: I18nService, useValue: i18nService }],
}).compileComponents();
fixture = TestBed.createComponent(AdditionalOptionsCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("rendering", () => {
it("should display the title", () => {
const title = fixture.debugElement.query(By.css("h3"));
expect(title.nativeElement.textContent.trim()).toBe("Additional options");
});
it("should display the description", () => {
const description = fixture.debugElement.query(By.css("p"));
expect(description.nativeElement.textContent.trim()).toContain(
"For additional help in managing your subscription",
);
});
it("should render both action buttons", () => {
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons.length).toBe(2);
});
it("should render download license button with correct text", () => {
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].nativeElement.textContent.trim()).toBe("Download license");
});
it("should render cancel subscription button with correct text", () => {
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[1].nativeElement.textContent.trim()).toBe("Cancel subscription");
});
});
describe("callsToActionDisabled", () => {
it("should disable both buttons when callsToActionDisabled is true", () => {
fixture.componentRef.setInput("callsToActionDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
});
it("should enable both buttons when callsToActionDisabled is false", () => {
fixture.componentRef.setInput("callsToActionDisabled", false);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].nativeElement.disabled).toBe(false);
expect(buttons[1].nativeElement.disabled).toBe(false);
});
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);
});
});
describe("button click events", () => {
it("should emit download-license action when download button is clicked", () => {
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const buttons = fixture.debugElement.queryAll(By.css("button"));
buttons[0].triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("download-license");
});
it("should emit cancel-subscription action when cancel button is clicked", () => {
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const buttons = fixture.debugElement.queryAll(By.css("button"));
buttons[1].triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("cancel-subscription");
});
});
});

View file

@ -0,0 +1,49 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { AdditionalOptionsCardComponent } from "./additional-options-card.component";
export default {
title: "Billing/Additional Options Card",
component: AdditionalOptionsCardComponent,
description:
"Displays additional subscription management options with action buttons for downloading license and canceling subscription.",
decorators: [
moduleMetadata({
imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
const translations: Record<string, string> = {
additionalOptions: "Additional options",
additionalOptionsDesc:
"For additional help in managing your subscription, please contact Customer Support.",
downloadLicense: "Download license",
cancelSubscription: "Cancel subscription",
};
return translations[key] || key;
},
},
},
],
}),
],
} as Meta<AdditionalOptionsCardComponent>;
type Story = StoryObj<AdditionalOptionsCardComponent>;
export const Default: Story = {
args: {},
};
export const ActionsDisabled: Story = {
name: "Actions Disabled",
args: {
callsToActionDisabled: true,
},
};

View file

@ -0,0 +1,17 @@
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";
@Component({
selector: "billing-additional-options-card",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./additional-options-card.component.html",
imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe],
})
export class AdditionalOptionsCardComponent {
readonly callsToActionDisabled = input<boolean>(false);
readonly callToActionClicked = output<AdditionalOptionsCardAction>();
}

View file

@ -0,0 +1,39 @@
<bit-card class="tw-size-full tw-flex tw-flex-col">
<!-- Title and Description -->
<div class="tw-flex tw-flex-col tw-gap-3 tw-mb-4">
<h3 bitTypography="h3" class="tw-m-0">{{ title() }}</h3>
<p bitTypography="body1" class="tw-m-0 tw-text-main">{{ description() }}</p>
</div>
<!-- Progress Bar -->
<div class="tw-mb-4">
<bit-progress
[barWidth]="percentageUsed()"
[bgColor]="progressBarColor()"
[showText]="false"
size="small"
></bit-progress>
</div>
<!-- Action Buttons -->
<div class="tw-flex tw-gap-4">
<button
bitButton
buttonType="secondary"
type="button"
[disabled]="callsToActionDisabled()"
(click)="callToActionClicked.emit('add-storage')"
>
{{ "addStorage" | i18n }}
</button>
<button
bitButton
buttonType="secondary"
type="button"
[disabled]="callsToActionDisabled() || !canRemoveStorage()"
(click)="callToActionClicked.emit('remove-storage')"
>
{{ "removeStorage" | i18n }}
</button>
</div>
</bit-card>

View file

@ -0,0 +1,333 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
import * as StorageCardStories from "./storage-card.component.stories";
<Meta of={StorageCardStories} />
# Storage Card
A visual component for displaying encrypted file storage usage with a progress bar and action
buttons. The component dynamically adapts its appearance based on storage capacity (empty, used, or
full).
<Canvas of={StorageCardStories.Empty} />
## Table of Contents
- [Usage](#usage)
- [API](#api)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [Data Structure](#data-structure)
- [Storage States](#storage-states)
- [Design](#design)
- [Examples](#examples)
- [Empty](#empty)
- [Used](#used)
- [Full](#full)
- [Low Usage (10%)](#low-usage-10)
- [Medium Usage (75%)](#medium-usage-75)
- [Nearly Full (95%)](#nearly-full-95)
- [Large Storage Pool (1TB)](#large-storage-pool-1tb)
- [Small Storage Pool (1GB)](#small-storage-pool-1gb)
- [Actions Disabled](#actions-disabled)
- [Features](#features)
- [Do's and Don'ts](#dos-and-donts)
- [Accessibility](#accessibility)
## Usage
The storage card component displays storage usage information on billing pages, account management
interfaces, and subscription dashboards. It provides visual feedback through a progress bar and
action buttons for managing storage.
```ts
import { StorageCardComponent, Storage } from "@bitwarden/subscription";
```
```html
<billing-storage-card [storage]="storage" (callToActionClicked)="handleStorageAction($event)">
</billing-storage-card>
```
## API
### 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`. |
### Outputs
| Output | Type | Description |
| --------------------- | ------------------- | ------------------------------------------------------- |
| `callToActionClicked` | `StorageCardAction` | Emitted when a user clicks add or remove storage button |
**StorageCardAction Type:**
```typescript
type StorageCardAction = "add-storage" | "remove-storage";
```
## Data Structure
The component uses the `Storage` type:
```typescript
type Storage = {
available: number; // Total GB available
used: number; // GB used
readableUsed: string; // Formatted string (e.g., "2.5 GB")
};
```
## Storage States
The component automatically adapts its appearance based on storage usage:
- **Empty**: 0% used - Gray progress bar, "Storage" title, empty description
- **Used**: 1-99% used - Blue progress bar, "Storage" title, used description
- **Full**: 100% used - Red progress bar, "Storage full" title, full description with warning
Key behaviors:
- Progress bar color changes from blue (primary) to red (danger) when full
- Remove storage button is disabled when storage is full
- Title changes to "Storage full" when at capacity
- Description provides context-specific messaging
## Design
The component follows the Bitwarden design system with:
- **Visual Progress Bar**: Animated bar showing storage usage percentage
- **Responsive Colors**: Blue for normal usage, red for full capacity
- **Action Buttons**: Secondary button style for add/remove actions
- **Modern Angular**: Uses signal inputs (`input.required`) and `computed` signals
- **OnPush Change Detection**: Optimized performance
- **Typography**: Uses `bitTypography` directives for consistent text styling
- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling
- **Card Layout**: Wrapped in `bit-card` component with consistent spacing
## Examples
### Empty
Storage with no files uploaded:
<Canvas of={StorageCardStories.Empty} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 0,
readableUsed: '0 GB'
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Used
Storage with partial usage (50%):
<Canvas of={StorageCardStories.Used} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 2.5,
readableUsed: '2.5 GB'
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Full
Storage at full capacity with disabled remove button:
<Canvas of={StorageCardStories.Full} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 5,
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.
### Low Usage (10%)
Minimal storage usage:
<Canvas of={StorageCardStories.LowUsage} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 0.5,
readableUsed: '500 MB'
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Medium Usage (75%)
Substantial storage usage:
<Canvas of={StorageCardStories.MediumUsage} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 3.75,
readableUsed: '3.75 GB'
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Nearly Full (95%)
Storage approaching capacity:
<Canvas of={StorageCardStories.NearlyFull} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 4.75,
readableUsed: '4.75 GB'
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Large Storage Pool (1TB)
Enterprise-level storage allocation:
<Canvas of={StorageCardStories.LargeStorage} />
```html
<billing-storage-card
[storage]="{
available: 1000,
used: 734,
readableUsed: '734 GB'
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Small Storage Pool (1GB)
Minimal storage allocation:
<Canvas of={StorageCardStories.SmallStorage} />
```html
<billing-storage-card
[storage]="{
available: 1,
used: 0.8,
readableUsed: '800 MB'
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Actions Disabled
Storage card with action buttons disabled (useful during async operations):
<Canvas of={StorageCardStories.ActionsDisabled} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 2.5,
readableUsed: '2.5 GB'
}"
[callsToActionDisabled]="true"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
adding or removing storage.
## Features
- **Visual Progress Bar**: Animated progress indicator showing storage usage percentage
- **Dynamic Colors**: Blue (primary) for normal usage, red (danger) when full
- **Context-Aware Titles**: Changes from "Storage" to "Storage full" at capacity
- **Descriptive Messages**: Clear descriptions of current storage status
- **Action Buttons**: Add and remove storage with appropriate enabled/disabled states
- **Automatic Calculations**: Percentage computed from available and used values
- **Responsive Design**: Adapts to container width with flexible layout
- **Computed Signals**: Efficient reactive computations using Angular signals
- **Type Safety**: Strong TypeScript typing for storage data
- **Internationalization**: All text uses i18n service for translation support
- **Event Emission**: Typed events for handling user actions
## Do's and Don'ts
### ✅ Do
- Handle both `add-storage` and `remove-storage` events in parent components
- Provide accurate storage data with `available`, `used`, and `readableUsed` fields
- 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
- Show loading states during async storage operations
### ❌ Don't
- Omit the `readableUsed` field - it's required for display
- Use inconsistent units between `available` and `used` (both should be in GB)
- Allow negative values for storage amounts
- Ignore the `callToActionClicked` events - they require handling
- Display inaccurate or stale storage information
- Override progress bar colors without considering accessibility
- Show progress percentages greater than 100%
- Use this component for non-storage related progress indicators
## Accessibility
The component includes:
- **Semantic HTML**: Proper heading hierarchy with `<h3>` and `<p>` tags
- **Button Accessibility**: Proper `type="button"` attributes on all buttons
- **Disabled State**: Visual and functional disabled state for remove button when full
- **Color Contrast**: Sufficient contrast ratios for text and progress bar colors
- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation
- **Focus Management**: Clear focus indicators on interactive elements
- **Screen Reader Support**: Descriptive text for all storage states and actions
- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes
- **Visual Feedback**: Multiple indicators of state (color, text, disabled buttons)

View file

@ -0,0 +1,285 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Storage, StorageCardComponent } from "@bitwarden/subscription";
describe("StorageCardComponent", () => {
let component: StorageCardComponent;
let fixture: ComponentFixture<StorageCardComponent>;
let i18nService: jest.Mocked<I18nService>;
const baseStorage: Storage = {
available: 5,
used: 0,
readableUsed: "0 GB",
};
beforeEach(async () => {
i18nService = {
t: jest.fn((key: string, ...args: any[]) => {
const translations: Record<string, string> = {
storage: "Storage",
storageFull: "Storage full",
storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`,
storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`,
addStorage: "Add storage",
removeStorage: "Remove storage",
};
return translations[key] || key;
}),
} as any;
await TestBed.configureTestingModule({
imports: [StorageCardComponent],
providers: [{ provide: I18nService, useValue: i18nService }],
}).compileComponents();
fixture = TestBed.createComponent(StorageCardComponent);
component = fixture.componentInstance;
});
function setupComponent(storage: Storage) {
fixture.componentRef.setInput("storage", storage);
fixture.detectChanges();
}
it("should create", () => {
setupComponent(baseStorage);
expect(component).toBeTruthy();
});
describe("isEmpty", () => {
it("should return true when storage is empty", () => {
setupComponent({ ...baseStorage, used: 0 });
expect(component.isEmpty()).toBe(true);
});
it("should return false when storage is used", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.isEmpty()).toBe(false);
});
it("should return false when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.isEmpty()).toBe(false);
});
});
describe("isFull", () => {
it("should return false when storage is empty", () => {
setupComponent({ ...baseStorage, used: 0 });
expect(component.isFull()).toBe(false);
});
it("should return false when storage is partially used", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.isFull()).toBe(false);
});
it("should return true when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.isFull()).toBe(true);
});
it("should return true when used exceeds available", () => {
setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" });
expect(component.isFull()).toBe(true);
});
});
describe("percentageUsed", () => {
it("should return 0 when storage is empty", () => {
setupComponent({ ...baseStorage, used: 0 });
expect(component.percentageUsed()).toBe(0);
});
it("should return 50 when half of storage is used", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.percentageUsed()).toBe(50);
});
it("should return 100 when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.percentageUsed()).toBe(100);
});
it("should cap at 100 when used exceeds available", () => {
setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" });
expect(component.percentageUsed()).toBe(100);
});
it("should return 0 when available is 0", () => {
setupComponent({ available: 0, used: 0, readableUsed: "0 GB" });
expect(component.percentageUsed()).toBe(0);
});
});
describe("title", () => {
it("should display 'Storage' when storage is empty", () => {
setupComponent({ ...baseStorage, used: 0 });
expect(component.title()).toBe("Storage");
});
it("should display 'Storage' when storage is partially used", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.title()).toBe("Storage");
});
it("should display 'Storage full' when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.title()).toBe("Storage full");
});
});
describe("description", () => {
it("should display used description when storage is empty", () => {
setupComponent({ ...baseStorage, used: 0 });
expect(component.description()).toContain("You have used 0 GB out of 5 GB");
});
it("should display used description when storage is partially used", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.description()).toContain("You have used 2.5 GB out of 5 GB");
});
it("should display full description when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
const desc = component.description();
expect(desc).toContain("You have used all 5 GB");
expect(desc).toContain("To continue storing files, add more storage");
});
});
describe("progressBarColor", () => {
it("should return primary color when storage is not full", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.progressBarColor()).toBe("primary");
});
it("should return danger color when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.progressBarColor()).toBe("danger");
});
});
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);
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons.length).toBe(2);
});
it("should enable remove button when storage is not full", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
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" });
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", () => {
setupComponent(baseStorage);
fixture.componentRef.setInput("callsToActionDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
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);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].nativeElement.disabled).toBe(false);
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);
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");
});
});
describe("button click events", () => {
it("should emit add-storage action when add button is clicked", () => {
setupComponent(baseStorage);
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const buttons = fixture.debugElement.queryAll(By.css("button"));
buttons[0].triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("add-storage");
});
it("should emit remove-storage action when remove button is clicked", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const buttons = fixture.debugElement.queryAll(By.css("button"));
buttons[1].triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("remove-storage");
});
});
describe("progress bar rendering", () => {
it("should render bit-progress component when storage is empty", () => {
setupComponent({ ...baseStorage, used: 0 });
const progressBar = fixture.debugElement.query(By.css("bit-progress"));
expect(progressBar).toBeTruthy();
});
it("should pass correct barWidth to bit-progress when half storage is used", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.percentageUsed()).toBe(50);
});
it("should pass correct barWidth to bit-progress when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.percentageUsed()).toBe(100);
});
it("should pass primary color to bit-progress when storage is not full", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.progressBarColor()).toBe("primary");
});
it("should pass danger color to bit-progress when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.progressBarColor()).toBe("danger");
});
});
});

View file

@ -0,0 +1,148 @@
import { CommonModule } from "@angular/common";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
ButtonModule,
CardComponent,
ProgressModule,
TypographyModule,
} from "@bitwarden/components";
import { Storage, StorageCardComponent } from "@bitwarden/subscription";
import { I18nPipe } from "@bitwarden/ui-common";
export default {
title: "Billing/Storage Card",
component: StorageCardComponent,
description:
"Displays storage usage with a visual progress bar and action buttons for adding or removing storage.",
decorators: [
moduleMetadata({
imports: [
CommonModule,
ButtonModule,
CardComponent,
ProgressModule,
TypographyModule,
I18nPipe,
],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string, ...args: any[]) => {
const translations: Record<string, string> = {
storage: "Storage",
storageFull: "Storage full",
storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`,
storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`,
addStorage: "Add storage",
removeStorage: "Remove storage",
};
return translations[key] || key;
},
},
},
],
}),
],
} as Meta<StorageCardComponent>;
type Story = StoryObj<StorageCardComponent>;
export const Empty: Story = {
args: {
storage: {
available: 5,
used: 0,
readableUsed: "0 GB",
} satisfies Storage,
},
};
export const Used: Story = {
args: {
storage: {
available: 5,
used: 2.5,
readableUsed: "2.5 GB",
} satisfies Storage,
},
};
export const Full: Story = {
args: {
storage: {
available: 5,
used: 5,
readableUsed: "5 GB",
} satisfies Storage,
},
};
export const LowUsage: Story = {
name: "Low Usage (10%)",
args: {
storage: {
available: 5,
used: 0.5,
readableUsed: "500 MB",
} satisfies Storage,
},
};
export const MediumUsage: Story = {
name: "Medium Usage (75%)",
args: {
storage: {
available: 5,
used: 3.75,
readableUsed: "3.75 GB",
} satisfies Storage,
},
};
export const NearlyFull: Story = {
name: "Nearly Full (95%)",
args: {
storage: {
available: 5,
used: 4.75,
readableUsed: "4.75 GB",
} satisfies Storage,
},
};
export const LargeStorage: Story = {
name: "Large Storage Pool (1TB)",
args: {
storage: {
available: 1000,
used: 734,
readableUsed: "734 GB",
} satisfies Storage,
},
};
export const SmallStorage: Story = {
name: "Small Storage Pool (1GB)",
args: {
storage: {
available: 1,
used: 0.8,
readableUsed: "800 MB",
} satisfies Storage,
},
};
export const ActionsDisabled: Story = {
name: "Actions Disabled",
args: {
storage: {
available: 5,
used: 2.5,
readableUsed: "2.5 GB",
} satisfies Storage,
callsToActionDisabled: true,
},
};

View file

@ -0,0 +1,68 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
ButtonModule,
CardComponent,
ProgressModule,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { Storage } from "../../types/storage";
export type StorageCardAction = "add-storage" | "remove-storage";
@Component({
selector: "billing-storage-card",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./storage-card.component.html",
imports: [CommonModule, ButtonModule, CardComponent, ProgressModule, TypographyModule, I18nPipe],
})
export class StorageCardComponent {
private i18nService = inject(I18nService);
readonly storage = input.required<Storage>();
readonly callsToActionDisabled = input<boolean>(false);
readonly callToActionClicked = output<StorageCardAction>();
readonly isEmpty = computed<boolean>(() => this.storage().used === 0);
readonly isFull = computed<boolean>(() => {
const storage = this.storage();
return storage.used >= storage.available;
});
readonly percentageUsed = computed<number>(() => {
const storage = this.storage();
if (storage.available === 0) {
return 0;
}
return Math.min((storage.used / storage.available) * 100, 100);
});
readonly title = computed<string>(() => {
return this.isFull() ? this.i18nService.t("storageFull") : this.i18nService.t("storage");
});
readonly description = computed<string>(() => {
const storage = this.storage();
const available = storage.available;
const readableUsed = storage.readableUsed;
if (this.isFull()) {
return this.i18nService.t("storageFullDescription", available.toString());
}
return this.i18nService.t("storageUsedDescription", readableUsed, available.toString());
});
readonly progressBarColor = computed<"danger" | "primary">(() => {
return this.isFull() ? "danger" : "primary";
});
readonly canRemoveStorage = computed<boolean>(() => !this.isFull());
}

View file

@ -0,0 +1,94 @@
<bit-card class="tw-size-full tw-flex tw-flex-col">
<!-- Title Section with Badge -->
<div class="tw-flex tw-items-center tw-justify-between tw-mb-4">
<h2 bitTypography="h3" class="tw-m-0">{{ title() }}</h2>
<!-- Status Badge -->
<span bitBadge [variant]="badge().variant" class="tw-ml-3">
{{ badge().text }}
</span>
</div>
<!-- Cart Summary -->
<div class="tw-mb-4">
<billing-cart-summary [cart]="subscription().cart" [header]="cartSummaryHeader" />
</div>
<!-- Status-Based Callout -->
@if (callout(); as callout) {
<bit-callout [type]="callout.type" [icon]="callout.icon" [title]="callout.title">
<div class="tw-flex tw-flex-col tw-gap-2">
<p class="tw-m-0">{{ callout.description }}</p>
@if (callout.callsToAction) {
<div class="tw-flex tw-flex-row tw-gap-2">
@for (cta of callout.callsToAction; track cta.action) {
<button
bitButton
[buttonType]="cta.buttonType"
(click)="callToActionClicked.emit(cta.action)"
type="button"
>
{{ cta.text }}
</button>
}
</div>
}
</div>
</bit-callout>
}
</bit-card>
<ng-template #cartSummaryHeader let-total="total">
<h2
bitTypography="h4"
class="!tw-m-0"
id="cart-summary-header-custom"
data-test-id="cart-summary-header-custom"
>
@let status = subscription().status;
@switch (status) {
@case ("incomplete") {
{{ "yourSubscriptionWillBeSuspendedOn" | i18n }}
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
}
@case ("incomplete_expired") {
{{ "yourSubscriptionWasSuspendedOn" | i18n }}
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
}
@case ("trialing") {
@if (cancelAt(); as cancelAt) {
{{ "yourSubscriptionWillBeCanceledOn" | i18n }}
<span class="tw-font-bold">{{ cancelAt | date: dateFormat }}</span>
} @else {
{{ "yourNextChargeIsFor" | i18n }}
<span class="tw-font-bold">{{ total | currency: "USD" : "symbol" }} USD</span>
{{ "dueOn" | i18n }}
<span class="tw-font-bold">{{ nextCharge() | date: dateFormat }}</span>
}
}
@case ("active") {
@if (cancelAt(); as cancelAt) {
{{ "yourSubscriptionWillBeCanceledOn" | i18n }}
<span class="tw-font-bold">{{ cancelAt | date: dateFormat }}</span>
} @else {
{{ "yourNextChargeIsFor" | i18n }}
<span class="tw-font-bold">{{ total | currency: "USD" : "symbol" }} USD</span>
{{ "dueOn" | i18n }}
<span class="tw-font-bold">{{ nextCharge() | date: dateFormat }}</span>
}
}
@case ("past_due") {
{{ "yourSubscriptionWillBeSuspendedOn" | i18n }}
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
}
@case ("canceled") {
{{ "yourSubscriptionWasCanceledOn" | i18n }}
<span class="tw-font-bold">{{ canceled() | date: dateFormat }}</span>
}
@case ("unpaid") {
{{ "yourSubscriptionWasSuspendedOn" | i18n }}
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
}
}
</h2>
</ng-template>

View file

@ -0,0 +1,459 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
import * as SubscriptionCardStories from "./subscription-card.component.stories";
<Meta of={SubscriptionCardStories} />
# Subscription Card
A comprehensive UI component for displaying subscription status, payment details, and contextual
action prompts based on subscription state. Dynamically adapts its presentation based on the
subscription status (active, trialing, incomplete, past due, canceled, unpaid, etc.).
<Canvas of={SubscriptionCardStories.Active} />
## Table of Contents
- [Usage](#usage)
- [API](#api)
- [Inputs](#inputs)
- [Outputs](#outputs)
- [Data Structure](#data-structure)
- [Subscription States](#subscription-states)
- [Design](#design)
- [Examples](#examples)
- [Active](#active)
- [Active With Upgrade](#active-with-upgrade)
- [Trial](#trial)
- [Trial With Upgrade](#trial-with-upgrade)
- [Incomplete Payment](#incomplete-payment)
- [Incomplete Expired](#incomplete-expired)
- [Past Due](#past-due)
- [Pending Cancellation](#pending-cancellation)
- [Unpaid](#unpaid)
- [Canceled](#canceled)
- [Enterprise](#enterprise)
- [Features](#features)
- [Do's and Don'ts](#dos-and-donts)
- [Accessibility](#accessibility)
## Usage
The subscription card component is designed to display comprehensive subscription information on
billing pages, account management interfaces, and subscription dashboards.
```ts
import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/subscription";
```
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="subscription"
[showUpgradeButton]="false"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
## API
### Inputs
| Input | Type | Description |
| ------------------- | ----------------------- | ----------------------------------------------------------------------- |
| `title` | `string` | **Required.** The title displayed at the top of the card |
| `subscription` | `BitwardenSubscription` | **Required.** The subscription data including status, cart, and storage |
| `showUpgradeButton` | `boolean` | **Optional.** Whether to show the upgrade callout (default: `false`) |
### Outputs
| Output | Type | Description |
| --------------------- | ---------------- | ---------------------------------------------------------- |
| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout |
**PlanCardAction Type:**
```typescript
type PlanCardAction =
| "contact-support"
| "manage-invoices"
| "reinstate-subscription"
| "update-payment"
| "upgrade-plan";
```
## Data Structure
The component uses the `BitwardenSubscription` type, which is a discriminated union based on status:
```typescript
type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled);
type HasCart = {
cart: Cart; // From @bitwarden/pricing
};
type HasStorage = {
storage: {
available: number;
readableUsed: string;
used: number;
};
};
type Suspension = {
status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid";
suspension: Date;
gracePeriod: number;
};
type Billable = {
status: "trialing" | "active";
nextCharge: Date;
cancelAt?: Date;
};
type Canceled = {
status: "canceled";
canceled: Date;
};
```
## Subscription States
The component dynamically adapts its appearance and calls-to-action based on the subscription
status:
- **active**: Subscription is active and paid up
- **trialing**: Subscription is in trial period
- **incomplete**: Payment failed, requires action
- **incomplete_expired**: Payment issue expired, subscription suspended
- **past_due**: Payment overdue but within grace period
- **unpaid**: Subscription suspended due to non-payment
- **canceled**: Subscription was canceled
Each state displays an appropriate badge, callout message, and relevant action buttons.
## Design
The component follows the Bitwarden design system with:
- **Status Badge**: Color-coded badges (success, warning, danger) indicating subscription state
- **Cart Summary**: Integrated cart summary showing pricing details
- **Contextual Callouts**: Warning/info/danger callouts with appropriate actions
- **Modern Angular**: Uses signal inputs (`input.required`, `input`) and `computed` signals
- **OnPush Change Detection**: Optimized performance with change detection strategy
- **Typography**: Consistent text styling using the typography module
- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling
- **Responsive Layout**: Flexbox-based layout that adapts to container size
## Examples
### Active
Standard active subscription with regular billing:
<Canvas of={SubscriptionCardStories.Active} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'active',
nextCharge: new Date('2025-02-15'),
cart: {
passwordManager: {
seats: {
quantity: 1,
name: 'members',
cost: 10.00
}
},
cadence: 'annually',
estimatedTax: 2.71
},
storage: {
available: 1000,
used: 234,
readableUsed: '234 MB'
}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
### Active With Upgrade
Active subscription with upgrade promotion callout:
<Canvas of={SubscriptionCardStories.ActiveWithUpgrade} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="activeSubscription"
[showUpgradeButton]="true"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
### Trial
Subscription in trial period showing next charge date:
<Canvas of={SubscriptionCardStories.Trial} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'trialing',
nextCharge: new Date('2025-02-01'),
cart: {...},
storage: {...}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
### Trial With Upgrade
Trial subscription with upgrade option displayed:
<Canvas of={SubscriptionCardStories.TrialWithUpgrade} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="trialSubscription"
[showUpgradeButton]="true"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
### Incomplete Payment
Payment failed, showing warning with update payment action:
<Canvas of={SubscriptionCardStories.Incomplete} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'incomplete',
suspension: new Date('2025-02-15'),
gracePeriod: 7,
cart: {...},
storage: {...}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
**Actions available:** Update Payment, Contact Support
### Incomplete Expired
Payment issue expired, subscription has been suspended:
<Canvas of={SubscriptionCardStories.IncompleteExpired} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'incomplete_expired',
suspension: new Date('2025-01-01'),
gracePeriod: 0,
cart: {...},
storage: {...}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
**Actions available:** Contact Support
### Past Due
Payment past due with active grace period:
<Canvas of={SubscriptionCardStories.PastDue} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'past_due',
suspension: new Date('2025-02-05'),
gracePeriod: 14,
cart: {...},
storage: {...}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
**Actions available:** Manage Invoices
### Pending Cancellation
Active subscription scheduled to be canceled:
<Canvas of={SubscriptionCardStories.PendingCancellation} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'active',
nextCharge: new Date('2025-02-15'),
cancelAt: new Date('2025-03-01'),
cart: {...},
storage: {...}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
**Actions available:** Reinstate Subscription
### Unpaid
Subscription suspended due to unpaid invoices:
<Canvas of={SubscriptionCardStories.Unpaid} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'unpaid',
suspension: new Date('2025-01-20'),
gracePeriod: 0,
cart: {...},
storage: {...}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
**Actions available:** Manage Invoices
### Canceled
Subscription that has been canceled:
<Canvas of={SubscriptionCardStories.Canceled} />
```html
<billing-subscription-card
[title]="'Premium Subscription'"
[subscription]="{
status: 'canceled',
canceled: new Date('2025-01-15'),
cart: {...},
storage: {...}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
**Note:** Canceled subscriptions display no callout or actions.
### Enterprise
Enterprise subscription with multiple products and discount:
<Canvas of={SubscriptionCardStories.Enterprise} />
```html
<billing-subscription-card
[title]="'Enterprise Subscription'"
[subscription]="{
status: 'active',
nextCharge: new Date('2025-03-01'),
cart: {
passwordManager: {
seats: { quantity: 5, name: 'members', cost: 7 },
additionalStorage: { quantity: 2, name: 'additionalStorageGB', cost: 0.5 }
},
secretsManager: {
seats: { quantity: 3, name: 'members', cost: 13 },
additionalServiceAccounts: { quantity: 5, name: 'additionalServiceAccountsV2', cost: 1 }
},
discount: { type: 'percent-off', active: true, value: 0.25 },
cadence: 'monthly',
estimatedTax: 6.4
},
storage: {
available: 7,
readableUsed: '7 GB',
used: 0
}
}"
(callToActionClicked)="handleAction($event)"
>
</billing-subscription-card>
```
## Features
- **Dynamic Badge**: Status badge changes color and text based on subscription state
- **Contextual Callouts**: Warning, info, or danger callouts with relevant messages
- **Action Buttons**: Context-specific call-to-action buttons (update payment, contact support,
etc.)
- **Cart Summary Integration**: Embedded cart summary with pricing breakdown
- **Custom Header Support**: Cart summary can display custom headers based on subscription status
- **Date Formatting**: Consistent date formatting throughout (MMM. d, y format)
- **Computed Signals**: Efficient reactive computations using Angular signals
- **Type Safety**: Discriminated union types ensure type-safe subscription data
- **Internationalization**: All text uses i18n service for translation support
- **Event Emission**: Emits typed events for handling user actions
## Do's and Don'ts
### ✅ Do
- Handle all `callToActionClicked` events appropriately in parent components
- Provide complete `BitwardenSubscription` objects with all required fields
- Use the correct subscription status from the defined status types
- Include accurate date information for nextCharge, suspension, and cancelAt fields
- Set `showUpgradeButton` to `true` only when upgrade paths are available
- Use real translation keys that exist in the i18n messages file
- Provide accurate storage information with readable format strings
### ❌ Don't
- Omit required fields from the BitwardenSubscription type
- Use custom status strings not defined in the type
- Display upgrade buttons for users who cannot upgrade
- Ignore the `callToActionClicked` events - they require handling
- Mix subscription states (e.g., having both `canceled` date and `nextCharge`)
- Provide incorrect dates that don't match the subscription status
- Override component styles without ensuring accessibility
- Use placeholder or mock data in production environments
## Accessibility
The component includes:
- **Semantic HTML**: Proper heading hierarchy with `<h2>`, `<h3>`, `<h4>` tags
- **ARIA Labels**: Badge variants use appropriate semantic colors
- **Keyboard Navigation**: All action buttons are keyboard accessible
- **Focus Management**: Clear focus indicators on interactive elements
- **Color Contrast**: Sufficient contrast ratios for all text and badge variants
- **Screen Reader Support**: Descriptive text for all interactive elements
- **Button Types**: Proper `type="button"` attributes on all buttons
- **Date Formatting**: Human-readable date formats for assistive technologies

View file

@ -0,0 +1,704 @@
import { DatePipe } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Cart } from "@bitwarden/pricing";
import { BitwardenSubscription, SubscriptionCardComponent } from "@bitwarden/subscription";
describe("SubscriptionCardComponent", () => {
let component: SubscriptionCardComponent;
let fixture: ComponentFixture<SubscriptionCardComponent>;
const mockCart: Cart = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 50,
},
},
cadence: "monthly",
estimatedTax: 0,
};
const baseSubscription = {
cart: mockCart,
storage: {
available: 1000,
readableUsed: "100 MB",
used: 100,
},
};
const mockI18nService = {
t: (key: string, ...params: any[]) => {
const translations: Record<string, string> = {
pendingCancellation: "Pending cancellation",
updatePayment: "Update payment",
expired: "Expired",
trial: "Trial",
active: "Active",
pastDue: "Past due",
canceled: "Canceled",
unpaid: "Unpaid",
weCouldNotProcessYourPayment: "We could not process your payment",
contactSupportShort: "Contact support",
yourSubscriptionHasExpired: "Your subscription has expired",
yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${params[0]}`,
reinstateSubscription: "Reinstate subscription",
upgradeYourPlan: "Upgrade your plan",
premiumShareEvenMore: "Premium share even more",
upgradeNow: "Upgrade now",
youHaveAGracePeriod: `You have a grace period of ${params[0]} days ending ${params[1]}`,
manageInvoices: "Manage invoices",
toReactivateYourSubscription: "To reactivate your subscription",
};
return translations[key] || key;
},
};
function setupComponent(subscription: BitwardenSubscription, title = "Test Plan") {
fixture.componentRef.setInput("title", title);
fixture.componentRef.setInput("subscription", subscription);
fixture.detectChanges();
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SubscriptionCardComponent],
providers: [
DatePipe,
{
provide: I18nService,
useValue: mockI18nService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(SubscriptionCardComponent);
component = fixture.componentInstance;
});
it("should create", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
expect(component).toBeTruthy();
});
describe("Badge rendering", () => {
it("should display 'Update payment' badge with warning variant for incomplete status", () => {
setupComponent({
...baseSubscription,
status: "incomplete",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
expect(component.badge().text).toBe("Update payment");
expect(component.badge().variant).toBe("warning");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent.trim()).toBe("Update payment");
});
it("should display 'Expired' badge with danger variant for incomplete_expired status", () => {
setupComponent({
...baseSubscription,
status: "incomplete_expired",
suspension: new Date("2025-01-15"),
gracePeriod: 7,
});
expect(component.badge().text).toBe("Expired");
expect(component.badge().variant).toBe("danger");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Expired");
});
it("should display 'Trial' badge with success variant for trialing status", () => {
setupComponent({
...baseSubscription,
status: "trialing",
nextCharge: new Date("2025-02-01"),
});
expect(component.badge().text).toBe("Trial");
expect(component.badge().variant).toBe("success");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Trial");
});
it("should display 'Pending cancellation' badge for trialing status with cancelAt", () => {
setupComponent({
...baseSubscription,
status: "trialing",
nextCharge: new Date("2025-02-01"),
cancelAt: new Date("2025-03-01"),
});
expect(component.badge().text).toBe("Pending cancellation");
expect(component.badge().variant).toBe("warning");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation");
});
it("should display 'Active' badge with success variant for active status", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
expect(component.badge().text).toBe("Active");
expect(component.badge().variant).toBe("success");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Active");
});
it("should display 'Pending cancellation' badge for active status with cancelAt", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
cancelAt: new Date("2025-03-01"),
});
expect(component.badge().text).toBe("Pending cancellation");
expect(component.badge().variant).toBe("warning");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation");
});
it("should display 'Past due' badge with warning variant for past_due status", () => {
setupComponent({
...baseSubscription,
status: "past_due",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
expect(component.badge().text).toBe("Past due");
expect(component.badge().variant).toBe("warning");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Past due");
});
it("should display 'Canceled' badge with danger variant for canceled status", () => {
setupComponent({
...baseSubscription,
status: "canceled",
canceled: new Date("2025-01-15"),
});
expect(component.badge().text).toBe("Canceled");
expect(component.badge().variant).toBe("danger");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Canceled");
});
it("should display 'Unpaid' badge with danger variant for unpaid status", () => {
setupComponent({
...baseSubscription,
status: "unpaid",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
expect(component.badge().text).toBe("Unpaid");
expect(component.badge().variant).toBe("danger");
const badge = fixture.debugElement.query(By.css("[bitBadge]"));
expect(badge.nativeElement.textContent.trim()).toBe("Unpaid");
});
});
describe("Callout rendering", () => {
it("should display incomplete callout with update payment and contact support actions", () => {
setupComponent({
...baseSubscription,
status: "incomplete",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const calloutData = component.callout();
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("warning");
expect(calloutData!.title).toBe("Update payment");
expect(calloutData!.description).toContain("We could not process your payment");
expect(calloutData!.callsToAction?.length).toBe(2);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeTruthy();
const description = callout.query(By.css("p"));
expect(description.nativeElement.textContent).toContain("We could not process your payment");
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(2);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Update payment");
expect(buttons[1].nativeElement.textContent.trim()).toBe("Contact support");
});
it("should display incomplete_expired callout with contact support action", () => {
setupComponent({
...baseSubscription,
status: "incomplete_expired",
suspension: new Date("2025-01-15"),
gracePeriod: 7,
});
const calloutData = component.callout();
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("danger");
expect(calloutData!.title).toBe("Expired");
expect(calloutData!.description).toContain("Your subscription has expired");
expect(calloutData!.callsToAction?.length).toBe(1);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeTruthy();
const description = callout.query(By.css("p"));
expect(description.nativeElement.textContent).toContain("Your subscription has expired");
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(1);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Contact support");
});
it("should display pending cancellation callout for active status with cancelAt", () => {
const cancelDate = new Date("2025-03-01");
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
cancelAt: cancelDate,
});
const calloutData = component.callout();
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("warning");
expect(calloutData!.title).toBe("Pending cancellation");
expect(calloutData!.callsToAction?.length).toBe(1);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeTruthy();
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(1);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Reinstate subscription");
});
it("should display upgrade callout for active status when showUpgradeButton is true", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
fixture.componentRef.setInput("showUpgradeButton", true);
fixture.detectChanges();
const calloutData = component.callout();
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("info");
expect(calloutData!.title).toBe("Upgrade your plan");
expect(calloutData!.description).toContain("Premium share even more");
expect(calloutData!.callsToAction?.length).toBe(1);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeTruthy();
const description = callout.query(By.css("p"));
expect(description.nativeElement.textContent).toContain("Premium share even more");
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(1);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Upgrade now");
});
it("should not display upgrade callout when showUpgradeButton is false", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
fixture.componentRef.setInput("showUpgradeButton", false);
fixture.detectChanges();
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeFalsy();
});
it("should display past_due callout with manage invoices action", () => {
setupComponent({
...baseSubscription,
status: "past_due",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const calloutData = component.callout();
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("warning");
expect(calloutData!.title).toBe("Past due");
expect(calloutData!.callsToAction?.length).toBe(1);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeTruthy();
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(1);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices");
});
it("should not display callout for canceled status", () => {
setupComponent({
...baseSubscription,
status: "canceled",
canceled: new Date("2025-01-15"),
});
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeFalsy();
});
it("should display unpaid callout with manage invoices action", () => {
setupComponent({
...baseSubscription,
status: "unpaid",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const calloutData = component.callout();
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("danger");
expect(calloutData!.title).toBe("Unpaid");
expect(calloutData!.description).toContain("To reactivate your subscription");
expect(calloutData!.callsToAction?.length).toBe(1);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeTruthy();
const description = callout.query(By.css("p"));
expect(description.nativeElement.textContent).toContain("To reactivate your subscription");
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(1);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices");
});
});
describe("Call-to-action clicks", () => {
it("should emit update-payment action when button is clicked", () => {
setupComponent({
...baseSubscription,
status: "incomplete",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const buttons = fixture.debugElement.queryAll(By.css("bit-callout button"));
expect(buttons.length).toBe(2);
buttons[0].triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("update-payment");
});
it("should emit contact-support action when button is clicked", () => {
setupComponent({
...baseSubscription,
status: "incomplete",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const buttons = fixture.debugElement.queryAll(By.css("bit-callout button"));
buttons[1].triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("contact-support");
});
it("should emit reinstate-subscription action when button is clicked", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
cancelAt: new Date("2025-03-01"),
});
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const button = fixture.debugElement.query(By.css("bit-callout button"));
button.triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("reinstate-subscription");
});
it("should emit upgrade-plan action when button is clicked", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
fixture.componentRef.setInput("showUpgradeButton", true);
fixture.detectChanges();
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const button = fixture.debugElement.query(By.css("bit-callout button"));
button.triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("upgrade-plan");
});
it("should emit manage-invoices action when button is clicked", () => {
setupComponent({
...baseSubscription,
status: "past_due",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const button = fixture.debugElement.query(By.css("bit-callout button"));
button.triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("manage-invoices");
});
});
describe("Cart summary header content", () => {
it("should display suspension date for incomplete status", () => {
setupComponent({
...baseSubscription,
status: "incomplete",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display suspension date for incomplete_expired status", () => {
setupComponent({
...baseSubscription,
status: "incomplete_expired",
suspension: new Date("2025-01-15"),
gracePeriod: 7,
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display cancellation date for trialing status with cancelAt", () => {
setupComponent({
...baseSubscription,
status: "trialing",
nextCharge: new Date("2025-02-01"),
cancelAt: new Date("2025-03-01"),
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display next charge for trialing status without cancelAt", () => {
setupComponent({
...baseSubscription,
status: "trialing",
nextCharge: new Date("2025-02-01"),
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display cancellation date for active status with cancelAt", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
cancelAt: new Date("2025-03-01"),
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display next charge for active status without cancelAt", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display suspension date for past_due status", () => {
setupComponent({
...baseSubscription,
status: "past_due",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display canceled date for canceled status", () => {
setupComponent({
...baseSubscription,
status: "canceled",
canceled: new Date("2025-01-15"),
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
it("should display suspension date for unpaid status", () => {
setupComponent({
...baseSubscription,
status: "unpaid",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
});
const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary"));
expect(cartSummary).toBeTruthy();
});
});
describe("Title rendering", () => {
it("should display the provided title", () => {
setupComponent(
{
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
},
"Premium Plan",
);
const title = fixture.debugElement.query(By.css("h2[bitTypography='h3']"));
expect(title.nativeElement.textContent.trim()).toBe("Premium Plan");
});
});
describe("Computed properties", () => {
it("should compute cancelAt for active status with cancelAt date", () => {
const cancelDate = new Date("2025-03-01");
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
cancelAt: cancelDate,
});
expect(component.cancelAt()).toEqual(cancelDate);
});
it("should compute cancelAt as undefined for active status without cancelAt", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
expect(component.cancelAt()).toBeUndefined();
});
it("should compute canceled date for canceled status", () => {
const canceledDate = new Date("2025-01-15");
setupComponent({
...baseSubscription,
status: "canceled",
canceled: canceledDate,
});
expect(component.canceled()).toEqual(canceledDate);
});
it("should compute canceled as undefined for non-canceled status", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
expect(component.canceled()).toBeUndefined();
});
it("should compute nextCharge for active status", () => {
const nextChargeDate = new Date("2025-02-01");
setupComponent({
...baseSubscription,
status: "active",
nextCharge: nextChargeDate,
});
expect(component.nextCharge()).toEqual(nextChargeDate);
});
it("should compute nextCharge as undefined for canceled status", () => {
setupComponent({
...baseSubscription,
status: "canceled",
canceled: new Date("2025-01-15"),
});
expect(component.nextCharge()).toBeUndefined();
});
it("should compute suspension date for incomplete status", () => {
const suspensionDate = new Date("2025-02-15");
setupComponent({
...baseSubscription,
status: "incomplete",
suspension: suspensionDate,
gracePeriod: 7,
});
expect(component.suspension()).toEqual(suspensionDate);
});
it("should compute suspension as undefined for active status", () => {
setupComponent({
...baseSubscription,
status: "active",
nextCharge: new Date("2025-02-01"),
});
expect(component.suspension()).toBeUndefined();
});
});
});

View file

@ -0,0 +1,411 @@
import { CommonModule, DatePipe } from "@angular/common";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BadgeModule,
ButtonModule,
CalloutModule,
CardComponent,
TypographyModule,
} from "@bitwarden/components";
import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitwardenSubscription } from "../../types/bitwarden-subscription";
import { SubscriptionCardComponent } from "./subscription-card.component";
export default {
title: "Billing/Subscription Card",
component: SubscriptionCardComponent,
description:
"Displays subscription status, payment details, and action prompts based on subscription state.",
decorators: [
moduleMetadata({
imports: [
CommonModule,
BadgeModule,
ButtonModule,
CalloutModule,
CardComponent,
CartSummaryComponent,
TypographyModule,
I18nPipe,
],
providers: [
DatePipe,
{
provide: I18nService,
useValue: {
t: (key: string, ...args: any[]) => {
const translations: Record<string, string> = {
pendingCancellation: "Pending cancellation",
updatePayment: "Update payment",
expired: "Expired",
trial: "Trial",
active: "Active",
pastDue: "Past due",
canceled: "Canceled",
unpaid: "Unpaid",
weCouldNotProcessYourPayment:
"We could not process your payment. Please update your payment method or contact the support team for assistance.",
contactSupportShort: "Contact Support",
yourSubscriptionHasExpired:
"Your subscription has expired. Please contact the support team for assistance.",
yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${args[0]}. You can reinstate it anytime before then.`,
reinstateSubscription: "Reinstate subscription",
upgradeYourPlan: "Upgrade your plan",
premiumShareEvenMore:
"Share even more with Families, or get powerful, trusted password security with Teams or Enterprise.",
upgradeNow: "Upgrade now",
youHaveAGracePeriod: `You have a grace period of ${args[0]} days from your subscription expiration date. Please resolve the past due invoices by ${args[1]}.`,
manageInvoices: "Manage invoices",
toReactivateYourSubscription:
"To reactivate your subscription, please resolve the past due invoices.",
yourSubscriptionWillBeSuspendedOn: "Your subscription will be suspended on",
yourSubscriptionWasSuspendedOn: "Your subscription was suspended on",
yourSubscriptionWillBeCanceledOn: "Your subscription will be canceled on",
yourNextChargeIsFor: "Your next charge is for",
dueOn: "due on",
yourSubscriptionWasCanceledOn: "Your subscription was canceled on",
members: "Members",
additionalStorageGB: "Additional storage GB",
month: "month",
year: "year",
estimatedTax: "Estimated tax",
total: "Total",
expandPurchaseDetails: "Expand purchase details",
collapsePurchaseDetails: "Collapse purchase details",
passwordManager: "Password Manager",
secretsManager: "Secrets Manager",
additionalStorageGb: "Additional storage (GB)",
additionalServiceAccountsV2: "Additional machine accounts",
};
return translations[key] || key;
},
},
},
],
}),
],
} as Meta<SubscriptionCardComponent>;
type Story = StoryObj<SubscriptionCardComponent>;
export const Active: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "active",
nextCharge: new Date("2025-02-15"),
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const ActiveWithUpgrade: Story = {
name: "Active - With Upgrade Option",
args: {
title: "Premium Subscription",
showUpgradeButton: true,
subscription: {
status: "active",
nextCharge: new Date("2025-02-15"),
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const Trial: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "trialing",
nextCharge: new Date("2025-02-01"),
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 50,
readableUsed: "50 MB",
},
} satisfies BitwardenSubscription,
},
};
export const TrialWithUpgrade: Story = {
name: "Trial - With Upgrade Option",
args: {
title: "Premium Subscription",
showUpgradeButton: true,
subscription: {
status: "trialing",
nextCharge: new Date("2025-02-01"),
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 50,
readableUsed: "50 MB",
},
} satisfies BitwardenSubscription,
},
};
export const Incomplete: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "incomplete",
suspension: new Date("2025-02-15"),
gracePeriod: 7,
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const IncompleteExpired: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "incomplete_expired",
suspension: new Date("2025-01-01"),
gracePeriod: 0,
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const PastDue: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "past_due",
suspension: new Date("2025-02-05"),
gracePeriod: 14,
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const PendingCancellation: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "active",
nextCharge: new Date("2025-02-15"),
cancelAt: new Date("2025-03-01"),
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const Unpaid: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "unpaid",
suspension: new Date("2025-01-20"),
gracePeriod: 0,
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const Canceled: Story = {
args: {
title: "Premium Subscription",
subscription: {
status: "canceled",
canceled: new Date("2025-01-15"),
cart: {
passwordManager: {
seats: {
quantity: 1,
name: "members",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
},
storage: {
available: 1000,
used: 234,
readableUsed: "234 MB",
},
} satisfies BitwardenSubscription,
},
};
export const Enterprise: Story = {
args: {
title: "Enterprise Subscription",
subscription: {
status: "active",
nextCharge: new Date("2025-03-01"),
cart: {
passwordManager: {
seats: {
quantity: 5,
name: "members",
cost: 7,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
cost: 0.5,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
cost: 13,
},
additionalServiceAccounts: {
quantity: 5,
name: "additionalServiceAccountsV2",
cost: 1,
},
},
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 0.25,
},
cadence: "monthly",
estimatedTax: 6.4,
},
storage: {
available: 7,
readableUsed: "7 GB",
used: 0,
},
},
},
};

View file

@ -0,0 +1,274 @@
import { CommonModule, DatePipe } from "@angular/common";
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BadgeModule,
BadgeVariant,
ButtonModule,
CalloutModule,
CardComponent,
TypographyModule,
CalloutTypes,
ButtonType,
} from "@bitwarden/components";
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";
type Badge = { text: string; variant: BadgeVariant };
type Callout = Maybe<{
title: string;
type: CalloutTypes;
icon?: string;
description: string;
callsToAction?: {
text: string;
buttonType: ButtonType;
action: PlanCardAction;
}[];
}>;
@Component({
selector: "billing-subscription-card",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./subscription-card.component.html",
imports: [
CommonModule,
BadgeModule,
ButtonModule,
CalloutModule,
CardComponent,
CartSummaryComponent,
TypographyModule,
I18nPipe,
],
})
export class SubscriptionCardComponent {
private datePipe = inject(DatePipe);
private i18nService = inject(I18nService);
protected readonly dateFormat = "MMM. d, y";
readonly title = input.required<string>();
readonly subscription = input.required<BitwardenSubscription>();
readonly showUpgradeButton = input<boolean>(false);
readonly callToActionClicked = output<PlanCardAction>();
readonly badge = computed<Badge>(() => {
const subscription = this.subscription();
const pendingCancellation: Badge = {
text: this.i18nService.t("pendingCancellation"),
variant: "warning",
};
switch (subscription.status) {
case SubscriptionStatuses.Incomplete: {
return {
text: this.i18nService.t("updatePayment"),
variant: "warning",
};
}
case SubscriptionStatuses.IncompleteExpired: {
return {
text: this.i18nService.t("expired"),
variant: "danger",
};
}
case SubscriptionStatuses.Trialing: {
if (subscription.cancelAt) {
return pendingCancellation;
}
return {
text: this.i18nService.t("trial"),
variant: "success",
};
}
case SubscriptionStatuses.Active: {
if (subscription.cancelAt) {
return pendingCancellation;
}
return {
text: this.i18nService.t("active"),
variant: "success",
};
}
case SubscriptionStatuses.PastDue: {
return {
text: this.i18nService.t("pastDue"),
variant: "warning",
};
}
case SubscriptionStatuses.Canceled: {
return {
text: this.i18nService.t("canceled"),
variant: "danger",
};
}
case SubscriptionStatuses.Unpaid: {
return {
text: this.i18nService.t("unpaid"),
variant: "danger",
};
}
}
});
readonly callout = computed<Callout>(() => {
const subscription = this.subscription();
switch (subscription.status) {
case SubscriptionStatuses.Incomplete: {
return {
title: this.i18nService.t("updatePayment"),
type: "warning",
description: this.i18nService.t("weCouldNotProcessYourPayment"),
callsToAction: [
{
text: this.i18nService.t("updatePayment"),
buttonType: "unstyled",
action: "update-payment",
},
{
text: this.i18nService.t("contactSupportShort"),
buttonType: "unstyled",
action: "contact-support",
},
],
};
}
case SubscriptionStatuses.IncompleteExpired: {
return {
title: this.i18nService.t("expired"),
type: "danger",
description: this.i18nService.t("yourSubscriptionHasExpired"),
callsToAction: [
{
text: this.i18nService.t("contactSupportShort"),
buttonType: "unstyled",
action: "contact-support",
},
],
};
}
case SubscriptionStatuses.Trialing:
case SubscriptionStatuses.Active: {
if (subscription.cancelAt) {
const cancelAt = this.datePipe.transform(subscription.cancelAt, this.dateFormat);
return {
title: this.i18nService.t("pendingCancellation"),
type: "warning",
description: this.i18nService.t("yourSubscriptionIsScheduledToCancel", cancelAt!),
callsToAction: [
{
text: this.i18nService.t("reinstateSubscription"),
buttonType: "unstyled",
action: "reinstate-subscription",
},
],
};
}
if (!this.showUpgradeButton()) {
return null;
}
return {
title: this.i18nService.t("upgradeYourPlan"),
type: "info",
icon: "bwi-gem",
description: this.i18nService.t("premiumShareEvenMore"),
callsToAction: [
{
text: this.i18nService.t("upgradeNow"),
buttonType: "unstyled",
action: "upgrade-plan",
},
],
};
}
case SubscriptionStatuses.PastDue: {
const suspension = this.datePipe.transform(subscription.suspension, this.dateFormat);
return {
title: this.i18nService.t("pastDue"),
type: "warning",
description: this.i18nService.t(
"youHaveAGracePeriod",
subscription.gracePeriod,
suspension!,
),
callsToAction: [
{
text: this.i18nService.t("manageInvoices"),
buttonType: "unstyled",
action: "manage-invoices",
},
],
};
}
case SubscriptionStatuses.Canceled: {
return null;
}
case SubscriptionStatuses.Unpaid: {
return {
title: this.i18nService.t("unpaid"),
type: "danger",
description: this.i18nService.t("toReactivateYourSubscription"),
callsToAction: [
{
text: this.i18nService.t("manageInvoices"),
buttonType: "unstyled",
action: "manage-invoices",
},
],
};
}
}
});
readonly cancelAt = computed<Maybe<Date>>(() => {
const subscription = this.subscription();
if (
subscription.status === SubscriptionStatuses.Trialing ||
subscription.status === SubscriptionStatuses.Active
) {
return subscription.cancelAt;
}
});
readonly canceled = computed<Maybe<Date>>(() => {
const subscription = this.subscription();
if (subscription.status === SubscriptionStatuses.Canceled) {
return subscription.canceled;
}
});
readonly nextCharge = computed<Maybe<Date>>(() => {
const subscription = this.subscription();
if (
subscription.status === SubscriptionStatuses.Trialing ||
subscription.status === SubscriptionStatuses.Active
) {
return subscription.nextCharge;
}
});
readonly suspension = computed<Maybe<Date>>(() => {
const subscription = this.subscription();
if (
subscription.status === SubscriptionStatuses.Incomplete ||
subscription.status === SubscriptionStatuses.IncompleteExpired ||
subscription.status === SubscriptionStatuses.PastDue ||
subscription.status === SubscriptionStatuses.Unpaid
) {
return subscription.suspension;
}
});
}

View file

@ -1 +1,8 @@
export type Placeholder = unknown;
// Components
export * from "./components/additional-options-card/additional-options-card.component";
export * from "./components/subscription-card/subscription-card.component";
export * from "./components/storage-card/storage-card.component";
// Types
export * from "./types/bitwarden-subscription";
export * from "./types/storage";

View file

@ -1,8 +0,0 @@
import * as lib from "./index";
describe("subscription", () => {
// This test will fail until something is exported from index.ts
it("should work", () => {
expect(lib).toBeDefined();
});
});

View file

@ -0,0 +1,40 @@
import { Cart } from "@bitwarden/pricing";
import { Storage } from "./storage";
export const SubscriptionStatuses = {
Incomplete: "incomplete",
IncompleteExpired: "incomplete_expired",
Trialing: "trialing",
Active: "active",
PastDue: "past_due",
Canceled: "canceled",
Unpaid: "unpaid",
} as const;
type HasCart = {
cart: Cart;
};
type HasStorage = {
storage: Storage;
};
type Suspension = {
status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid";
suspension: Date;
gracePeriod: number;
};
type Billable = {
status: "trialing" | "active";
nextCharge: Date;
cancelAt?: Date;
};
type Canceled = {
status: "canceled";
canceled: Date;
};
export type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled);

View file

@ -0,0 +1,5 @@
export type Storage = {
available: number;
readableUsed: string;
used: number;
};

View file

@ -0,0 +1,28 @@
import { webcrypto } from "crypto";
import "@bitwarden/ui-common/setup-jest";
Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {
value: () => {
return {
display: "none",
appearance: ["-webkit-appearance"],
};
},
});
Object.defineProperty(document, "doctype", {
value: "<!DOCTYPE html>",
});
Object.defineProperty(document.body.style, "transform", {
value: () => {
return {
enumerable: true,
configurable: true,
};
},
});
Object.defineProperty(window, "crypto", {
value: webcrypto,
});