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 commit2c00891f1f. * Revert "feat(pricing): Add quantity support to discount labels" This reverts commit8350fdd90f. * 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 commit076724276c. * Revert "fix(cart-summary): Adjust discount text styling" This reverts commitd02c12fc2a. * 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:
@@ -12782,5 +12782,8 @@
|
||||
},
|
||||
"invalidSendPassword": {
|
||||
"message": "Invalid Send password"
|
||||
},
|
||||
"perUser": {
|
||||
"message": "per user"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
|
||||
</h2>
|
||||
<span bitTypography="h3"> </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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
5
libs/pricing/src/types/credit.ts
Normal file
5
libs/pricing/src/types/credit.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Credit = {
|
||||
translationKey: string;
|
||||
translationParams?: Array<string | number>;
|
||||
value: number;
|
||||
};
|
||||
Reference in New Issue
Block a user