mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
Merge branch 'main' into nova_chat
This commit is contained in:
commit
80a237a14e
21 changed files with 1141 additions and 679 deletions
|
|
@ -7,7 +7,7 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>State-of-the-art memory and context engine for AI.</strong>
|
||||
<strong>State-of-the-art memory and context engine for AI. And yes - you can use it as a company/personal brain.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
|
|||
|
|
@ -175,10 +175,21 @@ function App() {
|
|||
setShowProjectSelector(true)
|
||||
}
|
||||
|
||||
// Reconcile stored default against live list: reset if deleted, refresh if renamed.
|
||||
useEffect(() => {
|
||||
if (!defaultProject && projects.length > 0) {
|
||||
const firstProject = projects[0]
|
||||
setDefaultProjectMutation.mutate(firstProject)
|
||||
if (projects.length === 0) return
|
||||
if (!defaultProject) {
|
||||
setDefaultProjectMutation.mutate(projects[0])
|
||||
return
|
||||
}
|
||||
const live = projects.find((p) => p.id === defaultProject.id)
|
||||
if (!live) {
|
||||
setDefaultProjectMutation.mutate(projects[0])
|
||||
} else if (
|
||||
live.name !== defaultProject.name ||
|
||||
live.containerTag !== defaultProject.containerTag
|
||||
) {
|
||||
setDefaultProjectMutation.mutate(live)
|
||||
}
|
||||
}, [defaultProject, projects, setDefaultProjectMutation])
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default defineConfig({
|
|||
manifest: {
|
||||
name: "supermemory",
|
||||
homepage_url: "https://supermemory.ai",
|
||||
version: "6.1.3",
|
||||
version: "6.1.4",
|
||||
permissions: ["storage", "activeTab", "webRequest", "tabs"],
|
||||
host_permissions: [
|
||||
"*://x.com/*",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import {
|
|||
CheckCircle2,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { analytics, type OnboardingStep } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
type DetectedSource = "x" | "linkedin" | "resume" | null
|
||||
|
|
@ -378,6 +378,13 @@ function isAccountSource(source: DetectedSource): source is "x" | "linkedin" {
|
|||
return source === "x" || source === "linkedin"
|
||||
}
|
||||
|
||||
const STATUS_TO_STEP: Record<Status, OnboardingStep> = {
|
||||
idle: "profile_input",
|
||||
processing: "processing",
|
||||
done: "done",
|
||||
error: "error",
|
||||
}
|
||||
|
||||
function useSpotlightAutoRotation(
|
||||
status: Status,
|
||||
pauseSpotlight: boolean,
|
||||
|
|
@ -555,6 +562,7 @@ export default function OnboardingPage() {
|
|||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const skippingRef = useRef(false)
|
||||
const completedTrackedRef = useRef(false)
|
||||
const [isSkipping, setIsSkipping] = useState(false)
|
||||
const [spotlightCategory, setSpotlightCategory] =
|
||||
useState<SpotlightCategoryId>("productivity")
|
||||
|
|
@ -591,6 +599,21 @@ export default function OnboardingPage() {
|
|||
usePollingCleanup(pollingRef)
|
||||
useDoneAnimation(status, setStampLanded, setVisibleSnippets)
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: fire per status transition only
|
||||
useEffect(() => {
|
||||
analytics.onboardingStepViewed({
|
||||
step: STATUS_TO_STEP[status],
|
||||
trigger: "auto",
|
||||
})
|
||||
if (status === "done" && !completedTrackedRef.current) {
|
||||
completedTrackedRef.current = true
|
||||
analytics.onboardingCompleted({
|
||||
source: isAccountSource(detected) ? detected : undefined,
|
||||
memories_count: memoriesCount,
|
||||
})
|
||||
}
|
||||
}, [status])
|
||||
|
||||
const handleChange = (v: string) => {
|
||||
setValue(v)
|
||||
setDetected(detectSource(v))
|
||||
|
|
@ -619,6 +642,7 @@ export default function OnboardingPage() {
|
|||
if (skippingRef.current) return
|
||||
skippingRef.current = true
|
||||
setIsSkipping(true)
|
||||
analytics.onboardingSkipped({ from_step: STATUS_TO_STEP[status] })
|
||||
try {
|
||||
await ensureOrg()
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
|
|
@ -628,7 +652,7 @@ export default function OnboardingPage() {
|
|||
skippingRef.current = false
|
||||
setIsSkipping(false)
|
||||
}
|
||||
}, [ensureOrg, router])
|
||||
}, [ensureOrg, router, status])
|
||||
|
||||
const pollDocument = useCallback((docId: string) => {
|
||||
const maxAttempts = 60
|
||||
|
|
@ -688,6 +712,7 @@ export default function OnboardingPage() {
|
|||
|
||||
const handleSubmit = useCallback(
|
||||
async (source: "x" | "linkedin" | "resume", resumeFileOverride?: File) => {
|
||||
analytics.onboardingProfileSubmitted({ source })
|
||||
setStatus("processing")
|
||||
setSpotlightCategory("productivity")
|
||||
setPauseSpotlight(false)
|
||||
|
|
|
|||
|
|
@ -572,7 +572,7 @@ export default function NewPage() {
|
|||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
const showBottomNav = isMobile && !isChatView && !!session
|
||||
const showBottomNav = isMobile && !!session
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
|
|
|
|||
|
|
@ -140,14 +140,11 @@ export default function LoginPage() {
|
|||
)
|
||||
return
|
||||
}
|
||||
router.replace("/")
|
||||
}, [
|
||||
sessionPending,
|
||||
sessionData?.session,
|
||||
oauthQueryForResume,
|
||||
params,
|
||||
router,
|
||||
])
|
||||
// Carry the flag so the dashboard posts the session token to the extension (else: sign-in loop).
|
||||
const dest = new URL("/", window.location.origin)
|
||||
dest.searchParams.set("extension-auth-success", "true")
|
||||
window.location.assign(dest.toString())
|
||||
}, [sessionPending, sessionData?.session, oauthQueryForResume, params])
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect")
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const viewport: Viewport = {
|
|||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
interactiveWidget: "resizes-content",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
} from "@ui/components/sheet"
|
||||
import { ScrollArea } from "@ui/components/scroll-area"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronDownIcon,
|
||||
HistoryIcon,
|
||||
|
|
@ -112,7 +111,7 @@ const CHAT_QUEUE_LIMIT = 5
|
|||
|
||||
export function ChatSidebar({
|
||||
isChatOpen,
|
||||
setIsChatOpen,
|
||||
setIsChatOpen: _setIsChatOpen,
|
||||
queuedMessage,
|
||||
queuedHighlightContent,
|
||||
onConsumeQueuedMessage,
|
||||
|
|
@ -1147,25 +1146,13 @@ export function ChatSidebar({
|
|||
"flex items-center justify-between px-0 z-10",
|
||||
isPageDesktop
|
||||
? "relative shrink-0 pt-2 pb-1"
|
||||
: "absolute top-0 right-0 left-0 pt-4 px-4",
|
||||
: isMobile
|
||||
? "relative shrink-0 px-4 pt-4 pb-2"
|
||||
: "absolute top-0 right-0 left-0 pt-4 px-4",
|
||||
!isMobile && !isPageDesktop && "rounded-t-2xl",
|
||||
)}
|
||||
>
|
||||
<div className="mr-2 flex min-w-0 flex-1 items-center gap-2">
|
||||
{layout === "page" && isMobile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="headers"
|
||||
className="h-10! w-10! shrink-0 cursor-pointer rounded-full border-[#73737333] bg-[#0D121A] p-0!"
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
onClick={() => setIsChatOpen(false)}
|
||||
aria-label="Back to memories"
|
||||
>
|
||||
<ArrowLeft className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
)}
|
||||
{!isStackedInput && (
|
||||
<>
|
||||
<ChatModelSelector
|
||||
|
|
@ -1220,7 +1207,7 @@ export function ChatSidebar({
|
|||
messages.length > 0
|
||||
? cn(
|
||||
"flex flex-col space-y-3 min-h-full justify-end",
|
||||
isPageDesktop ? "pt-2" : "pt-14",
|
||||
isPageDesktop || isMobile ? "pt-2" : "pt-14",
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
|
@ -1406,7 +1393,9 @@ export function ChatSidebar({
|
|||
className={cn(
|
||||
"shrink-0",
|
||||
isStackedInput &&
|
||||
"pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
|
||||
(isMobile
|
||||
? "px-4 pb-2"
|
||||
: "px-4 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6"),
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
|
|
@ -1466,35 +1455,25 @@ export function ChatSidebar({
|
|||
className={cn(
|
||||
"relative flex flex-col backdrop-blur-md",
|
||||
isMobile
|
||||
? "fixed inset-0 z-50 m-0 h-dvh w-full rounded-none pb-safe"
|
||||
? "flex h-full min-h-0 w-full flex-1 flex-col rounded-none"
|
||||
: isPageDesktop
|
||||
? "flex h-full min-h-0 w-full min-w-0 flex-1 flex-col basis-0 rounded-none border-x-0"
|
||||
: "m-4 mt-2 w-[min(450px,calc(100vw-2rem))] md:w-[380px] lg:w-[450px] rounded-2xl",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={
|
||||
isMobile
|
||||
isMobile || isPageDesktop
|
||||
? undefined
|
||||
: isPageDesktop
|
||||
? undefined
|
||||
: {
|
||||
height: `calc(100vh - ${heightOffset}px)`,
|
||||
}
|
||||
: {
|
||||
height: `calc(100vh - ${heightOffset}px)`,
|
||||
}
|
||||
}
|
||||
initial={
|
||||
isMobile
|
||||
? { y: "100%", opacity: 0 }
|
||||
: layout === "page"
|
||||
? { opacity: 0, y: 20 }
|
||||
: { x: "100px", opacity: 0 }
|
||||
layout === "page" ? { opacity: 0, y: 20 } : { x: "100px", opacity: 0 }
|
||||
}
|
||||
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||
exit={
|
||||
isMobile
|
||||
? { y: "100%", opacity: 0 }
|
||||
: layout === "page"
|
||||
? { opacity: 0, y: 12 }
|
||||
: { x: "100px", opacity: 0 }
|
||||
layout === "page" ? { opacity: 0, y: 12 } : { x: "100px", opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -169,9 +169,11 @@ function VersionStatus({
|
|||
export function GraphListMemories({
|
||||
memoryEntries,
|
||||
documentId,
|
||||
className,
|
||||
}: {
|
||||
memoryEntries: MemoryEntry[]
|
||||
documentId?: string
|
||||
className?: string
|
||||
}) {
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(
|
||||
|
|
@ -193,7 +195,10 @@ export function GraphListMemories({
|
|||
return (
|
||||
<div
|
||||
id="document-memories"
|
||||
className="relative flex-1 px-3 pt-3 rounded-[14px] flex flex-col overflow-hidden shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
className={cn(
|
||||
"relative h-[360px] shrink-0 px-3 pt-3 rounded-[14px] flex flex-col overflow-hidden shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] md:h-auto md:min-h-0 md:flex-1 md:shrink",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "url('/dot-pattern.svg')",
|
||||
backgroundRepeat: "repeat",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs"
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
XIcon,
|
||||
|
|
@ -268,60 +270,114 @@ export function DocumentModal({
|
|||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3",
|
||||
isMobile
|
||||
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
|
||||
: "w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
const hasPluginInsights =
|
||||
pluginDocument &&
|
||||
pluginDocument.kind !== "claude-code-doc" &&
|
||||
pluginDocument.kind !== "openclaw-session"
|
||||
const hasDocumentInsights = Boolean(
|
||||
hasPluginInsights ||
|
||||
_document?.summary ||
|
||||
pluginDocument?.summary ||
|
||||
(_document?.memoryEntries && _document.memoryEntries.length > 0),
|
||||
)
|
||||
|
||||
const documentPreview = (
|
||||
<div
|
||||
id="document-preview"
|
||||
className="bg-[#14161A] rounded-[14px] overflow-hidden flex min-h-0 flex-1 flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative"
|
||||
>
|
||||
<DocumentContent
|
||||
document={_document}
|
||||
textEditorProps={textEditorProps}
|
||||
pluginDocument={pluginDocument}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const documentInsights = (
|
||||
<div
|
||||
id="document-memories-summary"
|
||||
className={cn(
|
||||
"gap-3 flex flex-col overflow-hidden",
|
||||
isMobile && "min-h-0 flex-1",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{hasPluginInsights && <PluginDetails parsed={pluginDocument} />}
|
||||
{_document && (_document.summary || pluginDocument?.summary) && (
|
||||
<DocumentSummary
|
||||
memoryEntries={_document.memoryEntries}
|
||||
summary={(pluginDocument?.summary ?? _document.summary) as string}
|
||||
createdAt={_document.createdAt}
|
||||
/>
|
||||
)}
|
||||
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
|
||||
<GraphListMemories
|
||||
memoryEntries={_document.memoryEntries as MemoryEntry[]}
|
||||
documentId={_document.id}
|
||||
className={
|
||||
isMobile ? "h-auto min-h-[420px] flex-1 shrink" : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const modalContent = (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<DrawerTitle className="sr-only">
|
||||
{_document?.title} - Document
|
||||
</DrawerTitle>
|
||||
) : (
|
||||
<DialogTitle className="sr-only">
|
||||
{_document?.title} - Document
|
||||
</DialogTitle>
|
||||
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Title
|
||||
title={_document?.title}
|
||||
documentType={_document?.type ?? "text"}
|
||||
url={_document?.url}
|
||||
pluginIconSrc={pluginDocument?.pluginIconSrc}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||
{pluginDocument?.kind === "claude-code-doc" &&
|
||||
_document?.customId && (
|
||||
<CopySessionIdButton sessionId={_document.customId} />
|
||||
)}
|
||||
<DeleteButton
|
||||
documentId={_document?.id}
|
||||
customId={_document?.customId}
|
||||
deleteMutation={deleteMutation}
|
||||
/>
|
||||
{_document?.url && (
|
||||
<a
|
||||
href={getDocumentSourceUrl(_document)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
|
||||
isMobile ? "size-7 justify-center" : "px-3 py-2",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span className="line-clamp-1">Visit source</span>
|
||||
)}
|
||||
<ArrowUpRightIcon className="size-4 text-[#737373]" />
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Title
|
||||
title={_document?.title}
|
||||
documentType={_document?.type ?? "text"}
|
||||
url={_document?.url}
|
||||
pluginIconSrc={pluginDocument?.pluginIconSrc}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||
{pluginDocument?.kind === "claude-code-doc" &&
|
||||
_document?.customId && (
|
||||
<CopySessionIdButton sessionId={_document.customId} />
|
||||
)}
|
||||
<DeleteButton
|
||||
documentId={_document?.id}
|
||||
customId={_document?.customId}
|
||||
deleteMutation={deleteMutation}
|
||||
/>
|
||||
{_document?.url && (
|
||||
<a
|
||||
href={getDocumentSourceUrl(_document)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
|
||||
isMobile ? "size-7 justify-center" : "px-3 py-2",
|
||||
)}
|
||||
>
|
||||
{!isMobile && <span className="line-clamp-1">Visit source</span>}
|
||||
<ArrowUpRightIcon className="size-4 text-[#737373]" />
|
||||
</a>
|
||||
)}
|
||||
{isMobile ? (
|
||||
<button
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
) : (
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
data-slot="dialog-close"
|
||||
|
|
@ -331,50 +387,91 @@ export function DocumentModal({
|
|||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 overflow-hidden min-h-0">
|
||||
<div
|
||||
id="document-preview"
|
||||
className={cn(
|
||||
"bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative",
|
||||
)}
|
||||
</div>
|
||||
{isMobile && hasDocumentInsights ? (
|
||||
<Tabs
|
||||
defaultValue="content"
|
||||
className="flex min-h-0 flex-1 flex-col pt-1.5"
|
||||
>
|
||||
<TabsList className="grid h-11 w-full grid-cols-2 rounded-full border border-[#263142] bg-[#0A1019] p-1 shadow-[inset_0_1px_2px_rgba(255,255,255,0.04),0_1px_3px_rgba(0,0,0,0.35)]">
|
||||
<TabsTrigger
|
||||
value="content"
|
||||
className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]"
|
||||
>
|
||||
Content
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="insights"
|
||||
className="rounded-full text-[15px] font-medium text-[#8E99AA] transition-colors data-[state=active]:bg-[#0B2B60]! data-[state=active]:text-[#F8FAFC] data-[state=active]:shadow-[inset_0_1px_1px_rgba(255,255,255,0.08),0_1px_4px_rgba(54,155,253,0.18)]"
|
||||
>
|
||||
Insights
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="content" className="mt-4 flex min-h-0 flex-1">
|
||||
{documentPreview}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="insights"
|
||||
className="mt-4 flex min-h-0 flex-1 flex-col overflow-y-auto pb-1 scrollbar-thin"
|
||||
>
|
||||
<DocumentContent
|
||||
document={_document}
|
||||
textEditorProps={textEditorProps}
|
||||
pluginDocument={pluginDocument}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="document-memories-summary"
|
||||
className={cn(
|
||||
"gap-3 flex flex-col overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{pluginDocument &&
|
||||
pluginDocument.kind !== "claude-code-doc" &&
|
||||
pluginDocument.kind !== "openclaw-session" && (
|
||||
<PluginDetails parsed={pluginDocument} />
|
||||
)}
|
||||
{_document && (_document.summary || pluginDocument?.summary) && (
|
||||
<DocumentSummary
|
||||
memoryEntries={_document.memoryEntries}
|
||||
summary={
|
||||
(pluginDocument?.summary ?? _document.summary) as string
|
||||
}
|
||||
createdAt={_document.createdAt}
|
||||
/>
|
||||
)}
|
||||
{_document?.memoryEntries && _document.memoryEntries.length > 0 && (
|
||||
<GraphListMemories
|
||||
memoryEntries={_document.memoryEntries as MemoryEntry[]}
|
||||
documentId={_document.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{documentInsights}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : isMobile ? (
|
||||
<div className="flex min-h-0 flex-1 pt-1.5">{documentPreview}</div>
|
||||
) : (
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-3 min-h-0 overflow-hidden">
|
||||
{documentPreview}
|
||||
{documentInsights}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={(open: boolean) => !open && onClose()}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerContent
|
||||
className={cn(
|
||||
"flex flex-col gap-0 border-none bg-[#1B1F24] p-0",
|
||||
"h-[88svh] max-h-[88svh] overflow-hidden rounded-t-[22px]",
|
||||
"[&>div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 -12px 40px rgba(0, 0, 0, 0.45), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-3 pt-2 pb-4">
|
||||
{modalContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"p-0 border-none bg-[#1B1F24] flex flex-col px-3 md:px-4 pt-3 pb-4 gap-3",
|
||||
"w-[80%]! max-w-[1158px]! h-[86%]! max-h-[684px]! rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
{modalContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { getBillingSettingsUrl } from "@/lib/url-helpers"
|
||||
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -80,6 +81,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
feedbackParam,
|
||||
)
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const billingSettingsUrl = getBillingSettingsUrl()
|
||||
|
||||
const handleFeedback = () => setFeedbackOpen(true)
|
||||
|
||||
|
|
@ -352,6 +354,15 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<a href={billingSettingsUrl}>
|
||||
<Logo className="h-4 w-5 shrink-0" />
|
||||
Upgrade
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onAddMemory}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
|
|
@ -421,6 +432,27 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0 border border-[#2261CA33] bg-[#00173C] !text-white hover:bg-[#001F50]",
|
||||
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
|
||||
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-semibold",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<a href={billingSettingsUrl} aria-label="Upgrade">
|
||||
<Logo className="h-3.5 w-[17px] shrink-0 lg:h-4 lg:w-5" />
|
||||
<span className="max-lg:sr-only">Upgrade</span>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Upgrade
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Logo } from "@ui/assets/Logo"
|
|||
import { Button } from "@ui/components/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
|
|
@ -23,7 +22,6 @@ export function InitialHeader({
|
|||
|
||||
const handleSkip = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1135,7 +1135,10 @@ export function IntegrationsView() {
|
|||
}
|
||||
throw new Error(response.error?.message || "Failed to connect")
|
||||
},
|
||||
onMutate: (provider) => setConnectingProvider(provider),
|
||||
onMutate: (provider) => {
|
||||
setConnectingProvider(provider)
|
||||
analytics.connectionAuthStarted({ provider })
|
||||
},
|
||||
onError: (err) => {
|
||||
setConnectingProvider(null)
|
||||
toast.error("Failed to connect", {
|
||||
|
|
@ -1349,6 +1352,13 @@ export function IntegrationsView() {
|
|||
return true
|
||||
})
|
||||
|
||||
const trackCard = (item: Item) =>
|
||||
analytics.integrationCardClicked({
|
||||
kind: item.kind,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
})
|
||||
|
||||
const renderRight = (item: Item): ReactNode => {
|
||||
switch (item.kind) {
|
||||
case "plugin": {
|
||||
|
|
@ -1370,7 +1380,10 @@ export function IntegrationsView() {
|
|||
const busy = connectingPlugin === item.pluginId
|
||||
return (
|
||||
<PillButton
|
||||
onClick={() => createPluginKeyMutation.mutate(item.pluginId)}
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
{busy ? (
|
||||
|
|
@ -1397,7 +1410,10 @@ export function IntegrationsView() {
|
|||
const busy = connectingProvider === item.provider
|
||||
return (
|
||||
<PillButton
|
||||
onClick={() => addConnectionMutation.mutate(item.provider)}
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
addConnectionMutation.mutate(item.provider)
|
||||
}}
|
||||
disabled={!!connectingProvider}
|
||||
>
|
||||
{busy ? (
|
||||
|
|
@ -1415,6 +1431,7 @@ export function IntegrationsView() {
|
|||
return (
|
||||
<PillButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
window.open(
|
||||
(item.action as { type: "external"; href: string }).href,
|
||||
"_blank",
|
||||
|
|
@ -1431,12 +1448,13 @@ export function IntegrationsView() {
|
|||
}
|
||||
return (
|
||||
<PillButton
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
setViewMode(
|
||||
(item.action as { type: "view"; viewMode: ViewParamValue })
|
||||
.viewMode,
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</PillButton>
|
||||
|
|
@ -1444,13 +1462,23 @@ export function IntegrationsView() {
|
|||
}
|
||||
case "mcp-client":
|
||||
return (
|
||||
<PillButton onClick={() => openMcpClient(item.clientKey)}>
|
||||
<PillButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
openMcpClient(item.clientKey)
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</PillButton>
|
||||
)
|
||||
case "import":
|
||||
return (
|
||||
<PillButton onClick={() => setViewMode(item.viewMode)}>
|
||||
<PillButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
setViewMode(item.viewMode)
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</PillButton>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,15 +4,8 @@ import { dmSans125ClassName } from "@/lib/fonts"
|
|||
import { cn } from "@lib/utils"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import {
|
||||
PLAN_RANK,
|
||||
useTokenUsage,
|
||||
type PlanType,
|
||||
} from "@/hooks/use-token-usage"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { Popover, PopoverContent } from "@ui/components/popover"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -28,23 +21,21 @@ import {
|
|||
} from "@ui/components/dropdown-menu"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
Check,
|
||||
LoaderIcon,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
Users,
|
||||
UserPlus,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
UserMinus,
|
||||
X,
|
||||
Pencil,
|
||||
Tag,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { PopoverAnchor } from "@ui/components/popover"
|
||||
|
|
@ -135,16 +126,7 @@ function isPendingInvitation(invitation: {
|
|||
}
|
||||
|
||||
export default function Account() {
|
||||
const {
|
||||
user,
|
||||
org,
|
||||
organizations: allOrgs,
|
||||
setActiveOrg,
|
||||
refetchActiveOrg,
|
||||
} = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
const [orgMenuOpen, setOrgMenuOpen] = useState(false)
|
||||
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false)
|
||||
const [inviteEmail, setInviteEmail] = useState("")
|
||||
const [inviteRole, setInviteRole] = useState<InviteRole>("member")
|
||||
|
|
@ -156,6 +138,8 @@ export default function Account() {
|
|||
>([])
|
||||
const [tagQuery, setTagQuery] = useState("")
|
||||
const [tagDropdownOpen, setTagDropdownOpen] = useState(false)
|
||||
const [isEditingOrgName, setIsEditingOrgName] = useState(false)
|
||||
const [orgNameDraft, setOrgNameDraft] = useState("")
|
||||
const tagInputRef = useRef<HTMLInputElement>(null)
|
||||
const tagAnchorRef = useRef<HTMLDivElement>(null)
|
||||
const { allProjects: allContainerTags } = useContainerTags()
|
||||
|
|
@ -174,28 +158,17 @@ export default function Account() {
|
|||
const showAccessType = inviteRole === "member"
|
||||
const showTagPicker =
|
||||
inviteRole === "member" && inviteAccessType === "restricted"
|
||||
const canSwitchOrg = (allOrgs?.length ?? 0) > 1
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
|
||||
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
|
||||
if (orgId === org?.id) return
|
||||
setSwitchingOrgId(orgId)
|
||||
try {
|
||||
await setActiveOrg(orgSlug)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error("Failed to switch organization:", error)
|
||||
setSwitchingOrgId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
useEffect(() => {
|
||||
setOrgNameDraft(org?.name ?? "")
|
||||
setIsEditingOrgName(false)
|
||||
}, [org?.name])
|
||||
|
||||
const activeMemberRoleQuery = useQuery({
|
||||
queryKey: ["organization", org?.id, "active-member-role"],
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return null
|
||||
const result = await authClient.organization.getActiveMemberRole({
|
||||
const result = await authClient.organization.getActiveMember({
|
||||
query: { organizationId: org.id },
|
||||
})
|
||||
if (result.error) {
|
||||
|
|
@ -346,40 +319,50 @@ export default function Account() {
|
|||
},
|
||||
})
|
||||
|
||||
const updateOrgNameMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
if (!org?.id) throw new Error("No active organization")
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) throw new Error("Enter an organization name")
|
||||
const result = await authClient.organization.update({
|
||||
organizationId: org.id,
|
||||
data: { name: trimmed },
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
result.error.message ?? "Failed to update organization name",
|
||||
)
|
||||
}
|
||||
return trimmed
|
||||
},
|
||||
onSuccess: async (name) => {
|
||||
setOrgNameDraft(name)
|
||||
setIsEditingOrgName(false)
|
||||
await Promise.all([refetchActiveOrg(), refetchOrganizations()])
|
||||
toast.success("Organization name updated")
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, "Failed to update organization name"))
|
||||
},
|
||||
})
|
||||
|
||||
const handleInviteSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!canManageTeam || inviteMemberMutation.isPending) return
|
||||
inviteMemberMutation.mutate()
|
||||
}
|
||||
|
||||
const planByOrgId = useMemo(() => {
|
||||
const map = new Map<string, PlanType>()
|
||||
for (const summary of orgSummaries ?? []) {
|
||||
map.set(summary.orgId, summary.plan)
|
||||
const handleOrgNameSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!canManageTeam || updateOrgNameMutation.isPending) return
|
||||
const trimmed = orgNameDraft.trim()
|
||||
if (!trimmed || trimmed === org?.name) {
|
||||
setOrgNameDraft(org?.name ?? "")
|
||||
setIsEditingOrgName(false)
|
||||
return
|
||||
}
|
||||
return map
|
||||
}, [orgSummaries])
|
||||
|
||||
const sortedOrgsForMenu = useMemo(() => {
|
||||
if (!allOrgs?.length) return []
|
||||
return [...allOrgs].sort((a, b) => {
|
||||
const planA = resolveOrgPlan(
|
||||
a.id,
|
||||
a.id === org?.id,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
const planB = resolveOrgPlan(
|
||||
b.id,
|
||||
b.id === org?.id,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
const rankDiff = PLAN_RANK[planB] - PLAN_RANK[planA]
|
||||
if (rankDiff !== 0) return rankDiff
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}, [allOrgs, org?.id, currentPlan, planByOrgId])
|
||||
updateOrgNameMutation.mutate(trimmed)
|
||||
}
|
||||
|
||||
const memberSince = user?.createdAt
|
||||
? new Date(user.createdAt).toLocaleDateString("en-US", {
|
||||
|
|
@ -438,84 +421,84 @@ export default function Account() {
|
|||
>
|
||||
Organization
|
||||
</p>
|
||||
{canSwitchOrg ? (
|
||||
<Popover open={orgMenuOpen} onOpenChange={setOrgMenuOpen}>
|
||||
<PopoverTrigger
|
||||
{isEditingOrgName ? (
|
||||
<form
|
||||
onSubmit={handleOrgNameSubmit}
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[360px]"
|
||||
>
|
||||
<input
|
||||
value={orgNameDraft}
|
||||
onChange={(event) => setOrgNameDraft(event.target.value)}
|
||||
disabled={updateOrgNameMutation.isPending}
|
||||
maxLength={80}
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center gap-2 transition-opacity",
|
||||
"cursor-pointer hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
"h-9 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-3 text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
placeholder="Organization name"
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
updateOrgNameMutation.isPending ||
|
||||
!orgNameDraft.trim() ||
|
||||
orgNameDraft.trim() === org?.name
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"inline-flex h-8 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-[#737373]" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80 max-h-80 overflow-y-auto bg-[#1B1F24] rounded-[12px] border-white/10 p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{sortedOrgsForMenu.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-3 px-3 py-2.5 rounded-[8px] text-left transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/5"
|
||||
: "hover:bg-white/5 cursor-pointer",
|
||||
"disabled:opacity-60 disabled:cursor-default",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 text-[#737373] shrink-0" />
|
||||
<p className="min-w-0 flex-1 truncate text-[14px] tracking-[-0.14px] text-[#FAFAFA]">
|
||||
{organization.name}
|
||||
</p>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{updateOrgNameMutation.isPending ? (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateOrgNameMutation.isPending}
|
||||
aria-label="Cancel organization name edit"
|
||||
title="Cancel"
|
||||
onClick={() => {
|
||||
setOrgNameDraft(org?.name ?? "")
|
||||
setIsEditingOrgName(false)
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex size-8 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
{canManageTeam ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Edit organization name"
|
||||
title="Edit organization name"
|
||||
onClick={() => {
|
||||
setOrgNameDraft(org?.name ?? "")
|
||||
setIsEditingOrgName(true)
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[#FAFAFA] transition-colors hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import {
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
ExternalLink,
|
||||
LoaderIcon,
|
||||
|
|
@ -30,6 +33,11 @@ const API_BASE =
|
|||
const CREDIT_FEATURE_ID = "usd_credits"
|
||||
const TOP_UP_PLAN_ID = "credits_topup"
|
||||
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
|
||||
const PLAN_CARD_ACTION_CLASS =
|
||||
"inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] text-[14px] font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
|
||||
const SURFACE_SHADOW =
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
|
||||
|
||||
type BillingInvoice = {
|
||||
planIds?: string[]
|
||||
|
|
@ -61,6 +69,91 @@ type AutoTopupsResponse =
|
|||
}
|
||||
| { ok: false; reason: string; message?: string }
|
||||
|
||||
type PlanCardDefinition = {
|
||||
id: "free" | "pro" | "scale" | "enterprise"
|
||||
name: string
|
||||
price: string
|
||||
period: string
|
||||
credits: string
|
||||
productId: "api_free" | "api_pro" | "api_scale" | "api_enterprise"
|
||||
description: string
|
||||
includesFrom?: string
|
||||
features: string[]
|
||||
isContactSales?: boolean
|
||||
}
|
||||
|
||||
const PLAN_CARDS: PlanCardDefinition[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
period: "",
|
||||
credits: "$5",
|
||||
productId: "api_free",
|
||||
description: "Try supermemory with no commitment",
|
||||
features: [
|
||||
"Pay-as-you-go after $5 runs out",
|
||||
"Full search and memory access",
|
||||
"Email support",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price: "$19",
|
||||
period: "/mo",
|
||||
credits: "$20",
|
||||
productId: "api_pro",
|
||||
description: "For people building with AI memory",
|
||||
features: [
|
||||
"Auto top-up when balance runs low",
|
||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
|
||||
{
|
||||
id: "scale",
|
||||
name: "Scale",
|
||||
price: "$399",
|
||||
period: "/mo",
|
||||
credits: "$600",
|
||||
productId: "api_scale",
|
||||
description: "For teams and production workloads",
|
||||
includesFrom: "Pro",
|
||||
features: [
|
||||
"Auto top-up & spend caps",
|
||||
"Gmail, S3 & Web Crawler connectors",
|
||||
"Dedicated support",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
period: "",
|
||||
credits: "Unlimited",
|
||||
productId: "api_enterprise",
|
||||
description: "Custom deployments with dedicated engineering",
|
||||
includesFrom: "Scale",
|
||||
features: [
|
||||
"Custom metering & billing",
|
||||
"Custom integrations & SSO",
|
||||
"Forward-deployed engineer",
|
||||
],
|
||||
isContactSales: true,
|
||||
},
|
||||
]
|
||||
|
||||
const PLAN_RANK: Record<PlanCardDefinition["id"], number> = {
|
||||
free: 0,
|
||||
pro: 1,
|
||||
scale: 2,
|
||||
enterprise: 3,
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
children,
|
||||
aside,
|
||||
|
|
@ -103,6 +196,95 @@ function SettingsCard({
|
|||
)
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
action,
|
||||
plan,
|
||||
}: {
|
||||
action: React.ReactNode
|
||||
plan: PlanCardDefinition
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-[416px] flex-col overflow-hidden rounded-[14px] border p-5",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
"border-white/[0.08] bg-[#14161A]",
|
||||
)}
|
||||
>
|
||||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
|
||||
{plan.name}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-baseline gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[34px] font-bold leading-none tracking-[-0.34px] text-[#FAFAFA] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{plan.price}
|
||||
</span>
|
||||
{plan.period ? (
|
||||
<span className="text-[13px] text-[#737373]">{plan.period}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-2 text-[13px] leading-snug text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
{plan.description}
|
||||
</p>
|
||||
|
||||
{plan.isContactSales ? null : (
|
||||
<div className="mt-5 flex items-center gap-2 rounded-[8px] bg-white/[0.04] px-3 py-2.5 text-[#A3A3A3]">
|
||||
<Coins className="size-3.5 shrink-0 text-[#737373]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold leading-none text-[#C8D0DA] tabular-nums">
|
||||
{plan.credits}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] leading-none text-[#737373]">
|
||||
of usage included
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan.includesFrom ? (
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-white/[0.08]" />
|
||||
<span className="whitespace-nowrap text-[10px] text-[#737373]">
|
||||
Everything in {plan.includesFrom}, plus
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-white/[0.08]" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ul
|
||||
className={cn(
|
||||
"mb-6 flex flex-1 flex-col gap-3",
|
||||
plan.includesFrom ? "mt-5" : "mt-5",
|
||||
plan.isContactSales && !plan.includesFrom && "mt-7",
|
||||
)}
|
||||
>
|
||||
{plan.features.map((feature) => (
|
||||
<li
|
||||
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
|
||||
key={feature}
|
||||
>
|
||||
<Check className="mt-0.5 size-3.5 shrink-0 text-[#737373]" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
tone = "muted",
|
||||
|
|
@ -236,6 +418,8 @@ export default function Billing() {
|
|||
const [isCancelling, setIsCancelling] = useState(false)
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
|
||||
const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false)
|
||||
const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false)
|
||||
const [planPage, setPlanPage] = useState<0 | 1>(0)
|
||||
const [topUpAmount, setTopUpAmount] = useState<number>(25)
|
||||
const [customTopUpAmount, setCustomTopUpAmount] = useState("")
|
||||
const [topUpPendingAmount, setTopUpPendingAmount] = useState<number | null>(
|
||||
|
|
@ -333,11 +517,11 @@ export default function Billing() {
|
|||
|
||||
const planDisplayNames = PLAN_DISPLAY_NAMES
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const handleUpgrade = async (planId: "api_pro" | "api_scale") => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
planId,
|
||||
successUrl: `${window.location.origin}/settings#billing`,
|
||||
})
|
||||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||||
|
|
@ -490,6 +674,97 @@ export default function Billing() {
|
|||
})
|
||||
}
|
||||
|
||||
const getPlanCardAction = (plan: PlanCardDefinition) => {
|
||||
const disabled = isUpgrading || isCheckingStatus || autumn.isLoading
|
||||
const isCurrentPlan = plan.id === currentPlan
|
||||
const isIncludedPlan = PLAN_RANK[currentPlan] > PLAN_RANK[plan.id]
|
||||
|
||||
if (plan.id === "free") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
PLAN_CARD_ACTION_CLASS,
|
||||
"border border-white/[0.04] bg-white/[0.02] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{hasPaidPlan ? "Included with current plan" : "Your current plan"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (isCurrentPlan) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
PLAN_CARD_ACTION_CLASS,
|
||||
"border border-white/[0.04] bg-white/[0.02] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Your current plan
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (isIncludedPlan) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
PLAN_CARD_ACTION_CLASS,
|
||||
"border border-white/[0.04] bg-white/[0.02] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Included with {planDisplayNames[currentPlan]}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (plan.isContactSales) {
|
||||
return (
|
||||
<a
|
||||
href="mailto:support@supermemory.com?subject=Enterprise%20plan"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
PLAN_CARD_ACTION_CLASS,
|
||||
"border border-white/[0.08] bg-transparent text-[#FAFAFA] hover:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
Contact sales
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const checkoutPlanId =
|
||||
plan.productId === "api_pro" || plan.productId === "api_scale"
|
||||
? plan.productId
|
||||
: null
|
||||
if (!checkoutPlanId) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpgrade(checkoutPlanId)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
PLAN_CARD_ACTION_CLASS,
|
||||
"bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
|
||||
)}
|
||||
>
|
||||
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
|
||||
Upgrade to {plan.name}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-7">
|
||||
<section id="billing-subscription" className="flex flex-col gap-4">
|
||||
|
|
@ -668,348 +943,322 @@ export default function Billing() {
|
|||
: "Usage resets with your billing cycle"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!hasPaidPlan ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading || isCheckingStatus || autumn.isLoading}
|
||||
title={
|
||||
autumn.isLoading && !isUpgrading && !isCheckingStatus
|
||||
? "Loading billing details…"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{isUpgrading || isCheckingStatus || autumn.isLoading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Upgrade to Pro - $19/month
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Credits</SectionTitle>
|
||||
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||||
Usage this period
|
||||
</p>
|
||||
<div className="mt-4 flex items-baseline gap-1 text-[13px] text-[#A3A3A3]">
|
||||
<span className="font-semibold text-[#FAFAFA]">
|
||||
{planUsagePct < 1 && planUsagePct > 0
|
||||
? "< 1"
|
||||
: Math.round(planUsagePct)}
|
||||
%
|
||||
</span>
|
||||
<span>of monthly usage</span>
|
||||
<span className="text-[#737373]">
|
||||
{daysRemaining !== null
|
||||
? `· resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
|
||||
: "· resets with your billing cycle"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[#2E353D]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#4BA0FA]"
|
||||
style={{ width: `${planUsagePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-[12px] text-[#737373]">
|
||||
{planUsagePct > 0
|
||||
? `${formatUsd(usdSpent)} used this period`
|
||||
: "No usage yet this period"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:min-w-[170px]">
|
||||
<Dialog
|
||||
open={isCreditsDialogOpen}
|
||||
onOpenChange={setIsCreditsDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-[9px] bg-[#0054AD] px-3 text-[13px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Buy credits
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="w-[min(560px,calc(100vw-32px))] rounded-[18px] border border-[#1C2B3E] bg-[#0B0D12] p-6 shadow-[0px_18px_70px_rgba(0,0,0,0.72)]"
|
||||
<section id="billing-plans" className="flex flex-col gap-4">
|
||||
<SectionTitle
|
||||
aside={
|
||||
isPlanCarouselActive ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlanPage(0)}
|
||||
disabled={planPage === 0}
|
||||
className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
|
||||
aria-label="Show Free and Pro plans"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[22px] font-semibold tracking-[-0.22px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Buy Credits
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-2 text-[15px] text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
Add USD to your balance for metered usage.
|
||||
</p>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#0D121A] text-[#737373] transition-colors hover:text-[#FAFAFA]"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[16px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Choose an amount
|
||||
</p>
|
||||
<FieldSelect
|
||||
value={topUpAmount}
|
||||
values={TOP_UP_AMOUNTS}
|
||||
prefix="$"
|
||||
onChange={(value) => {
|
||||
setTopUpAmount(value)
|
||||
setCustomTopUpAmount("")
|
||||
}}
|
||||
disabled={topUpPendingAmount !== null}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="custom-topup-amount"
|
||||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||||
>
|
||||
Custom amount (USD)
|
||||
</label>
|
||||
<input
|
||||
id="custom-topup-amount"
|
||||
inputMode="decimal"
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
setCustomTopUpAmount(event.target.value)
|
||||
}
|
||||
placeholder="e.g. 75"
|
||||
type="number"
|
||||
value={customTopUpAmount}
|
||||
className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-white/[0.06]" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||||
Auto reload
|
||||
</p>
|
||||
<span className="text-[12px] text-[#737373]">
|
||||
{autoTopUpEnabled ? "on" : "off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Auto reload is{" "}
|
||||
{autoTopUpEnabled ? "enabled" : "disabled"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
isSavingAutoTopUp ||
|
||||
!isAdmin ||
|
||||
(!hasPaymentMethod && !activeAutoTopUp?.enabled)
|
||||
}
|
||||
onClick={() =>
|
||||
handleAutoReloadToggle(!autoTopUpEnabled)
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 min-w-[96px] items-center justify-center rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
{autoTopUpEnabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasPaymentMethod && !activeAutoTopUp?.enabled ? (
|
||||
<p className="text-[13px] text-[#737373]">
|
||||
Save a card in Manage Billing to enable automatic
|
||||
reloads.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3 transition-[filter,opacity] sm:grid-cols-2",
|
||||
!autoTopUpEnabled &&
|
||||
"pointer-events-none select-none opacity-45 blur-[3px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="auto-topup-threshold"
|
||||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||||
>
|
||||
Threshold (USD)
|
||||
</label>
|
||||
<input
|
||||
id="auto-topup-threshold"
|
||||
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const value = Number.parseFloat(
|
||||
event.target.value,
|
||||
)
|
||||
setAutoTopUpThreshold(
|
||||
Number.isFinite(value) ? value : 0,
|
||||
)
|
||||
}}
|
||||
type="number"
|
||||
value={
|
||||
Number.isFinite(autoTopUpThreshold)
|
||||
? autoTopUpThreshold
|
||||
: ""
|
||||
}
|
||||
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="auto-topup-amount"
|
||||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||||
>
|
||||
Reload amount (USD)
|
||||
</label>
|
||||
<input
|
||||
id="auto-topup-amount"
|
||||
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
|
||||
inputMode="decimal"
|
||||
min={0.01}
|
||||
onChange={(event) => {
|
||||
const value = Number.parseFloat(
|
||||
event.target.value,
|
||||
)
|
||||
setAutoTopUpAmount(
|
||||
Number.isFinite(value) ? value : 0,
|
||||
)
|
||||
}}
|
||||
type="number"
|
||||
value={
|
||||
Number.isFinite(autoTopUpAmount)
|
||||
? autoTopUpAmount
|
||||
: ""
|
||||
}
|
||||
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveAutoTopUp()}
|
||||
disabled={isSavingAutoTopUp || !isAdmin}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-[8px] border border-white/10 bg-[#080B10] text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{isSavingAutoTopUp ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Save threshold & reload amount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTopUp(selectedTopUpAmount)}
|
||||
disabled={
|
||||
topUpPendingAmount !== null ||
|
||||
!isAdmin ||
|
||||
selectedTopUpAmount <= 0
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{topUpPendingAmount !== null ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Buy {formatUsd(selectedTopUpAmount)} in credits
|
||||
</button>
|
||||
|
||||
{!isAdmin ? (
|
||||
<p className="text-center text-[11px] text-[#737373]">
|
||||
Only owners/admins can purchase credits.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreditsDialogOpen(true)}
|
||||
disabled={!isAdmin}
|
||||
className="inline-flex h-8 items-center justify-center gap-2 rounded-[8px] text-[12px] font-medium text-[#A3A3A3] transition-colors hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: autoTopUpEnabled ? "#4BA0FA" : "#737373",
|
||||
}}
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlanPage(1)}
|
||||
disabled={planPage === 1}
|
||||
className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
|
||||
aria-label="Show Scale and Enterprise plans"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Plans
|
||||
</SectionTitle>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className="flex gap-4 transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform:
|
||||
planPage === 1 ? "translateX(calc(-100% - 1rem))" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-2">
|
||||
{PLAN_CARDS.map((plan) => (
|
||||
<PlanCard
|
||||
action={getPlanCardAction(plan)}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
/>
|
||||
Auto reload: {autoTopUpEnabled ? "on" : "off"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-2">
|
||||
{ADVANCED_PLAN_CARDS.map((plan) => (
|
||||
<PlanCard
|
||||
action={getPlanCardAction(plan)}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{isPlanCarouselActive ? null : (
|
||||
<div className="flex justify-end px-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsPlanCarouselActive(true)
|
||||
setPlanPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex items-center justify-center gap-2 text-[13px] font-semibold text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<span className="relative after:absolute after:right-0 after:-bottom-0.5 after:left-0 after:h-px after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:after:scale-x-100">
|
||||
Other plans
|
||||
</span>
|
||||
<span className="translate-x-1 text-[15px]" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{hasPaidPlan ? (
|
||||
<Dialog open={isCreditsDialogOpen} onOpenChange={setIsCreditsDialogOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{ boxShadow: SURFACE_SHADOW }}
|
||||
className="w-[min(560px,calc(100vw-32px))] rounded-[22px] border border-white/[0.12] bg-[#1B1F24] p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[22px] font-semibold tracking-[-0.22px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Buy Credits
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-2 text-[15px] text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
Add USD to your balance for metered usage.
|
||||
</p>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#0D121A] text-[#737373] transition-colors hover:text-[#FAFAFA]"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[16px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Choose an amount
|
||||
</p>
|
||||
<FieldSelect
|
||||
value={topUpAmount}
|
||||
values={TOP_UP_AMOUNTS}
|
||||
prefix="$"
|
||||
onChange={(value) => {
|
||||
setTopUpAmount(value)
|
||||
setCustomTopUpAmount("")
|
||||
}}
|
||||
disabled={topUpPendingAmount !== null}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="custom-topup-amount"
|
||||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||||
>
|
||||
Custom amount (USD)
|
||||
</label>
|
||||
<input
|
||||
id="custom-topup-amount"
|
||||
inputMode="decimal"
|
||||
min={1}
|
||||
onChange={(event) => setCustomTopUpAmount(event.target.value)}
|
||||
placeholder="e.g. 75"
|
||||
type="number"
|
||||
value={customTopUpAmount}
|
||||
className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-white/[0.06]" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||||
Auto reload
|
||||
</p>
|
||||
<span className="text-[12px] text-[#737373]">
|
||||
{autoTopUpEnabled ? "on" : "off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Auto reload is {autoTopUpEnabled ? "enabled" : "disabled"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
isSavingAutoTopUp ||
|
||||
!isAdmin ||
|
||||
(!hasPaymentMethod && !activeAutoTopUp?.enabled)
|
||||
}
|
||||
onClick={() => handleAutoReloadToggle(!autoTopUpEnabled)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 min-w-[96px] items-center justify-center rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
{autoTopUpEnabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasPaymentMethod && !activeAutoTopUp?.enabled ? (
|
||||
<p className="text-[13px] text-[#737373]">
|
||||
Save a card in Manage Billing to enable automatic reloads.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3 transition-[filter,opacity] sm:grid-cols-2",
|
||||
!autoTopUpEnabled &&
|
||||
"pointer-events-none select-none opacity-45 blur-[3px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="auto-topup-threshold"
|
||||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||||
>
|
||||
Threshold (USD)
|
||||
</label>
|
||||
<input
|
||||
id="auto-topup-threshold"
|
||||
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const value = Number.parseFloat(event.target.value)
|
||||
setAutoTopUpThreshold(Number.isFinite(value) ? value : 0)
|
||||
}}
|
||||
type="number"
|
||||
value={
|
||||
Number.isFinite(autoTopUpThreshold)
|
||||
? autoTopUpThreshold
|
||||
: ""
|
||||
}
|
||||
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="auto-topup-amount"
|
||||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||||
>
|
||||
Reload amount (USD)
|
||||
</label>
|
||||
<input
|
||||
id="auto-topup-amount"
|
||||
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
|
||||
inputMode="decimal"
|
||||
min={0.01}
|
||||
onChange={(event) => {
|
||||
const value = Number.parseFloat(event.target.value)
|
||||
setAutoTopUpAmount(Number.isFinite(value) ? value : 0)
|
||||
}}
|
||||
type="number"
|
||||
value={
|
||||
Number.isFinite(autoTopUpAmount) ? autoTopUpAmount : ""
|
||||
}
|
||||
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveAutoTopUp()}
|
||||
disabled={isSavingAutoTopUp || !isAdmin}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-[8px] border border-white/10 bg-[#080B10] text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{isSavingAutoTopUp ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Save threshold & reload amount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTopUp(selectedTopUpAmount)}
|
||||
disabled={
|
||||
topUpPendingAmount !== null ||
|
||||
!isAdmin ||
|
||||
selectedTopUpAmount <= 0
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{topUpPendingAmount !== null ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Buy {formatUsd(selectedTopUpAmount)} in credits
|
||||
</button>
|
||||
|
||||
{!isAdmin ? (
|
||||
<p className="text-center text-[11px] text-[#737373]">
|
||||
Only owners/admins can purchase credits.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{hasPaidPlan ? (
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Credits</SectionTitle>
|
||||
<SettingsCard className="border border-dashed border-white/10 bg-[#14161A]/70">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Coins className="mt-1 size-4 shrink-0 text-[#4BA0FA]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||||
Top-up credits{" "}
|
||||
<span className="font-normal normal-case tracking-normal text-[#A3A3A3]">
|
||||
(optional)
|
||||
</span>
|
||||
Top-up credits
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -1039,12 +1288,12 @@ export default function Billing() {
|
|||
)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{creditRemaining > 0 ? "Add more" : "Add credits"}
|
||||
{creditRemaining > 0 ? "Add more" : "Buy credits"}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Invoice history</SectionTitle>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ function PillButton({
|
|||
className={cn(
|
||||
"relative flex items-center justify-center gap-2",
|
||||
"bg-[#0D121A]",
|
||||
"rounded-full h-11 px-4 flex-1",
|
||||
"rounded-full h-11 min-w-0 px-3 flex-1 sm:px-4",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
|
|
@ -244,6 +244,15 @@ export default function Integrations() {
|
|||
analytics.extensionInstallClicked()
|
||||
}
|
||||
|
||||
const addShortcutLabel =
|
||||
createApiKeyMutation.isPending && selectedShortcutType === "add"
|
||||
? "Creating..."
|
||||
: "Add shortcut"
|
||||
const searchShortcutLabel =
|
||||
createApiKeyMutation.isPending && selectedShortcutType === "search"
|
||||
? "Creating..."
|
||||
: "Search shortcut"
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
setShowApiKeyModal(open)
|
||||
if (!open) {
|
||||
|
|
@ -331,7 +340,7 @@ export default function Integrations() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="apple-shortcuts-cta" className="flex gap-4">
|
||||
<div id="apple-shortcuts-cta" className="flex gap-2 sm:gap-4">
|
||||
<PillButton
|
||||
onClick={() => handleShortcutClick("add")}
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
|
|
@ -342,11 +351,14 @@ export default function Integrations() {
|
|||
) : (
|
||||
<Plus className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "add"
|
||||
? "Creating..."
|
||||
: "Add memory shortcut"}
|
||||
<span className="whitespace-nowrap text-[12px] font-medium tracking-[-0.12px] text-[#FAFAFA] sm:text-[14px] sm:tracking-[-0.14px]">
|
||||
<span className="sm:hidden">{addShortcutLabel}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "add"
|
||||
? "Creating..."
|
||||
: "Add memory shortcut"}
|
||||
</span>
|
||||
</span>
|
||||
</PillButton>
|
||||
<PillButton
|
||||
|
|
@ -359,11 +371,14 @@ export default function Integrations() {
|
|||
) : (
|
||||
<Search className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "search"
|
||||
? "Creating..."
|
||||
: "Search memory shortcut"}
|
||||
<span className="whitespace-nowrap text-[12px] font-medium tracking-[-0.12px] text-[#FAFAFA] sm:text-[14px] sm:tracking-[-0.14px]">
|
||||
<span className="sm:hidden">{searchShortcutLabel}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "search"
|
||||
? "Creating..."
|
||||
: "Search memory shortcut"}
|
||||
</span>
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
|
|
@ -397,7 +412,7 @@ export default function Integrations() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="raycast-extension-cta" className="flex gap-4">
|
||||
<div id="raycast-extension-cta" className="flex gap-2 sm:gap-4">
|
||||
<PillButton
|
||||
onClick={handleRaycastClick}
|
||||
disabled={createRaycastApiKeyMutation.isPending}
|
||||
|
|
@ -407,7 +422,7 @@ export default function Integrations() {
|
|||
) : (
|
||||
<Key className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
<span className="whitespace-nowrap text-[12px] font-medium tracking-[-0.12px] text-[#FAFAFA] sm:text-[14px] sm:tracking-[-0.14px]">
|
||||
{createRaycastApiKeyMutation.isPending
|
||||
? "Generating..."
|
||||
: "Get API key"}
|
||||
|
|
@ -415,7 +430,7 @@ export default function Integrations() {
|
|||
</PillButton>
|
||||
<PillButton onClick={handleRaycastInstall}>
|
||||
<Download className="size-4 text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
<span className="whitespace-nowrap text-[12px] font-medium tracking-[-0.12px] text-[#FAFAFA] sm:text-[14px] sm:tracking-[-0.14px]">
|
||||
Install extension
|
||||
</span>
|
||||
</PillButton>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ function PillButton({
|
|||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
variant?: "default" | "danger" | "primary"
|
||||
variant?: "default" | "ghost" | "primary"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -103,14 +103,10 @@ function PillButton({
|
|||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[#0D121A] text-[#FAFAFA] hover:opacity-80",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
variant === "primary"
|
||||
? "border-transparent"
|
||||
: variant === "danger"
|
||||
? "border-transparent"
|
||||
: "border-transparent",
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full px-4 text-[13px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "ghost"
|
||||
? "px-3 font-medium text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
|
||||
: "border border-transparent bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -212,7 +208,7 @@ export function OrgContext() {
|
|||
<PillButton
|
||||
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
variant={enabled ? "danger" : "primary"}
|
||||
variant={enabled ? "ghost" : "primary"}
|
||||
>
|
||||
{enabled ? "DISABLE" : "ENABLE"}
|
||||
</PillButton>
|
||||
|
|
@ -300,7 +296,9 @@ export function OrgContext() {
|
|||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
|
||||
<PillButton onClick={handleCancel}>CANCEL</PillButton>
|
||||
<PillButton onClick={handleCancel} variant="ghost">
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || updateSettings.isPending}
|
||||
|
|
@ -363,13 +361,16 @@ export function OrgContext() {
|
|||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<PillButton onClick={() => setConfirmDialog(null)}>
|
||||
<PillButton
|
||||
onClick={() => setConfirmDialog(null)}
|
||||
variant="ghost"
|
||||
>
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={handleConfirmToggle}
|
||||
disabled={updateSettings.isPending}
|
||||
variant={confirmDialog === "disable" ? "danger" : "primary"}
|
||||
variant="primary"
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import posthog from "posthog-js"
|
||||
|
||||
export type OnboardingStep = "profile_input" | "processing" | "done" | "error"
|
||||
export type OnboardingSource = "x" | "linkedin" | "resume"
|
||||
|
||||
// Helper function to safely capture events
|
||||
const safeCapture = (
|
||||
eventName: string,
|
||||
|
|
@ -40,12 +43,13 @@ export const analytics = {
|
|||
upgradeCompleted: () => safeCapture("upgrade_completed"),
|
||||
billingPortalOpened: () => safeCapture("billing_portal_opened"),
|
||||
|
||||
connectionAdded: (provider: string) =>
|
||||
safeCapture("connection_added", { provider }),
|
||||
connectionDeleted: () => safeCapture("connection_deleted"),
|
||||
connectionAuthStarted: () => safeCapture("connection_auth_started"),
|
||||
connectionAuthCompleted: () => safeCapture("connection_auth_completed"),
|
||||
connectionAuthFailed: () => safeCapture("connection_auth_failed"),
|
||||
connectionAuthStarted: (props: { provider: string }) =>
|
||||
safeCapture("connection_auth_started", props),
|
||||
|
||||
// integrations surface (main Nova page)
|
||||
integrationCardClicked: (props: { kind: string; id: string; name: string }) =>
|
||||
safeCapture("integration_card_clicked", props),
|
||||
|
||||
nextAppResearchCtaDismissed: () =>
|
||||
safeCapture("next_app_research_cta_dismissed"),
|
||||
|
|
@ -72,21 +76,13 @@ export const analytics = {
|
|||
addDocumentModalOpened: () => safeCapture("add_document_modal_opened"),
|
||||
|
||||
// onboarding analytics
|
||||
onboardingStepViewed: (props: { step: string; trigger: "user" | "auto" }) =>
|
||||
safeCapture("onboarding_step_viewed", props),
|
||||
onboardingStepViewed: (props: {
|
||||
step: OnboardingStep
|
||||
trigger: "user" | "auto"
|
||||
}) => safeCapture("onboarding_step_viewed", props),
|
||||
|
||||
onboardingNameSubmitted: (props: { name_length: number }) =>
|
||||
safeCapture("onboarding_name_submitted", props),
|
||||
|
||||
onboardingProfileSubmitted: (props: {
|
||||
has_twitter: boolean
|
||||
has_linkedin: boolean
|
||||
other_links_count: number
|
||||
description_length: number
|
||||
}) => safeCapture("onboarding_profile_submitted", props),
|
||||
|
||||
onboardingRelatableSelected: (props: { options: string[] }) =>
|
||||
safeCapture("onboarding_relatable_selected", props),
|
||||
onboardingProfileSubmitted: (props: { source: OnboardingSource }) =>
|
||||
safeCapture("onboarding_profile_submitted", props),
|
||||
|
||||
onboardingIntegrationClicked: (props: { integration: string }) =>
|
||||
safeCapture("onboarding_integration_clicked", props),
|
||||
|
|
@ -100,7 +96,13 @@ export const analytics = {
|
|||
onboardingXBookmarksDetailOpened: () =>
|
||||
safeCapture("onboarding_x_bookmarks_detail_opened"),
|
||||
|
||||
onboardingCompleted: () => safeCapture("onboarding_completed"),
|
||||
onboardingSkipped: (props: { from_step: OnboardingStep }) =>
|
||||
safeCapture("onboarding_skipped", props),
|
||||
|
||||
onboardingCompleted: (props?: {
|
||||
source?: OnboardingSource
|
||||
memories_count?: number
|
||||
}) => safeCapture("onboarding_completed", props),
|
||||
|
||||
// main app analytics
|
||||
searchOpened: (props: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,22 @@
|
|||
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
const DEV_APP_ORIGIN = "https://app.dev.supermemory.ai"
|
||||
const PROD_APP_ORIGIN = "https://app.supermemory.ai"
|
||||
|
||||
export function getAppOriginForCurrentEnvironment(hostname?: string): string {
|
||||
const currentHostname =
|
||||
hostname ?? (typeof window !== "undefined" ? window.location.hostname : "")
|
||||
const normalized = currentHostname.toLowerCase()
|
||||
const isLocalOrDev =
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
PROXY_LOCAL_HOSTS.has(normalized) ||
|
||||
normalized.includes("app.dev.supermemory")
|
||||
|
||||
return isLocalOrDev ? DEV_APP_ORIGIN : PROD_APP_ORIGIN
|
||||
}
|
||||
|
||||
export function getBillingSettingsUrl(hostname?: string): string {
|
||||
return `${getAppOriginForCurrentEnvironment(hostname)}/settings#billing`
|
||||
}
|
||||
|
||||
/** Reconstruct the browser-facing URL when running behind portless (or similar). */
|
||||
export function getPublicRequestUrl(request: Request): URL {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ interface LegendProps {
|
|||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
colors: GraphThemeColors
|
||||
compact?: boolean
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
function HexagonIcon({
|
||||
|
|
@ -191,6 +193,8 @@ export const Legend = memo(function Legend({
|
|||
edges = [],
|
||||
isLoading: _isLoading = false,
|
||||
colors,
|
||||
compact = false,
|
||||
maxHeight,
|
||||
}: LegendProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
|
||||
|
|
@ -201,7 +205,8 @@ export const Legend = memo(function Legend({
|
|||
|
||||
const outerStyle: React.CSSProperties = {
|
||||
overflow: "hidden",
|
||||
width: 214,
|
||||
width: compact ? "min(214px, calc(100vw - 32px))" : 214,
|
||||
maxWidth: "100%",
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
|
|
@ -209,6 +214,7 @@ export const Legend = memo(function Legend({
|
|||
backgroundColor: colors.controlBg,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
|
||||
maxHeight,
|
||||
}
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
|
|
@ -217,6 +223,7 @@ export const Legend = memo(function Legend({
|
|||
alignItems: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
background: "none",
|
||||
|
|
@ -263,6 +270,21 @@ export const Legend = memo(function Legend({
|
|||
gap: 8,
|
||||
}
|
||||
|
||||
const expandedContentStyle: React.CSSProperties = {
|
||||
marginTop: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
...(compact
|
||||
? {
|
||||
maxHeight: maxHeight ? Math.max(maxHeight - 56, 112) : 220,
|
||||
overflowY: "auto",
|
||||
overscrollBehavior: "contain",
|
||||
paddingRight: 2,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div style={cardStyle}>
|
||||
|
|
@ -281,14 +303,7 @@ export const Legend = memo(function Legend({
|
|||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={expandedContentStyle}>
|
||||
{/* Statistics section */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={sectionLabelStyle}>Statistics</span>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export function MemoryGraph({
|
|||
const graphFitHeight = isCompactViewport
|
||||
? Math.max(containerSize.height - 170, 240)
|
||||
: containerSize.height
|
||||
const compactLegendMaxHeight = isCompactViewport
|
||||
? Math.max(containerSize.height - 104, 160)
|
||||
: undefined
|
||||
|
||||
// Rebuild version chain index during render (not in an effect) so that
|
||||
// the chain data is up-to-date when getChain() is called in useMemo below.
|
||||
|
|
@ -644,13 +647,14 @@ export function MemoryGraph({
|
|||
|
||||
const bottomLeftStackStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
bottom: isCompactViewport ? 12 : 16,
|
||||
left: isCompactViewport ? 12 : 16,
|
||||
zIndex: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
maxWidth: isCompactViewport ? "calc(100% - 24px)" : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -722,7 +726,9 @@ export function MemoryGraph({
|
|||
<Legend
|
||||
colors={colors}
|
||||
edges={edges}
|
||||
compact={isCompactViewport}
|
||||
isLoading={isLoading}
|
||||
maxHeight={compactLegendMaxHeight}
|
||||
nodes={nodes}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue