From 4ef9ab2c9ad962a440976f45b80b293eb87e59d3 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 10 Sep 2025 15:22:16 -0400 Subject: [PATCH] [PM-24982] Create Cart Summary Component in Bitwarden Pricing (#16344) * feature(billing): add cart-summary component * tests(billing): add tests for component * feature(billing): add stories and documentation for storybook * feature(billing): export component * fix: add localization and remove null coalescing for PM * fix: import localization pipe and update story for I18n Service remove service * fix(billing): add IconButtonModule and use lineitem name * fix(billing): Update story props and add Family and Premium examples * fix(billing): Add examples and table of contents do to docs * fix(billing): update aria properties * fix(billing): add figma link and description * fix(billing): update docs * fix(billing): remove optional chaining since property is already checked * fix(billing): Update fonts and button padding * fix(billing): Update bitIconButton size to small --- .../cart-summary/cart-summary.component.html | 120 ++++++ .../cart-summary/cart-summary.component.mdx | 351 ++++++++++++++++++ .../cart-summary.component.spec.ts | 209 +++++++++++ .../cart-summary.component.stories.ts | 154 ++++++++ .../cart-summary/cart-summary.component.ts | 92 +++++ libs/pricing/src/index.ts | 1 + 6 files changed, 927 insertions(+) create mode 100644 libs/pricing/src/components/cart-summary/cart-summary.component.html create mode 100644 libs/pricing/src/components/cart-summary/cart-summary.component.mdx create mode 100644 libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts create mode 100644 libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts create mode 100644 libs/pricing/src/components/cart-summary/cart-summary.component.ts diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html new file mode 100644 index 00000000000..5b315871f6d --- /dev/null +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -0,0 +1,120 @@ +@let passwordManager = this.passwordManager(); +@let additionalStorage = this.additionalStorage(); +@let secretsManager = this.secretsManager(); +@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts; + +
+
+
+

+ {{ "Total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD +

+   + / {{ passwordManager.cadence | i18n }} +
+ +
+ + @if (isExpanded()) { +
+ +
+
+

{{ "Password Manager" | i18n }}

+
+ + +
+
+
+ {{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x + {{ passwordManager.cost | currency: "USD" : "symbol" }} + / + {{ passwordManager.cadence | i18n }} +
+
+
+ {{ passwordManagerTotal() | currency: "USD" : "symbol" }} +
+
+ + + @if (additionalStorage) { +
+
+
+ {{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x + {{ additionalStorage.cost | currency: "USD" : "symbol" }} / + {{ additionalStorage.cadence | i18n }} +
+
+
+ {{ additionalStorageTotal() | currency: "USD" : "symbol" }} +
+
+ } +
+ + + @if (secretsManager) { +
+
+

{{ "Secrets Manager" | i18n }}

+
+ + +
+
+ {{ secretsManager.seats.quantity }} {{ secretsManager.seats.name | i18n }} x + {{ secretsManager.seats.cost | currency: "USD" : "symbol" }} + / {{ secretsManager.seats.cadence | i18n }} +
+
+ {{ secretsManagerSeatsTotal() | currency: "USD" : "symbol" }} +
+
+ + + @if (additionalServiceAccounts) { +
+
+ {{ additionalServiceAccounts.quantity }} + {{ additionalServiceAccounts.name | i18n }} x + {{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }} + / + {{ additionalServiceAccounts.cadence | i18n }} +
+
+ {{ additionalServiceAccountsTotal() | currency: "USD" : "symbol" }} +
+
+ } +
+ } + + +
+

{{ "Estimated tax" | i18n }}

+
+ {{ estimatedTax() | currency: "USD" : "symbol" }} +
+
+ + +
+

{{ "Total" | i18n }}

+
+ {{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }} +
+
+
+ } +
diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx new file mode 100644 index 00000000000..451d733b385 --- /dev/null +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx @@ -0,0 +1,351 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs"; +import * as CartSummaryStories from "./cart-summary.component.stories"; + + + +# Cart Summary + +A reusable UI component for displaying order summary information with consistent styling and +behavior across Bitwarden applications. + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Events](#events) +- [Data Structure](#data-structure) +- [Flexibility](#flexibility) +- [Design](#design) +- [Examples](#examples) + - [Yearly Cadence](#yearly-cadence) + - [With Additional Storage](#with-additional-storage) + - [With Secrets Manager](#with-secrets-manager) + - [With Secrets Manager and Additional Service Accounts](#with-secrets-manager-and-additional-service-accounts) + - [All Products](#all-products) + - [Premium Plan](#premium-plan) + - [Family Plan](#family-plan) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The cart summary component is designed to be used in checkout and subscription interfaces to display +order details, prices, and totals. + +```ts +import { CartSummaryComponent, LineItem } from "@bitwarden/pricing"; +``` + +```html + + +``` + +## 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 | + +### Events + +The cart summary component does not emit any events, but it does have internal behavior for +collapsing and expanding the view. + +## Data Structure + +The component uses the following LineItem data structure: + +```typescript +export type LineItem = { + quantity: number; // Number of items + name: string; // Display name of the item + cost: number; // Cost of each item + cadence: "month" | "year"; // Billing period +}; +``` + +## Flexibility + +The cart summary component provides flexibility through its structured input properties: + +```html + + + + + + + +``` + +## Design + +The component follows the Bitwarden design system with: + +- **Collapsible Interface**: Toggles between compact and detailed views +- **Clean Styling**: Uses Tailwind utility classes for consistent appearance +- **Modern Angular**: Uses `@if` control flow and `@let` with signal inputs +- **Signal inputs**: Type-safe inputs using Angular's signal-based input API +- **Typography**: Consistent text styling using the typography module +- **Layout**: Flexbox layout with clear section boundaries +- **Interactivity**: Collapsible summary with intuitive toggle behavior +- **Accessibility**: Semantic structure with proper button and icon usage using IconButtonModule + +## Examples + +### Yearly Cadence + +Show cart with yearly subscription: + + + +```html + + +``` + +### With Additional Storage + +Show cart with password manager and additional storage: + + + +```html + + +``` + +### With Secrets Manager + +Show cart with password manager and secrets manager seats only: + + + +```html + + +``` + +### With Secrets Manager and Additional Service Accounts + +Show cart with password manager, secrets manager seats, and additional service accounts: + + + +```html + + +``` + +### All Products + +Show a cart with all available products: + + + +```html + + +``` + +### Premium Plan + +Show cart with premium plan: + + +```html + + +``` + +### Families Plan + +Show cart with families plan: + + +```html + + +``` + +## Features + +- **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 +- **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 + +## Do's and Don'ts + +### ✅ Do + +- Use consistent naming and formatting for line 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 + +### ❌ Don't + +- Mix monthly and yearly cadences within the same cart +- Omit required inputs (passwordManager, estimatedTax) +- Modify the component's internal calculations +- Use inconsistent formatting for monetary values +- Override the default styles and layout + +## Accessibility + +The component includes: + +- Semantic HTML structure with proper headings +- Button element for the collapsible toggle +- Keyboard navigation support +- Clear visual indication of expanded state +- Descriptive labels and text +- Sufficient color contrast for text elements diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts new file mode 100644 index 00000000000..9e48e7f5c20 --- /dev/null +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -0,0 +1,209 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { CartSummaryComponent, LineItem } from "./cart-summary.component"; + +describe("CartSummaryComponent", () => { + let component: CartSummaryComponent; + let fixture: ComponentFixture; + + const mockPasswordManager: LineItem = { + quantity: 5, + name: "Password Manager", + cost: 50, + cadence: "month", + }; + + const mockAdditionalStorage: LineItem = { + quantity: 2, + name: "Additional Storage", + cost: 10, + cadence: "month", + }; + + const mockSecretsManager = { + seats: { + quantity: 3, + name: "Secrets Manager Seats", + cost: 30, + cadence: "month" as "month" | "year", + }, + additionalServiceAccounts: { + quantity: 2, + name: "Additional Service Accounts", + cost: 6, + cadence: "month" as "month" | "year", + }, + }; + + const mockEstimatedTax = 9.6; + + function setupComponent( + options: { + passwordManager?: LineItem; + additionalStorage?: LineItem | null; + secretsManager?: { seats: LineItem; additionalServiceAccounts?: LineItem } | null; + estimatedTax?: number; + } = {}, + ) { + const pm = options.passwordManager ?? mockPasswordManager; + const storage = + options.additionalStorage !== null + ? (options.additionalStorage ?? mockAdditionalStorage) + : undefined; + const sm = + options.secretsManager !== null ? (options.secretsManager ?? mockSecretsManager) : undefined; + const tax = options.estimatedTax ?? mockEstimatedTax; + + // Set input values + fixture.componentRef.setInput("passwordManager", pm); + if (storage !== undefined) { + fixture.componentRef.setInput("additionalStorage", storage); + } + if (sm !== undefined) { + fixture.componentRef.setInput("secretsManager", sm); + } + fixture.componentRef.setInput("estimatedTax", tax); + + fixture.detectChanges(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CartSummaryComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CartSummaryComponent); + component = fixture.componentInstance; + + // Default setup with all inputs + setupComponent(); + }); + + it("should create", () => { + // Assert + expect(component).toBeTruthy(); + }); + + describe("UI Toggle Functionality", () => { + it("should toggle expanded state when the button is clicked", () => { + // Arrange + expect(component.isExpanded()).toBe(true); + const toggleButton = fixture.debugElement.query(By.css("button[type='button']")); + expect(toggleButton).toBeTruthy(); + + // Act - First click (collapse) + toggleButton.triggerEventHandler("click", null); + fixture.detectChanges(); + + // Assert - Component is collapsed + expect(component.isExpanded()).toBe(false); + const icon = fixture.debugElement.query(By.css("i.bwi")); + expect(icon.nativeElement.classList.contains("bwi-angle-down")).toBe(true); + + // Act - Second click (expand) + toggleButton.triggerEventHandler("click", null); + fixture.detectChanges(); + + // Assert - Component is expanded again + expect(component.isExpanded()).toBe(true); + expect(icon.nativeElement.classList.contains("bwi-angle-up")).toBe(true); + }); + + it("should hide details when collapsed", () => { + // Arrange + component.isExpanded.set(false); + fixture.detectChanges(); + + // Act / Assert + const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted")); + expect(detailsSection).toBeFalsy(); + }); + + it("should show details when expanded", () => { + // Arrange + component.isExpanded.set(true); + fixture.detectChanges(); + + // Act / Assert + const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted")); + expect(detailsSection).toBeTruthy(); + }); + }); + + describe("Content Rendering", () => { + it("should display correct password manager information", () => { + // Arrange + const pmSection = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b")); + const pmHeading = pmSection.query(By.css(".tw-font-semibold")); + const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-sm")); + const pmTotal = pmSection.query(By.css(".tw-text-sm:not(.tw-flex-1 *)")); + + // Act/ Assert + expect(pmSection).toBeTruthy(); + expect(pmHeading.nativeElement.textContent.trim()).toBe("Password Manager"); + expect(pmLineItem.nativeElement.textContent).toContain("5 Members"); + expect(pmLineItem.nativeElement.textContent).toContain("$50.00"); + expect(pmLineItem.nativeElement.textContent).toContain("month"); + expect(pmTotal.nativeElement.textContent).toContain("$250.00"); // 5 * $50 + }); + + it("should display correct additional storage information", () => { + // Arrange + const storageItem = fixture.debugElement.query( + By.css(".tw-mb-3.tw-border-b .tw-flex-justify-between:nth-of-type(3)"), + ); + const storageText = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b")).nativeElement + .textContent; + // Act/Assert + + expect(storageItem).toBeTruthy(); + expect(storageText).toContain("2 Additional GB"); + expect(storageText).toContain("$10.00"); + expect(storageText).toContain("$20.00"); + }); + + it("should display correct secrets manager information", () => { + // Arrange + const smSection = fixture.debugElement.queryAll(By.css(".tw-mb-3.tw-border-b"))[1]; + const smHeading = smSection.query(By.css(".tw-font-semibold")); + const sectionText = smSection.nativeElement.textContent; + + // Act/ Assert + expect(smSection).toBeTruthy(); + expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager"); + + // Check seats line item + expect(sectionText).toContain("3 Members"); + expect(sectionText).toContain("$30.00"); + expect(sectionText).toContain("$90.00"); // 3 * $30 + + // Check additional service accounts + expect(sectionText).toContain("2 Additional machine accounts"); + expect(sectionText).toContain("$6.00"); + expect(sectionText).toContain("$12.00"); // 2 * $6 + }); + + it("should display correct tax and total", () => { + // Arrange + const taxSection = fixture.debugElement.query( + By.css(".tw-flex.tw-justify-between.tw-mb-3.tw-border-b:last-of-type"), + ); + const expectedTotal = "$381.60"; // 250 + 20 + 90 + 12 + 9.6 + const topTotal = fixture.debugElement.query(By.css("h2")); + const bottomTotal = fixture.debugElement.query( + By.css( + ".tw-flex.tw-justify-between.tw-items-center:last-child .tw-font-semibold:last-child", + ), + ); + + // Act / Assert + expect(taxSection.nativeElement.textContent).toContain("Estimated Tax"); + expect(taxSection.nativeElement.textContent).toContain("$9.60"); + + expect(topTotal.nativeElement.textContent).toContain(expectedTotal); + + expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal); + }); + }); +}); diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts new file mode 100644 index 00000000000..248cbb083ae --- /dev/null +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts @@ -0,0 +1,154 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { IconButtonModule, TypographyModule } from "@bitwarden/components"; + +import { CartSummaryComponent } from "./cart-summary.component"; + +export default { + title: "Billing/Cart Summary", + component: CartSummaryComponent, + description: "A summary of the items in the cart, including pricing details.", + decorators: [ + moduleMetadata({ + imports: [TypographyModule, IconButtonModule], + // Return the same value for all keys for simplicity + providers: [ + { + provide: I18nService, + useValue: { t: (key: string) => key }, + }, + ], + }), + ], + args: { + passwordManager: { + quantity: 5, + name: "Members", + cost: 50.0, + cadence: "month", + }, + estimatedTax: 9.6, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium-Upgrade-flows--pricing-increase-?node-id=877-23653&t=OpDXkupIsvfbh4jT-4", + }, + }, +} as Meta; + +type Story = StoryObj; +export const Default: Story = {}; + +export const WithAdditionalStorage: Story = { + args: { + ...Default.args, + additionalStorage: { + quantity: 2, + name: "Additional storage GB", + cost: 10.0, + cadence: "month", + }, + estimatedTax: 12.0, + }, +}; + +export const PasswordManagerYearlyCadence: Story = { + args: { + passwordManager: { + quantity: 5, + name: "Members", + cost: 500.0, + cadence: "year", + }, + estimatedTax: 120.0, + }, +}; + +export const SecretsManagerSeatsOnly: Story = { + args: { + ...Default.args, + secretsManager: { + seats: { + quantity: 3, + name: "Members", + cost: 30.0, + cadence: "month", + }, + }, + estimatedTax: 16.0, + }, +}; + +export const SecretsManagerSeatsAndServiceAccounts: Story = { + args: { + ...Default.args, + secretsManager: { + seats: { + quantity: 3, + name: "Members", + cost: 30.0, + cadence: "month", + }, + additionalServiceAccounts: { + quantity: 2, + name: "Additional machine accounts", + cost: 6.0, + cadence: "month", + }, + }, + estimatedTax: 16.0, + }, +}; + +export const AllProducts: Story = { + args: { + ...Default.args, + additionalStorage: { + quantity: 2, + name: "Additional storage GB", + cost: 10.0, + cadence: "month", + }, + secretsManager: { + seats: { + quantity: 3, + name: "Members", + cost: 30.0, + cadence: "month", + }, + additionalServiceAccounts: { + quantity: 2, + name: "Additional machine accounts", + cost: 6.0, + cadence: "month", + }, + }, + estimatedTax: 19.2, + }, +}; + +export const FamiliesPlan: Story = { + args: { + passwordManager: { + quantity: 1, + name: "Families membership", + cost: 40.0, + cadence: "year", + }, + estimatedTax: 4.67, + }, +}; + +export const PremiumPlan: Story = { + args: { + passwordManager: { + quantity: 1, + name: "Premium membership", + cost: 10.0, + cadence: "year", + }, + estimatedTax: 2.71, + }, +}; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts new file mode 100644 index 00000000000..b21276b5038 --- /dev/null +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -0,0 +1,92 @@ +import { CurrencyPipe } from "@angular/common"; +import { Component, computed, input, signal } from "@angular/core"; + +import { TypographyModule, IconButtonModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export type LineItem = { + quantity: number; + name: string; + cost: number; + cadence: "month" | "year"; +}; + +/** + * 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. + */ +@Component({ + selector: "billing-cart-summary", + templateUrl: "./cart-summary.component.html", + imports: [TypographyModule, IconButtonModule, CurrencyPipe, I18nPipe], +}) +export class CartSummaryComponent { + // Required inputs + passwordManager = input.required(); + additionalStorage = input(); + secretsManager = input<{ seats: LineItem; additionalServiceAccounts?: LineItem }>(); + estimatedTax = input.required(); + + // UI state + isExpanded = signal(true); + + /** + * Calculates total for password manager line item + */ + readonly passwordManagerTotal = computed(() => { + return this.passwordManager().quantity * this.passwordManager().cost; + }); + + /** + * Calculates total for additional storage line item if present + */ + readonly additionalStorageTotal = computed(() => { + const storage = this.additionalStorage(); + return storage ? storage.quantity * storage.cost : 0; + }); + + /** + * Calculates total for secrets manager seats if present + */ + readonly secretsManagerSeatsTotal = computed(() => { + const sm = this.secretsManager(); + return sm?.seats ? sm.seats.quantity * sm.seats.cost : 0; + }); + + /** + * Calculates total for secrets manager service accounts if present + */ + readonly additionalServiceAccountsTotal = computed(() => { + const sm = this.secretsManager(); + return sm?.additionalServiceAccounts + ? sm.additionalServiceAccounts.quantity * sm.additionalServiceAccounts.cost + : 0; + }); + + /** + * Calculates the total of all line items + */ + readonly total = computed(() => this.getTotalCost()); + + /** + * Toggles the expanded/collapsed state of the cart items + */ + 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() + ); + } +} diff --git a/libs/pricing/src/index.ts b/libs/pricing/src/index.ts index 9eeb2de518d..d7c7772bfcb 100644 --- a/libs/pricing/src/index.ts +++ b/libs/pricing/src/index.ts @@ -1,2 +1,3 @@ // Components export * from "./components/pricing-card/pricing-card.component"; +export * from "./components/cart-summary/cart-summary.component";