mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 07:13:32 +00:00
[PM-23713] premium badge interaction (#16911)
* feature flag * new upgrade dialog component and moved pricing service into libs first draft * moved pricing service to libs/common removed toast service from the pricing service and implemented error handling in calling components # Conflicts: # apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts * moved new premium upgrade dialog component to libs/angular * badge opens new dialog in browser extension * adds new dialog to desktop and fixes tests * updates send dropdown to use premium prompt service * styling and copy updates * implement in web and desktop * unit tests * converting premium reports to use premium badge, and some cleanup * fixes issue after merge * linter errors * pr feedback * handle async promise correctly * full sync after the premium upgrade is complete * fixing test * add padding to bottom of card in new dialog * add support for self hosting * fixing tests * fix test * Update has-premium.guard.ts * pr feedback * fix build and pr feedback * fix build * prettier * fixing stories and making badge line height consistent * pr feedback * updated upgrade dialog to no longer use pricing card * fixing incorrect markup and removing unused bits * formatting * pr feedback removing unused message keys and adding back in code that was erroneously removed * change detection * close dialog when error * claude pr feedback
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export * from "./premium.component";
|
||||
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";
|
||||
|
||||
@@ -10,7 +10,13 @@ import { BadgeModule } from "@bitwarden/components";
|
||||
selector: "app-premium-badge",
|
||||
standalone: true,
|
||||
template: `
|
||||
<button type="button" *appNotPremium bitBadge variant="success" (click)="promptForPremium()">
|
||||
<button
|
||||
type="button"
|
||||
*appNotPremium
|
||||
bitBadge
|
||||
variant="success"
|
||||
(click)="promptForPremium($event)"
|
||||
>
|
||||
{{ "premium" | i18n }}
|
||||
</button>
|
||||
`,
|
||||
@@ -21,7 +27,9 @@ export class PremiumBadgeComponent {
|
||||
|
||||
constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {}
|
||||
|
||||
async promptForPremium() {
|
||||
async promptForPremium(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { BadgeModule, I18nMockService } from "@bitwarden/components";
|
||||
|
||||
import { PremiumBadgeComponent } from "./premium-badge.component";
|
||||
|
||||
class MockMessagingService implements MessageSender {
|
||||
send = () => {
|
||||
alert("Clicked on badge");
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Badge",
|
||||
component: PremiumBadgeComponent,
|
||||
@@ -40,12 +33,6 @@ export default {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MessageSender,
|
||||
useFactory: () => {
|
||||
return new MockMessagingService();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@if (cardDetails$ | async; as cardDetails) {
|
||||
<section
|
||||
class="tw-min-w-[332px] md:tw-max-w-sm tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
<header
|
||||
class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2 !tw-bg-background !tw-border-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
[label]="'close' | i18n"
|
||||
(click)="close()"
|
||||
></button>
|
||||
</header>
|
||||
<div class="tw-flex tw-justify-center tw-mb-6">
|
||||
<div
|
||||
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2">
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">
|
||||
{{ "upgradeToPremium" | i18n }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Tagline with consistent height (exactly 2 lines) -->
|
||||
<div class="tw-mb-6 tw-h-6">
|
||||
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
|
||||
{{ cardDetails.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Section -->
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-mb-6 tw-h-12">
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
[block]="true"
|
||||
(click)="upgrade()"
|
||||
type="button"
|
||||
>
|
||||
@if (cardDetails.button.icon?.position === "before") {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
{{ cardDetails.button.text }}
|
||||
@if (
|
||||
cardDetails.button.icon &&
|
||||
(cardDetails.button.icon.position === "after" || !cardDetails.button.icon.position)
|
||||
) {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-ms-2" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="tw-flex-grow">
|
||||
@if (cardDetails.features.length > 0) {
|
||||
<ul class="tw-list-none tw-p-0 tw-m-0">
|
||||
@for (feature of cardDetails.features; track feature) {
|
||||
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
|
||||
<i
|
||||
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
|
||||
feature
|
||||
}}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { firstValueFrom, of, throwError } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
|
||||
|
||||
describe("PremiumUpgradeDialogComponent", () => {
|
||||
let component: PremiumUpgradeDialogComponent;
|
||||
let fixture: ComponentFixture<PremiumUpgradeDialogComponent>;
|
||||
let mockDialogRef: jest.Mocked<DialogRef>;
|
||||
let mockSubscriptionPricingService: jest.Mocked<SubscriptionPricingServiceAbstraction>;
|
||||
let mockI18nService: jest.Mocked<I18nService>;
|
||||
let mockToastService: jest.Mocked<ToastService>;
|
||||
let mockEnvironmentService: jest.Mocked<EnvironmentService>;
|
||||
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let mockLogService: jest.Mocked<LogService>;
|
||||
|
||||
const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Advanced features for power users",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [
|
||||
{ key: "feature1", value: "Feature 1" },
|
||||
{ key: "feature2", value: "Feature 2" },
|
||||
{ key: "feature3", value: "Feature 3" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockFamiliesTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: "Families",
|
||||
description: "Family plan",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: 6,
|
||||
annualPrice: 40,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [{ key: "featureA", value: "Feature A" }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDialogRef = {
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockSubscriptionPricingService = {
|
||||
getPersonalSubscriptionPricingTiers$: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockI18nService = {
|
||||
t: jest.fn((key: string) => key),
|
||||
} as any;
|
||||
|
||||
mockToastService = {
|
||||
showToast: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockEnvironmentService = {
|
||||
environment$: of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.com",
|
||||
getRegion: () => Region.US,
|
||||
}),
|
||||
} as any;
|
||||
|
||||
mockPlatformUtilsService = {
|
||||
launchUri: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockPremiumTier, mockFamiliesTier]),
|
||||
);
|
||||
|
||||
mockLogService = {
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should emit cardDetails$ observable with Premium tier data", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled();
|
||||
expect(cardDetails).toBeDefined();
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
});
|
||||
|
||||
it("should filter to Premium tier only", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.title).not.toBe("Families");
|
||||
});
|
||||
|
||||
it("should map Premium tier to card details correctly", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.tagline).toBe("Advanced features for power users");
|
||||
expect(cardDetails?.price.amount).toBe(10 / 12);
|
||||
expect(cardDetails?.price.cadence).toBe("monthly");
|
||||
expect(cardDetails?.button.text).toBe("upgradeNow");
|
||||
expect(cardDetails?.button.type).toBe("primary");
|
||||
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]);
|
||||
});
|
||||
|
||||
it("should use i18nService for button text", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow");
|
||||
expect(cardDetails?.button.text).toBe("upgradeNow");
|
||||
});
|
||||
|
||||
describe("upgrade()", () => {
|
||||
it("should launch URI with query parameter for cloud-hosted environments", async () => {
|
||||
mockEnvironmentService.environment$ = of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.com",
|
||||
getRegion: () => Region.US,
|
||||
} as any);
|
||||
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium",
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should launch URI without query parameter for self-hosted environments", async () => {
|
||||
mockEnvironmentService.environment$ = of({
|
||||
getWebVaultUrl: () => "https://self-hosted.example.com",
|
||||
getRegion: () => Region.SelfHosted,
|
||||
} as any);
|
||||
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://self-hosted.example.com/#/settings/subscription/premium",
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should launch URI with query parameter for EU cloud region", async () => {
|
||||
mockEnvironmentService.environment$ = of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.eu",
|
||||
getRegion: () => Region.EU,
|
||||
} as any);
|
||||
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium",
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should close dialog when close button clicked", () => {
|
||||
component["close"]();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should show error toast and return EMPTY and close dialog when getPersonalSubscriptionPricingTiers$ throws an error", (done) => {
|
||||
const error = new Error("Service error");
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
throwError(() => error),
|
||||
);
|
||||
|
||||
const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
const errorComponent = errorFixture.componentInstance;
|
||||
errorFixture.detectChanges();
|
||||
|
||||
const cardDetails$ = errorComponent["cardDetails$"];
|
||||
|
||||
cardDetails$.subscribe({
|
||||
next: () => {
|
||||
done.fail("Observable should not emit any values");
|
||||
},
|
||||
complete: () => {
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "error",
|
||||
message: "unexpectedError",
|
||||
});
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
error: (err: unknown) => done.fail(`Observable should not error: ${err}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
ToastOptions,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
|
||||
|
||||
const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Complete online security",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [
|
||||
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
|
||||
{ key: "secureFileStorage", value: "Secure file storage" },
|
||||
{ key: "emergencyAccess", value: "Emergency access" },
|
||||
{ key: "breachMonitoring", value: "Breach monitoring" },
|
||||
{ key: "andMoreFeatures", value: "And more!" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Upgrade Dialog",
|
||||
component: PremiumUpgradeDialogComponent,
|
||||
description: "A dialog for upgrading to Premium subscription",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [DialogModule, ButtonModule, TypographyModule],
|
||||
providers: [
|
||||
{
|
||||
provide: DialogRef,
|
||||
useValue: {
|
||||
close: () => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: {
|
||||
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: {
|
||||
showToast: (options: ToastOptions) => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
cloudWebVaultUrl$: of("https://vault.bitwarden.com"),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
launchUri: (uri: string) => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "upgradeNow":
|
||||
return "Upgrade Now";
|
||||
case "month":
|
||||
return "month";
|
||||
case "upgradeToPremium":
|
||||
return "Upgrade To Premium";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
error: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=931-17785&t=xOhvwjYLpjoMPgND-1",
|
||||
},
|
||||
},
|
||||
} as Meta<PremiumUpgradeDialogComponent>;
|
||||
|
||||
type Story = StoryObj<PremiumUpgradeDialogComponent>;
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
type CardDetails = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
price: { amount: number; cadence: SubscriptionCadence };
|
||||
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
|
||||
features: string[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "billing-premium-upgrade-dialog",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
TypographyModule,
|
||||
CdkTrapFocus,
|
||||
JslibModule,
|
||||
],
|
||||
templateUrl: "./premium-upgrade-dialog.component.html",
|
||||
})
|
||||
export class PremiumUpgradeDialogComponent {
|
||||
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(
|
||||
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
|
||||
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.logService.error("Error fetching and mapping pricing tiers", error);
|
||||
this.dialogRef.close();
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private dialogRef: DialogRef,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
protected async upgrade(): Promise<void> {
|
||||
const environment = await firstValueFrom(this.environmentService.environment$);
|
||||
let vaultUrl = environment.getWebVaultUrl() + "/#/settings/subscription/premium";
|
||||
if (environment.getRegion() !== Region.SelfHosted) {
|
||||
vaultUrl += "?callToAction=upgradeToPremium";
|
||||
}
|
||||
this.platformUtilsService.launchUri(vaultUrl);
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
protected close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
},
|
||||
button: {
|
||||
text: this.i18nService.t("upgradeNow"),
|
||||
type: "primary",
|
||||
icon: { type: "bwi-external-link", position: "after" },
|
||||
},
|
||||
features: tier.passwordManager.features.map((f) => f.value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the premium upgrade dialog.
|
||||
*
|
||||
* @param dialogService - The dialog service used to open the component
|
||||
* @returns A dialog reference object
|
||||
*/
|
||||
static open(dialogService: DialogService): DialogRef<PremiumUpgradeDialogComponent> {
|
||||
return dialogService.open(PremiumUpgradeDialogComponent);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { DestroyRef, Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -16,6 +17,7 @@ export class NotPremiumDirective implements OnInit {
|
||||
private templateRef: TemplateRef<any>,
|
||||
private viewContainer: ViewContainerRef,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private destroyRef: DestroyRef,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
@@ -27,14 +29,15 @@ export class NotPremiumDirective implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const premium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
);
|
||||
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
this.billingAccountProfileStateService
|
||||
.hasPremiumFromAnySource$(account.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((premium) => {
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
|
||||
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
|
||||
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
|
||||
@@ -158,6 +159,7 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service
|
||||
import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
|
||||
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
|
||||
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
|
||||
import {
|
||||
DefaultKeyGenerationService,
|
||||
@@ -1455,6 +1457,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useClass: DefaultSubscriptionPricingService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useClass: DefaultOrganizationManagementPreferencesService,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BehaviorSubject,
|
||||
concatMap,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
@@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
// Value = hours
|
||||
@@ -144,6 +146,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
protected premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
||||
@@ -192,10 +195,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => {
|
||||
this.type = val;
|
||||
this.typeChanged();
|
||||
});
|
||||
this.formGroup.controls.type.valueChanges
|
||||
.pipe(
|
||||
tap((val) => {
|
||||
this.type = val;
|
||||
}),
|
||||
switchMap(() => this.typeChanged()),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.formGroup.controls.selectedDeletionDatePreset.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@@ -426,11 +434,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
|
||||
typeChanged() {
|
||||
async typeChanged() {
|
||||
if (this.type === SendType.File && !this.alertShown) {
|
||||
if (!this.canAccessPremium) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("premiumRequired");
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
} else if (!this.emailVerified) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("emailVerificationRequired");
|
||||
|
||||
Reference in New Issue
Block a user