diff --git a/web/.gitignore b/web/.gitignore index c9c60f5..112e5ba 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,6 +1,8 @@ node_modules .next +.next-public out +out-public next-env.d.ts test-results playwright-report diff --git a/web/app/AppRedirect.tsx b/web/app/AppRedirect.tsx new file mode 100644 index 0000000..a2aa941 --- /dev/null +++ b/web/app/AppRedirect.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +// The /app build's root ("/app/") sends users to the dashboard. +export default function AppRedirect() { + const router = useRouter(); + useEffect(() => { + router.replace("/inicio/"); + }, [router]); + return null; +} diff --git a/web/app/page.tsx b/web/app/page.tsx index f01eb2e..cb4f975 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,12 +1,21 @@ -"use client"; +import type { Metadata } from "next"; +import AppRedirect from "./AppRedirect"; +import { Landing } from "@/components/marketing/Landing"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +// One file, two builds (see next.config.ts): +// - MARKETING_BUILD=1 → public landing at / (no basePath, → zinom-site/public) +// - otherwise → /app/ redirect to the dashboard (the engine-served app) +const IS_MARKETING = process.env.MARKETING_BUILD === "1"; + +export const metadata: Metadata = IS_MARKETING + ? { + title: "Zinom · dê memória à sua IA", + description: + "O Zinom conecta suas reuniões, anotações do Notion e calendários num cérebro pesquisável e dá memória ao ChatGPT, ao Claude.ai, ao Claude Code ou a qualquer IA via MCP.", + icons: { icon: "/favicon.svg" }, + } + : {}; export default function Page() { - const router = useRouter(); - useEffect(() => { - router.replace("/inicio/"); - }, [router]); - return null; + return IS_MARKETING ? : ; } diff --git a/web/components/marketing/AccessCard.tsx b/web/components/marketing/AccessCard.tsx new file mode 100644 index 0000000..cb1cbd2 --- /dev/null +++ b/web/components/marketing/AccessCard.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Icon } from "./icons"; +import { registerWithInvite, requestInvite } from "@/hooks/marketing-access"; + +type Tab = "invite" | "waitlist"; + +export function AccessCard() { + const [tab, setTab] = useState("invite"); + const [topNote, setTopNote] = useState(""); + + // invite form + const [invite, setInvite] = useState(""); + const [regEmail, setRegEmail] = useState(""); + const [inviteSent, setInviteSent] = useState(false); + const [inviteErr, setInviteErr] = useState(false); + const [inviteBusy, setInviteBusy] = useState(false); + + // waitlist form + const [reqEmail, setReqEmail] = useState(""); + const [reqNote, setReqNote] = useState(""); + const [waitSent, setWaitSent] = useState(false); + const [waitErr, setWaitErr] = useState(false); + const [waitBusy, setWaitBusy] = useState(false); + + // Query prefill + expired-link note (ported from landing.js handleQuery()). + useEffect(() => { + const p = new URLSearchParams(window.location.search); + const code = p.get("invite"); + const email = p.get("email"); + if (code) setInvite(code); + if (email) setRegEmail(email); + if (code || email) setTab("invite"); + if (p.get("error") === "link") { + setTopNote("Esse link expirou ou já foi usado. Peça um novo abaixo."); + } + }, []); + + const switchTab = (t: Tab) => { + setTab(t); + setInviteErr(false); + setWaitErr(false); + }; + + const onInvite = async (e: React.FormEvent) => { + e.preventDefault(); + setInviteErr(false); + setInviteBusy(true); + const res = await registerWithInvite({ + invite_code: invite, + email: regEmail, + }); + setInviteBusy(false); + if (res.ok) setInviteSent(true); + else setInviteErr(true); + }; + + const onWaitlist = async (e: React.FormEvent) => { + e.preventDefault(); + setWaitErr(false); + setWaitBusy(true); + const res = await requestInvite({ email: reqEmail, note: reqNote }); + setWaitBusy(false); + if (res.ok) setWaitSent(true); + else setWaitErr(true); + }; + + return ( +
+
+

Acesse o Zinom

+

Use seu código de convite ou peça acesso à lista de espera.

+

+ {topNote} +

+
+
+
switchTab("invite")} + > + Tenho convite +
+
switchTab("waitlist")} + > + Pedir convite +
+
+ +
+ {!inviteSent ? ( +
+
+ + setInvite(e.target.value)} + /> +
+
+ + setRegEmail(e.target.value)} + /> +
+ +

+ Convite inválido ou já usado. Confira o código e tente de novo. +

+

Ao continuar você concorda com os termos de uso.

+
+ ) : ( +
+
+ +
+

Tudo certo

+

+ Enviamos um link de acesso para o seu e-mail. Confira a caixa de + entrada. +

+
+ )} +
+ +
+ {!waitSent ? ( +
+
+ + setReqEmail(e.target.value)} + /> +
+
+ + setReqNote(e.target.value)} + /> +
+ +

+ Não deu certo. Confira o e-mail e tente de novo. +

+

Liberamos convites em lotes. Sem spam.

+
+ ) : ( +
+
+ +
+

Você está na lista

+

Avisaremos por e-mail assim que seu convite estiver pronto.

+
+ )} +
+
+ ); +} diff --git a/web/components/marketing/ChatDemo.tsx b/web/components/marketing/ChatDemo.tsx new file mode 100644 index 0000000..44032a5 --- /dev/null +++ b/web/components/marketing/ChatDemo.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Icon } from "./icons"; +import { ICON_SVG } from "./icons"; +import { useReveal } from "./useReveal"; +import { + CHAT_SCENARIOS, + CHAT_CLIENTS, + CHAT_PROMPTS, + type ChatStep, +} from "@/lib/marketing/chat-scenario"; + +// Imperative port of landing-chat.js (typewriter user msg, tool-calls with +// spinner→✓, typing dots, bubble + cites/actions). React owns the static chrome +// and the active-tab/-prompt/-step state; the chat body is hand-driven via refs +// and timers, cleaned up on unmount. + +export function ChatDemo() { + const copy = useReveal(); + const chat = useReveal(120); + + const body = useRef(null); + const timers = useRef[]>([]); + const started = useRef(false); + + const [client, setClient] = useState("claude"); + const [scenario, setScenario] = useState(0); + const [activeStep, setActiveStep] = useState(0); + + const clientRef = useRef(client); + clientRef.current = client; + + const later = useCallback((fn: () => void, ms: number) => { + const t = setTimeout(fn, ms); + timers.current.push(t); + return ms; + }, []); + const clearTimers = useCallback(() => { + timers.current.forEach(clearTimeout); + timers.current = []; + }, []); + + const el = (tag: string, cls?: string, html?: string) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; + }; + const scrollBottom = () => { + if (body.current) body.current.scrollTop = body.current.scrollHeight; + }; + + const aiStack = useCallback((): HTMLElement => { + const last = body.current?.lastElementChild as HTMLElement | null; + if (last && last.classList.contains("msg") && last.classList.contains("ai")) { + return last.querySelector(".stack") as HTMLElement; + } + const m = el("div", "msg ai"); + m.innerHTML = + '' + + ICON_SVG[CHAT_CLIENTS[clientRef.current].ic] + + ''; + body.current?.appendChild(m); + return m.querySelector(".stack") as HTMLElement; + }, []); + + const addUser = useCallback( + (text: string, done: () => void) => { + const m = el("div", "msg user"); + const b = el("span", "bubble", ""); + m.appendChild(b); + body.current?.appendChild(m); + let i = 0; + const iv = setInterval(() => { + b.textContent = text.slice(0, ++i); + scrollBottom(); + if (i >= text.length) { + clearInterval(iv); + later(done, 500); + } + }, 26); + timers.current.push(iv); + }, + [later] + ); + + const addTool = useCallback( + (fn: string, arg: string, delay: number, done: () => void) => { + const stack = aiStack(); + const t = el( + "div", + "tool-call", + '' + + ICON_SVG.zinom + + '' + + fn + + '' + + arg + + '' + ); + stack.appendChild(t); + scrollBottom(); + later(() => { + const sp = t.querySelector(".spin"); + if (sp) sp.outerHTML = ''; + later(done, 350); + }, delay); + }, + [aiStack, later] + ); + + const addAnswer = useCallback( + (s: Extract, done: () => void) => { + const stack = aiStack(); + const typing = el("div", "typing", ""); + stack.appendChild(typing); + scrollBottom(); + later(() => { + typing.remove(); + const b = el("div", "bubble ai", s.html); + if (s.cites) { + const c = el("div", "cites", 'Fontes'); + s.cites.forEach((ct) => { + c.appendChild( + el( + "a", + "cite", + '' + + ICON_SVG[ct.ic] + + '' + + ct.ttl + + '' + + ct.meta + + "" + ) + ); + }); + b.appendChild(c); + } + stack.appendChild(b); + if (s.actions) { + const ac = el("div", "actions", ""); + s.actions.forEach((a, i) => { + const row = el( + "div", + "action", + '' + + '' + + ICON_SVG[a.ic] + + '' + + a.tt + + '' + + a.mt + + "" + ); + row.style.opacity = "0"; + row.style.transition = "opacity .35s ease"; + ac.appendChild(row); + later(() => { + row.style.opacity = "1"; + scrollBottom(); + }, 900 + i * 450); + }); + stack.appendChild(ac); + } + scrollBottom(); + later(done, 600); + }, 1100); + }, + [aiStack, later] + ); + + const showReplay = useCallback((onReplay: () => void) => { + const r = el( + "div", + "chat-replay", + '' + ); + r.querySelector("button")?.addEventListener("click", onReplay); + body.current?.appendChild(r); + scrollBottom(); + }, []); + + const play = useCallback( + (scIdx: number) => { + clearTimers(); + setScenario(scIdx); + if (body.current) body.current.innerHTML = ""; + setActiveStep(0); + + const steps = CHAT_SCENARIOS[scIdx].steps; + let k = 0; + const next = () => { + if (k >= steps.length) { + later(() => { + setActiveStep(0); + showReplay(() => play(scIdx)); + }, 800); + return; + } + const s = steps[k++]; + setActiveStep(s.step); + if (s.type === "user") later(() => addUser(s.text, next), 350); + else if (s.type === "tool") addTool(s.fn, s.arg, s.delay, next); + else addAnswer(s, next); + }; + next(); + }, + [addAnswer, addTool, addUser, clearTimers, later, showReplay] + ); + + const pickClient = useCallback( + (cl: keyof typeof CHAT_CLIENTS) => { + setClient(cl); + clientRef.current = cl; + play(scenario); + }, + [play, scenario] + ); + + useEffect(() => { + if (!chat.shown || started.current) return; + started.current = true; + play(0); + return () => clearTimers(); + }, [chat.shown, play, clearTimers]); + + return ( +
+
+
+

Usar na sua IA

+

Pergunte. Sua IA busca, responde com a fonte e age.

+

+ O Zinom não é mais um chat de IA. É a memória conectada ao ChatGPT, + ao Claude.ai ou ao Claude Code. Acompanhe os 4 passos enquanto a demo + roda ao lado. +

+
+ {[ + { n: 1, b: "Você pergunta na sua IA", s: "Claude.ai, ChatGPT ou Claude Code, na que você preferir." }, + { n: 2, b: "Ela busca no seu cérebro", code: "brain_search", s: " encontra reuniões, páginas e eventos relevantes." }, + { n: 3, b: "Responde com a fonte", s: "Toda resposta vem com link para a reunião, página ou evento original." }, + { n: 4, b: "E age por você", s: "Cria tarefas e páginas no Notion, agenda eventos no Calendar e envia documentos para assinatura." }, + ].map((d) => ( +
+ {d.n} + + {d.b} + + {d.code ? ( + + {d.code} + + ) : null} + {d.s} + + +
+ ))} +
+
+ + + Assinatura digital + + + Envie documentos para assinatura direto pela sua IA (via Rubrix). + +
+

Troque o exemplo nos botões acima do chat →

+
+ +
+
+ + + + {" "} + Zinom via MCP + + + {(Object.keys(CHAT_CLIENTS) as (keyof typeof CHAT_CLIENTS)[]).map( + (cl) => ( + + ) + )} + +
+
+ {CHAT_PROMPTS.map((label, i) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/web/components/marketing/CtaBand.tsx b/web/components/marketing/CtaBand.tsx new file mode 100644 index 0000000..9a27d15 --- /dev/null +++ b/web/components/marketing/CtaBand.tsx @@ -0,0 +1,23 @@ +export function CtaBand() { + return ( +
+
+

Dê à sua IA a memória que faltava.

+

Convites liberados em lotes. Entre na lista e seja avisado.

+ +
+
+ ); +} diff --git a/web/components/marketing/FeedDemo.tsx b/web/components/marketing/FeedDemo.tsx new file mode 100644 index 0000000..32ca137 --- /dev/null +++ b/web/components/marketing/FeedDemo.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Icon, ICON_SVG } from "./icons"; +import { useReveal } from "./useReveal"; +import { + FEED_STEPS, + FEED_ICON, + FEED_HITS, + FEED_QUERY, + FEED_ANSWER_HTML, + TAG_CLASS, + TAG_LABEL, + chunkTargetUpTo, + type FeedStep, +} from "@/lib/marketing/feed-scenario"; + +// Imperative DOM port of landing-feed.js — the original drives nodes directly; +// here we keep the same approach inside a single mounted React shell (refs + +// timers) rather than rebuild it as declarative state, to preserve exact timing +// and the deterministic rebuild-up-to-idx behaviour. React owns the static +// chrome; the animated body is hand-driven, cleaned up on unmount. + +const PLAY = + ''; +const PAUSE = + ''; + +export function FeedDemo() { + const head = useReveal(); + const lab = useReveal(160); + + // refs to the animated sub-DOM + const dayHead = useRef(null); + const clock = useRef(null); + const dayBody = useRef(null); + const memList = useRef(null); + const count = useRef(null); + const lane = useRef(null); + const search = useRef(null); + const queryText = useRef(null); + const answer = useRef(null); + const playBtn = useRef(null); + + const [activeIdx, setActiveIdx] = useState(0); + const [doneIdx, setDoneIdx] = useState(-1); + const [playing, setPlaying] = useState(true); + + const st = useRef({ + idx: -1, + playing: true, + timer: 0 as ReturnType | 0, + typeTimer: 0 as ReturnType | 0, + chunkTimer: 0 as ReturnType | 0, + chunks: 0, + started: false, + }); + + const el = (tag: string, cls?: string, html?: string) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; + }; + + const tickChunks = useCallback((target: number) => { + clearInterval(st.current.chunkTimer as number); + st.current.chunkTimer = setInterval(() => { + if (st.current.chunks >= target) { + clearInterval(st.current.chunkTimer as number); + st.current.chunks = target; + } else { + st.current.chunks += Math.max( + 1, + Math.ceil((target - st.current.chunks) / 6) + ); + } + if (count.current) count.current.textContent = String(st.current.chunks); + }, 70); + }, []); + + const addDayCard = useCallback((s: FeedStep) => { + if (!s.card) return null; + const c = el( + "div", + "fl-card", + '' + + ICON_SVG[FEED_ICON[s.source]] + + "" + + '' + + '' + + s.card.title + + "" + + '' + + s.card.meta + + "" + + '' + + TAG_LABEL[s.source] + + "" + + s.card.src.split("·")[1].trim() + + "" + ); + dayBody.current?.appendChild(c); + requestAnimationFrame(() => + requestAnimationFrame(() => c.classList.add("in")) + ); + return c; + }, []); + + const addMemory = useCallback((s: FeedStep) => { + if (!s.mem) return null; + const m = el( + "div", + "fl-mem", + '' + + TAG_LABEL[s.source] + + "" + + '' + + s.mem.title + + "" + + '' + + s.mem.chunks + + "" + + '' + ); + memList.current?.appendChild(m); + requestAnimationFrame(() => + requestAnimationFrame(() => m.classList.add("in")) + ); + return m; + }, []); + + const firePulse = useCallback(() => { + const d = el("i", "fl-dot"); + lane.current?.appendChild(d); + requestAnimationFrame(() => d.classList.add("go")); + setTimeout(() => d.remove(), 1100); + }, []); + + const clearStage = useCallback(() => { + clearTimeout(st.current.timer as number); + clearInterval(st.current.typeTimer as number); + clearInterval(st.current.chunkTimer as number); + if (dayBody.current) dayBody.current.innerHTML = ""; + if (memList.current) memList.current.innerHTML = ""; + search.current?.classList.remove("show"); + answer.current?.classList.remove("show"); + if (queryText.current) queryText.current.textContent = ""; + st.current.chunks = 0; + if (count.current) count.current.textContent = "0"; + const empty = el( + "div", + "fl-empty", + "Seu dia começa como qualquer outro.
Você não instala nada, não copia nada, não cola nada." + ); + empty.id = "fl-empty"; + dayBody.current?.appendChild(empty); + }, []); + + const dimOldCards = useCallback(() => { + const cards = dayBody.current?.querySelectorAll(".fl-card"); + cards?.forEach((c, i) => { + c.classList.remove("lit"); + c.classList.toggle("dim", i < cards.length - 1); + }); + }, []); + + const runPayoff = useCallback(() => { + search.current?.classList.add("show"); + const q = FEED_QUERY; + let i = 0; + clearInterval(st.current.typeTimer as number); + st.current.typeTimer = setInterval(() => { + if (queryText.current) queryText.current.textContent = q.slice(0, ++i); + if (i >= q.length) { + clearInterval(st.current.typeTimer as number); + setTimeout(() => { + const mems = memList.current?.querySelectorAll(".fl-mem"); + FEED_HITS.forEach((h, k) => { + const m = mems?.[h.idx] as HTMLElement | undefined; + if (!m) return; + setTimeout(() => { + m.classList.add("hit", "scored"); + const sc = m.querySelector(".score"); + if (sc) sc.textContent = h.score; + }, 350 * k); + }); + setTimeout( + () => answer.current?.classList.add("show"), + 350 * FEED_HITS.length + 400 + ); + }, 350); + } + }, 38); + }, []); + + const goTo = useCallback( + (idx: number, manual: boolean) => { + if (manual) { + st.current.playing = true; + setPlaying(true); + } + clearTimeout(st.current.timer as number); + clearInterval(st.current.typeTimer as number); + + clearStage(); + let emptyEl = document.getElementById("fl-empty"); + for (let i = 0; i < idx; i++) { + const s = FEED_STEPS[i]; + if (!s.mem) continue; + if (emptyEl) { + emptyEl.remove(); + emptyEl = null; + } + addDayCard(s)?.classList.add("dim"); + addMemory(s); + } + st.current.chunks = chunkTargetUpTo(idx - 1); + if (count.current) count.current.textContent = String(st.current.chunks); + + st.current.idx = idx; + const step = FEED_STEPS[idx]; + setActiveIdx(idx); + setDoneIdx(idx - 1); + if (clock.current) clock.current.textContent = step.time; + + if (step.payoff) { + if (dayHead.current) + dayHead.current.textContent = "Fim do dia: nada mudou na sua rotina"; + runPayoff(); + } else { + if (dayHead.current) + dayHead.current.textContent = "Seu dia, como sempre"; + if (emptyEl) emptyEl.remove(); + const card = addDayCard(step); + card?.classList.add("lit"); + dimOldCards(); + card?.classList.add("lit"); + setTimeout(firePulse, 700); + setTimeout(() => { + addMemory(step); + tickChunks(chunkTargetUpTo(idx)); + }, 1450); + } + + if (st.current.playing) { + st.current.timer = setTimeout(() => { + goTo((idx + 1) % FEED_STEPS.length, false); + }, step.dur); + } + }, + [ + addDayCard, + addMemory, + clearStage, + dimOldCards, + firePulse, + runPayoff, + tickChunks, + ] + ); + + const togglePlay = useCallback(() => { + st.current.playing = !st.current.playing; + setPlaying(st.current.playing); + if (playBtn.current) + playBtn.current.innerHTML = st.current.playing ? PAUSE : PLAY; + if (st.current.playing) goTo((st.current.idx + 1) % FEED_STEPS.length, false); + else { + clearTimeout(st.current.timer as number); + setActiveIdx(st.current.idx); + } + }, [goTo]); + + // init when the lab enters the viewport (parity: only starts in view) + useEffect(() => { + if (!lab.shown || st.current.started) return; + st.current.started = true; + if (answer.current) answer.current.innerHTML = FEED_ANSWER_HTML; + if (playBtn.current) playBtn.current.innerHTML = PAUSE; + clearStage(); + if (clock.current) clock.current.textContent = "08:00"; + goTo(0, false); + const ref = st.current; + return () => { + clearTimeout(ref.timer as number); + clearInterval(ref.typeTimer as number); + clearInterval(ref.chunkTimer as number); + }; + }, [lab.shown, clearStage, goTo]); + + return ( +
+
+
+

Alimentar o cérebro

+

Você não precisa fazer nada de novo.

+

+ Veja um dia comum: cada reunião, anotação e evento vira memória + automaticamente, enquanto você trabalha. +

+
+ +
+
+ + ))} +
+
+ +
+
+
+ + Seu dia, como sempre + + + 08:00 + +
+
+
+ + +
+ ); +} diff --git a/web/components/marketing/Footer.tsx b/web/components/marketing/Footer.tsx new file mode 100644 index 0000000..64e9b1d --- /dev/null +++ b/web/components/marketing/Footer.tsx @@ -0,0 +1,56 @@ +import { Icon } from "./icons"; + +export function Footer() { + return ( + + ); +} diff --git a/web/components/marketing/Hero.tsx b/web/components/marketing/Hero.tsx new file mode 100644 index 0000000..0266ea5 --- /dev/null +++ b/web/components/marketing/Hero.tsx @@ -0,0 +1,55 @@ +import { Icon } from "./icons"; +import { AccessCard } from "./AccessCard"; +import { SourcesStrip } from "./SourcesStrip"; + +export function Hero() { + return ( +
+
+
+ + Conexão MCP · funciona com qualquer IA + +

+ Dê memória ao seu ChatGPT, Claude.ai ou Claude Code. +

+

+ Sua IA não conhece suas reuniões, suas notas nem sua agenda. O Zinom + é a conexão que liga essas fontes à IA que você já usa e transforma + seu dia a dia em memória pesquisável. +

+
+ + + Notion + + + + + + Reuniões + + + + + + Agenda + + + + + Zinom + + + sua IA, com memória +
+
+ + +
+ + +
+ ); +} diff --git a/web/components/marketing/HowItWorks.tsx b/web/components/marketing/HowItWorks.tsx new file mode 100644 index 0000000..0b46a33 --- /dev/null +++ b/web/components/marketing/HowItWorks.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Icon, type IconName } from "./icons"; +import { useReveal } from "./useReveal"; + +const LEFT: { ic: IconName; label: string; sub: string }[] = [ + { ic: "notion", label: "Notion", sub: "páginas e bancos de dados" }, + { ic: "granola", label: "Reuniões", sub: "transcripts do Granola" }, + { ic: "calendar", label: "Agenda", sub: "Google Calendar via iCal" }, +]; +const RIGHT: { ic: IconName; label: string; sub: string }[] = [ + { ic: "claude", label: "Claude.ai", sub: "conector custom" }, + { ic: "chatgpt", label: "ChatGPT", sub: "via MCP" }, + { ic: "claudecode", label: "Claude Code", sub: "e Cursor, no terminal" }, +]; +const STEPS = [ + { n: 1, b: "Conecte suas fontes", s: "Notion com 1 clique, Granola com a chave, agenda com o link iCal." }, + { n: 2, b: "O Zinom indexa sozinho", s: "Tudo vira memória pesquisável, atualizada em segundo plano." }, + { n: 3, b: "Pergunte na sua IA", s: "Ela busca o contexto, responde com a fonte e ainda age por você." }, +]; + +// All 6 diagram nodes light up in sequence (1400ms rotating `lit`), like landing.js. +function Diagram() { + const [lit, setLit] = useState(0); + const total = LEFT.length + RIGHT.length; + useEffect(() => { + const iv = setInterval(() => setLit((i) => (i + 1) % total), 1400); + return () => clearInterval(iv); + }, [total]); + + return ( +
+
+ {LEFT.map((n, i) => ( + + + + {n.label} + {n.sub} + + + ))} +
+
+ + +
+
+ + + + Zinom +
+
+ + +
+
+ {RIGHT.map((n, i) => ( + + + + {n.label} + {n.sub} + + + ))} +
+
+ ); +} + +export function HowItWorks() { + const head = useReveal(); + const diag = useReveal(); + const steps = useReveal(); + const inRef = useRef(null); + + return ( +
+
+
+

Como funciona

+

Uma conexão entre suas fontes e a sua IA.

+

+ O ChatGPT, o Claude.ai e o Claude Code não conhecem o seu trabalho. O + Zinom fica no meio: indexa suas fontes e entrega o contexto certo + para qualquer um deles, via MCP. +

+
+
+ +
+
+ {STEPS.map((s) => ( +
+ {s.n} + + {s.b} + {s.s} + +
+ ))} +
+
+
+ ); +} diff --git a/web/components/marketing/Landing.tsx b/web/components/marketing/Landing.tsx new file mode 100644 index 0000000..3f524de --- /dev/null +++ b/web/components/marketing/Landing.tsx @@ -0,0 +1,29 @@ +// Public marketing landing (SP2). Self-contained: its own scoped stylesheet +// (marketing.css, ported from zinom-site/public/landing.css and re-tokenized to +// Refined Editorial), its own client demos, and same-origin portal forms. It +// does NOT use the logged-in app shell / providers — keeps the public bundle lean. +import "./marketing.css"; +import { Nav } from "./Nav"; +import { Hero } from "./Hero"; +import { HowItWorks } from "./HowItWorks"; +import { FeedDemo } from "./FeedDemo"; +import { ChatDemo } from "./ChatDemo"; +import { Pricing } from "./Pricing"; +import { CtaBand } from "./CtaBand"; +import { Footer } from "./Footer"; + +export function Landing() { + return ( + <> +