diff --git a/apps/web/app/approvals/page.tsx b/apps/web/app/(app)/approvals/page.tsx similarity index 94% rename from apps/web/app/approvals/page.tsx rename to apps/web/app/(app)/approvals/page.tsx index 2e3847c..6e497de 100644 --- a/apps/web/app/approvals/page.tsx +++ b/apps/web/app/(app)/approvals/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -import { ApprovalCard } from '../../components/approval-card'; -import { ApiUnavailableError, NotAuthenticatedError, fetchApprovals } from '../../lib/api'; +import { ApprovalCard } from '../../../components/approval-card'; +import { ApiUnavailableError, NotAuthenticatedError, fetchApprovals } from '../../../lib/api'; export const dynamic = 'force-dynamic'; diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx new file mode 100644 index 0000000..82baa5a --- /dev/null +++ b/apps/web/app/(app)/layout.tsx @@ -0,0 +1,19 @@ +import { BottomNav } from '../../components/bottom-nav'; + +/** + * The signed-in app shell: a narrow reading column and the bottom nav. + * + * Marketing routes deliberately do not get this — a landing page inside a + * 672px app column under a tab bar reads as a broken deployment. + */ +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + <> + {/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */} +
+ {children} +
+ + + ); +} diff --git a/apps/web/app/more/page.tsx b/apps/web/app/(app)/more/page.tsx similarity index 94% rename from apps/web/app/more/page.tsx rename to apps/web/app/(app)/more/page.tsx index 5eb2850..29b76c8 100644 --- a/apps/web/app/more/page.tsx +++ b/apps/web/app/(app)/more/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { SignOutButton } from '../../components/sign-out-button'; -import { ApiUnavailableError, NotAuthenticatedError, fetchMe } from '../../lib/api'; +import { SignOutButton } from '../../../components/sign-out-button'; +import { ApiUnavailableError, NotAuthenticatedError, fetchMe } from '../../../lib/api'; export const dynamic = 'force-dynamic'; diff --git a/apps/web/app/prospects/[id]/page.tsx b/apps/web/app/(app)/prospects/[id]/page.tsx similarity index 97% rename from apps/web/app/prospects/[id]/page.tsx rename to apps/web/app/(app)/prospects/[id]/page.tsx index 1077c77..71959a3 100644 --- a/apps/web/app/prospects/[id]/page.tsx +++ b/apps/web/app/(app)/prospects/[id]/page.tsx @@ -5,8 +5,8 @@ import { NotAuthenticatedError, fetchProspect, relativeTime, -} from '../../../lib/api'; -import type { ProspectDetail } from '../../../lib/types'; +} from '../../../../lib/api'; +import type { ProspectDetail } from '../../../../lib/types'; export const dynamic = 'force-dynamic'; diff --git a/apps/web/app/prospects/page.tsx b/apps/web/app/(app)/prospects/page.tsx similarity index 95% rename from apps/web/app/prospects/page.tsx rename to apps/web/app/(app)/prospects/page.tsx index 319370f..b8e1310 100644 --- a/apps/web/app/prospects/page.tsx +++ b/apps/web/app/(app)/prospects/page.tsx @@ -1,8 +1,8 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { AddProspect } from '../../components/add-prospect'; -import { ApiUnavailableError, NotAuthenticatedError, fetchProspects } from '../../lib/api'; -import type { ProspectRow } from '../../lib/types'; +import { AddProspect } from '../../../components/add-prospect'; +import { ApiUnavailableError, NotAuthenticatedError, fetchProspects } from '../../../lib/api'; +import type { ProspectRow } from '../../../lib/types'; export const dynamic = 'force-dynamic'; diff --git a/apps/web/app/signals/page.tsx b/apps/web/app/(app)/signals/page.tsx similarity index 98% rename from apps/web/app/signals/page.tsx rename to apps/web/app/(app)/signals/page.tsx index d8f5c08..a1cc0ea 100644 --- a/apps/web/app/signals/page.tsx +++ b/apps/web/app/(app)/signals/page.tsx @@ -4,7 +4,7 @@ import { NotAuthenticatedError, fetchSignals, relativeTime, -} from '../../lib/api'; +} from '../../../lib/api'; export const dynamic = 'force-dynamic'; diff --git a/apps/web/app/today/page.tsx b/apps/web/app/(app)/today/page.tsx similarity index 97% rename from apps/web/app/today/page.tsx rename to apps/web/app/(app)/today/page.tsx index 303f983..25468b5 100644 --- a/apps/web/app/today/page.tsx +++ b/apps/web/app/(app)/today/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { VerifyBanner } from '../../components/verify-banner'; +import { VerifyBanner } from '../../../components/verify-banner'; import { ApiUnavailableError, NotAuthenticatedError, @@ -8,7 +8,7 @@ import { fetchMe, fetchSignals, relativeTime, -} from '../../lib/api'; +} from '../../../lib/api'; export const dynamic = 'force-dynamic'; diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/(marketing)/layout.tsx new file mode 100644 index 0000000..1574954 --- /dev/null +++ b/apps/web/app/(marketing)/layout.tsx @@ -0,0 +1,9 @@ +/** + * Public pages: full-bleed, no app chrome. + * + * Sections manage their own horizontal padding so a band can paint edge to + * edge while its content stays on the same measure as everything else. + */ +export default function MarketingLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/(marketing)/login/page.tsx similarity index 88% rename from apps/web/app/login/page.tsx rename to apps/web/app/(marketing)/login/page.tsx index 2aae927..acf4ed2 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/(marketing)/login/page.tsx @@ -1,4 +1,4 @@ -import { LoginForm } from '../../components/login-form'; +import { LoginForm } from '../../../components/login-form'; export const metadata = { title: 'Sign in · OutreachGraph' }; diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx new file mode 100644 index 0000000..394f4b1 --- /dev/null +++ b/apps/web/app/(marketing)/page.tsx @@ -0,0 +1,256 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { cookies } from 'next/headers'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { + title: 'OutreachGraph — you don’t need more contacts, you need a reason to talk', + description: + 'Find where your prospects are actually active, understand what they care about right now, and get the right moment to engage. Policy-aware, source-backed, human-approved.', +}; + +/** + * The public landing page (PRD §44). + * + * The hero is the product's actual output rather than a description of it: an + * approval card with a real quote, a source link and the recommended action. + * Everything this product claims — grounded personalisation, a visible reason + * to reach out, a human in the loop — is legible in that one artifact, and + * showing it is more convincing than asserting it. + * + * Signed-in visitors are sent to their inbox rather than shown marketing. The + * check is cookie presence only — a cheap test that avoids an API round trip + * on every visit. A stale cookie simply lands on /today, which redirects to + * /login itself, so nothing is trusted here beyond routing. + */ +export default async function LandingPage() { + const jar = await cookies(); + if (jar.get('og_session')) redirect('/today'); + + return ( +
+ + + + + + +
+ ); +} + +/** Content sits on one measure; bands paint full width around it. */ +function Band({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return ( +
+
{children}
+
+ ); +} + +function SiteNav() { + return ( + + + + ); +} + +function Hero() { + return ( + +
+
+

+ Signal-led outreach +

+ +

+ You don’t need more contacts. You need a reason to talk. +

+ +

+ Apollo finds their email. OutreachGraph finds the thing they said forty minutes ago that + makes an email worth sending — and refuses to send anything the platform, the person, or + your own limits say it shouldn’t. +

+ +
+ + Find my first 100 prospects + + + Sign in + +
+ +

+ Free while in beta · No card · Starts from a GitHub handle +

+
+ + +
+
+ ); +} + +/** + * A static replica of the real approval card. + * + * Deliberately not the live component: this one must render for a logged-out + * stranger with no workspace, no prospect and no API call. + */ +function ApprovalCardSample() { + return ( +
+
+
+
Alex Chen
+
Staff Engineer · Loopwright
+
+
+
92
+
opportunity
+
+
+ +
+
Why now
+

+ “Anyone know a good alternative to Stripe for cross-border payouts? Fees are brutal and + settlement takes days.” +

+

GitHub issue · 3 hours ago · source

+
+ +
+
+ Recommended +
+

+ Reply in thread — he asked a direct question in + public and the thread is still open. +

+
+ +
+
+ Approve +
+
+ Edit +
+
+ Skip +
+
+
+ ); +} + +function EvidenceSection() { + const identities = [ + ['github', '1.00', 'The handle you started from.'], + ['x', '0.96', 'Linked from his own GitHub profile field.'], + ['website', '0.92', 'Same domain in his bio and his commits.'], + ]; + + return ( + +

+ Every claim traces back to something they published. +

+

+ No stored evidence, no claim. If the draft can’t point at a source it isn’t shown — the card + keeps the prospect and the evidence, and you write the message yourself. +

+ + + +

+ Linked from what he published himself — never from a name match. Two weak observations give + 0.75, not certainty. +

+
+ ); +} + +function RefusalSection() { + return ( + +
+
+

+ It will tell you no. +

+

+ The policy engine is arithmetic, not judgement. No model is in the loop, an unknown + network is a deny, and LinkedIn automation isn’t discouraged — it’s unreachable. The + check runs again when you approve, not when the card was made. +

+
+ +
+
POST /recommendations/rec_8f2/approve
+
409 policy_denied
+
gate: suppression
+
“this person asked not to be contacted on 4 Aug”
+
+
+
+ ); +} + +function ClosingCta() { + return ( + +

+ Find the people already talking about your problem. +

+

+ Start with developer and technical buyers — the richest public signal there is. +

+ + Get started free + +
+ ); +} + +function SiteFooter() { + return ( + +
+ © {new Date().getFullYear()} OutreachGraph + No LinkedIn automation. Suppression survives deletion. +
+
+ ); +} diff --git a/apps/web/app/verify/page.tsx b/apps/web/app/(marketing)/verify/page.tsx similarity index 92% rename from apps/web/app/verify/page.tsx rename to apps/web/app/(marketing)/verify/page.tsx index 93d795e..16c454d 100644 --- a/apps/web/app/verify/page.tsx +++ b/apps/web/app/(marketing)/verify/page.tsx @@ -1,4 +1,4 @@ -import { VerifyForm } from '../../components/verify-form'; +import { VerifyForm } from '../../../components/verify-form'; export const dynamic = 'force-dynamic'; diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d287b4c..330bdd7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -8,6 +8,12 @@ * query. */ @theme { + /* Supplied by next/font in the root layout. The fallbacks matter: they are + what renders if the font request fails, and a metric-similar stack keeps + that from reflowing the page. */ + --font-sans: var(--font-schibsted), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-plex-mono), ui-monospace, Menlo, monospace; + --color-surface: #ffffff; --color-surface-raised: #f6f7f9; --color-border: #e3e6ea; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f6cd3e9..4ce4afd 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,8 +1,31 @@ import type { Metadata, Viewport } from 'next'; -import { BottomNav } from '../components/bottom-nav'; +import { IBM_Plex_Mono, Schibsted_Grotesk } from 'next/font/google'; import { ServiceWorkerRegistrar } from '../components/service-worker-registrar'; import './globals.css'; +/** + * Self-hosted by `next/font` at build time, so there is no render-blocking + * request to Google and no layout shift when the face arrives. + */ +/* + * The variable names must differ from Tailwind's `--font-sans` / `--font-mono`. + * next/font sets its variable on , which is also `:root`, so reusing the + * theme's own name makes `--font-sans: var(--font-sans), …` a self-reference on + * one element — an invalid cycle that silently drops the font. + */ +const sans = Schibsted_Grotesk({ + subsets: ['latin'], + display: 'swap', + variable: '--font-schibsted', +}); + +const mono = IBM_Plex_Mono({ + subsets: ['latin'], + weight: ['400', '500', '600'], + display: 'swap', + variable: '--font-plex-mono', +}); + export const metadata: Metadata = { title: 'OutreachGraph', description: 'Turn public intent signals into warm conversations.', @@ -30,16 +53,20 @@ export const viewport: Viewport = { ], }; +/** + * The root layout holds only what every route needs. + * + * The app shell — the narrow column and the bottom nav — lives in `(app)`, + * because wrapping the public landing page in a mobile app chrome capped at + * `max-w-2xl` made a marketing page render as a 672px strip under a nav bar + * for people who were not signed in. + */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - {/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */} -
- {children} -
- + {children} ); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx deleted file mode 100644 index a4ad0a6..0000000 --- a/apps/web/app/page.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import Link from 'next/link'; -import { redirect } from 'next/navigation'; -import { cookies } from 'next/headers'; - -export const dynamic = 'force-dynamic'; - -export const metadata = { - title: 'OutreachGraph — turn public intent signals into warm conversations', - description: - 'Find where your prospects are actually active, understand what they care about right now, and get the right moment to engage. Policy-aware, source-backed, human-approved.', -}; - -/** - * The public landing page (PRD §44). - * - * Signed-in visitors are sent to their inbox rather than shown marketing. The - * check is cookie presence only — a cheap test that avoids an API round trip - * on every visit. A stale cookie simply lands on /today, which redirects to - * /login itself, so nothing is trusted here beyond routing. - */ -export default async function LandingPage() { - const jar = await cookies(); - if (jar.get('og_session')) redirect('/today'); - - return ( -
- - - - - - -
- ); -} - -function Hero() { - return ( -
-

OutreachGraph

- -

- Stop cold emailing. -
- Start warm conversations. -

- -

- Find where your prospects actually spend time online, understand what they care about right - now, and let the system recommend the right moment to engage. -

- -
- - Find my first 100 prospects - - - Sign in - -
- -

- Apollo finds their contact info. OutreachGraph finds where they actually live online — and - tells you how to start the conversation. -

-
- ); -} - -function IdentitySection() { - const networks = ['LinkedIn', 'GitHub', 'X', 'Bluesky', 'Reddit', 'YouTube', 'Web']; - - return ( -
-

- The same human is a GitHub handle, an X account, a personal domain and a job title. - OutreachGraph links them with stored evidence and a confidence score — and refuses to merge - on a guess. -

- -
    - {networks.map((network) => ( -
  • - {network} -
  • - ))} -
- -
-
- A real resolution -
-
- - - -
-

- Linked from what she published herself — not from a name match. -

-
-
- ); -} - -function Row({ label, value, score }: { label: string; value: string; score: string }) { - return ( -
-
{label}
-
{value}
-
{score}
-
- ); -} - -function WhyNowSection() { - const quotes = [ - 'Anyone know a good alternative to…', - 'We just migrated off…', - 'This API is killing us.', - 'We’re hiring a payments engineer.', - 'What do you use for…?', - ]; - - return ( -
-

- Public activity becomes structured intent. Every recommendation names the signal that - triggered it, with a link to the source. -

- -
    - {quotes.map((quote) => ( -
  • - “{quote}” -
  • - ))} -
-
- ); -} - -function ControlSection() { - const steps = ['Research', 'Score', 'Draft', 'Approve', 'Engage']; - - return ( -
-
    - {steps.map((step, index) => ( -
  1. - - {step} - - {index < steps.length - 1 ? → : null} -
  2. - ))} -
- -

- Nothing goes out without a human. Approval is the default, automation is opt-in, and the - policy check runs again at the moment you approve — not when the card was created. -

-
- ); -} - -function SafetySection() { - const guarantees = [ - [ - 'Source-backed personalisation', - 'No stored evidence, no claim. The system will not invent a shared experience.', - ], - [ - 'Platform-aware by construction', - 'Actions a network prohibits are unreachable, not merely discouraged.', - ], - [ - 'Suppression that sticks', - 'Opting out leaves a tombstone that survives deletion, so no provider can re-ingest someone.', - ], - [ - 'Precision over recall', - 'A wrong-person message costs more than a missed match, so weak evidence never merges.', - ], - ]; - - return ( -
-
- {guarantees.map(([term, detail]) => ( -
-
{term}
-
{detail}
-
- ))} -
-
- ); -} - -function ClosingCta() { - return ( -
-

Find the people already talking about your problem.

-

- Start with developer and technical buyers — the richest public signal there is. -

- - Get started - -
- ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

-
{children}
-
- ); -}