diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 42bfce24..64ee0299 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -275,6 +275,7 @@ export default function BrainOnboardingPage() { -
-
{children}
+
+
+ {children} +
) diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 6e77cab6..388fb2fd 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -1,14 +1,10 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" +import Image from "next/image" import { Button } from "@ui/components/button" -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from "@ui/components/drawer" -import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" +import { Dialog, DialogClose, DialogContent } from "@ui/components/dialog" +import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons" import { Logo } from "@ui/assets/Logo" import { Select, @@ -21,12 +17,16 @@ import { AlertTriangle, ArrowRight, Check, - Database, + ChevronLeft, + ChevronRight, + Coins, + ExternalLink, FolderOpen, Github, - Globe, - Mic, + LoaderIcon, + Lock, Plus, + X, } from "lucide-react" import { AppleShortcutsIcon, @@ -47,6 +47,19 @@ function XBookmarksIcon({ className }: { className?: string }) { ) } +function GrokIcon({ className }: { className?: string }) { + return ( + + ) +} + function GmailIcon({ className }: { className?: string }) { return ( = { + pro: "Pro", + max: "Max", +} + +const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min" + +type PlanCardDefinition = { + id: PlanType + name: string + price: string + period: string + credits: string + productId: "api_free" | "api_pro" | "api_max" | "api_scale" | "api_enterprise" + description: string + includesFrom?: string + features: string[] + isContactSales?: boolean + mostPopular?: boolean +} + +type CheckoutPlanId = Extract< + PlanCardDefinition["productId"], + "api_pro" | "api_max" | "api_scale" +> + +const PLAN_CARDS: PlanCardDefinition[] = [ + { + id: "pro", + name: "Pro", + price: "$19", + period: "/mo", + credits: "$20", + productId: "api_pro", + description: "For people building with AI memory", + features: [ + "Auto top-up when balance runs low", + "All plugins (Claude Code, Cursor, Hermes...)", + "Priority support", + ], + }, + { + id: "max", + name: "Max", + price: "$100", + period: "/mo", + credits: "$130", + productId: "api_max", + description: "For power users who outgrow Pro", + includesFrom: "Pro", + mostPopular: true, + features: ["6x the credits of Pro", "Gmail connector", "Priority support"], + }, + { + id: "scale", + name: "Scale", + price: "$399", + period: "/mo", + credits: "$600", + productId: "api_scale", + description: "For teams and production workloads", + includesFrom: "Max", + features: [ + "Auto top-up & spend caps", + "S3 & Web Crawler connectors", + "Dedicated support", + ], + }, + { + id: "enterprise", + name: "Enterprise", + price: "Custom", + period: "", + credits: "Unlimited", + productId: "api_enterprise", + description: "Custom deployments with dedicated engineering", + includesFrom: "Scale", + features: [ + "Custom metering & billing", + "Custom integrations & SSO", + "Forward-deployed engineer", + ], + isContactSales: true, + }, +] + +const PLAN_CARD_SCROLL_STEP = 406 export interface SourcesValues { connected: Partial> @@ -95,6 +221,7 @@ export interface SourcesValues { interface Props { containerTag: string workspaceName: string + mode: BrainMode values: SourcesValues onChange: (next: SourcesValues) => void onContinue: () => void @@ -110,14 +237,69 @@ const inputBevelStyle = { "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)", } +function ChatAppsIconCluster() { + const tileClass = + "absolute flex size-[28px] items-center justify-center rounded-[10px] border border-white/10 bg-[#0D1117] shadow-[0_4px_10px_rgba(0,0,0,0.28)]" + const imageClass = "size-4 object-contain" + + return ( + + ) +} + export function StepSources({ containerTag, workspaceName, + mode, values, onChange, onContinue, }: Props) { const [moreOpen, setMoreOpen] = useState(false) + const [plansOpen, setPlansOpen] = useState(false) + const [requestedPlan, setRequestedPlan] = useState("pro") + const [requestedConnector, setRequestedConnector] = useState("This connector") + const autumn = useCustomer() + const hasPro = hasActivePlan(autumn.data?.subscriptions, "api_pro") + const hasMax = hasActivePlan(autumn.data?.subscriptions, "api_max") + const planLoading = autumn.isLoading + + useEffect(() => { + setMoreOpen(false) + }, []) + + const isLocked = (plan?: RequiredPlan) => { + if (!plan || planLoading) return false + return plan === "max" ? !hasMax : !hasPro + } const setState = (id: SourceId, state: SourceState) => { onChange({ ...values, connected: { ...values.connected, [id]: state } }) @@ -155,120 +337,734 @@ export function StepSources({ } } + const openExternal = (id: SourceId, url: string) => { + window.open(url, "_blank", "noopener,noreferrer") + setState(id, "connected") + } + + const guard = ( + plan: RequiredPlan | undefined, + title: string, + fn: () => void, + ) => { + return () => { + if (isLocked(plan) && plan) { + setRequestedPlan(plan) + setRequestedConnector(title) + setPlansOpen(true) + return + } + fn() + } + } + const connectedCount = Object.values(values.connected).filter( (s) => s === "connected" || s === "waitlist", ).length return ( -
-
-
-

- Connect your team's signals -

-

- Start with the sources that carry the most context. Add more - anytime. -

-
- -
- -
- } - state={values.connected.drive ?? "idle"} - ctaLabel="Connect" - perks={[ - "Docs, sheets, slides — all parsed", - "Stays in sync as files change", - "You pick what to share at sign-in", - ]} - onConnect={() => connectRealProvider("google-drive", "drive")} - headerNote={ - values.driveScope === "full" ? ( -

- - Full Drive can exhaust your monthly usage. +

+
+
+
+
+

+ {mode === "personal" + ? "Bring your context together" + : "Connect your team's signals"}

- ) : null - } - footerLeft={ - onChange({ ...values, driveScope: s })} +

+ Start with the sources that carry the most context. Add more + anytime. +

+
+ +
+ +
+ } + state={values.connected.bookmarks ?? "idle"} + ctaLabel="Connect" + doneLabel="Opened" + perks={[ + "Bookmarks become searchable memories", + "One-click import from the X bookmarks tab", + "Works via the Chrome extension", + ]} + onConnect={() => openExternal("bookmarks", CHROME_EXTENSION_URL)} /> - } - footerRight={} - /> - } - state={values.connected.notion ?? "idle"} - ctaLabel="Connect" - perks={[ - "Pages and database rows", - "Stays in sync when you edit", - "Pick which workspaces ingest", - ]} - onConnect={() => connectRealProvider("notion", "notion")} - footerRight={} - /> -
- -
- -
- - -
-
+
- setMoreOpen(false)} - containerTag={containerTag} - connectRealProvider={connectRealProvider} +
+
+ + +
+ + {moreOpen ? ( +
+ +
+ ) : null} +
+
+ + + {moreOpen &&
} +
) } +function OnboardingPlansModal({ + open, + onOpenChange, + requestedConnector, + requestedPlan, +}: { + open: boolean + onOpenChange: (open: boolean) => void + requestedConnector: string + requestedPlan: RequiredPlan +}) { + const autumn = useCustomer() + const { currentPlan, isLoading } = useTokenUsage(autumn) + const [upgradingPlan, setUpgradingPlan] = useState( + null, + ) + const [planIndex, setPlanIndex] = useState(0) + const lastPlanIndex = Math.max(0, PLAN_CARDS.length - 2) + + useEffect(() => { + if (open) { + setPlanIndex(0) + } + }, [open]) + + const handleUpgrade = async (planId: CheckoutPlanId) => { + setUpgradingPlan(planId) + try { + const result = await autumn.attach({ + planId, + successUrl: window.location.href, + }) + if ((result as { paymentUrl?: string })?.paymentUrl) { + window.location.href = (result as { paymentUrl: string }).paymentUrl + return + } + autumn.refetch?.() + } catch (error) { + console.error(error) + toast.error("Failed to start checkout. Please try again.") + } finally { + setUpgradingPlan(null) + } + } + + const scrollPlans = (direction: -1 | 1) => { + setPlanIndex((index) => + Math.min(lastPlanIndex, Math.max(0, index + direction)), + ) + } + + return ( + + +
+
+

+ Choose a plan +

+
+
+ + + + + +
+
+

+ {requestedConnector} requires{" "} + + {PLAN_LABELS[requestedPlan]} + + . +

+
+
+ +
+
+
+ {PLAN_CARDS.map((plan) => { + const isCurrent = currentPlan === plan.id + const isIncluded = + !isCurrent && PLAN_RANK[currentPlan] > PLAN_RANK[plan.id] + const isBusy = upgradingPlan === plan.productId + const checkoutPlanId = + plan.productId === "api_pro" || + plan.productId === "api_max" || + plan.productId === "api_scale" + ? plan.productId + : null + + return ( +
+ + Book a call + + + ) : ( + + ) + } + /> +
+ ) + })} +
+
+
+
+
+ ) +} + +function OnboardingPlanCard({ + action, + plan, +}: { + action: React.ReactNode + plan: PlanCardDefinition +}) { + return ( +
+ {plan.mostPopular ? ( + + Most popular + + ) : null} +

+ {plan.name} +

+ +
+ + {plan.price} + + {plan.period ? ( + {plan.period} + ) : null} +
+ +

+ {plan.description} +

+ + {plan.isContactSales ? null : ( +
+ +
+

+ {plan.credits} +

+

+ of usage included +

+
+
+ )} + + {plan.includesFrom ? ( +
+
+ + Everything in {plan.includesFrom}, plus + +
+
+ ) : null} + +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + {action} +
+ ) +} + +function SourceActions({ + connectedCount, + onContinue, + className, +}: { + connectedCount: number + onContinue: () => void + className?: string +}) { + return ( +
+ + +
+ ) +} + +function GoogleDriveSourceCard({ + values, + onChange, + isLocked, + guard, + connectRealProvider, +}: { + values: SourcesValues + onChange: (next: SourcesValues) => void + isLocked: (plan?: RequiredPlan) => boolean + guard: ( + plan: RequiredPlan | undefined, + title: string, + fn: () => void, + ) => () => void + connectRealProvider: ( + provider: "google-drive" | "notion" | "onedrive", + id: SourceId, + ) => void +}) { + return ( + } + state={values.connected.drive ?? "idle"} + ctaLabel="Connect" + locked={isLocked("pro")} + requiredPlan="pro" + perks={[ + "Docs, sheets, slides — all parsed", + "Stays in sync as files change", + "You pick what to share at sign-in", + ]} + onConnect={guard("pro", "Google Drive", () => + connectRealProvider("google-drive", "drive"), + )} + headerNote={ + values.driveScope === "full" ? ( +

+ + Full Drive can exhaust your monthly usage. +

+ ) : null + } + footerLeft={ + onChange({ ...values, driveScope: s })} + /> + } + footerRight={} + /> + ) +} + +function NotionSourceCard({ + values, + isLocked, + guard, + connectRealProvider, +}: { + values: SourcesValues + isLocked: (plan?: RequiredPlan) => boolean + guard: ( + plan: RequiredPlan | undefined, + title: string, + fn: () => void, + ) => () => void + connectRealProvider: ( + provider: "google-drive" | "notion" | "onedrive", + id: SourceId, + ) => void +}) { + return ( + } + state={values.connected.notion ?? "idle"} + ctaLabel="Connect" + locked={isLocked("pro")} + requiredPlan="pro" + perks={[ + "Pages and database rows", + "Stays in sync when you edit", + "Pick which workspaces ingest", + ]} + onConnect={guard("pro", "Notion", () => + connectRealProvider("notion", "notion"), + )} + footerRight={} + /> + ) +} + +function MoreSourcesGrid({ + mode, + values, + onChange, + isLocked, + guard, + setState, + openExternal, + connectRealProvider, +}: { + mode: BrainMode + values: SourcesValues + onChange: (next: SourcesValues) => void + isLocked: (plan?: RequiredPlan) => boolean + guard: ( + plan: RequiredPlan | undefined, + title: string, + fn: () => void, + ) => () => void + setState: (id: SourceId, state: SourceState) => void + openExternal: (id: SourceId, url: string) => void + connectRealProvider: ( + provider: "google-drive" | "notion" | "onedrive", + id: SourceId, + ) => void +}) { + return ( + <> + } + state={values.connected.raycast ?? "idle"} + ctaLabel="Connect" + doneLabel="Opened" + perks={[ + "Add memories without leaving Raycast", + "Search your brain from anywhere", + "Fast keyboard-first capture", + ]} + onConnect={() => openExternal("raycast", RAYCAST_EXTENSION_URL)} + /> + } + state={values.connected.shortcuts ?? "idle"} + ctaLabel="Connect" + doneLabel="Opened" + perks={[ + "Save from the iOS share sheet", + "Capture text, links and photos", + "Works on iPhone, iPad and Mac", + ]} + onConnect={() => openExternal("shortcuts", ADD_MEMORY_SHORTCUT_URL)} + /> + } + state={values.connected.chrome ?? "idle"} + ctaLabel="Connect" + doneLabel="Opened" + perks={[ + "Save any page in one click", + "Import your X bookmarks", + "Sync ChatGPT memories", + ]} + onConnect={() => openExternal("chrome", CHROME_EXTENSION_URL)} + /> + {mode === "personal" ? ( + + ) : ( + + )} + } + state={values.connected.onedrive ?? "idle"} + ctaLabel="Connect" + locked={isLocked("pro")} + requiredPlan="pro" + perks={[ + "Word, Excel, PowerPoint — all parsed", + "Stays in sync as files change", + "You pick what to share at sign-in", + ]} + onConnect={guard("pro", "OneDrive", () => + connectRealProvider("onedrive", "onedrive"), + )} + /> + } + state={values.connected.gmail ?? "idle"} + ctaLabel="Connect" + locked={isLocked("max")} + requiredPlan="max" + perks={[ + "Threads become searchable context", + "Decisions and follow-ups surfaced", + "You control which labels sync", + ]} + onConnect={guard("max", "Gmail", () => setState("gmail", "waitlist"))} + /> + } + state={values.connected.github ?? "idle"} + ctaLabel="Connect" + locked={isLocked("max")} + requiredPlan="max" + perks={[ + "PRs and issues parsed", + "READMEs and docs indexed", + "Stays in sync with new activity", + ]} + onConnect={guard("max", "GitHub", () => setState("github", "waitlist"))} + /> + } + state="idle" + ctaLabel="Connect" + locked={isLocked("max")} + requiredPlan="max" + perks={[ + "Meeting notes auto-captured", + "Decisions and action items extracted", + "Synced after every meeting", + ]} + onConnect={guard("max", "Granola", () => + toast.info("Granola is coming soon."), + )} + /> + + ) +} + function RoutingChip({ workspaceName }: { workspaceName: string }) { return (
-
-
+
+
{icon}
-
-

{title}

-

- {blurb} -

- {headerNote} -
+
+
+

+ {title} +

+

+ {blurb} +

+ {headerNote}
{isDone ? ( - {state === "waitlist" ? "Requested" : "Connected"} + {state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")} ) : ( )}
@@ -370,12 +1196,6 @@ function SourceCard({ ))} - {soft && !isDone && ( -

- OAuth lands shortly — request access and we'll auto-enable it. -

- )} - {(footerLeft || footerRight) && (
{footerLeft}
@@ -430,158 +1250,3 @@ function DriveScopePicker({ ) } - -function MoreDrawer({ - open, - onClose, - containerTag, - connectRealProvider, -}: { - open: boolean - onClose: () => void - containerTag: string - connectRealProvider: ( - provider: "google-drive" | "notion" | "onedrive", - id: SourceId, - ) => void -}) { - return ( - !o && onClose()}> - - - - More integrations - -

- Add any of these alongside your spotlight sources. Everything routes - to {containerTag}. -

-
-
- } - action="Request access" - soft - /> - } - action="Request access" - soft - /> - } - action="Connect" - onAction={() => connectRealProvider("onedrive", "onedrive")} - /> - } - action="Coming soon" - soft - /> - } - action="Connect" - soft - /> - } - action="Connect" - soft - /> - } - action="Install" - soft - /> - } - action="Install" - soft - /> - } - action="Install" - soft - /> - } - action="Import" - soft - /> -
-
-
- ) -} - -function MoreItem({ - title, - blurb, - icon, - action, - soft, - onAction, -}: { - title: string - blurb: string - icon: React.ReactNode - action: string - soft?: boolean - onAction?: () => void -}) { - return ( -
-
- {icon} -
-
-

- {title} -

-

- {blurb} -

-
- -
- ) -}