mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): company brain trial visibility + setup timeline (#1384)
- Header pill with trial days left (Autumn-first, org metadata fallback) - Brain home: Your Company Brain timeline card (trial strip, milestones from /brain/overview) promoted to top-right - Trial copy in CB onboarding Slack step and docked header - Brain home now reads the new /brain/overview endpoint (drops the dead /brain/connections fetch) Fixes ENG-1142
This commit is contained in:
parent
f14cdd7a4c
commit
a787041ca7
7 changed files with 531 additions and 185 deletions
|
|
@ -5,11 +5,18 @@ import { useAuth } from "@lib/auth-context"
|
|||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useBrainTrial } from "@/hooks/use-brain-trial"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { ConnectionsBoard } from "./connections-board"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import {
|
||||
AskInSlackCard,
|
||||
CONNECT_TOOLS_CARD_ID,
|
||||
ConnectToolsCard,
|
||||
SlackBanner,
|
||||
useConnectionsBoard,
|
||||
} from "./connections-board"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
|
@ -26,6 +33,26 @@ type RecentDoc = {
|
|||
updatedAt?: string | Date | null
|
||||
}
|
||||
|
||||
type RolloutOverview = {
|
||||
status: "running" | "done" | "failed"
|
||||
discovered: number
|
||||
joined: number
|
||||
ready: number
|
||||
introduced: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
type BrainOverview = {
|
||||
research: { status: string | null }
|
||||
slack: {
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
rollout: RolloutOverview | null
|
||||
}
|
||||
connections: { apps: number }
|
||||
members: { count: number }
|
||||
}
|
||||
|
||||
function useBrainOverview() {
|
||||
const { user, org } = useAuth()
|
||||
const enabled = !!user && !!org?.id
|
||||
|
|
@ -87,29 +114,6 @@ function useBrainOverview() {
|
|||
enabled,
|
||||
})
|
||||
|
||||
const brain = useQuery({
|
||||
queryKey: ["brain-connections"],
|
||||
queryFn: async () => {
|
||||
const [c, s] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
const toolkits = c.ok
|
||||
? ((await c.json()) as { toolkits: { org: boolean; user: boolean }[] })
|
||||
.toolkits
|
||||
: []
|
||||
const slack = s.ok
|
||||
? ((await s.json()) as { connected: boolean }).connected
|
||||
: false
|
||||
return {
|
||||
activeCount: toolkits.filter((t) => t.org || t.user).length,
|
||||
slack,
|
||||
}
|
||||
},
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const mcp = useQuery({
|
||||
queryKey: ["mcp-status"],
|
||||
queryFn: async () => {
|
||||
|
|
@ -121,11 +125,24 @@ function useBrainOverview() {
|
|||
enabled,
|
||||
})
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: ["brain-overview", org?.id],
|
||||
queryFn: async (): Promise<BrainOverview | null> => {
|
||||
const res = await fetch(`${BACKEND}/brain/overview`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as BrainOverview
|
||||
},
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const memoriesCount = docs.data?.pagination?.totalItems ?? 0
|
||||
const connectedCount =
|
||||
(brain.data?.activeCount ?? 0) +
|
||||
(brain.data?.slack ? 1 : 0) +
|
||||
(connectors.data?.length ?? 0)
|
||||
const slackConnected = overview.data?.slack.connected ?? false
|
||||
const appsCount =
|
||||
(overview.data?.connections.apps ?? 0) + (connectors.data?.length ?? 0)
|
||||
const connectedCount = appsCount + (slackConnected ? 1 : 0)
|
||||
|
||||
const currentRole = org?.members
|
||||
?.find((m) => m.userId === user?.id)
|
||||
|
|
@ -137,20 +154,35 @@ function useBrainOverview() {
|
|||
lastUpdatedAt: lastUpdated.data ?? null,
|
||||
memoriesCount,
|
||||
connectedCount,
|
||||
membersCount: org?.members?.length ?? 0,
|
||||
membersCount: overview.data?.members.count ?? org?.members?.length ?? 0,
|
||||
canInvite: currentRole === "owner" || currentRole === "admin",
|
||||
hasSource: connectedCount > 0,
|
||||
hasAgent: mcp.data ?? false,
|
||||
hasMemory: memoriesCount > 0,
|
||||
hasApps: appsCount > 0,
|
||||
slackConnected,
|
||||
researchStatus: overview.data?.research.status ?? null,
|
||||
rollout: overview.data?.slack.rollout ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function BrainHomeView() {
|
||||
const o = useBrainOverview()
|
||||
const stepsDone = [o.hasSource, o.hasAgent, o.hasMemory].filter(
|
||||
Boolean,
|
||||
).length
|
||||
const showGettingStarted = !o.loading && stepsDone < 3
|
||||
const trial = useBrainTrial()
|
||||
const board = useConnectionsBoard()
|
||||
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
|
||||
const milestones = [
|
||||
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
|
||||
o.slackConnected,
|
||||
...(o.rollout != null ? [o.rollout.status === "done"] : []),
|
||||
o.hasApps,
|
||||
o.membersCount > 1,
|
||||
o.hasMemory,
|
||||
]
|
||||
const milestonesDone = milestones.filter(Boolean).length
|
||||
const milestonesTotal = milestones.length
|
||||
const showTimeline =
|
||||
!o.loading && (trial.state !== "none" || milestonesDone < milestonesTotal)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] space-y-6">
|
||||
|
|
@ -159,24 +191,31 @@ export function BrainHomeView() {
|
|||
connected={o.connectedCount}
|
||||
members={o.membersCount}
|
||||
canInvite={o.canInvite}
|
||||
setupDone={stepsDone}
|
||||
setupDone={milestonesDone}
|
||||
setupTotal={milestonesTotal}
|
||||
lastUpdatedAt={o.lastUpdatedAt}
|
||||
/>
|
||||
<ConnectionsBoard />
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-6",
|
||||
showGettingStarted && "lg:grid-cols-[minmax(0,1fr)_340px]",
|
||||
)}
|
||||
>
|
||||
<RecentMemories docs={o.recentDocs} loading={o.loading} />
|
||||
{showGettingStarted && (
|
||||
<GettingStarted
|
||||
hasSource={o.hasSource}
|
||||
hasAgent={o.hasAgent}
|
||||
hasMemory={o.hasMemory}
|
||||
/>
|
||||
)}
|
||||
{board.slack && !board.slack.connected && <SlackBanner />}
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
{board.showBoard && <ConnectToolsCard board={board} />}
|
||||
<RecentMemories docs={o.recentDocs} loading={o.loading} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-6">
|
||||
{showTimeline && (
|
||||
<BrainTimeline
|
||||
researchStatus={o.researchStatus}
|
||||
slackConnected={o.slackConnected}
|
||||
rollout={o.rollout}
|
||||
hasApps={o.hasApps}
|
||||
invited={o.membersCount > 1}
|
||||
hasMemory={o.hasMemory}
|
||||
canInvite={o.canInvite}
|
||||
toolsCardVisible={board.showBoard}
|
||||
/>
|
||||
)}
|
||||
<AskInSlackCard board={board} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -188,6 +227,7 @@ function StatsRow({
|
|||
members,
|
||||
canInvite,
|
||||
setupDone,
|
||||
setupTotal,
|
||||
lastUpdatedAt,
|
||||
}: {
|
||||
memories: number
|
||||
|
|
@ -195,6 +235,7 @@ function StatsRow({
|
|||
members: number
|
||||
canInvite: boolean
|
||||
setupDone: number
|
||||
setupTotal: number
|
||||
lastUpdatedAt: string | Date | null
|
||||
}) {
|
||||
const { openSettings } = useSettingsModal()
|
||||
|
|
@ -226,8 +267,8 @@ function StatsRow({
|
|||
</button>
|
||||
) : undefined,
|
||||
},
|
||||
setupDone < 3
|
||||
? { label: "Setup", value: `${setupDone}/3` }
|
||||
setupDone < setupTotal
|
||||
? { label: "Setup", value: `${setupDone}/${setupTotal}` }
|
||||
: {
|
||||
label: "Last updated",
|
||||
value: formatWhen(lastUpdatedAt) || "—",
|
||||
|
|
@ -358,34 +399,182 @@ function RecentMemories({
|
|||
)
|
||||
}
|
||||
|
||||
function GettingStarted({
|
||||
hasSource,
|
||||
hasAgent,
|
||||
function TrialStrip() {
|
||||
const trial = useBrainTrial()
|
||||
const { openSettings } = useSettingsModal()
|
||||
|
||||
if (trial.state === "ended") {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-[12px] bg-[#14161A] px-3.5 py-2.5">
|
||||
<p className="text-[12px] font-medium text-[#E5735A]">
|
||||
Trial ended · your brain is paused
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings("billing")}
|
||||
className="shrink-0 rounded-full bg-white px-3 py-1 text-[11px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (trial.state !== "trialing" || !trial.startedAtMs || !trial.endsAtMs) {
|
||||
return null
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const totalDays = Math.max(
|
||||
1,
|
||||
Math.round((trial.endsAtMs - trial.startedAtMs) / DAY_MS),
|
||||
)
|
||||
const days = trial.daysRemaining ?? 0
|
||||
const dayNum = Math.min(totalDays, Math.max(1, totalDays - days + 1))
|
||||
const pct = Math.min(100, Math.max(4, (dayNum / totalDays) * 100))
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings("billing")}
|
||||
className="mb-4 block w-full cursor-pointer rounded-[12px] bg-[#14161A] px-3.5 py-2.5 text-left transition-colors hover:bg-[#171A1F]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[12px] font-medium text-[#fafafa]">
|
||||
Free trial ·{" "}
|
||||
<span className={cn(days <= 3 ? "text-[#E5A45A]" : "text-[#737373]")}>
|
||||
{days} day{days === 1 ? "" : "s"} left
|
||||
</span>
|
||||
</p>
|
||||
<p className="shrink-0 text-[11px] font-medium text-[#525D6E]">
|
||||
Ends{" "}
|
||||
{new Date(trial.endsAtMs).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 h-1 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
days <= 3 ? "bg-[#E5A45A]" : "bg-[#4BA0FA]",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainTimeline({
|
||||
researchStatus,
|
||||
slackConnected,
|
||||
rollout,
|
||||
hasApps,
|
||||
invited,
|
||||
hasMemory,
|
||||
canInvite,
|
||||
toolsCardVisible,
|
||||
}: {
|
||||
hasSource: boolean
|
||||
hasAgent: boolean
|
||||
researchStatus: string | null
|
||||
slackConnected: boolean
|
||||
rollout: RolloutOverview | null
|
||||
hasApps: boolean
|
||||
invited: boolean
|
||||
hasMemory: boolean
|
||||
canInvite: boolean
|
||||
toolsCardVisible: boolean
|
||||
}) {
|
||||
const steps = [
|
||||
const trial = useBrainTrial()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { setViewMode } = useViewMode()
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
|
||||
const onInvite = () => {
|
||||
setInvite("1")
|
||||
openSettings("account")
|
||||
}
|
||||
|
||||
const onSetUpApps = () => {
|
||||
const card = document.getElementById(CONNECT_TOOLS_CARD_ID)
|
||||
if (toolsCardVisible && card) {
|
||||
card.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
} else {
|
||||
void setViewMode("configure")
|
||||
}
|
||||
}
|
||||
|
||||
const researching =
|
||||
researchStatus === "queued" || researchStatus === "running"
|
||||
type Step = {
|
||||
done: boolean
|
||||
busy?: boolean
|
||||
title: string
|
||||
hint?: string
|
||||
action?: { label: string; onClick?: () => void; href?: string }
|
||||
}
|
||||
// Rows with unreported state are omitted rather than shown as never-started.
|
||||
const steps: Step[] = [
|
||||
...(researchStatus != null
|
||||
? [
|
||||
{
|
||||
done: researchStatus === "done",
|
||||
busy: researching,
|
||||
title: researching
|
||||
? "Researching your company…"
|
||||
: "Company research",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
done: hasSource,
|
||||
title: "Connect a source",
|
||||
hint: "GitHub, Linear, Drive or Slack.",
|
||||
href: "/settings/integrations",
|
||||
done: slackConnected,
|
||||
title: slackConnected ? "Slack connected" : "Connect Slack",
|
||||
hint: slackConnected
|
||||
? undefined
|
||||
: trial.state === "trialing"
|
||||
? "Ask your brain from any channel."
|
||||
: "Starts your 14-day free trial. No credit card needed.",
|
||||
action: slackConnected
|
||||
? undefined
|
||||
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
|
||||
},
|
||||
...(rollout != null
|
||||
? [
|
||||
{
|
||||
done: rollout.status === "done",
|
||||
busy: rollout.status === "running",
|
||||
title:
|
||||
rollout.status === "running"
|
||||
? `Learning from channels… ${rollout.ready}/${rollout.discovered} ready`
|
||||
: rollout.status === "done"
|
||||
? `Learning from channels · ${rollout.ready} ready`
|
||||
: "Learning from channels",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
done: hasApps,
|
||||
title: "Connect apps",
|
||||
hint: hasApps ? undefined : "Linear, Notion, GitHub and more.",
|
||||
action: hasApps ? undefined : { label: "Set up", onClick: onSetUpApps },
|
||||
},
|
||||
{
|
||||
done: hasAgent,
|
||||
title: "Install a coding agent",
|
||||
hint: "Claude Code, Codex or Cursor.",
|
||||
href: "/settings/integrations",
|
||||
done: invited,
|
||||
title: "Invite teammates",
|
||||
hint: invited ? undefined : "Multiply what the brain remembers.",
|
||||
action:
|
||||
invited || !canInvite
|
||||
? undefined
|
||||
: { label: "Invite", onClick: onInvite },
|
||||
},
|
||||
{
|
||||
done: hasMemory,
|
||||
title: "Add your first memory",
|
||||
hint: "Save a doc, or ask your brain below.",
|
||||
title: "First memories captured",
|
||||
hint: hasMemory ? undefined : "Save a doc, or ask your brain below.",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative h-fit overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
|
|
@ -405,13 +594,15 @@ function GettingStarted({
|
|||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Getting started
|
||||
Your Company Brain
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
A few steps to make your brain useful.
|
||||
<p className="mb-4 mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
How far you've come.
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
<TrialStrip />
|
||||
|
||||
<ul className="space-y-2.5">
|
||||
{steps.map((step) => (
|
||||
<li key={step.title} className="flex items-start gap-3">
|
||||
<span
|
||||
|
|
@ -423,31 +614,44 @@ function GettingStarted({
|
|||
: "border-[rgba(82,89,102,0.4)]",
|
||||
)}
|
||||
>
|
||||
{step.done && <Check className="size-3 text-white" />}
|
||||
{step.done ? (
|
||||
<Check className="size-3 text-white" />
|
||||
) : step.busy ? (
|
||||
<Loader2 className="size-3 animate-spin text-[#4BA0FA]" />
|
||||
) : null}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[13px] font-medium",
|
||||
step.done
|
||||
? "text-[#737373] line-through"
|
||||
: "text-[#fafafa]",
|
||||
step.done ? "text-[#737373]" : "text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
{!step.done && step.href && (
|
||||
<Link
|
||||
href={step.href}
|
||||
className="inline-flex shrink-0 items-center gap-0.5 text-[12px] font-medium text-[#4BA0FA] transition-opacity hover:opacity-80"
|
||||
>
|
||||
Set up
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
)}
|
||||
{!step.done &&
|
||||
step.action &&
|
||||
(step.action.href ? (
|
||||
<a
|
||||
href={step.action.href}
|
||||
className="inline-flex shrink-0 items-center gap-0.5 text-[12px] font-medium text-[#4BA0FA] transition-opacity hover:opacity-80"
|
||||
>
|
||||
{step.action.label}
|
||||
<ArrowRight className="size-3" />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={step.action.onClick}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center gap-0.5 text-[12px] font-medium text-[#4BA0FA] transition-opacity hover:opacity-80"
|
||||
>
|
||||
{step.action.label}
|
||||
<ArrowRight className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!step.done && (
|
||||
{!step.done && step.hint && (
|
||||
<p className="mt-0.5 text-[12px] font-medium leading-[1.4] text-[#737373]">
|
||||
{step.hint}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function titleCase(s: string) {
|
|||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function ConnectionsBoard() {
|
||||
export function useConnectionsBoard() {
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slack, setSlack] = useState<{
|
||||
|
|
@ -155,7 +155,6 @@ export function ConnectionsBoard() {
|
|||
}
|
||||
}
|
||||
|
||||
const { setViewMode } = useViewMode()
|
||||
const apps = catalog ?? []
|
||||
const loading = catalog === null
|
||||
const unconnected = apps.filter((a) => !isConnected(a.slug))
|
||||
|
|
@ -172,92 +171,84 @@ export function ConnectionsBoard() {
|
|||
const connectedCount = apps.filter((a) => isConnected(a.slug)).length
|
||||
const showBoard = loading || unconnected.length > 0
|
||||
|
||||
return {
|
||||
slack,
|
||||
loading,
|
||||
busy,
|
||||
featured,
|
||||
overflow,
|
||||
previewApps,
|
||||
connectedCount,
|
||||
showBoard,
|
||||
isConnected,
|
||||
connect,
|
||||
}
|
||||
}
|
||||
|
||||
export type ConnectionsBoardState = ReturnType<typeof useConnectionsBoard>
|
||||
|
||||
export const CONNECT_TOOLS_CARD_ID = "connect-tools"
|
||||
|
||||
export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
|
||||
const { setViewMode } = useViewMode()
|
||||
const { loading, featured, overflow, busy, isConnected, connect } = board
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{slack && !slack.connected && <SlackBanner />}
|
||||
|
||||
<div className="grid items-start gap-4 lg:grid-cols-5">
|
||||
{showBoard ? (
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(
|
||||
entry.slug,
|
||||
entry.name,
|
||||
"size-5",
|
||||
)}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={
|
||||
i < featured.length - 1 || overflow.length > 0
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<MoreTile
|
||||
count={overflow.length}
|
||||
names={overflow.slice(0, 3).map((a) => a.name)}
|
||||
onClick={() => void setViewMode("configure")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<AgentPreview
|
||||
apps={previewApps}
|
||||
isConnected={isConnected}
|
||||
connectedCount={connectedCount}
|
||||
wide={!showBoard}
|
||||
/>
|
||||
<section
|
||||
id={CONNECT_TOOLS_CARD_ID}
|
||||
className="relative flex h-fit min-w-0 scroll-mt-4 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already uses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name, "size-5")}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={i < featured.length - 1 || overflow.length > 0}
|
||||
/>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<MoreTile
|
||||
count={overflow.length}
|
||||
names={overflow.slice(0, 3).map((a) => a.name)}
|
||||
onClick={() => void setViewMode("configure")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentPreview({
|
||||
apps,
|
||||
isConnected,
|
||||
connectedCount,
|
||||
wide = false,
|
||||
}: {
|
||||
apps: CatalogEntry[]
|
||||
isConnected: (slug: string) => boolean
|
||||
connectedCount: number
|
||||
wide?: boolean
|
||||
}) {
|
||||
const prompts = apps
|
||||
export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
|
||||
const { previewApps, isConnected, connectedCount } = board
|
||||
const prompts = previewApps
|
||||
.filter((a) => AGENT_PROMPTS[a.slug])
|
||||
.slice(0, 6)
|
||||
.map((a) => ({
|
||||
|
|
@ -269,10 +260,7 @@ function AgentPreview({
|
|||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5",
|
||||
wide ? "lg:col-span-5" : "lg:col-span-2",
|
||||
)}
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -437,7 +425,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SlackBanner() {
|
||||
export function SlackBanner() {
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
|
||||
|
|
|
|||
45
apps/web/components/brain-trial-pill.tsx
Normal file
45
apps/web/components/brain-trial-pill.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Hourglass, TriangleAlert, X } from "lucide-react"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useBrainTrial } from "@/hooks/use-brain-trial"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
export function BrainTrialPill({ className }: { className?: string }) {
|
||||
const trial = useBrainTrial()
|
||||
const { openSettings } = useSettingsModal()
|
||||
|
||||
if (trial.state === "none") return null
|
||||
|
||||
const ended = trial.state === "ended"
|
||||
const days = trial.daysRemaining
|
||||
const closing = !ended && days != null && days <= 3
|
||||
const label = ended
|
||||
? "Trial ended · Activate"
|
||||
: days != null
|
||||
? `Trial · ${days} day${days === 1 ? "" : "s"} left`
|
||||
: "Free trial"
|
||||
const Icon = ended ? X : closing ? TriangleAlert : Hourglass
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings("billing")}
|
||||
aria-label={ended ? "Trial ended — activate Scale" : label}
|
||||
className={cn(
|
||||
"inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 text-[12px] font-medium transition-colors",
|
||||
ended
|
||||
? "border-[#E5735A]/30 bg-[#E5735A]/10 text-[#E5735A] hover:bg-[#E5735A]/15"
|
||||
: closing
|
||||
? "border-[#E5A45A]/30 bg-[#E5A45A]/10 text-[#E5A45A] hover:bg-[#E5A45A]/15"
|
||||
: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white/90",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import { DomainLogo } from "@/components/onboarding-brain/step-about"
|
|||
import { FeedbackModal } from "@/components/feedback-modal"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { BrainTrialPill } from "@/components/brain-trial-pill"
|
||||
import { GraphIcon } from "@/components/integration-icons"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
|
|
@ -496,6 +497,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<BrainTrialPill className="h-9 px-3" />
|
||||
{canInvite && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
Globe,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -464,6 +464,19 @@ function DockedHeader({
|
|||
exhausted: boolean
|
||||
onContinue: () => void
|
||||
}) {
|
||||
// Same key as the rail/header queries so a connect in another tab is picked up on refocus.
|
||||
const { data: slack } = useQuery({
|
||||
queryKey: ["brain-slack-status"],
|
||||
queryFn: async (): Promise<{ connected: boolean }> => {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return { connected: false }
|
||||
return (await res.json()) as { connected: boolean }
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const slackConnected = slack?.connected ?? false
|
||||
const brandName = workspaceNameFromDomain(domain) || domain
|
||||
const showSpinner = !done && !exhausted
|
||||
const statusLabel = done
|
||||
|
|
@ -504,6 +517,12 @@ function DockedHeader({
|
|||
)}
|
||||
{statusLabel}
|
||||
</span>
|
||||
{slackConnected && (
|
||||
<span className="hidden shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium text-[#5CD68A] sm:flex">
|
||||
<Check className="size-3" />
|
||||
Slack connected · free trial started
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Never gated on research; the admin can move on while it keeps working. */}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -522,16 +522,21 @@ function SlackStepBody({
|
|||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
<div>
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
<p className="mt-2 text-center text-[11px] font-medium leading-[1.5] text-[#525D6E]">
|
||||
Starts your 14-day free trial. No credit card needed.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
83
apps/web/hooks/use-brain-trial.ts
Normal file
83
apps/web/hooks/use-brain-trial.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { getBrainTrialInfo } from "@/lib/billing-utils"
|
||||
|
||||
export type BrainTrialState = {
|
||||
state: "trialing" | "ended" | "none"
|
||||
endsAtMs: number | null
|
||||
startedAtMs: number | null
|
||||
daysRemaining: number | null
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const NONE: BrainTrialState = {
|
||||
state: "none",
|
||||
endsAtMs: null,
|
||||
startedAtMs: null,
|
||||
daysRemaining: null,
|
||||
}
|
||||
|
||||
// Mirrors settings billing: open metadata trial OR Autumn trialing counts as trialing.
|
||||
export function useBrainTrial(): BrainTrialState {
|
||||
const { org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { isTrialing, trialEndsAtMs, hasPaidPlan, isLoading } =
|
||||
useTokenUsage(autumn)
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => setTick((v) => v + 1), 60_000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tick keeps daysRemaining current across midnight
|
||||
return useMemo(() => {
|
||||
const meta = getBrainTrialInfo(
|
||||
org?.metadata as Record<string, unknown> | string | null | undefined,
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
const metaOpen =
|
||||
meta.status === "active" && (meta.endsAtMs == null || meta.endsAtMs > now)
|
||||
const autumnOpen =
|
||||
isTrialing && (trialEndsAtMs == null || trialEndsAtMs > now)
|
||||
|
||||
if (metaOpen || autumnOpen) {
|
||||
const endsAtMs = meta.endsAtMs ?? trialEndsAtMs
|
||||
return {
|
||||
state: "trialing" as const,
|
||||
endsAtMs,
|
||||
startedAtMs:
|
||||
meta.startedAtMs ??
|
||||
(endsAtMs != null ? endsAtMs - 14 * DAY_MS : null),
|
||||
daysRemaining:
|
||||
endsAtMs != null
|
||||
? Math.max(0, Math.ceil((endsAtMs - now) / DAY_MS))
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
const metaEnded =
|
||||
meta.status === "expired" ||
|
||||
meta.status === "exhausted" ||
|
||||
(meta.status === "active" &&
|
||||
meta.endsAtMs != null &&
|
||||
meta.endsAtMs <= now)
|
||||
const autumnEnded =
|
||||
isTrialing && trialEndsAtMs != null && trialEndsAtMs <= now
|
||||
// Wait for Autumn so a paid/converted org never flashes trial chrome.
|
||||
if ((metaEnded || autumnEnded) && !isLoading && !hasPaidPlan) {
|
||||
return {
|
||||
state: "ended" as const,
|
||||
endsAtMs: meta.endsAtMs ?? trialEndsAtMs,
|
||||
startedAtMs: meta.startedAtMs,
|
||||
daysRemaining: 0,
|
||||
}
|
||||
}
|
||||
return NONE
|
||||
}, [org?.metadata, isTrialing, trialEndsAtMs, hasPaidPlan, isLoading, tick])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue