mirror of
https://github.com/bitwarden/clients.git
synced 2026-01-11 20:07:18 +00:00
[PM-29608] [PM-29609] Premium subscription redesign cards (#18145)
* refactor(pricing): misc - Remove unused test file * refactor(pricing): discount-badge.component - Introduce new Discount union type - Introduce Maybe type helper for T | null | undefined - Use Discount type in the discount-badge.component - Update the user-subscription.component to pass Discount type into the discount-badge.component - Update spec, stories and mdx * refactor(pricing): pricing-card.component - Support changeDetection: ChangeDetectionStrategy.OnPush - Update spec and mdx files * refactor(pricing): cart-summary.component - Introduce new Cart type - Use Cart type as main input in cart-summary.component - Support optional custom header template in cart-summary.component - Support optional cart-level Discount type in cart-summary.component - Update upgrade-payment.component to pass in new Cart type to cart-summary.component - Update spec file, stories and mdx file * feat(subscription): misc - Remove unused test file - Update jest.config.js - Add test.setup.ts * feat(subscription): subscription-card.component - Add BitwardenSubscription type - Add subscription-card.component - Add translations - Add spec file, stories and MDX file * feat(subscription): storage-card.component - Add standalone Storage type - Add storage-card.component - Add spec file, stories and MDX file * feat(subscription): additional-options-card.component - Add additional-options-card.component - Add spec file, stories and MDX file * fix(pricing): cart-summary.component.stories.ts lint * fix(pricing): discount-badge.component.stories.ts lint * fix(web): Resolve estimatedTax$ toSignal for use in cart on upgrade-payment.component * feedback(design): Fix design issues * Kyle's feedback * Kyle's feedback * cleanup: Use SubscriptionStatuses instead of string values * feat: Add CTA disabling input to storage-card.component * feat: Add CTA disabling input to additional-options-card.component
This commit is contained in:
parent
ba89a3dd70
commit
1f763f470a
46 changed files with 4627 additions and 609 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -50,11 +50,7 @@
|
|||
</section>
|
||||
|
||||
<section>
|
||||
<billing-cart-summary
|
||||
#cartSummaryComponent
|
||||
[passwordManager]="passwordManager()"
|
||||
[estimatedTax]="estimatedTax$ | async"
|
||||
></billing-cart-summary>
|
||||
<billing-cart-summary #cartSummaryComponent [cart]="cart()"></billing-cart-summary>
|
||||
@if (isFamiliesPlan) {
|
||||
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
|
||||
{{ "paymentChargedWithTrial" | i18n }}
|
||||
|
|
|
|||
|
|
@ -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<PlanDetails | null>(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<boolean>;
|
||||
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
|
||||
protected estimatedTax$!: Observable<number>;
|
||||
|
||||
// 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<Cart>(() => {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
}}
|
||||
</span>
|
||||
<billing-discount-badge
|
||||
[discount]="getDiscountInfo(sub?.customerDiscount)"
|
||||
[discount]="getDiscount(sub?.customerDiscount)"
|
||||
></billing-discount-badge>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
|||
|
|
@ -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<Discount> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
@let passwordManager = this.passwordManager();
|
||||
@let additionalStorage = this.additionalStorage();
|
||||
@let secretsManager = this.secretsManager();
|
||||
@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts;
|
||||
@let cart = this.cart();
|
||||
@let term = this.term();
|
||||
|
||||
<div class="tw-size-full">
|
||||
<div class="tw-flex tw-items-center tw-pb-2">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
id="purchase-summary-heading"
|
||||
class="!tw-m-0"
|
||||
data-testid="purchase-summary-heading-total"
|
||||
>
|
||||
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
|
||||
</h2>
|
||||
<span bitTypography="h3"> </span>
|
||||
<span bitTypography="body1" class="tw-text-main">/ {{ passwordManager.cadence | i18n }}</span>
|
||||
@if (this.header(); as header) {
|
||||
<ng-container *ngTemplateOutlet="header; context: { total: total() }" />
|
||||
} @else {
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
id="purchase-summary-heading"
|
||||
class="!tw-m-0"
|
||||
data-testid="purchase-summary-heading-total"
|
||||
>
|
||||
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
|
||||
</h2>
|
||||
<span bitTypography="h3"> </span>
|
||||
<span bitTypography="body1" class="tw-text-main">/ {{ term }}</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -42,26 +44,28 @@
|
|||
<!-- Password Manager Members -->
|
||||
<div id="password-manager-members" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
@let passwordManagerSeats = cart.passwordManager.seats;
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x
|
||||
{{ passwordManager.cost | currency: "USD" : "symbol" }}
|
||||
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x
|
||||
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ passwordManager.cadence | i18n }}
|
||||
{{ term }}
|
||||
</div>
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="password-manager-total">
|
||||
{{ passwordManagerTotal() | currency: "USD" : "symbol" }}
|
||||
{{ passwordManagerSeatsTotal() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Storage -->
|
||||
@let additionalStorage = cart.passwordManager.additionalStorage;
|
||||
@if (additionalStorage) {
|
||||
<div id="additional-storage" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x
|
||||
{{ additionalStorage.cost | currency: "USD" : "symbol" }} /
|
||||
{{ additionalStorage.cadence | i18n }}
|
||||
{{ term }}
|
||||
</div>
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="additional-storage-total">
|
||||
|
|
@ -72,7 +76,8 @@
|
|||
</div>
|
||||
|
||||
<!-- Secrets Manager Section -->
|
||||
@if (secretsManager) {
|
||||
@let secretsManagerSeats = cart.secretsManager?.seats;
|
||||
@if (secretsManagerSeats) {
|
||||
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
|
||||
|
|
@ -81,9 +86,9 @@
|
|||
<!-- Secrets Manager Members -->
|
||||
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ secretsManager.seats.quantity }} {{ secretsManager.seats.name | i18n }} x
|
||||
{{ secretsManager.seats.cost | currency: "USD" : "symbol" }}
|
||||
/ {{ secretsManager.seats.cadence | i18n }}
|
||||
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x
|
||||
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/ {{ term }}
|
||||
</div>
|
||||
<div
|
||||
bitTypography="body1"
|
||||
|
|
@ -95,6 +100,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Additional Service Accounts -->
|
||||
@let additionalServiceAccounts = cart.secretsManager?.additionalServiceAccounts;
|
||||
@if (additionalServiceAccounts) {
|
||||
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
|
|
@ -102,7 +108,7 @@
|
|||
{{ additionalServiceAccounts.name | i18n }} x
|
||||
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ additionalServiceAccounts.cadence | i18n }}
|
||||
{{ term }}
|
||||
</div>
|
||||
<div
|
||||
bitTypography="body1"
|
||||
|
|
@ -116,6 +122,20 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
<!-- Discount -->
|
||||
@if (discountAmount() > 0) {
|
||||
<div
|
||||
id="discount-section"
|
||||
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
|
||||
data-testid="discount-section"
|
||||
>
|
||||
<h3 bitTypography="h5" class="tw-text-success-600">{{ discountLabel() }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-success-600" data-testid="discount-amount">
|
||||
-{{ discountAmount() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Estimated Tax -->
|
||||
<div
|
||||
id="estimated-tax-section"
|
||||
|
|
@ -131,7 +151,7 @@
|
|||
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
|
||||
{{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }}
|
||||
{{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@ behavior across Bitwarden applications.
|
|||
- [With Secrets Manager](#with-secrets-manager)
|
||||
- [With Secrets Manager and Additional Service Accounts](#with-secrets-manager-and-additional-service-accounts)
|
||||
- [All Products](#all-products)
|
||||
- [With Percent Discount](#with-percent-discount)
|
||||
- [With Amount Discount](#with-amount-discount)
|
||||
- [Custom Header Template](#custom-header-template)
|
||||
- [Premium Plan](#premium-plan)
|
||||
- [Family Plan](#family-plan)
|
||||
- [Families Plan](#families-plan)
|
||||
- [Features](#features)
|
||||
- [Do's and Don'ts](#dos-and-donts)
|
||||
- [Accessibility](#accessibility)
|
||||
|
|
@ -34,32 +37,24 @@ behavior across Bitwarden applications.
|
|||
## Usage
|
||||
|
||||
The cart summary component is designed to be used in checkout and subscription interfaces to display
|
||||
order details, prices, and totals.
|
||||
order details, prices, totals, and discounts.
|
||||
|
||||
```ts
|
||||
import { CartSummaryComponent, LineItem } from "@bitwarden/pricing";
|
||||
import { CartSummaryComponent, Cart } from "@bitwarden/pricing";
|
||||
```
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="passwordManagerItem"
|
||||
[additionalStorage]="additionalStorageItem"
|
||||
[secretsManager]="secretsManagerItems"
|
||||
[estimatedTax]="taxAmount"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
<billing-cart-summary [cart]="cart"> </billing-cart-summary>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
| ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------- |
|
||||
| `passwordManager` | `LineItem` | **Required.** The Password Manager product line item |
|
||||
| `additionalStorage` | `LineItem \| undefined` | **Optional.** Additional storage line item, if applicable |
|
||||
| `secretsManager` | `{ seats: LineItem; additionalServiceAccounts?: LineItem } \| undefined` | **Optional.** Secrets Manager related line items, if applicable |
|
||||
| `estimatedTax` | `number` | **Required.** Estimated tax amount |
|
||||
| Input | Type | Description |
|
||||
| -------- | ------------------------ | ------------------------------------------------------------------------------- |
|
||||
| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence |
|
||||
| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header |
|
||||
|
||||
### Events
|
||||
|
||||
|
|
@ -68,49 +63,100 @@ collapsing and expanding the view.
|
|||
|
||||
## Data Structure
|
||||
|
||||
The component uses the following LineItem data structure:
|
||||
The component uses the following Cart and CartItem data structures:
|
||||
|
||||
```typescript
|
||||
export type LineItem = {
|
||||
export type CartItem = {
|
||||
name: string; // Display name for i18n lookup
|
||||
quantity: number; // Number of items
|
||||
name: string; // Display name of the item
|
||||
cost: number; // Cost of each item
|
||||
cadence: "month" | "year"; // Billing period
|
||||
cost: number; // Cost per item
|
||||
discount?: Discount; // Optional item-level discount
|
||||
};
|
||||
|
||||
export type Cart = {
|
||||
passwordManager: {
|
||||
seats: CartItem; // Required PM seats
|
||||
additionalStorage?: CartItem; // Optional additional storage
|
||||
};
|
||||
secretsManager?: {
|
||||
// Optional SM section
|
||||
seats: CartItem; // SM seats
|
||||
additionalServiceAccounts?: CartItem; // Optional service accounts
|
||||
};
|
||||
cadence: "annually" | "monthly"; // Billing period for entire cart
|
||||
discount?: Discount; // Optional cart-level discount
|
||||
estimatedTax: number; // Tax amount
|
||||
};
|
||||
|
||||
import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
|
||||
|
||||
export type Discount = {
|
||||
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
|
||||
active: boolean; // Whether discount is currently applied
|
||||
value: number; // Dollar amount or percentage (20 for 20%)
|
||||
};
|
||||
```
|
||||
|
||||
## Flexibility
|
||||
|
||||
The cart summary component provides flexibility through its structured input properties:
|
||||
The cart summary component provides flexibility through its structured Cart input:
|
||||
|
||||
```html
|
||||
<!-- Basic usage with only Password Manager -->
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
estimatedTax: 9.60
|
||||
}"
|
||||
[estimatedTax]="9.60"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
|
||||
<!-- With Additional Storage -->
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
estimatedTax: 12.00
|
||||
}"
|
||||
[additionalStorage]="{
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
>
|
||||
</billing-cart-summary>
|
||||
|
||||
<!-- With Discount -->
|
||||
<billing-cart-summary
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
discount: {
|
||||
type: 'percent-off',
|
||||
active: true,
|
||||
value: 20
|
||||
},
|
||||
estimatedTax: 8.00
|
||||
}"
|
||||
[estimatedTax]="12.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
|
@ -138,13 +184,17 @@ Show cart with yearly subscription:
|
|||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 500.00,
|
||||
cadence: 'year'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 500.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
estimatedTax: 120.00
|
||||
}"
|
||||
[estimatedTax]="120.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
|
@ -157,19 +207,22 @@ Show cart with password manager and additional storage:
|
|||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
estimatedTax: 12.00
|
||||
}"
|
||||
[additionalStorage]="{
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[estimatedTax]="12.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
|
@ -182,21 +235,24 @@ Show cart with password manager and secrets manager seats only:
|
|||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
cost: 30.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
estimatedTax: 16.00
|
||||
}"
|
||||
[secretsManager]="{
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
cost: 30.00,
|
||||
cadence: 'month'
|
||||
}
|
||||
}"
|
||||
[estimatedTax]="16.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
|
@ -209,27 +265,29 @@ Show cart with password manager, secrets manager seats, and additional service a
|
|||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[secretsManager]="{
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
cost: 30.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
cost: 6.00,
|
||||
cadence: 'month'
|
||||
}
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
cost: 30.00
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
cost: 6.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
estimatedTax: 16.00
|
||||
}"
|
||||
[estimatedTax]="16.00"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
|
@ -242,51 +300,144 @@ Show a cart with all available products:
|
|||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[additionalStorage]="{
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
}"
|
||||
[secretsManager]="{
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
cost: 30.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
cost: 6.00,
|
||||
cadence: 'month'
|
||||
}
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
cost: 30.00
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
cost: 6.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
estimatedTax: 19.20
|
||||
}"
|
||||
[estimatedTax]="19.20"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Percent Discount
|
||||
|
||||
Show cart with percentage-based discount:
|
||||
|
||||
<Canvas of={CartSummaryStories.WithPercentDiscount} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
discount: {
|
||||
type: 'percent-off',
|
||||
active: true,
|
||||
value: 20
|
||||
},
|
||||
estimatedTax: 10.40
|
||||
}"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Amount Discount
|
||||
|
||||
Show cart with fixed amount discount:
|
||||
|
||||
<Canvas of={CartSummaryStories.WithAmountDiscount} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
cost: 30.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
discount: {
|
||||
type: 'amount-off',
|
||||
active: true,
|
||||
value: 50.00
|
||||
},
|
||||
estimatedTax: 95.00
|
||||
}"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### Custom Header Template
|
||||
|
||||
Show cart with custom header template:
|
||||
|
||||
<Canvas of={CartSummaryStories.CustomHeaderTemplate} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary [cart]="cartData" [header]="customHeader">
|
||||
<ng-template #customHeader let-total="total">
|
||||
<div class="tw-flex tw-flex-col tw-gap-1">
|
||||
<h3 bitTypography="h3" class="!tw-m-0 tw-text-primary">
|
||||
Your Total: {{ total | currency: 'USD' : 'symbol' }}
|
||||
</h3>
|
||||
<p bitTypography="body2" class="!tw-m-0 tw-text-muted">Custom header with enhanced styling</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### Premium Plan
|
||||
|
||||
Show cart with premium plan:
|
||||
|
||||
<Canvas of={CartSummaryStories.PremiumPlan} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 1,
|
||||
name: 'premiumMembership',
|
||||
cost: 10.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: 'premiumMembership',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
estimatedTax: 2.71
|
||||
}"
|
||||
[estimatedTax]="2.71"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
|
@ -296,15 +447,20 @@ Show cart with premium plan:
|
|||
Show cart with families plan:
|
||||
|
||||
<Canvas of={CartSummaryStories.FamiliesPlan} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[passwordManager]="{
|
||||
quantity: 1,
|
||||
name: 'familiesMembership',
|
||||
cost: 40.00,
|
||||
cadence: 'month'
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: 'familiesMembership',
|
||||
cost: 40.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
estimatedTax: 4.67
|
||||
}"
|
||||
[estimatedTax]="4.67"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
|
@ -314,31 +470,37 @@ Show cart with families plan:
|
|||
- **Collapsible Interface**: Users can toggle between a summary view showing only the total and a
|
||||
detailed view showing all line items
|
||||
- **Line Item Grouping**: Organizes items by product category (Password Manager, Secrets Manager)
|
||||
- **Dynamic Calculations**: Automatically calculates and displays subtotals and totals using Angular
|
||||
signals and computed values
|
||||
- **Flexible Structure**: Accommodates different combinations of products and add-ons
|
||||
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, taxes, and totals using
|
||||
Angular signals and computed values
|
||||
- **Discount Support**: Displays both percentage-based and fixed-amount discounts with green success
|
||||
styling
|
||||
- **Custom Header Templates**: Optional header input allows for custom header designs while
|
||||
maintaining cart functionality
|
||||
- **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts
|
||||
- **Consistent Formatting**: Maintains uniform display of prices, quantities, and cadence
|
||||
- **Modern Angular Patterns**: Uses `@let` to efficiently store and reuse signal values in the
|
||||
template
|
||||
- **Modern Angular Patterns**: Uses `@let` to efficiently store and reuse signal values, OnPush
|
||||
change detection, and Angular 17+ control flow
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### ✅ Do
|
||||
|
||||
- Use consistent naming and formatting for line items
|
||||
- Use consistent naming and formatting for cart items
|
||||
- Include clear quantity and unit pricing information
|
||||
- Ensure tax estimates are accurate and clearly labeled
|
||||
- Maintain consistent cadence formats across related items
|
||||
- Use the same cadence for all items within a single cart
|
||||
- Use localized strings for LineItem names
|
||||
- Set `active: true` on discounts that should be displayed
|
||||
- Use localized strings for CartItem names (for i18n lookup)
|
||||
- Provide complete Cart object with all required fields
|
||||
- Use "annually" or "monthly" for cadence (not "year" or "month")
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
- Mix monthly and yearly cadences within the same cart
|
||||
- Omit required inputs (passwordManager, estimatedTax)
|
||||
- Modify the component's internal calculations
|
||||
- Omit required Cart fields (passwordManager.seats, cadence, estimatedTax)
|
||||
- Use old "month"/"year" cadence values (use "monthly"/"annually")
|
||||
- Modify the component's internal calculations or totals
|
||||
- Use inconsistent formatting for monetary values
|
||||
- Override the default styles and layout
|
||||
- Override the default styles without considering accessibility
|
||||
- Mix different cadences - the cart uses a single cadence for all items
|
||||
|
||||
## Accessibility
|
||||
|
||||
|
|
|
|||
|
|
@ -1,51 +1,49 @@
|
|||
import { CurrencyPipe } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, TemplateRef, viewChild } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
import { CartSummaryComponent, LineItem } from "./cart-summary.component";
|
||||
import { Cart } from "../../types/cart";
|
||||
|
||||
describe("CartSummaryComponent", () => {
|
||||
let component: CartSummaryComponent;
|
||||
let fixture: ComponentFixture<CartSummaryComponent>;
|
||||
|
||||
const mockPasswordManager: LineItem = {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50,
|
||||
cadence: "month",
|
||||
};
|
||||
|
||||
const mockAdditionalStorage: LineItem = {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10,
|
||||
cadence: "month",
|
||||
};
|
||||
|
||||
const mockSecretsManager = {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
cadence: "month",
|
||||
const mockCart: Cart = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10,
|
||||
},
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
cadence: "month",
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 9.6,
|
||||
};
|
||||
|
||||
const mockEstimatedTax = 9.6;
|
||||
|
||||
function setupComponent() {
|
||||
// Set input values
|
||||
fixture.componentRef.setInput("passwordManager", mockPasswordManager);
|
||||
fixture.componentRef.setInput("additionalStorage", mockAdditionalStorage);
|
||||
fixture.componentRef.setInput("secretsManager", mockSecretsManager);
|
||||
fixture.componentRef.setInput("estimatedTax", mockEstimatedTax);
|
||||
fixture.componentRef.setInput("cart", mockCart);
|
||||
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
|
@ -89,6 +87,8 @@ describe("CartSummaryComponent", () => {
|
|||
return "Families membership";
|
||||
case "premiumMembership":
|
||||
return "Premium membership";
|
||||
case "discount":
|
||||
return "discount";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
|
|
@ -161,7 +161,9 @@ describe("CartSummaryComponent", () => {
|
|||
// Arrange
|
||||
const pmSection = fixture.debugElement.query(By.css('[id="password-manager"]'));
|
||||
const pmHeading = pmSection.query(By.css("h3"));
|
||||
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-muted"));
|
||||
const pmLineItem = pmSection.query(
|
||||
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
|
||||
);
|
||||
const pmTotal = pmSection.query(By.css("[data-testid='password-manager-total']"));
|
||||
|
||||
// Act/ Assert
|
||||
|
|
@ -225,4 +227,258 @@ describe("CartSummaryComponent", () => {
|
|||
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default Header (without custom template)", () => {
|
||||
it("should render default header when no custom template is provided", () => {
|
||||
// Arrange / Act
|
||||
const defaultHeader = fixture.debugElement.query(
|
||||
By.css('[data-testid="purchase-summary-heading-total"]'),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(defaultHeader).toBeTruthy();
|
||||
expect(defaultHeader.nativeElement.textContent).toContain("Total:");
|
||||
expect(defaultHeader.nativeElement.textContent).toContain("$381.60");
|
||||
});
|
||||
|
||||
it("should display term (month/year) in default header", () => {
|
||||
// Arrange / Act
|
||||
const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-main"));
|
||||
// Find the span that contains the term
|
||||
const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/"));
|
||||
|
||||
// Assert
|
||||
expect(termElement).toBeTruthy();
|
||||
expect(termElement!.nativeElement.textContent.trim()).toBe("/ month");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Discount Display", () => {
|
||||
it("should not display discount section when no discount is present", () => {
|
||||
// Arrange / Act
|
||||
const discountSection = fixture.debugElement.query(
|
||||
By.css('[data-testid="discount-section"]'),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(discountSection).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should display percent-off discount correctly", () => {
|
||||
// Arrange
|
||||
const cartWithDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithDiscount);
|
||||
fixture.detectChanges();
|
||||
|
||||
const discountSection = fixture.debugElement.query(
|
||||
By.css('[data-testid="discount-section"]'),
|
||||
);
|
||||
const discountLabel = discountSection.query(By.css("h3"));
|
||||
const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]'));
|
||||
|
||||
// Act / Assert
|
||||
expect(discountSection).toBeTruthy();
|
||||
expect(discountLabel.nativeElement.textContent.trim()).toBe("20% discount");
|
||||
// Subtotal = 250 + 20 + 90 + 12 = 372, 20% of 372 = 74.4
|
||||
expect(discountAmount.nativeElement.textContent).toContain("-$74.40");
|
||||
});
|
||||
|
||||
it("should display amount-off discount correctly", () => {
|
||||
// Arrange
|
||||
const cartWithDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 50.0,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithDiscount);
|
||||
fixture.detectChanges();
|
||||
|
||||
const discountSection = fixture.debugElement.query(
|
||||
By.css('[data-testid="discount-section"]'),
|
||||
);
|
||||
const discountLabel = discountSection.query(By.css("h3"));
|
||||
const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]'));
|
||||
|
||||
// Act / Assert
|
||||
expect(discountSection).toBeTruthy();
|
||||
expect(discountLabel.nativeElement.textContent.trim()).toBe("$50.00 discount");
|
||||
expect(discountAmount.nativeElement.textContent).toContain("-$50.00");
|
||||
});
|
||||
|
||||
it("should not display discount when discount is inactive", () => {
|
||||
// Arrange
|
||||
const cartWithInactiveDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithInactiveDiscount);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act / Assert
|
||||
const discountSection = fixture.debugElement.query(
|
||||
By.css('[data-testid="discount-section"]'),
|
||||
);
|
||||
expect(discountSection).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should apply discount to total calculation", () => {
|
||||
// Arrange
|
||||
const cartWithDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithDiscount);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Subtotal = 372, discount = 74.4, tax = 9.6
|
||||
// Total = 372 - 74.4 + 9.6 = 307.2
|
||||
const expectedTotal = "$307.20";
|
||||
const topTotal = fixture.debugElement.query(By.css("h2"));
|
||||
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
|
||||
|
||||
// Act / Assert
|
||||
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CartSummaryComponent - Custom Header Template", () => {
|
||||
@Component({
|
||||
template: `
|
||||
<billing-cart-summary [cart]="cart" [header]="customHeader">
|
||||
<ng-template #customHeader let-total="total">
|
||||
<div data-testid="custom-header">
|
||||
<h2>Custom Total: {{ total | currency: "USD" : "symbol" }}</h2>
|
||||
</div>
|
||||
</ng-template>
|
||||
</billing-cart-summary>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CartSummaryComponent, CurrencyPipe],
|
||||
})
|
||||
class TestHostComponent {
|
||||
readonly customHeaderTemplate =
|
||||
viewChild.required<TemplateRef<{ total: number }>>("customHeader");
|
||||
cart: Cart = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 9.6,
|
||||
};
|
||||
}
|
||||
|
||||
let hostFixture: ComponentFixture<TestHostComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "month":
|
||||
return "month";
|
||||
case "year":
|
||||
return "year";
|
||||
case "members":
|
||||
return "Members";
|
||||
case "additionalStorageGB":
|
||||
return "Additional storage GB";
|
||||
case "additionalServiceAccountsV2":
|
||||
return "Additional machine accounts";
|
||||
case "secretsManagerSeats":
|
||||
return "Secrets Manager seats";
|
||||
case "passwordManager":
|
||||
return "Password Manager";
|
||||
case "secretsManager":
|
||||
return "Secrets Manager";
|
||||
case "additionalStorage":
|
||||
return "Additional Storage";
|
||||
case "estimatedTax":
|
||||
return "Estimated tax";
|
||||
case "total":
|
||||
return "Total";
|
||||
case "expandPurchaseDetails":
|
||||
return "Expand purchase details";
|
||||
case "collapsePurchaseDetails":
|
||||
return "Collapse purchase details";
|
||||
case "discount":
|
||||
return "discount";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
hostFixture = TestBed.createComponent(TestHostComponent);
|
||||
hostFixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should render custom header template when provided", () => {
|
||||
// Arrange / Act
|
||||
const customHeader = hostFixture.debugElement.query(By.css('[data-testid="custom-header"]'));
|
||||
const defaultHeader = hostFixture.debugElement.query(
|
||||
By.css('[data-testid="purchase-summary-heading-total"]'),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(customHeader).toBeTruthy();
|
||||
expect(defaultHeader).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should pass correct total value to custom header template", () => {
|
||||
// Arrange
|
||||
const expectedTotal = "$381.60"; // 250 + 20 + 90 + 12 + 9.6
|
||||
const customHeader = hostFixture.debugElement.query(By.css('[data-testid="custom-header"]'));
|
||||
|
||||
// Act / Assert
|
||||
expect(customHeader.nativeElement.textContent).toContain("Custom Total:");
|
||||
expect(customHeader.nativeElement.textContent).toContain(expectedTotal);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { DatePipe } from "@angular/common";
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { IconButtonModule, TypographyModule } from "@bitwarden/components";
|
||||
import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { CartSummaryComponent } from "./cart-summary.component";
|
||||
import { Cart } from "../../types/cart";
|
||||
|
||||
export default {
|
||||
title: "Billing/Cart Summary",
|
||||
|
|
@ -11,9 +14,10 @@ export default {
|
|||
description: "A summary of the items in the cart, including pricing details.",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [TypographyModule, IconButtonModule],
|
||||
imports: [TypographyModule, IconButtonModule, I18nPipe],
|
||||
// Return the same value for all keys for simplicity
|
||||
providers: [
|
||||
DatePipe,
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
|
|
@ -49,6 +53,10 @@ export default {
|
|||
return "Families membership";
|
||||
case "premiumMembership":
|
||||
return "Premium membership";
|
||||
case "yourNextChargeIsFor":
|
||||
return "Your next charge is for";
|
||||
case "dueOn":
|
||||
return "due on";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
|
|
@ -59,13 +67,17 @@ export default {
|
|||
}),
|
||||
],
|
||||
args: {
|
||||
passwordManager: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
cadence: "month",
|
||||
},
|
||||
estimatedTax: 9.6,
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 9.6,
|
||||
} satisfies Cart,
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
|
|
@ -76,116 +88,258 @@ export default {
|
|||
} as Meta<CartSummaryComponent>;
|
||||
|
||||
type Story = StoryObj<CartSummaryComponent>;
|
||||
export const Default: Story = {};
|
||||
export const Default: Story = {
|
||||
name: "Default (Password Manager Only)",
|
||||
};
|
||||
|
||||
export const WithAdditionalStorage: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
cadence: "month",
|
||||
},
|
||||
estimatedTax: 12.0,
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 12.0,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const PasswordManagerYearlyCadence: Story = {
|
||||
name: "Password Manager (Annual Billing)",
|
||||
args: {
|
||||
passwordManager: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 500.0,
|
||||
cadence: "year",
|
||||
},
|
||||
estimatedTax: 120.0,
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 500.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
estimatedTax: 120.0,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const SecretsManagerSeatsOnly: Story = {
|
||||
name: "With Secrets Manager Seats",
|
||||
args: {
|
||||
...Default.args,
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
cost: 30.0,
|
||||
cadence: "month",
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
estimatedTax: 16.0,
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 16.0,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const SecretsManagerSeatsAndServiceAccounts: Story = {
|
||||
name: "With Secrets Manager + Service Accounts",
|
||||
args: {
|
||||
...Default.args,
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
cost: 30.0,
|
||||
cadence: "month",
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
cadence: "month",
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
estimatedTax: 16.0,
|
||||
cadence: "monthly",
|
||||
estimatedTax: 16.0,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const AllProducts: Story = {
|
||||
name: "All Products (Complete Cart)",
|
||||
args: {
|
||||
...Default.args,
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
cadence: "month",
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
cost: 30.0,
|
||||
cadence: "month",
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
cadence: "month",
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
estimatedTax: 19.2,
|
||||
cadence: "monthly",
|
||||
estimatedTax: 19.2,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const FamiliesPlan: Story = {
|
||||
args: {
|
||||
passwordManager: {
|
||||
quantity: 1,
|
||||
name: "familiesMembership",
|
||||
cost: 40.0,
|
||||
cadence: "year",
|
||||
},
|
||||
estimatedTax: 4.67,
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "familiesMembership",
|
||||
cost: 40.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
estimatedTax: 4.67,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const PremiumPlan: Story = {
|
||||
args: {
|
||||
passwordManager: {
|
||||
quantity: 1,
|
||||
name: "premiumMembership",
|
||||
cost: 10.0,
|
||||
cadence: "year",
|
||||
},
|
||||
estimatedTax: 2.71,
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "premiumMembership",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
estimatedTax: 2.71,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomHeaderTemplate: Story = {
|
||||
args: {
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "premiumMembership",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
estimatedTax: 2.71,
|
||||
} satisfies Cart,
|
||||
},
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
nextChargeDate: new Date("2025-06-04"),
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<ng-template #customHeader let-total="total">
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
class="!tw-m-0"
|
||||
id="cart-summary-header-custom"
|
||||
data-test-id="cart-summary-header-custom"
|
||||
>
|
||||
{{ "yourNextChargeIsFor" | i18n }}
|
||||
<span class="tw-font-bold">{{ total | currency: "USD" : "symbol" }} USD</span>
|
||||
{{ "dueOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ nextChargeDate | date: "MMM. d, y" }}</span>
|
||||
</h2>
|
||||
</ng-template>
|
||||
|
||||
<billing-cart-summary [cart]="cart" [header]="customHeader" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithPercentDiscount: Story = {
|
||||
args: {
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
estimatedTax: 10.4,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAmountDiscount: Story = {
|
||||
args: {
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 50.0,
|
||||
},
|
||||
estimatedTax: 95.0,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,76 +1,153 @@
|
|||
import { CurrencyPipe } from "@angular/common";
|
||||
import { Component, computed, input, signal } from "@angular/core";
|
||||
import { CurrencyPipe, NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
TemplateRef,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
|
||||
import { TypographyModule, IconButtonModule } from "@bitwarden/components";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { IconButtonModule, TypographyModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
export type LineItem = {
|
||||
quantity: number;
|
||||
name: string;
|
||||
cost: number;
|
||||
cadence: "month" | "year";
|
||||
};
|
||||
import { Cart } from "../../types/cart";
|
||||
import { DiscountTypes, getLabel } from "../../types/discount";
|
||||
|
||||
/**
|
||||
* A reusable UI-only component that displays a cart summary with line items.
|
||||
* This component has no external dependencies and performs minimal logic -
|
||||
* it only displays data and allows expanding/collapsing of line items.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "billing-cart-summary",
|
||||
templateUrl: "./cart-summary.component.html",
|
||||
imports: [TypographyModule, IconButtonModule, CurrencyPipe, I18nPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TypographyModule, IconButtonModule, CurrencyPipe, I18nPipe, NgTemplateOutlet],
|
||||
})
|
||||
export class CartSummaryComponent {
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
// Required inputs
|
||||
readonly passwordManager = input.required<LineItem>();
|
||||
readonly additionalStorage = input<LineItem>();
|
||||
readonly secretsManager = input<{ seats: LineItem; additionalServiceAccounts?: LineItem }>();
|
||||
readonly estimatedTax = input.required<number>();
|
||||
readonly cart = input.required<Cart>();
|
||||
|
||||
// Optional inputs
|
||||
readonly header = input<TemplateRef<{ total: number }>>();
|
||||
|
||||
// UI state
|
||||
readonly isExpanded = signal(true);
|
||||
|
||||
/**
|
||||
* Calculates total for password manager line item
|
||||
* Calculates total for Password Manager seats
|
||||
*/
|
||||
readonly passwordManagerTotal = computed<number>(() => {
|
||||
return this.passwordManager().quantity * this.passwordManager().cost;
|
||||
readonly passwordManagerSeatsTotal = computed<number>(() => {
|
||||
const {
|
||||
passwordManager: { seats },
|
||||
} = this.cart();
|
||||
return seats.quantity * seats.cost;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates total for additional storage line item if present
|
||||
* Calculates total for additional storage
|
||||
*/
|
||||
readonly additionalStorageTotal = computed<number>(() => {
|
||||
const storage = this.additionalStorage();
|
||||
return storage ? storage.quantity * storage.cost : 0;
|
||||
const {
|
||||
passwordManager: { additionalStorage },
|
||||
} = this.cart();
|
||||
if (!additionalStorage) {
|
||||
return 0;
|
||||
}
|
||||
return additionalStorage.quantity * additionalStorage.cost;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates total for secrets manager seats if present
|
||||
* Calculates total for Secrets Manager seats
|
||||
*/
|
||||
readonly secretsManagerSeatsTotal = computed<number>(() => {
|
||||
const sm = this.secretsManager();
|
||||
return sm?.seats ? sm.seats.quantity * sm.seats.cost : 0;
|
||||
const { secretsManager } = this.cart();
|
||||
if (!secretsManager) {
|
||||
return 0;
|
||||
}
|
||||
return secretsManager.seats.quantity * secretsManager.seats.cost;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates total for secrets manager service accounts if present
|
||||
*/
|
||||
readonly additionalServiceAccountsTotal = computed<number>(() => {
|
||||
const sm = this.secretsManager();
|
||||
return sm?.additionalServiceAccounts
|
||||
? sm.additionalServiceAccounts.quantity * sm.additionalServiceAccounts.cost
|
||||
: 0;
|
||||
const { secretsManager } = this.cart();
|
||||
if (!secretsManager || !secretsManager.additionalServiceAccounts) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
secretsManager.additionalServiceAccounts.quantity *
|
||||
secretsManager.additionalServiceAccounts.cost
|
||||
);
|
||||
});
|
||||
|
||||
readonly estimatedTax = computed<number>(() => this.cart().estimatedTax);
|
||||
|
||||
readonly term = computed<string>(() => {
|
||||
const { cadence } = this.cart();
|
||||
switch (cadence) {
|
||||
case "annually":
|
||||
return this.i18nService.t("year");
|
||||
case "monthly":
|
||||
return this.i18nService.t("month");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates the total of all line items
|
||||
* Calculates the subtotal before discount and tax
|
||||
*/
|
||||
readonly total = computed<number>(() => this.getTotalCost());
|
||||
readonly subtotal = computed<number>(
|
||||
() =>
|
||||
this.passwordManagerSeatsTotal() +
|
||||
this.additionalStorageTotal() +
|
||||
this.secretsManagerSeatsTotal() +
|
||||
this.additionalServiceAccountsTotal(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculates the discount amount based on the cart discount
|
||||
*/
|
||||
readonly discountAmount = computed<number>(() => {
|
||||
const { discount } = this.cart();
|
||||
if (!discount || !discount.active) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const subtotal = this.subtotal();
|
||||
switch (discount.type) {
|
||||
case DiscountTypes.PercentOff: {
|
||||
const percentage = discount.value < 1 ? discount.value : discount.value / 100;
|
||||
return subtotal * percentage;
|
||||
}
|
||||
case DiscountTypes.AmountOff:
|
||||
return discount.value;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets the discount label for display
|
||||
*/
|
||||
readonly discountLabel = computed<string>(() => {
|
||||
const { discount } = this.cart();
|
||||
if (!discount || !discount.active) {
|
||||
return "";
|
||||
}
|
||||
return getLabel(this.i18nService, discount);
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates the total of all line items including discount and tax
|
||||
*/
|
||||
readonly total = computed<number>(
|
||||
() => this.subtotal() - this.discountAmount() + this.estimatedTax(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable of computed total value
|
||||
|
|
@ -83,18 +160,4 @@ export class CartSummaryComponent {
|
|||
toggleExpanded(): void {
|
||||
this.isExpanded.update((value: boolean) => !value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total cost of all line items in the cart
|
||||
* @returns The total cost as a number
|
||||
*/
|
||||
private getTotalCost(): number {
|
||||
return (
|
||||
this.passwordManagerTotal() +
|
||||
this.additionalStorageTotal() +
|
||||
this.secretsManagerSeatsTotal() +
|
||||
this.additionalServiceAccountsTotal() +
|
||||
this.estimatedTax()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
<span
|
||||
*ngIf="hasDiscount()"
|
||||
bitBadge
|
||||
variant="success"
|
||||
class="tw-w-fit"
|
||||
role="status"
|
||||
[attr.aria-label]="getDiscountText()"
|
||||
>
|
||||
{{ getDiscountText() }}
|
||||
</span>
|
||||
@if (display()) {
|
||||
<span bitBadge variant="success" class="tw-w-fit" role="status" [attr.aria-label]="label()">
|
||||
{{ label() }}
|
||||
</span>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import * as DiscountBadgeStories from "./discount-badge.component.stories";
|
|||
|
||||
# Discount Badge
|
||||
|
||||
A reusable UI component for displaying discount information (percentage or fixed amount) in a badge
|
||||
format.
|
||||
A reusable UI component for displaying a discount (percentage or fixed amount) in a badge format.
|
||||
|
||||
<Canvas of={DiscountBadgeStories.PercentDiscount} />
|
||||
|
||||
|
|
@ -16,41 +15,43 @@ The discount badge component is designed to be used in billing and subscription
|
|||
display discount information.
|
||||
|
||||
```ts
|
||||
import { DiscountBadgeComponent, DiscountInfo } from "@bitwarden/pricing";
|
||||
import { DiscountBadgeComponent, Discount } from "@bitwarden/pricing";
|
||||
```
|
||||
|
||||
```html
|
||||
<billing-discount-badge [discount]="discountInfo"></billing-discount-badge>
|
||||
<billing-discount-badge [discount]="discount"></billing-discount-badge>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
| ---------- | ---------------------- | -------------------------------------------------------------------------------- |
|
||||
| `discount` | `DiscountInfo \| null` | **Optional.** Discount information object. If null or inactive, badge is hidden. |
|
||||
| Input | Type | Description |
|
||||
| ---------- | ------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `discount` | `Discount \| null \| undefined` | **Optional.** Discount object. If null, undefined, or inactive, badge is hidden. |
|
||||
|
||||
### DiscountInfo Interface
|
||||
### Discount Type
|
||||
|
||||
```ts
|
||||
interface DiscountInfo {
|
||||
import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
|
||||
|
||||
type Discount = {
|
||||
/** The type of discount */
|
||||
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
|
||||
/** Whether the discount is currently active */
|
||||
active: boolean;
|
||||
/** Percentage discount (0-100 or 0-1 scale) */
|
||||
percentOff?: number;
|
||||
/** Fixed amount discount in the base currency */
|
||||
amountOff?: number;
|
||||
}
|
||||
/** The discount value (percentage or amount depending on type) */
|
||||
value: number;
|
||||
};
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- The badge is only displayed when `discount` is provided, `active` is `true`, and either
|
||||
`percentOff` or `amountOff` is greater than 0.
|
||||
- If both `percentOff` and `amountOff` are provided, `percentOff` takes precedence.
|
||||
- Percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%).
|
||||
- Amount values are formatted as currency (USD) with 2 decimal places.
|
||||
- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is
|
||||
greater than 0.
|
||||
- For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1
|
||||
(e.g., `0.2` for 20%).
|
||||
- For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places.
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DiscountBadgeComponent } from "./discount-badge.component";
|
||||
import { DiscountBadgeComponent, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
describe("DiscountBadgeComponent", () => {
|
||||
let component: DiscountBadgeComponent;
|
||||
|
|
@ -29,80 +28,104 @@ describe("DiscountBadgeComponent", () => {
|
|||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("hasDiscount", () => {
|
||||
describe("display", () => {
|
||||
it("should return false when discount is null", () => {
|
||||
fixture.componentRef.setInput("discount", null);
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when discount is inactive", () => {
|
||||
fixture.componentRef.setInput("discount", { active: false, percentOff: 20 });
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with percentOff", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 20 });
|
||||
it("should return true when discount is active with percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(true);
|
||||
expect(component.display()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with amountOff", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 });
|
||||
it("should return true when discount is active with amount-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(true);
|
||||
expect(component.display()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when percentOff is 0", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 0 });
|
||||
it("should return false when value is 0 (percent-off)", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when amountOff is 0", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, amountOff: 0 });
|
||||
it("should return false when value is 0 (amount-off)", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.hasDiscount()).toBe(false);
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDiscountText", () => {
|
||||
it("should return null when discount is null", () => {
|
||||
describe("label", () => {
|
||||
it("should return undefined when discount is null", () => {
|
||||
fixture.componentRef.setInput("discount", null);
|
||||
fixture.detectChanges();
|
||||
expect(component.getDiscountText()).toBeNull();
|
||||
expect(component.label()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return percentage text when percentOff is provided", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 20 });
|
||||
it("should return percentage text when type is percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
const text = component.label();
|
||||
expect(text).toContain("20%");
|
||||
expect(text).toContain("discount");
|
||||
});
|
||||
|
||||
it("should convert decimal percentOff to percentage", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 0.15 });
|
||||
it("should convert decimal value to percentage for percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0.15,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
const text = component.label();
|
||||
expect(text).toContain("15%");
|
||||
});
|
||||
|
||||
it("should return amount text when amountOff is provided", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 });
|
||||
it("should return amount text when type is amount-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
const text = component.label();
|
||||
expect(text).toContain("$10.99");
|
||||
expect(text).toContain("discount");
|
||||
});
|
||||
|
||||
it("should prefer percentOff over amountOff", () => {
|
||||
fixture.componentRef.setInput("discount", { active: true, percentOff: 25, amountOff: 10.99 });
|
||||
fixture.detectChanges();
|
||||
const text = component.getDiscountText();
|
||||
expect(text).toContain("25%");
|
||||
expect(text).not.toContain("$10.99");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
|||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BadgeModule } from "@bitwarden/components";
|
||||
|
||||
import { DiscountBadgeComponent, DiscountInfo } from "./discount-badge.component";
|
||||
import { Discount, DiscountBadgeComponent, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
export default {
|
||||
title: "Billing/Discount Badge",
|
||||
|
|
@ -40,9 +39,10 @@ export const PercentDiscount: Story = {
|
|||
}),
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
percentOff: 20,
|
||||
} as DiscountInfo,
|
||||
value: 20,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -53,9 +53,10 @@ export const PercentDiscountDecimal: Story = {
|
|||
}),
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
percentOff: 0.15, // 15% in decimal format
|
||||
} as DiscountInfo,
|
||||
value: 0.15, // 15% in decimal format
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -66,9 +67,10 @@ export const AmountDiscount: Story = {
|
|||
}),
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
amountOff: 10.99,
|
||||
} as DiscountInfo,
|
||||
value: 10.99,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -79,9 +81,10 @@ export const LargeAmountDiscount: Story = {
|
|||
}),
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
amountOff: 99.99,
|
||||
} as DiscountInfo,
|
||||
value: 99.99,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -92,9 +95,10 @@ export const InactiveDiscount: Story = {
|
|||
}),
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
percentOff: 20,
|
||||
} as DiscountInfo,
|
||||
value: 20,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -107,17 +111,3 @@ export const NoDiscount: Story = {
|
|||
discount: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const PercentAndAmountPreferPercent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
active: true,
|
||||
percentOff: 25,
|
||||
amountOff: 10.99,
|
||||
} as DiscountInfo,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,70 +1,35 @@
|
|||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BadgeModule } from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* Interface for discount information that can be displayed in the discount badge.
|
||||
* This is abstracted from the response class to avoid tight coupling.
|
||||
*/
|
||||
export interface DiscountInfo {
|
||||
/** Whether the discount is currently active */
|
||||
active: boolean;
|
||||
/** Percentage discount (0-100 or 0-1 scale) */
|
||||
percentOff?: number;
|
||||
/** Fixed amount discount in the base currency */
|
||||
amountOff?: number;
|
||||
}
|
||||
import { Discount, getLabel } from "../../types/discount";
|
||||
import { Maybe } from "../../types/maybe";
|
||||
|
||||
@Component({
|
||||
selector: "billing-discount-badge",
|
||||
templateUrl: "./discount-badge.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, BadgeModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, BadgeModule],
|
||||
})
|
||||
export class DiscountBadgeComponent {
|
||||
readonly discount = input<DiscountInfo | null>(null);
|
||||
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
getDiscountText(): string | null {
|
||||
const discount = this.discount();
|
||||
if (!discount) {
|
||||
return null;
|
||||
}
|
||||
readonly discount = input<Maybe<Discount>>(null);
|
||||
|
||||
if (discount.percentOff != null && discount.percentOff > 0) {
|
||||
const percentValue =
|
||||
discount.percentOff < 1 ? discount.percentOff * 100 : discount.percentOff;
|
||||
return `${Math.round(percentValue)}% ${this.i18nService.t("discount")}`;
|
||||
}
|
||||
|
||||
if (discount.amountOff != null && discount.amountOff > 0) {
|
||||
const formattedAmount = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(discount.amountOff);
|
||||
return `${formattedAmount} ${this.i18nService.t("discount")}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
hasDiscount(): boolean {
|
||||
readonly display = computed<boolean>(() => {
|
||||
const discount = this.discount();
|
||||
if (!discount) {
|
||||
return false;
|
||||
}
|
||||
if (!discount.active) {
|
||||
return false;
|
||||
return discount.active && discount.value > 0;
|
||||
});
|
||||
|
||||
readonly label = computed<Maybe<string>>(() => {
|
||||
const discount = this.discount();
|
||||
if (discount) {
|
||||
return getLabel(this.i18nService, discount);
|
||||
}
|
||||
return (
|
||||
(discount.percentOff != null && discount.percentOff > 0) ||
|
||||
(discount.amountOff != null && discount.amountOff > 0)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
[buttonType]="buttonConfig.type"
|
||||
[block]="true"
|
||||
[disabled]="buttonConfig.disabled"
|
||||
(click)="onButtonClick()"
|
||||
(click)="buttonClick.emit()"
|
||||
type="button"
|
||||
>
|
||||
@if (buttonConfig.icon?.position === "before") {
|
||||
|
|
|
|||
|
|
@ -69,10 +69,6 @@ The title slot allows complete control over the heading element and styling:
|
|||
<h2 slot="title" class="tw-m-0 tw-text-primary-600" bitTypography="h2">Featured Plan</h2>
|
||||
```
|
||||
|
||||
| Output | Type | Description |
|
||||
| ------------- | ------ | --------------------------------------- |
|
||||
| `buttonClick` | `void` | Emitted when the plan button is clicked |
|
||||
|
||||
## Design
|
||||
|
||||
The component follows the Bitwarden design system with:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { ButtonType, IconModule, TypographyModule } from "@bitwarden/components";
|
||||
import { BadgeVariant, ButtonType, IconModule, TypographyModule } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { PricingCardComponent } from "./pricing-card.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: `
|
||||
<billing-pricing-card
|
||||
|
|
@ -18,22 +15,30 @@ import { PricingCardComponent } from "./pricing-card.component";
|
|||
[activeBadge]="activeBadge"
|
||||
(buttonClick)="onButtonClick()"
|
||||
>
|
||||
<ng-container [ngSwitch]="titleLevel">
|
||||
<h1 *ngSwitchCase="'h1'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h1>
|
||||
|
||||
<h2 *ngSwitchCase="'h2'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h2>
|
||||
|
||||
<h3 *ngSwitchCase="'h3'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h3>
|
||||
|
||||
<h4 *ngSwitchCase="'h4'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h4>
|
||||
|
||||
<h5 *ngSwitchCase="'h5'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h5>
|
||||
|
||||
<h6 *ngSwitchCase="'h6'" slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h6>
|
||||
</ng-container>
|
||||
@switch (titleLevel) {
|
||||
@case ("h1") {
|
||||
<h1 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h1>
|
||||
}
|
||||
@case ("h2") {
|
||||
<h2 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h2>
|
||||
}
|
||||
@case ("h3") {
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h3>
|
||||
}
|
||||
@case ("h4") {
|
||||
<h4 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h4>
|
||||
}
|
||||
@case ("h5") {
|
||||
<h5 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h5>
|
||||
}
|
||||
@case ("h6") {
|
||||
<h6 slot="title" class="tw-m-0" bitTypography="h3">{{ titleText }}</h6>
|
||||
}
|
||||
}
|
||||
</billing-pricing-card>
|
||||
`,
|
||||
imports: [PricingCardComponent, CommonModule, TypographyModule],
|
||||
imports: [PricingCardComponent, TypographyModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestHostComponent {
|
||||
titleText = "Test Plan";
|
||||
|
|
@ -48,7 +53,7 @@ class TestHostComponent {
|
|||
};
|
||||
features = ["Feature 1", "Feature 2", "Feature 3"];
|
||||
titleLevel: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" = "h3";
|
||||
activeBadge: { text: string; variant?: string } | undefined = undefined;
|
||||
activeBadge: { text: string; variant?: BadgeVariant } | undefined = undefined;
|
||||
|
||||
onButtonClick() {
|
||||
// Test method
|
||||
|
|
@ -186,11 +191,10 @@ describe("PricingCardComponent", () => {
|
|||
it("should have proper layout structure with flexbox", () => {
|
||||
hostFixture.detectChanges();
|
||||
const compiled = hostFixture.nativeElement;
|
||||
const cardContainer = compiled.querySelector("div");
|
||||
const cardContainer = compiled.querySelector("bit-card");
|
||||
|
||||
expect(cardContainer.classList).toContain("tw-flex");
|
||||
expect(cardContainer.classList).toContain("tw-flex-col");
|
||||
expect(cardContainer.classList).toContain("tw-size-full");
|
||||
expect(cardContainer.classList).not.toContain("tw-block"); // Should not have conflicting display property
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CurrencyPipe } from "@angular/common";
|
||||
import { Component, EventEmitter, input, Output } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
|
||||
|
||||
import {
|
||||
BadgeModule,
|
||||
|
|
@ -16,11 +16,10 @@ import {
|
|||
* This component has no external dependencies and performs no logic - it only displays data
|
||||
* and emits events when the button is clicked.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "billing-pricing-card",
|
||||
templateUrl: "./pricing-card.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, CardComponent],
|
||||
})
|
||||
export class PricingCardComponent {
|
||||
|
|
@ -39,14 +38,5 @@ export class PricingCardComponent {
|
|||
readonly features = input<string[]>();
|
||||
readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>();
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() buttonClick = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Handles button click events and emits the buttonClick event
|
||||
*/
|
||||
onButtonClick(): void {
|
||||
this.buttonClick.emit();
|
||||
}
|
||||
readonly buttonClick = output<void>();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,8 @@
|
|||
export * from "./components/pricing-card/pricing-card.component";
|
||||
export * from "./components/cart-summary/cart-summary.component";
|
||||
export * from "./components/discount-badge/discount-badge.component";
|
||||
|
||||
// Types
|
||||
export * from "./types/cart";
|
||||
export * from "./types/discount";
|
||||
export * from "./types/maybe";
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
import * as lib from "./index";
|
||||
|
||||
describe("pricing", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
22
libs/pricing/src/types/cart.ts
Normal file
22
libs/pricing/src/types/cart.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Discount } from "@bitwarden/pricing";
|
||||
|
||||
export type CartItem = {
|
||||
name: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
};
|
||||
|
||||
export type Cart = {
|
||||
passwordManager: {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
};
|
||||
cadence: "annually" | "monthly";
|
||||
discount?: Discount;
|
||||
estimatedTax: number;
|
||||
};
|
||||
32
libs/pricing/src/types/discount.ts
Normal file
32
libs/pricing/src/types/discount.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
export const DiscountTypes = {
|
||||
AmountOff: "amount-off",
|
||||
PercentOff: "percent-off",
|
||||
} as const;
|
||||
|
||||
export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes];
|
||||
|
||||
export type Discount = {
|
||||
type: DiscountType;
|
||||
active: boolean;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export const getLabel = (i18nService: I18nService, discount: Discount): string => {
|
||||
switch (discount.type) {
|
||||
case DiscountTypes.AmountOff: {
|
||||
const formattedAmount = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(discount.value);
|
||||
return `${formattedAmount} ${i18nService.t("discount")}`;
|
||||
}
|
||||
case DiscountTypes.PercentOff: {
|
||||
const percentValue = discount.value < 1 ? discount.value * 100 : discount.value;
|
||||
return `${Math.round(percentValue)}% ${i18nService.t("discount")}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
1
libs/pricing/src/types/maybe.ts
Normal file
1
libs/pricing/src/types/maybe.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Maybe<T> = T | null | undefined;
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
const { compilerOptions } = require("../../tsconfig.base");
|
||||
|
||||
const sharedConfig = require("../../libs/shared/jest.config.angular");
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
displayName: "subscription",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
...sharedConfig,
|
||||
displayName: "libs/subscription tests",
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
coverageDirectory: "../../coverage/libs/subscription",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/../../",
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<bit-card class="tw-size-full tw-flex tw-flex-col">
|
||||
<!-- Title -->
|
||||
<h3 bitTypography="h3" class="tw-m-0 tw-mb-4">{{ "additionalOptions" | i18n }}</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p bitTypography="body1" class="tw-m-0 tw-mb-4 tw-text-main">
|
||||
{{ "additionalOptionsDesc" | i18n }}
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="tw-flex tw-gap-4">
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('download-license')"
|
||||
>
|
||||
{{ "downloadLicense" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="danger"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('cancel-subscription')"
|
||||
>
|
||||
{{ "cancelSubscription" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-card>
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
|
||||
import * as AdditionalOptionsCardStories from "./additional-options-card.component.stories";
|
||||
|
||||
<Meta of={AdditionalOptionsCardStories} />
|
||||
|
||||
# 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.
|
||||
|
||||
<Canvas of={AdditionalOptionsCardStories.Default} />
|
||||
|
||||
## 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
|
||||
<billing-additional-options-card (callToActionClicked)="handleAction($event)">
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
<Canvas of={AdditionalOptionsCardStories.Default} />
|
||||
|
||||
```html
|
||||
<billing-additional-options-card (callToActionClicked)="handleAction($event)">
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
**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):
|
||||
|
||||
<Canvas of={AdditionalOptionsCardStories.ActionsDisabled} />
|
||||
|
||||
```html
|
||||
<billing-additional-options-card
|
||||
[callsToActionDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
|
||||
downloading the license or processing subscription cancellation.
|
||||
|
||||
## 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 `<h3>` and `<p>` 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
|
||||
|
|
@ -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<AdditionalOptionsCardComponent>;
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
i18nService = {
|
||||
t: jest.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> = {
|
||||
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<AdditionalOptionsCardComponent>;
|
||||
|
||||
type Story = StoryObj<AdditionalOptionsCardComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const ActionsDisabled: Story = {
|
||||
name: "Actions Disabled",
|
||||
args: {
|
||||
callsToActionDisabled: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -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<boolean>(false);
|
||||
readonly callToActionClicked = output<AdditionalOptionsCardAction>();
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<bit-card class="tw-size-full tw-flex tw-flex-col">
|
||||
<!-- Title and Description -->
|
||||
<div class="tw-flex tw-flex-col tw-gap-3 tw-mb-4">
|
||||
<h3 bitTypography="h3" class="tw-m-0">{{ title() }}</h3>
|
||||
<p bitTypography="body1" class="tw-m-0 tw-text-main">{{ description() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="tw-mb-4">
|
||||
<bit-progress
|
||||
[barWidth]="percentageUsed()"
|
||||
[bgColor]="progressBarColor()"
|
||||
[showText]="false"
|
||||
size="small"
|
||||
></bit-progress>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="tw-flex tw-gap-4">
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('add-storage')"
|
||||
>
|
||||
{{ "addStorage" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled() || !canRemoveStorage()"
|
||||
(click)="callToActionClicked.emit('remove-storage')"
|
||||
>
|
||||
{{ "removeStorage" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-card>
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
|
||||
import * as StorageCardStories from "./storage-card.component.stories";
|
||||
|
||||
<Meta of={StorageCardStories} />
|
||||
|
||||
# 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).
|
||||
|
||||
<Canvas of={StorageCardStories.Empty} />
|
||||
|
||||
## 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
|
||||
<billing-storage-card [storage]="storage" (callToActionClicked)="handleStorageAction($event)">
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
<Canvas of={StorageCardStories.Empty} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 0,
|
||||
readableUsed: '0 GB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Used
|
||||
|
||||
Storage with partial usage (50%):
|
||||
|
||||
<Canvas of={StorageCardStories.Used} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Full
|
||||
|
||||
Storage at full capacity with disabled remove button:
|
||||
|
||||
<Canvas of={StorageCardStories.Full} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 5,
|
||||
readableUsed: '5 GB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns
|
||||
red.
|
||||
|
||||
### Low Usage (10%)
|
||||
|
||||
Minimal storage usage:
|
||||
|
||||
<Canvas of={StorageCardStories.LowUsage} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 0.5,
|
||||
readableUsed: '500 MB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Medium Usage (75%)
|
||||
|
||||
Substantial storage usage:
|
||||
|
||||
<Canvas of={StorageCardStories.MediumUsage} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 3.75,
|
||||
readableUsed: '3.75 GB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Nearly Full (95%)
|
||||
|
||||
Storage approaching capacity:
|
||||
|
||||
<Canvas of={StorageCardStories.NearlyFull} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 4.75,
|
||||
readableUsed: '4.75 GB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Large Storage Pool (1TB)
|
||||
|
||||
Enterprise-level storage allocation:
|
||||
|
||||
<Canvas of={StorageCardStories.LargeStorage} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 1000,
|
||||
used: 734,
|
||||
readableUsed: '734 GB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Small Storage Pool (1GB)
|
||||
|
||||
Minimal storage allocation:
|
||||
|
||||
<Canvas of={StorageCardStories.SmallStorage} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 1,
|
||||
used: 0.8,
|
||||
readableUsed: '800 MB'
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Actions Disabled
|
||||
|
||||
Storage card with action buttons disabled (useful during async operations):
|
||||
|
||||
<Canvas of={StorageCardStories.ActionsDisabled} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB'
|
||||
}"
|
||||
[callsToActionDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
**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 `<h3>` and `<p>` 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)
|
||||
|
|
@ -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<StorageCardComponent>;
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
|
||||
const baseStorage: Storage = {
|
||||
available: 5,
|
||||
used: 0,
|
||||
readableUsed: "0 GB",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
i18nService = {
|
||||
t: jest.fn((key: string, ...args: any[]) => {
|
||||
const translations: Record<string, string> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> = {
|
||||
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<StorageCardComponent>;
|
||||
|
||||
type Story = StoryObj<StorageCardComponent>;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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<Storage>();
|
||||
|
||||
readonly callsToActionDisabled = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<StorageCardAction>();
|
||||
|
||||
readonly isEmpty = computed<boolean>(() => this.storage().used === 0);
|
||||
|
||||
readonly isFull = computed<boolean>(() => {
|
||||
const storage = this.storage();
|
||||
return storage.used >= storage.available;
|
||||
});
|
||||
|
||||
readonly percentageUsed = computed<number>(() => {
|
||||
const storage = this.storage();
|
||||
if (storage.available === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min((storage.used / storage.available) * 100, 100);
|
||||
});
|
||||
|
||||
readonly title = computed<string>(() => {
|
||||
return this.isFull() ? this.i18nService.t("storageFull") : this.i18nService.t("storage");
|
||||
});
|
||||
|
||||
readonly description = computed<string>(() => {
|
||||
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<boolean>(() => !this.isFull());
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<bit-card class="tw-size-full tw-flex tw-flex-col">
|
||||
<!-- Title Section with Badge -->
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-mb-4">
|
||||
<h2 bitTypography="h3" class="tw-m-0">{{ title() }}</h2>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<span bitBadge [variant]="badge().variant" class="tw-ml-3">
|
||||
{{ badge().text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<div class="tw-mb-4">
|
||||
<billing-cart-summary [cart]="subscription().cart" [header]="cartSummaryHeader" />
|
||||
</div>
|
||||
|
||||
<!-- Status-Based Callout -->
|
||||
@if (callout(); as callout) {
|
||||
<bit-callout [type]="callout.type" [icon]="callout.icon" [title]="callout.title">
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
<p class="tw-m-0">{{ callout.description }}</p>
|
||||
@if (callout.callsToAction) {
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
@for (cta of callout.callsToAction; track cta.action) {
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="cta.buttonType"
|
||||
(click)="callToActionClicked.emit(cta.action)"
|
||||
type="button"
|
||||
>
|
||||
{{ cta.text }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</bit-callout>
|
||||
}
|
||||
</bit-card>
|
||||
|
||||
<ng-template #cartSummaryHeader let-total="total">
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
class="!tw-m-0"
|
||||
id="cart-summary-header-custom"
|
||||
data-test-id="cart-summary-header-custom"
|
||||
>
|
||||
@let status = subscription().status;
|
||||
@switch (status) {
|
||||
@case ("incomplete") {
|
||||
{{ "yourSubscriptionWillBeSuspendedOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
|
||||
}
|
||||
@case ("incomplete_expired") {
|
||||
{{ "yourSubscriptionWasSuspendedOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
|
||||
}
|
||||
@case ("trialing") {
|
||||
@if (cancelAt(); as cancelAt) {
|
||||
{{ "yourSubscriptionWillBeCanceledOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ cancelAt | date: dateFormat }}</span>
|
||||
} @else {
|
||||
{{ "yourNextChargeIsFor" | i18n }}
|
||||
<span class="tw-font-bold">{{ total | currency: "USD" : "symbol" }} USD</span>
|
||||
{{ "dueOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ nextCharge() | date: dateFormat }}</span>
|
||||
}
|
||||
}
|
||||
@case ("active") {
|
||||
@if (cancelAt(); as cancelAt) {
|
||||
{{ "yourSubscriptionWillBeCanceledOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ cancelAt | date: dateFormat }}</span>
|
||||
} @else {
|
||||
{{ "yourNextChargeIsFor" | i18n }}
|
||||
<span class="tw-font-bold">{{ total | currency: "USD" : "symbol" }} USD</span>
|
||||
{{ "dueOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ nextCharge() | date: dateFormat }}</span>
|
||||
}
|
||||
}
|
||||
@case ("past_due") {
|
||||
{{ "yourSubscriptionWillBeSuspendedOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
|
||||
}
|
||||
@case ("canceled") {
|
||||
{{ "yourSubscriptionWasCanceledOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ canceled() | date: dateFormat }}</span>
|
||||
}
|
||||
@case ("unpaid") {
|
||||
{{ "yourSubscriptionWasSuspendedOn" | i18n }}
|
||||
<span class="tw-font-bold">{{ suspension() | date: dateFormat }}</span>
|
||||
}
|
||||
}
|
||||
</h2>
|
||||
</ng-template>
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks";
|
||||
import * as SubscriptionCardStories from "./subscription-card.component.stories";
|
||||
|
||||
<Meta of={SubscriptionCardStories} />
|
||||
|
||||
# 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.).
|
||||
|
||||
<Canvas of={SubscriptionCardStories.Active} />
|
||||
|
||||
## 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
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="subscription"
|
||||
[showUpgradeButton]="false"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.Active} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'active',
|
||||
nextCharge: new Date('2025-02-15'),
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: 'members',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
estimatedTax: 2.71
|
||||
},
|
||||
storage: {
|
||||
available: 1000,
|
||||
used: 234,
|
||||
readableUsed: '234 MB'
|
||||
}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
### Active With Upgrade
|
||||
|
||||
Active subscription with upgrade promotion callout:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.ActiveWithUpgrade} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="activeSubscription"
|
||||
[showUpgradeButton]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
### Trial
|
||||
|
||||
Subscription in trial period showing next charge date:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.Trial} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'trialing',
|
||||
nextCharge: new Date('2025-02-01'),
|
||||
cart: {...},
|
||||
storage: {...}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
### Trial With Upgrade
|
||||
|
||||
Trial subscription with upgrade option displayed:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.TrialWithUpgrade} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="trialSubscription"
|
||||
[showUpgradeButton]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
### Incomplete Payment
|
||||
|
||||
Payment failed, showing warning with update payment action:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.Incomplete} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'incomplete',
|
||||
suspension: new Date('2025-02-15'),
|
||||
gracePeriod: 7,
|
||||
cart: {...},
|
||||
storage: {...}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Actions available:** Update Payment, Contact Support
|
||||
|
||||
### Incomplete Expired
|
||||
|
||||
Payment issue expired, subscription has been suspended:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.IncompleteExpired} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'incomplete_expired',
|
||||
suspension: new Date('2025-01-01'),
|
||||
gracePeriod: 0,
|
||||
cart: {...},
|
||||
storage: {...}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Actions available:** Contact Support
|
||||
|
||||
### Past Due
|
||||
|
||||
Payment past due with active grace period:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.PastDue} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'past_due',
|
||||
suspension: new Date('2025-02-05'),
|
||||
gracePeriod: 14,
|
||||
cart: {...},
|
||||
storage: {...}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Actions available:** Manage Invoices
|
||||
|
||||
### Pending Cancellation
|
||||
|
||||
Active subscription scheduled to be canceled:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.PendingCancellation} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'active',
|
||||
nextCharge: new Date('2025-02-15'),
|
||||
cancelAt: new Date('2025-03-01'),
|
||||
cart: {...},
|
||||
storage: {...}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Actions available:** Reinstate Subscription
|
||||
|
||||
### Unpaid
|
||||
|
||||
Subscription suspended due to unpaid invoices:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.Unpaid} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'unpaid',
|
||||
suspension: new Date('2025-01-20'),
|
||||
gracePeriod: 0,
|
||||
cart: {...},
|
||||
storage: {...}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Actions available:** Manage Invoices
|
||||
|
||||
### Canceled
|
||||
|
||||
Subscription that has been canceled:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.Canceled} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[title]="'Premium Subscription'"
|
||||
[subscription]="{
|
||||
status: 'canceled',
|
||||
canceled: new Date('2025-01-15'),
|
||||
cart: {...},
|
||||
storage: {...}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
**Note:** Canceled subscriptions display no callout or actions.
|
||||
|
||||
### Enterprise
|
||||
|
||||
Enterprise subscription with multiple products and discount:
|
||||
|
||||
<Canvas of={SubscriptionCardStories.Enterprise} />
|
||||
|
||||
```html
|
||||
<billing-subscription-card
|
||||
[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: 'percent-off', active: true, value: 0.25 },
|
||||
cadence: 'monthly',
|
||||
estimatedTax: 6.4
|
||||
},
|
||||
storage: {
|
||||
available: 7,
|
||||
readableUsed: '7 GB',
|
||||
used: 0
|
||||
}
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-subscription-card>
|
||||
```
|
||||
|
||||
## 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 `<h2>`, `<h3>`, `<h4>` 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
|
||||
|
|
@ -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<SubscriptionCardComponent>;
|
||||
|
||||
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<string, string> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> = {
|
||||
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<SubscriptionCardComponent>;
|
||||
|
||||
type Story = StoryObj<SubscriptionCardComponent>;
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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<string>();
|
||||
|
||||
readonly subscription = input.required<BitwardenSubscription>();
|
||||
|
||||
readonly showUpgradeButton = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<PlanCardAction>();
|
||||
|
||||
readonly badge = computed<Badge>(() => {
|
||||
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<Callout>(() => {
|
||||
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<Maybe<Date>>(() => {
|
||||
const subscription = this.subscription();
|
||||
if (
|
||||
subscription.status === SubscriptionStatuses.Trialing ||
|
||||
subscription.status === SubscriptionStatuses.Active
|
||||
) {
|
||||
return subscription.cancelAt;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canceled = computed<Maybe<Date>>(() => {
|
||||
const subscription = this.subscription();
|
||||
if (subscription.status === SubscriptionStatuses.Canceled) {
|
||||
return subscription.canceled;
|
||||
}
|
||||
});
|
||||
|
||||
readonly nextCharge = computed<Maybe<Date>>(() => {
|
||||
const subscription = this.subscription();
|
||||
if (
|
||||
subscription.status === SubscriptionStatuses.Trialing ||
|
||||
subscription.status === SubscriptionStatuses.Active
|
||||
) {
|
||||
return subscription.nextCharge;
|
||||
}
|
||||
});
|
||||
|
||||
readonly suspension = computed<Maybe<Date>>(() => {
|
||||
const subscription = this.subscription();
|
||||
if (
|
||||
subscription.status === SubscriptionStatuses.Incomplete ||
|
||||
subscription.status === SubscriptionStatuses.IncompleteExpired ||
|
||||
subscription.status === SubscriptionStatuses.PastDue ||
|
||||
subscription.status === SubscriptionStatuses.Unpaid
|
||||
) {
|
||||
return subscription.suspension;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
40
libs/subscription/src/types/bitwarden-subscription.ts
Normal file
40
libs/subscription/src/types/bitwarden-subscription.ts
Normal file
|
|
@ -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);
|
||||
5
libs/subscription/src/types/storage.ts
Normal file
5
libs/subscription/src/types/storage.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type Storage = {
|
||||
available: number;
|
||||
readableUsed: string;
|
||||
used: number;
|
||||
};
|
||||
28
libs/subscription/test.setup.ts
Normal file
28
libs/subscription/test.setup.ts
Normal file
|
|
@ -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: "<!DOCTYPE html>",
|
||||
});
|
||||
Object.defineProperty(document.body.style, "transform", {
|
||||
value: () => {
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
value: webcrypto,
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue