-
Notifications
You must be signed in to change notification settings - Fork 912
feat(desktop): add pro plan paywall dialog #808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4d9eee8
b70fbff
77d377c
7a8f26d
3c43f20
5485a96
c00c768
2dbc80a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cancel button skips
✅ 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 |
||
| <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 }); | ||
| }; | ||
| 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", | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export { Paywall, paywall } from "./Paywall"; | ||
| export type { GatedFeature } from "./usePaywall"; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| export { GATED_FEATURES, usePaywall } from "./usePaywall"; | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Use an object parameter for 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| 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, | ||
| ); | ||
|
saddlepaddle marked this conversation as resolved.
|
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.