Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules
.next
.next-public
out
out-public
next-env.d.ts
test-results
playwright-report
Expand Down
13 changes: 13 additions & 0 deletions web/app/AppRedirect.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 17 additions & 8 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <Landing /> : <AppRedirect />;
}
212 changes: 212 additions & 0 deletions web/components/marketing/AccessCard.tsx
Original file line number Diff line number Diff line change
@@ -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<Tab>("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 (
<div className="access" id="acesso">
<div className="access-head">
<h3>Acesse o Zinom</h3>
<p>Use seu código de convite ou peça acesso à lista de espera.</p>
<p className={"access-note" + (topNote ? " show" : "")} id="error">
{topNote}
</p>
</div>
<div className="tabs" role="tablist">
<div
className={"tab" + (tab === "invite" ? " active" : "")}
data-tab="invite"
role="tab"
onClick={() => switchTab("invite")}
>
Tenho convite
</div>
<div
className={"tab" + (tab === "waitlist" ? " active" : "")}
data-tab="waitlist"
role="tab"
onClick={() => switchTab("waitlist")}
>
Pedir convite
</div>
</div>

<div
className={"pane" + (tab === "invite" ? " active" : "")}
data-pane="invite"
>
{!inviteSent ? (
<form onSubmit={onInvite} data-form="invite">
<div className="field">
<label htmlFor="invite">Código de convite</label>
<input
id="invite"
name="invite_code"
type="text"
placeholder="ZIN-XXXX-XXXX"
autoComplete="off"
required
value={invite}
onChange={(e) => setInvite(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="reg-email">Seu e-mail</label>
<input
id="reg-email"
name="email"
type="email"
placeholder="voce@email.com"
required
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
/>
</div>
<button
className="btn btn-primary btn-lg btn-block"
type="submit"
disabled={inviteBusy}
>
Criar conta
</button>
<p className={"field-err" + (inviteErr ? " show" : "")} data-err="invite">
Convite inválido ou já usado. Confira o código e tente de novo.
</p>
<p className="hint">Ao continuar você concorda com os termos de uso.</p>
</form>
) : (
<div className="ok-msg show" data-ok="invite">
<div className="check">
<Icon name="check" style={{ width: 24, height: 24 }} />
</div>
<h4>Tudo certo</h4>
<p>
Enviamos um link de acesso para o seu e-mail. Confira a caixa de
entrada.
</p>
</div>
)}
</div>

<div
className={"pane" + (tab === "waitlist" ? " active" : "")}
data-pane="waitlist"
>
{!waitSent ? (
<form onSubmit={onWaitlist} data-form="waitlist">
<div className="field">
<label htmlFor="req-email">Seu e-mail</label>
<input
id="req-email"
name="email"
type="email"
placeholder="voce@email.com"
required
value={reqEmail}
onChange={(e) => setReqEmail(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="req-note">
Como você pretende usar o Zinom?{" "}
<span style={{ color: "var(--muted)", fontWeight: 400 }}>
(opcional)
</span>
</label>
<input
id="req-note"
name="note"
type="text"
placeholder="Ex.: organizar reuniões e notas do trabalho"
value={reqNote}
onChange={(e) => setReqNote(e.target.value)}
/>
</div>
<button
className="btn btn-primary btn-lg btn-block"
type="submit"
disabled={waitBusy}
>
Entrar na lista de espera
</button>
<p className={"field-err" + (waitErr ? " show" : "")} data-err="waitlist">
Não deu certo. Confira o e-mail e tente de novo.
</p>
<p className="hint">Liberamos convites em lotes. Sem spam.</p>
</form>
) : (
<div className="ok-msg show" data-ok="waitlist">
<div className="check">
<Icon name="check" style={{ width: 24, height: 24 }} />
</div>
<h4>Você está na lista</h4>
<p>Avisaremos por e-mail assim que seu convite estiver pronto.</p>
</div>
)}
</div>
</div>
);
}
Loading
Loading