feat(ui): add trial usage sidebar banner (#12420)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
This commit is contained in:
Hugo Pereira Brito
2026-08-13 11:36:59 +01:00
committed by GitHub
co-authored by Pablo F.G
parent 7bde42ffb9
commit d05c9fbb31
6 changed files with 683 additions and 9 deletions
@@ -0,0 +1 @@
Display the default one-scan free trial and trial expiration in the existing sidebar banner
@@ -0,0 +1,217 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
TRIAL_SIDEBAR_BANNER_VARIANT,
TrialSidebarBanner,
} from "./trial-sidebar-banner";
describe("TrialSidebarBanner", () => {
it("preserves the active day-based trial card", () => {
// Given / When
render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
remaining={7}
/>,
);
// Then
const banner = screen.getByRole("status", { name: "Active trial" });
expect(banner).toHaveTextContent("Unlimited trial");
expect(banner).toHaveTextContent("7 days left");
expect(banner).toHaveTextContent(
"Unlimited accounts, scans, and daily schedules",
);
expect(banner).toHaveTextContent("Explore plans");
expect(banner).toHaveAttribute("data-slot", "sidebar-trial");
expect(
screen.getByRole("link", {
name: "Explore plans for your unlimited trial",
}),
).toHaveAttribute("href", "/billing");
});
it("preserves an active unlimited trial without a counter", () => {
// Given / When
render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED}
/>,
);
// Then
const banner = screen.getByRole("status", { name: "Active trial" });
expect(banner).toHaveTextContent("Unlimited trial");
expect(banner).toHaveTextContent("Trial active");
expect(banner).toHaveTextContent(
"Unlimited accounts, scans, and daily schedules",
);
expect(banner).toHaveAttribute("data-urgency", "healthy");
expect(
screen.getByRole("link", {
name: "Explore plans for your unlimited trial",
}),
).toHaveAttribute("href", "/billing");
});
it("formats a singular remaining day", () => {
// Given / When
render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
remaining={1}
/>,
);
// Then
const banner = screen.getByRole("status", { name: "Active trial" });
expect(banner).toHaveTextContent("1 day left");
expect(banner).toHaveAttribute("data-urgency", "critical");
});
it.each([
{ remaining: 1, copy: "1 scan left", urgency: "healthy" },
{ remaining: 5, copy: "5 scans left", urgency: "healthy" },
])(
"formats $remaining remaining scan(s) in the active card",
({ remaining, copy, urgency }) => {
// Given / When
render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS}
remaining={remaining}
/>,
);
// Then
const banner = screen.getByRole("status", { name: "Active trial" });
expect(banner).toHaveTextContent(copy);
expect(banner).toHaveTextContent("Free trial");
expect(banner).not.toHaveTextContent("Unlimited trial");
expect(banner).toHaveTextContent(
"Choose a plan to keep running scans after your trial ends.",
);
expect(banner).toHaveAttribute("data-urgency", urgency);
expect(
screen.getByRole("link", {
name: "Explore plans for your free trial",
}),
).toHaveAttribute("href", "/billing");
},
);
it.each([
{ remaining: 0, copy: "0 scans left" },
{ remaining: -4, copy: "0 scans left" },
])(
"marks a scan trial with $remaining remaining as spent",
({ remaining, copy }) => {
// Given / When
render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS}
remaining={remaining}
/>,
);
// Then
const banner = screen.getByRole("status", { name: "Active trial" });
expect(banner).toHaveTextContent(copy);
expect(banner).toHaveAttribute("data-urgency", "critical");
expect(screen.getByText("Free trial")).toHaveClass(
"bg-bg-fail-secondary",
);
},
);
it("renders exhausted scan trials with the existing expired presentation", () => {
// Given / When
render(
<TrialSidebarBanner variant={TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED} />,
);
// Then
const banner = screen.getByRole("status", { name: "Expired trial" });
expect(banner).toHaveTextContent("Trial expired");
expect(banner).toHaveTextContent("Subscription required");
expect(banner).toHaveTextContent(
"Subscribe to continue scanning and running scheduled scans.",
);
expect(banner).not.toHaveTextContent("0 scans left");
expect(banner).toHaveAttribute("data-urgency", "critical");
expect(
screen.getByRole("link", {
name: "Explore plans after your trial expired",
}),
).toHaveAttribute("href", "/billing");
});
it("invokes the sidebar selection callback from the billing CTA", async () => {
// Given
const user = userEvent.setup();
const onSelect = vi.fn();
render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
remaining={7}
onSelect={onSelect}
/>,
);
document.addEventListener("click", (event) => event.preventDefault(), {
once: true,
});
// When
await user.click(
screen.getByRole("link", {
name: "Explore plans for your unlimited trial",
}),
);
// Then
expect(onSelect).toHaveBeenCalledOnce();
});
// The tilt runs on animation frames and `useReducedMotion` caches its media
// query module-globally, so both belong in Browser Mode, not jsdom.
it("renders the decorative glow layer", () => {
// Given / When
const { container } = render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
remaining={7}
/>,
);
// Then
const glow = container.querySelector('[data-slot="trial-glow"]');
expect(glow).toBeInTheDocument();
expect(glow).toHaveClass("motion-reduce:hidden");
});
it.each([{ pointerType: "touch" }, { pointerType: "mouse" }])(
"keeps the card usable under $pointerType input",
({ pointerType }) => {
// Given
render(
<TrialSidebarBanner
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
remaining={7}
/>,
);
const card = screen.getByRole("link", {
name: "Explore plans for your unlimited trial",
});
// When
fireEvent.pointerMove(card, { clientX: 250, clientY: 60, pointerType });
fireEvent.pointerLeave(card);
// Then
expect(card).toBeInTheDocument();
expect(card).toHaveTextContent("7 days left");
},
);
});
@@ -0,0 +1,317 @@
"use client";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "framer-motion";
import { ArrowRight, Sparkles } from "lucide-react";
import Link from "next/link";
import { type PointerEvent } from "react";
import { Badge } from "@/components/shadcn/badge/badge";
import { Card } from "@/components/shadcn/card/card";
import { cn } from "@/lib/utils";
export const TRIAL_SIDEBAR_BANNER_VARIANT = {
ACTIVE_DAYS: "active_days",
ACTIVE_SCANS: "active_scans",
ACTIVE_UNLIMITED: "active_unlimited",
EXPIRED: "expired",
} as const;
interface TrialSidebarBannerBaseProps {
onSelect?: () => HTMLElement | null;
}
interface ActiveTrialSidebarBannerProps extends TrialSidebarBannerBaseProps {
remaining: number;
}
interface ActiveDaysTrialSidebarBannerProps
extends ActiveTrialSidebarBannerProps {
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS;
}
interface ActiveScansTrialSidebarBannerProps
extends ActiveTrialSidebarBannerProps {
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS;
}
interface ActiveUnlimitedTrialSidebarBannerProps
extends TrialSidebarBannerBaseProps {
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED;
remaining?: never;
}
interface ExpiredTrialSidebarBannerProps extends TrialSidebarBannerBaseProps {
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED;
remaining?: never;
}
export type TrialSidebarBannerProps =
| ActiveDaysTrialSidebarBannerProps
| ActiveScansTrialSidebarBannerProps
| ActiveUnlimitedTrialSidebarBannerProps
| ExpiredTrialSidebarBannerProps;
const TRIAL_URGENCY = {
HEALTHY: "healthy",
WARNING: "warning",
CRITICAL: "critical",
} as const;
type TrialUrgency = (typeof TRIAL_URGENCY)[keyof typeof TRIAL_URGENCY];
const TRIAL_SIDEBAR_BANNER_UNIT = {
DAY: "day",
SCAN: "scan",
} as const;
type TrialSidebarBannerUnit =
(typeof TRIAL_SIDEBAR_BANNER_UNIT)[keyof typeof TRIAL_SIDEBAR_BANNER_UNIT];
type TrialSidebarBannerVariant =
(typeof TRIAL_SIDEBAR_BANNER_VARIANT)[keyof typeof TRIAL_SIDEBAR_BANNER_VARIANT];
type MeteredTrialVariant = Extract<
TrialSidebarBannerProps,
{ remaining: number }
>["variant"];
interface TrialSidebarBannerCopy {
badge: string;
body: string;
linkLabel: string;
cardLabel: string;
}
const UNLIMITED_COPY = {
badge: "Unlimited trial",
body: "Unlimited accounts, scans, and daily schedules. Subscribe to keep everything running.",
linkLabel: "Explore plans for your unlimited trial",
cardLabel: "Active trial",
} as const satisfies TrialSidebarBannerCopy;
// Keyed maps rather than ternaries: a new variant fails to compile until every
// string and unit is supplied for it.
const TRIAL_SIDEBAR_BANNER_COPY: Record<
TrialSidebarBannerVariant,
TrialSidebarBannerCopy
> = {
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS]: UNLIMITED_COPY,
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED]: UNLIMITED_COPY,
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS]: {
badge: "Free trial",
body: "Choose a plan to keep running scans after your trial ends.",
linkLabel: "Explore plans for your free trial",
cardLabel: "Active trial",
},
[TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED]: {
badge: "Trial expired",
body: "Subscribe to continue scanning and running scheduled scans.",
linkLabel: "Explore plans after your trial expired",
cardLabel: "Expired trial",
},
};
const TRIAL_SIDEBAR_BANNER_METER: Record<
MeteredTrialVariant,
TrialSidebarBannerUnit
> = {
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS]: TRIAL_SIDEBAR_BANNER_UNIT.DAY,
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS]: TRIAL_SIDEBAR_BANNER_UNIT.SCAN,
};
interface TrialUrgencyStyles {
sidebarBorder: string;
sidebarTint: string;
sidebarGlow: string;
}
const TRIAL_URGENCY_STYLES: Record<TrialUrgency, TrialUrgencyStyles> = {
[TRIAL_URGENCY.HEALTHY]: {
sidebarBorder: "border-button-primary",
sidebarTint: "bg-button-primary/10",
sidebarGlow: "bg-button-primary/20",
},
[TRIAL_URGENCY.WARNING]: {
sidebarBorder: "border-bg-warning",
sidebarTint: "bg-bg-warning-secondary/20",
sidebarGlow: "bg-bg-warning/20",
},
[TRIAL_URGENCY.CRITICAL]: {
sidebarBorder: "border-border-error",
sidebarTint: "bg-bg-fail-secondary/40",
sidebarGlow: "bg-bg-fail/20",
},
};
const getTrialUrgency = (props: TrialSidebarBannerProps): TrialUrgency => {
if (props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED) {
return TRIAL_URGENCY.CRITICAL;
}
if (
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS &&
props.remaining > 0
) {
return TRIAL_URGENCY.HEALTHY;
}
if (props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED) {
return TRIAL_URGENCY.HEALTHY;
}
if (props.remaining <= 1) return TRIAL_URGENCY.CRITICAL;
if (props.remaining <= 5) return TRIAL_URGENCY.WARNING;
return TRIAL_URGENCY.HEALTHY;
};
const formatRemaining = (remaining: number, unit: TrialSidebarBannerUnit) => {
// The API sends a cap and a usage counter, so callers subtract and can go negative.
const left = Math.max(0, remaining);
return `${left} ${unit}${left === 1 ? "" : "s"} left`;
};
const TILT_SPRING = { stiffness: 260, damping: 26, mass: 0.6 } as const;
const TILT_RESTING_POINTER = 0.5;
const TILT_RANGE_X = 1.5;
const TILT_RANGE_Y = 2;
const TILT_LIFT = -2;
export const TrialSidebarBanner = (props: TrialSidebarBannerProps) => {
const isExpired = props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED;
const isScanBased =
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS;
// The backend keeps a scan-capped trial `active` once its quota is spent.
const isExhausted = isScanBased && props.remaining <= 0;
const urgency = getTrialUrgency(props);
const urgencyStyles = TRIAL_URGENCY_STYLES[urgency];
const copy = TRIAL_SIDEBAR_BANNER_COPY[props.variant];
const heading = isExpired
? "Subscription required"
: props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED
? "Trial active"
: formatRemaining(
props.remaining,
TRIAL_SIDEBAR_BANNER_METER[props.variant],
);
const prefersReducedMotion = useReducedMotion();
// Normalised pointer position (0..1) over the card.
const pointerX = useMotionValue(TILT_RESTING_POINTER);
const pointerY = useMotionValue(TILT_RESTING_POINTER);
const hover = useMotionValue(0);
const rotateX = useSpring(
useTransform(pointerY, [0, 1], [TILT_RANGE_X, -TILT_RANGE_X]),
TILT_SPRING,
);
const rotateY = useSpring(
useTransform(pointerX, [0, 1], [-TILT_RANGE_Y, TILT_RANGE_Y]),
TILT_SPRING,
);
const lift = useSpring(
useTransform(hover, [0, 1], [0, TILT_LIFT]),
TILT_SPRING,
);
const glowLeft = useTransform(pointerX, (value) => `${value * 100}%`);
const glowTop = useTransform(pointerY, (value) => `${value * 100}%`);
const resetMotion = () => {
pointerX.set(TILT_RESTING_POINTER);
pointerY.set(TILT_RESTING_POINTER);
hover.set(0);
};
const followPointer = (event: PointerEvent<HTMLAnchorElement>) => {
if (prefersReducedMotion || event.pointerType === "touch") return;
const bounds = event.currentTarget.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
pointerX.set((event.clientX - bounds.left) / bounds.width);
pointerY.set((event.clientY - bounds.top) / bounds.height);
hover.set(1);
};
return (
<Link
href="/billing"
aria-label={copy.linkLabel}
onClick={props.onSelect}
onPointerMove={followPointer}
onPointerLeave={resetMotion}
onPointerCancel={resetMotion}
onBlur={resetMotion}
className="focus-visible:ring-button-primary/50 group mx-3 mb-4 block rounded-xl focus-visible:ring-2 focus-visible:outline-none"
>
<motion.div
className="rounded-xl"
style={{ transformPerspective: 700, rotateX, rotateY, y: lift }}
>
<Card
variant="inner"
padding="sm"
data-slot="sidebar-trial"
data-urgency={urgency}
role="status"
aria-label={copy.cardLabel}
// role="status" is implicitly atomic, which re-reads the whole card
// on every counter change.
aria-atomic="false"
className={cn(
"relative gap-3 overflow-hidden transition-colors duration-200",
urgencyStyles.sidebarBorder,
urgencyStyles.sidebarTint,
)}
>
<motion.span
data-slot="trial-glow"
aria-hidden="true"
className={cn(
"pointer-events-none absolute size-32 rounded-full opacity-0 blur-3xl transition-opacity duration-300 group-hover:opacity-100 motion-reduce:hidden",
urgencyStyles.sidebarGlow,
)}
style={{ left: glowLeft, top: glowTop, x: "-50%", y: "-50%" }}
/>
<div className="relative z-10 flex min-w-0 items-start gap-2.5">
<span className="border-border-neutral-tertiary bg-bg-neutral-secondary flex size-9 shrink-0 items-center justify-center rounded-md border">
<Sparkles
className={cn(
"size-4",
isExpired ? "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"}
size="sm"
className="w-fit"
>
{copy.badge}
</Badge>
<strong className="text-text-neutral-primary text-lg leading-none">
{heading}
</strong>
</div>
</div>
<p className="text-text-neutral-secondary relative z-10 text-xs leading-4">
{copy.body}
</p>
<span className="text-button-primary relative z-10 flex items-center justify-between text-xs font-semibold">
Explore plans
<ArrowRight
className="size-4 transition-transform duration-200 group-hover:translate-x-0.5 motion-reduce:transform-none"
aria-hidden="true"
/>
</span>
</Card>
</motion.div>
</Link>
);
};
+121
View File
@@ -0,0 +1,121 @@
import { render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { navigationState, routerRefreshMock } = vi.hoisted(() => ({
navigationState: { searchParams: new URLSearchParams() },
routerRefreshMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: routerRefreshMock }),
useSearchParams: () => navigationState.searchParams,
}));
import { AutoRefresh, SCAN_POLL_TICK_EVENT } from "./auto-refresh";
describe("AutoRefresh", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
navigationState.searchParams = new URLSearchParams();
});
afterEach(() => {
vi.useRealTimers();
});
it("dispatches a poll tick after a successful callback refresh", async () => {
// Given
let resolveRefresh!: () => void;
const refreshPromise = new Promise<void>((resolve) => {
resolveRefresh = resolve;
});
const onRefresh = vi.fn().mockReturnValue(refreshPromise);
const eventListener = vi.fn();
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
render(<AutoRefresh hasExecutingScan onRefresh={onRefresh} />);
// When
await vi.advanceTimersByTimeAsync(5_000);
// Then
expect(onRefresh).toHaveBeenCalledOnce();
expect(eventListener).not.toHaveBeenCalled();
// When
resolveRefresh();
await refreshPromise;
await Promise.resolve();
// Then
expect(eventListener).toHaveBeenCalledOnce();
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
});
it("dispatches a poll tick after the default router refresh", async () => {
// Given
const eventListener = vi.fn();
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
render(<AutoRefresh hasExecutingScan />);
// When
await vi.advanceTimersByTimeAsync(5_000);
// Then
expect(routerRefreshMock).toHaveBeenCalledOnce();
expect(eventListener).toHaveBeenCalledOnce();
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
});
it("does not dispatch a poll tick when an async callback rejects", async () => {
// Given
const onRefresh = vi.fn().mockRejectedValue(new Error("refresh failed"));
const eventListener = vi.fn();
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
render(<AutoRefresh hasExecutingScan onRefresh={onRefresh} />);
// When
await vi.advanceTimersByTimeAsync(5_000);
// Then
expect(onRefresh).toHaveBeenCalledOnce();
expect(eventListener).not.toHaveBeenCalled();
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
});
it.each([
{
hasExecutingScan: false,
searchParams: "",
condition: "no scan executes",
},
{
hasExecutingScan: true,
searchParams: "scanId=scan-executing",
condition: "the scan drawer is open",
},
])(
"does not poll when $condition",
async ({ hasExecutingScan, searchParams }) => {
// Given
const onRefresh = vi.fn();
const eventListener = vi.fn();
navigationState.searchParams = new URLSearchParams(searchParams);
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
render(
<AutoRefresh
hasExecutingScan={hasExecutingScan}
onRefresh={onRefresh}
/>,
);
// When
await vi.advanceTimersByTimeAsync(5_000);
// Then
expect(onRefresh).not.toHaveBeenCalled();
expect(eventListener).not.toHaveBeenCalled();
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
},
);
});
+25 -7
View File
@@ -3,6 +3,13 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
/**
* Signals that a poll cycle ran, not that fresh data landed: the default branch
* calls the fire-and-forget `router.refresh()`, which reports neither.
* Listeners must read authoritative state themselves.
*/
export const SCAN_POLL_TICK_EVENT = "prowler:scan-poll-tick";
interface AutoRefreshProps {
hasExecutingScan: boolean;
/** Optional callback for client-side refresh (used when data is managed in local state) */
@@ -21,13 +28,24 @@ export function AutoRefresh({ hasExecutingScan, onRefresh }: AutoRefreshProps) {
if (scanId) return;
const interval = setInterval(() => {
if (onRefresh) {
// Use custom refresh callback for client-side state management
onRefresh();
} else {
// Default: trigger server-side refresh
router.refresh();
}
const refresh = async () => {
try {
if (onRefresh) {
// Use custom refresh callback for client-side state management
await onRefresh();
} else {
// Default: trigger server-side refresh
router.refresh();
}
} catch (error) {
console.error("Scan auto-refresh failed:", error);
return;
}
window.dispatchEvent(new Event(SCAN_POLL_TICK_EVENT));
};
void refresh();
}, 5000);
return () => clearInterval(interval);
+2 -2
View File
@@ -595,9 +595,9 @@
"section": "dependencies",
"name": "sharp",
"from": "0.33.5",
"to": "0.33.5",
"to": "0.35.3",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
"generatedAt": "2026-08-11T11:35:35.609Z"
},
{
"section": "dependencies",