fix(web): org plan badges via org-summaries and refresh Nova chat empty state (#968)

Use /v3/auth/org-summaries for per-org plan tiers in settings (same as console v2).
Extract chat empty state, add space-aware subtitle, and use AutoSpaceIcon in selectors.
This commit is contained in:
MaheshtheDev 2026-05-18 23:44:09 +00:00
parent 6448849f77
commit ac43fe157f
7 changed files with 220 additions and 108 deletions

View file

@ -0,0 +1,84 @@
"use client"
import { Search } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
export const DEFAULT_CHAT_PROMPTS = [
"What do you know about me?",
"What have I been working on lately?",
"What themes keep showing up in my memories?",
] as const
const SUGGESTION_PILL_CLASS = cn(
"inline-flex max-w-full items-center gap-2 rounded-full border border-[#2261CA33] bg-[#041127]",
"px-3 py-2 text-left transition-colors cursor-pointer",
"hover:border-[#3374FF]/55 hover:bg-[#0A1A3A] hover:[&_span]:text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3374FF]/40",
)
export function ChatEmptyStatePlaceholder({
onSuggestionClick,
suggestions = [...DEFAULT_CHAT_PROMPTS],
subtitle,
}: {
onSuggestionClick: (suggestion: string) => void
suggestions?: string[]
subtitle?: string
}) {
const prompts = suggestions.slice(0, 3)
return (
<div
id="chat-empty-state"
className={cn(
"flex min-h-full items-center justify-center px-4 py-10",
dmSansClassName(),
)}
>
<div className="flex w-full max-w-[min(100%,360px)] flex-col items-center gap-5">
<div className="flex flex-col items-center gap-3 text-center">
<NovaOrb size={44} className="blur-[1px]!" />
<p
className={cn(
"text-lg font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Nova knows you.
</p>
{subtitle ? (
<p className="max-w-[280px] text-sm leading-snug text-[#737373]">
{subtitle}
</p>
) : null}
</div>
<div className="flex w-full flex-col items-center gap-2">
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-[#525966]">
Try asking
</p>
<div className="flex w-full flex-col items-center gap-2">
{prompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onSuggestionClick(prompt)}
className={SUGGESTION_PILL_CLASS}
>
<Search
className="size-3.5 shrink-0 text-[#4BA0FA]"
aria-hidden
/>
<span className="text-[12px] font-medium leading-snug text-[#4BA0FA]">
{prompt}
</span>
</button>
))}
</div>
</div>
</div>
</div>
)
}

View file

@ -1,6 +1,8 @@
"use client"
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
import { useQueryState } from "nuqs"
import type { UIMessage } from "@ai-sdk/react"
import { motion } from "motion/react"
@ -49,83 +51,7 @@ import { generateId } from "@lib/generate-id"
import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
const DEFAULT_CHAT_PROMPTS = [
"What do you know about me?",
"What have I been working on lately?",
"What themes keep showing up in my memories?",
]
const chatEmptyCardClass = cn(
"flex min-h-[76px] flex-col justify-between rounded-lg border border-[#2B3038] bg-[#14161A]/95 p-3 text-left md:min-h-[88px]",
"shadow-[0_18px_50px_rgba(0,0,0,0.32),inset_0_1px_0_rgba(255,255,255,0.04)]",
"transition-colors hover:border-[#3374FF]/55 hover:bg-[#1A1F26] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3374FF]/70",
)
function ChatEmptyStatePlaceholder({
onSuggestionClick,
suggestions = DEFAULT_CHAT_PROMPTS,
}: {
onSuggestionClick: (suggestion: string) => void
suggestions?: string[]
}) {
const promptCards = suggestions.slice(0, 3)
return (
<div
id="chat-empty-state"
className="relative flex min-h-full items-center justify-center overflow-hidden px-0 py-6 md:px-3"
>
<div
className="pointer-events-none absolute inset-x-[-1rem] inset-y-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.28)_1px,transparent_1px)] bg-size-[32px_32px] opacity-80 mask-[radial-gradient(ellipse_at_center,black_52%,transparent_100%)]"
aria-hidden
/>
<div
className="pointer-events-none absolute inset-x-[-1rem] bottom-0 h-2/3 bg-[radial-gradient(ellipse_at_bottom,rgba(20,65,255,0.42),transparent_68%)]"
aria-hidden
/>
<div className="relative z-10 flex w-full max-w-xl flex-col items-center text-center">
<NovaOrb size={52} className="mb-3 blur-[1.5px]!" />
<h2
className={cn(
"mb-1 max-w-[420px] text-[24px] font-medium leading-[1.12] tracking-normal text-white md:text-[30px]",
dmSansClassName(),
)}
>
Nova knows you.
</h2>
<p
className={cn(
"mb-4 max-w-[420px] text-[14px] leading-5 text-[#8B8B8B] md:text-[15px]",
dmSansClassName(),
)}
>
<span className="text-[#FAFAFA]">
Your personal memories are all here.
</span>{" "}
Chat with supermemory and ask about...
</p>
<div className="mb-3 grid w-full grid-cols-1 gap-2.5 sm:grid-cols-3">
{promptCards.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={() => onSuggestionClick(suggestion)}
className={chatEmptyCardClass}
>
<span className="flex size-5 items-center justify-center rounded-full border border-[#3374FF]/35 bg-[#071B3A] text-[11px] font-medium text-[#4BA0FA]">
{index + 1}
</span>
<span className="mt-2 line-clamp-3 text-[13px] font-medium leading-[18px] text-white md:text-[14px] md:leading-5">
{suggestion}
</span>
</button>
))}
</div>
</div>
</div>
)
}
import { ChatEmptyStatePlaceholder } from "./chat-empty-state"
export function ChatLaunchFab({
onOpen,
@ -243,6 +169,43 @@ export function ChatSidebar({
}),
[chatProject, allProjects],
)
const isAutoChatSpace = chatProject === AUTO_CHAT_SPACE_ID
const { data: chatSpaceMemoryCount } = useQuery({
queryKey: ["chat-empty-space-count", chatProject],
queryFn: async (): Promise<number> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 1,
sort: "createdAt",
order: "desc",
containerTags: [chatProject],
},
disableValidation: true,
})
if (response.error) return 0
const data = response.data as {
pagination?: { totalItems?: number }
} | null
return data?.pagination?.totalItems ?? 0
},
staleTime: 30 * 1000,
enabled: !!chatProject && !isAutoChatSpace,
})
const emptyStateSubtitle = useMemo(() => {
if (isAutoChatSpace) {
return "Picks the best space for each question"
}
if (chatSpaceMemoryCount === undefined) {
return `Grounded in ${chatSpaceLabel}`
}
if (chatSpaceMemoryCount === 0) {
return `Nothing in ${chatSpaceLabel} yet`
}
const countLabel = chatSpaceMemoryCount.toLocaleString()
const memoryWord = chatSpaceMemoryCount === 1 ? "memory" : "memories"
return `${countLabel} ${memoryWord} in ${chatSpaceLabel}`
}, [isAutoChatSpace, chatSpaceLabel, chatSpaceMemoryCount])
const { viewMode } = useViewMode()
const { user: _user } = useAuth()
const [threadId, setThreadId] = useQueryState("thread", threadParam)
@ -1011,6 +974,7 @@ export function ChatSidebar({
<ChatEmptyStatePlaceholder
onSuggestionClick={handleSuggestedQuestion}
suggestions={emptyStateSuggestions}
subtitle={emptyStateSubtitle}
/>
)}
<div
@ -1246,3 +1210,4 @@ export function ChatSidebar({
}
export { HomeChatComposer } from "./home-chat-composer"
export { ChatEmptyStatePlaceholder } from "./chat-empty-state"

View file

@ -0,0 +1,37 @@
"use client"
import { Shuffle } from "lucide-react"
import NovaOrb from "./nova-orb"
import { cn } from "@lib/utils"
/** Nova orb with a corner badge — Auto mode (Nova picks across spaces). */
export function AutoSpaceIcon({
size = 20,
className,
}: {
size?: number
className?: string
}) {
const badgeSize = Math.max(10, Math.round(size * 0.5))
const badgeIcon = Math.max(6, Math.round(badgeSize * 0.55))
return (
<span
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
aria-hidden
>
<NovaOrb size={size} className="blur-[0.45px]!" />
<span
className="absolute -bottom-px -right-px flex items-center justify-center rounded-full bg-[#4BA0FA] text-[#041127] ring-1 ring-[#14161A]"
style={{ width: badgeSize, height: badgeSize }}
>
<Shuffle
className="shrink-0"
style={{ width: badgeIcon, height: badgeIcon }}
strokeWidth={2.5}
/>
</span>
</span>
)
}

View file

@ -19,7 +19,6 @@ import {
Loader,
Pencil,
Check,
Sparkles,
} from "lucide-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
@ -47,6 +46,7 @@ import { InstallSteps, PillButton } from "./integrations/install-steps"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
interface SelectSpacesModalProps {
isOpen: boolean
@ -813,12 +813,7 @@ export function SelectSpacesModal({
onClick={handleSelectAuto}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0"
>
<span
className="shrink-0 flex h-5 w-5 items-center justify-center rounded-[6px] bg-[#071B3A] text-[#4BA0FA]"
aria-hidden
>
<Sparkles className="size-3.5" />
</span>
<AutoSpaceIcon size={20} />
<span className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium">
Auto
<span className="ml-1.5 text-[12px] text-[#737373]">

View file

@ -7,9 +7,9 @@ import {
useAccountMemberships,
useDeleteUserAccount,
} from "@/hooks/use-account-settings"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import {
normalizePlanType,
PLAN_DISPLAY_NAMES,
PLAN_RANK,
useTokenUsage,
@ -216,18 +216,14 @@ function OrgPlanBadge({ plan }: { plan: PlanType }) {
function resolveOrgPlan(
orgId: string,
organization: { metadata?: unknown },
isCurrent: boolean,
currentPlan: PlanType,
membershipPlanByOrgId: Map<string, PlanType>,
planByOrgId: Map<string, PlanType>,
): PlanType {
const fromSummary = planByOrgId.get(orgId)
if (fromSummary) return fromSummary
if (isCurrent) return currentPlan
const fromMembership = membershipPlanByOrgId.get(orgId)
if (fromMembership) return fromMembership
const metadata = organization.metadata as Record<string, unknown> | null
return normalizePlanType(metadata?.plan ?? metadata?.subscriptionPlan)
return "free"
}
export default function Account() {
@ -250,6 +246,7 @@ export default function Account() {
const canSwitchOrg = (allOrgs?.length ?? 0) > 1
const { data: memberships, isPending: membershipsPending } =
useAccountMemberships()
const { data: orgSummaries } = useOrgSummaries()
const sortedMemberships = useMemo(() => {
if (!memberships?.length) return []
@ -306,38 +303,34 @@ export default function Account() {
const planDisplayNames = PLAN_DISPLAY_NAMES
const membershipPlanByOrgId = useMemo(() => {
const planByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
for (const membership of memberships ?? []) {
if (membership.plan) {
map.set(membership.orgId, normalizePlanType(membership.plan))
}
for (const summary of orgSummaries ?? []) {
map.set(summary.orgId, summary.plan)
}
return map
}, [memberships])
}, [orgSummaries])
const sortedOrgsForMenu = useMemo(() => {
if (!allOrgs?.length) return []
return [...allOrgs].sort((a, b) => {
const planA = resolveOrgPlan(
a.id,
a,
a.id === org?.id,
currentPlan,
membershipPlanByOrgId,
planByOrgId,
)
const planB = resolveOrgPlan(
b.id,
b,
b.id === org?.id,
currentPlan,
membershipPlanByOrgId,
planByOrgId,
)
const rankDiff = PLAN_RANK[planB] - PLAN_RANK[planA]
if (rankDiff !== 0) return rankDiff
return a.name.localeCompare(b.name)
})
}, [allOrgs, org?.id, currentPlan, membershipPlanByOrgId])
}, [allOrgs, org?.id, currentPlan, planByOrgId])
// Handlers
const handleUpgrade = async () => {
@ -508,10 +501,9 @@ export default function Account() {
const isSwitching = switchingOrgId === organization.id
const plan = resolveOrgPlan(
organization.id,
organization,
isCurrent,
currentPlan,
membershipPlanByOrgId,
planByOrgId,
)
return (
<button

View file

@ -7,7 +7,7 @@ import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { ChevronDownIcon, Sparkles, XIcon, Loader2, Trash2 } from "lucide-react"
import { ChevronDownIcon, XIcon, Loader2, Trash2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { AddSpaceModal } from "./add-space-modal"
@ -41,6 +41,7 @@ import {
import { detectPluginSpace, pluginInitial } from "@/lib/plugin-space"
import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
export interface SpaceSelectorProps {
selectedProjects: string[]
@ -386,7 +387,7 @@ export function SpaceSelector({
)}
>
{displayInfo.isAuto ? (
<Sparkles className="size-3.5 shrink-0 text-[#4BA0FA]" />
<AutoSpaceIcon size={compact ? 16 : 18} />
) : displayInfo.isOwnSpace ? (
<NovaOrb
size={compact ? 14 : 16}

View file

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query"
import { useAuth } from "@lib/auth-context"
import { normalizePlanType, type PlanType } from "@/hooks/use-token-usage"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export type OrgSummary = {
orgId: string
plan: PlanType
activeConnectors: number
containerTagCount: number
documentCount: number
}
export function useOrgSummaries() {
const { user } = useAuth()
return useQuery({
queryKey: ["account", "org-summaries"],
queryFn: async (): Promise<OrgSummary[]> => {
const res = await fetch(`${API_BASE}/v3/auth/org-summaries`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
})
if (!res.ok) {
throw new Error("Failed to load organization plans")
}
const data = (await res.json()) as { summaries: OrgSummary[] }
return (data.summaries ?? []).map((s) => ({
...s,
plan: normalizePlanType(s.plan),
}))
},
enabled: !!user?.id,
staleTime: 60 * 1000,
})
}