mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 20:33:41 +00:00
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
"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",
|
|
CANCELLED: "cancelled",
|
|
} 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;
|
|
}
|
|
|
|
interface CancelledTrialSidebarBannerProps extends TrialSidebarBannerBaseProps {
|
|
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.CANCELLED;
|
|
remaining?: never;
|
|
}
|
|
|
|
export type TrialSidebarBannerProps =
|
|
| ActiveDaysTrialSidebarBannerProps
|
|
| ActiveScansTrialSidebarBannerProps
|
|
| ActiveUnlimitedTrialSidebarBannerProps
|
|
| ExpiredTrialSidebarBannerProps
|
|
| CancelledTrialSidebarBannerProps;
|
|
|
|
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",
|
|
},
|
|
[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<
|
|
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 ||
|
|
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.CANCELLED
|
|
) {
|
|
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) => {
|
|
// 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.
|
|
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 = isTerminal
|
|
? "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",
|
|
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={isTerminal || 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>
|
|
);
|
|
};
|