From 4d9eee81b3930082a685e1c8ec9e14afc4c61dec Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 13:56:42 -0800 Subject: [PATCH 1/8] feat(desktop): add pro plan paywall dialog - Implement paywall UI with Alerter-style pattern (no Zustand) - Add usePaywall hook for feature gating with type-safe constants - Co-locate component + hook + constants in Paywall/ folder - Support async callbacks in gateFeature() - Add mesh gradient backgrounds matching marketing site - Update copy to focus on team collaboration & multi-agent workflows --- .../renderer/components/Paywall/Paywall.tsx | 181 ++++++++++++++++++ .../renderer/components/Paywall/constants.ts | 72 +++++++ .../src/renderer/components/Paywall/index.ts | 3 + .../renderer/components/Paywall/usePaywall.ts | 93 +++++++++ .../renderer/routes/_authenticated/layout.tsx | 2 + 5 files changed, 351 insertions(+) create mode 100644 apps/desktop/src/renderer/components/Paywall/Paywall.tsx create mode 100644 apps/desktop/src/renderer/components/Paywall/constants.ts create mode 100644 apps/desktop/src/renderer/components/Paywall/index.ts create mode 100644 apps/desktop/src/renderer/components/Paywall/usePaywall.ts diff --git a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx new file mode 100644 index 00000000000..21f57dfd659 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx @@ -0,0 +1,181 @@ +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 type { GatedFeature } from "./usePaywall"; +import { FEATURE_ID_MAP, PRO_FEATURES } from "./constants"; + +let showPaywallFn: ((feature: GatedFeature) => void) | null = null; + +export const Paywall = () => { + const [triggeredFeature, setTriggeredFeature] = + useState(null); + const [isOpen, setIsOpen] = useState(false); + + showPaywallFn = (feature: GatedFeature) => { + setTriggeredFeature(feature); + setIsOpen(true); + }; + + // Determine which feature to highlight based on what triggered the paywall + const initialFeatureId = + (triggeredFeature && FEATURE_ID_MAP[triggeredFeature]) || + PRO_FEATURES[0]?.id || + "team-collaboration"; + + const [selectedFeatureId, setSelectedFeatureId] = + useState(initialFeatureId); + + // Update selected feature when triggered feature changes + useEffect(() => { + if (triggeredFeature && isOpen) { + const mappedId = FEATURE_ID_MAP[triggeredFeature] || PRO_FEATURES[0]?.id; + if (mappedId) { + setSelectedFeatureId(mappedId); + } + } + }, [triggeredFeature, 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 = () => { + console.log("[paywall] User clicked upgrade for:", selectedFeature.id); + // TODO: Open external pricing page or Stripe checkout + setIsOpen(false); + }; + + return ( + + + {/* Main Layout */} +
+ {/* Left Sidebar */} +
+ {/* Header */} +
+

+ Pro Features +

+
+ + {/* Feature Cards */} +
+ {PRO_FEATURES.map((proFeature) => { + const Icon = proFeature.icon; + const isSelected = selectedFeatureId === proFeature.id; + + return ( + + ); + })} +
+
+ + {/* Right Panel - Feature Display */} +
+ {/* Feature Visual/Preview - Expanded to top */} +
+ {/* Render all gradients with opacity transitions */} + {PRO_FEATURES.map((proFeature) => ( +
+ +
+ ))} + + {/* Icon overlay */} +
+ +
+
+ + {/* Feature Details */} +
+
+ + {selectedFeature.title} + + PRO +
+ + {selectedFeature.description} + +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export const paywall = (feature: GatedFeature) => { + if (!showPaywallFn) { + console.error( + "[paywall] Paywall not mounted. Make sure to render in your app", + ); + return; + } + showPaywallFn(feature); +}; diff --git a/apps/desktop/src/renderer/components/Paywall/constants.ts b/apps/desktop/src/renderer/components/Paywall/constants.ts new file mode 100644 index 00000000000..36db9616903 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/constants.ts @@ -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 "renderer/hooks/usePaywall"; +import { GATED_FEATURES } from "renderer/hooks/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 = { + [GATED_FEATURES.INVITE_MEMBERS]: "team-collaboration", + [GATED_FEATURES.AI_COMPLETION]: "ai-features", + [GATED_FEATURES.SPLIT_TERMINAL]: "advanced-terminal", + [GATED_FEATURES.CREATE_WORKSPACE]: "unlimited-workspaces", +}; diff --git a/apps/desktop/src/renderer/components/Paywall/index.ts b/apps/desktop/src/renderer/components/Paywall/index.ts new file mode 100644 index 00000000000..7a82a4a0c1f --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/index.ts @@ -0,0 +1,3 @@ +export { Paywall, paywall } from "./Paywall"; +export { GATED_FEATURES, usePaywall } from "./usePaywall"; +export type { GatedFeature } from "./usePaywall"; diff --git a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts new file mode 100644 index 00000000000..76bccae8042 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts @@ -0,0 +1,93 @@ +import { authClient } from "renderer/lib/auth-client"; +import { paywall } from "./Paywall"; + +type UserPlan = "free" | "pro"; + +// Feature identifiers - use these constants to avoid typos and get autocomplete +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]; + +/** + * Hook for managing feature access and paywall. + * + * Usage: + * ```tsx + * import { usePaywall, GATED_FEATURES } from 'renderer/components/Paywall'; + * + * const { hasAccess, gateFeature } = usePaywall(); + * + * // Guard a sync action + * + * + * // Guard an async action + * + * + * // Conditional rendering + * {hasAccess(GATED_FEATURES.INVITE_MEMBERS) && } + * ``` + */ +export function usePaywall() { + const { data: session } = authClient.useSession(); + + // TODO: Once Stripe integration is done, use: session?.user?.plan + // For now, mock as 'free' to test paywall + void session; + const userPlan: UserPlan = "free"; // Replace with: (session?.user?.plan as UserPlan) || "free"; + + /** + * Check if user has access to a feature. + * @param feature - Feature identifier from GATED_FEATURES + */ + function hasAccess(feature: GatedFeature): boolean { + // For now, all features require 'pro' plan + // Later: add feature -> required plan mapping using the feature param + void feature; + return userPlan === "pro"; + } + + /** + * Gate a feature - only execute callback if user has access. + * Shows paywall dialog if user doesn't have access. + * Supports both sync and async callbacks. + * + * @param feature - Feature identifier from GATED_FEATURES + * @param callback - Function to execute if user has access (can be async) + */ + function gateFeature( + feature: GatedFeature, + callback: () => void | Promise, + ): void { + if (hasAccess(feature)) { + // Execute callback - handle both sync and async + const result = callback(); + if (result instanceof Promise) { + result.catch((error) => { + console.error(`[paywall] Callback error for ${feature}:`, error); + }); + } + } else { + console.log(`[paywall] User blocked from feature: ${feature}`); + paywall(feature); + } + } + + return { + hasAccess, + gateFeature, + userPlan, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 39de8599172..0e88ad9a26d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -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"; @@ -72,6 +73,7 @@ function AuthenticatedLayout() { + ); From b70fbff9068102be01a3ddb7d827ffaf1d55ba3f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 14:01:48 -0800 Subject: [PATCH 2/8] feat(desktop): add pro plan paywall with analytics callbacks - Implement paywall UI with Alerter-style pattern (no Zustand) - Add usePaywall hook for feature gating with type-safe constants - Co-locate component + hook + constants in Paywall/ folder - Support async callbacks in gateFeature() - Add optional analytics callbacks (onOpen, onUpgrade, onCancel) - Remove unnecessary comments for cleaner code - Fix import paths to use co-located modules --- .../renderer/components/Paywall/Paywall.tsx | 32 ++++++------- .../renderer/components/Paywall/constants.ts | 4 +- .../renderer/components/Paywall/usePaywall.ts | 47 +------------------ 3 files changed, 19 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx index 21f57dfd659..a3c407fb09f 100644 --- a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx +++ b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx @@ -7,19 +7,28 @@ import { useEffect, useState } from "react"; import type { GatedFeature } from "./usePaywall"; import { FEATURE_ID_MAP, PRO_FEATURES } from "./constants"; +type PaywallCallbacks = { + onOpen?: (feature: GatedFeature) => void; + onUpgrade?: (feature: GatedFeature) => void; + onCancel?: (feature: GatedFeature) => void; +}; + let showPaywallFn: ((feature: GatedFeature) => void) | null = null; +let callbacks: PaywallCallbacks = {}; -export const Paywall = () => { +export const Paywall = (props?: PaywallCallbacks) => { const [triggeredFeature, setTriggeredFeature] = useState(null); const [isOpen, setIsOpen] = useState(false); + callbacks = props || {}; + showPaywallFn = (feature: GatedFeature) => { setTriggeredFeature(feature); setIsOpen(true); + callbacks.onOpen?.(feature); }; - // Determine which feature to highlight based on what triggered the paywall const initialFeatureId = (triggeredFeature && FEATURE_ID_MAP[triggeredFeature]) || PRO_FEATURES[0]?.id || @@ -28,7 +37,6 @@ export const Paywall = () => { const [selectedFeatureId, setSelectedFeatureId] = useState(initialFeatureId); - // Update selected feature when triggered feature changes useEffect(() => { if (triggeredFeature && isOpen) { const mappedId = FEATURE_ID_MAP[triggeredFeature] || PRO_FEATURES[0]?.id; @@ -39,7 +47,8 @@ export const Paywall = () => { }, [triggeredFeature, isOpen]); const handleOpenChange = (open: boolean) => { - if (!open) { + if (!open && triggeredFeature) { + callbacks.onCancel?.(triggeredFeature); setIsOpen(false); } }; @@ -52,8 +61,9 @@ export const Paywall = () => { } const handleUpgrade = () => { - console.log("[paywall] User clicked upgrade for:", selectedFeature.id); - // TODO: Open external pricing page or Stripe checkout + if (triggeredFeature) { + callbacks.onUpgrade?.(triggeredFeature); + } setIsOpen(false); }; @@ -63,18 +73,14 @@ export const Paywall = () => { className="!w-[744px] !max-w-[744px] p-0 gap-0 overflow-hidden" showCloseButton={false} > - {/* Main Layout */}
- {/* Left Sidebar */}
- {/* Header */}

Pro Features

- {/* Feature Cards */}
{PRO_FEATURES.map((proFeature) => { const Icon = proFeature.icon; @@ -115,11 +121,8 @@ export const Paywall = () => {
- {/* Right Panel - Feature Display */}
- {/* Feature Visual/Preview - Expanded to top */}
- {/* Render all gradients with opacity transitions */} {PRO_FEATURES.map((proFeature) => (
{
))} - {/* Icon overlay */}
- {/* Feature Details */}
@@ -158,7 +159,6 @@ export const Paywall = () => {
- {/* Footer */}
- * - * // Guard an async action - * - * - * // Conditional rendering - * {hasAccess(GATED_FEATURES.INVITE_MEMBERS) && } - * ``` - */ export function usePaywall() { const { data: session } = authClient.useSession(); - // TODO: Once Stripe integration is done, use: session?.user?.plan - // For now, mock as 'free' to test paywall void session; - const userPlan: UserPlan = "free"; // Replace with: (session?.user?.plan as UserPlan) || "free"; + const userPlan: UserPlan = "free"; - /** - * Check if user has access to a feature. - * @param feature - Feature identifier from GATED_FEATURES - */ function hasAccess(feature: GatedFeature): boolean { - // For now, all features require 'pro' plan - // Later: add feature -> required plan mapping using the feature param void feature; return userPlan === "pro"; } - /** - * Gate a feature - only execute callback if user has access. - * Shows paywall dialog if user doesn't have access. - * Supports both sync and async callbacks. - * - * @param feature - Feature identifier from GATED_FEATURES - * @param callback - Function to execute if user has access (can be async) - */ function gateFeature( feature: GatedFeature, callback: () => void | Promise, ): void { if (hasAccess(feature)) { - // Execute callback - handle both sync and async const result = callback(); if (result instanceof Promise) { result.catch((error) => { From 77d377c42c10dd4aae830820f20bf62952dc9bc2 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 14:51:28 -0800 Subject: [PATCH 3/8] fix(desktop): cleanup paywall showPaywallFn on unmount - Add useEffect cleanup to reset showPaywallFn = null when Paywall unmounts - Prevents stale state setters from being called after component unmounts --- .../src/renderer/components/Paywall/Paywall.tsx | 13 ++++++++++--- .../src/renderer/components/Paywall/index.ts | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx index a3c407fb09f..3dee5360d8f 100644 --- a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx +++ b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx @@ -4,8 +4,8 @@ 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 type { GatedFeature } from "./usePaywall"; import { FEATURE_ID_MAP, PRO_FEATURES } from "./constants"; +import type { GatedFeature } from "./usePaywall"; type PaywallCallbacks = { onOpen?: (feature: GatedFeature) => void; @@ -17,8 +17,9 @@ let showPaywallFn: ((feature: GatedFeature) => void) | null = null; let callbacks: PaywallCallbacks = {}; export const Paywall = (props?: PaywallCallbacks) => { - const [triggeredFeature, setTriggeredFeature] = - useState(null); + const [triggeredFeature, setTriggeredFeature] = useState( + null, + ); const [isOpen, setIsOpen] = useState(false); callbacks = props || {}; @@ -29,6 +30,12 @@ export const Paywall = (props?: PaywallCallbacks) => { callbacks.onOpen?.(feature); }; + useEffect(() => { + return () => { + showPaywallFn = null; + }; + }, []); + const initialFeatureId = (triggeredFeature && FEATURE_ID_MAP[triggeredFeature]) || PRO_FEATURES[0]?.id || diff --git a/apps/desktop/src/renderer/components/Paywall/index.ts b/apps/desktop/src/renderer/components/Paywall/index.ts index 7a82a4a0c1f..519461e781e 100644 --- a/apps/desktop/src/renderer/components/Paywall/index.ts +++ b/apps/desktop/src/renderer/components/Paywall/index.ts @@ -1,3 +1,3 @@ export { Paywall, paywall } from "./Paywall"; -export { GATED_FEATURES, usePaywall } from "./usePaywall"; export type { GatedFeature } from "./usePaywall"; +export { GATED_FEATURES, usePaywall } from "./usePaywall"; From 7a8f26d9812ae675f547f6f2835bec126bc94512 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 15:09:25 -0800 Subject: [PATCH 4/8] fix(desktop): add local stripe-gradient type declaration - Add apps/desktop/src/types/stripe-gradient.d.ts with proper type definitions - Matches approach used in marketing and web apps - Avoids hacky tsconfig includes --- apps/desktop/src/types/stripe-gradient.d.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/desktop/src/types/stripe-gradient.d.ts diff --git a/apps/desktop/src/types/stripe-gradient.d.ts b/apps/desktop/src/types/stripe-gradient.d.ts new file mode 100644 index 00000000000..fb2a11254a4 --- /dev/null +++ b/apps/desktop/src/types/stripe-gradient.d.ts @@ -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, + ); + } +} From 3c43f2071ee48ac7f266a52e0eb76618d6c2ae88 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 17:11:49 -0800 Subject: [PATCH 5/8] refactor(desktop): remove analytics callbacks from paywall - Remove PaywallCallbacks type and props - Remove module-level callbacks variable - Simplify to just show/hide dialog - Consumers can add their own tracking if needed --- .../renderer/components/Paywall/Paywall.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx index 3dee5360d8f..b13f2ac79c2 100644 --- a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx +++ b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx @@ -7,27 +7,17 @@ import { useEffect, useState } from "react"; import { FEATURE_ID_MAP, PRO_FEATURES } from "./constants"; import type { GatedFeature } from "./usePaywall"; -type PaywallCallbacks = { - onOpen?: (feature: GatedFeature) => void; - onUpgrade?: (feature: GatedFeature) => void; - onCancel?: (feature: GatedFeature) => void; -}; - let showPaywallFn: ((feature: GatedFeature) => void) | null = null; -let callbacks: PaywallCallbacks = {}; -export const Paywall = (props?: PaywallCallbacks) => { +export const Paywall = () => { const [triggeredFeature, setTriggeredFeature] = useState( null, ); const [isOpen, setIsOpen] = useState(false); - callbacks = props || {}; - showPaywallFn = (feature: GatedFeature) => { setTriggeredFeature(feature); setIsOpen(true); - callbacks.onOpen?.(feature); }; useEffect(() => { @@ -54,8 +44,7 @@ export const Paywall = (props?: PaywallCallbacks) => { }, [triggeredFeature, isOpen]); const handleOpenChange = (open: boolean) => { - if (!open && triggeredFeature) { - callbacks.onCancel?.(triggeredFeature); + if (!open) { setIsOpen(false); } }; @@ -68,9 +57,6 @@ export const Paywall = (props?: PaywallCallbacks) => { } const handleUpgrade = () => { - if (triggeredFeature) { - callbacks.onUpgrade?.(triggeredFeature); - } setIsOpen(false); }; From 5485a96d567a6775ddb54008ffd51cb21d67de91 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 17:19:40 -0800 Subject: [PATCH 6/8] feat(desktop): add optional tracking context to gateFeature - Add optional context parameter to gateFeature() - Logs context when paywall is triggered - Allows consumers to track source/origin of paywall triggers --- apps/desktop/src/renderer/components/Paywall/usePaywall.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts index 897162f5715..a80b72260d0 100644 --- a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts +++ b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts @@ -26,6 +26,7 @@ export function usePaywall() { function gateFeature( feature: GatedFeature, callback: () => void | Promise, + context?: Record, ): void { if (hasAccess(feature)) { const result = callback(); @@ -35,7 +36,7 @@ export function usePaywall() { }); } } else { - console.log(`[paywall] User blocked from feature: ${feature}`); + console.log(`[paywall] User blocked from feature: ${feature}`, context); paywall(feature); } } From c00c7688fe0db327569e3614faa1feabcda1f555 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 17:21:08 -0800 Subject: [PATCH 7/8] feat(desktop): automatically include user context in paywall tracking - Auto-include userId, organizationId, and userPlan from session - Consumer only needs to pass additional context like source/location - Merge custom context with internal context --- .../src/renderer/components/Paywall/usePaywall.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts index a80b72260d0..dae6cb5b2d0 100644 --- a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts +++ b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts @@ -15,7 +15,6 @@ export type GatedFeature = (typeof GATED_FEATURES)[keyof typeof GATED_FEATURES]; export function usePaywall() { const { data: session } = authClient.useSession(); - void session; const userPlan: UserPlan = "free"; function hasAccess(feature: GatedFeature): boolean { @@ -36,7 +35,13 @@ export function usePaywall() { }); } } else { - console.log(`[paywall] User blocked from feature: ${feature}`, context); + const trackingContext = { + userId: session?.user?.id, + organizationId: session?.session?.activeOrganizationId, + userPlan, + ...context, + }; + console.log(`[paywall] User blocked from feature: ${feature}`, trackingContext); paywall(feature); } } From 2dbc80ab3fd0158eb7d3b8582af3845b72435867 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 18 Jan 2026 17:23:30 -0800 Subject: [PATCH 8/8] refactor(desktop): follow Alerter pattern for paywall state - Pass options through showPaywallFn and store in state (like Alerter) - Remove userId from tracking (already identified in authenticated route) - Auto-include organizationId and userPlan in tracking context - Consumers only pass additional context like source/location --- .../renderer/components/Paywall/Paywall.tsx | 29 ++++++++++++------- .../renderer/components/Paywall/usePaywall.ts | 4 +-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx index b13f2ac79c2..60be1481ca0 100644 --- a/apps/desktop/src/renderer/components/Paywall/Paywall.tsx +++ b/apps/desktop/src/renderer/components/Paywall/Paywall.tsx @@ -7,16 +7,21 @@ import { useEffect, useState } from "react"; import { FEATURE_ID_MAP, PRO_FEATURES } from "./constants"; import type { GatedFeature } from "./usePaywall"; -let showPaywallFn: ((feature: GatedFeature) => void) | null = null; +type PaywallOptions = { + feature: GatedFeature; + context?: Record; +}; + +let showPaywallFn: ((options: PaywallOptions) => void) | null = null; export const Paywall = () => { - const [triggeredFeature, setTriggeredFeature] = useState( + const [paywallOptions, setPaywallOptions] = useState( null, ); const [isOpen, setIsOpen] = useState(false); - showPaywallFn = (feature: GatedFeature) => { - setTriggeredFeature(feature); + showPaywallFn = (options: PaywallOptions) => { + setPaywallOptions(options); setIsOpen(true); }; @@ -27,7 +32,7 @@ export const Paywall = () => { }, []); const initialFeatureId = - (triggeredFeature && FEATURE_ID_MAP[triggeredFeature]) || + (paywallOptions?.feature && FEATURE_ID_MAP[paywallOptions.feature]) || PRO_FEATURES[0]?.id || "team-collaboration"; @@ -35,13 +40,14 @@ export const Paywall = () => { useState(initialFeatureId); useEffect(() => { - if (triggeredFeature && isOpen) { - const mappedId = FEATURE_ID_MAP[triggeredFeature] || PRO_FEATURES[0]?.id; + if (paywallOptions?.feature && isOpen) { + const mappedId = + FEATURE_ID_MAP[paywallOptions.feature] || PRO_FEATURES[0]?.id; if (mappedId) { setSelectedFeatureId(mappedId); } } - }, [triggeredFeature, isOpen]); + }, [paywallOptions?.feature, isOpen]); const handleOpenChange = (open: boolean) => { if (!open) { @@ -163,12 +169,15 @@ export const Paywall = () => { ); }; -export const paywall = (feature: GatedFeature) => { +export const paywall = ( + feature: GatedFeature, + context?: Record, +) => { if (!showPaywallFn) { console.error( "[paywall] Paywall not mounted. Make sure to render in your app", ); return; } - showPaywallFn(feature); + showPaywallFn({ feature, context }); }; diff --git a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts index dae6cb5b2d0..65bcfc5fec5 100644 --- a/apps/desktop/src/renderer/components/Paywall/usePaywall.ts +++ b/apps/desktop/src/renderer/components/Paywall/usePaywall.ts @@ -36,13 +36,11 @@ export function usePaywall() { } } else { const trackingContext = { - userId: session?.user?.id, organizationId: session?.session?.activeOrganizationId, userPlan, ...context, }; - console.log(`[paywall] User blocked from feature: ${feature}`, trackingContext); - paywall(feature); + paywall(feature, trackingContext); } }