supermemory/apps/web/app/(app)/settings/page.tsx
MaheshtheDev 53c971ac85 feat(billing): add Max plan to nova with cancel/resume flow (#1024)
Integrate the new api_max plan ($100/mo, $130 credits) from mono into the
nova web app, mirroring mono's tier semantics.

- Tiers: insert api_max between pro and scale; resolves to Pro-tier for feature gates while displaying as "Max" (queries.ts, use-token-usage)
- Billing page: Max plan card ("Most Popular"), checkout/cancel/invoice wiring; advanced carousel page now 3-up (Max/Scale/Enterprise); Scale builds on Max
- Carousel auto-opens to the page holding the current plan (Max/Scale/ Enterprise users land on their own card instead of Free+Pro)
- Cancel: retention dialog listing what you lose + type-to-confirm "CANCEL"
- Cancel/resume state mirroring console: detect canceledAt-scheduled cancellation, show "Cancelling" pill + "Cancels on {date} · N days left", and a "Resume plan" (uncancel) action
2026-05-31 19:10:17 +00:00

756 lines
24 KiB
TypeScript

"use client"
import { Logo } from "@ui/assets/Logo"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { useAuth } from "@lib/auth-context"
import NovaOrb from "@/components/nova/nova-orb"
import { useState, useEffect, useRef, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
import Billing from "@/components/settings/billing"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
import Support from "@/components/settings/support"
import { ErrorBoundary } from "@/components/error-boundary"
import { useRouter } from "next/navigation"
import { useIsMobile } from "@hooks/use-mobile"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { analytics } from "@/lib/analytics"
import {
LogOut,
RotateCcw,
Trash2,
Sun,
LoaderIcon,
User as UserIcon,
Zap,
HelpCircle,
CreditCard,
ShieldAlert,
ChevronRight,
ChevronsUpDown,
Check,
Building2,
} from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
import { useCustomer } from "autumn-js/react"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import {
PLAN_DISPLAY_NAMES,
useTokenUsage,
type PlanType,
} from "@/hooks/use-token-usage"
const TABS = [
"account",
"billing",
"integrations",
"connections",
"support",
] as const
type SettingsTab = (typeof TABS)[number]
type NavItem = {
id: SettingsTab
label: string
description: string
icon: React.ReactNode
}
const NAV_ITEMS: NavItem[] = [
{
id: "account",
label: "Account",
description: "Your profile and organization",
icon: <UserIcon className="size-[18px]" />,
},
{
id: "billing",
label: "Billing",
description: "Plan, usage and payments",
icon: <CreditCard className="size-[18px]" />,
},
{
id: "integrations",
label: "Integrations",
description: "Save, sync and search across tools",
icon: <Sun className="size-[18px]" />,
},
{
id: "connections",
label: "Connections & MCP",
description: "Drive, Notion, OneDrive, MCP",
icon: <Zap className="size-[18px]" />,
},
{
id: "support",
label: "Support & Help",
description: "Get help or share feedback",
icon: <HelpCircle className="size-[18px]" />,
},
]
function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
? (cleaned as SettingsTab)
: "account"
}
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
function OrgPlanBadge({ plan }: { plan: PlanType }) {
return (
<span
className={cn(
dmSans125ClassName(),
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
ORG_PLAN_BADGE_STYLES[plan],
)}
>
{PLAN_DISPLAY_NAMES[plan]}
</span>
)
}
function resolveOrgPlan(
orgId: string,
isCurrent: boolean,
currentPlan: PlanType,
planByOrgId: Map<string, PlanType>,
): PlanType {
const fromSummary = planByOrgId.get(orgId)
if (fromSummary) return fromSummary
if (isCurrent) return currentPlan
return "free"
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 pt-3 pb-1.5 text-[10.5px] font-semibold tracking-[0.14em] text-white/30 uppercase">
{children}
</div>
)
}
function IdentityCard({ displayName }: { displayName: string }) {
const firstName = displayName?.split(" ")[0] || ""
return (
<div className="relative flex items-center justify-center h-[140px]">
<NovaOrb size={150} className="blur-[3px]!" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<Logo className="h-7 shrink-0 text-white" />
<div
className={cn(
"flex flex-col items-start justify-center ml-3.5",
dmSansClassName(),
)}
>
<p className="text-white text-[14px] font-medium leading-none">
{firstName ? `${firstName}'s` : "Your"}
</p>
<p className="text-white font-bold text-[20px] leading-none mt-1">
supermemory
</p>
</div>
</div>
</div>
)
}
export default function SettingsPage() {
const { user, org, organizations, setActiveOrg } = useAuth()
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
const hasInitialized = useRef(false)
const router = useRouter()
const isMobile = useIsMobile()
const localStorageUsername = useLocalStorageUsername()
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
const [resetConfirmation, setResetConfirmation] = useState("")
const resetOrganization = useResetOrganization()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
const deleteUserAccount = useDeleteUserAccount()
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false)
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const canSwitchOrg = (organizations?.length ?? 0) > 1
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const planByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
for (const summary of orgSummaries ?? []) {
map.set(summary.orgId, summary.plan)
}
return map
}, [orgSummaries])
const activeOrgPlan = org?.id
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
: currentPlan
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
if (orgId === org?.id) {
setOrgSwitcherOpen(false)
return
}
setSwitchingOrgId(orgId)
try {
await setActiveOrg(orgSlug)
window.location.reload()
} catch (error) {
console.error("Failed to switch organization:", error)
setSwitchingOrgId(null)
}
}
const handleLogout = async () => {
await authClient.signOut()
router.push("/login")
}
const handleDeleteAccount = async () => {
if (deleteEmailConfirm !== user?.email) return
deleteUserAccount.mutate(
{ confirmation: deleteEmailConfirm },
{
onSuccess: () => {
setIsDeleteDialogOpen(false)
setDeleteEmailConfirm("")
router.push("/login")
},
},
)
}
useEffect(() => {
if (hasInitialized.current) return
hasInitialized.current = true
const hash = window.location.hash
const tab = parseHashToTab(hash)
setActiveTab(tab)
analytics.settingsTabChanged({ tab })
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
window.history.pushState(null, "", "#account")
}
}, [])
useEffect(() => {
const handleHashChange = () => {
const tab = parseHashToTab(window.location.hash)
setActiveTab(tab)
analytics.settingsTabChanged({ tab })
}
window.addEventListener("hashchange", handleHashChange)
return () => window.removeEventListener("hashchange", handleHashChange)
}, [])
const headerDisplayName =
user?.displayUsername ||
localStorageUsername ||
user?.name ||
user?.email?.split("@")[0] ||
""
return (
<div className="h-screen flex flex-col overflow-hidden bg-[#08090C]">
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-8 py-3 shrink-0">
<nav
className={cn(
"flex items-center gap-2 sm:gap-3 min-w-0 text-sm",
dmSansClassName(),
)}
aria-label="Breadcrumb"
>
<button
type="button"
onClick={() => router.push("/")}
className={cn(
"flex items-center min-w-0 rounded-lg py-1 pr-2 -ml-1 pl-1",
"hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors cursor-pointer text-left",
)}
>
<Logo className="h-7 shrink-0" />
<span className="ml-2 font-semibold text-white/90 tracking-tight">
supermemory
</span>
</button>
<span className="text-white/30 shrink-0" aria-hidden>
/
</span>
<span className="text-white/55 font-medium shrink-0">Settings</span>
</nav>
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
{!isMobile &&
(canSwitchOrg ? (
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
"bg-white/[0.03] border border-white/[0.06]",
"cursor-pointer hover:bg-white/[0.06]",
dmSansClassName(),
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={8}
className={cn(
"w-[260px] max-h-80 overflow-y-auto p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
{[...(organizations ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingOrgId === organization.id
const plan = resolveOrgPlan(
organization.id,
isCurrent,
currentPlan,
planByOrgId,
)
return (
<button
key={organization.id}
type="button"
disabled={isCurrent || isSwitching}
onClick={() =>
handleOrgSwitch(organization.slug, organization.id)
}
className={cn(
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
isCurrent
? "bg-white/5"
: "hover:bg-white/5 cursor-pointer",
"disabled:cursor-default",
)}
>
<Building2 className="size-4 shrink-0 text-white/40" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
{organization.name}
</span>
{isSwitching ? (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
) : isCurrent ? (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
) : null}
<OrgPlanBadge plan={plan} />
</button>
)
})}
</PopoverContent>
</Popover>
) : (
<div
className={cn(
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
"bg-white/[0.03] border border-white/[0.06]",
dmSansClassName(),
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
</div>
))}
<UserProfileMenu />
</div>
</header>
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-10 px-4 md:px-8 pt-5 md:pt-7 pb-6 md:h-full md:w-full md:max-w-[1240px] md:mx-auto">
{/* Left rail */}
<aside className="md:flex md:h-full md:min-h-0 md:w-[280px] md:flex-col shrink-0">
{!isMobile && (
<div className="mb-4">
<IdentityCard displayName={headerDisplayName} />
</div>
)}
<nav
className={cn(
"flex",
isMobile
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
dmSansClassName(),
)}
>
{!isMobile && <SectionLabel>Organisation</SectionLabel>}
{NAV_ITEMS.map((item) => {
const isActive = activeTab === item.id
return (
<button
key={item.id}
type="button"
onClick={() => {
window.location.hash = item.id
setActiveTab(item.id)
analytics.settingsTabChanged({ tab: item.id })
}}
className={cn(
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
isActive
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
)}
>
{!isMobile && (
<span
aria-hidden
className={cn(
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
isActive
? "h-5 bg-[#4BA0FA]"
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
)}
/>
)}
<span
className={cn(
"shrink-0 transition-colors",
isActive ? "text-white" : "text-white/45",
)}
>
{item.icon}
</span>
<span className="font-medium text-[14px] whitespace-nowrap">
{item.label}
</span>
</button>
)
})}
{!isMobile && <div className="flex-1" />}
<button
type="button"
onClick={handleLogout}
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
)}
>
<LogOut className="size-[18px] shrink-0 text-white/45" />
<span className="font-medium text-[14px] whitespace-nowrap">
Log out
</span>
</button>
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
isMobile
? "px-3 py-2 text-sm border border-[#3A211C]"
: "text-left px-3 py-2.5 mt-1",
dangerMenuOpen
? "bg-[#1A0F0C] text-[#C73B1B]"
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
)}
>
<ShieldAlert className="size-[18px] shrink-0" />
<span className="font-medium text-[14px] whitespace-nowrap">
Danger zone
</span>
<ChevronRight
className={cn(
"size-4 ml-auto shrink-0 transition-transform opacity-60",
dangerMenuOpen ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
sideOffset={8}
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsResetDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
>
<RotateCcw className="size-[16px] shrink-0" />
<span className="font-medium text-[13.5px]">
Reset data
</span>
</button>
<div className="my-1 h-px bg-white/[0.06]" />
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
>
<Trash2 className="size-[16px] shrink-0" />
<span className="font-semibold text-[13.5px]">
Delete account
</span>
</button>
</PopoverContent>
</Popover>
</nav>
</aside>
{/* Content */}
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
<ErrorBoundary
key={activeTab}
fallback={
<p className="text-muted-foreground p-4">
Something went wrong loading this section.{" "}
<button
type="button"
className="underline cursor-pointer"
onClick={() => window.location.reload()}
>
Reload
</button>
</p>
}
>
{activeTab === "account" && <Account />}
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</section>
</div>
</main>
{/* Reset data dialog */}
{(() => {
const confirmText = org?.name || user?.name || ""
return (
<Dialog
open={isResetDialogOpen}
onOpenChange={(open) => {
setIsResetDialogOpen(open)
if (!open) setResetConfirmation("")
}}
>
<DialogContent className="sm:max-w-md">
<div
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Reset all data?
</h2>
<p className="text-sm text-[#8B8B8B]">
This permanently removes:
</p>
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
<li>All documents and memories</li>
<li>All connections (Google Drive, Notion, etc.)</li>
<li>All custom spaces (default space stays)</li>
<li>Organization settings and filters</li>
</ul>
<p className="text-sm text-[#8B8B8B] mt-1">
Your account and billing plan stay intact.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type{" "}
<strong className="text-[#FAFAFA]">
{confirmText || "your name"}
</strong>{" "}
to confirm:
</p>
<input
type="text"
value={resetConfirmation}
onChange={(e) => setResetConfirmation(e.target.value)}
placeholder={confirmText || "Your name"}
autoComplete="off"
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
!confirmText ||
resetConfirmation !== confirmText ||
resetOrganization.isPending
}
title={
!confirmText || resetConfirmation !== confirmText
? `Type "${confirmText || "your name"}" exactly to enable`
: undefined
}
onClick={() =>
resetOrganization.mutate(
{ confirmation: confirmText },
{
onSuccess: () => {
setIsResetDialogOpen(false)
setResetConfirmation("")
},
},
)
}
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{resetOrganization.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<RotateCcw className="size-[15px]" />
)}
{resetOrganization.isPending
? "Resetting…"
: "Reset organization"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
)
})()}
{/* Delete account dialog */}
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open)
if (!open) setDeleteEmailConfirm("")
}}
>
<DialogContent className="sm:max-w-md">
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Delete your account?
</h2>
<p className="text-sm text-[#8B8B8B]">
Permanently deletes all your data and cancels any active
subscriptions.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type your email{" "}
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
confirm:
</p>
<input
type="email"
value={deleteEmailConfirm}
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
placeholder={user?.email ?? "your@email.com"}
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
deleteEmailConfirm !== user?.email ||
deleteUserAccount.isPending
}
title={
deleteEmailConfirm !== user?.email
? `Type ${user?.email ?? "your email"} exactly to confirm`
: undefined
}
onClick={handleDeleteAccount}
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{deleteUserAccount.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Trash2 className="size-[15px]" />
)}
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}