feat(ui): add Local Server Cloud upgrade prompts (#11982)

This commit is contained in:
Alejandro Bailo
2026-07-15 12:11:28 +02:00
committed by GitHub
parent 077dff7f68
commit faa74f4c6c
68 changed files with 1687 additions and 429 deletions
@@ -1,90 +0,0 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
interface MenuFeatureBadgeProps {
label?: string;
variant?: "cloud" | "new";
size?: "default" | "sm";
className?: string;
}
const FEATURE_BADGE_STYLE: Record<
NonNullable<MenuFeatureBadgeProps["variant"]>,
CSSProperties | undefined
> = {
cloud: {
backgroundImage:
"linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)",
},
new: undefined,
};
const FEATURE_BADGE_VARIANT_CLASS: Record<
NonNullable<MenuFeatureBadgeProps["variant"]>,
string
> = {
cloud: "text-black",
new: "bg-emerald-500 text-white",
};
const FEATURE_BADGE_SIZE_CLASS: Record<
NonNullable<MenuFeatureBadgeProps["size"]>,
string
> = {
default: "h-6 rounded-lg px-2 text-xs leading-5",
sm: "h-5 rounded-md px-1.5 text-[10px] leading-4",
};
export const MenuFeatureBadge = ({
label,
variant = "cloud",
size = "default",
className,
}: MenuFeatureBadgeProps) => (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center font-bold whitespace-nowrap",
FEATURE_BADGE_VARIANT_CLASS[variant],
FEATURE_BADGE_SIZE_CLASS[size],
className,
)}
style={FEATURE_BADGE_STYLE[variant]}
>
{label}
</span>
);
export const CloudFeatureBadge = ({
label = "Available in Prowler Cloud",
size,
className,
}: Omit<MenuFeatureBadgeProps, "variant">) => (
<MenuFeatureBadge
label={label}
variant="cloud"
size={size}
className={className}
/>
);
interface CloudFeatureBadgeLinkProps
extends Omit<MenuFeatureBadgeProps, "variant"> {
href?: string;
}
export const CloudFeatureBadgeLink = ({
href = "https://prowler.com/pricing",
label,
size,
className,
}: CloudFeatureBadgeLinkProps) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 whitespace-nowrap transition-opacity hover:opacity-90"
>
<CloudFeatureBadge label={label} size={size} className={className} />
</a>
);
@@ -0,0 +1,136 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { CloudUpgradeModal } from "./cloud-upgrade-modal";
describe("CloudUpgradeModal", () => {
afterEach(() => {
cleanup();
vi.unstubAllEnvs();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("renders the active contextual upgrade in Local Server", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
// When
render(<CloudUpgradeModal />);
// Then
expect(
await screen.findByRole("dialog", { name: "Turn Findings into Alerts" }),
).toBeVisible();
expect(screen.getByText("Available in Prowler Cloud")).toBeVisible();
expect(
screen.getByRole("link", { name: "Create Alerts in Prowler Cloud" }),
).toHaveAttribute(
"href",
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=alerts",
);
expect(
screen.getByRole("link", { name: "View Plans & Pricing" }),
).toHaveAttribute(
"href",
"https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=alerts",
);
});
it("uses the standard equal-width CTA layout", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS);
// When
render(<CloudUpgradeModal />);
// Then
const dialog = await screen.findByRole("dialog", {
name: "Add Your Entire AWS Organization",
});
const primaryCta = screen.getByRole("link", {
name: "Set Up AWS Organizations in Prowler Cloud",
});
const secondaryCta = screen.getByRole("link", {
name: "View Plans & Pricing",
});
expect(dialog).toHaveClass("sm:max-w-2xl");
expect(primaryCta.parentElement).toHaveClass("gap-3", "md:flex-row");
expect(primaryCta).toHaveClass(
"h-auto",
"min-h-9",
"whitespace-normal",
"md:flex-1",
);
expect(secondaryCta).toHaveClass(
"h-auto",
"min-h-9",
"whitespace-normal",
"md:flex-1",
);
expect(primaryCta.querySelector(".truncate")).not.toBeInTheDocument();
expect(primaryCta).toHaveAttribute(
"href",
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=organization",
);
});
it("closes the active upgrade and returns focus to its trigger", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const user = userEvent.setup();
render(
<>
<button
type="button"
onClick={() =>
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.GENERAL)
}
>
Explore Prowler Cloud
</button>
<CloudUpgradeModal />
</>,
);
const trigger = screen.getByRole("button", {
name: "Explore Prowler Cloud",
});
await user.click(trigger);
// When
await user.click(screen.getByRole("button", { name: "Close" }));
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
});
it("does not render upgrade UI in Prowler Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS);
// When
render(<CloudUpgradeModal />);
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,107 @@
"use client";
import { Check, Cloud } from "lucide-react";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Modal } from "@/components/shadcn/modal";
import {
CLOUD_UPGRADE_CONTENT,
CLOUD_UPGRADE_FOOTER_NOTE,
CLOUD_UPGRADE_SECONDARY_CTA,
getCloudUpgradeCompareUrl,
getCloudUpgradePrimaryUrl,
} from "@/lib/cloud-upgrade";
import { isCloud } from "@/lib/shared/env";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
const allowInitialAutoFocus = () => {};
export const CloudUpgradeModal = () => {
const activeFeature = useCloudUpgradeStore((state) => state.activeFeature);
const closeCloudUpgrade = useCloudUpgradeStore(
(state) => state.closeCloudUpgrade,
);
const returnFocusElement = useCloudUpgradeStore(
(state) => state.returnFocusElement,
);
if (isCloud()) return null;
const feature = activeFeature ?? CLOUD_UPGRADE_FEATURE.GENERAL;
const content = CLOUD_UPGRADE_CONTENT[feature];
return (
<Modal
open={activeFeature !== null}
onOpenChange={(open) => !open && closeCloudUpgrade()}
onOpenAutoFocus={allowInitialAutoFocus}
onCloseAutoFocus={(event) => {
event.preventDefault();
returnFocusElement?.focus();
}}
title={content.title}
description={content.description}
size="2xl"
>
<div className="min-w-0 space-y-6">
<div className="flex items-center gap-3">
<div className="bg-bg-neutral-tertiary text-text-neutral-primary flex size-10 items-center justify-center rounded-xl">
<Cloud aria-hidden="true" className="size-5" />
</div>
<Badge variant="cloud">Available in Prowler Cloud</Badge>
</div>
<ul className="space-y-3">
{content.benefits.map((benefit) => (
<li
key={benefit}
className="text-text-neutral-secondary flex items-start gap-3 text-sm"
>
<Check
aria-hidden="true"
className="text-text-success mt-0.5 size-4"
/>
<span>{benefit}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-3 md:flex-row">
<Button
asChild
className="h-auto min-h-9 w-full min-w-0 shrink whitespace-normal md:flex-1"
>
<a
href={getCloudUpgradePrimaryUrl(feature)}
target="_blank"
rel="noopener noreferrer"
title={content.primaryCta}
>
{content.primaryCta}
</a>
</Button>
<Button
asChild
variant="outline"
className="h-auto min-h-9 w-full min-w-0 shrink whitespace-normal md:flex-1"
>
<a
href={getCloudUpgradeCompareUrl(feature)}
target="_blank"
rel="noopener noreferrer"
title={CLOUD_UPGRADE_SECONDARY_CTA}
>
{CLOUD_UPGRADE_SECONDARY_CTA}
</a>
</Button>
</div>
<p className="text-text-neutral-tertiary text-center text-xs">
{CLOUD_UPGRADE_FOOTER_NOTE}
</p>
</div>
</Modal>
);
};