diff --git a/.storybook/main.ts b/.storybook/main.ts index e1f3561a1b7..353d959a6b9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -12,6 +12,8 @@ const config: StorybookConfig = { "../libs/dirt/card/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/pricing/src/**/*.mdx", "../libs/pricing/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/subscription/src/**/*.mdx", + "../libs/subscription/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/tools/send/send-ui/src/**/*.mdx", "../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/vault/src/**/*.mdx", diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 45a68136a00..02dd69be6aa 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -50,11 +50,7 @@
- + @if (isFamiliesPlan) {

{{ "paymentChargedWithTrial" | i18n }} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index a824e850db6..34362b4be3e 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -9,7 +9,7 @@ import { signal, viewChild, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { debounceTime, @@ -22,6 +22,7 @@ import { combineLatest, map, shareReplay, + defer, } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -35,7 +36,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { CartSummaryComponent } from "@bitwarden/pricing"; +import { Cart, CartSummaryComponent } from "@bitwarden/pricing"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { @@ -118,23 +119,48 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { protected readonly selectedPlan = signal(null); protected readonly loading = signal(true); protected readonly upgradeToMessage = signal(""); - // Cart Summary data - protected readonly passwordManager = computed(() => { - if (!this.selectedPlan()) { - return { name: "", cost: 0, quantity: 0, cadence: "year" as const }; - } - - return { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan()!.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year" as const, - }; - }); protected hasEnoughAccountCredit$!: Observable; private pricingTiers$!: Observable; - protected estimatedTax$!: Observable; + + // Use defer to lazily create the observable when subscribed to + protected estimatedTax$ = defer(() => + this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + debounceTime(1000), + switchMap(() => this.refreshSalesTax$()), + ), + ); + + // Convert estimatedTax$ to signal for use in computed cart + protected readonly estimatedTax = toSignal(this.estimatedTax$, { + initialValue: this.INITIAL_TAX_VALUE, + }); + + // Cart Summary data + protected readonly cart = computed(() => { + if (!this.selectedPlan()) { + return { + passwordManager: { + seats: { name: "", cost: 0, quantity: 0 }, + }, + cadence: "annually", + estimatedTax: 0, + }; + } + + return { + passwordManager: { + seats: { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0, + quantity: 1, + }, + }, + cadence: "annually", + estimatedTax: this.estimatedTax() ?? 0, + }; + }); constructor( private i18nService: I18nService, @@ -186,13 +212,6 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } }); - this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( - startWith(this.formGroup.controls.billingAddress.value), - debounceTime(1000), - // Only proceed when form has required values - switchMap(() => this.refreshSalesTax$()), - ); - this.loading.set(false); } diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index 2d653ff200b..307f170f116 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -65,7 +65,7 @@ }} diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 8d99b807540..2fc39218cf8 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -17,7 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { DiscountInfo } from "@bitwarden/pricing"; +import { Discount, DiscountTypes, Maybe } from "@bitwarden/pricing"; import { AdjustStorageDialogComponent, @@ -251,15 +251,13 @@ export class UserSubscriptionComponent implements OnInit { } } - getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null { + getDiscount(discount: BillingCustomerDiscount | null): Maybe { if (!discount) { return null; } - return { - active: discount.active, - percentOff: discount.percentOff, - amountOff: discount.amountOff, - }; + return discount.amountOff + ? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff }; } get isSubscriptionActive(): boolean { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 57ea900fa69..db30a9d1153 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1188,7 +1188,7 @@ "message": "Me" }, "myItems": { - "message": "My items" + "message": "My Items" }, "myVault": { "message": "My vault" @@ -3275,7 +3275,7 @@ "nextChargeHeader": { "message": "Next Charge" }, - "plan": { + "plan": { "message": "Plan" }, "details": { @@ -3673,9 +3673,6 @@ "defaultCollection": { "message": "Default collection" }, - "myItems": { - "message": "My Items" - }, "getHelp": { "message": "Get help" }, @@ -4500,7 +4497,6 @@ "updateBrowser": { "message": "Update browser" }, - "generatingYourAccessIntelligence": { "message": "Generating your Access Intelligence..." }, @@ -5888,22 +5884,22 @@ "message": "credential lifecycle", "description": "This will be used as a hyperlink" }, - "organizationDataOwnershipWarningTitle":{ + "organizationDataOwnershipWarningTitle": { "message": "Are you sure you want to proceed?" }, - "organizationDataOwnershipWarning1":{ + "organizationDataOwnershipWarning1": { "message": "will remain accessible to members" }, - "organizationDataOwnershipWarning2":{ + "organizationDataOwnershipWarning2": { "message": "will not be automatically selected when creating new items" }, - "organizationDataOwnershipWarning3":{ + "organizationDataOwnershipWarning3": { "message": "cannot be managed from the Admin Console until the user is offboarded" }, - "organizationDataOwnershipWarningContentTop":{ + "organizationDataOwnershipWarningContentTop": { "message": "By turning this policy off, the default collection: " }, - "organizationDataOwnershipWarningContentBottom":{ + "organizationDataOwnershipWarningContentBottom": { "message": "Learn more about the ", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'" }, @@ -6027,7 +6023,7 @@ "uriMatchDetectionOptionsLabel": { "message": "Default URI match detection" }, - "invalidUriMatchDefaultPolicySetting": { + "invalidUriMatchDefaultPolicySetting": { "message": "Please select a valid URI match detection option.", "description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection." }, @@ -8937,7 +8933,7 @@ } }, "accessedSecret": { - "message": "Accessed secret $SECRET_ID$.", + "message": "Accessed secret $SECRET_ID$.", "placeholders": { "secret_id": { "content": "$1", @@ -8945,7 +8941,7 @@ } } }, - "editedSecretWithId": { + "editedSecretWithId": { "message": "Edited a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8954,7 +8950,7 @@ } } }, - "deletedSecretWithId": { + "deletedSecretWithId": { "message": "Deleted a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8972,7 +8968,7 @@ } } }, - "restoredSecretWithId": { + "restoredSecretWithId": { "message": "Restored a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8981,7 +8977,7 @@ } } }, - "createdSecretWithId": { + "createdSecretWithId": { "message": "Created a new secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8991,7 +8987,7 @@ } }, "accessedProjectWithIdentifier": { - "message": "Accessed a project with identifier: $PROJECT_ID$.", + "message": "Accessed a project with identifier: $PROJECT_ID$.", "placeholders": { "project_id": { "content": "$1", @@ -9000,7 +8996,7 @@ } }, "nameUnavailableProjectDeleted": { - "message": "Deleted project Id: $PROJECT_ID$", + "message": "Deleted project Id: $PROJECT_ID$", "placeholders": { "project_id": { "content": "$1", @@ -9009,7 +9005,7 @@ } }, "nameUnavailableSecretDeleted": { - "message": "Deleted secret Id: $SECRET_ID$", + "message": "Deleted secret Id: $SECRET_ID$", "placeholders": { "secret_id": { "content": "$1", @@ -9018,7 +9014,7 @@ } }, "nameUnavailableServiceAccountDeleted": { - "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", + "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", "placeholders": { "service_account_id": { "content": "$1", @@ -9026,7 +9022,7 @@ } } }, - "editedProjectWithId": { + "editedProjectWithId": { "message": "Edited a project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9105,7 +9101,7 @@ } } }, - "deletedProjectWithId": { + "deletedProjectWithId": { "message": "Deleted a project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9114,7 +9110,7 @@ } } }, - "createdProjectWithId": { + "createdProjectWithId": { "message": "Created a new project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9832,15 +9828,15 @@ "message": "Common formats", "description": "Label indicating the most common import formats" }, - "uriMatchDefaultStrategyHint": { + "uriMatchDefaultStrategyHint": { "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, - "regExAdvancedOptionWarning": { + "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, - "startsWithAdvancedOptionWarning": { + "startsWithAdvancedOptionWarning": { "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, @@ -9848,11 +9844,11 @@ "message": "More about match detection", "description": "Link to match detection docs on warning dialog for advance match strategy" }, - "uriAdvancedOption":{ + "uriAdvancedOption": { "message": "Advanced options", "description": "Advanced option placeholder for uri option component" }, - "warningCapitalized": { + "warningCapitalized": { "message": "Warning", "description": "Warning (should maintain locale-relevant capitalization)" }, @@ -12193,9 +12189,6 @@ "updateYourEncryptionSettings": { "message": "Update your encryption settings" }, - "updateSettings": { - "message": "Update settings" - }, "algorithm": { "message": "Algorithm" }, @@ -12266,7 +12259,7 @@ } } }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -12284,10 +12277,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -12421,7 +12414,7 @@ } } }, - "howToManageMyVault": { + "howToManageMyVault": { "message": "How do I manage my vault?" }, "transferItemsToOrganizationTitle": { @@ -12451,7 +12444,7 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, - "youHaveBitwardenPremium": { + "youHaveBitwardenPremium": { "message": "You have Bitwarden Premium" }, "viewAndManagePremiumSubscription": { @@ -12469,7 +12462,7 @@ } } }, - "uploadLicenseFile": { + "uploadLicenseFile": { "message": "Upload license file" }, "uploadYourLicenseFile": { @@ -12487,7 +12480,7 @@ } } }, - "alreadyHaveSubscriptionQuestion": { + "alreadyHaveSubscriptionQuestion": { "message": "Already have a subscription?" }, "alreadyHaveSubscriptionSelfHostedMessage": { @@ -12496,7 +12489,81 @@ "viewAllPlans": { "message": "View all plans" }, - "planDescPremium":{ + "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index 85695ea1395..e2fe7d80dc0 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -1,21 +1,23 @@ -@let passwordManager = this.passwordManager(); -@let additionalStorage = this.additionalStorage(); -@let secretsManager = this.secretsManager(); -@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts; +@let cart = this.cart(); +@let term = this.term();

-

- {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD -

-   - / {{ passwordManager.cadence | i18n }} + @if (this.header(); as header) { + + } @else { +

+ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD +

+   + / {{ term }} + }
+ +
+ diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx new file mode 100644 index 00000000000..4519d19a530 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -0,0 +1,159 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as AdditionalOptionsCardStories from "./additional-options-card.component.stories"; + + + +# Additional Options Card + +A UI component for displaying additional subscription management options with action buttons for +downloading license and canceling subscription. The component provides quick access to important +subscription actions. + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Design](#design) +- [Examples](#examples) + - [Default](#default) + - [Actions Disabled](#actions-disabled) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The additional options card component displays important subscription management actions on billing +pages and subscription dashboards. It provides quick access to download license and cancel +subscription actions. + +```ts +import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ----------------------- | --------- | ---------------------------------------------------------------------- | +| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | + +### Outputs + +| Output | Type | Description | +| --------------------- | ----------------------------- | ------------------------------------------- | +| `callToActionClicked` | `AdditionalOptionsCardAction` | Emitted when a user clicks an action button | + +**AdditionalOptionsCardAction Type:** + +```typescript +type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +``` + +## Design + +The component follows the Bitwarden design system with: + +- **Simple Card Layout**: Clean card design with title and description +- **Action Buttons**: Two prominent buttons for key subscription actions +- **Modern Angular**: Standalone component with signal-based outputs +- **OnPush Change Detection**: Optimized performance +- **Typography**: Uses `bitTypography` directives for consistent text styling +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Button Variants**: Secondary button for download, danger button for cancel +- **Internationalization**: All text uses i18n service for translation support + +## Examples + +### Default + +Standard display with download license and cancel subscription buttons: + + + +```html + + +``` + +**Handler example:** + +```typescript +handleAction(action: AdditionalOptionsCardAction) { + switch (action) { + case "download-license": + // Handle license download + break; + case "cancel-subscription": + // Handle subscription cancellation + break; + } +} +``` + +### Actions Disabled + +Component with action buttons disabled (useful during async operations): + + + +```html + + +``` + +**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like +downloading the license or processing subscription cancellation. + +## Features + +- **Download License**: Provides quick access to download subscription license +- **Cancel Subscription**: Provides quick access to cancel subscription with danger styling +- **Event Emission**: Emits typed events for handling user actions +- **Internationalization**: All text uses i18n service for translation support +- **Type Safety**: Strong TypeScript typing for action events +- **Accessible**: Proper button semantics and keyboard navigation + +## Do's and Don'ts + +### ✅ Do + +- 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 +- Provide clear user feedback after action completion +- Consider adding additional safety measures for subscription cancellation + +### ❌ Don't + +- Ignore the `callToActionClicked` events - they require handling +- Execute subscription cancellation without user confirmation +- Display this component to users who don't have permission to perform these actions +- Allow multiple simultaneous action executions +- Forget to handle error cases when actions fail + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

` and `

` tags +- **Button Accessibility**: Proper `type="button"` attributes on all buttons +- **Button Variants**: Clear visual distinction between secondary and danger actions +- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation +- **Focus Management**: Clear focus indicators on interactive elements +- **Screen Reader Support**: Descriptive button text for all actions +- **Color Differentiation**: Danger button uses red color to indicate destructive action +- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts new file mode 100644 index 00000000000..345de037fd3 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; + +describe("AdditionalOptionsCardComponent", () => { + let component: AdditionalOptionsCardComponent; + let fixture: ComponentFixture; + let i18nService: jest.Mocked; + + beforeEach(async () => { + i18nService = { + t: jest.fn((key: string) => { + const translations: Record = { + additionalOptions: "Additional options", + additionalOptionsDesc: + "For additional help in managing your subscription, please contact Customer Support.", + downloadLicense: "Download license", + cancelSubscription: "Cancel subscription", + }; + return translations[key] || key; + }), + } as any; + + await TestBed.configureTestingModule({ + imports: [AdditionalOptionsCardComponent], + providers: [{ provide: I18nService, useValue: i18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(AdditionalOptionsCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("rendering", () => { + it("should display the title", () => { + const title = fixture.debugElement.query(By.css("h3")); + expect(title.nativeElement.textContent.trim()).toBe("Additional options"); + }); + + it("should display the description", () => { + const description = fixture.debugElement.query(By.css("p")); + expect(description.nativeElement.textContent.trim()).toContain( + "For additional help in managing your subscription", + ); + }); + + it("should render both action buttons", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + }); + + it("should render download license button with correct text", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Download license"); + }); + + it("should render cancel subscription button with correct text", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].nativeElement.textContent.trim()).toBe("Cancel subscription"); + }); + }); + + describe("callsToActionDisabled", () => { + it("should disable both buttons when callsToActionDisabled is true", () => { + fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable both buttons when callsToActionDisabled is false", () => { + fixture.componentRef.setInput("callsToActionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + 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); + }); + }); + + describe("button click events", () => { + it("should emit download-license action when download button is clicked", () => { + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("download-license"); + }); + + it("should emit cancel-subscription action when cancel button is clicked", () => { + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("cancel-subscription"); + }); + }); +}); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts new file mode 100644 index 00000000000..66c151f536f --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -0,0 +1,49 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AdditionalOptionsCardComponent } from "./additional-options-card.component"; + +export default { + title: "Billing/Additional Options Card", + component: AdditionalOptionsCardComponent, + description: + "Displays additional subscription management options with action buttons for downloading license and canceling subscription.", + decorators: [ + moduleMetadata({ + imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + const translations: Record = { + additionalOptions: "Additional options", + additionalOptionsDesc: + "For additional help in managing your subscription, please contact Customer Support.", + downloadLicense: "Download license", + cancelSubscription: "Cancel subscription", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const ActionsDisabled: Story = { + name: "Actions Disabled", + args: { + callsToActionDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts new file mode 100644 index 00000000000..a962a167ec6 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -0,0 +1,17 @@ +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"; + +@Component({ + selector: "billing-additional-options-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./additional-options-card.component.html", + imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], +}) +export class AdditionalOptionsCardComponent { + readonly callsToActionDisabled = input(false); + readonly callToActionClicked = output(); +} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html new file mode 100644 index 00000000000..c11f1917176 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -0,0 +1,39 @@ + + +

+

{{ title() }}

+

{{ description() }}

+
+ + +
+ +
+ + +
+ + +
+ diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx new file mode 100644 index 00000000000..43215cb863c --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -0,0 +1,333 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as StorageCardStories from "./storage-card.component.stories"; + + + +# Storage Card + +A visual component for displaying encrypted file storage usage with a progress bar and action +buttons. The component dynamically adapts its appearance based on storage capacity (empty, used, or +full). + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Data Structure](#data-structure) +- [Storage States](#storage-states) +- [Design](#design) +- [Examples](#examples) + - [Empty](#empty) + - [Used](#used) + - [Full](#full) + - [Low Usage (10%)](#low-usage-10) + - [Medium Usage (75%)](#medium-usage-75) + - [Nearly Full (95%)](#nearly-full-95) + - [Large Storage Pool (1TB)](#large-storage-pool-1tb) + - [Small Storage Pool (1GB)](#small-storage-pool-1gb) + - [Actions Disabled](#actions-disabled) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The storage card component displays storage usage information on billing pages, account management +interfaces, and subscription dashboards. It provides visual feedback through a progress bar and +action buttons for managing storage. + +```ts +import { StorageCardComponent, Storage } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### 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`. | + +### Outputs + +| Output | Type | Description | +| --------------------- | ------------------- | ------------------------------------------------------- | +| `callToActionClicked` | `StorageCardAction` | Emitted when a user clicks add or remove storage button | + +**StorageCardAction Type:** + +```typescript +type StorageCardAction = "add-storage" | "remove-storage"; +``` + +## Data Structure + +The component uses the `Storage` type: + +```typescript +type Storage = { + available: number; // Total GB available + used: number; // GB used + readableUsed: string; // Formatted string (e.g., "2.5 GB") +}; +``` + +## Storage States + +The component automatically adapts its appearance based on storage usage: + +- **Empty**: 0% used - Gray progress bar, "Storage" title, empty description +- **Used**: 1-99% used - Blue progress bar, "Storage" title, used description +- **Full**: 100% used - Red progress bar, "Storage full" title, full description with warning + +Key behaviors: + +- Progress bar color changes from blue (primary) to red (danger) when full +- Remove storage button is disabled when storage is full +- Title changes to "Storage full" when at capacity +- Description provides context-specific messaging + +## Design + +The component follows the Bitwarden design system with: + +- **Visual Progress Bar**: Animated bar showing storage usage percentage +- **Responsive Colors**: Blue for normal usage, red for full capacity +- **Action Buttons**: Secondary button style for add/remove actions +- **Modern Angular**: Uses signal inputs (`input.required`) and `computed` signals +- **OnPush Change Detection**: Optimized performance +- **Typography**: Uses `bitTypography` directives for consistent text styling +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Card Layout**: Wrapped in `bit-card` component with consistent spacing + +## Examples + +### Empty + +Storage with no files uploaded: + + + +```html + + +``` + +### Used + +Storage with partial usage (50%): + + + +```html + + +``` + +### Full + +Storage at full capacity with disabled remove button: + + + +```html + + +``` + +**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns +red. + +### Low Usage (10%) + +Minimal storage usage: + + + +```html + + +``` + +### Medium Usage (75%) + +Substantial storage usage: + + + +```html + + +``` + +### Nearly Full (95%) + +Storage approaching capacity: + + + +```html + + +``` + +### Large Storage Pool (1TB) + +Enterprise-level storage allocation: + + + +```html + + +``` + +### Small Storage Pool (1GB) + +Minimal storage allocation: + + + +```html + + +``` + +### Actions Disabled + +Storage card with action buttons disabled (useful during async operations): + + + +```html + + +``` + +**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like +adding or removing storage. + +## Features + +- **Visual Progress Bar**: Animated progress indicator showing storage usage percentage +- **Dynamic Colors**: Blue (primary) for normal usage, red (danger) when full +- **Context-Aware Titles**: Changes from "Storage" to "Storage full" at capacity +- **Descriptive Messages**: Clear descriptions of current storage status +- **Action Buttons**: Add and remove storage with appropriate enabled/disabled states +- **Automatic Calculations**: Percentage computed from available and used values +- **Responsive Design**: Adapts to container width with flexible layout +- **Computed Signals**: Efficient reactive computations using Angular signals +- **Type Safety**: Strong TypeScript typing for storage data +- **Internationalization**: All text uses i18n service for translation support +- **Event Emission**: Typed events for handling user actions + +## Do's and Don'ts + +### ✅ Do + +- Handle both `add-storage` and `remove-storage` events in parent components +- Provide accurate storage data with `available`, `used`, and `readableUsed` fields +- 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 +- Show loading states during async storage operations + +### ❌ Don't + +- Omit the `readableUsed` field - it's required for display +- Use inconsistent units between `available` and `used` (both should be in GB) +- Allow negative values for storage amounts +- Ignore the `callToActionClicked` events - they require handling +- Display inaccurate or stale storage information +- Override progress bar colors without considering accessibility +- Show progress percentages greater than 100% +- Use this component for non-storage related progress indicators + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

` and `

` tags +- **Button Accessibility**: Proper `type="button"` attributes on all buttons +- **Disabled State**: Visual and functional disabled state for remove button when full +- **Color Contrast**: Sufficient contrast ratios for text and progress bar colors +- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation +- **Focus Management**: Clear focus indicators on interactive elements +- **Screen Reader Support**: Descriptive text for all storage states and actions +- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes +- **Visual Feedback**: Multiple indicators of state (color, text, disabled buttons) diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts new file mode 100644 index 00000000000..ae0d7ad9dcb --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -0,0 +1,285 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Storage, StorageCardComponent } from "@bitwarden/subscription"; + +describe("StorageCardComponent", () => { + let component: StorageCardComponent; + let fixture: ComponentFixture; + let i18nService: jest.Mocked; + + const baseStorage: Storage = { + available: 5, + used: 0, + readableUsed: "0 GB", + }; + + beforeEach(async () => { + i18nService = { + t: jest.fn((key: string, ...args: any[]) => { + const translations: Record = { + storage: "Storage", + storageFull: "Storage full", + storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`, + storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`, + addStorage: "Add storage", + removeStorage: "Remove storage", + }; + return translations[key] || key; + }), + } as any; + + await TestBed.configureTestingModule({ + imports: [StorageCardComponent], + providers: [{ provide: I18nService, useValue: i18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(StorageCardComponent); + component = fixture.componentInstance; + }); + + function setupComponent(storage: Storage) { + fixture.componentRef.setInput("storage", storage); + fixture.detectChanges(); + } + + it("should create", () => { + setupComponent(baseStorage); + expect(component).toBeTruthy(); + }); + + describe("isEmpty", () => { + it("should return true when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.isEmpty()).toBe(true); + }); + + it("should return false when storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.isEmpty()).toBe(false); + }); + + it("should return false when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.isEmpty()).toBe(false); + }); + }); + + describe("isFull", () => { + it("should return false when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.isFull()).toBe(false); + }); + + it("should return false when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.isFull()).toBe(false); + }); + + it("should return true when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.isFull()).toBe(true); + }); + + it("should return true when used exceeds available", () => { + setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" }); + expect(component.isFull()).toBe(true); + }); + }); + + describe("percentageUsed", () => { + it("should return 0 when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.percentageUsed()).toBe(0); + }); + + it("should return 50 when half of storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.percentageUsed()).toBe(50); + }); + + it("should return 100 when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should cap at 100 when used exceeds available", () => { + setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should return 0 when available is 0", () => { + setupComponent({ available: 0, used: 0, readableUsed: "0 GB" }); + expect(component.percentageUsed()).toBe(0); + }); + }); + + describe("title", () => { + it("should display 'Storage' when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.title()).toBe("Storage"); + }); + + it("should display 'Storage' when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.title()).toBe("Storage"); + }); + + it("should display 'Storage full' when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.title()).toBe("Storage full"); + }); + }); + + describe("description", () => { + it("should display used description when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.description()).toContain("You have used 0 GB out of 5 GB"); + }); + + it("should display used description when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.description()).toContain("You have used 2.5 GB out of 5 GB"); + }); + + it("should display full description when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + const desc = component.description(); + expect(desc).toContain("You have used all 5 GB"); + expect(desc).toContain("To continue storing files, add more storage"); + }); + }); + + describe("progressBarColor", () => { + it("should return primary color when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.progressBarColor()).toBe("primary"); + }); + + it("should return danger color when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.progressBarColor()).toBe("danger"); + }); + }); + + 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); + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + }); + + it("should enable remove button when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + 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" }); + 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", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + 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); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + 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); + 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"); + }); + }); + + describe("button click events", () => { + it("should emit add-storage action when add button is clicked", () => { + setupComponent(baseStorage); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("add-storage"); + }); + + it("should emit remove-storage action when remove button is clicked", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("remove-storage"); + }); + }); + + describe("progress bar rendering", () => { + it("should render bit-progress component when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + const progressBar = fixture.debugElement.query(By.css("bit-progress")); + expect(progressBar).toBeTruthy(); + }); + + it("should pass correct barWidth to bit-progress when half storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.percentageUsed()).toBe(50); + }); + + it("should pass correct barWidth to bit-progress when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should pass primary color to bit-progress when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.progressBarColor()).toBe("primary"); + }); + + it("should pass danger color to bit-progress when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.progressBarColor()).toBe("danger"); + }); + }); +}); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts new file mode 100644 index 00000000000..8c2070e59f9 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -0,0 +1,148 @@ +import { CommonModule } from "@angular/common"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, +} from "@bitwarden/components"; +import { Storage, StorageCardComponent } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export default { + title: "Billing/Storage Card", + component: StorageCardComponent, + description: + "Displays storage usage with a visual progress bar and action buttons for adding or removing storage.", + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, + I18nPipe, + ], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string, ...args: any[]) => { + const translations: Record = { + storage: "Storage", + storageFull: "Storage full", + storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`, + storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`, + addStorage: "Add storage", + removeStorage: "Remove storage", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + storage: { + available: 5, + used: 0, + readableUsed: "0 GB", + } satisfies Storage, + }, +}; + +export const Used: Story = { + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + }, +}; + +export const Full: Story = { + args: { + storage: { + available: 5, + used: 5, + readableUsed: "5 GB", + } satisfies Storage, + }, +}; + +export const LowUsage: Story = { + name: "Low Usage (10%)", + args: { + storage: { + available: 5, + used: 0.5, + readableUsed: "500 MB", + } satisfies Storage, + }, +}; + +export const MediumUsage: Story = { + name: "Medium Usage (75%)", + args: { + storage: { + available: 5, + used: 3.75, + readableUsed: "3.75 GB", + } satisfies Storage, + }, +}; + +export const NearlyFull: Story = { + name: "Nearly Full (95%)", + args: { + storage: { + available: 5, + used: 4.75, + readableUsed: "4.75 GB", + } satisfies Storage, + }, +}; + +export const LargeStorage: Story = { + name: "Large Storage Pool (1TB)", + args: { + storage: { + available: 1000, + used: 734, + readableUsed: "734 GB", + } satisfies Storage, + }, +}; + +export const SmallStorage: Story = { + name: "Small Storage Pool (1GB)", + args: { + storage: { + available: 1, + used: 0.8, + readableUsed: "800 MB", + } satisfies Storage, + }, +}; + +export const ActionsDisabled: Story = { + name: "Actions Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + callsToActionDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts new file mode 100644 index 00000000000..988f4a0ec60 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -0,0 +1,68 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { Storage } from "../../types/storage"; + +export type StorageCardAction = "add-storage" | "remove-storage"; + +@Component({ + selector: "billing-storage-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./storage-card.component.html", + imports: [CommonModule, ButtonModule, CardComponent, ProgressModule, TypographyModule, I18nPipe], +}) +export class StorageCardComponent { + private i18nService = inject(I18nService); + + readonly storage = input.required(); + + readonly callsToActionDisabled = input(false); + + readonly callToActionClicked = output(); + + readonly isEmpty = computed(() => this.storage().used === 0); + + readonly isFull = computed(() => { + const storage = this.storage(); + return storage.used >= storage.available; + }); + + readonly percentageUsed = computed(() => { + const storage = this.storage(); + if (storage.available === 0) { + return 0; + } + return Math.min((storage.used / storage.available) * 100, 100); + }); + + readonly title = computed(() => { + return this.isFull() ? this.i18nService.t("storageFull") : this.i18nService.t("storage"); + }); + + readonly description = computed(() => { + const storage = this.storage(); + const available = storage.available; + const readableUsed = storage.readableUsed; + + if (this.isFull()) { + return this.i18nService.t("storageFullDescription", available.toString()); + } + + return this.i18nService.t("storageUsedDescription", readableUsed, available.toString()); + }); + + readonly progressBarColor = computed<"danger" | "primary">(() => { + return this.isFull() ? "danger" : "primary"; + }); + + readonly canRemoveStorage = computed(() => !this.isFull()); +} diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.html b/libs/subscription/src/components/subscription-card/subscription-card.component.html new file mode 100644 index 00000000000..524adc8d008 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.html @@ -0,0 +1,94 @@ + + +

+

{{ title() }}

+ + + + {{ badge().text }} + +
+ + +
+ +
+ + + @if (callout(); as callout) { + +
+

{{ callout.description }}

+ @if (callout.callsToAction) { +
+ @for (cta of callout.callsToAction; track cta.action) { + + } +
+ } +
+
+ } + + + +

+ @let status = subscription().status; + @switch (status) { + @case ("incomplete") { + {{ "yourSubscriptionWillBeSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("incomplete_expired") { + {{ "yourSubscriptionWasSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("trialing") { + @if (cancelAt(); as cancelAt) { + {{ "yourSubscriptionWillBeCanceledOn" | i18n }} + {{ cancelAt | date: dateFormat }} + } @else { + {{ "yourNextChargeIsFor" | i18n }} + {{ total | currency: "USD" : "symbol" }} USD + {{ "dueOn" | i18n }} + {{ nextCharge() | date: dateFormat }} + } + } + @case ("active") { + @if (cancelAt(); as cancelAt) { + {{ "yourSubscriptionWillBeCanceledOn" | i18n }} + {{ cancelAt | date: dateFormat }} + } @else { + {{ "yourNextChargeIsFor" | i18n }} + {{ total | currency: "USD" : "symbol" }} USD + {{ "dueOn" | i18n }} + {{ nextCharge() | date: dateFormat }} + } + } + @case ("past_due") { + {{ "yourSubscriptionWillBeSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("canceled") { + {{ "yourSubscriptionWasCanceledOn" | i18n }} + {{ canceled() | date: dateFormat }} + } + @case ("unpaid") { + {{ "yourSubscriptionWasSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + } +

+
diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx new file mode 100644 index 00000000000..0f605f0f05e --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -0,0 +1,459 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as SubscriptionCardStories from "./subscription-card.component.stories"; + + + +# Subscription Card + +A comprehensive UI component for displaying subscription status, payment details, and contextual +action prompts based on subscription state. Dynamically adapts its presentation based on the +subscription status (active, trialing, incomplete, past due, canceled, unpaid, etc.). + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Data Structure](#data-structure) +- [Subscription States](#subscription-states) +- [Design](#design) +- [Examples](#examples) + - [Active](#active) + - [Active With Upgrade](#active-with-upgrade) + - [Trial](#trial) + - [Trial With Upgrade](#trial-with-upgrade) + - [Incomplete Payment](#incomplete-payment) + - [Incomplete Expired](#incomplete-expired) + - [Past Due](#past-due) + - [Pending Cancellation](#pending-cancellation) + - [Unpaid](#unpaid) + - [Canceled](#canceled) + - [Enterprise](#enterprise) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The subscription card component is designed to display comprehensive subscription information on +billing pages, account management interfaces, and subscription dashboards. + +```ts +import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ------------------- | ----------------------- | ----------------------------------------------------------------------- | +| `title` | `string` | **Required.** The title displayed at the top of the card | +| `subscription` | `BitwardenSubscription` | **Required.** The subscription data including status, cart, and storage | +| `showUpgradeButton` | `boolean` | **Optional.** Whether to show the upgrade callout (default: `false`) | + +### Outputs + +| Output | Type | Description | +| --------------------- | ---------------- | ---------------------------------------------------------- | +| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | + +**PlanCardAction Type:** + +```typescript +type PlanCardAction = + | "contact-support" + | "manage-invoices" + | "reinstate-subscription" + | "update-payment" + | "upgrade-plan"; +``` + +## Data Structure + +The component uses the `BitwardenSubscription` type, which is a discriminated union based on status: + +```typescript +type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled); + +type HasCart = { + cart: Cart; // From @bitwarden/pricing +}; + +type HasStorage = { + storage: { + available: number; + readableUsed: string; + used: number; + }; +}; + +type Suspension = { + status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid"; + suspension: Date; + gracePeriod: number; +}; + +type Billable = { + status: "trialing" | "active"; + nextCharge: Date; + cancelAt?: Date; +}; + +type Canceled = { + status: "canceled"; + canceled: Date; +}; +``` + +## Subscription States + +The component dynamically adapts its appearance and calls-to-action based on the subscription +status: + +- **active**: Subscription is active and paid up +- **trialing**: Subscription is in trial period +- **incomplete**: Payment failed, requires action +- **incomplete_expired**: Payment issue expired, subscription suspended +- **past_due**: Payment overdue but within grace period +- **unpaid**: Subscription suspended due to non-payment +- **canceled**: Subscription was canceled + +Each state displays an appropriate badge, callout message, and relevant action buttons. + +## Design + +The component follows the Bitwarden design system with: + +- **Status Badge**: Color-coded badges (success, warning, danger) indicating subscription state +- **Cart Summary**: Integrated cart summary showing pricing details +- **Contextual Callouts**: Warning/info/danger callouts with appropriate actions +- **Modern Angular**: Uses signal inputs (`input.required`, `input`) and `computed` signals +- **OnPush Change Detection**: Optimized performance with change detection strategy +- **Typography**: Consistent text styling using the typography module +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Responsive Layout**: Flexbox-based layout that adapts to container size + +## Examples + +### Active + +Standard active subscription with regular billing: + + + +```html + + +``` + +### Active With Upgrade + +Active subscription with upgrade promotion callout: + + + +```html + + +``` + +### Trial + +Subscription in trial period showing next charge date: + + + +```html + + +``` + +### Trial With Upgrade + +Trial subscription with upgrade option displayed: + + + +```html + + +``` + +### Incomplete Payment + +Payment failed, showing warning with update payment action: + + + +```html + + +``` + +**Actions available:** Update Payment, Contact Support + +### Incomplete Expired + +Payment issue expired, subscription has been suspended: + + + +```html + + +``` + +**Actions available:** Contact Support + +### Past Due + +Payment past due with active grace period: + + + +```html + + +``` + +**Actions available:** Manage Invoices + +### Pending Cancellation + +Active subscription scheduled to be canceled: + + + +```html + + +``` + +**Actions available:** Reinstate Subscription + +### Unpaid + +Subscription suspended due to unpaid invoices: + + + +```html + + +``` + +**Actions available:** Manage Invoices + +### Canceled + +Subscription that has been canceled: + + + +```html + + +``` + +**Note:** Canceled subscriptions display no callout or actions. + +### Enterprise + +Enterprise subscription with multiple products and discount: + + + +```html + + +``` + +## Features + +- **Dynamic Badge**: Status badge changes color and text based on subscription state +- **Contextual Callouts**: Warning, info, or danger callouts with relevant messages +- **Action Buttons**: Context-specific call-to-action buttons (update payment, contact support, + etc.) +- **Cart Summary Integration**: Embedded cart summary with pricing breakdown +- **Custom Header Support**: Cart summary can display custom headers based on subscription status +- **Date Formatting**: Consistent date formatting throughout (MMM. d, y format) +- **Computed Signals**: Efficient reactive computations using Angular signals +- **Type Safety**: Discriminated union types ensure type-safe subscription data +- **Internationalization**: All text uses i18n service for translation support +- **Event Emission**: Emits typed events for handling user actions + +## Do's and Don'ts + +### ✅ Do + +- Handle all `callToActionClicked` events appropriately in parent components +- Provide complete `BitwardenSubscription` objects with all required fields +- Use the correct subscription status from the defined status types +- Include accurate date information for nextCharge, suspension, and cancelAt fields +- Set `showUpgradeButton` to `true` only when upgrade paths are available +- Use real translation keys that exist in the i18n messages file +- Provide accurate storage information with readable format strings + +### ❌ Don't + +- Omit required fields from the BitwardenSubscription type +- Use custom status strings not defined in the type +- Display upgrade buttons for users who cannot upgrade +- Ignore the `callToActionClicked` events - they require handling +- Mix subscription states (e.g., having both `canceled` date and `nextCharge`) +- Provide incorrect dates that don't match the subscription status +- Override component styles without ensuring accessibility +- Use placeholder or mock data in production environments + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

`, `

`, `

` tags +- **ARIA Labels**: Badge variants use appropriate semantic colors +- **Keyboard Navigation**: All action buttons are keyboard accessible +- **Focus Management**: Clear focus indicators on interactive elements +- **Color Contrast**: Sufficient contrast ratios for all text and badge variants +- **Screen Reader Support**: Descriptive text for all interactive elements +- **Button Types**: Proper `type="button"` attributes on all buttons +- **Date Formatting**: Human-readable date formats for assistive technologies diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts new file mode 100644 index 00000000000..3485f2a493a --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -0,0 +1,704 @@ +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Cart } from "@bitwarden/pricing"; +import { BitwardenSubscription, SubscriptionCardComponent } from "@bitwarden/subscription"; + +describe("SubscriptionCardComponent", () => { + let component: SubscriptionCardComponent; + let fixture: ComponentFixture; + + const mockCart: Cart = { + passwordManager: { + seats: { + quantity: 5, + name: "members", + cost: 50, + }, + }, + cadence: "monthly", + estimatedTax: 0, + }; + + const baseSubscription = { + cart: mockCart, + storage: { + available: 1000, + readableUsed: "100 MB", + used: 100, + }, + }; + + const mockI18nService = { + t: (key: string, ...params: any[]) => { + const translations: Record = { + pendingCancellation: "Pending cancellation", + updatePayment: "Update payment", + expired: "Expired", + trial: "Trial", + active: "Active", + pastDue: "Past due", + canceled: "Canceled", + unpaid: "Unpaid", + weCouldNotProcessYourPayment: "We could not process your payment", + contactSupportShort: "Contact support", + yourSubscriptionHasExpired: "Your subscription has expired", + yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${params[0]}`, + reinstateSubscription: "Reinstate subscription", + upgradeYourPlan: "Upgrade your plan", + premiumShareEvenMore: "Premium share even more", + upgradeNow: "Upgrade now", + youHaveAGracePeriod: `You have a grace period of ${params[0]} days ending ${params[1]}`, + manageInvoices: "Manage invoices", + toReactivateYourSubscription: "To reactivate your subscription", + }; + return translations[key] || key; + }, + }; + + function setupComponent(subscription: BitwardenSubscription, title = "Test Plan") { + fixture.componentRef.setInput("title", title); + fixture.componentRef.setInput("subscription", subscription); + fixture.detectChanges(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SubscriptionCardComponent], + providers: [ + DatePipe, + { + provide: I18nService, + useValue: mockI18nService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubscriptionCardComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component).toBeTruthy(); + }); + + describe("Badge rendering", () => { + it("should display 'Update payment' badge with warning variant for incomplete status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Update payment"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge).toBeTruthy(); + expect(badge.nativeElement.textContent.trim()).toBe("Update payment"); + }); + + it("should display 'Expired' badge with danger variant for incomplete_expired status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Expired"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Expired"); + }); + + it("should display 'Trial' badge with success variant for trialing status", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.badge().text).toBe("Trial"); + expect(component.badge().variant).toBe("success"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Trial"); + }); + + it("should display 'Pending cancellation' badge for trialing status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + expect(component.badge().text).toBe("Pending cancellation"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation"); + }); + + it("should display 'Active' badge with success variant for active status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.badge().text).toBe("Active"); + expect(component.badge().variant).toBe("success"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Active"); + }); + + it("should display 'Pending cancellation' badge for active status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + expect(component.badge().text).toBe("Pending cancellation"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation"); + }); + + it("should display 'Past due' badge with warning variant for past_due status", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Past due"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Past due"); + }); + + it("should display 'Canceled' badge with danger variant for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + expect(component.badge().text).toBe("Canceled"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Canceled"); + }); + + it("should display 'Unpaid' badge with danger variant for unpaid status", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Unpaid"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Unpaid"); + }); + }); + + describe("Callout rendering", () => { + it("should display incomplete callout with update payment and contact support actions", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Update payment"); + expect(calloutData!.description).toContain("We could not process your payment"); + expect(calloutData!.callsToAction?.length).toBe(2); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("We could not process your payment"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Update payment"); + expect(buttons[1].nativeElement.textContent.trim()).toBe("Contact support"); + }); + + it("should display incomplete_expired callout with contact support action", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("danger"); + expect(calloutData!.title).toBe("Expired"); + expect(calloutData!.description).toContain("Your subscription has expired"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("Your subscription has expired"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Contact support"); + }); + + it("should display pending cancellation callout for active status with cancelAt", () => { + const cancelDate = new Date("2025-03-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: cancelDate, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Pending cancellation"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Reinstate subscription"); + }); + + it("should display upgrade callout for active status when showUpgradeButton is true", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", true); + fixture.detectChanges(); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("info"); + expect(calloutData!.title).toBe("Upgrade your plan"); + expect(calloutData!.description).toContain("Premium share even more"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("Premium share even more"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Upgrade now"); + }); + + it("should not display upgrade callout when showUpgradeButton is false", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", false); + fixture.detectChanges(); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeFalsy(); + }); + + it("should display past_due callout with manage invoices action", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Past due"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices"); + }); + + it("should not display callout for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeFalsy(); + }); + + it("should display unpaid callout with manage invoices action", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("danger"); + expect(calloutData!.title).toBe("Unpaid"); + expect(calloutData!.description).toContain("To reactivate your subscription"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("To reactivate your subscription"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices"); + }); + }); + + describe("Call-to-action clicks", () => { + it("should emit update-payment action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("bit-callout button")); + expect(buttons.length).toBe(2); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("update-payment"); + }); + + it("should emit contact-support action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("bit-callout button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("contact-support"); + }); + + it("should emit reinstate-subscription action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("reinstate-subscription"); + }); + + it("should emit upgrade-plan action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", true); + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("upgrade-plan"); + }); + + it("should emit manage-invoices action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("manage-invoices"); + }); + }); + + describe("Cart summary header content", () => { + it("should display suspension date for incomplete status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for incomplete_expired status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display cancellation date for trialing status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display next charge for trialing status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display cancellation date for active status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display next charge for active status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for past_due status", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display canceled date for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for unpaid status", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + }); + + describe("Title rendering", () => { + it("should display the provided title", () => { + setupComponent( + { + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }, + "Premium Plan", + ); + + const title = fixture.debugElement.query(By.css("h2[bitTypography='h3']")); + expect(title.nativeElement.textContent.trim()).toBe("Premium Plan"); + }); + }); + + describe("Computed properties", () => { + it("should compute cancelAt for active status with cancelAt date", () => { + const cancelDate = new Date("2025-03-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: cancelDate, + }); + + expect(component.cancelAt()).toEqual(cancelDate); + }); + + it("should compute cancelAt as undefined for active status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.cancelAt()).toBeUndefined(); + }); + + it("should compute canceled date for canceled status", () => { + const canceledDate = new Date("2025-01-15"); + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: canceledDate, + }); + + expect(component.canceled()).toEqual(canceledDate); + }); + + it("should compute canceled as undefined for non-canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.canceled()).toBeUndefined(); + }); + + it("should compute nextCharge for active status", () => { + const nextChargeDate = new Date("2025-02-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: nextChargeDate, + }); + + expect(component.nextCharge()).toEqual(nextChargeDate); + }); + + it("should compute nextCharge as undefined for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + expect(component.nextCharge()).toBeUndefined(); + }); + + it("should compute suspension date for incomplete status", () => { + const suspensionDate = new Date("2025-02-15"); + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: suspensionDate, + gracePeriod: 7, + }); + + expect(component.suspension()).toEqual(suspensionDate); + }); + + it("should compute suspension as undefined for active status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.suspension()).toBeUndefined(); + }); + }); +}); diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts new file mode 100644 index 00000000000..abe5789382b --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -0,0 +1,411 @@ +import { CommonModule, DatePipe } from "@angular/common"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + TypographyModule, +} from "@bitwarden/components"; +import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { BitwardenSubscription } from "../../types/bitwarden-subscription"; + +import { SubscriptionCardComponent } from "./subscription-card.component"; + +export default { + title: "Billing/Subscription Card", + component: SubscriptionCardComponent, + description: + "Displays subscription status, payment details, and action prompts based on subscription state.", + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + CartSummaryComponent, + TypographyModule, + I18nPipe, + ], + providers: [ + DatePipe, + { + provide: I18nService, + useValue: { + t: (key: string, ...args: any[]) => { + const translations: Record = { + pendingCancellation: "Pending cancellation", + updatePayment: "Update payment", + expired: "Expired", + trial: "Trial", + active: "Active", + pastDue: "Past due", + canceled: "Canceled", + unpaid: "Unpaid", + weCouldNotProcessYourPayment: + "We could not process your payment. Please update your payment method or contact the support team for assistance.", + contactSupportShort: "Contact Support", + yourSubscriptionHasExpired: + "Your subscription has expired. Please contact the support team for assistance.", + yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${args[0]}. You can reinstate it anytime before then.`, + reinstateSubscription: "Reinstate subscription", + upgradeYourPlan: "Upgrade your plan", + premiumShareEvenMore: + "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise.", + upgradeNow: "Upgrade now", + youHaveAGracePeriod: `You have a grace period of ${args[0]} days from your subscription expiration date. Please resolve the past due invoices by ${args[1]}.`, + manageInvoices: "Manage invoices", + toReactivateYourSubscription: + "To reactivate your subscription, please resolve the past due invoices.", + yourSubscriptionWillBeSuspendedOn: "Your subscription will be suspended on", + yourSubscriptionWasSuspendedOn: "Your subscription was suspended on", + yourSubscriptionWillBeCanceledOn: "Your subscription will be canceled on", + yourNextChargeIsFor: "Your next charge is for", + dueOn: "due on", + yourSubscriptionWasCanceledOn: "Your subscription was canceled on", + members: "Members", + additionalStorageGB: "Additional storage GB", + month: "month", + year: "year", + estimatedTax: "Estimated tax", + total: "Total", + expandPurchaseDetails: "Expand purchase details", + collapsePurchaseDetails: "Collapse purchase details", + passwordManager: "Password Manager", + secretsManager: "Secrets Manager", + additionalStorageGb: "Additional storage (GB)", + additionalServiceAccountsV2: "Additional machine accounts", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Active: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const ActiveWithUpgrade: Story = { + name: "Active - With Upgrade Option", + args: { + title: "Premium Subscription", + showUpgradeButton: true, + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Trial: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "trialing", + nextCharge: new Date("2025-02-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 50, + readableUsed: "50 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const TrialWithUpgrade: Story = { + name: "Trial - With Upgrade Option", + args: { + title: "Premium Subscription", + showUpgradeButton: true, + subscription: { + status: "trialing", + nextCharge: new Date("2025-02-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 50, + readableUsed: "50 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Incomplete: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const IncompleteExpired: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "incomplete_expired", + suspension: new Date("2025-01-01"), + gracePeriod: 0, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const PastDue: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "past_due", + suspension: new Date("2025-02-05"), + gracePeriod: 14, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const PendingCancellation: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cancelAt: new Date("2025-03-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Unpaid: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "unpaid", + suspension: new Date("2025-01-20"), + gracePeriod: 0, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Canceled: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "canceled", + canceled: new Date("2025-01-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Enterprise: Story = { + args: { + title: "Enterprise Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-03-01"), + cart: { + passwordManager: { + seats: { + quantity: 5, + name: "members", + cost: 7, + }, + additionalStorage: { + quantity: 2, + name: "additionalStorageGB", + cost: 0.5, + }, + }, + secretsManager: { + seats: { + quantity: 3, + name: "members", + cost: 13, + }, + additionalServiceAccounts: { + quantity: 5, + name: "additionalServiceAccountsV2", + cost: 1, + }, + }, + discount: { + type: DiscountTypes.PercentOff, + active: true, + value: 0.25, + }, + cadence: "monthly", + estimatedTax: 6.4, + }, + storage: { + available: 7, + readableUsed: "7 GB", + used: 0, + }, + }, + }, +}; diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts new file mode 100644 index 00000000000..f52127a0104 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -0,0 +1,274 @@ +import { CommonModule, DatePipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + BadgeVariant, + ButtonModule, + CalloutModule, + CardComponent, + TypographyModule, + CalloutTypes, + ButtonType, +} from "@bitwarden/components"; +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"; + +type Badge = { text: string; variant: BadgeVariant }; + +type Callout = Maybe<{ + title: string; + type: CalloutTypes; + icon?: string; + description: string; + callsToAction?: { + text: string; + buttonType: ButtonType; + action: PlanCardAction; + }[]; +}>; + +@Component({ + selector: "billing-subscription-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./subscription-card.component.html", + imports: [ + CommonModule, + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + CartSummaryComponent, + TypographyModule, + I18nPipe, + ], +}) +export class SubscriptionCardComponent { + private datePipe = inject(DatePipe); + private i18nService = inject(I18nService); + + protected readonly dateFormat = "MMM. d, y"; + + readonly title = input.required(); + + readonly subscription = input.required(); + + readonly showUpgradeButton = input(false); + + readonly callToActionClicked = output(); + + readonly badge = computed(() => { + const subscription = this.subscription(); + const pendingCancellation: Badge = { + text: this.i18nService.t("pendingCancellation"), + variant: "warning", + }; + switch (subscription.status) { + case SubscriptionStatuses.Incomplete: { + return { + text: this.i18nService.t("updatePayment"), + variant: "warning", + }; + } + case SubscriptionStatuses.IncompleteExpired: { + return { + text: this.i18nService.t("expired"), + variant: "danger", + }; + } + case SubscriptionStatuses.Trialing: { + if (subscription.cancelAt) { + return pendingCancellation; + } + return { + text: this.i18nService.t("trial"), + variant: "success", + }; + } + case SubscriptionStatuses.Active: { + if (subscription.cancelAt) { + return pendingCancellation; + } + return { + text: this.i18nService.t("active"), + variant: "success", + }; + } + case SubscriptionStatuses.PastDue: { + return { + text: this.i18nService.t("pastDue"), + variant: "warning", + }; + } + case SubscriptionStatuses.Canceled: { + return { + text: this.i18nService.t("canceled"), + variant: "danger", + }; + } + case SubscriptionStatuses.Unpaid: { + return { + text: this.i18nService.t("unpaid"), + variant: "danger", + }; + } + } + }); + + readonly callout = computed(() => { + const subscription = this.subscription(); + switch (subscription.status) { + case SubscriptionStatuses.Incomplete: { + return { + title: this.i18nService.t("updatePayment"), + type: "warning", + description: this.i18nService.t("weCouldNotProcessYourPayment"), + callsToAction: [ + { + text: this.i18nService.t("updatePayment"), + buttonType: "unstyled", + action: "update-payment", + }, + { + text: this.i18nService.t("contactSupportShort"), + buttonType: "unstyled", + action: "contact-support", + }, + ], + }; + } + case SubscriptionStatuses.IncompleteExpired: { + return { + title: this.i18nService.t("expired"), + type: "danger", + description: this.i18nService.t("yourSubscriptionHasExpired"), + callsToAction: [ + { + text: this.i18nService.t("contactSupportShort"), + buttonType: "unstyled", + action: "contact-support", + }, + ], + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + if (subscription.cancelAt) { + const cancelAt = this.datePipe.transform(subscription.cancelAt, this.dateFormat); + return { + title: this.i18nService.t("pendingCancellation"), + type: "warning", + description: this.i18nService.t("yourSubscriptionIsScheduledToCancel", cancelAt!), + callsToAction: [ + { + text: this.i18nService.t("reinstateSubscription"), + buttonType: "unstyled", + action: "reinstate-subscription", + }, + ], + }; + } + if (!this.showUpgradeButton()) { + return null; + } + return { + title: this.i18nService.t("upgradeYourPlan"), + type: "info", + icon: "bwi-gem", + description: this.i18nService.t("premiumShareEvenMore"), + callsToAction: [ + { + text: this.i18nService.t("upgradeNow"), + buttonType: "unstyled", + action: "upgrade-plan", + }, + ], + }; + } + case SubscriptionStatuses.PastDue: { + const suspension = this.datePipe.transform(subscription.suspension, this.dateFormat); + return { + title: this.i18nService.t("pastDue"), + type: "warning", + description: this.i18nService.t( + "youHaveAGracePeriod", + subscription.gracePeriod, + suspension!, + ), + callsToAction: [ + { + text: this.i18nService.t("manageInvoices"), + buttonType: "unstyled", + action: "manage-invoices", + }, + ], + }; + } + case SubscriptionStatuses.Canceled: { + return null; + } + case SubscriptionStatuses.Unpaid: { + return { + title: this.i18nService.t("unpaid"), + type: "danger", + description: this.i18nService.t("toReactivateYourSubscription"), + callsToAction: [ + { + text: this.i18nService.t("manageInvoices"), + buttonType: "unstyled", + action: "manage-invoices", + }, + ], + }; + } + } + }); + + readonly cancelAt = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active + ) { + return subscription.cancelAt; + } + }); + + readonly canceled = computed>(() => { + const subscription = this.subscription(); + if (subscription.status === SubscriptionStatuses.Canceled) { + return subscription.canceled; + } + }); + + readonly nextCharge = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active + ) { + return subscription.nextCharge; + } + }); + + readonly suspension = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Incomplete || + subscription.status === SubscriptionStatuses.IncompleteExpired || + subscription.status === SubscriptionStatuses.PastDue || + subscription.status === SubscriptionStatuses.Unpaid + ) { + return subscription.suspension; + } + }); +} diff --git a/libs/subscription/src/index.ts b/libs/subscription/src/index.ts index 3deb7c89d41..29b96017cda 100644 --- a/libs/subscription/src/index.ts +++ b/libs/subscription/src/index.ts @@ -1 +1,8 @@ -export type Placeholder = unknown; +// Components +export * from "./components/additional-options-card/additional-options-card.component"; +export * from "./components/subscription-card/subscription-card.component"; +export * from "./components/storage-card/storage-card.component"; + +// Types +export * from "./types/bitwarden-subscription"; +export * from "./types/storage"; diff --git a/libs/subscription/src/subscription.spec.ts b/libs/subscription/src/subscription.spec.ts deleted file mode 100644 index 7f0836a5063..00000000000 --- a/libs/subscription/src/subscription.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as lib from "./index"; - -describe("subscription", () => { - // This test will fail until something is exported from index.ts - it("should work", () => { - expect(lib).toBeDefined(); - }); -}); diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts new file mode 100644 index 00000000000..15bf64d03aa --- /dev/null +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -0,0 +1,40 @@ +import { Cart } from "@bitwarden/pricing"; + +import { Storage } from "./storage"; + +export const SubscriptionStatuses = { + Incomplete: "incomplete", + IncompleteExpired: "incomplete_expired", + Trialing: "trialing", + Active: "active", + PastDue: "past_due", + Canceled: "canceled", + Unpaid: "unpaid", +} as const; + +type HasCart = { + cart: Cart; +}; + +type HasStorage = { + storage: Storage; +}; + +type Suspension = { + status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid"; + suspension: Date; + gracePeriod: number; +}; + +type Billable = { + status: "trialing" | "active"; + nextCharge: Date; + cancelAt?: Date; +}; + +type Canceled = { + status: "canceled"; + canceled: Date; +}; + +export type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled); diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts new file mode 100644 index 00000000000..beb187250dd --- /dev/null +++ b/libs/subscription/src/types/storage.ts @@ -0,0 +1,5 @@ +export type Storage = { + available: number; + readableUsed: string; + used: number; +}; diff --git a/libs/subscription/test.setup.ts b/libs/subscription/test.setup.ts new file mode 100644 index 00000000000..159c28d2be5 --- /dev/null +++ b/libs/subscription/test.setup.ts @@ -0,0 +1,28 @@ +import { webcrypto } from "crypto"; +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); + +Object.defineProperty(window, "crypto", { + value: webcrypto, +});