mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +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
|
// Components
|
||||||
export * from "./components/pricing-card/pricing-card.component";
|
export * from "./components/pricing-card/pricing-card.component";
|
||||||
|
export * from "./components/cart-summary/cart-summary.component";
|
||||||
|
|||||||
Reference in New Issue
Block a user