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:
@@ -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"> </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"> </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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
[buttonType]="buttonConfig.type"
|
||||
[block]="true"
|
||||
[disabled]="buttonConfig.disabled"
|
||||
(click)="onButtonClick()"
|
||||
(click)="buttonClick.emit()"
|
||||
type="button"
|
||||
>
|
||||
@if (buttonConfig.icon?.position === "before") {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
22
libs/pricing/src/types/cart.ts
Normal file
22
libs/pricing/src/types/cart.ts
Normal 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;
|
||||
};
|
||||
31
libs/pricing/src/types/discount.ts
Normal file
31
libs/pricing/src/types/discount.ts
Normal 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")}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
1
libs/pricing/src/types/maybe.ts
Normal file
1
libs/pricing/src/types/maybe.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Maybe<T> = T | null | undefined;
|
||||
Reference in New Issue
Block a user