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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
|
||||
PaymentComponent,
|
||||
IndividualSelfHostingLicenseUploaderComponent,
|
||||
OrganizationSelfHostingLicenseUploaderComponent,
|
||||
PlanCardComponent,
|
||||
PricingSummaryComponent,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user