feat(ui): add cancelled subscription variant to the trial sidebar banner (#12538)

This commit is contained in:
Alejandro Bailo
2026-08-26 08:59:00 +02:00
committed by GitHub
parent 39c85ffb77
commit 7a64e1a1d1
4 changed files with 60 additions and 6 deletions
@@ -0,0 +1 @@
Cancelled-subscription variant in the sidebar trial banner (Prowler Cloud only)
@@ -148,6 +148,29 @@ describe("TrialSidebarBanner", () => {
).toHaveAttribute("href", "/billing");
});
it("renders the cancelled subscription card with the terminal treatment", () => {
// Given / When
render(
<TrialSidebarBanner variant={TRIAL_SIDEBAR_BANNER_VARIANT.CANCELLED} />,
);
// Then
const banner = screen.getByRole("status", {
name: "Cancelled subscription",
});
expect(banner).toHaveTextContent("Subscription cancelled");
expect(banner).toHaveTextContent("Subscription required");
expect(banner).toHaveTextContent(
"Scans are paused and your assessment data remains available. Subscribe again to continue scanning.",
);
expect(banner).toHaveAttribute("data-urgency", "critical");
expect(
screen.getByRole("link", {
name: "Explore plans after your cancelled subscription",
}),
).toHaveAttribute("href", "/billing");
});
it("invokes the sidebar selection callback from the billing CTA", async () => {
// Given
const user = userEvent.setup();
@@ -20,6 +20,7 @@ export const TRIAL_SIDEBAR_BANNER_VARIANT = {
ACTIVE_SCANS: "active_scans",
ACTIVE_UNLIMITED: "active_unlimited",
EXPIRED: "expired",
CANCELLED: "cancelled",
} as const;
interface TrialSidebarBannerBaseProps {
@@ -51,11 +52,17 @@ interface ExpiredTrialSidebarBannerProps extends TrialSidebarBannerBaseProps {
remaining?: never;
}
interface CancelledTrialSidebarBannerProps extends TrialSidebarBannerBaseProps {
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.CANCELLED;
remaining?: never;
}
export type TrialSidebarBannerProps =
| ActiveDaysTrialSidebarBannerProps
| ActiveScansTrialSidebarBannerProps
| ActiveUnlimitedTrialSidebarBannerProps
| ExpiredTrialSidebarBannerProps;
| ExpiredTrialSidebarBannerProps
| CancelledTrialSidebarBannerProps;
const TRIAL_URGENCY = {
HEALTHY: "healthy",
@@ -115,6 +122,12 @@ const TRIAL_SIDEBAR_BANNER_COPY: Record<
linkLabel: "Explore plans after your trial expired",
cardLabel: "Expired trial",
},
[TRIAL_SIDEBAR_BANNER_VARIANT.CANCELLED]: {
badge: "Subscription cancelled",
body: "Scans are paused and your assessment data remains available. Subscribe again to continue scanning.",
linkLabel: "Explore plans after your cancelled subscription",
cardLabel: "Cancelled subscription",
},
};
const TRIAL_SIDEBAR_BANNER_METER: Record<
@@ -150,7 +163,10 @@ const TRIAL_URGENCY_STYLES: Record<TrialUrgency, TrialUrgencyStyles> = {
};
const getTrialUrgency = (props: TrialSidebarBannerProps): TrialUrgency => {
if (props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED) {
if (
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED ||
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.CANCELLED
) {
return TRIAL_URGENCY.CRITICAL;
}
if (
@@ -181,7 +197,11 @@ const TILT_RANGE_Y = 2;
const TILT_LIFT = -2;
export const TrialSidebarBanner = (props: TrialSidebarBannerProps) => {
const isExpired = props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED;
// Expired trial and cancelled subscription share the terminal treatment:
// error badge, error icon, "Subscription required" heading.
const isTerminal =
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED ||
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.CANCELLED;
const isScanBased =
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS;
// The backend keeps a scan-capped trial `active` once its quota is spent.
@@ -189,7 +209,7 @@ export const TrialSidebarBanner = (props: TrialSidebarBannerProps) => {
const urgency = getTrialUrgency(props);
const urgencyStyles = TRIAL_URGENCY_STYLES[urgency];
const copy = TRIAL_SIDEBAR_BANNER_COPY[props.variant];
const heading = isExpired
const heading = isTerminal
? "Subscription required"
: props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED
? "Trial active"
@@ -282,14 +302,16 @@ export const TrialSidebarBanner = (props: TrialSidebarBannerProps) => {
<Sparkles
className={cn(
"size-4",
isExpired ? "text-text-error-primary" : "text-button-primary",
isTerminal
? "text-text-error-primary"
: "text-button-primary",
)}
aria-hidden="true"
/>
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Badge
variant={isExpired || isExhausted ? "error" : "success"}
variant={isTerminal || isExhausted ? "error" : "success"}
size="sm"
className="w-fit"
>
+8
View File
@@ -8,6 +8,14 @@ const { withSentryConfig } = require("@sentry/nextjs");
// HTTP Security Headers
const nextConfig = {
poweredByHeader: false,
// Dev-only. Lets the dev server accept HMR/asset requests from a tunnel
// hostname (ngrok/cloudflared), needed when testing the local UI through a
// public tunnel or from an external device.
// Comma-separated, e.g. NEXT_ALLOWED_DEV_ORIGINS=foo.ngrok-free.dev
allowedDevOrigins: (process.env.NEXT_ALLOWED_DEV_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean),
// Use standalone only in production deployments, not for CI/testing
...(process.env.NODE_ENV === "production" &&
!process.env.CI && {