diff options
| author | Dhravya Shah <[email protected]> | 2026-01-26 23:25:45 -0700 |
|---|---|---|
| committer | Dhravya Shah <[email protected]> | 2026-01-26 23:25:45 -0700 |
| commit | c0c106f68c39c17f01af533d4f3a6a5a056e6bb6 (patch) | |
| tree | 6c15ab1cf453c5faa5570a1d4be315ae0daad094 | |
| parent | security: fix CVE-2026-23864 (diff) | |
| parent | fix: local db sync issue and threads pull (#708) (diff) | |
| download | supermemory-c0c106f68c39c17f01af533d4f3a6a5a056e6bb6.tar.xz supermemory-c0c106f68c39c17f01af533d4f3a6a5a056e6bb6.zip | |
Merge branch 'main' of https://github.com/supermemoryai/supermemory
30 files changed, 945 insertions, 234 deletions
diff --git a/apps/web/app/(navigation)/page.tsx b/apps/web/app/(navigation)/page.tsx index 73da4f3c..39b6f47c 100644 --- a/apps/web/app/(navigation)/page.tsx +++ b/apps/web/app/(navigation)/page.tsx @@ -1,10 +1,11 @@ "use client" import { useOnboardingStorage } from "@hooks/use-onboarding-storage" +import { useOrgOnboarding } from "@hooks/use-org-onboarding" import { useAuth } from "@lib/auth-context" import { ChevronsDown, LoaderIcon } from "lucide-react" import { useRouter } from "next/navigation" -import { useEffect } from "react" +import { useEffect, useMemo } from "react" import { InstallPrompt } from "@/components/install-prompt" import { ChromeExtensionButton } from "@/components/chrome-extension-button" import { ChatInput } from "@/components/chat-input" @@ -14,11 +15,37 @@ import { useFeatureFlagEnabled } from "posthog-js/react" export default function Page() { const { user, session } = useAuth() - const { shouldShowOnboarding, isLoading: onboardingLoading } = - useOnboardingStorage() const router = useRouter() const flagEnabled = useFeatureFlagEnabled("nova-alpha-access") + // TODO: remove this flow after the feature flag is removed + // Old app: localStorage-backed onboarding + const { + shouldShowOnboarding: shouldShowOldOnboarding, + isLoading: oldOnboardingLoading, + } = useOnboardingStorage() + + // New app: DB-backed onboarding (org.metadata.isOnboarded) + const { + shouldShowOnboarding: shouldShowNewOnboarding, + isLoading: newOnboardingLoading, + } = useOrgOnboarding() + + // Select the appropriate onboarding state based on feature flag + const isOnboardingLoading = useMemo(() => { + if (flagEnabled) { + return newOnboardingLoading + } + return oldOnboardingLoading + }, [flagEnabled, newOnboardingLoading, oldOnboardingLoading]) + + const shouldShowOnboarding = useMemo(() => { + if (flagEnabled) { + return shouldShowNewOnboarding() + } + return shouldShowOldOnboarding() + }, [flagEnabled, shouldShowNewOnboarding, shouldShowOldOnboarding]) + useEffect(() => { const url = new URL(window.location.href) const authenticateChromeExtension = url.searchParams.get( @@ -46,16 +73,16 @@ export default function Page() { }, [user, session]) useEffect(() => { - if (user && !onboardingLoading && shouldShowOnboarding()) { + if (user && !isOnboardingLoading && shouldShowOnboarding) { if (flagEnabled) { router.push("/new/onboarding?step=input&flow=welcome") } else { router.push("/onboarding") } } - }, [user, shouldShowOnboarding, onboardingLoading, router, flagEnabled]) + }, [user, shouldShowOnboarding, isOnboardingLoading, router, flagEnabled]) - if (!user || onboardingLoading) { + if (!user || isOnboardingLoading) { return ( <div className="min-h-screen flex items-center justify-center bg-[#0f1419]"> <div className="flex flex-col items-center gap-4"> @@ -66,7 +93,7 @@ export default function Page() { ) } - if (shouldShowOnboarding()) { + if (shouldShowOnboarding) { return null } diff --git a/apps/web/app/api/onboarding/research/route.ts b/apps/web/app/api/onboarding/research/route.ts index 5e9b933e..67bf4654 100644 --- a/apps/web/app/api/onboarding/research/route.ts +++ b/apps/web/app/api/onboarding/research/route.ts @@ -7,11 +7,22 @@ interface ResearchRequest { email?: string } -// prompt to get user context from X/Twitter profile -function finalPrompt(xUrl: string, userContext: string) { +function extractHandle(url: string): string { + const cleaned = url + .toLowerCase() + .replace("https://x.com/", "") + .replace("https://twitter.com/", "") + .replace("http://x.com/", "") + .replace("http://twitter.com/", "") + .replace("@", "") + + return (cleaned.split("/")[0] ?? cleaned).split("?")[0] ?? cleaned +} + +function finalPrompt(handle: string, userContext: string) { return `You are researching a user based on their X/Twitter profile to help personalize their experience. -X/Twitter Profile URL: ${xUrl}${userContext} +X Handle: @${handle}${userContext} Please analyze this X/Twitter profile and provide a comprehensive but concise summary of the user. Include: - Professional background and current role (if available) @@ -29,18 +40,12 @@ export async function POST(req: Request) { if (!xUrl?.trim()) { return Response.json( - { error: "X/Twitter URL is required" }, + { error: "X/Twitter URL or handle is required" }, { status: 400 }, ) } - const lowerUrl = xUrl.toLowerCase() - if (!lowerUrl.includes("x.com") && !lowerUrl.includes("twitter.com")) { - return Response.json( - { error: "URL must be an X/Twitter profile link" }, - { status: 400 }, - ) - } + const handle = extractHandle(xUrl) const contextParts: string[] = [] if (name) contextParts.push(`Name: ${name}`) @@ -51,29 +56,13 @@ export async function POST(req: Request) { : "" const { text } = await generateText({ - model: xai("grok-4-1-fast-reasoning"), - prompt: finalPrompt(xUrl, userContext), - providerOptions: { - xai: { - searchParameters: { - mode: "on", - sources: [ - { - type: "web", - safeSearch: true, - }, - { - type: "x", - includedXHandles: [ - lowerUrl - .replace("https://x.com/", "") - .replace("https://twitter.com/", ""), - ], - postFavoriteCount: 10, - }, - ], - }, - }, + model: xai.responses("grok-4-fast"), + prompt: finalPrompt(handle, userContext), + tools: { + web_search: xai.tools.webSearch(), + x_search: xai.tools.xSearch({ + allowedXHandles: [handle], + }), }, }) diff --git a/apps/web/app/new/layout.tsx b/apps/web/app/new/layout.tsx index e761fea2..f0cc0c1e 100644 --- a/apps/web/app/new/layout.tsx +++ b/apps/web/app/new/layout.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react" import { useFeatureFlagEnabled } from "posthog-js/react" import { useRouter } from "next/navigation" +import { MobileBanner } from "@/components/new/mobile-banner" export default function NewLayout({ children }: { children: React.ReactNode }) { const router = useRouter() @@ -18,5 +19,10 @@ export default function NewLayout({ children }: { children: React.ReactNode }) { return null } - return <>{children}</> + return ( + <> + <MobileBanner /> + {children} + </> + ) } diff --git a/apps/web/app/new/onboarding/setup/layout.tsx b/apps/web/app/new/onboarding/setup/layout.tsx index 3a958469..6694f5fd 100644 --- a/apps/web/app/new/onboarding/setup/layout.tsx +++ b/apps/web/app/new/onboarding/setup/layout.tsx @@ -1,8 +1,16 @@ "use client" -import { createContext, useContext, useCallback, type ReactNode } from "react" +import { + createContext, + useContext, + useCallback, + useEffect, + useRef, + type ReactNode, +} from "react" import { useRouter, useSearchParams } from "next/navigation" import { useOnboardingContext, type MemoryFormData } from "../layout" +import { analytics } from "@/lib/analytics" export const SETUP_STEPS = ["relatable", "integrations"] as const export type SetupStep = (typeof SETUP_STEPS)[number] @@ -34,9 +42,11 @@ export default function SetupLayout({ children }: { children: ReactNode }) { const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep) ? (stepParam as SetupStep) : "relatable" + const hasTrackedInitialStep = useRef(false) const goToStep = useCallback( (step: SetupStep) => { + analytics.onboardingStepViewed({ step, trigger: "user" }) router.push(`/new/onboarding/setup?step=${step}`) }, [router], @@ -54,6 +64,13 @@ export default function SetupLayout({ children }: { children: ReactNode }) { router.push("/new") }, [router, resetOnboarding]) + useEffect(() => { + if (!hasTrackedInitialStep.current) { + analytics.onboardingStepViewed({ step: currentStep, trigger: "user" }) + hasTrackedInitialStep.current = true + } + }, [currentStep]) + const contextValue: SetupContextValue = { memoryFormData, currentStep, diff --git a/apps/web/app/new/onboarding/welcome/layout.tsx b/apps/web/app/new/onboarding/welcome/layout.tsx index 427907c2..07d3c4b3 100644 --- a/apps/web/app/new/onboarding/welcome/layout.tsx +++ b/apps/web/app/new/onboarding/welcome/layout.tsx @@ -11,6 +11,7 @@ import { } from "react" import { useRouter, useSearchParams } from "next/navigation" import { useOnboardingContext, type MemoryFormData } from "../layout" +import { analytics } from "@/lib/analytics" export const WELCOME_STEPS = [ "input", @@ -61,6 +62,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { const [isSubmitting, setIsSubmitting] = useState(false) const [showWelcomeContent, setShowWelcomeContent] = useState(false) const isMountedRef = useRef(true) + const hasTrackedInitialStep = useRef(false) useEffect(() => { isMountedRef.current = true @@ -89,6 +91,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { timers.push( setTimeout(() => { if (isMountedRef.current) { + analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" }) router.replace("/new/onboarding/welcome?step=welcome") } }, 2000), @@ -97,6 +100,10 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { timers.push( setTimeout(() => { if (isMountedRef.current) { + analytics.onboardingStepViewed({ + step: "username", + trigger: "auto", + }) router.replace("/new/onboarding/welcome?step=username") } }, 2000), @@ -108,8 +115,19 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) { } }, [currentStep, router]) + useEffect(() => { + if (!hasTrackedInitialStep.current) { + analytics.onboardingStepViewed({ + step: currentStep, + trigger: "user", + }) + hasTrackedInitialStep.current = true + } + }, [currentStep]) + const goToStep = useCallback( (step: WelcomeStep) => { + analytics.onboardingStepViewed({ step, trigger: "user" }) router.push(`/new/onboarding/welcome?step=${step}`) }, [router], diff --git a/apps/web/app/new/onboarding/welcome/page.tsx b/apps/web/app/new/onboarding/welcome/page.tsx index 706cd40a..a09f93b5 100644 --- a/apps/web/app/new/onboarding/welcome/page.tsx +++ b/apps/web/app/new/onboarding/welcome/page.tsx @@ -19,6 +19,7 @@ import { } from "./layout" import { gapVariants, orbVariants } from "@/lib/variants" import { authClient } from "@lib/auth" +import { analytics } from "@/lib/analytics" function UserSupermemory({ name }: { name: string }) { return ( @@ -88,6 +89,7 @@ export default function WelcomePage() { console.error("Failed to update displayUsername:", error) } + analytics.onboardingNameSubmitted({ name_length: name.trim().length }) goToStep("greeting") setIsSubmitting(false) } diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx index 31fab182..0eb8613c 100644 --- a/apps/web/app/new/page.tsx +++ b/apps/web/app/new/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useCallback } from "react" +import { useState, useCallback, useEffect } from "react" import { Header } from "@/components/new/header" import { ChatSidebar } from "@/components/new/chat" import { MemoriesGrid } from "@/components/new/memories-grid" @@ -16,7 +16,10 @@ import { useHotkeys } from "react-hotkeys-hook" import { AnimatePresence } from "motion/react" import { useIsMobile } from "@hooks/use-mobile" import { useProject } from "@/stores" -import { useQuickNoteDraftReset } from "@/stores/quick-note-draft" +import { + useQuickNoteDraftReset, + useQuickNoteDraft, +} from "@/stores/quick-note-draft" import { analytics } from "@/lib/analytics" import { useDocumentMutations } from "@/hooks/use-document-mutations" import { useQuery } from "@tanstack/react-query" @@ -42,6 +45,7 @@ export default function NewPage() { const [searchPrefill, setSearchPrefill] = useState("") const resetDraft = useQuickNoteDraftReset(selectedProject) + const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "") const { noteMutation } = useDocumentMutations({ onClose: () => { @@ -92,11 +96,19 @@ export default function NewPage() { }) useHotkeys("mod+k", (e) => { e.preventDefault() + analytics.searchOpened({ source: "hotkey" }) setIsSearchOpen(true) }) const [isChatOpen, setIsChatOpen] = useState(!isMobile) + useEffect(() => { + setIsChatOpen(!isMobile) + }, [isMobile]) + const handleOpenDocument = useCallback((document: DocumentWithMemories) => { + if (document.id) { + analytics.documentModalOpened({ document_id: document.id }) + } setSelectedDocument(document) setIsDocumentModalOpen(true) }, []) @@ -104,28 +116,50 @@ export default function NewPage() { const handleQuickNoteSave = useCallback( (content: string) => { if (content.trim()) { - noteMutation.mutate({ content, project: selectedProject }) + const hadPreviousContent = quickNoteDraft.trim().length > 0 + noteMutation.mutate( + { content, project: selectedProject }, + { + onSuccess: () => { + if (hadPreviousContent) { + analytics.quickNoteEdited() + } else { + analytics.quickNoteCreated() + } + }, + }, + ) } }, - [selectedProject, noteMutation], + [selectedProject, noteMutation, quickNoteDraft], ) const handleFullScreenSave = useCallback( (content: string) => { if (content.trim()) { - noteMutation.mutate({ content, project: selectedProject }) + const hadInitialContent = fullscreenInitialContent.trim().length > 0 + noteMutation.mutate( + { content, project: selectedProject }, + { + onSuccess: () => { + if (hadInitialContent) { + analytics.quickNoteEdited() + } else { + analytics.quickNoteCreated() + } + }, + }, + ) } }, - [selectedProject, noteMutation], + [selectedProject, noteMutation, fullscreenInitialContent], ) - const handleMaximize = useCallback( - (content: string) => { - setFullscreenInitialContent(content) - setIsFullScreenNoteOpen(true) - }, - [], - ) + const handleMaximize = useCallback((content: string) => { + analytics.fullscreenNoteModalOpened() + setFullscreenInitialContent(content) + setIsFullScreenNoteOpen(true) + }, []) const handleHighlightsChat = useCallback((seed: string) => { setQueuedChatSeed(seed) @@ -133,6 +167,7 @@ export default function NewPage() { }, []) const handleHighlightsShowRelated = useCallback((query: string) => { + analytics.searchOpened({ source: "highlight_related" }) setSearchPrefill(query) setIsSearchOpen(true) }, []) @@ -154,7 +189,10 @@ export default function NewPage() { setIsMCPModalOpen(true) }} onOpenChat={() => setIsChatOpen(true)} - onOpenSearch={() => setIsSearchOpen(true)} + onOpenSearch={() => { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} /> <main key={`main-container-${isChatOpen}`} diff --git a/apps/web/app/new/settings/page.tsx b/apps/web/app/new/settings/page.tsx index f1972bb9..b2e35b7f 100644 --- a/apps/web/app/new/settings/page.tsx +++ b/apps/web/app/new/settings/page.tsx @@ -13,6 +13,7 @@ import ConnectionsMCP from "@/components/new/settings/connections-mcp" import Support from "@/components/new/settings/support" import { useRouter } from "next/navigation" import { useIsMobile } from "@hooks/use-mobile" +import { analytics } from "@/lib/analytics" const TABS = ["account", "integrations", "connections", "support"] as const type SettingsTab = (typeof TABS)[number] @@ -158,6 +159,7 @@ export default function SettingsPage() { const hash = window.location.hash const tab = parseHashToTab(hash) setActiveTab(tab) + analytics.settingsTabChanged({ tab }) // If no hash or invalid hash, push #account if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) { @@ -169,6 +171,7 @@ export default function SettingsPage() { const handleHashChange = () => { const tab = parseHashToTab(window.location.hash) setActiveTab(tab) + analytics.settingsTabChanged({ tab }) } window.addEventListener("hashchange", handleHashChange) @@ -230,6 +233,7 @@ export default function SettingsPage() { onClick={() => { window.location.hash = item.id setActiveTab(item.id) + analytics.settingsTabChanged({ tab: item.id }) }} className={cn( "rounded-xl transition-colors flex items-start gap-3 shrink-0", diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx index dcf64ad0..cee9879b 100644 --- a/apps/web/app/onboarding/page.tsx +++ b/apps/web/app/onboarding/page.tsx @@ -4,6 +4,7 @@ import { OnboardingProvider } from "./onboarding-context" import { OnboardingProgressBar } from "./progress-bar" import { redirect } from "next/navigation" import { OnboardingBackground } from "./onboarding-background" +import { OnboardingWrapper } from "@/components/onboarding/onboarding-wrapper" import type { Metadata } from "next" export const metadata: Metadata = { title: "Welcome to Supermemory", @@ -16,11 +17,13 @@ export default function OnboardingPage() { if (!session) redirect("/login") return ( - <OnboardingProvider> - <OnboardingProgressBar /> - <OnboardingBackground> - <OnboardingForm /> - </OnboardingBackground> - </OnboardingProvider> + <OnboardingWrapper> + <OnboardingProvider> + <OnboardingProgressBar /> + <OnboardingBackground> + <OnboardingForm /> + </OnboardingBackground> + </OnboardingProvider> + </OnboardingWrapper> ) } diff --git a/apps/web/components/new/add-space-modal.tsx b/apps/web/components/new/add-space-modal.tsx index 63ee5e96..fdc5347f 100644 --- a/apps/web/components/new/add-space-modal.tsx +++ b/apps/web/components/new/add-space-modal.tsx @@ -9,6 +9,7 @@ import { XIcon, Loader2 } from "lucide-react" import { Button } from "@ui/components/button" import { useProjectMutations } from "@/hooks/use-project-mutations" import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { analytics } from "@/lib/analytics" const EMOJI_LIST = [ "📁", @@ -87,6 +88,7 @@ export function AddSpaceModal({ { name: trimmedName, emoji: emoji || undefined }, { onSuccess: () => { + analytics.spaceCreated() handleClose() }, }, diff --git a/apps/web/components/new/chat/index.tsx b/apps/web/components/new/chat/index.tsx index 22a77812..51bbe455 100644 --- a/apps/web/components/new/chat/index.tsx +++ b/apps/web/components/new/chat/index.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useCallback, useRef } from "react" +import type { UIMessage } from "@ai-sdk/react" import { motion, AnimatePresence } from "motion/react" import { useChat } from "@ai-sdk/react" import { DefaultChatTransport } from "ai" @@ -32,8 +33,7 @@ import { dmSansClassName } from "@/lib/fonts" import ChatInput from "./input" import ChatModelSelector from "./model-selector" import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo" -import { useProject, usePersistentChat } from "@/stores" -import { areUIMessageArraysEqual } from "@/stores/chat" +import { useProject } from "@/stores" import type { ModelId } from "@/lib/models" import { SuperLoader } from "../../superloader" import { UserMessage } from "./message/user-message" @@ -135,23 +135,11 @@ export function ChatSidebar({ const pendingFollowUpGenerations = useRef<Set<string>>(new Set()) const messagesContainerRef = useRef<HTMLDivElement>(null) const { selectedProject } = useProject() - const { - currentChatId, - setCurrentChatId, - setConversation, - getCurrentConversation, - } = usePersistentChat() - const lastSavedMessagesRef = useRef<typeof messages | null>(null) - const lastSavedActiveIdRef = useRef<string | null>(null) - const lastLoadedChatIdRef = useRef<string | null>(null) - const lastLoadedMessagesRef = useRef<typeof messages | null>(null) - - // Initialize chat ID if none exists - useEffect(() => { - if (!currentChatId) { - setCurrentChatId(generateId()) - } - }, [currentChatId, setCurrentChatId]) + const [currentChatId, setCurrentChatId] = useState<string>(() => generateId()) + const [pendingThreadLoad, setPendingThreadLoad] = useState<{ + id: string + messages: UIMessage[] + } | null>(null) // Adjust chat height based on scroll position (desktop only) useEffect(() => { @@ -195,58 +183,12 @@ export function ChatSidebar({ }, }) - // Restore messages from store when currentChatId changes - useEffect(() => { - if (currentChatId !== lastLoadedChatIdRef.current) { - lastLoadedMessagesRef.current = null - lastSavedMessagesRef.current = null - } - - if (currentChatId === lastLoadedChatIdRef.current) { - return - } - - const msgs = getCurrentConversation() - - if (msgs && msgs.length > 0) { - const currentMessages = lastLoadedMessagesRef.current - if (!currentMessages || !areUIMessageArraysEqual(currentMessages, msgs)) { - lastLoadedMessagesRef.current = msgs - setMessages(msgs) - } - } else if (!currentChatId) { - if ( - lastLoadedMessagesRef.current && - lastLoadedMessagesRef.current.length > 0 - ) { - lastLoadedMessagesRef.current = [] - setMessages([]) - } - } - - lastLoadedChatIdRef.current = currentChatId - }, [currentChatId, getCurrentConversation, setMessages]) - - // Persist messages to store whenever they change useEffect(() => { - const activeId = currentChatId - if (!activeId || messages.length === 0) { - return - } - - if (activeId !== lastSavedActiveIdRef.current) { - lastSavedMessagesRef.current = null - lastSavedActiveIdRef.current = activeId - } - - const lastSaved = lastSavedMessagesRef.current - if (lastSaved && areUIMessageArraysEqual(lastSaved, messages)) { - return + if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) { + setMessages(pendingThreadLoad.messages) + setPendingThreadLoad(null) } - - lastSavedMessagesRef.current = messages - setConversation(activeId, messages) - }, [messages, currentChatId, setConversation]) + }, [currentChatId, pendingThreadLoad, setMessages]) // Generate follow-up questions after assistant messages are complete useEffect(() => { @@ -362,6 +304,7 @@ export function ChatSidebar({ const handleSend = () => { if (!input.trim() || status === "submitted" || status === "streaming") return + analytics.chatMessageSent({ source: "typed" }) sendMessage({ text: input }) setInput("") scrollToBottom() @@ -379,27 +322,50 @@ export function ChatSidebar({ } const handleCopyMessage = useCallback((messageId: string, text: string) => { + analytics.chatMessageCopied({ message_id: messageId }) navigator.clipboard.writeText(text) setCopiedMessageId(messageId) setTimeout(() => setCopiedMessageId(null), 2000) }, []) - const handleLikeMessage = useCallback((messageId: string) => { - setMessageFeedback((prev) => ({ - ...prev, - [messageId]: prev[messageId] === "like" ? null : "like", - })) - }, []) + const handleLikeMessage = useCallback( + (messageId: string) => { + const wasLiked = messageFeedback[messageId] === "like" + setMessageFeedback((prev) => ({ + ...prev, + [messageId]: prev[messageId] === "like" ? null : "like", + })) + if (!wasLiked) { + analytics.chatMessageLiked({ message_id: messageId }) + } + }, + [messageFeedback], + ) - const handleDislikeMessage = useCallback((messageId: string) => { - setMessageFeedback((prev) => ({ - ...prev, - [messageId]: prev[messageId] === "dislike" ? null : "dislike", - })) - }, []) + const handleDislikeMessage = useCallback( + (messageId: string) => { + const wasDisliked = messageFeedback[messageId] === "dislike" + setMessageFeedback((prev) => ({ + ...prev, + [messageId]: prev[messageId] === "dislike" ? null : "dislike", + })) + if (!wasDisliked) { + analytics.chatMessageDisliked({ message_id: messageId }) + } + }, + [messageFeedback], + ) const handleToggleMemories = useCallback((messageId: string) => { - setExpandedMemories((prev) => (prev === messageId ? null : messageId)) + setExpandedMemories((prev) => { + const isExpanding = prev !== messageId + if (isExpanding) { + analytics.chatMemoryExpanded({ message_id: messageId }) + } else { + analytics.chatMemoryCollapsed({ message_id: messageId }) + } + return prev === messageId ? null : messageId + }) }, []) const handleNewChat = useCallback(() => { @@ -408,7 +374,7 @@ export function ChatSidebar({ setCurrentChatId(newId) setMessages([]) setInput("") - }, [setCurrentChatId, setMessages]) + }, [setMessages]) const fetchThreads = useCallback(async () => { setIsLoadingThreads(true) @@ -428,41 +394,37 @@ export function ChatSidebar({ } }, [selectedProject]) - const loadThread = useCallback( - async (threadId: string) => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`, - { credentials: "include" }, + const loadThread = useCallback(async (threadId: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`, + { credentials: "include" }, + ) + if (response.ok) { + const data = await response.json() + const uiMessages = data.messages.map( + (m: { + id: string + role: string + parts: unknown + createdAt: string + }) => ({ + id: m.id, + role: m.role, + parts: m.parts || [], + createdAt: new Date(m.createdAt), + }), ) - if (response.ok) { - const data = await response.json() - setCurrentChatId(threadId) - // Convert API messages to UIMessage format - const uiMessages = data.messages.map( - (m: { - id: string - role: string - parts: unknown - createdAt: string - }) => ({ - id: m.id, - role: m.role, - parts: m.parts || [], - createdAt: new Date(m.createdAt), - }), - ) - setMessages(uiMessages) - setConversation(threadId, uiMessages) // persist messages to store - setIsHistoryOpen(false) - setConfirmingDeleteId(null) - } - } catch (error) { - console.error("Failed to load thread:", error) + setCurrentChatId(threadId) + setPendingThreadLoad({ id: threadId, messages: uiMessages }) + analytics.chatThreadLoaded({ thread_id: threadId }) + setIsHistoryOpen(false) + setConfirmingDeleteId(null) } - }, - [setCurrentChatId, setMessages, setConversation], - ) + } catch (error) { + console.error("Failed to load thread:", error) + } + }, []) const deleteThread = useCallback( async (threadId: string) => { @@ -472,6 +434,7 @@ export function ChatSidebar({ { method: "DELETE", credentials: "include" }, ) if (response.ok) { + analytics.chatThreadDeleted({ thread_id: threadId }) setThreads((prev) => prev.filter((t) => t.id !== threadId)) if (currentChatId === threadId) { handleNewChat() @@ -524,6 +487,7 @@ export function ChatSidebar({ status !== "submitted" && status !== "streaming" ) { + analytics.chatMessageSent({ source: "highlight" }) sendMessage({ text: queuedMessage }) onConsumeQueuedMessage?.() } @@ -667,7 +631,8 @@ export function ChatSidebar({ variant="headers" className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer" style={{ - boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset", + boxShadow: + "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset", }} > <HistoryIcon className="size-4 text-[#737373]" /> @@ -833,6 +798,8 @@ export function ChatSidebar({ {messages.length === 0 && ( <ChatEmptyStatePlaceholder onSuggestionClick={(suggestion) => { + analytics.chatSuggestedQuestionClicked() + analytics.chatMessageSent({ source: "suggested" }) sendMessage({ text: suggestion }) }} suggestions={emptyStateSuggestions} @@ -883,6 +850,10 @@ export function ChatSidebar({ onDislike={handleDislikeMessage} onToggleMemories={handleToggleMemories} onQuestionClick={(question) => { + analytics.chatFollowUpClicked({ + thread_id: currentChatId || undefined, + }) + analytics.chatMessageSent({ source: "follow_up" }) setInput(question) }} /> diff --git a/apps/web/components/new/chat/model-selector.tsx b/apps/web/components/new/chat/model-selector.tsx index c1952bea..db77a5c7 100644 --- a/apps/web/components/new/chat/model-selector.tsx +++ b/apps/web/components/new/chat/model-selector.tsx @@ -6,6 +6,7 @@ import { Button } from "@ui/components/button" import { dmSansClassName } from "@/lib/fonts" import { ChevronDownIcon } from "lucide-react" import { models, type ModelId, modelNames } from "@/lib/models" +import { analytics } from "@/lib/analytics" interface ChatModelSelectorProps { selectedModel?: ModelId @@ -28,6 +29,7 @@ export default function ChatModelSelector({ } else { setInternalModel(modelId) } + analytics.modelChanged({ model: modelId }) setIsOpen(false) } diff --git a/apps/web/components/new/document-cards/tweet-preview.tsx b/apps/web/components/new/document-cards/tweet-preview.tsx index 2d9baacf..c9a30ecd 100644 --- a/apps/web/components/new/document-cards/tweet-preview.tsx +++ b/apps/web/components/new/document-cards/tweet-preview.tsx @@ -60,12 +60,12 @@ function CustomTweetHeader({ </div> <div className="flex flex-col items-start"> <div className="flex gap-0.5 items-center"> - <p - className={cn( - "font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]", - dmSansClassName(), - )} - > + <p + className={cn( + "font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]", + dmSansClassName(), + )} + > {user.name} </p> {isVerified && <VerifiedBadge />} diff --git a/apps/web/components/new/feedback-modal.tsx b/apps/web/components/new/feedback-modal.tsx new file mode 100644 index 00000000..82f4623c --- /dev/null +++ b/apps/web/components/new/feedback-modal.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useState } from "react" +import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" +import { Textarea } from "@ui/components/textarea" +import { Button } from "@ui/components/button" +import { cn } from "@lib/utils" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon, Loader2 } from "lucide-react" +import { usePostHog } from "@lib/posthog" +import { toast } from "sonner" + +interface FeedbackModalProps { + isOpen: boolean + onClose: () => void +} + +const FEEDBACK_SURVEY_ID = "019bf2dd-f002-0000-d819-8a914cb23999" +const FEEDBACK_QUESTION_ID = "0af81ccd-cb43-43a3-a61b-3a74c08a922a" +const FEEDBACK_QUESTION = "What can we do to improve our product?" + +export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) { + const [feedback, setFeedback] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const posthog = usePostHog() + + const handleSubmit = async () => { + if (!feedback.trim() || isSubmitting) return + + setIsSubmitting(true) + + try { + if (posthog.__loaded) { + const responseKey = FEEDBACK_QUESTION_ID.replace(/-/g, "_") + posthog.capture("survey sent", { + $survey_id: FEEDBACK_SURVEY_ID, + $survey_questions: [ + { + id: FEEDBACK_QUESTION_ID, + question: FEEDBACK_QUESTION, + }, + ], + [`$survey_response_${responseKey}`]: feedback.trim(), + $survey_name: "Nova feedback", + $survey_completed: true, + }) + } + + setFeedback("") + toast.success("Thank you for your feedback!") + onClose() + } catch (error) { + console.error("Failed to submit feedback:", error) + toast.error("Failed to submit feedback. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + const handleClose = () => { + if (!isSubmitting) { + setFeedback("") + onClose() + } + } + + const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + if ( + e.key === "Enter" && + (e.metaKey || e.ctrlKey) && + feedback.trim() && + !isSubmitting + ) { + e.preventDefault() + handleSubmit() + } + } + + return ( + <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}> + <DialogContent + className={cn( + "w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 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} + > + <div className="flex flex-col gap-4"> + <div className="flex justify-between items-start gap-4"> + <DialogHeader className="pl-1 space-y-1 flex-1"> + <DialogTitle + className={cn( + "font-semibold text-[#fafafa]", + dmSans125ClassName(), + )} + > + Feedback + </DialogTitle> + <p + className={cn( + "text-[#737373] font-medium text-[16px] leading-[1.35]", + )} + > + {FEEDBACK_QUESTION} + </p> + </DialogHeader> + <DialogPrimitive.Close + onClick={handleClose} + disabled={isSubmitting} + className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0" + style={{ + boxShadow: + "0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset", + }} + > + <XIcon className="size-4 text-[#737373]" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </div> + + <div className="flex flex-col gap-3"> + <Textarea + value={feedback} + onChange={(e) => setFeedback(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Share your thoughts..." + className={cn( + "min-h-[120px] bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[#fafafa] placeholder:text-[#737373] focus-visible:border-[#2261CA] focus-visible:ring-[#2261CA33] resize-none", + dmSansClassName(), + )} + disabled={isSubmitting} + /> + + <div className="flex justify-end gap-2"> + <Button + variant="ghost" + onClick={handleClose} + disabled={isSubmitting} + className={cn( + "text-[#737373] hover:text-[#fafafa] hover:bg-[#293952]/40", + dmSansClassName(), + )} + > + Cancel + </Button> + <Button + onClick={handleSubmit} + disabled={!feedback.trim() || isSubmitting} + className={cn( + "bg-[#2261CA] hover:bg-[#1a4fa0] text-white", + dmSansClassName(), + )} + > + {isSubmitting ? ( + <> + <Loader2 className="size-4 animate-spin mr-2" /> + Submitting... + </> + ) : ( + "Submit" + )} + </Button> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx index 9691f733..c244bf88 100644 --- a/apps/web/components/new/header.tsx +++ b/apps/web/components/new/header.tsx @@ -15,6 +15,7 @@ import { HelpCircle, MenuIcon, MessageCircleIcon, + RotateCcw, } from "lucide-react" import { Button } from "@ui/components/button" import { cn } from "@lib/utils" @@ -34,6 +35,9 @@ import { useRouter } from "next/navigation" import Link from "next/link" import { SpaceSelector } from "./space-selector" import { useIsMobile } from "@hooks/use-mobile" +import { useOrgOnboarding } from "@hooks/use-org-onboarding" +import { useState } from "react" +import { FeedbackModal } from "./feedback-modal" interface HeaderProps { onAddMemory?: () => void @@ -53,6 +57,17 @@ export function Header({ const { switchProject } = useProjectMutations() const router = useRouter() const isMobile = useIsMobile() + const { resetOrgOnboarded } = useOrgOnboarding() + const [isFeedbackOpen, setIsFeedbackOpen] = useState(false) + + const handleTryOnboarding = () => { + resetOrgOnboarded() + router.push("/new/onboarding?step=input&flow=welcome") + } + + const handleFeedback = () => { + setIsFeedbackOpen(true) + } const displayName = user?.displayUsername || @@ -214,6 +229,13 @@ export function Header({ </DropdownMenuItem> <DropdownMenuSeparator className="bg-[#2E3033]" /> <DropdownMenuItem + onClick={handleFeedback} + className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2" + > + <MessageCircleIcon className="h-4 w-4 text-[#737373]" /> + Feedback + </DropdownMenuItem> + <DropdownMenuItem onClick={() => router.push("/new/settings")} className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2" > @@ -275,6 +297,16 @@ export function Header({ <span className={cn(dmSansClassName())}>K</span> </span> </Button> + <Button + variant="headers" + className="rounded-full text-base gap-2 h-10!" + onClick={handleFeedback} + > + <div className="flex items-center gap-2"> + <MessageCircleIcon className="size-4" /> + Feedback + </div> + </Button> </> )} {user && ( @@ -316,6 +348,13 @@ export function Header({ <Settings className="h-4 w-4 text-[#737373]" /> Settings </DropdownMenuItem> + <DropdownMenuItem + onClick={handleTryOnboarding} + className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2" + > + <RotateCcw className="h-4 w-4 text-[#737373]" /> + Restart Onboarding + </DropdownMenuItem> <DropdownMenuSeparator className="bg-[#2E3033]" /> <DropdownMenuItem asChild @@ -361,6 +400,7 @@ export function Header({ </DropdownMenu> )} </div> + <FeedbackModal isOpen={isFeedbackOpen} onClose={() => setIsFeedbackOpen(false)} /> </div> ) } diff --git a/apps/web/components/new/highlights-card.tsx b/apps/web/components/new/highlights-card.tsx index eb3e0b44..8ca07637 100644 --- a/apps/web/components/new/highlights-card.tsx +++ b/apps/web/components/new/highlights-card.tsx @@ -12,6 +12,7 @@ import { Link2, } from "lucide-react" import { Logo } from "@ui/assets/Logo" +import { analytics } from "@/lib/analytics" export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner" @@ -83,12 +84,20 @@ export function HighlightsCard({ const handleChat = useCallback(() => { if (!currentItem) return + analytics.highlightClicked({ + highlight_id: currentItem.id, + action: "chat", + }) const seed = `Tell me more about "${currentItem.title}"` onChat(seed) }, [currentItem, onChat]) const handleShowRelated = useCallback(() => { if (!currentItem) return + analytics.highlightClicked({ + highlight_id: currentItem.id, + action: "related", + }) onShowRelated(currentItem.query || currentItem.title) }, [currentItem, onShowRelated]) diff --git a/apps/web/components/new/mobile-banner.tsx b/apps/web/components/new/mobile-banner.tsx new file mode 100644 index 00000000..245b6952 --- /dev/null +++ b/apps/web/components/new/mobile-banner.tsx @@ -0,0 +1,24 @@ +"use client" + +import { useIsMobile } from "@hooks/use-mobile" +import { cn } from "@lib/utils" + +export function MobileBanner() { + const isMobile = useIsMobile() + + if (!isMobile) { + return null + } + + return ( + <div + className={cn( + "bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/30", + "px-4 py-2 text-xs text-yellow-800 dark:text-yellow-200 text-center", + )} + id="mobile-development-banner" + > + 🚧 Mobile responsive in development. Desktop recommended. + </div> + ) +} diff --git a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx index 22e8cae1..47af432d 100644 --- a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx +++ b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx @@ -6,7 +6,13 @@ import { useChat } from "@ai-sdk/react" import { DefaultChatTransport } from "ai" import NovaOrb from "@/components/nova/nova-orb" import { Button } from "@ui/components/button" -import { PanelRightCloseIcon, SendIcon, CheckIcon, XIcon } from "lucide-react" +import { + PanelRightCloseIcon, + SendIcon, + CheckIcon, + XIcon, + Loader2, +} from "lucide-react" import { collectValidUrls } from "@/lib/url-helpers" import { $fetch } from "@lib/api" import { cn } from "@lib/utils" @@ -61,10 +67,14 @@ export function ChatSidebar({ formData }: ChatSidebarProps) { "correct" | "incorrect" | null >(null) const [isConfirmed, setIsConfirmed] = useState(false) + const [processingByUrl, setProcessingByUrl] = useState<Record<string, boolean>>( + {}, + ) const displayedMemoriesRef = useRef<Set<string>>(new Set()) const contextInjectedRef = useRef(false) const draftsBuiltRef = useRef(false) const isProcessingRef = useRef(false) + const draftRequestIdRef = useRef(0) const { messages: chatMessages, @@ -225,9 +235,27 @@ export function ChatSidebar({ formData }: ChatSidebarProps) { if (!hasContent) return + const requestId = ++draftRequestIdRef.current + setIsFetchingDrafts(true) const drafts: DraftDoc[] = [] + const urls = collectValidUrls(formData.linkedin, formData.otherLinks) + const allProcessingUrls: string[] = [...urls] + if (formData.twitter) { + allProcessingUrls.push(formData.twitter) + } + + if (allProcessingUrls.length > 0) { + setProcessingByUrl((prev) => { + const next = { ...prev } + for (const url of allProcessingUrls) { + next[url] = true + } + return next + }) + } + try { if (formData.description?.trim()) { drafts.push({ @@ -241,37 +269,66 @@ export function ChatSidebar({ formData }: ChatSidebarProps) { }) } - const urls = collectValidUrls(formData.linkedin, formData.otherLinks) + // Fetch each URL separately for per-link loading state + const linkPromises = urls.map(async (url) => { + try { + const response = await fetch("/api/onboarding/extract-content", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ urls: [url] }), + }) + const data = await response.json() + return data.results?.[0] || null + } catch { + return null + } finally { + // Clear this URL's processing state + if (draftRequestIdRef.current === requestId) { + setProcessingByUrl((prev) => ({ ...prev, [url]: false })) + } + } + }) + + // Fetch X/Twitter research + const xResearchPromise = formData.twitter + ? (async () => { + try { + const response = await fetch("/api/onboarding/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + xUrl: formData.twitter, + name: user?.name, + email: user?.email, + }), + }) + if (!response.ok) return null + const data = await response.json() + return data?.text?.trim() || null + } catch { + return null + } finally { + // Clear twitter URL's processing state + if (draftRequestIdRef.current === requestId) { + setProcessingByUrl((prev) => ({ + ...prev, + [formData.twitter]: false, + })) + } + } + })() + : Promise.resolve(null) const [exaResults, xResearchResult] = await Promise.all([ - urls.length > 0 - ? fetch("/api/onboarding/extract-content", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ urls }), - }) - .then((r) => r.json()) - .then((data) => data.results || []) - .catch(() => []) - : Promise.resolve([]), - formData.twitter - ? fetch("/api/onboarding/research", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - xUrl: formData.twitter, - name: user?.name, - email: user?.email, - }), - }) - .then((r) => (r.ok ? r.json() : null)) - .then((data) => data?.text?.trim() || null) - .catch(() => null) - : Promise.resolve(null), + Promise.all(linkPromises), + xResearchPromise, ]) + // Guard against stale request completing after a newer one + if (draftRequestIdRef.current !== requestId) return + for (const result of exaResults) { - if (result.text || result.description) { + if (result && (result.text || result.description)) { drafts.push({ kind: "link", content: result.text || result.description || "", @@ -304,7 +361,9 @@ export function ChatSidebar({ formData }: ChatSidebarProps) { } catch (error) { console.warn("Error building draft docs:", error) } finally { - setIsFetchingDrafts(false) + if (draftRequestIdRef.current === requestId) { + setIsFetchingDrafts(false) + } } }, [formData, user]) @@ -502,18 +561,23 @@ export function ChatSidebar({ formData }: ChatSidebarProps) { {msg.type === "formData" && ( <div className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-1 flex-1"> {msg.title && ( - <h3 - className="text-sm font-medium" - style={{ - background: - "linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)", - WebkitBackgroundClip: "text", - WebkitTextFillColor: "transparent", - backgroundClip: "text", - }} - > - {msg.title} - </h3> + <div className="flex items-center gap-2"> + <h3 + className="text-sm font-medium" + style={{ + background: + "linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + backgroundClip: "text", + }} + > + {msg.title} + </h3> + {msg.url && processingByUrl[msg.url] && ( + <Loader2 className="h-3 w-3 animate-spin text-blue-400" /> + )} + </div> )} {msg.url && ( <a diff --git a/apps/web/components/new/onboarding/setup/integrations-step.tsx b/apps/web/components/new/onboarding/setup/integrations-step.tsx index 75e0d782..951f26c0 100644 --- a/apps/web/components/new/onboarding/setup/integrations-step.tsx +++ b/apps/web/components/new/onboarding/setup/integrations-step.tsx @@ -7,7 +7,8 @@ import { XBookmarksDetailView } from "@/components/new/onboarding/x-bookmarks-de import { useRouter } from "next/navigation" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" -import { useOnboardingStorage } from "@hooks/use-onboarding-storage" +import { useOrgOnboarding } from "@hooks/use-org-onboarding" +import { analytics } from "@/lib/analytics" const integrationCards = [ { @@ -60,10 +61,11 @@ const integrationCards = [ export function IntegrationsStep() { const router = useRouter() const [selectedCard, setSelectedCard] = useState<string | null>(null) - const { markOnboardingCompleted } = useOnboardingStorage() + const { markOrgOnboarded } = useOrgOnboarding() const handleContinue = () => { - markOnboardingCompleted() + markOrgOnboarded() + analytics.onboardingCompleted() router.push("/new") } @@ -108,11 +110,22 @@ export function IntegrationsStep() { )} onClick={() => { if (card.title === "Capture") { + analytics.onboardingChromeExtensionClicked({ + source: "onboarding", + }) window.open( "https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc", "_blank", ) } else { + analytics.onboardingIntegrationClicked({ + integration: card.title, + }) + if (card.title === "Connect to AI") { + analytics.onboardingMcpDetailOpened() + } else if (card.title === "Import") { + analytics.onboardingXBookmarksDetailOpened() + } setSelectedCard(card.title) } }} diff --git a/apps/web/components/new/onboarding/setup/relatable-question.tsx b/apps/web/components/new/onboarding/setup/relatable-question.tsx index 5edb4a99..b65bdab4 100644 --- a/apps/web/components/new/onboarding/setup/relatable-question.tsx +++ b/apps/web/components/new/onboarding/setup/relatable-question.tsx @@ -6,6 +6,7 @@ import { Button } from "@ui/components/button" import { useRouter } from "next/navigation" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" +import { analytics } from "@/lib/analytics" const relatableOptions = [ { @@ -35,6 +36,10 @@ export function RelatableQuestion() { const [selectedOptions, setSelectedOptions] = useState<number[]>([]) const handleContinueOrSkip = () => { + const selectedTexts = selectedOptions.map( + (idx) => relatableOptions[idx]?.text || "", + ) + analytics.onboardingRelatableSelected({ options: selectedTexts }) router.push("/new/onboarding/setup?step=integrations") } diff --git a/apps/web/components/new/onboarding/welcome/profile-step.tsx b/apps/web/components/new/onboarding/welcome/profile-step.tsx index 34393dad..8d05997a 100644 --- a/apps/web/components/new/onboarding/welcome/profile-step.tsx +++ b/apps/web/components/new/onboarding/welcome/profile-step.tsx @@ -10,6 +10,7 @@ import { toXProfileUrl, toLinkedInProfileUrl, } from "@/lib/url-helpers" +import { analytics } from "@/lib/analytics" interface ProfileStepProps { onSubmit: (data: { @@ -291,6 +292,12 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) { description: description, otherLinks: otherLinks.filter((l) => l.trim()), } + analytics.onboardingProfileSubmitted({ + has_twitter: !!twitterHandle.trim(), + has_linkedin: !!linkedinProfile.trim(), + other_links_count: otherLinks.filter((l) => l.trim()).length, + description_length: description.trim().length, + }) onSubmit(formData) router.push("/new/onboarding/setup?step=relatable") }} diff --git a/apps/web/components/new/settings/integrations.tsx b/apps/web/components/new/settings/integrations.tsx index deccf5e4..f28695e7 100644 --- a/apps/web/components/new/settings/integrations.tsx +++ b/apps/web/components/new/settings/integrations.tsx @@ -283,6 +283,7 @@ export default function Integrations() { "_blank", "noopener,noreferrer", ) + analytics.onboardingChromeExtensionClicked({ source: "settings" }) analytics.extensionInstallClicked() } @@ -310,6 +311,7 @@ export default function Integrations() { const handleRaycastInstall = () => { window.open(RAYCAST_EXTENSION_URL, "_blank") + analytics.onboardingChromeExtensionClicked({ source: "settings" }) analytics.extensionInstallClicked() } diff --git a/apps/web/components/new/space-selector.tsx b/apps/web/components/new/space-selector.tsx index be2fcb50..45b51512 100644 --- a/apps/web/components/new/space-selector.tsx +++ b/apps/web/components/new/space-selector.tsx @@ -27,6 +27,7 @@ import { SelectValue, } from "@repo/ui/components/select" import { Button } from "@repo/ui/components/button" +import { analytics } from "@/lib/analytics" export interface SpaceSelectorProps { value: string @@ -98,6 +99,9 @@ export function SpaceSelector({ const selectedProjectEmoji = selectedProject.emoji || "📁" const handleSelect = (containerTag: string) => { + if (containerTag !== value) { + analytics.spaceSwitched({ space_id: containerTag }) + } onValueChange(containerTag) setIsOpen(false) } diff --git a/apps/web/components/onboarding/new-onboarding-modal.tsx b/apps/web/components/onboarding/new-onboarding-modal.tsx new file mode 100644 index 00000000..5dc545c1 --- /dev/null +++ b/apps/web/components/onboarding/new-onboarding-modal.tsx @@ -0,0 +1,66 @@ +"use client" + +import { useEffect, useState } from "react" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { useRouter } from "next/navigation" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@ui/components/dialog" +import { Button } from "@ui/components/button" +import { useIsMobile } from "@hooks/use-mobile" + +export function NewOnboardingModal() { + const router = useRouter() + const flagEnabled = useFeatureFlagEnabled("nova-alpha-access") + const isMobile = useIsMobile() + const [open, setOpen] = useState(false) + + useEffect(() => { + if (flagEnabled) { + setOpen(true) + } + }, [flagEnabled]) + + const handleContinue = () => { + setOpen(false) + router.push("/new/onboarding") + } + + if (!flagEnabled) { + return null + } + + return ( + <Dialog open={open} onOpenChange={(isOpen) => { + if (!isOpen) { + setOpen(false) + } + }}> + <DialogContent onInteractOutside={(e) => e.preventDefault()}> + <DialogHeader> + <DialogTitle>Experience the new onboarding</DialogTitle> + <DialogDescription> + We've redesigned the onboarding experience. Would you like to try + it? + {isMobile && ( + <span className="block mt-2 text-yellow-600 dark:text-yellow-500"> + Desktop view is recommended for the best experience. + </span> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + Stay here + </Button> + <Button onClick={handleContinue}>Continue to new onboarding</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/web/components/onboarding/onboarding-wrapper.tsx b/apps/web/components/onboarding/onboarding-wrapper.tsx new file mode 100644 index 00000000..ee1fcabb --- /dev/null +++ b/apps/web/components/onboarding/onboarding-wrapper.tsx @@ -0,0 +1,12 @@ +"use client" + +import { NewOnboardingModal } from "./new-onboarding-modal" + +export function OnboardingWrapper({ children }: { children: React.ReactNode }) { + return ( + <> + <NewOnboardingModal /> + {children} + </> + ) +} diff --git a/apps/web/hooks/use-document-mutations.ts b/apps/web/hooks/use-document-mutations.ts index ea3c3783..3480c247 100644 --- a/apps/web/hooks/use-document-mutations.ts +++ b/apps/web/hooks/use-document-mutations.ts @@ -359,7 +359,8 @@ export function useDocumentMutations({ return response.data }, - onSuccess: () => { + onSuccess: (_data, variables) => { + analytics.documentEdited({ document_id: variables.documentId }) toast.success("Document saved successfully!") queryClient.invalidateQueries({ queryKey: ["documents-with-memories"], @@ -451,7 +452,8 @@ export function useDocumentMutations({ description: _error instanceof Error ? _error.message : "Unknown error", }) }, - onSuccess: () => { + onSuccess: (_data, variables) => { + analytics.documentDeleted({ document_id: variables.documentId }) toast.success("Document deleted successfully!") queryClient.invalidateQueries({ queryKey: ["documents-with-memories"], diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 84eda62b..6d8c00eb 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -62,4 +62,105 @@ export const analytics = { mcpModalOpened: () => safeCapture("mcp_modal_opened"), addDocumentModalOpened: () => safeCapture("add_document_modal_opened"), + + // onboarding analytics + onboardingStepViewed: (props: { step: string; 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), + + onboardingIntegrationClicked: (props: { integration: string }) => + safeCapture("onboarding_integration_clicked", props), + + onboardingChromeExtensionClicked: (props: { + source: "onboarding" | "settings" + }) => safeCapture("onboarding_chrome_extension_clicked", props), + + onboardingMcpDetailOpened: () => safeCapture("onboarding_mcp_detail_opened"), + + onboardingXBookmarksDetailOpened: () => + safeCapture("onboarding_x_bookmarks_detail_opened"), + + onboardingCompleted: () => safeCapture("onboarding_completed"), + + // main app analytics + searchOpened: (props: { + source: "hotkey" | "header" | "highlight_related" + }) => safeCapture("search_opened", props), + + documentModalOpened: (props: { document_id: string }) => + safeCapture("document_modal_opened", props), + + fullscreenNoteModalOpened: () => safeCapture("fullscreen_note_modal_opened"), + + highlightClicked: (props: { + highlight_id: string + action: "chat" | "related" + }) => safeCapture("highlight_clicked", props), + + // chat analytics + chatMessageSent: (props: { + source: "typed" | "suggested" | "highlight" | "follow_up" + }) => safeCapture("chat_message_sent", props), + + chatFollowUpClicked: (props: { thread_id?: string }) => + safeCapture("chat_follow_up_clicked", props), + + chatSuggestedQuestionClicked: () => + safeCapture("chat_suggested_question_clicked"), + + chatMessageLiked: (props: { message_id: string }) => + safeCapture("chat_message_liked", props), + + chatMessageDisliked: (props: { message_id: string }) => + safeCapture("chat_message_disliked", props), + + chatMessageCopied: (props: { message_id: string }) => + safeCapture("chat_message_copied", props), + + chatMemoryExpanded: (props: { message_id: string }) => + safeCapture("chat_memory_expanded", props), + + chatMemoryCollapsed: (props: { message_id: string }) => + safeCapture("chat_memory_collapsed", props), + + chatThreadLoaded: (props: { thread_id: string }) => + safeCapture("chat_thread_loaded", props), + + chatThreadDeleted: (props: { thread_id: string }) => + safeCapture("chat_thread_deleted", props), + + modelChanged: (props: { model: string }) => + safeCapture("model_changed", props), + + // settings / spaces / docs analytics + settingsTabChanged: (props: { + tab: "account" | "integrations" | "connections" | "support" + }) => safeCapture("settings_tab_changed", props), + + spaceCreated: () => safeCapture("space_created"), + + spaceSwitched: (props: { space_id: string }) => + safeCapture("space_switched", props), + + quickNoteCreated: () => safeCapture("quick_note_created"), + + quickNoteEdited: () => safeCapture("quick_note_edited"), + + documentDeleted: (props: { document_id: string }) => + safeCapture("document_deleted", props), + + documentEdited: (props: { document_id: string }) => + safeCapture("document_edited", props), } diff --git a/apps/web/stores/quick-note-draft.ts b/apps/web/stores/quick-note-draft.ts index afd2bc6d..31efa1d7 100644 --- a/apps/web/stores/quick-note-draft.ts +++ b/apps/web/stores/quick-note-draft.ts @@ -35,9 +35,7 @@ export const useQuickNoteDraftStore = create<QuickNoteDraftState>()( ) export function useQuickNoteDraft(projectId: string) { - const draft = useQuickNoteDraftStore( - (s) => s.draftByProject[projectId] ?? "", - ) + const draft = useQuickNoteDraftStore((s) => s.draftByProject[projectId] ?? "") const setDraft = useQuickNoteDraftStore((s) => s.setDraft) const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft) diff --git a/packages/hooks/use-org-onboarding.ts b/packages/hooks/use-org-onboarding.ts new file mode 100644 index 00000000..e71eab8f --- /dev/null +++ b/packages/hooks/use-org-onboarding.ts @@ -0,0 +1,85 @@ +"use client" + +import { useCallback, useMemo } from "react" +import { useAuth } from "@lib/auth-context" +import { authClient } from "@lib/auth" + +/** + * DB-backed onboarding completion hook for the new app flow. + * Uses consumer org `metadata.isOnboarded` instead of localStorage. + * + * TODO: remove this after the feature flag is removed + * This hook is for the new app flow only (feature-flagged `nova-alpha-access`). + * The old onboarding flow will continue to use `useOnboardingStorage` (localStorage). + */ +export function useOrgOnboarding() { + const { org, updateOrgMetadata } = useAuth() + + const isOrgOnboarded = useMemo(() => { + if (!org) return null + return org.metadata?.isOnboarded === true + }, [org]) + + const markOrgOnboarded = useCallback(() => { + if (!org?.id) { + console.error("No organization context when marking as onboarded") + return + } + + // Optimistic update: update in-memory state immediately + updateOrgMetadata({ isOnboarded: true }) + + authClient.organization + .update({ + organizationId: org.id, + data: { + metadata: { + ...org.metadata, + isOnboarded: true, + }, + }, + }) + .catch((error) => { + console.error("Failed to mark organization as onboarded:", error) + updateOrgMetadata({ isOnboarded: false }) + }) + }, [org, updateOrgMetadata]) + + const resetOrgOnboarded = useCallback(() => { + if (!org?.id) { + console.error("No organization context when resetting onboarding") + return + } + + // Optimistic update: update in-memory state immediately + updateOrgMetadata({ isOnboarded: false }) + + authClient.organization + .update({ + organizationId: org.id, + data: { + metadata: { + ...org.metadata, + isOnboarded: false, + }, + }, + }) + .catch((error) => { + console.error("Failed to reset organization onboarding:", error) + updateOrgMetadata({ isOnboarded: true }) + }) + }, [org, updateOrgMetadata]) + + const shouldShowOnboarding = useCallback(() => { + if (isOrgOnboarded === null) return null // Still loading (org not ready) + return !isOrgOnboarded + }, [isOrgOnboarded]) + + return { + isOrgOnboarded, + markOrgOnboarded, + resetOrgOnboarded, + shouldShowOnboarding, + isLoading: org === null, + } +} diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index 4bfdc2d7..67e57d49 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -3,6 +3,7 @@ import { createContext, type ReactNode, + useCallback, useContext, useEffect, useState, @@ -17,6 +18,9 @@ interface AuthContextType { user: SessionData["user"] | null org: Organization | null setActiveOrg: (orgSlug: string) => Promise<void> + updateOrgMetadata: ( + partial: Record<string, unknown>, + ) => void } const AuthContext = createContext<AuthContextType | undefined>(undefined) @@ -35,6 +39,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { setOrg(activeOrg) } + const updateOrgMetadata = useCallback( + (partial: Record<string, unknown>) => { + setOrg((prev) => { + if (!prev) return prev + return { + ...prev, + metadata: { + ...prev.metadata, + ...partial, + }, + } + }) + }, + [], + ) + // biome-ignore lint/correctness/useExhaustiveDependencies: ignoring the setActiveOrg dependency useEffect(() => { if (session?.session.activeOrganizationId) { @@ -99,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { session: session?.session ?? null, user: session?.user ?? null, setActiveOrg, + updateOrgMetadata, }} > {children} |