mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 07:42:43 +00:00
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:
parent
6448849f77
commit
ac43fe157f
7 changed files with 220 additions and 108 deletions
84
apps/web/components/chat/chat-empty-state.tsx
Normal file
84
apps/web/components/chat/chat-empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
37
apps/web/components/nova/auto-space-icon.tsx
Normal file
37
apps/web/components/nova/auto-space-icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
38
apps/web/hooks/use-org-summaries.ts
Normal file
38
apps/web/hooks/use-org-summaries.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue