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:
MaheshtheDev 2026-08-02 07:46:05 +00:00
parent f14cdd7a4c
commit a787041ca7
7 changed files with 531 additions and 185 deletions

View file

@ -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>

View file

@ -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"

View 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>
)
}

View file

@ -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>

View file

@ -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

View file

@ -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>
)
}

View 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])
}