1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 03:03:26 +00:00

Add Changes for the plan-card

This commit is contained in:
Cy Okeke
2025-09-08 17:46:02 +05:30
parent 689a7d9f37
commit 34af55d346
6 changed files with 680 additions and 468 deletions

View File

@@ -50,279 +50,42 @@
</div>
</div>
<!-- Plan Selection Cards -->
<ng-container *ngIf="!loading && !selfHosted && this.passwordManagerPlans">
<ng-container
*ngIf="!loading && !selfHosted && this.passwordManagerPlans && upgradePlanCards.length > 0"
>
<div
class="tw-grid tw-grid-flow-col tw-gap-4 tw-mb-4"
[class]="'tw-grid-cols-' + selectableProducts.length"
[class]="'tw-grid-cols-' + upgradePlanCards.length"
>
<bit-card
<div
*ngFor="
let selectableProduct of selectableProducts;
trackBy: manageSelectableProduct;
let upgradePlanCard of upgradePlanCards;
trackBy: manageUpgradePlanCard;
let i = index
"
[ngClass]="getPlanCardContainerClasses(selectableProduct, i)"
(click)="selectPlan(selectableProduct)"
[attr.tabindex]="focusedIndex !== i || isCardDisabled(i) ? '-1' : '0'"
[attr.tabindex]="
focusedIndex !== i || isUpgradeCardDisabled(upgradePlanCard) ? '-1' : '0'
"
(keyup)="onKeydown($event, i)"
(focus)="onFocus(i)"
[attr.aria-disabled]="isCardDisabled(i)"
[attr.aria-disabled]="isUpgradeCardDisabled(upgradePlanCard)"
[id]="i + 'a_plan_card'"
>
<div class="tw-relative">
<div
*ngIf="
selectableProduct.productTier === productTypes.Enterprise &&
!isSubscriptionCanceled
"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': selectableProduct === selectedPlan,
'tw-bg-secondary-100': !(selectableProduct === selectedPlan),
}"
>
{{ "recommended" | i18n }}
</div>
<div
class="tw-px-2 tw-pb-[4px]"
[ngClass]="{
'tw-py-1': !(selectableProduct === selectedPlan),
'tw-py-0': selectableProduct === selectedPlan,
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{
selectableProduct.nameLocalizationKey | i18n
}}</span>
<span
bitBadge
variant="secondary"
*ngIf="selectableProduct === currentPlan"
class="tw-ml-2 tw-align-middle"
>
{{ "current" | i18n }}</span
>
</h3>
<span *ngIf="selectableProduct.productTier != productTypes.Free">
<ng-container
*ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship"
>
<b class="tw-text-lg tw-font-semibold">
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.basePrice / 12
: selectableProduct.PasswordManager.basePrice
) | currency: "$"
}}
</b>
<span class="tw-text-xs tw-px-0">
/{{
selectableProduct.productTier === productTypes.Families
? "month"
: ("monthPerMember" | i18n)
}}</span
>
<b class="tw-text-sm tw-font-semibold">
<ng-container
*ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption"
>
{{ ("additionalUsers" | i18n).toLowerCase() }}
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.seatPrice / 12
: selectableProduct.PasswordManager.seatPrice
) | currency: "$"
}}
/{{ selectedPlanInterval | i18n }}
</ng-container>
</b>
</ng-container>
</span>
<span
*ngIf="
!selectableProduct.PasswordManager.basePrice &&
selectableProduct.PasswordManager.hasAdditionalSeatsOption
"
>
<b class="tw-text-lg tw-font-semibold"
>{{
"costPerMember"
| i18n
: (((this.sub.useSecretsManager
? selectableProduct.SecretsManager.seatPrice
: 0) +
selectableProduct.PasswordManager.seatPrice) /
(selectableProduct.isAnnual ? 12 : 1)
| currency: "$")
}}
</b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
</span>
<span *ngIf="selectableProduct.productTier == productTypes.Free"
>{{ "freeForever" | i18n }}
</span>
</div>
</div>
<ng-container
*ngIf="
selectableProduct.productTier === productTypes.Enterprise;
else nonEnterprisePlans
"
>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
</p>
<p class="tw-text-xs tw-px-2 tw-mb-1">{{ "enterprisePlanUpgradeMessage" | i18n }}</p>
<ul class="bwi-ul tw-text-xs">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "includeEnterprisePolicies" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "passwordLessSso" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "accountRecovery" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "customRoles" | i18n }}
</li>
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenSecretsManager" | i18n }}
</p>
<ul class="bwi-ul tw-text-xs" *ngIf="organization.useSecretsManager">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedSecretsStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedUsers" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedProjects" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "UpTo50MachineAccounts" | i18n }}
</li>
</ul>
</ng-container>
<ng-template #nonEnterprisePlans>
<ng-container
*ngIf="
selectableProduct.productTier === productTypes.Teams &&
teamsStarterPlanIsAvailable;
else fullFeatureList
"
>
<ul class="tw-px-2 tw-pb-2 tw-list-inside tw-mb-0 tw-text-xs">
<li>{{ "includeAllTeamsStarterFeatures" | i18n }}</li>
<li>{{ "chooseMonthlyOrAnnualBilling" | i18n }}</li>
<li>{{ "abilityToAddMoreThanNMembers" | i18n: 10 }}</li>
</ul>
</ng-container>
<ng-template #fullFeatureList>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
</p>
<p
*ngIf="selectableProduct.productTier === productTypes.Teams"
class="tw-text-xs tw-px-2 tw-mb-1"
>
{{ "teamsPlanUpgradeMessage" | i18n }}
</p>
<p
*ngIf="selectableProduct.productTier === productTypes.Families"
class="tw-text-xs tw-px-2 tw-mb-1"
>
{{ "familyPlanUpgradeMessage" | i18n }}
</p>
<ul
class="bwi-ul tw-text-xs tw-mb-1"
*ngIf="selectableProduct.productTier == productTypes.Families"
>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumAccounts" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedSharing" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedCollections" | i18n }}
</li>
</ul>
<ul
class="bwi-ul tw-text-xs"
*ngIf="selectableProduct.productTier == productTypes.Teams"
>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "secureDataSharing" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "eventLogMonitoring" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "directoryIntegration" | i18n }}
</li>
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="
organization.useSecretsManager &&
selectableProduct.productTier !== productTypes.Families
"
>
{{ "bitwardenSecretsManager" | i18n }}
</p>
<ul
class="bwi-ul tw-text-xs"
*ngIf="
organization.useSecretsManager &&
selectableProduct.productTier == productTypes.Teams
"
>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedSecretsStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedProjects" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "UpTo20MachineAccounts" | i18n }}
</li>
</ul>
</ng-template>
</ng-template>
</bit-card>
<app-plan-card
[plan]="upgradePlanCard"
[upgradeData]="{
organization: organization,
subscription: sub,
currentPlan: currentPlan,
selectedPlan: selectedPlan,
selectedPlanInterval: selectedPlanInterval,
acceptingSponsorship: acceptingSponsorship,
isSubscriptionCanceled: isSubscriptionCanceled,
teamsStarterPlanIsAvailable: teamsStarterPlanIsAvailable,
}"
(cardClicked)="selectUpgradePlan(upgradePlanCard)"
></app-plan-card>
</div>
</div>
<br />
<bit-callout

View File

@@ -65,9 +65,11 @@ import {
import { KeyService } from "@bitwarden/key-management";
import { BillingNotificationService } from "../services/billing-notification.service";
import { PlanCardService } from "../services/plan-card.service";
import { PricingSummaryService } from "../services/pricing-summary.service";
import { BillingSharedModule } from "../shared/billing-shared.module";
import { PaymentComponent } from "../shared/payment/payment.component";
import { PlanCard } from "../shared/plan-card/plan-card.component";
import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component";
type ChangePlanDialogParams = {
@@ -83,13 +85,7 @@ export enum ChangePlanDialogResultType {
Submitted = "submitted",
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum PlanCardState {
Selected = "selected",
NotSelected = "not_selected",
Disabled = "disabled",
}
// PlanCardState enum removed - plan card styling is now handled by app-plan-card component
export const openChangePlanDialog = (
dialogService: DialogService,
@@ -100,7 +96,7 @@ export const openChangePlanDialog = (
dialogConfig,
);
type PlanCard = {
type PlanCardLegacy = {
name: string;
selected: boolean;
};
@@ -153,7 +149,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
protected discountPercentage: number = 20;
protected discountPercentageFromSub: number;
protected loading = true;
protected planCards: PlanCard[];
protected planCards: PlanCardLegacy[];
protected upgradePlanCards: PlanCard[] = [];
protected ResultType = ChangePlanDialogResultType;
selfHosted = false;
@@ -198,6 +195,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
pricingSummaryData: PricingSummaryData;
private destroy$ = new Subject<void>();
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<void> {
@@ -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<void> {
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<void> {
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();
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -60,6 +60,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
PaymentComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
PlanCardComponent,
PricingSummaryComponent,
],
})

View File

@@ -1,15 +1,16 @@
@let isFocused = plan().isSelected;
@let isRecommended = plan().isAnnual;
@let isCurrentInUpgradeMode = isUpgradeMode && isCurrent;
<bit-card
class="tw-h-full"
[ngClass]="getPlanCardContainerClasses()"
(click)="cardClicked.emit()"
[attr.tabindex]="!isFocused || plan().isDisabled ? '-1' : '0'"
(click)="onCardClick()"
[attr.tabindex]="!isFocused || plan().isDisabled || isCurrentInUpgradeMode ? '-1' : '0'"
[attr.data-selected]="plan()?.isSelected"
>
<div class="tw-relative">
@if (isRecommended) {
<!-- Simple Plan Card (for trial payment dialog) -->
<div class="tw-relative" *ngIf="!isUpgradeMode">
@if (plan().isAnnual) {
<div
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
[ngClass]="{
@@ -32,7 +33,7 @@
>
<span class="tw-capitalize tw-whitespace-nowrap">{{ plan().title }}</span>
<!-- Discount Badge -->
<span class="tw-mr-1 tw-ml-2" *ngIf="isRecommended" bitBadge variant="success">
<span class="tw-mr-1 tw-ml-2" *ngIf="plan().isAnnual" bitBadge variant="success">
{{ "upgradeDiscount" | i18n: plan().discount }}</span
>
</h3>
@@ -42,4 +43,218 @@
</span>
</div>
</div>
<!-- Enhanced Plan Card (for upgrade dialog) -->
<div class="tw-relative" *ngIf="isUpgradeMode && selectableProduct">
<div
*ngIf="isRecommended"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': plan().isSelected,
'tw-bg-secondary-100': !plan().isSelected,
}"
>
{{ "recommended" | i18n }}
</div>
<div
class="tw-px-2 tw-pb-[4px]"
[ngClass]="{
'tw-py-1': !plan().isSelected,
'tw-py-0': plan().isSelected,
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{
selectableProduct.nameLocalizationKey | i18n
}}</span>
<span bitBadge variant="secondary" *ngIf="isCurrent" class="tw-ml-2 tw-align-middle">
{{ "current" | i18n }}</span
>
</h3>
<span *ngIf="selectableProduct.productTier != productTiers.Free">
<ng-container
*ngIf="
selectableProduct.PasswordManager.basePrice && !upgradeData()?.acceptingSponsorship
"
>
<b class="tw-text-lg tw-font-semibold">
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.basePrice / 12
: selectableProduct.PasswordManager.basePrice
) | currency: "$"
}}
</b>
<span class="tw-text-xs tw-px-0">
/{{
selectableProduct.productTier === productTiers.Families
? "month"
: ("monthPerMember" | i18n)
}}</span
>
<b class="tw-text-sm tw-font-semibold">
<ng-container *ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption">
{{ ("additionalUsers" | i18n).toLowerCase() }}
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.seatPrice / 12
: selectableProduct.PasswordManager.seatPrice
) | currency: "$"
}}
/{{ upgradeData()?.selectedPlanInterval | i18n }}
</ng-container>
</b>
</ng-container>
</span>
<span
*ngIf="
!selectableProduct.PasswordManager.basePrice &&
selectableProduct.PasswordManager.hasAdditionalSeatsOption
"
>
<b class="tw-text-lg tw-font-semibold"
>{{
"costPerMember"
| i18n
: (((upgradeData()?.subscription?.useSecretsManager
? selectableProduct.SecretsManager.seatPrice
: 0) +
selectableProduct.PasswordManager.seatPrice) /
(selectableProduct.isAnnual ? 12 : 1)
| currency: "$")
}}
</b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
</span>
<span *ngIf="selectableProduct.productTier == productTiers.Free"
>{{ "freeForever" | i18n }}
</span>
</div>
<!-- Feature Lists -->
<ng-container
*ngIf="selectableProduct.productTier === productTiers.Enterprise; else nonEnterprisePlans"
>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="upgradeData()?.organization?.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
</p>
<p class="tw-text-xs tw-px-2 tw-mb-1">{{ "enterprisePlanUpgradeMessage" | i18n }}</p>
<ul class="bwi-ul tw-text-xs">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "includeEnterprisePolicies" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "passwordLessSso" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "accountRecovery" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "customRoles" | i18n }}
</li>
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="upgradeData()?.organization?.useSecretsManager"
>
{{ "bitwardenSecretsManager" | i18n }}
</p>
<ul class="bwi-ul tw-text-xs" *ngIf="upgradeData()?.organization?.useSecretsManager">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedSecretsStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedUsers" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedProjects" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "UpTo50MachineAccounts" | i18n }}
</li>
</ul>
</ng-container>
<ng-template #nonEnterprisePlans>
<!-- Teams Plan Features -->
<ng-container *ngIf="selectableProduct.productTier === productTiers.Teams">
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="upgradeData()?.organization?.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
</p>
<p class="tw-text-xs tw-px-2 tw-mb-1">{{ "teamsPlanUpgradeMessage" | i18n }}</p>
<ul class="bwi-ul tw-text-xs">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "secureDataSharing" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "eventLogMonitoring" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "directoryIntegration" | i18n }}
</li>
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
*ngIf="upgradeData()?.organization?.useSecretsManager"
>
{{ "bitwardenSecretsManager" | i18n }}
</p>
<ul class="bwi-ul tw-text-xs" *ngIf="upgradeData()?.organization?.useSecretsManager">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedSecretsStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedProjects" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "UpTo20MachineAccounts" | i18n }}
</li>
</ul>
</ng-container>
<!-- Families Plan Features -->
<ng-container *ngIf="selectableProduct.productTier === productTiers.Families">
<p class="tw-text-xs tw-px-2 tw-mb-1">{{ "familyPlanUpgradeMessage" | i18n }}</p>
<ul class="bwi-ul tw-text-xs tw-mb-1">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumAccounts" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedSharing" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "unlimitedCollections" | i18n }}
</li>
</ul>
</ng-container>
</ng-template>
</div>
</bit-card>

View File

@@ -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<PlanCard>();
upgradeData = input<UpgradePlanCardData>();
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",