diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 2c5eaf07d..3d67b3bae 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/components/document-modal/graph-list-memories.tsx b/apps/web/components/document-modal/graph-list-memories.tsx index c8f4ae8dd..313424e79 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", - )} - > - <DocumentContent - document={_document} - textEditorProps={textEditorProps} - pluginDocument={pluginDocument} - /> - </div> - <div - id="document-memories-summary" - className={cn( - "gap-3 flex flex-col overflow-hidden", - dmSansClassName(), - )} + </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" > - {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/initial-header.tsx b/apps/web/components/initial-header.tsx index 831fb77ce..95bc9628a 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 e602f070c..5a5a71618 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/billing.tsx b/apps/web/components/settings/billing.tsx index 0a650a15c..b03a6659e 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -31,6 +31,9 @@ const CREDIT_FEATURE_ID = "usd_credits" const TOP_UP_PLAN_ID = "credits_topup" const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const +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[] stripeId: string @@ -694,322 +697,237 @@ export default function Billing() { </SettingsCard> </section> - <section className="flex flex-col gap-4"> - <SectionTitle>Credits</SectionTitle> + <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> - <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 + <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> - <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}%` }} + <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> - <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} + <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]", + )} > - <DialogTrigger asChild> + <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 items-center justify-center gap-2 rounded-[9px] bg-[#0054AD] px-3 text-[13px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9]", + "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", )} > - <Plus className="size-3.5" /> - Buy credits + {isSavingAutoTopUp ? ( + <LoaderIcon className="size-3.5 animate-spin" /> + ) : null} + Save threshold & reload amount </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)]" - > - <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> + </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> + <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", - }} - /> - Auto reload: {autoTopUpEnabled ? "on" : "off"} - </button> - </div> + {!isAdmin ? ( + <p className="text-center text-[11px] text-[#737373]"> + Only owners/admins can purchase credits. + </p> + ) : null} </div> - </SettingsCard> + </DialogContent> + </Dialog> - {hasPaidPlan ? ( + {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 +957,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/org-context.tsx b/apps/web/components/settings/org-context.tsx index dac97f7e7..8c17ed01d 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 d762654e5..132ebeb33 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), - - onboardingNameSubmitted: (props: { name_length: number }) => - safeCapture("onboarding_name_submitted", props), + onboardingStepViewed: (props: { + step: OnboardingStep + trigger: "user" | "auto" + }) => safeCapture("onboarding_step_viewed", 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/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index 724efdd76..bd6b2f3ae 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 ed5ec0940..dd82f4906 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>