1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 10:43:47 +00:00

[PM-29602] Update Cart Summary for Upgrade Flow (#18605)

* feat(billing): update cart-summary logic

 Add functionality to hide breakdown and allow translation params

* tests(cart-summary): update tests and stories

* feat(pricing): Add quantity support to discount labels

* feat(pricing): discount quantity story

* Revert "feat(pricing): discount quantity story"

This reverts commit 2c00891f1f.

* Revert "feat(pricing): Add quantity support to discount labels"

This reverts commit 8350fdd90f.

* fix(cart-summary): Adjust discount text styling

* feat(pricing): adds support for hidden discount amounts

Allows hiding the formatted amount for discounts in the cart summary.

This is useful for scenarios where the discount amount is displayed
elsewhere or is not relevant to the user.

Updates the storybook to include a story demonstrating this feature.

* feat(pricing): conditionally format currency amounts to show or hide decimals

* Revert "feat(pricing): adds support for hidden discount amounts"

This reverts commit 076724276c.

* Revert "fix(cart-summary): Adjust discount text styling"

This reverts commit d02c12fc2a.

* Revert "discount translation"

* feat(pricing): add credit type to cart summary

* feat(pricing-card): Add i18n and icon component infrastructure

* feat(pricing-card): Apply i18n pipe to pricing card template

* refactor(pricing-card): Replace `<i>` tags with `<bit-icon>` in template

* test(pricing-card): Update tests for i18n and icon component changes

* docs(pricing-card): Enhance Storybook and documentation for new features

* feat(pricing-card): Adds "per user" translation key

* refactor(pricing-card): use property binding for bit-icon name

* docs(pricing-card): expand price cadence options in MDX

* fix(icon): update exports for icon types

* feat(billing): Use strongly typed BitwardenIcon for pricing card buttons

* refactor(pricing): Remove unused I18nService from PricingCardComponent

* fix(pricing): Improve pricing card button icon template null-safety

* fix(pricing-card): format update

Clarifies the description of the `price` property within the PricingCard component documentation.

No functional code changes are included.

* refactor: Update discount label typography in cart summary

* refactor(stories): Rename account credit translation key to premium subscription credit

* feat(pricing-card): update spacing for card without button
This commit is contained in:
Stephon Brown
2026-02-03 12:47:58 -05:00
committed by GitHub
parent 2d85b62beb
commit 38465c059c
15 changed files with 662 additions and 59 deletions

View File

@@ -12782,5 +12782,8 @@
},
"invalidSendPassword": {
"message": "Invalid Send password"
},
"perUser": {
"message": "per user"
}
}

View File

@@ -1,10 +1,14 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ButtonType } from "@bitwarden/components";
import { BitwardenIcon, ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
button: {
text: string;
type: ButtonType;
icon?: { type: BitwardenIcon; position: "before" | "after" };
};
features: string[];
};

View File

@@ -1,4 +1,5 @@
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
export { BitwardenIcon } from "./shared/icon";
export * from "./a11y";
export * from "./anon-layout";
export * from "./async-actions";

View File

@@ -16,7 +16,7 @@
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
</h2>
<span bitTypography="h3">&nbsp;</span>
<span bitTypography="body1" class="tw-text-main">/ {{ term }}</span>
<span bitTypography="body1" class="tw-text-main tw-font-normal">/ {{ term }}</span>
}
</div>
<button
@@ -38,21 +38,36 @@
<!-- Password Manager Section -->
<div id="password-manager" 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">{{ "passwordManager" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "passwordManager" | i18n }}
</h3>
</div>
<!-- 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">
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ passwordManagerSeats.quantity }}
{{
translateWithParams(
passwordManagerSeats.translationKey,
passwordManagerSeats.translationParams
)
}}
@if (!passwordManagerSeats.hideBreakdown) {
x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="password-manager-total">
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="password-manager-total"
>
{{ passwordManagerSeatsTotal() | currency: "USD" : "symbol" }}
</div>
</div>
@@ -62,13 +77,25 @@
@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.translationKey | i18n }} x
{{ additionalStorage.cost | currency: "USD" : "symbol" }} /
{{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ additionalStorage.quantity }}
{{
translateWithParams(
additionalStorage.translationKey,
additionalStorage.translationParams
)
}}
@if (!additionalStorage.hideBreakdown) {
x {{ additionalStorage.cost | currency: "USD" : "symbol" }} /
{{ term }}
}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="additional-storage-total">
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="additional-storage-total"
>
{{ additionalStorageTotal() | currency: "USD" : "symbol" }}
</div>
</div>
@@ -80,19 +107,30 @@
@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>
<div bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "secretsManager" | i18n }}
</div>
</div>
<!-- Secrets Manager Members -->
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ secretsManagerSeats.quantity }}
{{
translateWithParams(
secretsManagerSeats.translationKey,
secretsManagerSeats.translationParams
)
}}
@if (!secretsManagerSeats.hideBreakdown) {
x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
}
</div>
<div
bitTypography="body1"
class="tw-text-muted"
class="tw-text-muted tw-font-normal"
data-testid="secrets-manager-seats-total"
>
{{ secretsManagerSeatsTotal() | currency: "USD" : "symbol" }}
@@ -103,12 +141,20 @@
@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">
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ additionalServiceAccounts.quantity }}
{{ additionalServiceAccounts.translationKey | i18n }} x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ term }}
{{
translateWithParams(
additionalServiceAccounts.translationKey,
additionalServiceAccounts.translationParams
)
}}
@if (!additionalServiceAccounts.hideBreakdown) {
x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ term }}
}
</div>
<div
bitTypography="body1"
@@ -129,19 +175,41 @@
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">{{ discountLabel() }}</div>
<div bitTypography="body1" class="tw-text-success-600" data-testid="discount-amount">
-{{ discountAmount() | currency: "USD" : "symbol" }}
</div>
</div>
}
<!-- Credit -->
@if (creditAmount() > 0) {
<div
id="credit-section"
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
data-testid="credit-section"
>
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ translateWithParams(cart.credit!.translationKey, cart.credit!.translationParams) }}
</div>
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="credit-amount"
>
-{{ creditAmount() | currency: "USD" : "symbol" }}
</div>
</div>
}
<!-- Estimated Tax -->
<div
id="estimated-tax-section"
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">{{ "estimatedTax" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "estimatedTax" | i18n }}
</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax">
{{ estimatedTax() | currency: "USD" : "symbol" }}
</div>
@@ -149,7 +217,7 @@
<!-- Total -->
<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>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">{{ "total" | i18n }}</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
{{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }}
</div>

View File

@@ -25,8 +25,10 @@ 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 Account Credit](#with-account-credit)
- [With Percent Discount](#with-percent-discount)
- [With Amount Discount](#with-amount-discount)
- [With Discount and Credit](#with-discount-and-credit)
- [Custom Header Template](#custom-header-template)
- [Premium Plan](#premium-plan)
- [Families Plan](#families-plan)
@@ -85,9 +87,16 @@ export type Cart = {
};
cadence: "annually" | "monthly"; // Billing period for entire cart
discount?: Discount; // Optional cart-level discount
credit?: Credit; // Optional account credit
estimatedTax: number; // Tax amount
};
export type Credit = {
translationKey: string; // Translation key for credit label
translationParams?: Array<string | number>; // Optional params for translation
value: number; // Credit amount to subtract from subtotal
};
import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
export type Discount = {
@@ -330,6 +339,33 @@ Show a cart with all available products:
</billing-cart-summary>
```
### With Account Credit
Show cart with account credit applied:
<Canvas of={CartSummaryStories.WithCredit} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00
}
},
cadence: 'monthly',
credit: {
translationKey: 'accountCredit',
value: 25.00
},
estimatedTax: 10.00
}"
>
</billing-cart-summary>
```
### With Percent Discount
Show cart with percentage-based discount:
@@ -396,6 +432,42 @@ Show cart with fixed amount discount:
</billing-cart-summary>
```
### With Discount and Credit
Show cart with both discount and credit applied:
<Canvas of={CartSummaryStories.WithDiscountAndCredit} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'annually',
discount: {
type: 'percent-off',
value: 15
},
credit: {
translationKey: 'accountCredit',
value: 50.00
},
estimatedTax: 15.00
}"
>
</billing-cart-summary>
```
### Custom Header Template
Show cart with custom header template:
@@ -466,10 +538,12 @@ 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 subtotals, discounts, taxes, and totals using
Angular signals and computed values
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, credits, taxes, and
totals using Angular signals and computed values
- **Discount Support**: Displays both percentage-based and fixed-amount discounts with green success
styling
- **Credit Support**: Shows account credit deductions with clear labeling using i18n translation
keys
- **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

View File

@@ -89,6 +89,8 @@ describe("CartSummaryComponent", () => {
return "Premium membership";
case "discount":
return "discount";
case "accountCredit":
return "accountCredit";
default:
return key;
}
@@ -253,6 +255,126 @@ describe("CartSummaryComponent", () => {
});
});
describe("hideBreakdown Property", () => {
it("should hide cost breakdown when hideBreakdown is true for password manager seats", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const pmLineItem = fixture.debugElement.query(
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
);
// Act / Assert
expect(pmLineItem.nativeElement.textContent).toContain("5 Members");
});
it("should show cost breakdown when hideBreakdown is false for password manager seats", () => {
// Arrange / Act
const pmLineItem = fixture.debugElement.query(
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
);
// Assert
expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month");
});
it("should hide cost breakdown for additional storage when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
passwordManager: {
...mockCart.passwordManager,
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']"));
const storageLineItem = storageItem.query(By.css(".tw-flex-1 .tw-text-muted"));
const storageTotal = storageItem.query(By.css("[data-testid='additional-storage-total']"));
// Act / Assert
expect(storageLineItem.nativeElement.textContent).toContain("2 Additional storage GB");
expect(storageTotal.nativeElement.textContent).toContain("$20.00");
});
it("should hide cost breakdown for secrets manager seats when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
secretsManager: {
seats: {
quantity: 3,
translationKey: "secretsManagerSeats",
cost: 30,
hideBreakdown: true,
},
additionalServiceAccounts: mockCart.secretsManager!.additionalServiceAccounts,
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const smLineItem = fixture.debugElement.query(
By.css('[id="secrets-manager-members"] .tw-text-muted'),
);
const smTotal = fixture.debugElement.query(
By.css('[data-testid="secrets-manager-seats-total"]'),
);
// Act / Assert
expect(smLineItem.nativeElement.textContent).toContain("3 Secrets Manager seats");
expect(smTotal.nativeElement.textContent).toContain("$90.00");
});
it("should hide cost breakdown for additional service accounts when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
secretsManager: {
seats: mockCart.secretsManager!.seats,
additionalServiceAccounts: {
quantity: 2,
translationKey: "additionalServiceAccountsV2",
cost: 6,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const saLineItem = fixture.debugElement.query(
By.css('[id="additional-service-accounts"] .tw-text-muted'),
);
const saTotal = fixture.debugElement.query(
By.css('[data-testid="additional-service-accounts-total"]'),
);
// Act / Assert
expect(saLineItem.nativeElement.textContent).toContain("2 Additional machine accounts");
expect(saTotal.nativeElement.textContent).toContain("$12.00");
});
});
describe("Discount Display", () => {
it("should not display discount section when no discount is present", () => {
// Arrange / Act
@@ -336,6 +458,94 @@ describe("CartSummaryComponent", () => {
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
describe("Credit Display", () => {
it("should not display credit section when no credit is present", () => {
// Arrange / Act
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
// Assert
expect(creditSection).toBeFalsy();
});
it("should display credit correctly", () => {
// Arrange
const cartWithCredit: Cart = {
...mockCart,
credit: {
translationKey: "accountCredit",
value: 25.0,
},
};
fixture.componentRef.setInput("cart", cartWithCredit);
fixture.detectChanges();
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
const creditLabel = creditSection.query(By.css("h3"));
const creditAmount = creditSection.query(By.css('[data-testid="credit-amount"]'));
// Act / Assert
expect(creditSection).toBeTruthy();
expect(creditLabel.nativeElement.textContent.trim()).toBe("accountCredit");
expect(creditAmount.nativeElement.textContent).toContain("-$25.00");
});
it("should apply credit to total calculation", () => {
// Arrange
const cartWithCredit: Cart = {
...mockCart,
credit: {
translationKey: "accountCredit",
value: 50.0,
},
};
fixture.componentRef.setInput("cart", cartWithCredit);
fixture.detectChanges();
// Subtotal = 372, credit = 50, tax = 9.6
// Total = 372 - 50 + 9.6 = 331.6
const expectedTotal = "$331.60";
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);
});
it("should display and apply both discount and credit correctly", () => {
// Arrange
const cartWithBoth: Cart = {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
value: 10,
},
credit: {
translationKey: "accountCredit",
value: 30.0,
},
};
fixture.componentRef.setInput("cart", cartWithBoth);
fixture.detectChanges();
// Subtotal = 372, discount = 37.2 (10%), credit = 30, tax = 9.6
// Total = 372 - 37.2 - 30 + 9.6 = 314.4
const expectedTotal = "$314.40";
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
const topTotal = fixture.debugElement.query(By.css("h2"));
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
// Act / Assert
expect(discountSection).toBeTruthy();
expect(creditSection).toBeTruthy();
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
});
describe("CartSummaryComponent - Custom Header Template", () => {
@@ -424,6 +634,8 @@ describe("CartSummaryComponent - Custom Header Template", () => {
return "Collapse purchase details";
case "discount":
return "discount";
case "accountCredit":
return "accountCredit";
default:
return key;
}

View File

@@ -57,6 +57,8 @@ export default {
return "Your next charge is for";
case "dueOn":
return "due on";
case "premiumSubscriptionCredit":
return "Premium subscription credit";
default:
return key;
}
@@ -341,3 +343,92 @@ export const WithAmountDiscount: Story = {
} satisfies Cart,
},
};
export const WithHiddenBreakdown: Story = {
name: "Hidden Cost Breakdown",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
hideBreakdown: true,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
hideBreakdown: true,
},
},
secretsManager: {
seats: {
quantity: 3,
translationKey: "members",
cost: 30.0,
hideBreakdown: true,
},
additionalServiceAccounts: {
quantity: 2,
translationKey: "additionalServiceAccountsV2",
cost: 6.0,
hideBreakdown: true,
},
},
cadence: "monthly",
estimatedTax: 19.2,
} satisfies Cart,
},
};
export const WithCredit: Story = {
name: "With Account Credit",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
},
cadence: "monthly",
credit: {
translationKey: "premiumSubscriptionCredit",
value: 25.0,
},
estimatedTax: 10.0,
} satisfies Cart,
},
};
export const WithDiscountAndCredit: Story = {
name: "With Both Discount and Credit",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
cadence: "annually",
discount: {
type: DiscountTypes.PercentOff,
value: 15,
},
credit: {
translationKey: "premiumSubscriptionCredit",
value: 50.0,
},
estimatedTax: 15.0,
} satisfies Cart,
},
};

View File

@@ -142,11 +142,22 @@ export class CartSummaryComponent {
return getLabel(this.i18nService, discount);
});
/**
* Calculates the credit amount from the cart credit
*/
readonly creditAmount = computed<number>(() => {
const { credit } = this.cart();
if (!credit) {
return 0;
}
return credit.value;
});
/**
* Calculates the total of all line items including discount and tax
*/
readonly total = computed<number>(
() => this.subtotal() - this.discountAmount() + this.estimatedTax(),
() => this.subtotal() - this.discountAmount() - this.creditAmount() + this.estimatedTax(),
);
/**
@@ -154,6 +165,16 @@ export class CartSummaryComponent {
*/
readonly total$ = toObservable(this.total);
/**
* Translates a key with optional parameters
*/
translateWithParams(key: string, params?: Array<string | number>): string {
if (!params || params.length === 0) {
return this.i18nService.t(key);
}
return this.i18nService.t(key, ...params);
}
/**
* Toggles the expanded/collapsed state of the cart items
*/

View File

@@ -22,22 +22,24 @@
@if (price(); as priceValue) {
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<!-- Show no decimals for whole numbers (e.g. $5), but always show 2 decimals when present (e.g. $120.50) -->
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
priceValue.amount | currency: "$"
priceValue.amount
| currency: "$" : true : (priceValue.amount % 1 === 0 ? "1.0-0" : "1.2-2")
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ priceValue.cadence }}
/ {{ priceValue.cadence | i18n }}
@if (priceValue.showPerUser) {
per user
{{ "perUser" | i18n }}
}
</span>
</div>
</div>
}
<!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12">
@if (button(); as buttonConfig) {
<!-- Button -->
@if (button(); as buttonConfig) {
<div class="tw-mb-6 tw-h-12">
<button
bitButton
[buttonType]="buttonConfig.type"
@@ -46,19 +48,19 @@
(click)="buttonClick.emit()"
type="button"
>
@if (buttonConfig.icon?.position === "before") {
<i class="bwi {{ buttonConfig.icon.type }} tw-me-2" aria-hidden="true"></i>
@if (buttonConfig.icon && buttonConfig.icon.position === "before") {
<bit-icon [name]="buttonConfig.icon.type" class="tw-me-2" aria-hidden="true"></bit-icon>
}
{{ buttonConfig.text }}
@if (
buttonConfig.icon &&
(buttonConfig.icon.position === "after" || !buttonConfig.icon.position)
) {
<i class="bwi {{ buttonConfig.icon.type }} tw-ms-2" aria-hidden="true"></i>
<bit-icon [name]="buttonConfig.icon.type" class="tw-ms-2" aria-hidden="true"></bit-icon>
}
</button>
}
</div>
</div>
}
<!-- Features List -->
<div class="tw-flex-grow">
@@ -67,10 +69,12 @@
<ul class="tw-list-none tw-p-0 tw-m-0">
@for (feature of featureList; track feature) {
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
<i
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
<bit-icon
name="bwi-check"
class="tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
aria-hidden="true"
></i>
>
</bit-icon>
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
feature
}}</span>

View File

@@ -39,7 +39,7 @@ import { PricingCardComponent } from "@bitwarden/pricing";
| Input | Type | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) |
| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
| `price` | `{ amount: number; cadence: "month" \| "monthly" \| "year" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" |
| `features` | `string[]` | **Optional.** List of features with checkmarks |
| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown |
@@ -182,6 +182,58 @@ For coming soon or unavailable plans:
</billing-pricing-card>
```
### With Button Icons
Add icons to buttons for enhanced visual communication:
<Canvas of={PricingCardStories.WithButtonIcon} />
```html
<!-- Icon after text (default) -->
<billing-pricing-card
title="Premium Plan"
tagline="Upgrade for advanced features"
[price]="{ amount: 10, cadence: 'monthly' }"
[button]="{
text: 'Upgrade Now',
type: 'primary',
icon: { type: 'bwi-external-link', position: 'after' }
}"
[features]="premiumFeatures"
>
</billing-pricing-card>
<!-- Icon before text -->
<billing-pricing-card
title="Business Plan"
tagline="Add more features to your plan"
[price]="{ amount: 5, cadence: 'monthly', showPerUser: true }"
[button]="{
text: 'Add Features',
type: 'secondary',
icon: { type: 'bwi-plus', position: 'before' }
}"
[features]="businessFeatures"
>
</billing-pricing-card>
```
### Active Plan Badge
Show which plan is currently active:
<Canvas of={PricingCardStories.ActivePlan} />
```html
<billing-pricing-card
title="Free Plan"
tagline="Your current plan with essential features"
[features]="freeFeatures"
[activeBadge]="{ text: 'Active plan' }"
>
</billing-pricing-card>
```
### Pricing Grid Layout
Multiple cards displayed together:

View File

@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
@@ -69,6 +70,29 @@ describe("PricingCardComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "monthly":
return "monthly";
case "year":
return "year";
case "annually":
return "annually";
case "perUser":
return "per user";
default:
return key;
}
},
},
},
],
}).compileComponents();
// For signal inputs, we need to set required inputs through the host component
@@ -151,7 +175,7 @@ describe("PricingCardComponent", () => {
it("should display bwi-check icons for features", () => {
hostFixture.detectChanges();
const compiled = hostFixture.nativeElement;
const icons = compiled.querySelectorAll("i.bwi-check");
const icons = compiled.querySelectorAll("bit-icon[name='bwi-check']");
expect(icons.length).toBe(3); // One for each feature
});

View File

@@ -1,15 +1,42 @@
import { Meta, StoryObj } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { TypographyModule } from "@bitwarden/components";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SvgModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { PricingCardComponent } from "./pricing-card.component";
export default {
title: "Billing/Pricing Card",
component: PricingCardComponent,
moduleMetadata: {
imports: [TypographyModule],
},
decorators: [
moduleMetadata({
imports: [PricingCardComponent, SvgModule, TypographyModule, I18nPipe],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "monthly":
return "monthly";
case "year":
return "year";
case "annually":
return "annually";
case "perUser":
return "per user";
default:
return key;
}
},
},
},
],
}),
],
args: {
tagline: "Everything you need for secure password management across all your devices",
},
@@ -83,7 +110,7 @@ export const WithoutFeatures: Story = {
}),
args: {
tagline: "Advanced security and management for your organization",
price: { amount: 3, cadence: "monthly" },
price: { amount: 3, cadence: "month" },
button: { text: "Contact Sales", type: "primary" },
},
};
@@ -150,7 +177,7 @@ export const LongTagline: Story = {
args: {
tagline:
"Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business",
price: { amount: 5, cadence: "monthly", showPerUser: true },
price: { amount: 5, cadence: "month", showPerUser: true },
button: { text: "Start Business Trial", type: "primary" },
features: [
"Everything in Premium",
@@ -274,7 +301,7 @@ export const WithoutButton: Story = {
}),
args: {
tagline: "This plan will be available soon with exciting new features",
price: { amount: 15, cadence: "monthly" },
price: { amount: 15, cadence: "month" },
features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"],
},
};

View File

@@ -4,12 +4,15 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core
import {
BadgeModule,
BadgeVariant,
BitwardenIcon,
ButtonModule,
ButtonType,
CardComponent,
IconModule,
SvgModule,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
/**
* A reusable UI-only component that displays pricing information in a card format.
@@ -20,20 +23,29 @@ import {
selector: "billing-pricing-card",
templateUrl: "./pricing-card.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent],
imports: [
BadgeModule,
ButtonModule,
SvgModule,
IconModule,
TypographyModule,
CurrencyPipe,
CardComponent,
I18nPipe,
],
})
export class PricingCardComponent {
readonly tagline = input.required<string>();
readonly price = input<{
amount: number;
cadence: "monthly" | "annually";
cadence: "month" | "monthly" | "year" | "annually";
showPerUser?: boolean;
}>();
readonly button = input<{
type: ButtonType;
text: string;
disabled?: boolean;
icon?: { type: string; position: "before" | "after" };
icon?: { type: BitwardenIcon; position: "before" | "after" };
}>();
readonly features = input<string[]>();
readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>();

View File

@@ -1,10 +1,14 @@
import { Discount } from "@bitwarden/pricing";
import { Credit } from "./credit";
export type CartItem = {
translationKey: string;
translationParams?: Array<string | number>;
quantity: number;
cost: number;
discount?: Discount;
hideBreakdown?: boolean;
};
export type Cart = {
@@ -18,5 +22,6 @@ export type Cart = {
};
cadence: "annually" | "monthly";
discount?: Discount;
credit?: Credit;
estimatedTax: number;
};

View File

@@ -0,0 +1,5 @@
export type Credit = {
translationKey: string;
translationParams?: Array<string | number>;
value: number;
};