1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-21 11:53:34 +00:00

[PM-29604] [PM-29605] [PM-29606] Premium subscription page redesign (#18300)

* refactor(subscription-card): Correctly name card action

* feat(storage-card): Switch 'callsToActionDisabled' into 1 input per CTA

* refactor(additional-options-card): Switch 'callsToActionDisabled' into 1 input per CTA

* feat(contract-alignment): Remove 'active' property from Discount

* feat(contract-alignment): Rename 'name' to 'translationKey' in Cart model

* feat(response-models): Add DiscountResponse

* feat(response-models): Add StorageResponse

* feat(response-models): Add CartResponse

* feat(response-models): Add BitwardenSubscriptionResponse

* feat(clients): Add new endpoint invocations

* feat(redesign): Add feature flags

* feat(redesign): Add AdjustAccountSubscriptionStorageDialogComponent

* feat(redesign): Add AccountSubscriptionComponent

* feat(redesign): Pivot subscription component on FF

* docs: Note FF removal POIs

* fix(subscription-card): Resolve compilation error in stories

* fix(upgrade-payment.service): Fix failing tests
This commit is contained in:
Alex Morask
2026-01-12 10:45:12 -06:00
committed by GitHub
parent 3707a6fc84
commit b1dcf34e9a
44 changed files with 1238 additions and 271 deletions

View File

@@ -0,0 +1,102 @@
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Cart } from "@bitwarden/pricing";
import {
BitwardenSubscription,
Storage,
SubscriptionStatus,
SubscriptionStatuses,
} from "@bitwarden/subscription";
export class BitwardenSubscriptionResponse extends BaseResponse {
status: SubscriptionStatus;
cart: Cart;
storage: Storage;
cancelAt?: Date;
canceled?: Date;
nextCharge?: Date;
suspension?: Date;
gracePeriod?: number;
constructor(response: any) {
super(response);
const status = this.getResponseProperty("Status");
if (
status !== SubscriptionStatuses.Incomplete &&
status !== SubscriptionStatuses.IncompleteExpired &&
status !== SubscriptionStatuses.Trialing &&
status !== SubscriptionStatuses.Active &&
status !== SubscriptionStatuses.PastDue &&
status !== SubscriptionStatuses.Canceled &&
status !== SubscriptionStatuses.Unpaid
) {
throw new Error(`Failed to parse invalid subscription status: ${status}`);
}
this.status = status;
this.cart = new CartResponse(this.getResponseProperty("Cart"));
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
const suspension = this.getResponseProperty("Suspension");
if (suspension) {
this.suspension = new Date(suspension);
}
const gracePeriod = this.getResponseProperty("GracePeriod");
if (gracePeriod) {
this.gracePeriod = gracePeriod;
}
const nextCharge = this.getResponseProperty("NextCharge");
if (nextCharge) {
this.nextCharge = new Date(nextCharge);
}
const cancelAt = this.getResponseProperty("CancelAt");
if (cancelAt) {
this.cancelAt = new Date(cancelAt);
}
const canceled = this.getResponseProperty("Canceled");
if (canceled) {
this.canceled = new Date(canceled);
}
}
toDomain = (): BitwardenSubscription => {
switch (this.status) {
case SubscriptionStatuses.Incomplete:
case SubscriptionStatuses.IncompleteExpired:
case SubscriptionStatuses.PastDue:
case SubscriptionStatuses.Unpaid: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
suspension: this.suspension!,
gracePeriod: this.gracePeriod!,
};
}
case SubscriptionStatuses.Trialing:
case SubscriptionStatuses.Active: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
nextCharge: this.nextCharge!,
cancelAt: this.cancelAt,
};
}
case SubscriptionStatuses.Canceled: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
canceled: this.canceled!,
};
}
}
};
}

View File

@@ -0,0 +1,97 @@
import {
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
import { DiscountResponse } from "./discount.response";
export class CartItemResponse extends BaseResponse implements CartItem {
translationKey: string;
quantity: number;
cost: number;
discount?: Discount;
constructor(response: any) {
super(response);
this.translationKey = this.getResponseProperty("TranslationKey");
this.quantity = this.getResponseProperty("Quantity");
this.cost = this.getResponseProperty("Cost");
const discount = this.getResponseProperty("Discount");
if (discount) {
this.discount = discount;
}
}
}
class PasswordManagerCartItemResponse extends BaseResponse {
seats: CartItem;
additionalStorage?: CartItem;
constructor(response: any) {
super(response);
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
const additionalStorage = this.getResponseProperty("AdditionalStorage");
if (additionalStorage) {
this.additionalStorage = new CartItemResponse(additionalStorage);
}
}
}
class SecretsManagerCartItemResponse extends BaseResponse {
seats: CartItem;
additionalServiceAccounts?: CartItem;
constructor(response: any) {
super(response);
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
if (additionalServiceAccounts) {
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
}
}
}
export class CartResponse extends BaseResponse implements Cart {
passwordManager: {
seats: CartItem;
additionalStorage?: CartItem;
};
secretsManager?: {
seats: CartItem;
additionalServiceAccounts?: CartItem;
};
cadence: SubscriptionCadence;
discount?: Discount;
estimatedTax: number;
constructor(response: any) {
super(response);
this.passwordManager = new PasswordManagerCartItemResponse(
this.getResponseProperty("PasswordManager"),
);
const secretsManager = this.getResponseProperty("SecretsManager");
if (secretsManager) {
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
}
const cadence = this.getResponseProperty("Cadence");
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
}
this.cadence = cadence;
const discount = this.getResponseProperty("Discount");
if (discount) {
this.discount = new DiscountResponse(discount);
}
this.estimatedTax = this.getResponseProperty("EstimatedTax");
}
}

View File

@@ -0,0 +1,18 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
export class DiscountResponse extends BaseResponse implements Discount {
type: DiscountType;
value: number;
constructor(response: any) {
super(response);
const type = this.getResponseProperty("Type");
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
throw new Error(`Failed to parse invalid discount type: ${type}`);
}
this.type = type;
this.value = this.getResponseProperty("Value");
}
}

View File

@@ -0,0 +1,16 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Storage } from "@bitwarden/subscription";
export class StorageResponse extends BaseResponse implements Storage {
available: number;
used: number;
readableUsed: string;
constructor(response: any) {
super(response);
this.available = this.getResponseProperty("Available");
this.used = this.getResponseProperty("Used");
this.readableUsed = this.getResponseProperty("ReadableUsed");
}
}

View File

@@ -31,6 +31,8 @@ export enum FeatureFlag {
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
PM26462_Milestone_3 = "pm-26462-milestone-3",
PM23341_Milestone_2 = "pm-23341-milestone-2",
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -137,6 +139,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,
[FeatureFlag.PM23341_Milestone_2]: FALSE,
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction {
return new PaymentResponse(r);
}
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
postReinstatePremium(): Promise<any> {
return this.send("POST", "/accounts/reinstate-premium", null, true, false);
}

View File

@@ -46,7 +46,7 @@
<div class="tw-flex-1">
@let passwordManagerSeats = cart.passwordManager.seats;
<div bitTypography="body1" class="tw-text-muted">
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
@@ -63,7 +63,7 @@
<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" }} /
{{ term }}
</div>
@@ -86,7 +86,7 @@
<!-- Secrets Manager Members -->
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
</div>
@@ -105,7 +105,7 @@
<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" }}
/
{{ term }}

View File

@@ -67,7 +67,7 @@ The component uses the following Cart and CartItem data structures:
```typescript
export type CartItem = {
name: string; // Display name for i18n lookup
translationKey: string; // Translation key for i18n lookup
quantity: number; // Number of items
cost: number; // Cost per item
discount?: Discount; // Optional item-level discount
@@ -92,7 +92,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
export type Discount = {
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
active: boolean; // Whether discount is currently applied
value: number; // Dollar amount or percentage (20 for 20%)
};
```
@@ -108,7 +107,7 @@ The cart summary component provides flexibility through its structured Cart inpu
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
}
},
@@ -124,12 +123,12 @@ The cart summary component provides flexibility through its structured Cart inpu
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
@@ -145,14 +144,13 @@ The cart summary component provides flexibility through its structured Cart inpu
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
}
},
cadence: 'monthly',
discount: {
type: 'percent-off',
active: true,
value: 20
},
estimatedTax: 8.00
@@ -188,7 +186,7 @@ Show cart with yearly subscription:
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 500.00
}
},
@@ -211,12 +209,12 @@ Show cart with password manager and additional storage:
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
@@ -239,14 +237,14 @@ Show cart with password manager and secrets manager seats only:
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
}
},
secretsManager: {
seats: {
quantity: 3,
name: 'members',
translationKey: 'members',
cost: 30.00
}
},
@@ -269,19 +267,19 @@ Show cart with password manager, secrets manager seats, and additional service a
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
}
},
secretsManager: {
seats: {
quantity: 3,
name: 'members',
translationKey: 'members',
cost: 30.00
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
translationKey: 'additionalServiceAccounts',
cost: 6.00
}
},
@@ -304,24 +302,24 @@ Show a cart with all available products:
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
secretsManager: {
seats: {
quantity: 3,
name: 'members',
translationKey: 'members',
cost: 30.00
},
additionalServiceAccounts: {
quantity: 2,
name: 'additionalServiceAccounts',
translationKey: 'additionalServiceAccounts',
cost: 6.00
}
},
@@ -344,19 +342,18 @@ Show cart with percentage-based discount:
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
name: 'additionalStorageGB',
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'monthly',
discount: {
type: 'percent-off',
active: true,
value: 20
},
estimatedTax: 10.40
@@ -377,21 +374,20 @@ Show cart with fixed amount discount:
passwordManager: {
seats: {
quantity: 5,
name: 'members',
translationKey: 'members',
cost: 50.00
}
},
secretsManager: {
seats: {
quantity: 3,
name: 'members',
translationKey: 'members',
cost: 30.00
}
},
cadence: 'annually',
discount: {
type: 'amount-off',
active: true,
value: 50.00
},
estimatedTax: 95.00
@@ -431,7 +427,7 @@ Show cart with premium plan:
passwordManager: {
seats: {
quantity: 1,
name: 'premiumMembership',
translationKey: 'premiumMembership',
cost: 10.00
}
},
@@ -454,7 +450,7 @@ Show cart with families plan:
passwordManager: {
seats: {
quantity: 1,
name: 'familiesMembership',
translationKey: 'familiesMembership',
cost: 40.00
}
},
@@ -488,8 +484,7 @@ Show cart with families plan:
- Use consistent naming and formatting for cart items
- Include clear quantity and unit pricing information
- Ensure tax estimates are accurate and clearly labeled
- Set `active: true` on discounts that should be displayed
- Use localized strings for CartItem names (for i18n lookup)
- 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")

View File

@@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
translationKey: "additionalStorageGB",
cost: 10,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "secretsManagerSeats",
translationKey: "secretsManagerSeats",
cost: 30,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
translationKey: "additionalServiceAccountsV2",
cost: 6,
},
},
@@ -270,7 +270,6 @@ describe("CartSummaryComponent", () => {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
},
};
@@ -296,7 +295,6 @@ describe("CartSummaryComponent", () => {
...mockCart,
discount: {
type: DiscountTypes.AmountOff,
active: true,
value: 50.0,
},
};
@@ -315,33 +313,12 @@ describe("CartSummaryComponent", () => {
expect(discountAmount.nativeElement.textContent).toContain("-$50.00");
});
it("should not display discount when discount is inactive", () => {
// Arrange
const cartWithInactiveDiscount: Cart = {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
active: false,
value: 20,
},
};
fixture.componentRef.setInput("cart", cartWithInactiveDiscount);
fixture.detectChanges();
// Act / Assert
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
expect(discountSection).toBeFalsy();
});
it("should apply discount to total calculation", () => {
// Arrange
const cartWithDiscount: Cart = {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
},
};
@@ -382,24 +359,24 @@ describe("CartSummaryComponent - Custom Header Template", () => {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
translationKey: "additionalStorageGB",
cost: 10,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "secretsManagerSeats",
translationKey: "secretsManagerSeats",
cost: 30,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
translationKey: "additionalServiceAccountsV2",
cost: 6,
},
},

View File

@@ -71,7 +71,7 @@ export default {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50.0,
},
},
@@ -98,12 +98,12 @@ export const WithAdditionalStorage: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
@@ -120,7 +120,7 @@ export const PasswordManagerYearlyCadence: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 500.0,
},
},
@@ -137,14 +137,14 @@ export const SecretsManagerSeatsOnly: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50.0,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
translationKey: "members",
cost: 30.0,
},
},
@@ -161,19 +161,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50.0,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
translationKey: "members",
cost: 30.0,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
translationKey: "additionalServiceAccountsV2",
cost: 6.0,
},
},
@@ -190,24 +190,24 @@ export const AllProducts: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
translationKey: "members",
cost: 30.0,
},
additionalServiceAccounts: {
quantity: 2,
name: "additionalServiceAccountsV2",
translationKey: "additionalServiceAccountsV2",
cost: 6.0,
},
},
@@ -223,7 +223,7 @@ export const FamiliesPlan: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "familiesMembership",
translationKey: "familiesMembership",
cost: 40.0,
},
},
@@ -239,7 +239,7 @@ export const PremiumPlan: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "premiumMembership",
translationKey: "premiumMembership",
cost: 10.0,
},
},
@@ -255,7 +255,7 @@ export const CustomHeaderTemplate: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "premiumMembership",
translationKey: "premiumMembership",
cost: 10.0,
},
},
@@ -296,19 +296,18 @@ export const WithPercentDiscount: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
cadence: "monthly",
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
},
estimatedTax: 10.4,
@@ -322,21 +321,20 @@ export const WithAmountDiscount: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50.0,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
translationKey: "members",
cost: 30.0,
},
},
cadence: "annually",
discount: {
type: DiscountTypes.AmountOff,
active: true,
value: 50.0,
},
estimatedTax: 95.0,

View File

@@ -116,7 +116,7 @@ export class CartSummaryComponent {
*/
readonly discountAmount = computed<number>(() => {
const { discount } = this.cart();
if (!discount || !discount.active) {
if (!discount) {
return 0;
}
@@ -136,7 +136,7 @@ export class CartSummaryComponent {
*/
readonly discountLabel = computed<string>(() => {
const { discount } = this.cart();
if (!discount || !discount.active) {
if (!discount) {
return "";
}
return getLabel(this.i18nService, discount);

View File

@@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
type Discount = {
/** The type of discount */
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
/** Whether the discount is currently active */
active: boolean;
/** The discount value (percentage or amount depending on type) */
value: number;
};
@@ -47,8 +45,7 @@ type Discount = {
## Behavior
- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is
greater than 0.
- 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.
@@ -62,7 +59,3 @@ type Discount = {
### Amount Discount
<Canvas of={DiscountBadgeStories.AmountDiscount} />
### Inactive Discount
<Canvas of={DiscountBadgeStories.InactiveDiscount} />

View File

@@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => {
expect(component.display()).toBe(false);
});
it("should return false when discount is inactive", () => {
it("should return true when discount has percent-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: false,
value: 20,
});
fixture.detectChanges();
expect(component.display()).toBe(false);
});
it("should return true when discount is active with percent-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
});
fixture.detectChanges();
expect(component.display()).toBe(true);
});
it("should return true when discount is active with amount-off", () => {
it("should return true when discount has amount-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
active: true,
value: 10.99,
});
fixture.detectChanges();
@@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => {
it("should return false when value is 0 (percent-off)", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: true,
value: 0,
});
fixture.detectChanges();
@@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => {
it("should return false when value is 0 (amount-off)", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
active: true,
value: 0,
});
fixture.detectChanges();
@@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => {
it("should return percentage text when type is percent-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
});
fixture.detectChanges();
@@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => {
it("should convert decimal value to percentage for percent-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.PercentOff,
active: true,
value: 0.15,
});
fixture.detectChanges();
@@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => {
it("should return amount text when type is amount-off", () => {
fixture.componentRef.setInput("discount", {
type: DiscountTypes.AmountOff,
active: true,
value: 10.99,
});
fixture.detectChanges();

View File

@@ -40,7 +40,6 @@ export const PercentDiscount: Story = {
args: {
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 20,
} as Discount,
},
@@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = {
args: {
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 0.15, // 15% in decimal format
} as Discount,
},
@@ -68,7 +66,6 @@ export const AmountDiscount: Story = {
args: {
discount: {
type: DiscountTypes.AmountOff,
active: true,
value: 10.99,
} as Discount,
},
@@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = {
args: {
discount: {
type: DiscountTypes.AmountOff,
active: true,
value: 99.99,
} as Discount,
},
};
export const InactiveDiscount: Story = {
render: (args) => ({
props: args,
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
}),
args: {
discount: {
type: DiscountTypes.PercentOff,
active: false,
value: 20,
} as Discount,
},
};
export const NoDiscount: Story = {
render: (args) => ({
props: args,

View File

@@ -23,7 +23,7 @@ export class DiscountBadgeComponent {
if (!discount) {
return false;
}
return discount.active && discount.value > 0;
return discount.value > 0;
});
readonly label = computed<Maybe<string>>(() => {

View File

@@ -1,7 +1,7 @@
import { Discount } from "@bitwarden/pricing";
export type CartItem = {
name: string;
translationKey: string;
quantity: number;
cost: number;
discount?: Discount;

View File

@@ -9,7 +9,6 @@ export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes];
export type Discount = {
type: DiscountType;
active: boolean;
value: number;
};

View File

@@ -13,8 +13,8 @@
bitButton
buttonType="secondary"
type="button"
[disabled]="callsToActionDisabled()"
(click)="callToActionClicked.emit('download-license')"
[disabled]="downloadLicenseDisabled()"
(click)="callToActionClicked.emit(actions.DownloadLicense)"
>
{{ "downloadLicense" | i18n }}
</button>
@@ -22,8 +22,8 @@
bitButton
buttonType="danger"
type="button"
[disabled]="callsToActionDisabled()"
(click)="callToActionClicked.emit('cancel-subscription')"
[disabled]="cancelSubscriptionDisabled()"
(click)="callToActionClicked.emit(actions.CancelSubscription)"
>
{{ "cancelSubscription" | i18n }}
</button>

View File

@@ -21,6 +21,8 @@ subscription actions.
- [Examples](#examples)
- [Default](#default)
- [Actions Disabled](#actions-disabled)
- [Download License Disabled](#download-license-disabled)
- [Cancel Subscription Disabled](#cancel-subscription-disabled)
- [Features](#features)
- [Do's and Don'ts](#dos-and-donts)
- [Accessibility](#accessibility)
@@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription";
### Inputs
| Input | Type | Description |
| ----------------------- | --------- | ---------------------------------------------------------------------- |
| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. |
| Input | Type | Description |
| ---------------------------- | --------- | ----------------------------------------------------------------------------- |
| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. |
| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. |
### Outputs
@@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations):
```html
<billing-additional-options-card
[callsToActionDisabled]="true"
[downloadLicenseDisabled]="true"
[cancelSubscriptionDisabled]="true"
(callToActionClicked)="handleAction($event)"
>
</billing-additional-options-card>
```
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
downloading the license or processing subscription cancellation.
**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control
button states during async operations like downloading the license or processing subscription
cancellation.
### Download License Disabled
Component with only the download license button disabled:
<Canvas of={AdditionalOptionsCardStories.DownloadLicenseDisabled} />
```html
<billing-additional-options-card
[downloadLicenseDisabled]="true"
[cancelSubscriptionDisabled]="false"
(callToActionClicked)="handleAction($event)"
>
</billing-additional-options-card>
```
### Cancel Subscription Disabled
Component with only the cancel subscription button disabled:
<Canvas of={AdditionalOptionsCardStories.CancelSubscriptionDisabled} />
```html
<billing-additional-options-card
[downloadLicenseDisabled]="false"
[cancelSubscriptionDisabled]="true"
(callToActionClicked)="handleAction($event)"
>
</billing-additional-options-card>
```
## Features
@@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation.
- Handle both `download-license` and `cancel-subscription` events in parent components
- Show appropriate confirmation dialogs before executing destructive actions (cancel subscription)
- Disable buttons or show loading states during async operations
- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during
operations
- Provide clear user feedback after action completion
- Consider adding additional safety measures for subscription cancellation
- Control button states independently based on business logic
### ❌ Don't

View File

@@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => {
});
});
describe("callsToActionDisabled", () => {
it("should disable both buttons when callsToActionDisabled is true", () => {
fixture.componentRef.setInput("callsToActionDisabled", true);
describe("button disabled states", () => {
it("should enable both buttons by default", () => {
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].nativeElement.disabled).toBe(false);
expect(buttons[1].nativeElement.disabled).toBe(false);
});
it("should disable download license button when downloadLicenseDisabled is true", () => {
fixture.componentRef.setInput("downloadLicenseDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
});
it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => {
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
});
it("should disable both buttons independently", () => {
fixture.componentRef.setInput("downloadLicenseDisabled", true);
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
@@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => {
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
});
it("should enable both buttons when callsToActionDisabled is false", () => {
fixture.componentRef.setInput("callsToActionDisabled", false);
it("should allow download enabled while cancel disabled", () => {
fixture.componentRef.setInput("downloadLicenseDisabled", false);
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].nativeElement.disabled).toBe(false);
expect(buttons[1].nativeElement.disabled).toBe(false);
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
});
it("should enable both buttons by default", () => {
it("should allow cancel enabled while download disabled", () => {
fixture.componentRef.setInput("downloadLicenseDisabled", true);
fixture.componentRef.setInput("cancelSubscriptionDisabled", false);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].nativeElement.disabled).toBe(false);
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
expect(buttons[1].nativeElement.disabled).toBe(false);
});
});

View File

@@ -44,6 +44,23 @@ export const Default: Story = {
export const ActionsDisabled: Story = {
name: "Actions Disabled",
args: {
callsToActionDisabled: true,
downloadLicenseDisabled: true,
cancelSubscriptionDisabled: true,
},
};
export const DownloadLicenseDisabled: Story = {
name: "Download License Disabled",
args: {
downloadLicenseDisabled: true,
cancelSubscriptionDisabled: false,
},
};
export const CancelSubscriptionDisabled: Story = {
name: "Cancel Subscription Disabled",
args: {
downloadLicenseDisabled: false,
cancelSubscriptionDisabled: true,
},
};

View File

@@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core
import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription";
export const AdditionalOptionsCardActions = {
DownloadLicense: "download-license",
CancelSubscription: "cancel-subscription",
} as const;
export type AdditionalOptionsCardAction =
(typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions];
@Component({
selector: "billing-additional-options-card",
@@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript
imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe],
})
export class AdditionalOptionsCardComponent {
readonly callsToActionDisabled = input<boolean>(false);
readonly downloadLicenseDisabled = input<boolean>(false);
readonly cancelSubscriptionDisabled = input<boolean>(false);
readonly callToActionClicked = output<AdditionalOptionsCardAction>();
protected readonly actions = AdditionalOptionsCardActions;
}

View File

@@ -21,8 +21,8 @@
bitButton
buttonType="secondary"
type="button"
[disabled]="callsToActionDisabled()"
(click)="callToActionClicked.emit('add-storage')"
[disabled]="addStorageDisabled()"
(click)="callToActionClicked.emit(actions.AddStorage)"
>
{{ "addStorage" | i18n }}
</button>
@@ -30,8 +30,8 @@
bitButton
buttonType="secondary"
type="button"
[disabled]="callsToActionDisabled() || !canRemoveStorage()"
(click)="callToActionClicked.emit('remove-storage')"
[disabled]="removeStorageDisabled()"
(click)="callToActionClicked.emit(actions.RemoveStorage)"
>
{{ "removeStorage" | i18n }}
</button>

View File

@@ -30,6 +30,8 @@ full).
- [Large Storage Pool (1TB)](#large-storage-pool-1tb)
- [Small Storage Pool (1GB)](#small-storage-pool-1gb)
- [Actions Disabled](#actions-disabled)
- [Add Storage Disabled](#add-storage-disabled)
- [Remove Storage Disabled](#remove-storage-disabled)
- [Features](#features)
- [Do's and Don'ts](#dos-and-donts)
- [Accessibility](#accessibility)
@@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription";
### Inputs
| Input | Type | Description |
| ----------------------- | --------- | ---------------------------------------------------------------------- |
| `storage` | `Storage` | **Required.** Storage data including available, used, and readable |
| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. |
| Input | Type | Description |
| ----------------------- | --------- | ------------------------------------------------------------------------ |
| `storage` | `Storage` | **Required.** Storage data including available, used, and readable |
| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. |
| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. |
### Outputs
@@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage:
Key behaviors:
- Progress bar color changes from blue (primary) to red (danger) when full
- Remove storage button is disabled when storage is full
- Button disabled states are controlled independently via `addStorageDisabled` and
`removeStorageDisabled` inputs
- Title changes to "Storage full" when at capacity
- Description provides context-specific messaging
@@ -123,7 +127,7 @@ Storage with no files uploaded:
[storage]="{
available: 5,
used: 0,
readableUsed: '0 GB'
readableUsed: '0 GB',
}"
(callToActionClicked)="handleAction($event)"
>
@@ -141,7 +145,7 @@ Storage with partial usage (50%):
[storage]="{
available: 5,
used: 2.5,
readableUsed: '2.5 GB'
readableUsed: '2.5 GB',
}"
(callToActionClicked)="handleAction($event)"
>
@@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button:
[storage]="{
available: 5,
used: 5,
readableUsed: '5 GB'
readableUsed: '5 GB',
}"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns
red.
**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled
independently via the `addStorageDisabled` and `removeStorageDisabled` inputs.
### Low Usage (10%)
@@ -180,7 +184,7 @@ Minimal storage usage:
[storage]="{
available: 5,
used: 0.5,
readableUsed: '500 MB'
readableUsed: '500 MB',
}"
(callToActionClicked)="handleAction($event)"
>
@@ -198,7 +202,7 @@ Substantial storage usage:
[storage]="{
available: 5,
used: 3.75,
readableUsed: '3.75 GB'
readableUsed: '3.75 GB',
}"
(callToActionClicked)="handleAction($event)"
>
@@ -216,7 +220,7 @@ Storage approaching capacity:
[storage]="{
available: 5,
used: 4.75,
readableUsed: '4.75 GB'
readableUsed: '4.75 GB',
}"
(callToActionClicked)="handleAction($event)"
>
@@ -234,7 +238,7 @@ Enterprise-level storage allocation:
[storage]="{
available: 1000,
used: 734,
readableUsed: '734 GB'
readableUsed: '734 GB',
}"
(callToActionClicked)="handleAction($event)"
>
@@ -252,7 +256,7 @@ Minimal storage allocation:
[storage]="{
available: 1,
used: 0.8,
readableUsed: '800 MB'
readableUsed: '800 MB',
}"
(callToActionClicked)="handleAction($event)"
>
@@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations):
[storage]="{
available: 5,
used: 2.5,
readableUsed: '2.5 GB'
readableUsed: '2.5 GB',
}"
[callsToActionDisabled]="true"
[addStorageDisabled]="true"
[removeStorageDisabled]="true"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
adding or removing storage.
**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button
states during async operations like adding or removing storage.
### Add Storage Disabled
Storage card with only the add button disabled:
<Canvas of={StorageCardStories.AddStorageDisabled} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 2.5,
readableUsed: '2.5 GB',
}"
[addStorageDisabled]="true"
[removeStorageDisabled]="false"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
### Remove Storage Disabled
Storage card with only the remove button disabled:
<Canvas of={StorageCardStories.RemoveStorageDisabled} />
```html
<billing-storage-card
[storage]="{
available: 5,
used: 2.5,
readableUsed: '2.5 GB',
}"
[addStorageDisabled]="false"
[removeStorageDisabled]="true"
(callToActionClicked)="handleAction($event)"
>
</billing-storage-card>
```
## Features
@@ -304,13 +349,14 @@ adding or removing storage.
- Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed`
- Keep `used` value less than or equal to `available` under normal circumstances
- Update storage data in real-time when user adds or removes storage
- Disable UI interactions when storage operations are in progress
- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations
- Show loading states during async storage operations
- Control button states independently based on business logic
### ❌ Don't
- Omit the `readableUsed` field - it's required for display
- Use inconsistent units between `available` and `used` (both should be in GB)
- Omit the `readableUsed` field - it's required
- Use inconsistent units between `available` and `used` (all should be in GB)
- Allow negative values for storage amounts
- Ignore the `callToActionClicked` events - they require handling
- Display inaccurate or stale storage information

View File

@@ -163,18 +163,6 @@ describe("StorageCardComponent", () => {
});
});
describe("canRemoveStorage", () => {
it("should return true when storage is not full", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
expect(component.canRemoveStorage()).toBe(true);
});
it("should return false when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
expect(component.canRemoveStorage()).toBe(false);
});
});
describe("button rendering", () => {
it("should render both buttons", () => {
setupComponent(baseStorage);
@@ -182,25 +170,46 @@ describe("StorageCardComponent", () => {
expect(buttons.length).toBe(2);
});
it("should enable remove button when storage is not full", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
it("should enable add button by default", () => {
setupComponent(baseStorage);
const buttons = fixture.debugElement.queryAll(By.css("button"));
const addButton = buttons[0].nativeElement;
expect(addButton.disabled).toBe(false);
});
it("should disable add button when addStorageDisabled is true", () => {
setupComponent(baseStorage);
fixture.componentRef.setInput("addStorageDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
const addButton = buttons[0];
expect(addButton.attributes["aria-disabled"]).toBe("true");
});
it("should enable remove button by default", () => {
setupComponent(baseStorage);
const buttons = fixture.debugElement.queryAll(By.css("button"));
const removeButton = buttons[1].nativeElement;
expect(removeButton.disabled).toBe(false);
});
it("should disable remove button when storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
it("should disable remove button when removeStorageDisabled is true", () => {
setupComponent(baseStorage);
fixture.componentRef.setInput("removeStorageDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
const removeButton = buttons[1];
expect(removeButton.attributes["aria-disabled"]).toBe("true");
});
});
describe("callsToActionDisabled", () => {
it("should disable both buttons when callsToActionDisabled is true", () => {
describe("independent button disabled states", () => {
it("should disable both buttons independently", () => {
setupComponent(baseStorage);
fixture.componentRef.setInput("callsToActionDisabled", true);
fixture.componentRef.setInput("addStorageDisabled", true);
fixture.componentRef.setInput("removeStorageDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
@@ -208,9 +217,10 @@ describe("StorageCardComponent", () => {
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
});
it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
fixture.componentRef.setInput("callsToActionDisabled", false);
it("should enable both buttons when both disabled inputs are false", () => {
setupComponent(baseStorage);
fixture.componentRef.setInput("addStorageDisabled", false);
fixture.componentRef.setInput("removeStorageDisabled", false);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
@@ -218,15 +228,27 @@ describe("StorageCardComponent", () => {
expect(buttons[1].nativeElement.disabled).toBe(false);
});
it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => {
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
fixture.componentRef.setInput("callsToActionDisabled", false);
it("should allow add button enabled while remove button disabled", () => {
setupComponent(baseStorage);
fixture.componentRef.setInput("addStorageDisabled", false);
fixture.componentRef.setInput("removeStorageDisabled", true);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].nativeElement.disabled).toBe(false);
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
});
it("should allow remove button enabled while add button disabled", () => {
setupComponent(baseStorage);
fixture.componentRef.setInput("addStorageDisabled", true);
fixture.componentRef.setInput("removeStorageDisabled", false);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css("button"));
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
expect(buttons[1].nativeElement.disabled).toBe(false);
});
});
describe("button click events", () => {
@@ -243,7 +265,7 @@ describe("StorageCardComponent", () => {
});
it("should emit remove-storage action when remove button is clicked", () => {
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
setupComponent(baseStorage);
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");

View File

@@ -143,6 +143,33 @@ export const ActionsDisabled: Story = {
used: 2.5,
readableUsed: "2.5 GB",
} satisfies Storage,
callsToActionDisabled: true,
addStorageDisabled: true,
removeStorageDisabled: true,
},
};
export const AddStorageDisabled: Story = {
name: "Add Storage Disabled",
args: {
storage: {
available: 5,
used: 2.5,
readableUsed: "2.5 GB",
} satisfies Storage,
addStorageDisabled: true,
removeStorageDisabled: false,
},
};
export const RemoveStorageDisabled: Story = {
name: "Remove Storage Disabled",
args: {
storage: {
available: 5,
used: 2.5,
readableUsed: "2.5 GB",
} satisfies Storage,
addStorageDisabled: false,
removeStorageDisabled: true,
},
};

View File

@@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { Storage } from "../../types/storage";
export type StorageCardAction = "add-storage" | "remove-storage";
export const StorageCardActions = {
AddStorage: "add-storage",
RemoveStorage: "remove-storage",
} as const;
export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions];
@Component({
selector: "billing-storage-card",
@@ -25,7 +30,8 @@ export class StorageCardComponent {
readonly storage = input.required<Storage>();
readonly callsToActionDisabled = input<boolean>(false);
readonly addStorageDisabled = input<boolean>(false);
readonly removeStorageDisabled = input<boolean>(false);
readonly callToActionClicked = output<StorageCardAction>();
@@ -64,5 +70,5 @@ export class StorageCardComponent {
return this.isFull() ? "danger" : "primary";
});
readonly canRemoveStorage = computed<boolean>(() => !this.isFull());
protected readonly actions = StorageCardActions;
}

View File

@@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub
### Outputs
| Output | Type | Description |
| --------------------- | ---------------- | ---------------------------------------------------------- |
| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout |
| Output | Type | Description |
| --------------------- | ------------------------ | ---------------------------------------------------------- |
| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout |
**PlanCardAction Type:**
**SubscriptionCardAction Type:**
```typescript
type PlanCardAction =
type SubscriptionCardAction =
| "contact-support"
| "manage-invoices"
| "reinstate-subscription"

View File

@@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 50,
},
},

View File

@@ -103,7 +103,7 @@ export const Active: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -157,7 +157,7 @@ export const Trial: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -212,7 +212,7 @@ export const Incomplete: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -239,7 +239,7 @@ export const IncompleteExpired: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -266,7 +266,7 @@ export const PastDue: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -293,7 +293,7 @@ export const PendingCancellation: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -320,7 +320,7 @@ export const Unpaid: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -346,7 +346,7 @@ export const Canceled: Story = {
passwordManager: {
seats: {
quantity: 1,
name: "members",
translationKey: "members",
cost: 10.0,
},
},
@@ -372,31 +372,30 @@ export const Enterprise: Story = {
passwordManager: {
seats: {
quantity: 5,
name: "members",
translationKey: "members",
cost: 7,
},
additionalStorage: {
quantity: 2,
name: "additionalStorageGB",
translationKey: "additionalStorageGB",
cost: 0.5,
},
},
secretsManager: {
seats: {
quantity: 3,
name: "members",
translationKey: "members",
cost: 13,
},
additionalServiceAccounts: {
quantity: 5,
name: "additionalServiceAccountsV2",
translationKey: "additionalServiceAccountsV2",
cost: 1,
},
},
discount: {
type: DiscountTypes.PercentOff,
active: true,
value: 0.25,
value: 25,
},
cadence: "monthly",
estimatedTax: 6.4,

View File

@@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing";
import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription";
import { I18nPipe } from "@bitwarden/ui-common";
export type PlanCardAction =
| "contact-support"
| "manage-invoices"
| "reinstate-subscription"
| "update-payment"
| "upgrade-plan";
export const SubscriptionCardActions = {
ContactSupport: "contact-support",
ManageInvoices: "manage-invoices",
ReinstateSubscription: "reinstate-subscription",
UpdatePayment: "update-payment",
UpgradePlan: "upgrade-plan",
} as const;
export type SubscriptionCardAction =
(typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions];
type Badge = { text: string; variant: BadgeVariant };
@@ -33,7 +37,7 @@ type Callout = Maybe<{
callsToAction?: {
text: string;
buttonType: ButtonType;
action: PlanCardAction;
action: SubscriptionCardAction;
}[];
}>;
@@ -64,7 +68,7 @@ export class SubscriptionCardComponent {
readonly showUpgradeButton = input<boolean>(false);
readonly callToActionClicked = output<PlanCardAction>();
readonly callToActionClicked = output<SubscriptionCardAction>();
readonly badge = computed<Badge>(() => {
const subscription = this.subscription();
@@ -136,12 +140,12 @@ export class SubscriptionCardComponent {
{
text: this.i18nService.t("updatePayment"),
buttonType: "unstyled",
action: "update-payment",
action: SubscriptionCardActions.UpdatePayment,
},
{
text: this.i18nService.t("contactSupportShort"),
buttonType: "unstyled",
action: "contact-support",
action: SubscriptionCardActions.ContactSupport,
},
],
};
@@ -155,7 +159,7 @@ export class SubscriptionCardComponent {
{
text: this.i18nService.t("contactSupportShort"),
buttonType: "unstyled",
action: "contact-support",
action: SubscriptionCardActions.ContactSupport,
},
],
};
@@ -172,7 +176,7 @@ export class SubscriptionCardComponent {
{
text: this.i18nService.t("reinstateSubscription"),
buttonType: "unstyled",
action: "reinstate-subscription",
action: SubscriptionCardActions.ReinstateSubscription,
},
],
};
@@ -189,7 +193,7 @@ export class SubscriptionCardComponent {
{
text: this.i18nService.t("upgradeNow"),
buttonType: "unstyled",
action: "upgrade-plan",
action: SubscriptionCardActions.UpgradePlan,
},
],
};
@@ -208,7 +212,7 @@ export class SubscriptionCardComponent {
{
text: this.i18nService.t("manageInvoices"),
buttonType: "unstyled",
action: "manage-invoices",
action: SubscriptionCardActions.ManageInvoices,
},
],
};
@@ -225,7 +229,7 @@ export class SubscriptionCardComponent {
{
text: this.i18nService.t("manageInvoices"),
buttonType: "unstyled",
action: "manage-invoices",
action: SubscriptionCardActions.ManageInvoices,
},
],
};

View File

@@ -12,6 +12,8 @@ export const SubscriptionStatuses = {
Unpaid: "unpaid",
} as const;
export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses];
type HasCart = {
cart: Cart;
};

View File

@@ -1,3 +1,5 @@
export const MAX_STORAGE_GB = 100;
export type Storage = {
available: number;
readableUsed: string;