1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-19 10:54:00 +00:00

Merge remote-tracking branch 'origin/main' into playwright

This commit is contained in:
Matt Gibson
2026-01-26 12:57:05 -08:00
1790 changed files with 150488 additions and 32025 deletions

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.translationKey | 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.quantity }} {{ additionalStorage.translationKey | 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.translationKey | i18n }} x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
</div>
<div
bitTypography="body1"
@@ -95,14 +100,15 @@
</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">
{{ additionalServiceAccounts.quantity }}
{{ additionalServiceAccounts.name | i18n }} x
{{ additionalServiceAccounts.translationKey | 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

@@ -1,4 +1,4 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs";
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
import * as CartSummaryStories from "./cart-summary.component.stories";
<Meta of={CartSummaryStories} />
@@ -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,98 @@ 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 = {
translationKey: string; // Translation key 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
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,
translationKey: '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,
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
translationKey: '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,
translationKey: 'members',
cost: 50.00
}
},
cadence: 'monthly',
discount: {
type: 'percent-off',
value: 20
},
estimatedTax: 8.00
}"
[estimatedTax]="12.00"
>
</billing-cart-summary>
```
@@ -138,13 +182,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,
translationKey: 'members',
cost: 500.00
}
},
cadence: 'annually',
estimatedTax: 120.00
}"
[estimatedTax]="120.00"
>
</billing-cart-summary>
```
@@ -157,19 +205,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,
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
translationKey: '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 +233,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,
translationKey: 'members',
cost: 50.00
}
},
secretsManager: {
seats: {
quantity: 3,
translationKey: '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 +263,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,
translationKey: 'members',
cost: 50.00
}
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
cost: 6.00,
cadence: 'month'
}
secretsManager: {
seats: {
quantity: 3,
translationKey: 'members',
cost: 30.00
},
additionalServiceAccounts: {
quantity: 2,
translationKey: 'additionalServiceAccounts',
cost: 6.00
}
},
cadence: 'monthly',
estimatedTax: 16.00
}"
[estimatedTax]="16.00"
>
</billing-cart-summary>
```
@@ -242,51 +298,142 @@ 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,
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
cost: 6.00,
cadence: 'month'
}
secretsManager: {
seats: {
quantity: 3,
translationKey: 'members',
cost: 30.00
},
additionalServiceAccounts: {
quantity: 2,
translationKey: '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,
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'monthly',
discount: {
type: 'percent-off',
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,
translationKey: 'members',
cost: 50.00
}
},
secretsManager: {
seats: {
quantity: 3,
translationKey: 'members',
cost: 30.00
}
},
cadence: 'annually',
discount: {
type: 'amount-off',
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,
translationKey: 'premiumMembership',
cost: 10.00
}
},
cadence: 'annually',
estimatedTax: 2.71
}"
[estimatedTax]="2.71"
>
</billing-cart-summary>
```
@@ -296,15 +443,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,
translationKey: 'familiesMembership',
cost: 40.00
}
},
cadence: 'annually',
estimatedTax: 4.67
}"
[estimatedTax]="4.67"
>
</billing-cart-summary>
```
@@ -314,31 +466,36 @@ 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
- Use valid translation keys for CartItem translationKey (for i18n lookup)
- Provide complete Cart object with all required fields
- Use "annually" or "monthly" for cadence (not "year" or "month")
### ❌ 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,
translationKey: "members",
cost: 50,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10,
},
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6,
cadence: "month",
secretsManager: {
seats: {
quantity: 3,
translationKey: "secretsManagerSeats",
cost: 30,
},
additionalServiceAccounts: {
quantity: 2,
translationKey: "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,235 @@ 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,
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,
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 apply discount to total calculation", () => {
// Arrange
const cartWithDiscount: Cart = {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
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,
translationKey: "members",
cost: 50,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10,
},
},
secretsManager: {
seats: {
quantity: 3,
translationKey: "secretsManagerSeats",
cost: 30,
},
additionalServiceAccounts: {
quantity: 2,
translationKey: "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,
translationKey: "members",
cost: 50.0,
},
},
cadence: "monthly",
estimatedTax: 9.6,
} satisfies Cart,
},
parameters: {
design: {
@@ -76,116 +88,256 @@ 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,
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
translationKey: "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,
translationKey: "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,
translationKey: "members",
cost: 50.0,
},
},
},
estimatedTax: 16.0,
secretsManager: {
seats: {
quantity: 3,
translationKey: "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,
translationKey: "members",
cost: 50.0,
},
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6.0,
cadence: "month",
secretsManager: {
seats: {
quantity: 3,
translationKey: "members",
cost: 30.0,
},
additionalServiceAccounts: {
quantity: 2,
translationKey: "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,
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
cost: 6.0,
cadence: "month",
secretsManager: {
seats: {
quantity: 3,
translationKey: "members",
cost: 30.0,
},
additionalServiceAccounts: {
quantity: 2,
translationKey: "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,
translationKey: "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,
translationKey: "premiumMembership",
cost: 10.0,
},
},
cadence: "annually",
estimatedTax: 2.71,
} satisfies Cart,
},
};
export const CustomHeaderTemplate: Story = {
args: {
cart: {
passwordManager: {
seats: {
quantity: 1,
translationKey: "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,
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
cadence: "monthly",
discount: {
type: DiscountTypes.PercentOff,
value: 20,
},
estimatedTax: 10.4,
} satisfies Cart,
},
};
export const WithAmountDiscount: Story = {
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
},
secretsManager: {
seats: {
quantity: 3,
translationKey: "members",
cost: 30.0,
},
},
cadence: "annually",
discount: {
type: DiscountTypes.AmountOff,
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) {
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) {
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

@@ -1,12 +1,11 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs";
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
import * as DiscountBadgeStories from "./discount-badge.component.stories";
<Meta of={DiscountBadgeStories} />
# 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,40 @@ 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 {
/** 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 { DiscountTypes, DiscountType } from "@bitwarden/pricing";
type Discount = {
/** The type of discount */
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
/** 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 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
@@ -61,7 +59,3 @@ interface DiscountInfo {
### Amount Discount
<Canvas of={DiscountBadgeStories.AmountDiscount} />
### Inactive Discount
<Canvas of={DiscountBadgeStories.InactiveDiscount} />

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,87 @@ 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 });
it("should return true when discount has percent-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
value: 20,
});
fixture.detectChanges();
expect(component.hasDiscount()).toBe(false);
expect(component.display()).toBe(true);
});
it("should return true when discount is active with percentOff", () => {
fixture.componentRef.setInput("discount", { active: true, percentOff: 20 });
it("should return true when discount has amount-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
value: 10.99,
});
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 false when value is 0 (percent-off)", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
value: 0,
});
fixture.detectChanges();
expect(component.hasDiscount()).toBe(true);
expect(component.display()).toBe(false);
});
it("should return false when percentOff is 0", () => {
fixture.componentRef.setInput("discount", { active: true, percentOff: 0 });
it("should return false when value is 0 (amount-off)", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
value: 0,
});
fixture.detectChanges();
expect(component.hasDiscount()).toBe(false);
});
it("should return false when amountOff is 0", () => {
fixture.componentRef.setInput("discount", { active: true, amountOff: 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,
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,
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,
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,9 @@ export const PercentDiscount: Story = {
}),
args: {
discount: {
active: true,
percentOff: 20,
} as DiscountInfo,
type: DiscountTypes.PercentOff,
value: 20,
} as Discount,
},
};
@@ -53,9 +52,9 @@ export const PercentDiscountDecimal: Story = {
}),
args: {
discount: {
active: true,
percentOff: 0.15, // 15% in decimal format
} as DiscountInfo,
type: DiscountTypes.PercentOff,
value: 0.15, // 15% in decimal format
} as Discount,
},
};
@@ -66,9 +65,9 @@ export const AmountDiscount: Story = {
}),
args: {
discount: {
active: true,
amountOff: 10.99,
} as DiscountInfo,
type: DiscountTypes.AmountOff,
value: 10.99,
} as Discount,
},
};
@@ -79,22 +78,9 @@ export const LargeAmountDiscount: Story = {
}),
args: {
discount: {
active: true,
amountOff: 99.99,
} as DiscountInfo,
},
};
export const InactiveDiscount: Story = {
render: (args) => ({
props: args,
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
}),
args: {
discount: {
active: false,
percentOff: 20,
} as DiscountInfo,
type: DiscountTypes.AmountOff,
value: 99.99,
} as Discount,
},
};
@@ -107,17 +93,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.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

@@ -1,4 +1,4 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs";
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
import * as PricingCardStories from "./pricing-card.component.stories";
<Meta of={PricingCardStories} />
@@ -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 = {
translationKey: 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,31 @@
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;
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;