From 34af55d34612b226a6627cf08693c5c494cc455e Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Mon, 8 Sep 2025 17:46:02 +0530 Subject: [PATCH] Add Changes for the plan-card --- .../change-plan-dialog.component.html | 289 ++------------ .../change-plan-dialog.component.ts | 363 ++++++++---------- .../app/billing/services/plan-card.service.ts | 196 ++++++++++ .../billing/shared/billing-shared.module.ts | 1 + .../shared/plan-card/plan-card.component.html | 227 ++++++++++- .../shared/plan-card/plan-card.component.ts | 72 +++- 6 files changed, 680 insertions(+), 468 deletions(-) diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 3f0c31d56cf..6f38d83b51b 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -50,279 +50,42 @@ - +
- -
-
- {{ "recommended" | i18n }} -
-
-

- {{ - selectableProduct.nameLocalizationKey | i18n - }} - - {{ "current" | i18n }} -

- - - - {{ - (selectableProduct.isAnnual - ? selectableProduct.PasswordManager.basePrice / 12 - : selectableProduct.PasswordManager.basePrice - ) | currency: "$" - }} - - - /{{ - selectableProduct.productTier === productTypes.Families - ? "month" - : ("monthPerMember" | i18n) - }} - - - {{ ("additionalUsers" | i18n).toLowerCase() }} - {{ - (selectableProduct.isAnnual - ? selectableProduct.PasswordManager.seatPrice / 12 - : selectableProduct.PasswordManager.seatPrice - ) | currency: "$" - }} - /{{ selectedPlanInterval | i18n }} - - - - - - {{ - "costPerMember" - | i18n - : (((this.sub.useSecretsManager - ? selectableProduct.SecretsManager.seatPrice - : 0) + - selectableProduct.PasswordManager.seatPrice) / - (selectableProduct.isAnnual ? 12 : 1) - | currency: "$") - }} - - /{{ "monthPerMember" | i18n }} - - {{ "freeForever" | i18n }} - -
-
- - -

- {{ "bitwardenPasswordManager" | i18n }} -

-

{{ "enterprisePlanUpgradeMessage" | i18n }}

- -
    -
  • - - {{ "includeEnterprisePolicies" | i18n }} -
  • -
  • - - {{ "passwordLessSso" | i18n }} -
  • -
  • - - {{ "accountRecovery" | i18n }} -
  • -
  • - - {{ "customRoles" | i18n }} -
  • -
- -

- {{ "bitwardenSecretsManager" | i18n }} -

-
    -
  • - - {{ "unlimitedSecretsStorage" | i18n }} -
  • -
  • - - {{ "unlimitedUsers" | i18n }} -
  • -
  • - - {{ "unlimitedProjects" | i18n }} -
  • -
  • - - {{ "UpTo50MachineAccounts" | i18n }} -
  • -
-
- - -
    -
  • {{ "includeAllTeamsStarterFeatures" | i18n }}
  • -
  • {{ "chooseMonthlyOrAnnualBilling" | i18n }}
  • -
  • {{ "abilityToAddMoreThanNMembers" | i18n: 10 }}
  • -
-
- -

- {{ "bitwardenPasswordManager" | i18n }} -

-

- {{ "teamsPlanUpgradeMessage" | i18n }} -

-

- {{ "familyPlanUpgradeMessage" | i18n }} -

-
    -
  • - - {{ "premiumAccounts" | i18n }} -
  • -
  • - - {{ "unlimitedSharing" | i18n }} -
  • -
  • - - {{ "unlimitedCollections" | i18n }} -
  • -
-
    -
  • - - {{ "secureDataSharing" | i18n }} -
  • -
  • - - {{ "eventLogMonitoring" | i18n }} -
  • -
  • - - {{ "directoryIntegration" | i18n }} -
  • -
-

- {{ "bitwardenSecretsManager" | i18n }} -

-
    -
  • - - {{ "unlimitedSecretsStorage" | i18n }} -
  • -
  • - - {{ "unlimitedProjects" | i18n }} -
  • -
  • - - {{ "UpTo20MachineAccounts" | i18n }} -
  • -
-
-
-
+ +

(); + private planSelectionDebounceTimer: any; protected taxInformation: TaxInformation; @@ -221,6 +219,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private organizationBillingService: OrganizationBillingService, private billingNotificationService: BillingNotificationService, private pricingSummaryService: PricingSummaryService, + private planCardService: PlanCardService, ) {} async ngOnInit(): Promise { @@ -232,7 +231,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.dialogHeaderName = this.resolveHeaderName(this.sub); this.organizationId = this.dialogParams.organizationId; this.currentPlan = this.sub?.plan; - this.selectedPlan = this.sub?.plan; + // Don't set selectedPlan here - let the upgrade card logic choose Enterprise by default const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); @@ -308,7 +307,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ? 0 : (this.sub?.customerDiscount?.percentOff ?? 0); - this.setInitialPlanSelection(); + // Initialize upgrade plan cards first + await this.initializeUpgradePlanCards(); + + // Initial plan selection is now handled in initializeUpgradePlanCards() + // No need for additional logic here + this.loading = false; const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); @@ -337,21 +341,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } - setInitialPlanSelection() { - this.focusedIndex = this.selectableProducts.length - 1; - if (!this.isSubscriptionCanceled) { - const enterprisePlan = this.getPlanByType(ProductTierType.Enterprise); - const planToSelect = - enterprisePlan || this.selectableProducts[this.selectableProducts.length - 1]; - if (planToSelect) { - this.selectPlan(planToSelect); - } - } - } - - getPlanByType(productTier: ProductTierType) { - return this.selectableProducts.find((product) => product.productTier === productTier); - } + // These methods are no longer needed since we use upgradePlanCards from the service + // The Enterprise plan selection is now handled in initializeUpgradePlanCards() isPaymentSourceEmpty() { return this.paymentSource === null || this.paymentSource === undefined; @@ -366,13 +357,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } planTypeChanged() { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + // Find Enterprise plan from upgradePlanCards instead + const enterprisePlan = this.upgradePlanCards.find( + (card) => card.planResponse?.productTier === ProductTierType.Enterprise, + ); + if (enterprisePlan?.planResponse) { + this.selectPlan(enterprisePlan.planResponse); + } } updateInterval(event: number) { this.selectedInterval = event; this.planTypeChanged(); - void this.updatePricingSummaryData(); + + // Debounce API calls when changing intervals too + this.debouncePlanSelection(); + void this.updateUpgradePlanCardsOnIntervalChange(); } protected getPlanIntervals() { @@ -392,83 +392,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return index; } - protected getPlanCardContainerClasses(plan: PlanResponse, index: number) { - let cardState: PlanCardState; - - if (plan == this.currentPlan) { - cardState = PlanCardState.Disabled; - this.isCardStateDisabled = true; - this.focusedIndex = index; - } else if (plan == this.selectedPlan) { - cardState = PlanCardState.Selected; - this.isCardStateDisabled = false; - this.focusedIndex = index; - } else if ( - this.selectedInterval === PlanInterval.Monthly && - plan.productTier == ProductTierType.Families - ) { - cardState = PlanCardState.Disabled; - this.isCardStateDisabled = true; - this.focusedIndex = this.selectableProducts.length - 1; - } else { - cardState = PlanCardState.NotSelected; - this.isCardStateDisabled = false; - } - - switch (cardState) { - case PlanCardState.Selected: { - return [ - "tw-cursor-pointer", - "tw-block", - "tw-rounded", - "tw-border", - "tw-border-solid", - "tw-border-primary-600", - "hover:tw-border-primary-700", - "focus:tw-border-2", - "focus:tw-border-primary-700", - "focus:tw-rounded-lg", - ]; - } - case PlanCardState.NotSelected: { - return [ - "tw-cursor-pointer", - "tw-block", - "tw-rounded", - "tw-border", - "tw-border-solid", - "tw-border-secondary-300", - "hover:tw-border-text-main", - "focus:tw-border-2", - "focus:tw-border-primary-700", - ]; - } - case PlanCardState.Disabled: { - if (this.isSubscriptionCanceled) { - return [ - "tw-cursor-not-allowed", - "tw-bg-secondary-100", - "tw-font-normal", - "tw-bg-blur", - "tw-text-muted", - "tw-block", - "tw-rounded", - "tw-w-80", - ]; - } - - return [ - "tw-cursor-not-allowed", - "tw-bg-secondary-100", - "tw-font-normal", - "tw-bg-blur", - "tw-text-muted", - "tw-block", - "tw-rounded", - ]; - } - } - } + // getPlanCardContainerClasses method removed - plan card styling is now handled by app-plan-card component protected selectPlan(plan: PlanResponse) { if (!plan) { @@ -485,19 +409,38 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (plan === this.currentPlan && !this.isSubscriptionCanceled) { return; } + this.selectedPlan = plan; this.formGroup.patchValue({ productTier: plan.productTier }); - try { - this.refreshSalesTax(); - } catch { - void this.updatePricingSummaryData(); + // Debounce the API calls to prevent rate limiting + this.debouncePlanSelection(); + } + + private debouncePlanSelection(): void { + // Clear any existing timer + if (this.planSelectionDebounceTimer) { + clearTimeout(this.planSelectionDebounceTimer); } + + // Set a new timer to delay API calls + this.planSelectionDebounceTimer = setTimeout(() => { + try { + this.refreshSalesTax(); + } catch { + void this.updatePricingSummaryData(); + } + }, 500); // 500ms delay to prevent rapid API calls } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); + + // Clean up any pending debounce timer + if (this.planSelectionDebounceTimer) { + clearTimeout(this.planSelectionDebounceTimer); + } } get upgradeRequiresPaymentMethod() { @@ -518,83 +461,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { get selectedPlanInterval() { if (this.isSubscriptionCanceled) { - return this.currentPlan.isAnnual ? "year" : "month"; + return this.currentPlan?.isAnnual ? "year" : "month"; } - return this.selectedPlan.isAnnual ? "year" : "month"; + return this.selectedPlan?.isAnnual ? "year" : "month"; } - get selectableProducts() { - if (this.isSubscriptionCanceled) { - // Return only the current plan if the subscription is canceled - return [this.currentPlan]; - } - - if (this.acceptingSponsorship) { - const familyPlan = this.passwordManagerPlans.find( - (plan) => plan.type === PlanType.FamiliesAnnually, - ); - this.discount = familyPlan.PasswordManager.basePrice; - return [familyPlan]; - } - - const businessOwnedIsChecked = this.formGroup.controls.businessOwned.value; - - const result = this.passwordManagerPlans.filter( - (plan) => - plan.type !== PlanType.Custom && - (!businessOwnedIsChecked || plan.canBeUsedByBusiness) && - (this.showFree || plan.productTier !== ProductTierType.Free) && - (plan.productTier === ProductTierType.Free || - plan.productTier === ProductTierType.TeamsStarter || - (this.selectedInterval === PlanInterval.Annually && plan.isAnnual) || - (this.selectedInterval === PlanInterval.Monthly && !plan.isAnnual)) && - (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && - this.planIsEnabled(plan), - ); - - if ( - this.currentPlan.productTier === ProductTierType.Free && - this.selectedInterval === PlanInterval.Monthly && - !this.organization.useSecretsManager - ) { - const familyPlan = this.passwordManagerPlans.find( - (plan) => plan.productTier == ProductTierType.Families, - ); - result.push(familyPlan); - } - - if ( - this.organization.useSecretsManager && - this.currentPlan.productTier === ProductTierType.Free - ) { - const familyPlanIndex = result.findIndex( - (plan) => plan.productTier === ProductTierType.Families, - ); - - if (familyPlanIndex !== -1) { - result.splice(familyPlanIndex, 1); - } - } - - if (this.currentPlan.productTier !== ProductTierType.Free) { - result.push(this.currentPlan); - } - - result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); - - return result; - } - - get selectablePlans() { - const selectedProductTierType = this.formGroup.controls.productTier.value; - const result = - this.passwordManagerPlans?.filter( - (plan) => plan.productTier === selectedProductTierType && this.planIsEnabled(plan), - ) || []; - - result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); - return result; - } + // selectableProducts and selectablePlans getters removed - + // This logic is now handled by PlanCardService.getUpgradePlanCards() get storageGb() { return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0; @@ -612,7 +485,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } get teamsStarterPlanIsAvailable() { - return this.selectablePlans.some((plan) => plan.type === PlanType.TeamsStarter); + return this.passwordManagerPlans?.some((plan) => plan.type === PlanType.TeamsStarter) || false; } get additionalServiceAccount() { @@ -629,11 +502,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } changedProduct() { - const selectedPlan = this.selectablePlans[0]; - - this.setPlanType(selectedPlan.type); - this.handlePremiumAddonAccess(selectedPlan.PasswordManager.hasPremiumAccessOption); - this.handleAdditionalSeats(selectedPlan.PasswordManager.hasAdditionalSeatsOption); + // Use the currently selected plan instead of selectablePlans[0] + if (this.selectedPlan) { + this.setPlanType(this.selectedPlan.type); + this.handlePremiumAddonAccess(this.selectedPlan.PasswordManager.hasPremiumAccessOption); + this.handleAdditionalSeats(this.selectedPlan.PasswordManager.hasAdditionalSeatsOption); + } } setPlanType(planType: PlanType) { @@ -942,7 +816,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) { do { newIndex = (newIndex + direction + cardElements.length) % cardElements.length; - } while (this.isCardDisabled(newIndex) && newIndex !== index); + } while (this.isUpgradeCardDisabled(this.upgradePlanCards[newIndex]) && newIndex !== index); event.preventDefault(); @@ -962,17 +836,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { onFocus(index: number) { this.focusedIndex = index; - this.selectPlan(this.selectableProducts[index]); + // Plan selection is now handled by the app-plan-card component click events } - isCardDisabled(index: number): boolean { - const card = this.selectableProducts[index]; - return card === (this.currentPlan || this.isCardStateDisabled); - } + // isCardDisabled removed - this logic is now in the app-plan-card component - manageSelectableProduct(index: number) { - return index; - } + // manageSelectableProduct removed - now using manageUpgradePlanCard for tracking private refreshSalesTax(): void { if ( @@ -1057,4 +926,102 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return this.i18nService.t("upgrade"); } } + + /** + * Initialize upgrade plan cards using the new PlanCardService method + */ + async initializeUpgradePlanCards(): Promise { + if (this.currentPlan && this.sub && this.organization) { + const businessOwned = this.formGroup.controls.businessOwned.value; + this.upgradePlanCards = await this.planCardService.getUpgradePlanCards( + this.currentPlan, + this.sub, + this.organization, + this.selectedInterval, + this.showFree, + this.acceptingSponsorship, + businessOwned, + ); + + // Ensure Enterprise plan (recommended) is selected by default + if (!this.isSubscriptionCanceled && this.upgradePlanCards.length > 0) { + const enterprisePlan = this.upgradePlanCards.find( + (card) => card.planResponse?.productTier === ProductTierType.Enterprise, + ); + if (enterprisePlan?.planResponse) { + this.selectPlan(enterprisePlan.planResponse); + } else { + // Fallback to last plan if Enterprise not available + const lastPlan = this.upgradePlanCards[this.upgradePlanCards.length - 1]; + if (lastPlan?.planResponse) { + this.selectPlan(lastPlan.planResponse); + } + } + } + + // Set selected state for the plan cards based on current selected plan + this.updateUpgradePlanCardSelection(); + } + } + + /** + * Update the selected state of upgrade plan cards + */ + updateUpgradePlanCardSelection(): void { + this.upgradePlanCards.forEach((card) => { + card.isSelected = card.planResponse === this.selectedPlan; + }); + } + + /** + * Handle plan selection from upgrade plan cards + */ + selectUpgradePlan(planCard: PlanCard): void { + if (planCard.planResponse) { + this.selectPlan(planCard.planResponse); + this.updateUpgradePlanCardSelection(); + } + } + + /** + * Check if an upgrade plan card is disabled + */ + isUpgradeCardDisabled(planCard: PlanCard): boolean { + if (!planCard.planResponse) { + return true; + } + + return ( + this.selectedInterval === PlanInterval.Monthly && + planCard.planResponse.productTier === ProductTierType.Families + ); + } + + /** + * Track by function for upgrade plan cards + */ + manageUpgradePlanCard(index: number, planCard: PlanCard): any { + return planCard.planResponse + ? `${planCard.planResponse.type}_${planCard.planResponse.productTier}_${planCard.planResponse.isAnnual}` + : index; + } + + /** + * Update upgrade plan cards when interval changes + */ + async updateUpgradePlanCardsOnIntervalChange(): Promise { + const previousSelectedTier = this.selectedPlan?.productTier; + await this.initializeUpgradePlanCards(); + + // Try to maintain the same tier selection after interval change + if (previousSelectedTier && this.upgradePlanCards.length > 0) { + const sameTierPlan = this.upgradePlanCards.find( + (card) => card.planResponse?.productTier === previousSelectedTier, + ); + if (sameTierPlan?.planResponse) { + this.selectPlan(sameTierPlan.planResponse); + this.updateUpgradePlanCardSelection(); + } + } + } } diff --git a/apps/web/src/app/billing/services/plan-card.service.ts b/apps/web/src/app/billing/services/plan-card.service.ts index 25974a428fd..2ae3e234b3c 100644 --- a/apps/web/src/app/billing/services/plan-card.service.ts +++ b/apps/web/src/app/billing/services/plan-card.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; @@ -56,4 +58,198 @@ export class PlanCardService { return planCards.reverse(); } + + /** + * Get upgrade plan cards for the change plan dialog + * This method handles the plan selection logic similar to what's in ChangePlanDialogComponent + * but returns structured data for rendering with plan-card component + */ + async getUpgradePlanCards( + currentPlan: PlanResponse, + subscription: OrganizationSubscriptionResponse, + organization: Organization, + selectedInterval: number, + showFree: boolean = false, + acceptingSponsorship: boolean = false, + businessOwned: boolean = false, + ) { + const plans = await this.apiService.getPlans(); + const passwordManagerPlans = plans.data.filter((plan) => !!plan.PasswordManager); + + // Helper function to check if plan is enabled + const planIsEnabled = (plan: PlanResponse): boolean => { + return !plan.disabled && !plan.legacyYear; + }; + + let selectableProducts: PlanResponse[] = []; + + if (acceptingSponsorship) { + const familyPlan = passwordManagerPlans.find( + (plan) => plan.type === PlanType.FamiliesAnnually, + ); + if (familyPlan) { + selectableProducts = [familyPlan]; + } + } else { + selectableProducts = passwordManagerPlans.filter( + (plan) => + plan.type !== PlanType.Custom && + (!businessOwned || plan.canBeUsedByBusiness) && + (showFree || plan.productTier !== ProductTierType.Free) && + (plan.productTier === ProductTierType.Free || + plan.productTier === ProductTierType.TeamsStarter || + (selectedInterval === 1 && plan.isAnnual) || + (selectedInterval === 0 && !plan.isAnnual)) && + (!currentPlan || currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && + planIsEnabled(plan), + ); + + // Add family plan for monthly interval when appropriate + if ( + currentPlan.productTier === ProductTierType.Free && + selectedInterval === 0 && + !organization.useSecretsManager + ) { + const familyPlan = passwordManagerPlans.find( + (plan) => plan.productTier === ProductTierType.Families, + ); + if (familyPlan) { + selectableProducts.push(familyPlan); + } + } + + // Remove family plan for secrets manager users + if (organization.useSecretsManager && currentPlan.productTier === ProductTierType.Free) { + selectableProducts = selectableProducts.filter( + (plan) => plan.productTier !== ProductTierType.Families, + ); + } + + // Add current plan if not free tier + if (currentPlan.productTier !== ProductTierType.Free) { + selectableProducts.push(currentPlan); + } + + selectableProducts.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); + } + + // Convert to plan cards format + const planCards = selectableProducts.map((plan) => { + let costPerMember = 0; + + if (plan.PasswordManager.basePrice) { + costPerMember = plan.isAnnual + ? plan.PasswordManager.basePrice / 12 + : plan.PasswordManager.basePrice; + } else if (!plan.PasswordManager.basePrice && plan.PasswordManager.hasAdditionalSeatsOption) { + const secretsManagerCost = subscription.useSecretsManager + ? plan.SecretsManager?.seatPrice || 0 + : 0; + const passwordManagerCost = plan.PasswordManager.seatPrice; + costPerMember = (secretsManagerCost + passwordManagerCost) / (plan.isAnnual ? 12 : 1); + } + + const percentOff = subscription.customerDiscount?.percentOff ?? 0; + const discount = percentOff === 0 && plan.isAnnual ? 20 : percentOff; + + return { + title: this.getPlanTitle(plan), + costPerMember, + discount, + isDisabled: false, + isSelected: false, // Will be set by the component + isAnnual: plan.isAnnual, + productTier: plan.productTier, + planResponse: plan, // Include the original plan for reference + // Note: Features are handled conditionally in the template based on plan type and organization settings + }; + }); + + return planCards; + } + + private getPlanTitle(plan: PlanResponse): string { + switch (plan.productTier) { + case ProductTierType.Free: + return "Free"; + case ProductTierType.Families: + return "Families"; + case ProductTierType.Teams: + return "Teams"; + case ProductTierType.Enterprise: + return "Enterprise"; + case ProductTierType.TeamsStarter: + return "Teams Starter"; + default: + return plan.name || "Unknown"; + } + } + + private getPlanFeatures(plan: PlanResponse, organization: Organization): string[] { + const features: string[] = []; + + // Add basic features based on plan properties + if (plan.PasswordManager) { + if (plan.PasswordManager.maxSeats) { + features.push(`addShareLimitedUsers:${plan.PasswordManager.maxSeats}`); + } else { + features.push("addShareUnlimitedUsers"); + } + + if (plan.PasswordManager.maxCollections) { + features.push(`limitedCollections:${plan.PasswordManager.maxCollections}`); + } else { + features.push("createUnlimitedCollections"); + } + + if (plan.PasswordManager.baseStorageGb) { + features.push(`gbEncryptedFileStorage:${plan.PasswordManager.baseStorageGb}GB`); + } + } + + // Add tier-specific features + switch (plan.productTier) { + case ProductTierType.Free: + features.push("twoStepLogin"); + break; + + case ProductTierType.Families: + features.push("premiumAccounts"); + features.push("unlimitedSharing"); + features.push("priorityCustomerSupport"); + break; + + case ProductTierType.Teams: + features.push("secureDataSharing"); + features.push("eventLogMonitoring"); + features.push("directoryIntegration"); + features.push("priorityCustomerSupport"); + if (organization?.useSecretsManager) { + features.push("unlimitedSecretsStorage"); + features.push("unlimitedProjects"); + features.push("UpTo20MachineAccounts"); + } + break; + + case ProductTierType.Enterprise: + features.push("includeEnterprisePolicies"); + features.push("passwordLessSso"); + features.push("accountRecovery"); + features.push("customRoles"); + features.push("priorityCustomerSupport"); + if (organization?.useSecretsManager) { + features.push("unlimitedSecretsStorage"); + features.push("unlimitedUsers"); + features.push("unlimitedProjects"); + features.push("UpTo50MachineAccounts"); + } + break; + } + + if (plan.usersGetPremium) { + features.push("usersGetPremium"); + } + + return features; + } } diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 5e2d9f656b9..b644dbd56bf 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -60,6 +60,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac PaymentComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, + PlanCardComponent, PricingSummaryComponent, ], }) diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.html b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html index 08fd3b435f6..6c53d619d8b 100644 --- a/apps/web/src/app/billing/shared/plan-card/plan-card.component.html +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html @@ -1,15 +1,16 @@ @let isFocused = plan().isSelected; -@let isRecommended = plan().isAnnual; +@let isCurrentInUpgradeMode = isUpgradeMode && isCurrent; -
- @if (isRecommended) { + +
+ @if (plan().isAnnual) {
{{ plan().title }} - + {{ "upgradeDiscount" | i18n: plan().discount }} @@ -42,4 +43,218 @@
+ + +
+
+ {{ "recommended" | i18n }} +
+
+

+ {{ + selectableProduct.nameLocalizationKey | i18n + }} + + {{ "current" | i18n }} +

+ + + + {{ + (selectableProduct.isAnnual + ? selectableProduct.PasswordManager.basePrice / 12 + : selectableProduct.PasswordManager.basePrice + ) | currency: "$" + }} + + + /{{ + selectableProduct.productTier === productTiers.Families + ? "month" + : ("monthPerMember" | i18n) + }} + + + {{ ("additionalUsers" | i18n).toLowerCase() }} + {{ + (selectableProduct.isAnnual + ? selectableProduct.PasswordManager.seatPrice / 12 + : selectableProduct.PasswordManager.seatPrice + ) | currency: "$" + }} + /{{ upgradeData()?.selectedPlanInterval | i18n }} + + + + + + {{ + "costPerMember" + | i18n + : (((upgradeData()?.subscription?.useSecretsManager + ? selectableProduct.SecretsManager.seatPrice + : 0) + + selectableProduct.PasswordManager.seatPrice) / + (selectableProduct.isAnnual ? 12 : 1) + | currency: "$") + }} + + /{{ "monthPerMember" | i18n }} + + {{ "freeForever" | i18n }} + +
+ + + +

+ {{ "bitwardenPasswordManager" | i18n }} +

+

{{ "enterprisePlanUpgradeMessage" | i18n }}

+ +
    +
  • + + {{ "includeEnterprisePolicies" | i18n }} +
  • +
  • + + {{ "passwordLessSso" | i18n }} +
  • +
  • + + {{ "accountRecovery" | i18n }} +
  • +
  • + + {{ "customRoles" | i18n }} +
  • +
+ +

+ {{ "bitwardenSecretsManager" | i18n }} +

+
    +
  • + + {{ "unlimitedSecretsStorage" | i18n }} +
  • +
  • + + {{ "unlimitedUsers" | i18n }} +
  • +
  • + + {{ "unlimitedProjects" | i18n }} +
  • +
  • + + {{ "UpTo50MachineAccounts" | i18n }} +
  • +
+
+ + + +

+ {{ "bitwardenPasswordManager" | i18n }} +

+

{{ "teamsPlanUpgradeMessage" | i18n }}

+ +
    +
  • + + {{ "secureDataSharing" | i18n }} +
  • +
  • + + {{ "eventLogMonitoring" | i18n }} +
  • +
  • + + {{ "directoryIntegration" | i18n }} +
  • +
+ +

+ {{ "bitwardenSecretsManager" | i18n }} +

+
    +
  • + + {{ "unlimitedSecretsStorage" | i18n }} +
  • +
  • + + {{ "unlimitedProjects" | i18n }} +
  • +
  • + + {{ "UpTo20MachineAccounts" | i18n }} +
  • +
+
+ + + +

{{ "familyPlanUpgradeMessage" | i18n }}

+
    +
  • + + {{ "premiumAccounts" | i18n }} +
  • +
  • + + {{ "unlimitedSharing" | i18n }} +
  • +
  • + + {{ "unlimitedCollections" | i18n }} +
  • +
+
+
+
diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts index 9e3f03a5e7d..21f83850345 100644 --- a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts @@ -1,6 +1,9 @@ import { Component, input, output } from "@angular/core"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; export interface PlanCard { title: string; @@ -10,6 +13,30 @@ export interface PlanCard { isAnnual: boolean; isSelected: boolean; productTier: ProductTierType; + planResponse?: PlanResponse; // Optional for backward compatibility + features?: string[]; // Additional feature list for upgrade cards +} + +export interface UpgradePlanCardData { + organization?: Organization; + subscription?: OrganizationSubscriptionResponse; + currentPlan?: PlanResponse; + selectedPlan?: PlanResponse; + selectedPlanInterval?: string; + acceptingSponsorship?: boolean; + isSubscriptionCanceled?: boolean; + teamsStarterPlanIsAvailable?: boolean; +} + +export interface UpgradePlanCard extends PlanCard { + planResponse: PlanResponse; // Required for upgrade cards + isCurrent?: boolean; + isRecommended?: boolean; + organization?: any; // For organization context + subscription?: any; // For subscription context + selectedPlanInterval?: string; + acceptingSponsorship?: boolean; + teamsStarterPlanIsAvailable?: boolean; } @Component({ @@ -19,14 +46,57 @@ export interface PlanCard { }) export class PlanCardComponent { plan = input.required(); + upgradeData = input(); productTiers = ProductTierType; cardClicked = output(); + onCardClick(): void { + // Don't emit click if this is the current plan in upgrade mode + if (this.isUpgradeMode && this.isCurrent) { + return; + } + // Don't emit click if the card is disabled + if (this.plan().isDisabled) { + return; + } + this.cardClicked.emit(); + } + + get isUpgradeMode(): boolean { + return !!(this.upgradeData() && this.plan().planResponse); + } + + get isRecommended(): boolean { + if (this.isUpgradeMode) { + const upgradeData = this.upgradeData(); + return ( + this.plan().planResponse?.productTier === ProductTierType.Enterprise && + !upgradeData?.isSubscriptionCanceled + ); + } + return this.plan().isAnnual; + } + + get isCurrent(): boolean { + if (this.isUpgradeMode) { + const upgradeData = this.upgradeData(); + return this.plan().planResponse === upgradeData?.currentPlan; + } + return false; + } + + get selectableProduct(): PlanResponse | undefined { + return this.plan().planResponse; + } + getPlanCardContainerClasses(): string[] { const isSelected = this.plan().isSelected; const isDisabled = this.plan().isDisabled; - if (isDisabled) { + const isCurrent = this.isCurrent; + + // Disable current plan cards in upgrade mode + if (isDisabled || (this.isUpgradeMode && isCurrent)) { return [ "tw-cursor-not-allowed", "tw-bg-secondary-100",