diff --git a/README.md b/README.md index a134afad..7760c85b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

- State-of-the-art memory and context engine for AI. + State-of-the-art memory and context engine for AI. And yes - you can use it as a company/personal brain.

diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx index 498a75a2..ac646ff7 100644 --- a/apps/browser-extension/entrypoints/popup/App.tsx +++ b/apps/browser-extension/entrypoints/popup/App.tsx @@ -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]) diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts index c810e73f..b7b6147f 100644 --- a/apps/browser-extension/wxt.config.ts +++ b/apps/browser-extension/wxt.config.ts @@ -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/*", diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 2c5eaf07..3d67b3ba 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -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 = { + 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(null) const pollingRef = useRef | null>(null) const skippingRef = useRef(false) + const completedTrackedRef = useRef(false) const [isSkipping, setIsSkipping] = useState(false) const [spotlightCategory, setSpotlightCategory] = useState("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) diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 6c70deb2..17433857 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -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 ( diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 49fc56fc..dbdacdfc 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -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") diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 5218f989..f044e533 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -38,6 +38,7 @@ export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover", + interactiveWidget: "resizes-content", } export default function RootLayout({ diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index ac92197c..6dccfb7d 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -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", )} >

- {layout === "page" && isMobile && ( - - )} {!isStackedInput && ( <> 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"), )} > diff --git a/apps/web/components/document-modal/graph-list-memories.tsx b/apps/web/components/document-modal/graph-list-memories.tsx index c8f4ae8d..313424e7 100644 --- a/apps/web/components/document-modal/graph-list-memories.tsx +++ b/apps/web/components/document-modal/graph-list-memories.tsx @@ -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>( @@ -193,7 +195,10 @@ export function GraphListMemories({ return (
!open && onClose()}> - + 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 = ( +
+ +
+ ) + + const documentInsights = ( +
+ {hasPluginInsights && } + {_document && (_document.summary || pluginDocument?.summary) && ( + + )} + {_document?.memoryEntries && _document.memoryEntries.length > 0 && ( + + )} +
+ ) + + const modalContent = ( + <> + {isMobile ? ( + + {_document?.title} - Document + + ) : ( {_document?.title} - Document -
-
- - </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> ) diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index fd81bf52..c70c62d0 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -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 diff --git a/apps/web/components/initial-header.tsx b/apps/web/components/initial-header.tsx index 831fb77c..95bc9628 100644 --- a/apps/web/components/initial-header.tsx +++ b/apps/web/components/initial-header.tsx @@ -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 ?? "/") } diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index e602f070..5a5a7161 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -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> ) diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 155659b5..fa3d7dc9 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -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"> diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index 0a650a15..6fe59994 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -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> diff --git a/apps/web/components/settings/integrations.tsx b/apps/web/components/settings/integrations.tsx index 125cec90..355a51e1 100644 --- a/apps/web/components/settings/integrations.tsx +++ b/apps/web/components/settings/integrations.tsx @@ -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> diff --git a/apps/web/components/settings/org-context.tsx b/apps/web/components/settings/org-context.tsx index dac97f7e..8c17ed01 100644 --- a/apps/web/components/settings/org-context.tsx +++ b/apps/web/components/settings/org-context.tsx @@ -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" /> diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index d762654e..132ebeb3 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -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: { diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts index cfd57209..8fc7d29e 100644 --- a/apps/web/lib/url-helpers.ts +++ b/apps/web/lib/url-helpers.ts @@ -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 { diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index 724efdd7..bd6b2f3a 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -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> diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index ed5ec094..dd82f490 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -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>