mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat(marketing): CI-first homepage, connector API pages, and hero chat demo
- Homepage rebuilt around competitive-intelligence positioning: connector grid, how-it-works, use-case art, compare table, FAQ, and a scripted new-chat hero demo (typewriter prompt, agent timeline, streamed answer, /login on send) - Marketing pages for the Reddit/YouTube/Maps/SERP/Web Crawl scrape APIs at /<slug>, a /connectors hub, and a bespoke /mcp-connector page - Remove stale app/dashboard/[search_space_id] tree that broke the build (canonical route is [workspace_id]) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
80927a2872
commit
fb5b0da816
137 changed files with 4161 additions and 16017 deletions
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { motion, useReducedMotion, type Variants } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgentTranscript as AgentTranscriptModel } from "@/lib/connectors-marketing/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Custom ease-out: fast start, gentle settle. Never ease-in for entrances.
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
|
||||
const container: Variants = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.06, delayChildren: 0.08 } },
|
||||
};
|
||||
|
||||
// Enter from a small offset + fade. Never from scale(0). Under 300ms per item.
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.24, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
/** Reveal a string one character at a time. Returns the full string instantly when disabled. */
|
||||
function useTypedText(text: string, enabled: boolean, speedMs = 16) {
|
||||
const [count, setCount] = useState(enabled ? 0 : text.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setCount(text.length);
|
||||
return;
|
||||
}
|
||||
setCount(0);
|
||||
let i = 0;
|
||||
const id = window.setInterval(() => {
|
||||
i += 1;
|
||||
setCount(i);
|
||||
if (i >= text.length) window.clearInterval(id);
|
||||
}, speedMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [text, enabled, speedMs]);
|
||||
|
||||
return { shown: text.slice(0, count), done: count >= text.length };
|
||||
}
|
||||
|
||||
export function AgentTranscript({
|
||||
transcript,
|
||||
className,
|
||||
}: {
|
||||
transcript: AgentTranscriptModel;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
const animate = !reduce;
|
||||
const { shown: typedPrompt, done: promptDone } = useTypedText(transcript.prompt, animate);
|
||||
const revealed = promptDone;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-xl border bg-card shadow-sm",
|
||||
"font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Window chrome */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-2.5">
|
||||
<span className="flex gap-1.5" aria-hidden>
|
||||
<span className="size-2.5 rounded-full bg-muted-foreground/25" />
|
||||
<span className="size-2.5 rounded-full bg-muted-foreground/25" />
|
||||
<span className="size-2.5 rounded-full bg-muted-foreground/25" />
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">agent · surfsense</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-5">
|
||||
{/* Prompt line (typed) */}
|
||||
<p className="flex flex-wrap items-baseline gap-x-2 leading-relaxed">
|
||||
<span className="select-none text-muted-foreground">$</span>
|
||||
<span className="text-foreground">
|
||||
{typedPrompt}
|
||||
{animate && !promptDone && (
|
||||
<span className="ml-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 animate-pulse bg-foreground/70" />
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Tool call + results reveal only after the prompt is typed */}
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
variants={container}
|
||||
initial={animate ? "hidden" : false}
|
||||
animate={revealed ? "show" : "hidden"}
|
||||
>
|
||||
<motion.pre
|
||||
variants={item}
|
||||
className="overflow-x-auto rounded-lg border bg-muted/50 px-3 py-2.5 text-xs leading-relaxed text-muted-foreground"
|
||||
>
|
||||
<code className="whitespace-pre-wrap wrap-break-word text-foreground/80">
|
||||
{transcript.toolCall}
|
||||
</code>
|
||||
</motion.pre>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{transcript.rows.map((row) => (
|
||||
<motion.li
|
||||
key={row.primary}
|
||||
variants={item}
|
||||
className="flex items-start justify-between gap-3 rounded-lg border bg-background px-3 py-2.5"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-medium text-foreground">
|
||||
{row.primary}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{row.secondary}
|
||||
</span>
|
||||
</span>
|
||||
{row.tag && (
|
||||
<span className="shrink-0 rounded-full border border-brand/30 bg-brand/10 px-2 py-0.5 text-[10px] font-medium tracking-wide text-brand uppercase">
|
||||
{row.tag}
|
||||
</span>
|
||||
)}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<motion.p
|
||||
variants={item}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Check className="size-3.5 text-brand" aria-hidden />
|
||||
{transcript.resultSummary}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
surfsense_web/components/connectors-marketing/api-mcp-tabs.tsx
Normal file
255
surfsense_web/components/connectors-marketing/api-mcp-tabs.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { ApiSample } from "@/lib/connectors-marketing/types";
|
||||
|
||||
/** Pretty-print the request body as canonical JSON (a valid JS/JSON object literal). */
|
||||
function prettyJson(requestBody: Record<string, unknown>): string {
|
||||
return JSON.stringify(requestBody, null, 2);
|
||||
}
|
||||
|
||||
/** Indent every line except the first by `pad` (for splicing a JSON block inline). */
|
||||
function indentRest(text: string, pad: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : pad + line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Render one request-body value as a Python literal (True/False, not true/false). */
|
||||
function toPythonValue(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "True" : "False";
|
||||
if (Array.isArray(value)) return `[${value.map((v) => JSON.stringify(v)).join(", ")}]`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `"key" <arrow> <json-value>,` lines for languages whose literal syntax matches
|
||||
* JSON scalar/array values (Ruby hashes, PHP arrays).
|
||||
*/
|
||||
function kvLines(requestBody: Record<string, unknown>, arrow: string, pad: string): string {
|
||||
return Object.entries(requestBody)
|
||||
.map(([k, v]) => `${pad}"${k}" ${arrow} ${JSON.stringify(v)},`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildCurl({ platform, verb, requestBody }: ApiSample): string {
|
||||
const body = indentRest(prettyJson(requestBody), " ");
|
||||
return [
|
||||
`curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/${platform}/${verb}" \\`,
|
||||
` -H "Authorization: Bearer $SURFSENSE_API_KEY" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body}'`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildPython({ platform, verb, requestBody }: ApiSample): string {
|
||||
const entries = Object.entries(requestBody)
|
||||
.map(([k, v]) => ` ${JSON.stringify(k)}: ${toPythonValue(v)},`)
|
||||
.join("\n");
|
||||
return [
|
||||
"import os",
|
||||
"import requests",
|
||||
"",
|
||||
"base = os.environ['SURFSENSE_API_URL']",
|
||||
"key = os.environ['SURFSENSE_API_KEY']",
|
||||
"",
|
||||
"resp = requests.post(",
|
||||
` f"{base}/workspaces/{WORKSPACE_ID}/scrapers/${platform}/${verb}",`,
|
||||
' headers={"Authorization": f"Bearer {key}"},',
|
||||
" json={",
|
||||
entries,
|
||||
" },",
|
||||
")",
|
||||
"resp.raise_for_status()",
|
||||
'items = resp.json()["items"]',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildJavaScript({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
`const payload = ${prettyJson(requestBody)};`,
|
||||
"",
|
||||
"const res = await fetch(",
|
||||
" `${process.env.SURFSENSE_API_URL}/workspaces/${WORKSPACE_ID}/scrapers/" +
|
||||
platform +
|
||||
"/" +
|
||||
verb +
|
||||
"`,",
|
||||
" {",
|
||||
' method: "POST",',
|
||||
" headers: {",
|
||||
" Authorization: `Bearer ${process.env.SURFSENSE_API_KEY}`,",
|
||||
' "Content-Type": "application/json",',
|
||||
" },",
|
||||
" body: JSON.stringify(payload),",
|
||||
" },",
|
||||
");",
|
||||
"const { items } = await res.json();",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildGo({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"package main",
|
||||
"",
|
||||
"import (",
|
||||
'\t"bytes"',
|
||||
'\t"net/http"',
|
||||
'\t"os"',
|
||||
")",
|
||||
"",
|
||||
"func main() {",
|
||||
"\tpayload := []byte(`" + prettyJson(requestBody) + "`)",
|
||||
'\turl := os.Getenv("SURFSENSE_API_URL") + "/workspaces/" +',
|
||||
'\t\tos.Getenv("WORKSPACE_ID") + "/scrapers/' + platform + "/" + verb + '"',
|
||||
'\treq, _ := http.NewRequest("POST", url, bytes.NewReader(payload))',
|
||||
'\treq.Header.Set("Authorization", "Bearer "+os.Getenv("SURFSENSE_API_KEY"))',
|
||||
'\treq.Header.Set("Content-Type", "application/json")',
|
||||
"\tresp, _ := http.DefaultClient.Do(req)",
|
||||
"\tdefer resp.Body.Close()",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildPhp({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"<?php",
|
||||
"$ch = curl_init();",
|
||||
"curl_setopt_array($ch, [",
|
||||
' CURLOPT_URL => getenv("SURFSENSE_API_URL")',
|
||||
' . "/workspaces/" . getenv("WORKSPACE_ID")',
|
||||
' . "/scrapers/' + platform + "/" + verb + '",',
|
||||
" CURLOPT_POST => true,",
|
||||
" CURLOPT_RETURNTRANSFER => true,",
|
||||
" CURLOPT_HTTPHEADER => [",
|
||||
' "Authorization: Bearer " . getenv("SURFSENSE_API_KEY"),',
|
||||
' "Content-Type: application/json",',
|
||||
" ],",
|
||||
" CURLOPT_POSTFIELDS => json_encode([",
|
||||
kvLines(requestBody, "=>", " "),
|
||||
" ]),",
|
||||
"]);",
|
||||
'$items = json_decode(curl_exec($ch), true)["items"];',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRuby({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
'require "net/http"',
|
||||
'require "json"',
|
||||
'require "uri"',
|
||||
"",
|
||||
"uri = URI(\"#{ENV['SURFSENSE_API_URL']}/workspaces/#{ENV['WORKSPACE_ID']}/scrapers/" +
|
||||
platform +
|
||||
"/" +
|
||||
verb +
|
||||
'")',
|
||||
"req = Net::HTTP::Post.new(uri)",
|
||||
'req["Authorization"] = "Bearer #{ENV[\'SURFSENSE_API_KEY\']}"',
|
||||
'req["Content-Type"] = "application/json"',
|
||||
"req.body = {",
|
||||
kvLines(requestBody, "=>", " "),
|
||||
"}.to_json",
|
||||
"",
|
||||
"res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }",
|
||||
'items = JSON.parse(res.body)["items"]',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildJava({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"import java.net.URI;",
|
||||
"import java.net.http.HttpClient;",
|
||||
"import java.net.http.HttpRequest;",
|
||||
"import java.net.http.HttpResponse;",
|
||||
"",
|
||||
"var client = HttpClient.newHttpClient();",
|
||||
'String payload = """',
|
||||
prettyJson(requestBody),
|
||||
'""";',
|
||||
"var request = HttpRequest.newBuilder()",
|
||||
' .uri(URI.create(System.getenv("SURFSENSE_API_URL")',
|
||||
' + "/workspaces/" + System.getenv("WORKSPACE_ID")',
|
||||
' + "/scrapers/' + platform + "/" + verb + '"))',
|
||||
' .header("Authorization", "Bearer " + System.getenv("SURFSENSE_API_KEY"))',
|
||||
' .header("Content-Type", "application/json")',
|
||||
" .POST(HttpRequest.BodyPublishers.ofString(payload))",
|
||||
" .build();",
|
||||
"var response = client.send(request, HttpResponse.BodyHandlers.ofString());",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildCsharp({ platform, verb, requestBody }: ApiSample): string {
|
||||
return [
|
||||
"using System;",
|
||||
"using System.Net.Http;",
|
||||
"using System.Text;",
|
||||
"",
|
||||
"var http = new HttpClient();",
|
||||
'var url = Environment.GetEnvironmentVariable("SURFSENSE_API_URL")',
|
||||
' + "/workspaces/" + Environment.GetEnvironmentVariable("WORKSPACE_ID")',
|
||||
' + "/scrapers/' + platform + "/" + verb + '";',
|
||||
'var payload = """',
|
||||
prettyJson(requestBody),
|
||||
'""";',
|
||||
"var request = new HttpRequestMessage(HttpMethod.Post, url)",
|
||||
"{",
|
||||
' Content = new StringContent(payload, Encoding.UTF8, "application/json"),',
|
||||
"};",
|
||||
'request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("SURFSENSE_API_KEY"));',
|
||||
"var response = await http.SendAsync(request);",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildMcp({ mcpTool }: ApiSample): string {
|
||||
const config = {
|
||||
mcpServers: {
|
||||
surfsense: {
|
||||
url: "https://mcp.surfsense.com",
|
||||
headers: { Authorization: "Bearer ${SURFSENSE_API_KEY}" },
|
||||
},
|
||||
},
|
||||
};
|
||||
return `${JSON.stringify(config, null, 2)}\n\n# Your agent can now call the ${mcpTool} tool.`;
|
||||
}
|
||||
|
||||
/** Ordered language set for the code-sample tabs. cURL is the default. */
|
||||
const SAMPLES: { value: string; label: string; build: (api: ApiSample) => string }[] = [
|
||||
{ value: "curl", label: "cURL", build: buildCurl },
|
||||
{ value: "python", label: "Python", build: buildPython },
|
||||
{ value: "javascript", label: "JavaScript", build: buildJavaScript },
|
||||
{ value: "go", label: "Go", build: buildGo },
|
||||
{ value: "php", label: "PHP", build: buildPhp },
|
||||
{ value: "ruby", label: "Ruby", build: buildRuby },
|
||||
{ value: "java", label: "Java", build: buildJava },
|
||||
{ value: "csharp", label: "C#", build: buildCsharp },
|
||||
{ value: "mcp", label: "MCP", build: buildMcp },
|
||||
];
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg border bg-muted/50 p-4 text-xs leading-relaxed">
|
||||
<code className="font-mono text-foreground/90">{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiMcpTabs({ api }: { api: ApiSample }) {
|
||||
return (
|
||||
<Tabs defaultValue="curl" className="w-full">
|
||||
<TabsList className="flex h-auto flex-wrap justify-start gap-1">
|
||||
{SAMPLES.map((sample) => (
|
||||
<TabsTrigger key={sample.value} value={sample.value}>
|
||||
{sample.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{SAMPLES.map((sample) => (
|
||||
<TabsContent key={sample.value} value={sample.value}>
|
||||
<CodeBlock code={sample.build(api)} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import type { FaqItem } from "@/lib/connectors-marketing/types";
|
||||
|
||||
export function ConnectorFaq({ items }: { items: FaqItem[] }) {
|
||||
return (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{items.map((item, i) => (
|
||||
<AccordionItem key={item.question} value={`item-${i}`}>
|
||||
<AccordionTrigger className="text-base">{item.question}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground leading-relaxed">
|
||||
{item.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
232
surfsense_web/components/connectors-marketing/connector-page.tsx
Normal file
232
surfsense_web/components/connectors-marketing/connector-page.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { ArrowRight, Check } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { ConnectorPageContent } from "@/lib/connectors-marketing/types";
|
||||
import { AgentTranscript } from "./agent-transcript";
|
||||
import { ApiMcpTabs } from "./api-mcp-tabs";
|
||||
import { ConnectorFaq } from "./connector-faq";
|
||||
import { Reveal } from "./reveal";
|
||||
|
||||
const GITHUB_URL = "https://github.com/MODSetter/SurfSense";
|
||||
|
||||
export function ConnectorPage({ content }: { content: ConnectorPageContent }) {
|
||||
const Icon = content.icon;
|
||||
const label = content.cardTitle ?? `${content.name} API`;
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{/* Hero */}
|
||||
<MarketingSection className="pt-28 pb-12 sm:pt-32 sm:pb-16">
|
||||
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
|
||||
<div>
|
||||
<BreadcrumbNav
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ name: "Connectors", href: "/connectors" },
|
||||
{ name: content.name, href: `/${content.slug}` },
|
||||
]}
|
||||
/>
|
||||
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
|
||||
<Icon className="size-3.5" />
|
||||
{content.name} connector
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
|
||||
{content.h1}
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
{content.heroLede}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/docs">Read the docs</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="lg">
|
||||
<Link href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandGithub className="size-4" />
|
||||
GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AgentTranscript transcript={content.transcript} />
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* What you can extract */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
What you can extract from {content.name}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
{content.extractIntro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{content.extractFields.map((field) => (
|
||||
<div
|
||||
key={field.label}
|
||||
className="rounded-xl border bg-card p-5 transition-colors hover:border-brand/40"
|
||||
>
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
<Check className="size-4 text-brand" aria-hidden />
|
||||
{field.label}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Use cases */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{content.useCasesHeading}
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
{content.useCases.map((useCase) => (
|
||||
<Reveal key={useCase.title}>
|
||||
<div className="h-full rounded-xl border bg-card p-6">
|
||||
<h3 className="text-lg font-semibold">{useCase.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{useCase.description}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
|
||||
{/* API / MCP */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Call it from your code or your agent
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
One typed endpoint, one API key. Or add the SurfSense MCP server and let your agent call{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">
|
||||
{content.api.mcpTool}
|
||||
</code>{" "}
|
||||
as a native tool.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
{/* Code capped at a readable measure; left edge stays on the page grid. */}
|
||||
<div className="mt-8 max-w-4xl">
|
||||
<ApiMcpTabs api={content.api} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Comparison */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{content.comparison.heading}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
{content.comparison.intro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-xl text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-left">
|
||||
<th className="p-4 font-medium">Feature</th>
|
||||
<th className="p-4 font-medium text-muted-foreground">
|
||||
{content.comparison.columnLabel}
|
||||
</th>
|
||||
<th className="p-4 font-medium text-brand">SurfSense</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{content.comparison.rows.map((row) => (
|
||||
<tr key={row.feature} className="border-b last:border-b-0">
|
||||
<th scope="row" className="p-4 text-left font-medium">
|
||||
{row.feature}
|
||||
</th>
|
||||
<td className="p-4 text-muted-foreground">{row.official}</td>
|
||||
<td className="bg-brand/5 p-4 text-foreground">{row.surfsense}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* FAQ */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{label}: frequently asked questions
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
{/* Accordion capped at a readable measure; left edge stays on the page grid. */}
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<ConnectorFaq items={content.faq} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
|
||||
{/* Closing CTA + related */}
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Point your agents at {content.name}
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
|
||||
The {content.name} connector is one of many in the SurfSense{" "}
|
||||
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
|
||||
competitive intelligence platform
|
||||
</Link>
|
||||
. Start free, no credit card required.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/pricing">See pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<nav aria-label="Other connectors" className="flex flex-wrap justify-center gap-2">
|
||||
{content.related.map((link) => (
|
||||
<Button key={link.href} asChild variant="ghost" size="sm">
|
||||
<Link href={link.href}>{link.label}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
surfsense_web/components/connectors-marketing/reveal.tsx
Normal file
38
surfsense_web/components/connectors-marketing/reveal.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
|
||||
/**
|
||||
* One quiet scroll reveal per section: fade + small rise, triggered once when
|
||||
* the block enters the viewport. Renders statically under prefers-reduced-motion.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
className,
|
||||
delay = 0,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
|
||||
if (reduce) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.28, ease: EASE_OUT, delay }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
48
surfsense_web/components/homepage/community-strip.tsx
Normal file
48
surfsense_web/components/homepage/community-strip.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const GITHUB_URL = "https://github.com/MODSetter/SurfSense";
|
||||
const DISCORD_URL = "https://discord.gg/ejRNvftDp9";
|
||||
|
||||
/** Closing CTA doubling as the GitHub/community strip (brief section 7). */
|
||||
export function CommunityStrip() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Open source, built in public
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
|
||||
No CI incumbent can show you its code. SurfSense can. Star the repository, join the
|
||||
community, or self-host the whole platform. Start free, no credit card required.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/register">
|
||||
Start for free
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandGithub className="size-4" />
|
||||
Star on GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="lg">
|
||||
<Link href={DISCORD_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandDiscord className="size-4" />
|
||||
Join Discord
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
81
surfsense_web/components/homepage/compare-table.tsx
Normal file
81
surfsense_web/components/homepage/compare-table.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
feature: "Who does the work",
|
||||
suites: "Analysts read human dashboards",
|
||||
scrapers: "You build everything on raw data",
|
||||
surfsense: "Your AI agents gather, analyze, and alert",
|
||||
},
|
||||
{
|
||||
feature: "Intelligence layer",
|
||||
suites: "Curated but slow, human-in-the-loop",
|
||||
scrapers: "None; data only",
|
||||
surfsense: "Agent harness turns live data into briefs",
|
||||
},
|
||||
{
|
||||
feature: "Pricing",
|
||||
suites: "Enterprise contracts, annual quotes",
|
||||
scrapers: "Usage-priced infrastructure",
|
||||
surfsense: "Self-serve, pay per item, free tier",
|
||||
},
|
||||
{
|
||||
feature: "Developer surface",
|
||||
suites: "No agent surface",
|
||||
scrapers: "APIs, but no MCP or harness",
|
||||
surfsense: "REST API with your API key, plus an MCP server for agents",
|
||||
},
|
||||
{
|
||||
feature: "Open source",
|
||||
suites: "No",
|
||||
scrapers: "No",
|
||||
surfsense: "Yes, self-hostable",
|
||||
},
|
||||
];
|
||||
|
||||
export function CompareTable() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How SurfSense compares</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
CI suites sell dashboards to analysts. Scraper APIs sell raw data to engineers. SurfSense
|
||||
gives AI agents the data and the harness in one open-source platform.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-2xl text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-left">
|
||||
<th className="p-4 font-medium">Feature</th>
|
||||
<th className="p-4 font-medium text-muted-foreground">
|
||||
CI suites
|
||||
<span className="block text-xs font-normal">Crayon, Klue</span>
|
||||
</th>
|
||||
<th className="p-4 font-medium text-muted-foreground">
|
||||
Scraper APIs
|
||||
<span className="block text-xs font-normal">Bright Data, Apify</span>
|
||||
</th>
|
||||
<th className="p-4 font-medium text-brand">SurfSense</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ROWS.map((row) => (
|
||||
<tr key={row.feature} className="border-b last:border-b-0">
|
||||
<th scope="row" className="p-4 text-left font-medium">
|
||||
{row.feature}
|
||||
</th>
|
||||
<td className="p-4 text-muted-foreground">{row.suites}</td>
|
||||
<td className="p-4 text-muted-foreground">{row.scrapers}</td>
|
||||
<td className="bg-brand/5 p-4 text-foreground">{row.surfsense}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
68
surfsense_web/components/homepage/connector-grid.tsx
Normal file
68
surfsense_web/components/homepage/connector-grid.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAllConnectors } from "@/lib/connectors-marketing";
|
||||
|
||||
/** Registry-driven connector grid with a live count badge (brief: never list connectors in copy). */
|
||||
export function ConnectorGrid() {
|
||||
const connectors = getAllConnectors();
|
||||
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Connectors for every platform your market uses
|
||||
</h2>
|
||||
<Badge variant="outline" className="py-1">
|
||||
{connectors.length} connectors and growing
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
Each connector is a platform-native REST API. Call it from your own app with your
|
||||
SurfSense API key, or hand it to your agents through the SurfSense MCP server. Live data
|
||||
in, structured intelligence out.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{connectors.map((connector) => {
|
||||
const Icon = connector.icon;
|
||||
return (
|
||||
<Link
|
||||
key={connector.slug}
|
||||
href={`/${connector.slug}`}
|
||||
className="group flex flex-col rounded-xl border bg-card p-5 transition-colors hover:border-brand/40"
|
||||
>
|
||||
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
<Icon className="size-5 text-foreground" />
|
||||
</span>
|
||||
<h3 className="mt-3 font-semibold">
|
||||
{connector.cardTitle ?? `${connector.name} API`}
|
||||
</h3>
|
||||
<p className="mt-1.5 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-2">
|
||||
{connector.heroLede}
|
||||
</p>
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-foreground">
|
||||
Explore
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/connectors">
|
||||
View all connectors
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"use client";
|
||||
import { IconMessageCircleQuestion } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CTAHomepage() {
|
||||
return (
|
||||
<section className="w-full grid grid-cols-1 md:grid-cols-3 my-20 md:my-20 justify-start relative z-20 max-w-7xl mx-auto bg-gradient-to-br from-gray-100 to-white dark:from-neutral-900 dark:to-neutral-950">
|
||||
<GridLineHorizontal className="top-0" offset="200px" />
|
||||
<GridLineHorizontal className="bottom-0 top-auto" offset="200px" />
|
||||
<GridLineVertical className="left-0" offset="80px" />
|
||||
<GridLineVertical className="left-auto right-0" offset="80px" />
|
||||
<div className="md:col-span-2 p-8 md:p-14">
|
||||
<h2 className="text-left text-neutral-500 dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
|
||||
Transform how your team{" "}
|
||||
<span className="font-bold text-black dark:text-white">discovers and collaborates</span>
|
||||
</h2>
|
||||
<p className="text-left text-neutral-500 mt-4 max-w-lg dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
|
||||
Unite your <span className="text-sky-700">team's knowledge</span> in one collaborative
|
||||
space with <span className="text-indigo-700">intelligent search</span>.
|
||||
</p>
|
||||
|
||||
<div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="mt-8 h-auto gap-2 rounded-lg border border-neutral-200 px-4 py-2 text-base text-black shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset] dark:border-neutral-800 dark:text-white"
|
||||
>
|
||||
<Link href="/contact" className="group">
|
||||
<span>Talk to us</span>
|
||||
<IconMessageCircleQuestion className="text-black dark:text-white group-hover:translate-x-1 stroke-[1px] h-3 w-3 mt-0.5 transition-transform duration-200" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="border-t md:border-t-0 md:border-l border-dashed p-8 md:p-14">
|
||||
<p className="text-base text-neutral-700 dark:text-neutral-200">
|
||||
"SurfSense has revolutionized how our team shares and discovers knowledge.
|
||||
Everyone can contribute and find what they need instantly. True collaboration at scale."
|
||||
</p>
|
||||
<div className="flex flex-col text-sm items-start mt-4 gap-1">
|
||||
<p className="font-bold text-neutral-800 dark:text-neutral-200">
|
||||
Sarah Chen
|
||||
</p>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
Research Lead
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"absolute w-[calc(100%+var(--offset))] h-[var(--height)] left-[calc(var(--offset)/2*-1)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "5px",
|
||||
"--width": "1px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "150px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"absolute h-[calc(100%+var(--offset))] w-[var(--width)] top-[calc(var(--offset)/2*-1)]",
|
||||
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,991 +0,0 @@
|
|||
import {
|
||||
IconBinaryTree,
|
||||
IconBolt,
|
||||
IconMessage,
|
||||
IconMicrophone,
|
||||
IconSearch,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { BentoGrid, BentoGridItem } from "@/components/ui/bento-grid";
|
||||
|
||||
export function FeaturesBentoGrid() {
|
||||
return (
|
||||
<BentoGrid className="max-w-7xl my-8 mx-auto md:auto-rows-[20rem]">
|
||||
{items.map((item, i) => (
|
||||
<BentoGridItem
|
||||
key={i}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
header={item.header}
|
||||
className={item.className}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</BentoGrid>
|
||||
);
|
||||
}
|
||||
|
||||
const CitationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-blue-950/20 dark:via-purple-950/20 dark:to-pink-950/20 p-4">
|
||||
<svg viewBox="0 0 400 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Citation feature illustration showing clickable source reference</title>
|
||||
{/* Background chat message */}
|
||||
<g>
|
||||
{/* Chat bubble */}
|
||||
<rect
|
||||
x="20"
|
||||
y="30"
|
||||
width="200"
|
||||
height="60"
|
||||
rx="12"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Text lines */}
|
||||
<line
|
||||
x1="35"
|
||||
y1="50"
|
||||
x2="150"
|
||||
y2="50"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="35"
|
||||
y1="65"
|
||||
x2="180"
|
||||
y2="65"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Citation badge with glow */}
|
||||
<defs>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Clickable citation */}
|
||||
<g className="cursor-pointer" filter="url(#glow)">
|
||||
<rect
|
||||
x="185"
|
||||
y="57"
|
||||
width="28"
|
||||
height="20"
|
||||
rx="6"
|
||||
className="fill-blue-500 dark:fill-blue-600"
|
||||
/>
|
||||
<text
|
||||
x="199"
|
||||
y="70"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
className="fill-white"
|
||||
textAnchor="middle"
|
||||
>
|
||||
[1]
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Connecting line with animation effect */}
|
||||
<g>
|
||||
<path
|
||||
d="M 199 77 Q 240 90, 260 110"
|
||||
className="stroke-blue-500 dark:stroke-blue-400"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
|
||||
{/* Arrow head */}
|
||||
<polygon
|
||||
points="258,113 262,110 260,106"
|
||||
className="fill-blue-500 dark:fill-blue-400"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Citation popup card */}
|
||||
<g>
|
||||
{/* Card shadow */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="75"
|
||||
rx="10"
|
||||
className="fill-black"
|
||||
opacity="0.1"
|
||||
transform="translate(2, 2)"
|
||||
/>
|
||||
|
||||
{/* Main card */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="75"
|
||||
rx="10"
|
||||
className="fill-white dark:fill-neutral-800 stroke-blue-500 dark:stroke-blue-400"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Card header */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="25"
|
||||
rx="10"
|
||||
className="fill-blue-100 dark:fill-blue-900/50"
|
||||
/>
|
||||
<line
|
||||
x1="245"
|
||||
y1="138"
|
||||
x2="390"
|
||||
y2="138"
|
||||
className="stroke-blue-200 dark:stroke-blue-800"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Header text */}
|
||||
<text
|
||||
x="317.5"
|
||||
y="128"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
className="fill-blue-700 dark:fill-blue-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Referenced Chunk
|
||||
</text>
|
||||
|
||||
{/* Content lines */}
|
||||
<line
|
||||
x1="255"
|
||||
y1="150"
|
||||
x2="365"
|
||||
y2="150"
|
||||
className="stroke-neutral-600 dark:stroke-neutral-400"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="255"
|
||||
y1="162"
|
||||
x2="340"
|
||||
y2="162"
|
||||
className="stroke-neutral-500 dark:stroke-neutral-500"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="255"
|
||||
y1="174"
|
||||
x2="380"
|
||||
y2="174"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Sparkle effects */}
|
||||
<g className="opacity-60">
|
||||
{/* Sparkle 1 */}
|
||||
<circle cx="195" cy="45" r="2" className="fill-yellow-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="195" cy="45" r="1" className="fill-white">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
{/* Sparkle 2 */}
|
||||
<circle cx="370" cy="125" r="2" className="fill-purple-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="370" cy="125" r="1" className="fill-white">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
{/* Sparkle 3 */}
|
||||
<circle cx="250" cy="95" r="1.5" className="fill-blue-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="3s"
|
||||
begin="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* AI Sparkle icon in corner */}
|
||||
<g transform="translate(25, 100)">
|
||||
<path
|
||||
d="M 0,0 L 3,-8 L 6,0 L 14,3 L 6,6 L 3,14 L 0,6 L -8,3 Z"
|
||||
className="fill-purple-500 dark:fill-purple-400"
|
||||
opacity="0.7"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 3 3"
|
||||
to="360 3 3"
|
||||
dur="8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CollaborationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Illustration of a realtime collaboration in a text editor."
|
||||
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none"
|
||||
>
|
||||
<div className="relative flex h-fit w-fit flex-col items-start">
|
||||
<div className="w-full text-2xl sm:text-3xl lg:text-4xl leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
<span className="flex items-stretch flex-wrap">
|
||||
{/* <span>Real-time </span> */}
|
||||
<span className="relative bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 px-1">
|
||||
Real-time
|
||||
</span>
|
||||
<span className="relative z-10 inline-flex items-stretch justify-start">
|
||||
<span className="absolute h-full w-0.5 rounded-b-sm bg-blue-500"></span>
|
||||
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-blue-500">
|
||||
Sarah
|
||||
</span>
|
||||
</span>
|
||||
<span>collabo</span>
|
||||
<span>orat</span>
|
||||
<span className="relative z-10 inline-flex items-stretch justify-start">
|
||||
<span className="absolute h-full w-0.5 rounded-b-sm bg-purple-600 dark:bg-purple-500"></span>
|
||||
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-purple-600 dark:bg-purple-500">
|
||||
Josh
|
||||
</span>
|
||||
</span>
|
||||
<span>ion</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute -right-4 bottom-0 -left-4 h-24 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnnotationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Illustration of a text editor with annotation comments."
|
||||
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none md:left-1/2"
|
||||
>
|
||||
<div className="relative flex h-fit w-fit flex-col items-start justify-center gap-3.5">
|
||||
{/* Text above the comment box */}
|
||||
<div className="absolute left-0 h-fit -translate-x-full pr-7 text-3xl sm:text-4xl lg:text-5xl tracking-tight whitespace-nowrap text-neutral-400 dark:text-neutral-600">
|
||||
<span className="relative">
|
||||
Add context with
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white dark:from-black via-white dark:via-black to-transparent"></div>
|
||||
</span>{" "}
|
||||
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
|
||||
comments
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comment card */}
|
||||
<div className="flex flex-col items-start gap-4 rounded-xl bg-neutral-100 dark:bg-neutral-900/50 px-6 py-5 text-xl sm:text-2xl lg:text-3xl max-w-md">
|
||||
<div className="truncate leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
<span>Let's discuss this tomorrow!</span>
|
||||
</div>
|
||||
|
||||
{/* Reaction icons */}
|
||||
<div className="flex items-center gap-3 opacity-30">
|
||||
{/* @ icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Mention icon</title>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
>
|
||||
<path d="M11.998 15.6a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2Z" />
|
||||
<path d="M15.602 8.4v4.44c0 1.326 1.026 2.52 2.28 2.52a2.544 2.544 0 0 0 2.52-2.52V12a8.4 8.4 0 1 0-3.36 6.72" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Emoji icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Emoji icon</title>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
>
|
||||
<path d="M12.002 20.4a8.4 8.4 0 1 0 0-16.8 8.4 8.4 0 0 0 0 16.8Z" />
|
||||
<path d="M9 13.8s.9 1.8 3 1.8 3-1.8 3-1.8M9.6 9.6h.008M14.398 9.6h.009M9.597 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6ZM14.402 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Attachment icon */}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Attachment icon</title>
|
||||
<path
|
||||
d="M16.8926 14.0829L12.425 18.4269C10.565 20.2353 7.47136 20.2353 5.61136 18.4269C3.75976 16.6269 3.75976 13.6029 5.61136 11.8029L12.4886 5.11529C13.7294 3.90809 15.7934 3.90689 17.0354 5.11169C18.2714 6.31169 18.2738 8.33009 17.039 9.53249L10.1462 16.2189C9.83929 16.5093 9.43285 16.6711 9.01036 16.6711C8.58786 16.6711 8.18142 16.5093 7.87456 16.2189C7.72623 16.0757 7.60817 15.9042 7.52737 15.7146C7.44656 15.525 7.40466 15.321 7.40416 15.1149C7.40416 14.7009 7.57216 14.3037 7.87456 14.0109L12.4178 9.59849"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute -right-4 bottom-0 -left-4 h-20 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AudioCommentIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src="/homepage/comments-audio.webp"
|
||||
alt="Audio Comment Illustration"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AiSortIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-emerald-50 via-teal-50 to-cyan-50 dark:from-emerald-950/20 dark:via-teal-950/20 dark:to-cyan-950/20 p-4">
|
||||
<svg viewBox="0 0 400 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>AI File Sorting illustration showing automatic folder organization</title>
|
||||
{/* Scattered documents on the left */}
|
||||
<g opacity="0.5">
|
||||
<rect
|
||||
x="20"
|
||||
y="40"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="4"
|
||||
className="fill-neutral-200 dark:fill-neutral-700"
|
||||
transform="rotate(-8 37 62)"
|
||||
/>
|
||||
<rect
|
||||
x="50"
|
||||
y="80"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="4"
|
||||
className="fill-neutral-200 dark:fill-neutral-700"
|
||||
transform="rotate(5 67 102)"
|
||||
/>
|
||||
<rect
|
||||
x="15"
|
||||
y="110"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="4"
|
||||
className="fill-neutral-200 dark:fill-neutral-700"
|
||||
transform="rotate(-3 32 132)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* AI sparkle / magic in the center */}
|
||||
<g transform="translate(140, 90)">
|
||||
<path
|
||||
d="M 0,-18 L 4,-6 L 16,-4 L 6,4 L 8,16 L 0,10 L -8,16 L -6,4 L -16,-4 L -4,-6 Z"
|
||||
className="fill-emerald-500 dark:fill-emerald-400"
|
||||
opacity="0.85"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0"
|
||||
to="360"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<circle cx="0" cy="0" r="3" className="fill-white dark:fill-emerald-200">
|
||||
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* Animated sorting arrows */}
|
||||
<g
|
||||
className="stroke-emerald-500 dark:stroke-emerald-400"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
>
|
||||
<path d="M 100 70 Q 140 60, 180 50" strokeDasharray="4,4">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path d="M 100 100 Q 140 100, 180 100" strokeDasharray="4,4">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path d="M 100 130 Q 140 140, 180 150" strokeDasharray="4,4">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
|
||||
{/* Organized folder tree on the right */}
|
||||
{/* Root folder */}
|
||||
<g>
|
||||
<rect
|
||||
x="220"
|
||||
y="30"
|
||||
width="160"
|
||||
height="28"
|
||||
rx="6"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<rect
|
||||
x="228"
|
||||
y="36"
|
||||
width="16"
|
||||
height="14"
|
||||
rx="3"
|
||||
className="fill-emerald-500 dark:fill-emerald-400"
|
||||
/>
|
||||
<line
|
||||
x1="252"
|
||||
y1="43"
|
||||
x2="330"
|
||||
y2="43"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Subfolder 1 */}
|
||||
<g>
|
||||
<line
|
||||
x1="240"
|
||||
y1="58"
|
||||
x2="240"
|
||||
y2="76"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="76"
|
||||
x2="250"
|
||||
y2="76"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="250"
|
||||
y="64"
|
||||
width="130"
|
||||
height="24"
|
||||
rx="5"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<rect
|
||||
x="257"
|
||||
y="70"
|
||||
width="12"
|
||||
height="11"
|
||||
rx="2"
|
||||
className="fill-teal-400 dark:fill-teal-500"
|
||||
/>
|
||||
<line
|
||||
x1="276"
|
||||
y1="76"
|
||||
x2="340"
|
||||
y2="76"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Subfolder 2 */}
|
||||
<g>
|
||||
<line
|
||||
x1="240"
|
||||
y1="76"
|
||||
x2="240"
|
||||
y2="108"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="108"
|
||||
x2="250"
|
||||
y2="108"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="250"
|
||||
y="96"
|
||||
width="130"
|
||||
height="24"
|
||||
rx="5"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<rect
|
||||
x="257"
|
||||
y="102"
|
||||
width="12"
|
||||
height="11"
|
||||
rx="2"
|
||||
className="fill-cyan-400 dark:fill-cyan-500"
|
||||
/>
|
||||
<line
|
||||
x1="276"
|
||||
y1="108"
|
||||
x2="350"
|
||||
y2="108"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Subfolder 3 */}
|
||||
<g>
|
||||
<line
|
||||
x1="240"
|
||||
y1="108"
|
||||
x2="240"
|
||||
y2="140"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="140"
|
||||
x2="250"
|
||||
y2="140"
|
||||
className="stroke-neutral-300 dark:stroke-neutral-600"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="250"
|
||||
y="128"
|
||||
width="130"
|
||||
height="24"
|
||||
rx="5"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<rect
|
||||
x="257"
|
||||
y="134"
|
||||
width="12"
|
||||
height="11"
|
||||
rx="2"
|
||||
className="fill-emerald-400 dark:fill-emerald-500"
|
||||
/>
|
||||
<line
|
||||
x1="276"
|
||||
y1="140"
|
||||
x2="325"
|
||||
y2="140"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-500"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Sparkle accents */}
|
||||
<g className="opacity-60">
|
||||
<circle cx="170" cy="45" r="2" className="fill-emerald-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="190" cy="155" r="1.5" className="fill-teal-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="155" cy="120" r="1.5" className="fill-cyan-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="3s"
|
||||
begin="0.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AutomationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-indigo-50 via-violet-50 to-fuchsia-50 dark:from-indigo-950/20 dark:via-violet-950/20 dark:to-fuchsia-950/20 p-4">
|
||||
<svg viewBox="0 0 800 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>
|
||||
AI automation flow illustration showing a trigger starting an AI agent that acts across
|
||||
connectors
|
||||
</title>
|
||||
|
||||
{/* Animated flow connectors */}
|
||||
<g
|
||||
className="stroke-violet-500 dark:stroke-violet-400"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
>
|
||||
<path d="M 215 100 L 320 100" strokeDasharray="6,6">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="12"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<path d="M 480 100 L 585 100" strokeDasharray="6,6">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="12"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
<g className="fill-violet-500 dark:fill-violet-400" opacity="0.7">
|
||||
<polygon points="320,100 312,95 312,105" />
|
||||
<polygon points="585,100 577,95 577,105" />
|
||||
</g>
|
||||
|
||||
{/* Trigger node */}
|
||||
<g>
|
||||
<rect
|
||||
x="40"
|
||||
y="60"
|
||||
width="175"
|
||||
height="80"
|
||||
rx="14"
|
||||
className="fill-white dark:fill-neutral-800 stroke-indigo-300 dark:stroke-indigo-700"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="127"
|
||||
y="50"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-indigo-600 dark:fill-indigo-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Trigger
|
||||
</text>
|
||||
{/* Schedule chip */}
|
||||
<g transform="translate(58, 80)">
|
||||
<rect
|
||||
width="64"
|
||||
height="22"
|
||||
rx="11"
|
||||
className="fill-indigo-100 dark:fill-indigo-900/50"
|
||||
/>
|
||||
<circle
|
||||
cx="14"
|
||||
cy="11"
|
||||
r="6"
|
||||
className="fill-none stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
y1="11"
|
||||
x2="14"
|
||||
y2="7"
|
||||
className="stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
y1="11"
|
||||
x2="17"
|
||||
y2="13"
|
||||
className="stroke-indigo-500 dark:stroke-indigo-400"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x="38"
|
||||
y="15"
|
||||
fontSize="9"
|
||||
fontWeight="500"
|
||||
className="fill-indigo-700 dark:fill-indigo-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Cron
|
||||
</text>
|
||||
</g>
|
||||
{/* Event chip */}
|
||||
<g transform="translate(58, 108)">
|
||||
<rect
|
||||
width="64"
|
||||
height="22"
|
||||
rx="11"
|
||||
className="fill-fuchsia-100 dark:fill-fuchsia-900/40"
|
||||
/>
|
||||
<path
|
||||
d="M 13 5 L 9 13 L 14 13 L 11 19 L 18 10 L 13 10 Z"
|
||||
className="fill-fuchsia-500 dark:fill-fuchsia-400"
|
||||
/>
|
||||
<text
|
||||
x="40"
|
||||
y="15"
|
||||
fontSize="9"
|
||||
fontWeight="500"
|
||||
className="fill-fuchsia-700 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Event
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* AI Agent core */}
|
||||
<g>
|
||||
<rect
|
||||
x="320"
|
||||
y="50"
|
||||
width="160"
|
||||
height="100"
|
||||
rx="16"
|
||||
className="fill-white dark:fill-neutral-800 stroke-violet-400 dark:stroke-violet-500"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<text
|
||||
x="400"
|
||||
y="40"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-violet-600 dark:fill-violet-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
AI Agent
|
||||
</text>
|
||||
{/* Sparkle */}
|
||||
<g transform="translate(400, 92)">
|
||||
<path
|
||||
d="M 0,-22 L 5,-7 L 20,-5 L 7,5 L 10,20 L 0,12 L -10,20 L -7,5 L -20,-5 L -5,-7 Z"
|
||||
className="fill-violet-500 dark:fill-violet-400"
|
||||
opacity="0.9"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0"
|
||||
to="360"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
<circle cx="0" cy="0" r="4" className="fill-white dark:fill-violet-200">
|
||||
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Actions across connectors */}
|
||||
<g>
|
||||
<rect
|
||||
x="585"
|
||||
y="60"
|
||||
width="175"
|
||||
height="80"
|
||||
rx="14"
|
||||
className="fill-white dark:fill-neutral-800 stroke-fuchsia-300 dark:stroke-fuchsia-700"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="672"
|
||||
y="50"
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
className="fill-fuchsia-600 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Act on Connectors
|
||||
</text>
|
||||
<g>
|
||||
<circle cx="618" cy="100" r="13" className="fill-indigo-100 dark:fill-indigo-900/50" />
|
||||
<circle cx="650" cy="100" r="13" className="fill-violet-100 dark:fill-violet-900/50" />
|
||||
<circle cx="682" cy="100" r="13" className="fill-fuchsia-100 dark:fill-fuchsia-900/50" />
|
||||
<circle cx="714" cy="100" r="13" className="fill-pink-100 dark:fill-pink-900/40" />
|
||||
<text
|
||||
x="730"
|
||||
y="104"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
className="fill-fuchsia-600 dark:fill-fuchsia-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
25+
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Sparkle accents */}
|
||||
<g className="opacity-60">
|
||||
<circle cx="270" cy="70" r="2" className="fill-violet-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="530" cy="130" r="2" className="fill-fuchsia-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Find, Ask, Act",
|
||||
description:
|
||||
"Get instant information, detailed updates, and cited answers across company and personal knowledge.",
|
||||
header: <CitationIllustration />,
|
||||
className: "md:col-span-2",
|
||||
icon: <IconSearch className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Work Together in Real Time",
|
||||
description:
|
||||
"Transform your company docs into multiplayer spaces with live edits, synced content, and presence.",
|
||||
header: <CollaborationIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconUsers className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "AI File Sorting",
|
||||
description:
|
||||
"Automatically organize documents into a smart folder hierarchy using AI-powered categorization by source, date, and topic.",
|
||||
header: <AiSortIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconBinaryTree className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Collaborate Beyond Text",
|
||||
description:
|
||||
"Create podcasts and multimedia your team can comment on, share, and refine together.",
|
||||
header: <AudioCommentIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconMicrophone className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Context Where It Counts",
|
||||
description: "Add comments directly to your chats and docs for clear, in-the-moment feedback.",
|
||||
header: <AnnotationIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconMessage className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Automate Your Workflows",
|
||||
description:
|
||||
"Describe an AI agent in plain English and SurfSense builds it. Run it on a schedule or trigger it when a document lands, acting across all your connectors hands-free.",
|
||||
header: <AutomationIllustration />,
|
||||
className: "md:col-span-3",
|
||||
icon: <IconBolt className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Sliders, Users, Workflow } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
export function FeaturesCards() {
|
||||
return (
|
||||
<section className="py-2 md:py-8 dark:bg-transparent">
|
||||
<div className="@container mx-auto max-w-7xl">
|
||||
<div className="text-center">
|
||||
<h2 className="text-balance text-4xl font-semibold lg:text-5xl">
|
||||
Your Team's AI-Powered Knowledge Hub
|
||||
</h2>
|
||||
<p className="mt-4">
|
||||
Powerful features designed to enhance collaboration, boost productivity, and streamline
|
||||
your workflow.
|
||||
</p>
|
||||
</div>
|
||||
<div className="@min-4xl:max-w-full @min-4xl:grid-cols-3 mx-auto mt-8 grid max-w-sm gap-6 *:text-center md:mt-16">
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Workflow className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Streamlined Workflow</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Centralize all your knowledge and resources in one intelligent workspace. Find what
|
||||
you need instantly and accelerate decision-making.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Users className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Seamless Collaboration</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Work together effortlessly with real-time collaboration tools that keep your entire
|
||||
team aligned.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Sliders className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Fully Customizable</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Choose from 100+ leading LLMs, seamlessly calling any model on demand. Even run
|
||||
on-prem local LLM inference via vLLM, Ollama, llama.cpp, LM Studio, and more.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const CardDecorator = ({ children }: { children: ReactNode }) => (
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative mx-auto size-36 mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
|
||||
>
|
||||
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-size-[24px_24px] opacity-10" />
|
||||
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
69
surfsense_web/components/homepage/flow-line.tsx
Normal file
69
surfsense_web/components/homepage/flow-line.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
|
||||
/** Column centers for the three equal-width step cards (1/6, 1/2, 5/6). */
|
||||
const NODES = ["16.666%", "50%", "83.333%"];
|
||||
|
||||
/**
|
||||
* Decorative data-flow line tying the three "How it works" steps together.
|
||||
* Sequence on first view: nodes pop in, the line draws left-to-right, then a
|
||||
* repeating dash pattern drifts forever to read as data flowing. Uses only
|
||||
* transform and background-position; renders statically under reduced motion.
|
||||
*/
|
||||
export function FlowLine() {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
|
||||
const dashes = {
|
||||
backgroundImage: "repeating-linear-gradient(90deg, currentColor 0 6px, transparent 6px 16px)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div aria-hidden className="relative mt-8 mb-4 hidden h-3 text-brand md:block">
|
||||
{/* Line track between the outer nodes */}
|
||||
<div className="absolute top-1/2 left-[16.666%] h-0.5 w-[66.666%] -translate-y-1/2 overflow-hidden rounded-full opacity-70">
|
||||
{reduce ? (
|
||||
<div className="size-full" style={dashes} />
|
||||
) : (
|
||||
<motion.div
|
||||
className="size-full origin-left"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileInView={{ scaleX: 1 }}
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
transition={{ duration: 0.7, ease: EASE_OUT, delay: 0.35 }}
|
||||
>
|
||||
<motion.div
|
||||
className="size-full"
|
||||
style={dashes}
|
||||
animate={{ backgroundPositionX: ["0px", "32px"] }}
|
||||
transition={{ duration: 1.6, ease: "linear", repeat: Infinity }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step nodes at each column center */}
|
||||
{NODES.map((left, i) => (
|
||||
<span
|
||||
key={left}
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left }}
|
||||
>
|
||||
{reduce ? (
|
||||
<span className="block size-3 rounded-full border-2 border-brand bg-background" />
|
||||
) : (
|
||||
<motion.span
|
||||
className="block size-3 rounded-full border-2 border-brand bg-background"
|
||||
initial={{ scale: 0 }}
|
||||
whileInView={{ scale: 1 }}
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.1 + i * 0.12 }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ export function FooterNew() {
|
|||
// title: "Clients",
|
||||
// href: "#",
|
||||
// },
|
||||
{
|
||||
title: "Connectors",
|
||||
href: "/connectors",
|
||||
},
|
||||
{
|
||||
title: "Pricing",
|
||||
href: "/pricing",
|
||||
|
|
|
|||
361
surfsense_web/components/homepage/hero-chat-demo.tsx
Normal file
361
surfsense_web/components/homepage/hero-chat-demo.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowUp, ChevronRightIcon, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type HeroChatDemoStep = {
|
||||
/** Timeline row title, phrased like the real tool display names ("Read webpage"). */
|
||||
title: string;
|
||||
/** Optional sub-bullets shown under the title while/after the step runs. */
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
export type HeroChatDemoScript = {
|
||||
/** The query the typewriter types and "sends". */
|
||||
prompt: string;
|
||||
/** Agent timeline steps, run sequentially like the real chat timeline. */
|
||||
steps: HeroChatDemoStep[];
|
||||
/** Bullet list in the final streamed answer. */
|
||||
rows: { primary: string; secondary: string }[];
|
||||
/** Closing line of the answer. */
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type Stage = "typing" | "steps" | "answer" | "done";
|
||||
|
||||
const PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
|
||||
|
||||
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
|
||||
function Caret() {
|
||||
return (
|
||||
<span className="ml-px inline-block h-4 w-0.5 animate-pulse bg-foreground align-text-bottom" />
|
||||
);
|
||||
}
|
||||
|
||||
/** Status dot cloned from the real timeline: pulsing while running, muted when settled. */
|
||||
function StatusDot({ running }: { running: boolean }) {
|
||||
if (running) {
|
||||
return (
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="size-2 rounded-full bg-muted-foreground/30" />;
|
||||
}
|
||||
|
||||
/** Clone of the chat's collapsible agent timeline (dots, connector lines, shimmer header). */
|
||||
function DemoTimeline({
|
||||
steps,
|
||||
startedCount,
|
||||
runningIndex,
|
||||
settled,
|
||||
}: {
|
||||
steps: HeroChatDemoStep[];
|
||||
startedCount: number;
|
||||
runningIndex: number;
|
||||
settled: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Mirror the real timeline: open while processing, auto-collapse once settled.
|
||||
useEffect(() => {
|
||||
setIsOpen(!settled);
|
||||
}, [settled]);
|
||||
|
||||
const visible = steps.slice(0, startedCount);
|
||||
const headerText = settled ? "Reviewed" : (steps[runningIndex]?.title ?? "Processing");
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="h-auto w-full justify-start gap-1.5 p-0 text-left text-sm font-normal text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
|
||||
>
|
||||
{settled ? <span>{headerText}</span> : <TextShimmerLoader text={headerText} size="sm" />}
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-3 pl-1">
|
||||
{visible.map((step, i) => {
|
||||
const running = !settled && i === runningIndex;
|
||||
const isLast = i === visible.length - 1;
|
||||
return (
|
||||
<div key={step.title} className="relative flex gap-3">
|
||||
<div className="relative flex w-2 flex-col items-center self-stretch">
|
||||
{!isLast && (
|
||||
<div className="absolute left-1/2 top-[15px] -bottom-[15px] w-px -translate-x-1/2 bg-muted-foreground/30" />
|
||||
)}
|
||||
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||
<StatusDot running={running} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-5",
|
||||
running ? "font-medium text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
{step.items && step.items.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{step.items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-start gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||
<span className="min-w-0">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scripted clone of the new-chat UI: the demo types a use-case prompt into the
|
||||
* composer, sends it, walks the agent timeline (like the real chat), then
|
||||
* streams the answer. Focusing the input pauses the demo (it resumes on empty
|
||||
* blur); sending anything routes to /login.
|
||||
*/
|
||||
export function HeroChatDemo({
|
||||
demo,
|
||||
reduceMotion,
|
||||
}: {
|
||||
demo: HeroChatDemoScript;
|
||||
reduceMotion: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [interrupted, setInterrupted] = useState(false);
|
||||
const [userText, setUserText] = useState("");
|
||||
const [stage, setStage] = useState<Stage>("typing");
|
||||
const [typed, setTyped] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [startedSteps, setStartedSteps] = useState(0);
|
||||
const [revealedRows, setRevealedRows] = useState(0);
|
||||
|
||||
const animating = !reduceMotion && !interrupted;
|
||||
|
||||
useEffect(() => {
|
||||
if (!animating) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
while (!cancelled) {
|
||||
setStage("typing");
|
||||
setTyped("");
|
||||
setSent(false);
|
||||
setStartedSteps(0);
|
||||
setRevealedRows(0);
|
||||
await wait(700);
|
||||
for (let i = 1; i <= demo.prompt.length; i++) {
|
||||
if (cancelled) return;
|
||||
setTyped(demo.prompt.slice(0, i));
|
||||
await wait(20);
|
||||
}
|
||||
await wait(550);
|
||||
if (cancelled) return;
|
||||
setSent(true);
|
||||
setTyped("");
|
||||
setStage("steps");
|
||||
await wait(500);
|
||||
for (let i = 1; i <= demo.steps.length; i++) {
|
||||
if (cancelled) return;
|
||||
setStartedSteps(i);
|
||||
await wait(1400);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setStage("answer");
|
||||
await wait(500); // timeline collapse
|
||||
for (let i = 1; i <= demo.rows.length; i++) {
|
||||
if (cancelled) return;
|
||||
setRevealedRows(i);
|
||||
await wait(500);
|
||||
}
|
||||
await wait(350);
|
||||
setStage("done");
|
||||
await wait(5600);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [animating, demo]);
|
||||
|
||||
// Keep the newest streamed content visible in the small viewport.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: demo progress states are scroll triggers, not effect inputs
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [stage, startedSteps, revealedRows, sent]);
|
||||
|
||||
// Reduced motion: show the finished conversation instead of looping.
|
||||
const showSent = reduceMotion ? true : !interrupted && sent;
|
||||
const shownStage: Stage = reduceMotion ? "done" : stage;
|
||||
const shownSteps = reduceMotion ? demo.steps.length : startedSteps;
|
||||
const shownRows = reduceMotion ? demo.rows.length : revealedRows;
|
||||
const stepsSettled = shownStage === "answer" || shownStage === "done";
|
||||
|
||||
const handleSend = () => {
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const composer = (
|
||||
<div className="rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 transition-[border-color] focus-within:border-input/60 hover:border-input/60">
|
||||
<div className="relative px-4 pt-1 pb-1">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={userText}
|
||||
onChange={(e) => setUserText(e.target.value)}
|
||||
onFocus={() => setInterrupted(true)}
|
||||
onBlur={() => {
|
||||
if (!userText.trim()) setInterrupted(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={interrupted ? PLACEHOLDER : undefined}
|
||||
aria-label="Try SurfSense: describe a task for your agent"
|
||||
className="w-full resize-none bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{!interrupted && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden px-4 pt-1 pb-1 text-sm"
|
||||
>
|
||||
{typed ? (
|
||||
<span className="text-foreground">
|
||||
{typed}
|
||||
<Caret />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{PLACEHOLDER}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Action bar: attach on the left, round send on the right */}
|
||||
<div className="mx-3 mb-3 flex items-center justify-between">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 items-center justify-center rounded-full text-muted-foreground"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
aria-label="Send message"
|
||||
className="size-9 shrink-0 rounded-full"
|
||||
>
|
||||
<ArrowUp className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-88 flex-col overflow-hidden rounded-lg border bg-background sm:h-96 sm:rounded-xl">
|
||||
{showSent ? (
|
||||
<>
|
||||
{/* Messages viewport */}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="flex-1 overflow-y-auto px-3 py-3 sm:px-4"
|
||||
aria-hidden={!interrupted}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* User message (real bubble style) */}
|
||||
<div className="flex justify-end pl-8">
|
||||
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2.5 text-sm text-foreground">
|
||||
{demo.prompt}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent timeline */}
|
||||
{shownSteps > 0 && (
|
||||
<DemoTimeline
|
||||
steps={demo.steps}
|
||||
startedCount={shownSteps}
|
||||
runningIndex={shownSteps - 1}
|
||||
settled={stepsSettled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Streamed answer */}
|
||||
{stepsSettled && shownRows > 0 && (
|
||||
<div className="space-y-2 pr-6 text-sm leading-relaxed text-foreground">
|
||||
<ul className="space-y-1.5">
|
||||
{demo.rows.slice(0, shownRows).map((row) => (
|
||||
<li
|
||||
key={row.primary}
|
||||
className="fade-in slide-in-from-bottom-1 flex animate-in items-start gap-2 duration-200"
|
||||
>
|
||||
<span className="mt-2 size-1 shrink-0 rounded-full bg-foreground/60" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{row.primary}</span>
|
||||
<span className="text-muted-foreground"> — {row.secondary}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{shownStage === "done" && (
|
||||
<p className="fade-in animate-in text-muted-foreground duration-300">
|
||||
{demo.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Composer docked at the bottom while a thread is active */}
|
||||
<div className="p-2 sm:p-3">{composer}</div>
|
||||
</>
|
||||
) : (
|
||||
/* Empty thread: composer centered, like the real new-chat welcome */
|
||||
<div className="flex flex-1 items-center p-3 sm:p-4">
|
||||
<div className="w-full">{composer}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +1,15 @@
|
|||
"use client";
|
||||
import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
CornerDownLeft,
|
||||
Download,
|
||||
Lightbulb,
|
||||
Monitor,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Balancer from "react-wrap-balancer";
|
||||
import { HeroChatDemo, type HeroChatDemoScript } from "@/components/homepage/hero-chat-demo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
GITHUB_RELEASES_URL,
|
||||
getAssetLabel,
|
||||
usePrimaryDownload,
|
||||
} from "@/lib/desktop-download-utils";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -74,161 +47,509 @@ type HeroUseCase = {
|
|||
title: string;
|
||||
description: string;
|
||||
src: string | null;
|
||||
comingSoon?: boolean;
|
||||
examples?: string[];
|
||||
/** Scripted chat demo shown when there is no recorded video. */
|
||||
demo?: HeroChatDemoScript;
|
||||
};
|
||||
|
||||
type HeroCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
desktopOnly?: boolean;
|
||||
useCases: HeroUseCase[];
|
||||
};
|
||||
|
||||
const HERO_TUTORIAL = "/homepage/hero_tutorial";
|
||||
const HERO_REALTIME = "/homepage/hero_realtime";
|
||||
|
||||
/*
|
||||
* Every scripted demo below mirrors a task the SurfSense agent has actually run
|
||||
* end-to-end (see backend agent e2e suite). Recorded videos take precedence via
|
||||
* `src`; everything else plays the chat demo.
|
||||
*/
|
||||
const CATEGORIES: HeroCategory[] = [
|
||||
{
|
||||
id: "desktop",
|
||||
label: "Desktop App",
|
||||
desktopOnly: true,
|
||||
id: "competitor-monitoring",
|
||||
label: "Competitor Monitoring",
|
||||
useCases: [
|
||||
{
|
||||
id: "general",
|
||||
title: "General Assist",
|
||||
description: "Launch SurfSense instantly from any application with a global shortcut.",
|
||||
src: `${HERO_TUTORIAL}/general_assist.mp4`,
|
||||
id: "pricing-watch",
|
||||
title: "Competitor Pricing Watch",
|
||||
description:
|
||||
"The agent extracts every plan from a competitor's pricing page, and an automation re-checks it so you hear about changes first.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Extract every plan, price, and limit from our top 3 competitors' pricing pages.",
|
||||
steps: [
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Crawl 3 pricing pages", "Extract plans, prices, limits into one table"],
|
||||
},
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: [
|
||||
"competitor-a.com/pricing · 4 plans",
|
||||
"competitor-b.com/pricing · 3 plans",
|
||||
"competitor-c.com/plans · 4 plans",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Daily pricing re-check · alert on any change"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Competitor A — Pro $49/mo",
|
||||
secondary: "10k credits · 3 seats · raised from $39 on Jun 12",
|
||||
},
|
||||
{
|
||||
primary: "Competitor B — Team $99/mo",
|
||||
secondary: "50k credits · unlimited seats · annual-only",
|
||||
},
|
||||
{
|
||||
primary: "Competitor C — Free tier removed",
|
||||
secondary: "Trial now 7 days · card required",
|
||||
},
|
||||
],
|
||||
summary: "3 pages parsed · 11 plans in one table · daily re-check scheduled",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quick",
|
||||
title: "Quick Assist",
|
||||
description: "Select text anywhere, then ask AI to explain, rewrite, or act on it.",
|
||||
src: `${HERO_TUTORIAL}/quick_assist.mp4`,
|
||||
id: "site-diff",
|
||||
title: "Product & Changelog Tracking",
|
||||
description:
|
||||
"An automation crawls a rival's product, changelog, and careers pages and briefs you on what shipped.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Every Monday, crawl our competitors' changelogs and brief me on what they shipped.",
|
||||
steps: [
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: [
|
||||
"competitor-a.com/changelog · 6 entries",
|
||||
"competitor-b.com/whats-new · 3 entries",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Weekly changelog brief · Mondays 8:00"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Competitor A shipped SSO + audit logs",
|
||||
secondary: "changelog · Jun 30 · enterprise push",
|
||||
},
|
||||
{
|
||||
primary: "Competitor B launched API v2 beta",
|
||||
secondary: "whats-new · Jul 2 · targets developers",
|
||||
},
|
||||
],
|
||||
summary: "Brief saved to workspace · automation runs Mondays 8:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "screenshot",
|
||||
title: "Screenshot Assist",
|
||||
description: "Capture any region of your screen and ask AI about what’s in it.",
|
||||
src: `${HERO_TUTORIAL}/screenshot_assist.mp4`,
|
||||
},
|
||||
{
|
||||
id: "watch-folder",
|
||||
title: "Watch Local Folder",
|
||||
description: "Auto-sync a local folder to your knowledge base. Great for Obsidian vaults.",
|
||||
src: `${HERO_TUTORIAL}/folder_watch.mp4`,
|
||||
id: "serp-watch",
|
||||
title: "Rank & Ad Monitoring",
|
||||
description:
|
||||
"Automations track the Google rankings, paid ads, and AI Overview citations your market actually sees.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Track who ranks and runs ads for our top 10 keywords in the US.",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["Scraping 10 SERPs (US) · organic, ads, AI Overviews"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Diff against last capture", "Flag rank and ad movements"],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Daily rank + ad watch on these keywords"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: '"competitive intelligence tools" — you #4, ↓1',
|
||||
secondary: "Competitor A took #3 · runs 2 sponsored ads",
|
||||
},
|
||||
{
|
||||
primary: "AI Overview cites Competitor B",
|
||||
secondary: 'triggered on "brand monitoring software"',
|
||||
},
|
||||
],
|
||||
summary: "10 SERPs captured · 3 movements flagged · daily automation on",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deliverables",
|
||||
label: "Deliverable Studio",
|
||||
id: "lead-generation",
|
||||
label: "B2B Lead Generation",
|
||||
useCases: [
|
||||
{
|
||||
id: "local-leads",
|
||||
title: "Local Business Leads",
|
||||
description:
|
||||
"Turn a category and a territory into a lead list with phones, websites, ratings, and decision-maker contacts.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Find the top 5 burger places in San Jose and pull staff contacts from their websites.",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Maps",
|
||||
items: ['Searching "burger san jose" · top 5 by rating and reviews'],
|
||||
},
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: ["Visiting 5 business sites", "Extracting staff and contact pages"],
|
||||
},
|
||||
{
|
||||
title: "Save document",
|
||||
items: ["Lead list with phones, sites, contacts"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "The Counter — 4.5★ (2,310 reviews)",
|
||||
secondary: "+1 408-423-9200 · thecounter.com · 2 contacts found",
|
||||
},
|
||||
{
|
||||
primary: "Paper Plane — 4.6★ (1,882 reviews)",
|
||||
secondary: "site crawled · owner + events email found",
|
||||
},
|
||||
{
|
||||
primary: "Smoking Pig BBQ — 4.4★ (3,041 reviews)",
|
||||
secondary: "+1 408-380-4784 · catering contact found",
|
||||
},
|
||||
],
|
||||
summary: "5 places · 9 contacts with provenance · saved as lead list",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team-rosters",
|
||||
title: "Team Rosters & Contacts",
|
||||
description:
|
||||
"Spider any company site and pull the full team with emails, socials, and provenance, exported to CSV.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Get the complete a16z team roster and save it as a CSV in my workspace.",
|
||||
steps: [
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: ["a16z.com/team · following profile links", "142 profiles found"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Enrich each profile with email or LinkedIn"],
|
||||
},
|
||||
{
|
||||
title: "Write file",
|
||||
items: ["a16z-team.csv · saved to workspace"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "142 team profiles extracted",
|
||||
secondary: "name, role, focus area, profile URL",
|
||||
},
|
||||
{
|
||||
primary: "89 enriched with email or LinkedIn",
|
||||
secondary: "pulled from bios and linked pages",
|
||||
},
|
||||
{ primary: "a16z-team.csv", secondary: "saved to your workspace · ready to export" },
|
||||
],
|
||||
summary: "Full roster in 94s · every row cites its source page",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "portfolio-mapping",
|
||||
title: "Portfolio & Market Mapping",
|
||||
description:
|
||||
"Map an investor's portfolio or a whole category, then enrich every company with pricing and contacts.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Get the a16z team and their portfolio companies.",
|
||||
steps: [
|
||||
{
|
||||
title: "Web Crawler",
|
||||
items: ["a16z.com/team · 142 profiles", "a16z.com/portfolio · 312 companies"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: [
|
||||
"Match partners to their portfolio boards",
|
||||
"Enrich your category with pricing",
|
||||
],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "312 portfolio companies mapped",
|
||||
secondary: "sector, stage, website, one-line pitch",
|
||||
},
|
||||
{
|
||||
primary: "48 in your category",
|
||||
secondary: "enriched with pricing page + team size",
|
||||
},
|
||||
],
|
||||
summary: "Market map saved · new investments auto-added by automation",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "brand-listening",
|
||||
label: "Brand & Market Listening",
|
||||
useCases: [
|
||||
{
|
||||
id: "reddit-listening",
|
||||
title: "Reddit Brand Monitoring",
|
||||
description:
|
||||
"Hear what your market says about you, your competitors, and your category in the threads where buyers speak candidly.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Give me 20 posts on Reddit where people ask for an alternative to NotebookLM.",
|
||||
steps: [
|
||||
{
|
||||
title: "Reddit",
|
||||
items: [
|
||||
'Searching "notebooklm alternative" across Reddit',
|
||||
"37 posts found · ranking by relevance and upvotes",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Tag each post: buying intent, churn signal, question"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Best NotebookLM alternative that's open source?",
|
||||
secondary: "r/selfhosted · 214 upvotes · buying intent",
|
||||
},
|
||||
{
|
||||
primary: "NotebookLM keeps losing my sources, what else?",
|
||||
secondary: "r/artificial · 96 upvotes · churn signal",
|
||||
},
|
||||
{
|
||||
primary: "Anything like NotebookLM but with an API?",
|
||||
secondary: "r/LocalLLaMA · 71 upvotes · developer intent",
|
||||
},
|
||||
],
|
||||
summary: "20 posts · 8 with buying intent · daily tracking automation on",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "youtube-sentiment",
|
||||
title: "YouTube Audience Sentiment",
|
||||
description:
|
||||
"Pull videos, transcripts, and comments at scale to mine what audiences praise and complain about.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt:
|
||||
"Analyze the comments on our competitor's last 10 videos and cluster the complaints.",
|
||||
steps: [
|
||||
{
|
||||
title: "Youtube",
|
||||
items: ["Fetching last 10 videos", "4,812 comments pulled"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Cluster complaints by theme", "Score sentiment per cluster"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Pricing complaints — 31% of negative comments",
|
||||
secondary: '"went from free to $20/mo overnight"',
|
||||
},
|
||||
{
|
||||
primary: "Export limits — 22%",
|
||||
secondary: '"can\'t get my own data out"',
|
||||
},
|
||||
{
|
||||
primary: "Praise: onboarding — 18% positive",
|
||||
secondary: "worth copying in your messaging",
|
||||
},
|
||||
],
|
||||
summary: "4,812 comments clustered · sentiment report saved",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "switcher-mining",
|
||||
title: "Switcher & Intent Mining",
|
||||
description:
|
||||
"Find the people actively looking for an alternative to a competitor, ranked by how ready they are to move.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Find people asking for alternatives to our biggest competitor this month.",
|
||||
steps: [
|
||||
{
|
||||
title: "Reddit",
|
||||
items: [
|
||||
'Searching "alternative" mentions · past month',
|
||||
"12 active switcher threads",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Rank by recency and engagement", "Extract switching triggers"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "12 threads with active switchers",
|
||||
secondary: "ranked by recency and engagement",
|
||||
},
|
||||
{
|
||||
primary: "Top trigger: API price increase",
|
||||
secondary: "mentioned in 7 of 12 threads",
|
||||
},
|
||||
],
|
||||
summary: "Outreach-ready summaries drafted for the 5 hottest threads",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "market-research",
|
||||
label: "Market Research",
|
||||
useCases: [
|
||||
{
|
||||
id: "deep-research",
|
||||
title: "Deep Research on the Live Web",
|
||||
description:
|
||||
"The agent crawls dozens of live sources on a question and synthesizes a cited answer, not a stale index.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Research the AI note-taking market and build a landscape brief with citations.",
|
||||
steps: [
|
||||
{
|
||||
title: "Research",
|
||||
items: ["Crawling 38 live sources", "Vendor sites, reviews, pricing pages"],
|
||||
},
|
||||
{
|
||||
title: "Generate report",
|
||||
items: ["Landscape brief · 24 inline citations"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "23 vendors mapped across 4 segments",
|
||||
secondary: "consumer, prosumer, team, developer-first",
|
||||
},
|
||||
{
|
||||
primary: "Pricing clusters at $10 and $20/mo",
|
||||
secondary: "3 vendors moved upmarket this quarter",
|
||||
},
|
||||
],
|
||||
summary: "Landscape brief saved · 24 inline citations you can check",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "geo-monitoring",
|
||||
title: "AI Overview & GEO Tracking",
|
||||
description:
|
||||
"Capture when Google's AI Overviews answer your market's queries, and exactly which sources they cite.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Which of our target keywords trigger an AI Overview, and who gets cited?",
|
||||
steps: [
|
||||
{
|
||||
title: "Google Search",
|
||||
items: ["Scraping 25 SERPs", "Capturing AI Overviews and citations"],
|
||||
},
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Map citations to competitors", "Compute your citation gap"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "9 of 25 keywords trigger an AI Overview",
|
||||
secondary: "up from 6 last month",
|
||||
},
|
||||
{
|
||||
primary: "Competitor A cited on 4 · you on 1",
|
||||
secondary: "their listicle wins 3 of those citations",
|
||||
},
|
||||
],
|
||||
summary: "Citation gap report saved · weekly re-check scheduled",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cited-briefs",
|
||||
title: "Cited Briefs & Alerts",
|
||||
description:
|
||||
"Everything the agents gather lands in your workspace as briefs and alerts with sources you can check.",
|
||||
src: null,
|
||||
demo: {
|
||||
prompt: "Send me a Monday brief of every competitor change detected last week.",
|
||||
steps: [
|
||||
{
|
||||
title: "Plan tasks",
|
||||
items: ["Collect pricing, changelog, SERP, Reddit signals"],
|
||||
},
|
||||
{
|
||||
title: "Create automation",
|
||||
items: ["Weekly brief · Mondays 8:00 · workspace + email"],
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
primary: "Sources: pricing, changelogs, SERPs, Reddit",
|
||||
secondary: "everything your agents tracked this week",
|
||||
},
|
||||
{
|
||||
primary: "Delivered to workspace + email",
|
||||
secondary: "every claim links to its source",
|
||||
},
|
||||
],
|
||||
summary: "Automation created · first brief lands Monday 8:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "platform-agent",
|
||||
label: "Platform Agent",
|
||||
useCases: [
|
||||
{
|
||||
id: "chat-workspace",
|
||||
title: "Chat With Everything You Gather",
|
||||
description:
|
||||
"Ask questions across every crawl, mention, and document in your workspace and get answers with inline citations.",
|
||||
src: `${HERO_TUTORIAL}/BQnaGif_compressed.mp4`,
|
||||
},
|
||||
{
|
||||
id: "report",
|
||||
title: "AI Report Generator",
|
||||
description:
|
||||
"Generate cited research reports from your documents, then export to PDF or Markdown.",
|
||||
"Turn your intelligence into cited research reports, then export to PDF or Markdown.",
|
||||
src: `${HERO_TUTORIAL}/ReportGenGif_compressed.mp4`,
|
||||
},
|
||||
{
|
||||
id: "podcast",
|
||||
title: "AI Podcast Generator",
|
||||
description: "Turn any document or folder into a two-host AI podcast in under 20 seconds.",
|
||||
description: "Turn any brief or folder into a two-host AI podcast in under 20 seconds.",
|
||||
src: `${HERO_TUTORIAL}/PodcastGenGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "presentation",
|
||||
title: "AI Presentation & Video Maker",
|
||||
description: "Create editable slide decks and narrated video overviews from your sources.",
|
||||
description: "Create editable slide decks and narrated video overviews from your findings.",
|
||||
src: `${HERO_TUTORIAL}/video_gen_surf.mp4`,
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
title: "AI Image Generator",
|
||||
description: "Generate high-quality images straight from your chats and documents.",
|
||||
src: `${HERO_TUTORIAL}/ImageGenGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
title: "AI Resume Builder",
|
||||
description: "Tailor your existing resume to any job description and beat the ATS.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Tailor my resume to this job description so it gets past ATS and lands an interview.",
|
||||
"Optimize my resume for ATS by matching the keywords in this job posting.",
|
||||
"Rewrite my resume bullet points to highlight the skills this role is asking for.",
|
||||
"Compare my resume against this job description and list the gaps to fix.",
|
||||
"Write a matching cover letter from my resume and this job description.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
label: "Automations",
|
||||
useCases: [
|
||||
{
|
||||
id: "schedule",
|
||||
title: "Scheduled AI Workflows",
|
||||
description: "Run an agent on a schedule: daily briefs, weekly digests, recurring reports.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Email me a daily brief of new documents in my knowledge base every morning.",
|
||||
"Generate a weekly status report from my Slack and Gmail every Friday.",
|
||||
"Run a monthly competitor analysis report and save it to my workspace.",
|
||||
"Summarize my GitHub and Linear activity into a daily standup update.",
|
||||
"Create a recurring weekly research report on the topics I track.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "event",
|
||||
title: "Event-Triggered Automations",
|
||||
id: "connect",
|
||||
title: "Connect & Sync Your Tools",
|
||||
description:
|
||||
"Fire an agent the moment a document lands in a folder, then post the result to your tools.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"When a PDF lands in my Research folder, generate a cited AI summary.",
|
||||
"When new meeting notes are added, turn them into meeting minutes with action items.",
|
||||
"When an invoice is uploaded, extract the vendor, total, and due date into a table.",
|
||||
"When a contract enters my Legal folder, flag key terms and renewal dates.",
|
||||
"When a resume is added to Candidates, screen it against the job description.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chat-built",
|
||||
title: "Chat-Built Automations",
|
||||
description: "Describe an automation in plain English and SurfSense builds it for you.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Build an AI agent that emails me a summary of new Notion pages each morning.",
|
||||
"Create a no-code automation that posts a weekly research digest to Slack.",
|
||||
"Set up an AI note taker that turns new meeting notes into minutes.",
|
||||
"Make a workflow that extracts action items from meeting notes and assigns owners.",
|
||||
"Automate a daily email brief from my Gmail and Google Drive.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "search-chat",
|
||||
label: "Search & Chat",
|
||||
useCases: [
|
||||
{
|
||||
id: "chat-docs",
|
||||
title: "Chat With Your PDFs & Docs",
|
||||
description: "Ask questions across all your files and get answers with inline citations.",
|
||||
src: `${HERO_TUTORIAL}/BQnaGif_compressed.mp4`,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
title: "AI Search With Citations",
|
||||
description: "Hybrid semantic and keyword search across your entire knowledge base.",
|
||||
src: `${HERO_TUTORIAL}/BSNCGif.mp4`,
|
||||
"Sync Notion, Slack, Google Drive, Gmail, GitHub, Linear and 25+ sources into the same searchable workspace.",
|
||||
src: `${HERO_TUTORIAL}/ConnectorFlowGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "collab",
|
||||
|
|
@ -236,45 +557,6 @@ const CATEGORIES: HeroCategory[] = [
|
|||
description: "Work on AI conversations with your team in real time.",
|
||||
src: `${HERO_REALTIME}/RealTimeChatGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "comments",
|
||||
title: "Comments & Mentions",
|
||||
description: "Comment and tag teammates on any AI message.",
|
||||
src: `${HERO_REALTIME}/RealTimeCommentsFlow.mp4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "connectors",
|
||||
label: "Connectors & Integrations",
|
||||
useCases: [
|
||||
{
|
||||
id: "connect",
|
||||
title: "Connect & Sync Your Tools",
|
||||
description:
|
||||
"Sync Notion, Slack, Google Drive, Gmail, GitHub, Linear and 25+ sources into one searchable corpus.",
|
||||
src: `${HERO_TUTORIAL}/ConnectorFlowGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "upload",
|
||||
title: "Chat With Uploaded Files",
|
||||
description: "Drop in PDFs, Office docs, images and audio. Instantly searchable.",
|
||||
src: `${HERO_TUTORIAL}/DocUploadGif.mp4`,
|
||||
},
|
||||
{
|
||||
id: "write-back",
|
||||
title: "Connector Write-Back",
|
||||
description: "Let the agent post results back to Notion, Slack, Linear and Drive.",
|
||||
src: null,
|
||||
comingSoon: true,
|
||||
examples: [
|
||||
"Post this research summary to my Notion workspace.",
|
||||
"Send these meeting action items to our team Slack channel.",
|
||||
"Create a Jira ticket from this bug report.",
|
||||
"Open a Linear issue from this feature request.",
|
||||
"Save this generated report to Google Drive as a doc.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -285,24 +567,24 @@ export function HeroSection() {
|
|||
<div className="mt-4 flex w-full min-w-0 flex-col items-start px-2 md:px-8 xl:px-0">
|
||||
<h1
|
||||
className={cn(
|
||||
"relative mt-4 max-w-7xl text-left text-4xl font-bold tracking-tight text-balance text-neutral-900 sm:text-5xl md:text-6xl xl:text-8xl dark:text-neutral-50"
|
||||
"relative mt-4 max-w-4xl text-left text-4xl font-bold tracking-tight text-balance text-neutral-900 sm:text-5xl md:text-6xl dark:text-neutral-50"
|
||||
)}
|
||||
>
|
||||
<Balancer>NotebookLM for Teams</Balancer>
|
||||
<Balancer>Give your AI agents competitive intelligence.</Balancer>
|
||||
</h1>
|
||||
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
|
||||
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-8 md:flex-row md:items-end md:gap-10">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
|
||||
"relative mb-8 max-w-2xl text-left text-sm text-neutral-600 antialiased sm:text-base md:text-lg dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
A free, open source NotebookLM alternative for teams with no data limits. Use ChatGPT,
|
||||
Claude AI, and any AI model for free.
|
||||
SurfSense is an open-source competitive intelligence platform. Your AI agents monitor
|
||||
competitors, track rankings, and listen to your market with live data from the
|
||||
platforms that matter, through one API or MCP server.
|
||||
</p>
|
||||
|
||||
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
|
||||
<DownloadButton />
|
||||
<GetStartedButton />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -346,88 +628,6 @@ function GetStartedButton() {
|
|||
);
|
||||
}
|
||||
|
||||
function DownloadButton() {
|
||||
const { os, primary, alternatives, isMobileOS } = usePrimaryDownload();
|
||||
|
||||
const fallbackUrl = GITHUB_RELEASES_URL;
|
||||
const mobileDisabledLabel = "Desktop app unavailable on mobile";
|
||||
|
||||
if (isMobileOS) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled
|
||||
className="h-14 w-full gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 sm:w-auto sm:px-6 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{mobileDisabledLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!primary) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-14 w-full gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-auto sm:px-6 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<a href={fallbackUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="size-4" />
|
||||
Download for {os}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-14 w-full items-stretch sm:w-auto">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-auto flex-1 gap-2 rounded-l-lg rounded-r-none border border-r-0 border-neutral-200 bg-white px-5 text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-[0.99] hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<a href={primary.url}>
|
||||
<Download className="size-4 shrink-0" />
|
||||
Download for {os}
|
||||
</a>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto rounded-l-none rounded-r-lg border border-neutral-200 bg-white px-2.5 text-neutral-500 shadow-sm transition duration-150 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{alternatives.map((asset) => (
|
||||
<DropdownMenuItem key={asset.name} asChild>
|
||||
<a href={asset.url} className="cursor-pointer">
|
||||
<Download className="mr-2 size-3.5" />
|
||||
{getAssetLabel(asset.name)}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={fallbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
All downloads
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TabVideo = memo(function TabVideo({
|
||||
src,
|
||||
title,
|
||||
|
|
@ -478,52 +678,6 @@ const TabVideo = memo(function TabVideo({
|
|||
);
|
||||
});
|
||||
|
||||
const UseCasePlaceholder = ({ title }: { title: string }) => (
|
||||
<Empty className="size-full justify-center rounded-lg border border-dashed bg-muted/30 sm:rounded-xl">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Clock aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Demo coming soon</EmptyTitle>
|
||||
<EmptyDescription className="text-pretty">{`A walkthrough of ${title} is on the way.`}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
|
||||
const UseCaseExamples = ({ examples }: { examples: string[] }) => (
|
||||
<div className="flex size-full flex-col gap-3 rounded-lg border border-dashed bg-muted/30 p-4 sm:rounded-xl sm:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-foreground">Try prompts like these today</p>
|
||||
</div>
|
||||
<ul className="flex min-w-0 flex-col gap-2">
|
||||
{examples.map((example) => (
|
||||
<li key={example}>
|
||||
<div className="flex items-start gap-2.5 rounded-md border bg-background px-3 py-2">
|
||||
<CornerDownLeft
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70"
|
||||
/>
|
||||
<span className="min-w-0 text-sm text-pretty text-muted-foreground">{example}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DesktopBadge = () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-0.5 inline-flex items-center text-amber-600 dark:text-amber-400">
|
||||
<Monitor aria-hidden="true" className="size-3.5" />
|
||||
<span className="sr-only">Desktop app only</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Desktop app only</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const UseCasePane = memo(function UseCasePane({
|
||||
useCase,
|
||||
reduceMotion,
|
||||
|
|
@ -532,7 +686,7 @@ const UseCasePane = memo(function UseCasePane({
|
|||
reduceMotion: boolean;
|
||||
}) {
|
||||
const { expanded, open, close } = useExpandedMedia();
|
||||
const hasVideo = !useCase.comingSoon && Boolean(useCase.src);
|
||||
const hasVideo = Boolean(useCase.src);
|
||||
|
||||
const media = hasVideo ? (
|
||||
<Button
|
||||
|
|
@ -546,13 +700,7 @@ const UseCasePane = memo(function UseCasePane({
|
|||
</Button>
|
||||
) : (
|
||||
<div className="bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950">
|
||||
{useCase.examples && useCase.examples.length > 0 ? (
|
||||
<UseCaseExamples examples={useCase.examples} />
|
||||
) : (
|
||||
<div className="aspect-video w-full">
|
||||
<UseCasePlaceholder title={useCase.title} />
|
||||
</div>
|
||||
)}
|
||||
{useCase.demo && <HeroChatDemo demo={useCase.demo} reduceMotion={reduceMotion} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -609,14 +757,6 @@ const CategoryPanel = memo(function CategoryPanel({
|
|||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{category.desktopOnly && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300/60 bg-amber-50 px-3 py-2 text-xs text-amber-800 sm:text-sm dark:border-amber-500/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<Sparkles aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<span className="text-pretty">
|
||||
The desktop app includes everything in SurfSense, plus these native-only superpowers.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
defaultValue={category.useCases[0]?.id}
|
||||
orientation="vertical"
|
||||
|
|
@ -670,15 +810,9 @@ const BrowserWindow = () => {
|
|||
<React.Fragment key={category.id}>
|
||||
<TabsTrigger
|
||||
value={category.id}
|
||||
className={cn(
|
||||
"h-auto shrink-0 touch-manipulation gap-1.5 rounded-md px-2.5 py-1 text-xs sm:text-sm",
|
||||
category.desktopOnly
|
||||
? "bg-amber-100/70 text-amber-800 hover:bg-amber-100 data-[state=active]:bg-amber-200/80 data-[state=active]:text-amber-900 data-[state=active]:shadow-sm dark:bg-amber-950/40 dark:text-amber-200 dark:hover:bg-amber-900/40 dark:data-[state=active]:bg-amber-900/60 dark:data-[state=active]:text-amber-50"
|
||||
: "data-[state=active]:bg-background data-[state=active]:shadow"
|
||||
)}
|
||||
className="h-auto shrink-0 touch-manipulation gap-1.5 rounded-md px-2.5 py-1 text-xs data-[state=active]:bg-background data-[state=active]:shadow sm:text-sm"
|
||||
>
|
||||
{category.label}
|
||||
{category.desktopOnly && <DesktopBadge />}
|
||||
</TabsTrigger>
|
||||
{index !== CATEGORIES.length - 1 && (
|
||||
<Separator
|
||||
|
|
|
|||
52
surfsense_web/components/homepage/home-faq.tsx
Normal file
52
surfsense_web/components/homepage/home-faq.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
import { FAQJsonLd } from "@/components/seo/json-ld";
|
||||
|
||||
/** Answers are 40-60 words, written as quotable definitions for AI Overviews. */
|
||||
export const HOME_FAQ = [
|
||||
{
|
||||
question: "What is competitive intelligence?",
|
||||
answer:
|
||||
"Competitive intelligence is the practice of gathering and analyzing public information about competitors and your market to make better decisions. It covers pricing, product moves, rankings, reviews, and what customers say online. SurfSense automates it: AI agents collect the live data and turn it into briefs and alerts.",
|
||||
},
|
||||
{
|
||||
question: "What is an MCP server?",
|
||||
answer:
|
||||
"An MCP server exposes tools and data to AI agents through the Model Context Protocol, an open standard adopted by Claude, Cursor, and most agent frameworks. Add the SurfSense MCP server and your agents can call every connector, such as reddit.scrape or google_search.scrape, as native tools.",
|
||||
},
|
||||
{
|
||||
question: "How is SurfSense different from a web scraping API?",
|
||||
answer:
|
||||
"A web scraping API returns raw data and leaves the intelligence to you. SurfSense pairs platform-native connectors with an agent harness: retries, structured output, credit metering, and an MCP server, so your agents go from a question to a brief without you building the plumbing in between.",
|
||||
},
|
||||
{
|
||||
question: "Can I use the connector APIs directly in my own app?",
|
||||
answer:
|
||||
"Yes. Every platform connector is a typed REST endpoint you can call from any language with your SurfSense API key, no agent required. Send a POST request with your query and get structured JSON back. Each connector page has copy-paste examples in cURL, Python, JavaScript, Go, and more.",
|
||||
},
|
||||
{
|
||||
question: "Can I self-host SurfSense?",
|
||||
answer:
|
||||
"Yes. SurfSense is open source and self-hostable, so you can run the entire platform on your own infrastructure and keep sensitive competitive research in-house. Use the cloud version to start in minutes, or deploy from the GitHub repository when you need full control.",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeFaq() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<FAQJsonLd questions={HOME_FAQ} />
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
{/* Accordion capped at a readable measure; left edge stays on the page grid. */}
|
||||
<div className="mt-6 max-w-3xl">
|
||||
<ConnectorFaq items={HOME_FAQ} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
49
surfsense_web/components/homepage/how-it-works.tsx
Normal file
49
surfsense_web/components/homepage/how-it-works.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { FlowLine } from "@/components/homepage/flow-line";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
/** Numbered because the content is genuinely sequential: connect, gather, act. */
|
||||
const STEPS = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Connect",
|
||||
description:
|
||||
"Grab one API key and call any connector straight from your own code, or add the SurfSense MCP server to Claude, Cursor, or your own agents. Every connector is a REST endpoint and a native agent tool.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Agents gather",
|
||||
description:
|
||||
"Your agents pull live data through the agent harness: platform connectors, retries, structured output, and credit metering handled for you.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "You act",
|
||||
description:
|
||||
"Get briefs and alerts instead of raw exports. A rank moves, a price changes, a thread turns on you, and you hear about it first.",
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How SurfSense works</h2>
|
||||
</Reveal>
|
||||
<FlowLine />
|
||||
<div className="grid gap-6 md:mt-0 mt-8 md:grid-cols-3">
|
||||
{STEPS.map((step, i) => (
|
||||
<Reveal key={step.number} delay={i * 0.06}>
|
||||
<div className="h-full rounded-xl border bg-card p-6">
|
||||
<span className="font-mono text-sm font-medium text-brand">{step.number}</span>
|
||||
<h3 className="mt-2 text-lg font-semibold">{step.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type React from "react";
|
||||
|
||||
interface Integration {
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
// Search
|
||||
{ name: "Tavily", icon: "/connectors/tavily.svg" },
|
||||
{ name: "Elasticsearch", icon: "/connectors/elasticsearch.svg" },
|
||||
{ name: "Baidu Search", icon: "/connectors/baidu-search.svg" },
|
||||
{ name: "SearXNG", icon: "/connectors/searxng.svg" },
|
||||
|
||||
// Communication
|
||||
{ name: "Slack", icon: "/connectors/slack.svg" },
|
||||
{ name: "Discord", icon: "/connectors/discord.svg" },
|
||||
{ name: "Gmail", icon: "/connectors/google-gmail.svg" },
|
||||
{ name: "Microsoft Teams", icon: "/connectors/microsoft-teams.svg" },
|
||||
|
||||
// Project Management
|
||||
{ name: "Linear", icon: "/connectors/linear.svg" },
|
||||
{ name: "Jira", icon: "/connectors/jira.svg" },
|
||||
{ name: "ClickUp", icon: "/connectors/clickup.svg" },
|
||||
{ name: "Airtable", icon: "/connectors/airtable.svg" },
|
||||
|
||||
// Documentation & Knowledge
|
||||
{ name: "Confluence", icon: "/connectors/confluence.svg" },
|
||||
{ name: "Notion", icon: "/connectors/notion.svg" },
|
||||
{ name: "BookStack", icon: "/connectors/bookstack.svg" },
|
||||
{ name: "Obsidian", icon: "/connectors/obsidian.svg" },
|
||||
|
||||
// Cloud Storage
|
||||
{ name: "Google Drive", icon: "/connectors/google-drive.svg" },
|
||||
{ name: "OneDrive", icon: "/connectors/onedrive.svg" },
|
||||
{ name: "Dropbox", icon: "/connectors/dropbox.svg" },
|
||||
|
||||
// Development
|
||||
{ name: "GitHub", icon: "/connectors/github.svg" },
|
||||
|
||||
// Productivity
|
||||
{ name: "Google Calendar", icon: "/connectors/google-calendar.svg" },
|
||||
{ name: "Luma", icon: "/connectors/luma.svg" },
|
||||
|
||||
// Media
|
||||
{ name: "YouTube", icon: "/connectors/youtube.svg" },
|
||||
|
||||
// Search
|
||||
{ name: "Linkup", icon: "/connectors/linkup.svg" },
|
||||
|
||||
// Meetings
|
||||
{ name: "Circleback", icon: "/connectors/circleback.svg" },
|
||||
|
||||
// AI
|
||||
{ name: "MCP", icon: "/connectors/modelcontextprotocol.svg" },
|
||||
];
|
||||
|
||||
// 5 vertical columns — 26 icons spread across categories
|
||||
const COLUMNS: number[][] = [
|
||||
[2, 5, 10, 0, 21, 11],
|
||||
[1, 7, 20, 17, 24],
|
||||
[13, 6, 23, 4, 16, 25],
|
||||
[12, 8, 15, 18],
|
||||
[3, 9, 14, 22, 19],
|
||||
];
|
||||
|
||||
// Different scroll speeds per column for organic feel (seconds)
|
||||
const SCROLL_DURATIONS = [26, 32, 22, 30, 28];
|
||||
|
||||
function IntegrationCard({ integration }: { integration: Integration }) {
|
||||
return (
|
||||
<div
|
||||
className="w-[60px] h-[60px] sm:w-[80px] sm:h-[80px] md:w-[120px] md:h-[120px] lg:w-[140px] lg:h-[140px] rounded-[16px] sm:rounded-[20px] md:rounded-[24px] flex items-center justify-center shrink-0 select-none"
|
||||
style={{
|
||||
background: "linear-gradient(145deg, var(--card-from), var(--card-to))",
|
||||
boxShadow: "inset 0 1px 0 0 var(--card-highlight), 0 4px 24px var(--card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={integration.icon}
|
||||
alt={integration.name}
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 md:w-10 md:h-10 lg:w-12 lg:h-12 object-contain select-none pointer-events-none"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollingColumn({
|
||||
cards,
|
||||
scrollUp,
|
||||
duration,
|
||||
colIndex,
|
||||
isEdge,
|
||||
isEdgeAdjacent,
|
||||
}: {
|
||||
cards: number[];
|
||||
scrollUp: boolean;
|
||||
duration: number;
|
||||
colIndex: number;
|
||||
isEdge: boolean;
|
||||
isEdgeAdjacent: boolean;
|
||||
}) {
|
||||
// Edge columns get a heavy vertical mask; edge-adjacent columns get a lighter one to smooth the transition
|
||||
const columnMask = isEdge
|
||||
? {
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 20%, black 40%, black 60%, transparent 80%, transparent 100%)",
|
||||
}
|
||||
: isEdgeAdjacent
|
||||
? {
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, transparent 10%, black 30%, black 70%, transparent 90%, transparent 100%)",
|
||||
}
|
||||
: {};
|
||||
|
||||
const cardSet = cards.map((integrationIndex, i) => (
|
||||
<IntegrationCard
|
||||
key={`${INTEGRATIONS[integrationIndex].name}-c${colIndex}-${i}`}
|
||||
integration={INTEGRATIONS[integrationIndex]}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 overflow-hidden"
|
||||
style={{ ...columnMask, contain: "layout style paint" }}
|
||||
>
|
||||
{/* Outer div has NO gap — each inner copy uses pb matching the gap so both halves are identical in height → seamless -50% loop */}
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
animation: `${scrollUp ? "integrations-scroll-up" : "integrations-scroll-down"} ${duration}s linear infinite`,
|
||||
willChange: "transform",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
|
||||
{cardSet}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:gap-3 md:gap-5 lg:gap-6 pb-2 sm:pb-3 md:pb-5 lg:pb-6">
|
||||
{cardSet}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalIntegrations() {
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"relative py-20 md:py-28 overflow-hidden",
|
||||
// No explicit background — inherits the page gradient for seamless blending
|
||||
// CSS custom properties — light mode (card styling)
|
||||
"[--card-from:rgba(255,255,255,0.9)]",
|
||||
"[--card-to:rgba(245,245,248,0.92)]",
|
||||
"[--card-highlight:rgba(255,255,255,0.5)]",
|
||||
"[--card-lowlight:transparent]",
|
||||
"[--card-shadow:transparent]",
|
||||
"[--card-border:transparent]",
|
||||
// CSS custom properties — dark mode (card styling)
|
||||
"dark:[--card-from:rgb(28,28,32)]",
|
||||
"dark:[--card-to:rgb(28,28,32)]",
|
||||
"dark:[--card-highlight:rgba(255,255,255,0.03)]",
|
||||
"dark:[--card-lowlight:rgba(0,0,0,0.1)]",
|
||||
"dark:[--card-shadow:rgba(0,0,0,0.15)]",
|
||||
"dark:[--card-border:rgba(255,255,255,0.03)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Heading */}
|
||||
<div className="text-center mb-12 md:mb-16 relative z-20 px-4">
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-[1.1] tracking-tight">
|
||||
Integrate with your
|
||||
<br />
|
||||
team's most important tools
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Scrolling columns container — masked at edges so the page background shows through seamlessly */}
|
||||
<div
|
||||
className="relative"
|
||||
style={
|
||||
{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, black 25%, black 70%, transparent 100%), " +
|
||||
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%), " +
|
||||
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 5 scrolling columns */}
|
||||
<div className="flex justify-center gap-2 sm:gap-3 md:gap-5 lg:gap-6 h-[340px] sm:h-[420px] md:h-[560px] lg:h-[640px] overflow-hidden">
|
||||
{COLUMNS.map((column, colIndex) => (
|
||||
<ScrollingColumn
|
||||
key={`col-${SCROLL_DURATIONS[colIndex]}-${colIndex}`}
|
||||
cards={column}
|
||||
scrollUp={colIndex % 2 === 0}
|
||||
duration={SCROLL_DURATIONS[colIndex]}
|
||||
colIndex={colIndex}
|
||||
isEdge={colIndex === 0 || colIndex === COLUMNS.length - 1}
|
||||
isEdgeAdjacent={colIndex === 1 || colIndex === COLUMNS.length - 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
"use client";
|
||||
import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import {
|
||||
IconBook,
|
||||
IconBrandDiscord,
|
||||
IconBrandReddit,
|
||||
IconChevronDown,
|
||||
IconMenu2,
|
||||
IconNews,
|
||||
IconSparkles,
|
||||
IconSpeakerphone,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { SignInButton } from "@/components/auth/sign-in-button";
|
||||
import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge";
|
||||
import { Logo } from "@/components/Logo";
|
||||
|
|
@ -15,6 +25,38 @@ interface NavItem {
|
|||
link: string;
|
||||
}
|
||||
|
||||
interface ResourceItem extends NavItem {
|
||||
description: string;
|
||||
icon: typeof IconNews;
|
||||
}
|
||||
|
||||
const resourceItems: ResourceItem[] = [
|
||||
{
|
||||
name: "Blog",
|
||||
link: "/blog",
|
||||
description: "Guides, comparisons, and deep dives",
|
||||
icon: IconNews,
|
||||
},
|
||||
{
|
||||
name: "Announcements",
|
||||
link: "/announcements",
|
||||
description: "Product news and updates",
|
||||
icon: IconSpeakerphone,
|
||||
},
|
||||
{
|
||||
name: "Changelog",
|
||||
link: "/changelog",
|
||||
description: "What's new in SurfSense",
|
||||
icon: IconSparkles,
|
||||
},
|
||||
{
|
||||
name: "Docs",
|
||||
link: "/docs",
|
||||
description: "Setup, connectors, and API reference",
|
||||
icon: IconBook,
|
||||
},
|
||||
];
|
||||
|
||||
interface NavbarProps {
|
||||
/** Override the scrolled-state background classes (desktop & mobile). */
|
||||
scrolledBgClassName?: string;
|
||||
|
|
@ -35,13 +77,11 @@ interface MobileNavProps {
|
|||
export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ name: "Free\u00A0AI", link: "/free" },
|
||||
const navItems: NavItem[] = [
|
||||
{ name: "Connectors", link: "/connectors" },
|
||||
{ name: "Pricing", link: "/pricing" },
|
||||
{ name: "Blog", link: "/blog" },
|
||||
{ name: "Changelog", link: "/changelog" },
|
||||
{ name: "Docs", link: "/docs" },
|
||||
{ name: "Contact\u00A0Us", link: "/contact" },
|
||||
{ name: "Free\u00A0AI", link: "/free" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -72,6 +112,96 @@ export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
|
|||
);
|
||||
};
|
||||
|
||||
const ResourcesDropdown = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const openMenu = () => {
|
||||
if (closeTimeout.current) clearTimeout(closeTimeout.current);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
// ponytail: small close delay bridges the pointer gap between trigger and panel
|
||||
const closeMenu = () => {
|
||||
closeTimeout.current = setTimeout(() => setOpen(false), 100);
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: hover intent only; keyboard access lives on the button
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={openMenu}
|
||||
onMouseLeave={closeMenu}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded-full px-4 py-2 text-neutral-600 outline-none transition-colors dark:text-neutral-300",
|
||||
open && "bg-gray-100 dark:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
Resources
|
||||
<IconChevronDown
|
||||
className={cn("h-3.5 w-3.5 transition-transform duration-200", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="absolute left-1/2 top-full -translate-x-1/2 pt-2">
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: shouldReduceMotion ? 1 : 0.95,
|
||||
y: shouldReduceMotion ? 0 : 6,
|
||||
}}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: shouldReduceMotion ? 1 : 0.97,
|
||||
y: shouldReduceMotion ? 0 : 4,
|
||||
transition: { duration: 0.12, ease: "easeIn" },
|
||||
}}
|
||||
transition={{ type: "spring", duration: 0.3, bounce: 0.15 }}
|
||||
className="w-72 origin-top overflow-hidden rounded-2xl border border-white/20 bg-white/90 p-2 shadow-2xl backdrop-blur-xl dark:border-neutral-800/50 dark:bg-neutral-950/90"
|
||||
>
|
||||
{resourceItems.map((item) => (
|
||||
<Link
|
||||
key={item.link}
|
||||
href={item.link}
|
||||
onClick={() => setOpen(false)}
|
||||
className="group flex items-start gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-gray-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-500 transition-colors group-hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:group-hover:text-white">
|
||||
<item.icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavProps) => {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
return (
|
||||
|
|
@ -96,21 +226,23 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavPro
|
|||
</Link>
|
||||
<div className="hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2">
|
||||
{navItems.map((navItem: NavItem, idx: number) => (
|
||||
<Link
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
|
||||
key={navItem.link}
|
||||
href={navItem.link}
|
||||
>
|
||||
{hovered === idx && (
|
||||
<motion.div
|
||||
layoutId="hovered"
|
||||
className="absolute inset-0 h-full w-full rounded-full bg-gray-100 dark:bg-neutral-800"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-20">{navItem.name}</span>
|
||||
</Link>
|
||||
<Fragment key={navItem.link}>
|
||||
<Link
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
|
||||
href={navItem.link}
|
||||
>
|
||||
{hovered === idx && (
|
||||
<motion.div
|
||||
layoutId="hovered"
|
||||
className="absolute inset-0 h-full w-full rounded-full bg-gray-100 dark:bg-neutral-800"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-20">{navItem.name}</span>
|
||||
</Link>
|
||||
{navItem.link === "/pricing" && <ResourcesDropdown />}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
|
|
@ -206,13 +338,24 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
|
|||
className="absolute inset-x-0 top-full mt-1 z-20 flex w-full flex-col items-start justify-start gap-4 rounded-xl bg-white/90 backdrop-blur-xl border border-white/20 shadow-2xl px-4 py-6 dark:bg-neutral-950/90 dark:border-neutral-800/50"
|
||||
>
|
||||
{navItems.map((navItem: NavItem) => (
|
||||
<Link
|
||||
key={navItem.link}
|
||||
href={navItem.link}
|
||||
className="relative text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<motion.span className="block">{navItem.name} </motion.span>
|
||||
</Link>
|
||||
<Fragment key={navItem.link}>
|
||||
<Link
|
||||
href={navItem.link}
|
||||
className="relative text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<motion.span className="block">{navItem.name} </motion.span>
|
||||
</Link>
|
||||
{navItem.link === "/pricing" &&
|
||||
resourceItems.map((item) => (
|
||||
<Link
|
||||
key={item.link}
|
||||
href={item.link}
|
||||
className="relative text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<motion.span className="block">{item.name} </motion.span>
|
||||
</Link>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="flex w-full items-center gap-2 pt-2">
|
||||
<Link
|
||||
|
|
|
|||
301
surfsense_web/components/homepage/use-case-art.tsx
Normal file
301
surfsense_web/components/homepage/use-case-art.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
|
||||
const VIEWPORT = { once: true, amount: 0.4 } as const;
|
||||
|
||||
export type UseCaseArtVariant = "price" | "brand" | "leads" | "serp";
|
||||
|
||||
/** Soft infinite pulse ring marking the "live" signal in each artifact. */
|
||||
function Pulse({
|
||||
cx,
|
||||
cy,
|
||||
reduce,
|
||||
delay = 0,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
reduce: boolean;
|
||||
delay?: number;
|
||||
}) {
|
||||
if (reduce) return null;
|
||||
return (
|
||||
<motion.circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={5}
|
||||
className="fill-brand/40"
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
animate={{ scale: [1, 2.4], opacity: [0.5, 0] }}
|
||||
transition={{ duration: 2, ease: "easeOut", repeat: Infinity, delay }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Competitor price monitoring: a price line steps up; the change point alerts. */
|
||||
function PriceArt({ reduce }: { reduce: boolean }) {
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* Grid */}
|
||||
{[24, 48, 72].map((y) => (
|
||||
<line
|
||||
key={y}
|
||||
x1="12"
|
||||
y1={y}
|
||||
x2="228"
|
||||
y2={y}
|
||||
className="stroke-muted-foreground/15"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{/* Price line with a step change at x=132 */}
|
||||
<motion.path
|
||||
d="M 12 66 L 56 64 L 96 62 L 132 62 L 132 38 L 176 36 L 228 34"
|
||||
fill="none"
|
||||
className="stroke-brand"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
variants={{ hidden: { pathLength: 0 }, visible: { pathLength: 1 } }}
|
||||
transition={{ duration: 0.9, ease: EASE_OUT }}
|
||||
/>
|
||||
{/* Alert at the step */}
|
||||
<Pulse cx={132} cy={38} reduce={reduce} delay={0.9} />
|
||||
<motion.circle
|
||||
cx="132"
|
||||
cy="38"
|
||||
r="4"
|
||||
className="fill-brand"
|
||||
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.7 }}
|
||||
/>
|
||||
{/* Price-change tag */}
|
||||
<motion.g
|
||||
variants={{ hidden: { opacity: 0, y: 6 }, visible: { opacity: 1, y: 0 } }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.85 }}
|
||||
>
|
||||
<rect x="146" y="46" width="52" height="18" rx="4" className="fill-brand/10" />
|
||||
<text x="172" y="59" textAnchor="middle" className="fill-brand text-[10px] font-medium">
|
||||
+$10/mo
|
||||
</text>
|
||||
</motion.g>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brand monitoring: a listening radar; mentions blip in around the brand. */
|
||||
function BrandArt({ reduce }: { reduce: boolean }) {
|
||||
const blips = [
|
||||
{ cx: 84, cy: 30, delay: 0.5 },
|
||||
{ cx: 168, cy: 38, delay: 0.7 },
|
||||
{ cx: 100, cy: 70, delay: 0.9 },
|
||||
];
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* Radar rings */}
|
||||
{[16, 30, 44].map((r, i) => (
|
||||
<motion.circle
|
||||
key={r}
|
||||
cx="120"
|
||||
cy="48"
|
||||
r={r}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground/20"
|
||||
strokeWidth="1"
|
||||
variants={{ hidden: { opacity: 0, scale: 0.6 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.45, ease: EASE_OUT, delay: i * 0.12 }}
|
||||
/>
|
||||
))}
|
||||
{/* Brand at the center */}
|
||||
<motion.circle
|
||||
cx="120"
|
||||
cy="48"
|
||||
r="4"
|
||||
className="fill-brand"
|
||||
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.35 }}
|
||||
/>
|
||||
<Pulse cx={120} cy={48} reduce={reduce} delay={0.6} />
|
||||
{/* Mention blips */}
|
||||
{blips.map((blip) => (
|
||||
<motion.circle
|
||||
key={`${blip.cx}-${blip.cy}`}
|
||||
cx={blip.cx}
|
||||
cy={blip.cy}
|
||||
r="3"
|
||||
className="fill-brand/70"
|
||||
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: blip.delay }}
|
||||
/>
|
||||
))}
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** B2B lead generation: a lead list fills in row by row, each verified. */
|
||||
function LeadsArt({ reduce }: { reduce: boolean }) {
|
||||
const rows = [22, 48, 74];
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{rows.map((y, i) => (
|
||||
<motion.g
|
||||
key={y}
|
||||
variants={{ hidden: { opacity: 0, x: -10 }, visible: { opacity: 1, x: 0 } }}
|
||||
transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.15 + i * 0.18 }}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<circle cx="24" cy={y} r="7" className="fill-muted-foreground/20" />
|
||||
{/* Name + contact lines */}
|
||||
<rect
|
||||
x="40"
|
||||
y={y - 8}
|
||||
width="92"
|
||||
height="6"
|
||||
rx="3"
|
||||
className="fill-muted-foreground/30"
|
||||
/>
|
||||
<rect
|
||||
x="40"
|
||||
y={y + 3}
|
||||
width="64"
|
||||
height="5"
|
||||
rx="2.5"
|
||||
className="fill-muted-foreground/15"
|
||||
/>
|
||||
{/* Verified check */}
|
||||
<motion.path
|
||||
d={`M 206 ${y - 1} l 4 5 l 8 -10`}
|
||||
fill="none"
|
||||
className="stroke-brand"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
variants={{
|
||||
hidden: { pathLength: 0, opacity: 0 },
|
||||
visible: { pathLength: 1, opacity: 1 },
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.45 + i * 0.18 }}
|
||||
/>
|
||||
</motion.g>
|
||||
))}
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Market research: SERP rows; your result climbs from #3 to #1. */
|
||||
function SerpArt({ reduce }: { reduce: boolean }) {
|
||||
const swapDelay = 0.8;
|
||||
const swapY = { duration: 0.5, ease: EASE_OUT, delay: swapDelay, times: [0, 0.6, 1] };
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 240 96"
|
||||
className="h-auto w-full"
|
||||
initial={reduce ? undefined : "hidden"}
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
>
|
||||
{/* Competitor rows: start at ranks 1 and 2, shift down one slot */}
|
||||
{[
|
||||
{ startY: 14, width: 148 },
|
||||
{ startY: 40, width: 120 },
|
||||
].map((row, i) => (
|
||||
<motion.g
|
||||
key={row.startY}
|
||||
style={reduce ? { y: 26 } : undefined}
|
||||
variants={
|
||||
reduce
|
||||
? undefined
|
||||
: { hidden: { opacity: 0, y: 0 }, visible: { opacity: 1, y: [0, 0, 26] } }
|
||||
}
|
||||
transition={
|
||||
reduce
|
||||
? undefined
|
||||
: { opacity: { duration: 0.35, ease: EASE_OUT, delay: 0.1 + i * 0.15 }, y: swapY }
|
||||
}
|
||||
>
|
||||
<rect
|
||||
x="34"
|
||||
y={row.startY}
|
||||
width={row.width}
|
||||
height="8"
|
||||
rx="4"
|
||||
className="fill-muted-foreground/25"
|
||||
/>
|
||||
<rect
|
||||
x="34"
|
||||
y={row.startY + 12}
|
||||
width={row.width * 0.6}
|
||||
height="5"
|
||||
rx="2.5"
|
||||
className="fill-muted-foreground/12"
|
||||
/>
|
||||
</motion.g>
|
||||
))}
|
||||
{/* Your row: starts at rank 3, climbs to rank 1 */}
|
||||
<motion.g
|
||||
style={reduce ? { y: -52 } : undefined}
|
||||
variants={
|
||||
reduce
|
||||
? undefined
|
||||
: { hidden: { opacity: 0, y: 0 }, visible: { opacity: 1, y: [0, 0, -52] } }
|
||||
}
|
||||
transition={
|
||||
reduce ? undefined : { opacity: { duration: 0.35, ease: EASE_OUT, delay: 0.4 }, y: swapY }
|
||||
}
|
||||
>
|
||||
<rect x="34" y="66" width="160" height="8" rx="4" className="fill-brand" />
|
||||
<rect x="34" y="78" width="96" height="5" rx="2.5" className="fill-brand/40" />
|
||||
<motion.path
|
||||
d="M 210 78 l 5 -7 l 5 7"
|
||||
fill="none"
|
||||
className="stroke-brand"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
variants={reduce ? undefined : { hidden: { opacity: 0 }, visible: { opacity: 1 } }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT, delay: swapDelay }}
|
||||
/>
|
||||
</motion.g>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ART: Record<UseCaseArtVariant, (props: { reduce: boolean }) => React.ReactNode> = {
|
||||
price: PriceArt,
|
||||
brand: BrandArt,
|
||||
leads: LeadsArt,
|
||||
serp: SerpArt,
|
||||
};
|
||||
|
||||
/** Small animated artifact rendered at the top of each use-case card. */
|
||||
export function UseCaseArt({ variant }: { variant: UseCaseArtVariant }) {
|
||||
const reduce = useReducedMotion() ?? false;
|
||||
const Art = ART[variant];
|
||||
return (
|
||||
<div aria-hidden className="mb-4 rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<Art reduce={reduce} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
surfsense_web/components/homepage/use-cases.tsx
Normal file
79
surfsense_web/components/homepage/use-cases.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/connectors-marketing/reveal";
|
||||
import { UseCaseArt, type UseCaseArtVariant } from "@/components/homepage/use-case-art";
|
||||
import { MarketingSection } from "@/components/marketing/section";
|
||||
|
||||
/** Buyer language from the high-CPC keyword clusters; each anchors to the connector that fulfills it. */
|
||||
const USE_CASES: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
anchor: string;
|
||||
art: UseCaseArtVariant;
|
||||
}[] = [
|
||||
{
|
||||
title: "Competitor price monitoring",
|
||||
description:
|
||||
"Crawl competitor pricing and product pages on a schedule and get an alert the day something changes, not the quarter after.",
|
||||
href: "/web-crawl",
|
||||
anchor: "Web Crawl API",
|
||||
art: "price",
|
||||
},
|
||||
{
|
||||
title: "Brand monitoring",
|
||||
description:
|
||||
"Track every mention of your brand, your competitors, and your category across the communities where buyers speak candidly.",
|
||||
href: "/reddit",
|
||||
anchor: "Reddit API",
|
||||
art: "brand",
|
||||
},
|
||||
{
|
||||
title: "B2B lead generation",
|
||||
description:
|
||||
"Turn a category and a territory into a clean lead list with phones, websites, and ratings, ready for your CRM.",
|
||||
href: "/google-maps",
|
||||
anchor: "Google Maps API",
|
||||
art: "leads",
|
||||
},
|
||||
{
|
||||
title: "Market research",
|
||||
description:
|
||||
"Watch the rankings, ads, and AI answers your market actually sees, and mine audience sentiment at scale.",
|
||||
href: "/google-search",
|
||||
anchor: "SERP API",
|
||||
art: "serp",
|
||||
},
|
||||
];
|
||||
|
||||
export function UseCasesRow() {
|
||||
return (
|
||||
<MarketingSection>
|
||||
<Reveal>
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
What teams use SurfSense for
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
{USE_CASES.map((useCase) => (
|
||||
<Reveal key={useCase.title}>
|
||||
<div className="flex h-full flex-col rounded-xl border bg-card p-6">
|
||||
<UseCaseArt variant={useCase.art} />
|
||||
<h3 className="text-lg font-semibold">{useCase.title}</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{useCase.description}
|
||||
</p>
|
||||
<Link
|
||||
href={useCase.href}
|
||||
className="group mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground"
|
||||
>
|
||||
{useCase.anchor}
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</MarketingSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,426 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { IconPointerFilled } from "@tabler/icons-react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Unlimited & Self-Hosted",
|
||||
description:
|
||||
"No caps on sources, notebooks, or file sizes. Deploy on your own infra and your data never leaves your control.",
|
||||
skeleton: <UnlimitedSkeleton />,
|
||||
},
|
||||
{
|
||||
title: "100+ LLMs, Zero Lock-in",
|
||||
description:
|
||||
"Swap between 100+ LLMs via OpenAI spec and LiteLLM, or run fully private with vLLM, Ollama, and more.",
|
||||
skeleton: <LLMFlexibilitySkeleton />,
|
||||
},
|
||||
{
|
||||
title: "Real-Time Multiplayer",
|
||||
description:
|
||||
"RBAC with Owner, Admin, Editor, and Viewer roles plus real-time chat and comment threads. Built for teams.",
|
||||
skeleton: <MultiplayerSkeleton />,
|
||||
},
|
||||
];
|
||||
|
||||
export function WhySurfSense() {
|
||||
return (
|
||||
<section className="max-w-7xl mx-auto my-10">
|
||||
<div className="mx-auto mb-10 text-center md:mb-16">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-widest text-brand">
|
||||
Why SurfSense
|
||||
</p>
|
||||
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl lg:text-5xl">
|
||||
Everything NotebookLM should have been
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground">
|
||||
Open source. No data limits. No vendor lock-in. Built for teams that care about privacy
|
||||
and flexibility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid mx-auto max-w-7xl grid-cols-1 divide-x-0 divide-y divide-border overflow-hidden rounded-2xl shadow-sm ring-1 ring-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||
{cards.map((card) => (
|
||||
<FeatureCard key={card.title} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ComparisonStrip />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlimitedSkeleton({ className }: { className?: string }) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
|
||||
const items = [
|
||||
{ label: "Sources", notebookLm: "50-600", surfSense: "Unlimited", icon: "📄" },
|
||||
{ label: "Notebooks", notebookLm: "100-500", surfSense: "Unlimited", icon: "📓" },
|
||||
{ label: "File size", notebookLm: "200 MB", surfSense: "No limit", icon: "📦" },
|
||||
{ label: "Self-host", notebookLm: "No", surfSense: "Yes", icon: "🏠" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex h-full flex-col justify-center gap-2.5", className)}>
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.35, delay: index * 0.15 }}
|
||||
className="flex items-center gap-2 rounded-lg bg-background px-3 py-2 shadow-sm ring-1 ring-border"
|
||||
>
|
||||
<span className="text-sm">{item.icon}</span>
|
||||
<span className="min-w-[60px] text-xs font-medium text-foreground">{item.label}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground line-through">
|
||||
{item.notebookLm}
|
||||
</span>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={isInView ? { scale: 1 } : {}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: index * 0.15 + 0.2,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
}}
|
||||
>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{item.surfSense}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LLMFlexibilitySkeleton({ className }: { className?: string }) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
const models = [
|
||||
{ name: "GPT-4o", provider: "OpenAI", color: "bg-green-500" },
|
||||
{ name: "Claude 4", provider: "Anthropic", color: "bg-orange-500" },
|
||||
{ name: "Gemini 2.5", provider: "Google", color: "bg-blue-500" },
|
||||
{ name: "Llama 4", provider: "Local", color: "bg-purple-500" },
|
||||
{ name: "DeepSeek R1", provider: "DeepSeek", color: "bg-cyan-500" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex h-full flex-col items-center justify-center gap-3", className)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex w-full max-w-[180px] flex-col gap-1.5"
|
||||
>
|
||||
{models.map((model, index) => (
|
||||
<motion.button
|
||||
key={model.name}
|
||||
type="button"
|
||||
onClick={() => setSelected(index)}
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.3, delay: 0.1 + index * 0.1 }}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-all",
|
||||
selected === index ? "bg-background shadow-sm ring-1 ring-border" : "hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className={cn("size-2 shrink-0 rounded-full", model.color)} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-foreground">{model.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{model.provider}</p>
|
||||
</div>
|
||||
{selected === index && (
|
||||
<motion.div
|
||||
layoutId="model-check"
|
||||
className="ml-auto"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<Check className="size-3 text-brand" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiplayerSkeleton({ className }: { className?: string }) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
|
||||
const collaborators = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Alice",
|
||||
role: "Editor",
|
||||
color: "#3b82f6",
|
||||
path: [
|
||||
{ x: 15, y: 10 },
|
||||
{ x: 80, y: 40 },
|
||||
{ x: 40, y: 80 },
|
||||
{ x: 15, y: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Bob",
|
||||
role: "Viewer",
|
||||
color: "#10b981",
|
||||
path: [
|
||||
{ x: 115, y: 70 },
|
||||
{ x: 55, y: 20 },
|
||||
{ x: 95, y: 50 },
|
||||
{ x: 115, y: 70 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const codeLines = [
|
||||
{ indent: 0, width: "60%", color: "bg-chart-4/60" },
|
||||
{ indent: 1, width: "75%", color: "bg-muted-foreground/20" },
|
||||
{ indent: 1, width: "50%", color: "bg-chart-1/60" },
|
||||
{ indent: 2, width: "80%", color: "bg-muted-foreground/20" },
|
||||
{ indent: 2, width: "45%", color: "bg-chart-2/60" },
|
||||
{ indent: 1, width: "30%", color: "bg-muted-foreground/20" },
|
||||
{ indent: 0, width: "20%", color: "bg-chart-4/60" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("relative flex h-full items-center justify-center overflow-visible", className)}
|
||||
>
|
||||
<motion.div
|
||||
className="relative w-full max-w-[160px] rounded-lg bg-background p-3 shadow-sm ring-1 ring-border"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<div className="flex gap-1">
|
||||
<div className="size-1.5 rounded-full bg-red-400" />
|
||||
<div className="size-1.5 rounded-full bg-yellow-400" />
|
||||
<div className="size-1.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="ml-2 h-1.5 w-12 rounded-full bg-muted" />
|
||||
</div>
|
||||
|
||||
{codeLines.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="my-1.5 flex items-center"
|
||||
style={{ paddingLeft: line.indent * 8 }}
|
||||
>
|
||||
<div className={cn("h-1.5 rounded-full", line.color)} style={{ width: line.width }} />
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{collaborators.map((collaborator, index) => (
|
||||
<motion.div
|
||||
key={collaborator.id}
|
||||
className="absolute"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
isInView
|
||||
? {
|
||||
opacity: 1,
|
||||
x: collaborator.path.map((p) => p.x),
|
||||
y: collaborator.path.map((p) => p.y),
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={{
|
||||
opacity: { duration: 0.3, delay: 0.5 + index * 0.2 },
|
||||
x: {
|
||||
duration: 6,
|
||||
delay: 0.5 + index * 0.3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
y: {
|
||||
duration: 6,
|
||||
delay: 0.5 + index * 0.3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconPointerFilled
|
||||
className="size-5 drop-shadow-sm"
|
||||
style={{ color: collaborator.color }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-5 left-3 z-50 flex w-max items-center gap-1.5 rounded-full py-1 pr-2.5 pl-1 shadow-sm"
|
||||
style={{ backgroundColor: collaborator.color }}
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-white/20 text-[9px] font-bold text-white">
|
||||
{collaborator.name[0]}
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium text-white">{collaborator.name}</span>
|
||||
<span className="rounded bg-white/20 px-1 py-px text-[8px] text-white/80">
|
||||
{collaborator.role}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
title,
|
||||
description,
|
||||
skeleton,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
skeleton: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between bg-card p-10 first:rounded-l-2xl last:rounded-r-2xl">
|
||||
<div className="h-60 w-full overflow-visible rounded-md">{skeleton}</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-base font-bold tracking-tight text-card-foreground">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed tracking-tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const comparisonRows: {
|
||||
feature: string;
|
||||
notebookLm: string | boolean;
|
||||
surfSense: string | boolean;
|
||||
}[] = [
|
||||
{
|
||||
feature: "Sources per Notebook",
|
||||
notebookLm: "50-600",
|
||||
surfSense: "Unlimited",
|
||||
},
|
||||
{
|
||||
feature: "LLM Support",
|
||||
notebookLm: "Gemini only",
|
||||
surfSense: "100+ LLMs",
|
||||
},
|
||||
{
|
||||
feature: "Self-Hostable",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "Open Source",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "External Connectors",
|
||||
notebookLm: "Limited",
|
||||
surfSense: "27+",
|
||||
},
|
||||
{
|
||||
feature: "Desktop App",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "Agentic Architecture",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
{
|
||||
feature: "AI Automations & Agents",
|
||||
notebookLm: false,
|
||||
surfSense: "Scheduled & event-triggered",
|
||||
},
|
||||
{
|
||||
feature: "AI File Sorting",
|
||||
notebookLm: false,
|
||||
surfSense: true,
|
||||
},
|
||||
];
|
||||
|
||||
function ComparisonStrip() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-80px" });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="mx-auto mt-12 max-w-7xl overflow-hidden rounded-2xl bg-card shadow-sm ring-1 ring-border"
|
||||
>
|
||||
<div className="grid grid-cols-3 px-4 py-3 sm:px-6">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Feature
|
||||
</span>
|
||||
<span className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
NotebookLM
|
||||
</span>
|
||||
<span className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
SurfSense
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{comparisonRows.map((row, index) => (
|
||||
<motion.div
|
||||
key={row.feature}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }}
|
||||
>
|
||||
<div className="grid grid-cols-3 items-center px-4 py-2.5 text-sm sm:px-6">
|
||||
<span className="font-medium text-card-foreground">{row.feature}</span>
|
||||
<span className="flex justify-center">
|
||||
{typeof row.notebookLm === "boolean" ? (
|
||||
row.notebookLm ? (
|
||||
<Check className="size-4 text-brand" />
|
||||
) : (
|
||||
<X className="size-4 text-muted-foreground/40" />
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{row.notebookLm}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex justify-center">
|
||||
{typeof row.surfSense === "boolean" ? (
|
||||
row.surfSense ? (
|
||||
<Check className="size-4 text-brand" />
|
||||
) : (
|
||||
<X className="size-4 text-muted-foreground/40" />
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary">{row.surfSense}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{index !== comparisonRows.length - 1 && <Separator />}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
21
surfsense_web/components/marketing/section.tsx
Normal file
21
surfsense_web/components/marketing/section.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shared marketing-page section container. Mirrors the navbar/hero grid exactly
|
||||
* (max-w-7xl with px-2 md:px-8 xl:px-0 gutters) so every section edge aligns
|
||||
* across the homepage, connector pages, and the connectors hub.
|
||||
*/
|
||||
export function MarketingSection({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("py-12 sm:py-16", className)}>
|
||||
<div className="mx-auto w-full max-w-7xl px-2 md:px-8 xl:px-0">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,8 +19,8 @@ export function OrganizationJsonLd() {
|
|||
url: "https://www.surfsense.com",
|
||||
logo: "https://www.surfsense.com/logo.png",
|
||||
description:
|
||||
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
|
||||
sameAs: ["https://github.com/MODSetter/SurfSense", "https://discord.gg/Cg2M4GUJ"],
|
||||
"SurfSense is an open-source competitive intelligence platform. AI agents monitor competitors, track rankings, and listen to your market through one API or MCP server.",
|
||||
sameAs: ["https://github.com/MODSetter/SurfSense", "https://discord.gg/ejRNvftDp9"],
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
email: "rohan@surfsense.com",
|
||||
|
|
@ -40,7 +40,7 @@ export function WebSiteJsonLd() {
|
|||
name: "SurfSense",
|
||||
url: "https://www.surfsense.com",
|
||||
description:
|
||||
"Open source NotebookLM alternative for teams with no data limits. Free ChatGPT, Claude AI, and any AI model.",
|
||||
"SurfSense is an open-source competitive intelligence platform for AI agents, with live data connectors served through one API or MCP server.",
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: {
|
||||
|
|
@ -70,23 +70,21 @@ export function SoftwareApplicationJsonLd() {
|
|||
description: "Free plan with 500 pages included",
|
||||
},
|
||||
description:
|
||||
"Open source NotebookLM alternative with free access to ChatGPT, Claude AI, and any model. Connect Slack, Google Drive, Notion, Confluence, GitHub, and dozens more data sources.",
|
||||
"SurfSense is an open-source competitive intelligence platform. AI agents monitor competitors, track rankings, and listen to your market with platform-native connectors for Reddit, YouTube, Google Maps, Google Search, and the open web, through one API or MCP server.",
|
||||
url: "https://www.surfsense.com",
|
||||
downloadUrl: "https://github.com/MODSetter/SurfSense/releases",
|
||||
featureList: [
|
||||
"Free access to ChatGPT, Claude AI, and any AI model",
|
||||
"AI-powered semantic search across all connected tools",
|
||||
"Federated search across Slack, Google Drive, Notion, Confluence, GitHub",
|
||||
"Platform-native connectors: Reddit, YouTube, Google Maps, Google Search, Web Crawl",
|
||||
"MCP server that exposes every connector as a native agent tool",
|
||||
"Agent harness with retries, structured output, and credit metering",
|
||||
"Competitor, brand, and rank monitoring with briefs and alerts",
|
||||
"AI automations and agents (scheduled and event-triggered workflows)",
|
||||
"Connector write-back to Notion, Slack, Linear, Jira",
|
||||
"Native desktop app with Quick, General, and Screenshot Assist",
|
||||
"No data limits with open source self-hosting",
|
||||
"AI-powered semantic search across connected tools and documents",
|
||||
"Federated search across Slack, Google Drive, Notion, Confluence, GitHub",
|
||||
"Document Q&A with citations, report, podcast, and video generation",
|
||||
"Real-time collaborative team chats",
|
||||
"Document Q&A with citations",
|
||||
"Report generation",
|
||||
"Podcast and video generation from sources",
|
||||
"Enterprise knowledge management",
|
||||
"Self-hostable and privacy-focused",
|
||||
"Native desktop app with Quick, General, and Screenshot Assist",
|
||||
"Open source and self-hostable with no data limits",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
import {
|
||||
AutomationModelFields,
|
||||
type AutomationModelSelection,
|
||||
} from "@/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields";
|
||||
} from "@/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue