Skip to content
183 changes: 183 additions & 0 deletions apps/desktop/src/renderer/components/Paywall/Paywall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { Badge } from "@superset/ui/badge";
import { Button } from "@superset/ui/button";
import { Dialog, DialogContent } from "@superset/ui/dialog";
import { MeshGradient } from "@superset/ui/mesh-gradient";
import { cn } from "@superset/ui/utils";
import { useEffect, useState } from "react";
import { FEATURE_ID_MAP, PRO_FEATURES } from "./constants";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import type { GatedFeature } from "./usePaywall";

type PaywallOptions = {
feature: GatedFeature;
context?: Record<string, unknown>;
};

let showPaywallFn: ((options: PaywallOptions) => void) | null = null;

export const Paywall = () => {
const [paywallOptions, setPaywallOptions] = useState<PaywallOptions | null>(
null,
);
const [isOpen, setIsOpen] = useState(false);

showPaywallFn = (options: PaywallOptions) => {
setPaywallOptions(options);
setIsOpen(true);
};

useEffect(() => {
return () => {
showPaywallFn = null;
};
}, []);

const initialFeatureId =
(paywallOptions?.feature && FEATURE_ID_MAP[paywallOptions.feature]) ||
PRO_FEATURES[0]?.id ||
"team-collaboration";

const [selectedFeatureId, setSelectedFeatureId] =
useState<string>(initialFeatureId);

useEffect(() => {
if (paywallOptions?.feature && isOpen) {
const mappedId =
FEATURE_ID_MAP[paywallOptions.feature] || PRO_FEATURES[0]?.id;
if (mappedId) {
setSelectedFeatureId(mappedId);
}
}
}, [paywallOptions?.feature, isOpen]);

const handleOpenChange = (open: boolean) => {
if (!open) {
setIsOpen(false);
}
};

const selectedFeature =
PRO_FEATURES.find((f) => f.id === selectedFeatureId) || PRO_FEATURES[0];

if (!selectedFeature) {
return null;
}

const handleUpgrade = () => {
setIsOpen(false);
};

return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className="!w-[744px] !max-w-[744px] p-0 gap-0 overflow-hidden"
showCloseButton={false}
>
<div className="flex">
<div className="flex flex-col border-r bg-neutral-900">
<div className="px-5 py-2.5">
<h1 className="mb-0 mt-1.5 text-lg font-bold text-foreground">
Pro Features
</h1>
</div>

<div className="flex flex-col gap-2.5 px-5 py-2.5">
{PRO_FEATURES.map((proFeature) => {
const Icon = proFeature.icon;
const isSelected = selectedFeatureId === proFeature.id;

return (
<button
key={proFeature.id}
type="button"
onClick={() => setSelectedFeatureId(proFeature.id)}
className={cn(
"group flex w-[209px] h-16 items-center gap-3 px-4 py-3.5 transition-all duration-200 ease-out",
"cursor-pointer text-left",
isSelected
? "bg-muted text-foreground"
: "text-foreground/70 hover:text-foreground hover:bg-foreground/5",
)}
>
<Icon
className={cn(
"shrink-0 text-xl transition-all duration-200 ease-out",
isSelected
? proFeature.iconColor
: "text-foreground/40 group-hover:text-foreground/60",
)}
/>
<span
className={cn(
"text-sm font-semibold transition-all duration-200",
isSelected ? "text-foreground" : "",
)}
>
{proFeature.title}
</span>
</button>
);
})}
</div>
</div>

<div className="flex h-[487px] w-[495px] flex-col">
<div className="relative h-[346px] overflow-hidden">
{PRO_FEATURES.map((proFeature) => (
<div
key={`gradient-${proFeature.id}`}
className={cn(
"absolute inset-0 transition-opacity duration-1000 ease-in-out",
selectedFeature.id === proFeature.id
? "opacity-100"
: "opacity-0",
)}
>
<MeshGradient
colors={proFeature.gradientColors}
className="absolute inset-0 w-full h-full"
/>
</div>
))}

<div className="absolute inset-0 flex items-center justify-center">
<selectedFeature.icon className="text-white/20 text-[120px] select-none pointer-events-none" />
</div>
</div>

<div className="flex min-h-[141px] w-full flex-col border-t bg-background px-6 py-4 items-center justify-center">
<div className="mb-2 flex w-full items-center justify-center gap-2">
<span className="text-lg font-semibold text-foreground">
{selectedFeature.title}
</span>
<Badge variant="default">PRO</Badge>
</div>
<span className="text-center text-sm font-normal text-muted-foreground">
{selectedFeature.description}
</span>
</div>
</div>
</div>

<div className="box-border flex items-center justify-between border-t bg-background px-5 py-4">
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
Comment on lines +161 to +164
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cancel button skips onCancel callback.

setIsOpen(false) bypasses the cancel handler, so callbacks.onCancel won’t run for the explicit Cancel action.

✅ Align Cancel with the same close path
-					<Button variant="outline" onClick={() => setIsOpen(false)}>
+					<Button variant="outline" onClick={() => handleOpenChange(false)}>
 						Cancel
 					</Button>
🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/components/Paywall/Paywall.tsx` around lines 162 -
165, The Cancel button currently calls setIsOpen(false) directly, bypassing the
component's cancel callback; update the Button onClick to invoke the same close
path that triggers callbacks.onCancel (for example call callbacks.onCancel() and
then setIsOpen(false), or call the existing close handler method used elsewhere
such as handleClose/handleCancel if present) so the explicit Cancel action runs
callbacks.onCancel before closing the Paywall component.

<Button onClick={handleUpgrade}>Get Superset Pro</Button>
</div>
</DialogContent>
</Dialog>
);
};

export const paywall = (
feature: GatedFeature,
context?: Record<string, unknown>,
) => {
if (!showPaywallFn) {
console.error(
"[paywall] Paywall not mounted. Make sure to render <Paywall /> in your app",
);
return;
}
showPaywallFn({ feature, context });
};
72 changes: 72 additions & 0 deletions apps/desktop/src/renderer/components/Paywall/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { IconType } from "react-icons";
import { HiUsers } from "react-icons/hi2";
import { IoSparkles, IoTerminal } from "react-icons/io5";
import { MdWorkspaces } from "react-icons/md";
import { RiRocketLine } from "react-icons/ri";
import type { GatedFeature } from "./usePaywall";
import { GATED_FEATURES } from "./usePaywall";

export interface ProFeature {
id: string;
title: string;
description: string;
icon: IconType;
iconColor: string;
gradientColors: readonly [string, string, string, string];
}

export const PRO_FEATURES: ProFeature[] = [
{
id: "team-collaboration",
title: "Team Collaboration",
description:
"Invite your team to shared workspaces. See real-time updates, sync configurations, and manage team access across agents.",
icon: HiUsers,
iconColor: "text-blue-500",
gradientColors: ["#1e40af", "#1e3a8a", "#172554", "#1a1a2e"],
},
{
id: "ai-features",
title: "AI-Powered Features",
description:
"Enhanced AI agent capabilities with context-aware completions, automated workflow suggestions, and intelligent terminal assistance.",
icon: IoSparkles,
iconColor: "text-purple-500",
gradientColors: ["#6b21a8", "#581c87", "#3b0764", "#1a1a2e"],
},
{
id: "advanced-terminal",
title: "Advanced Terminal",
description:
"Split your terminal into multiple panes for parallel execution. Session persistence, custom themes, and comprehensive command history search.",
icon: IoTerminal,
iconColor: "text-green-500",
gradientColors: ["#047857", "#065f46", "#064e3b", "#1a1a2e"],
},
{
id: "unlimited-workspaces",
title: "Unlimited Workspaces",
description:
"Create as many workspaces and worktrees as you need. Organize complex multi-agent workflows without hitting limits.",
icon: MdWorkspaces,
iconColor: "text-orange-500",
gradientColors: ["#b45309", "#92400e", "#78350f", "#1a1a2e"],
},
{
id: "priority-support",
title: "Priority Support",
description:
"Priority email support from the Superset team. Early access to new Pro features and beta releases.",
icon: RiRocketLine,
iconColor: "text-red-500",
gradientColors: ["#7f1d1d", "#991b1b", "#450a0a", "#1a1a2e"],
},
];

// Map gated feature IDs to the feature to highlight in the paywall dialog
export const FEATURE_ID_MAP: Record<GatedFeature, string> = {
[GATED_FEATURES.INVITE_MEMBERS]: "team-collaboration",
[GATED_FEATURES.AI_COMPLETION]: "ai-features",
[GATED_FEATURES.SPLIT_TERMINAL]: "advanced-terminal",
[GATED_FEATURES.CREATE_WORKSPACE]: "unlimited-workspaces",
};
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/components/Paywall/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Paywall, paywall } from "./Paywall";
export type { GatedFeature } from "./usePaywall";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export { GATED_FEATURES, usePaywall } from "./usePaywall";
52 changes: 52 additions & 0 deletions apps/desktop/src/renderer/components/Paywall/usePaywall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { authClient } from "renderer/lib/auth-client";
import { paywall } from "./Paywall";

type UserPlan = "free" | "pro";

export const GATED_FEATURES = {
INVITE_MEMBERS: "invite-members",
AI_COMPLETION: "ai-completion",
SPLIT_TERMINAL: "split-terminal",
CREATE_WORKSPACE: "create-workspace",
} as const;

export type GatedFeature = (typeof GATED_FEATURES)[keyof typeof GATED_FEATURES];

export function usePaywall() {
const { data: session } = authClient.useSession();

const userPlan: UserPlan = "free";

function hasAccess(feature: GatedFeature): boolean {
void feature;
return userPlan === "pro";
}

function gateFeature(
feature: GatedFeature,
callback: () => void | Promise<void>,
context?: Record<string, unknown>,
): void {
Comment on lines +25 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use an object parameter for gateFeature to match codebase guidelines.

This function has two parameters and should take a single object (named properties) for clarity and extensibility. Update the JSDoc examples accordingly.

♻️ Suggested refactor
-	function gateFeature(
-		feature: GatedFeature,
-		callback: () => void | Promise<void>,
-	): void {
+	function gateFeature(params: {
+		feature: GatedFeature;
+		callback: () => void | Promise<void>;
+	}): void {
+		const { feature, callback } = params;
 		if (hasAccess(feature)) {
 			// Execute callback - handle both sync and async
 			const result = callback();

As per coding guidelines, use object parameters for functions with 2+ parameters.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function gateFeature(
feature: GatedFeature,
callback: () => void | Promise<void>,
): void {
function gateFeature(params: {
feature: GatedFeature;
callback: () => void | Promise<void>;
}): void {
const { feature, callback } = params;
if (hasAccess(feature)) {
// Execute callback - handle both sync and async
const result = callback();
🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/components/Paywall/usePaywall.ts` around lines 70 -
73, Change the gateFeature function to accept a single object parameter instead
of two positional args: use a signature like gateFeature({ feature, callback }:
{ feature: GatedFeature; callback: () => void | Promise<void> }) and update the
JSDoc examples to show named properties; update the function body to destructure
feature and callback, and modify all call sites to pass an object ({ feature:
..., callback: ... }) and any tests/uses that rely on the old positional
signature.

if (hasAccess(feature)) {
const result = callback();
if (result instanceof Promise) {
result.catch((error) => {
console.error(`[paywall] Callback error for ${feature}:`, error);
});
}
} else {
const trackingContext = {
organizationId: session?.session?.activeOrganizationId,
userPlan,
...context,
};
paywall(feature, trackingContext);
}
}

return {
hasAccess,
gateFeature,
userPlan,
};
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/routes/_authenticated/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@tanstack/react-router";
import { DndProvider } from "react-dnd";
import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal";
import { Paywall } from "renderer/components/Paywall";
import { useUpdateListener } from "renderer/components/UpdateToast";
import { authClient } from "renderer/lib/auth-client";
import { dragDropManager } from "renderer/lib/dnd";
Expand Down Expand Up @@ -72,6 +73,7 @@ function AuthenticatedLayout() {
<Outlet />
<WorkspaceInitEffects />
<NewWorkspaceModal />
<Paywall />
</CollectionsProvider>
</DndProvider>
);
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/types/stripe-gradient.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
declare module "stripe-gradient" {
export class Gradient {
constructor();
initGradient(selector: string): void;
play(): void;
pause(): void;
disconnect(): void;
}

export class MiniGl {
constructor(
canvas: HTMLCanvasElement,
width?: number,
height?: number,
debug?: boolean,
);
Comment thread
saddlepaddle marked this conversation as resolved.
}
}
Loading