Remove paid plugin UI (#1403)

This commit is contained in:
Ishaan Gupta 2026-08-15 18:41:46 +05:30 committed by GitHub
parent 5ecbc26345
commit e651045ac5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 150 additions and 222 deletions

View file

@ -4,11 +4,10 @@ import { useAuth } from "@lib/auth-context"
import { useSession } from "@lib/auth" import { useSession } from "@lib/auth"
import { cn } from "@lib/utils" import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts" import { dmSans125ClassName } from "@/lib/fonts"
import { useCustomer } from "autumn-js/react" import { ArrowRight, XCircle } from "lucide-react"
import { ArrowRight, Loader, XCircle } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react" import { Suspense, useEffect, useMemo, useState } from "react"
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants" import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
@ -88,7 +87,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
"Auto-capture of project decisions", "Auto-capture of project decisions",
"Context-aware suggestions", "Context-aware suggestions",
], ],
icon: "/images/plugins/cursor.svg", icon: "/images/plugins/cursor.png",
}, },
codex: { codex: {
name: "OpenAI Codex", name: "OpenAI Codex",
@ -103,11 +102,77 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
}, },
} }
const MULTI_PLUGIN_FEATURES = [
"Share one persistent memory layer across selected coding agents.",
"Recall project context, coding decisions, and prior sessions.",
"Connect every selected plugin with one approval.",
]
function isKnownPlugin(value: string): boolean {
return Object.hasOwn(PLUGIN_INFO, value)
}
function getPluginName(client: string): string { function getPluginName(client: string): string {
return PLUGIN_INFO[client]?.name ?? "External Tool" return PLUGIN_INFO[client]?.name ?? "External Tool"
} }
type Status = "loading" | "creating" | "success" | "error" | "upgrade" function formatPluginNames(clients: string[]): string {
const names = clients.map((id) => getPluginName(id))
if (names.length === 0) return "External Tool"
if (names.length === 1) return names[0] ?? "External Tool"
if (names.length === 2) {
return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}`
}
return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}`
}
function encodeBase64UrlJson(value: Record<string, string>): string {
return btoa(JSON.stringify(value))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "")
}
function PluginLogoStack({ clients }: { clients: string[] }) {
if (clients.length === 0) {
return (
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<ArrowRight className="size-5 text-[#4BA0FA]" />
</div>
)
}
return (
<div className="flex items-center justify-center">
{clients.map((id, index) => {
const plugin = PLUGIN_INFO[id]
return (
<div
className="-ml-2 flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F] p-2 first:ml-0"
key={`${id}-${index}`}
style={{ zIndex: clients.length - index }}
title={plugin?.name ?? id}
>
{plugin ? (
<Image
alt={plugin.name}
className="size-6 object-contain"
height={24}
src={plugin.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
)
})}
</div>
)
}
type Status = "loading" | "creating" | "success" | "error"
const pageWrapperClass = const pageWrapperClass =
"flex items-center justify-center min-h-screen bg-background p-4" "flex items-center justify-center min-h-screen bg-background p-4"
@ -121,16 +186,34 @@ function AuthConnectContent() {
const router = useRouter() const router = useRouter()
const { data: session, isPending } = useSession() const { data: session, isPending } = useSession()
const { org, organizations, isRestoring } = useAuth() const { org, organizations, isRestoring } = useAuth()
const autumn = useCustomer()
const [status, setStatus] = useState<Status>("loading") const [status, setStatus] = useState<Status>("loading")
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [isUpgrading, setIsUpgrading] = useState(false)
const callback = params.get("callback") const callback = params.get("callback")
const client = params.get("client") const client = params.get("client")
const validClient = client && client in PLUGIN_INFO ? client : null const clientsParam = params.get("clients")
const displayName = validClient ? getPluginName(validClient) : "External Tool" const hasClientList = params.has("clients")
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null const rawRequestedClients = useMemo(
() =>
(clientsParam !== null ? clientsParam.split(",") : client ? [client] : [])
.map((value) => value.trim())
.filter(Boolean),
[client, clientsParam],
)
const requestedClients = useMemo(
() => Array.from(new Set(rawRequestedClients.filter(isKnownPlugin))),
[rawRequestedClients],
)
const invalidClients = useMemo(
() => rawRequestedClients.filter((value) => !isKnownPlugin(value)),
[rawRequestedClients],
)
const validClient = requestedClients[0] ?? null
const displayName = formatPluginNames(requestedClients)
const pluginInfo =
requestedClients.length === 1 && validClient
? PLUGIN_INFO[validClient]
: null
// Redirect new users (logged in but no organization) to onboarding. // Redirect new users (logged in but no organization) to onboarding.
// Store the current connect URL so onboarding can redirect back here. // Store the current connect URL so onboarding can redirect back here.
@ -166,6 +249,16 @@ function AuthConnectContent() {
setError("Invalid callback URL.") setError("Invalid callback URL.")
return return
} }
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
return
}
if (requestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (!session || !org) { if (!session || !org) {
setStatus("error") setStatus("error")
setError( setError(
@ -177,17 +270,13 @@ function AuthConnectContent() {
try { try {
setStatus("creating") setStatus("creating")
const fetchParams = new URLSearchParams({ callback }) const fetchParams = new URLSearchParams({ callback })
if (validClient) fetchParams.set("client", validClient) fetchParams.set("client", requestedClients[0] ?? "")
const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, { const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, {
credentials: "include", credentials: "include",
}) })
if (!res.ok) { if (!res.ok) {
if (res.status === 403) {
setStatus("upgrade")
return
}
const errorData = (await res.json().catch(() => ({}))) as { const errorData = (await res.json().catch(() => ({}))) as {
message?: string message?: string
} }
@ -198,7 +287,21 @@ function AuthConnectContent() {
setStatus("success") setStatus("success")
const redirectUrl = new URL(callback) const redirectUrl = new URL(callback)
redirectUrl.searchParams.set("apikey", data.key) if (hasClientList) {
redirectUrl.searchParams.set(
"keys",
encodeBase64UrlJson(
Object.fromEntries(
requestedClients.map((requestedClient) => [
requestedClient,
data.key,
]),
),
),
)
} else {
redirectUrl.searchParams.set("apikey", data.key)
}
redirectUrl.searchParams.set("api_url", API_URL) redirectUrl.searchParams.set("api_url", API_URL)
window.location.href = redirectUrl.toString() window.location.href = redirectUrl.toString()
} catch (err) { } catch (err) {
@ -208,23 +311,23 @@ function AuthConnectContent() {
} }
} }
async function handleUpgrade() {
try {
setIsUpgrading(true)
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
await autumn.attach({
planId: "api_pro",
successUrl: safeSuccessUrl,
})
} catch (err) {
console.error("Upgrade failed:", err)
setIsUpgrading(false)
}
}
// Show a spinner while session/org data is loading or while we're about // Show a spinner while session/org data is loading or while we're about
// to redirect to onboarding (prevents a brief flash of the connect card). // to redirect to onboarding (prevents a brief flash of the connect card).
const isAuthLoading = isPending || isRestoring || organizations === null const isAuthLoading = isPending || isRestoring || organizations === null
useEffect(() => {
if (status !== "loading") return
if (rawRequestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
}
}, [invalidClients, rawRequestedClients.length, status])
if (isAuthLoading || shouldRedirectToOnboarding) { if (isAuthLoading || shouldRedirectToOnboarding) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-background"> <div className="flex items-center justify-center min-h-screen bg-background">
@ -238,19 +341,7 @@ function AuthConnectContent() {
<div className={pageWrapperClass}> <div className={pageWrapperClass}>
<div className={cardClass}> <div className={cardClass}>
<div className="flex flex-col items-center gap-5"> <div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]"> <PluginLogoStack clients={requestedClients} />
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<div className="text-center"> <div className="text-center">
<h2 <h2
className={dmSans125ClassName( className={dmSans125ClassName(
@ -265,13 +356,15 @@ function AuthConnectContent() {
)} )}
> >
{pluginInfo?.description ?? {pluginInfo?.description ??
`Allow ${displayName} to access your Supermemory account.`} (requestedClients.length > 1
? "Use one Supermemory account across these plugins."
: `Use your Supermemory account with ${displayName}.`)}
</p> </p>
</div> </div>
{pluginInfo && ( <ul className="w-full space-y-2.5">
<ul className="w-full space-y-2.5"> {(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map(
{pluginInfo.features.map((feature) => ( (feature) => (
<li key={feature} className="flex items-start gap-2.5"> <li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" /> <ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span <span
@ -282,16 +375,16 @@ function AuthConnectContent() {
{feature} {feature}
</span> </span>
</li> </li>
))} ),
</ul> )}
)} </ul>
<button <button
type="button" type="button"
onClick={handleConnect} onClick={handleConnect}
className={cn( className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center", "relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]", "text-[#FAFAFA] font-medium text-[14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]", "shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"cursor-pointer transition-opacity hover:opacity-90", "cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(), dmSans125ClassName(),
@ -311,104 +404,6 @@ function AuthConnectContent() {
</div> </div>
) )
} }
if (status === "upgrade") {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<div className="text-center">
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
{pluginInfo?.name ?? displayName}
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{pluginInfo?.description ??
`A paid plan is required to use ${displayName} with Supermemory.`}
</p>
</div>
{pluginInfo && (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName(
"text-[13px] text-[#8B8B8B]",
)}
>
{feature}
</span>
</li>
))}
</ul>
)}
<button
type="button"
onClick={handleUpgrade}
disabled={isUpgrading || autumn.isLoading}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading || autumn.isLoading ? (
<>
<Loader className="size-4 animate-spin mr-2" />
Upgrading
</>
) : (
"Upgrade to Pro \u2014 $19/month"
)}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
<a
href="https://app.supermemory.ai/settings#billing"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
>
View all plans
</a>
</div>
</div>
</div>
)
}
if (status === "error") { if (status === "error") {
return ( return (
<div className={pageWrapperClass}> <div className={pageWrapperClass}>
@ -435,7 +430,7 @@ function AuthConnectContent() {
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<button <button
type="button" type="button"
onClick={() => window.location.reload()} onClick={() => void handleConnect()}
className={cn( className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4", "w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]", "bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",

View file

@ -2747,11 +2747,6 @@ export function IntegrationsView({
credentials: "include", credentials: "include",
}) })
if (!res.ok) { if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as { const errorData = (await res.json().catch(() => ({}))) as {
message?: string message?: string
} }

View file

@ -415,48 +415,10 @@ function PluginRow({
) )
} }
type TierFilter = "all" | "pro" | "free"
const TIER_FILTERS: { value: TierFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "pro", label: "Pro" },
{ value: "free", label: "Free" },
]
function TierFilterToggle({
value,
onChange,
}: {
value: TierFilter
onChange: (value: TierFilter) => void
}) {
return (
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
{TIER_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => onChange(filter.value)}
className={cn(
dmSans125ClassName(),
"rounded-full px-3 h-7 text-[12px] font-medium transition-colors",
value === filter.value
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
{filter.label}
</button>
))}
</div>
)
}
export function PluginsDetail() { export function PluginsDetail() {
const { org } = useAuth() const { org } = useAuth()
const autumn = useCustomer() const autumn = useCustomer()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null) const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>( const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null, null,
@ -572,11 +534,6 @@ export function PluginsDetail() {
credentials: "include", credentials: "include",
}) })
if (!res.ok) { if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as { const errorData = (await res.json().catch(() => ({}))) as {
message?: string message?: string
} }
@ -635,17 +592,12 @@ export function PluginsDetail() {
) )
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
const filtered = catalogRows.filter((id) => {
if (tierFilter === "free") return isFreeTierPlugin(id)
if (tierFilter === "pro") return !isFreeTierPlugin(id)
return true
})
// Connected plugins float to the top (stable within each group). // Connected plugins float to the top (stable within each group).
return [...filtered].sort( return [...catalogRows].sort(
(a, b) => (a, b) =>
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)), Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
) )
}, [catalogRows, tierFilter, connectedPluginIds]) }, [catalogRows, connectedPluginIds])
const dialogPlugin = newKey.pluginId const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId] ? PLUGIN_CATALOG[newKey.pluginId]
@ -684,12 +636,7 @@ export function PluginsDetail() {
)} )}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3"> <SectionHeader>Plugins</SectionHeader>
<SectionHeader>Plugins</SectionHeader>
{catalogRows.length > 0 && (
<TierFilterToggle value={tierFilter} onChange={setTierFilter} />
)}
</div>
<div className="flex flex-col"> <div className="flex flex-col">
{visibleRows.map((pluginId) => { {visibleRows.map((pluginId) => {
const plugin = PLUGIN_CATALOG[pluginId] const plugin = PLUGIN_CATALOG[pluginId]

View file

@ -149,11 +149,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
credits: "$20", credits: "$20",
productId: "api_pro", productId: "api_pro",
description: "For people building with AI memory", description: "For people building with AI memory",
features: [ features: ["Auto top-up when balance runs low", "Priority support"],
"Auto top-up when balance runs low",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support",
],
}, },
{ {
id: "max", id: "max",

View file

@ -394,11 +394,6 @@ export function SelectSpacesModal({
credentials: "include", credentials: "include",
}) })
if (!res.ok) { if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as { const errorData = (await res.json().catch(() => ({}))) as {
message?: string message?: string
} }

View file

@ -137,6 +137,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [ features: [
"Pay-as-you-go after $5 runs out", "Pay-as-you-go after $5 runs out",
"Full search and memory access", "Full search and memory access",
"All plugins (Claude Code, Cursor, Hermes...)",
"Email support", "Email support",
], ],
}, },
@ -151,7 +152,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [ features: [
"Auto top-up when balance runs low", "Auto top-up when balance runs low",
"Google Drive, Notion, OneDrive & Granola connectors", "Google Drive, Notion, OneDrive & Granola connectors",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support", "Priority support",
], ],
}, },