mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[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
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
@let passwordManager = this.passwordManager();
|
||||
@let additionalStorage = this.additionalStorage();
|
||||
@let secretsManager = this.secretsManager();
|
||||
@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts;
|
||||
|
||||
<div class="tw-size-full">
|
||||
<div class="tw-flex tw-items-center tw-pb-4">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<h2 bitTypography="h4" id="purchase-summary-heading" class="!tw-m-0">
|
||||
{{ "Total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
|
||||
</h2>
|
||||
<span bitTypography="h3"> </span>
|
||||
<span bitTypography="body1" class="tw-text-main">/ {{ passwordManager.cadence | i18n }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
size="small"
|
||||
(click)="toggleExpanded()"
|
||||
[bitIconButton]="isExpanded() ? 'bwi-angle-up' : 'bwi-angle-down'"
|
||||
[label]="isExpanded() ? 'Collapse purchase details' : 'Expand purchase details'"
|
||||
[attr.aria-expanded]="isExpanded()"
|
||||
[attr.aria-controls]="'purchase-summary-details'"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
@if (isExpanded()) {
|
||||
<div id="purchase-summary-details" class="tw-pb-2">
|
||||
<!-- Password Manager Section -->
|
||||
<div class="tw-border-b tw-border-secondary-100 tw-pb-2">
|
||||
<div class="tw-flex tw-justify-between tw-mb-1">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "Password Manager" | i18n }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Password Manager Members -->
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x
|
||||
{{ passwordManager.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ passwordManager.cadence | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManagerTotal() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Storage -->
|
||||
@if (additionalStorage) {
|
||||
<div 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 }}
|
||||
</div>
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalStorageTotal() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Secrets Manager Section -->
|
||||
@if (secretsManager) {
|
||||
<div 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">{{ "Secrets Manager" | i18n }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Secrets Manager Members -->
|
||||
<div 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 }}
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ secretsManagerSeatsTotal() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Service Accounts -->
|
||||
@if (additionalServiceAccounts) {
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalServiceAccounts.quantity }}
|
||||
{{ additionalServiceAccounts.name | i18n }} x
|
||||
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ additionalServiceAccounts.cadence | i18n }}
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalServiceAccountsTotal() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Estimated Tax -->
|
||||
<div class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "Estimated tax" | i18n }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ estimatedTax() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div 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">
|
||||
{{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Meta, Story, Canvas } from "@storybook/addon-docs";
|
||||
import * as CartSummaryStories from "./cart-summary.component.stories";
|
||||
|
||||
<Meta of={CartSummaryStories} />
|
||||
|
||||
# Cart Summary
|
||||
|
||||
A reusable UI component for displaying order summary information with consistent styling and
|
||||
behavior across Bitwarden applications.
|
||||
|
||||
<Canvas of={CartSummaryStories.Default} />
|
||||
|
||||
## 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
|
||||
<billing-cart-summary
|
||||
[passwordManager]="passwordManagerItem"
|
||||
[additionalStorage]="additionalStorageItem"
|
||||
[secretsManager]="secretsManagerItems"
|
||||
[estimatedTax]="taxAmount"
|
||||
>
|
||||
</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 |
|
||||
|
||||
### 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
|
||||
<!-- Basic usage with only Password Manager -->
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'Members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[estimatedTax]="9.60"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
|
||||
<!-- With Additional Storage -->
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'Members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[additionalStorage]="{
|
||||
quantity: 2,
|
||||
name: 'Additional storage GB',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[estimatedTax]="12.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
<Canvas of={CartSummaryStories.PasswordManagerYearlyCadence} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'Members',
|
||||
cost: 500.00,
|
||||
cadence: 'year'
|
||||
}"
|
||||
[estimatedTax]="120.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Additional Storage
|
||||
|
||||
Show cart with password manager and additional storage:
|
||||
|
||||
<Canvas of={CartSummaryStories.WithAdditionalStorage} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'Members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[additionalStorage]="{
|
||||
quantity: 2,
|
||||
name: 'Additional storage GB',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[estimatedTax]="12.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Secrets Manager
|
||||
|
||||
Show cart with password manager and secrets manager seats only:
|
||||
|
||||
<Canvas of={CartSummaryStories.SecretsManagerSeatsOnly} />
|
||||
|
||||
```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'
|
||||
}
|
||||
}"
|
||||
[estimatedTax]="16.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Secrets Manager and Additional Service Accounts
|
||||
|
||||
Show cart with password manager, secrets manager seats, and additional service accounts:
|
||||
|
||||
<Canvas of={CartSummaryStories.SecretsManagerSeatsAndServiceAccounts} />
|
||||
|
||||
```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'
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'Additional machine accounts',
|
||||
cost: 6.00,
|
||||
cadence: 'month'
|
||||
}
|
||||
}"
|
||||
[estimatedTax]="16.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### All Products
|
||||
|
||||
Show a cart with all available products:
|
||||
|
||||
<Canvas of={CartSummaryStories.AllProducts} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'Members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[additionalStorage]="{
|
||||
quantity: 2,
|
||||
name: 'Additional storage GB',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[secretsManager]="{
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'Members',
|
||||
cost: 30.00,
|
||||
cadence: 'month'
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'Additional machine accounts',
|
||||
cost: 6.00,
|
||||
cadence: 'month'
|
||||
}
|
||||
}"
|
||||
[estimatedTax]="19.20"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### Premium Plan
|
||||
|
||||
Show cart with premium plan:
|
||||
|
||||
<Canvas of={CartSummaryStories.PremiumPlan} />
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 1,
|
||||
name: 'Premium membership',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[estimatedTax]="2.71"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### Families Plan
|
||||
|
||||
Show cart with families plan:
|
||||
|
||||
<Canvas of={CartSummaryStories.FamiliesPlan} />
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 1,
|
||||
name: 'Families membership',
|
||||
cost: 40.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[estimatedTax]="4.67"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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<CartSummaryComponent>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<CartSummaryComponent>;
|
||||
|
||||
type Story = StoryObj<CartSummaryComponent>;
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -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<LineItem>();
|
||||
additionalStorage = input<LineItem>();
|
||||
secretsManager = input<{ seats: LineItem; additionalServiceAccounts?: LineItem }>();
|
||||
estimatedTax = input.required<number>();
|
||||
|
||||
// UI state
|
||||
isExpanded = signal(true);
|
||||
|
||||
/**
|
||||
* Calculates total for password manager line item
|
||||
*/
|
||||
readonly passwordManagerTotal = computed<number>(() => {
|
||||
return this.passwordManager().quantity * this.passwordManager().cost;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates total for additional storage line item if present
|
||||
*/
|
||||
readonly additionalStorageTotal = computed<number>(() => {
|
||||
const storage = this.additionalStorage();
|
||||
return storage ? storage.quantity * storage.cost : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates total for secrets manager seats if present
|
||||
*/
|
||||
readonly secretsManagerSeatsTotal = computed<number>(() => {
|
||||
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<number>(() => {
|
||||
const sm = this.secretsManager();
|
||||
return sm?.additionalServiceAccounts
|
||||
? sm.additionalServiceAccounts.quantity * sm.additionalServiceAccounts.cost
|
||||
: 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates the total of all line items
|
||||
*/
|
||||
readonly total = computed<number>(() => 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
// Components
|
||||
export * from "./components/pricing-card/pricing-card.component";
|
||||
export * from "./components/cart-summary/cart-summary.component";
|
||||
|
||||
Reference in New Issue
Block a user