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
17 changes: 14 additions & 3 deletions apps/web/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Link from 'next/link';
import { BottomNav } from '../../components/bottom-nav';
import { BrandLockup } from '../../components/brand';

/**
* The signed-in app shell: a narrow reading column and the bottom nav.
Expand All @@ -9,10 +11,19 @@ import { BottomNav } from '../../components/bottom-nav';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
{/*
* A slim brand row, not a title bar: every page already owns its own <h1>,
* so this stays one line tall and only says which app you are in — the
* thing an installed PWA loses when the browser chrome goes away.
*/}
<div className="mx-auto w-full max-w-2xl px-4 pt-[env(safe-area-inset-top)]">
<Link href="/today" aria-label="OutreachGraph home" className="inline-flex py-3">
<BrandLockup height="h-6" text="text-[15px]" />
</Link>
</div>

{/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */}
<main className="mx-auto w-full max-w-2xl px-4 pt-[env(safe-area-inset-top)] pb-28">
{children}
</main>
<main className="mx-auto w-full max-w-2xl px-4 pb-28">{children}</main>
<BottomNav />
</>
);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/(marketing)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { LoginForm } from '../../../components/login-form';
import { BrandLockup } from '../../../components/brand';

export const metadata = { title: 'Sign in · OutreachGraph' };

export default function LoginPage() {
return (
<div className="flex min-h-[80dvh] flex-col justify-center">
<header className="mb-6">
<h1 className="text-2xl font-semibold">OutreachGraph</h1>
{/* The lockup is the heading here, so it carries the h1 rather than sitting above one. */}
<h1>
<BrandLockup height="h-9" text="text-2xl" />
</h1>
<p className="text-ink-muted mt-1 text-sm">
Turn public intent signals into warm conversations.
</p>
Expand Down
12 changes: 10 additions & 2 deletions apps/web/app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { BrandLockup, BrandMark, BrandWordmark } from '../../components/brand';

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -53,7 +54,9 @@ function SiteNav() {
return (
<Band>
<nav className="flex items-center justify-between py-5">
<span className="text-[17px] font-bold tracking-tight">OutreachGraph</span>
<Link href="/" aria-label="OutreachGraph home">
<BrandLockup height="h-8" />
</Link>
<Link
href="/login"
className="border-border rounded-xl border px-4 py-2.5 text-sm font-medium"
Expand Down Expand Up @@ -228,6 +231,9 @@ function RefusalSection() {
function ClosingCta() {
return (
<Band className="bg-ink py-16 text-center text-white sm:py-20">
{/* This band is `bg-ink` in both themes, so the wordmark is safe here. */}
<BrandWordmark className="mx-auto mb-7 h-9 w-auto sm:h-11" />

<h2 className="text-[28px] leading-tight font-semibold tracking-[-0.025em] text-balance sm:text-[34px]">
Find the people already talking about your problem.
</h2>
Expand All @@ -248,7 +254,9 @@ function SiteFooter() {
return (
<Band className="border-border border-t py-8">
<div className="text-ink-muted flex flex-wrap items-center justify-between gap-3 text-[13px]">
<span>© {new Date().getFullYear()} OutreachGraph</span>
<span className="inline-flex items-center gap-2">
<BrandMark className="h-5 w-5" />© {new Date().getFullYear()} OutreachGraph
</span>
<span>No LinkedIn automation. Suppression survives deletion.</span>
</div>
</Band>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/(marketing)/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { VerifyForm } from '../../../components/verify-form';
import { BrandLockup } from '../../../components/brand';

export const dynamic = 'force-dynamic';

Expand All @@ -20,6 +21,7 @@ export default async function VerifyPage({

return (
<div className="mx-auto flex max-w-sm flex-col gap-4 pt-12">
<BrandLockup height="h-7" />
<h1 className="text-xl font-semibold">Confirm your email</h1>
<VerifyForm token={token ?? ''} />
</div>
Expand Down
38 changes: 38 additions & 0 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,48 @@ const mono = localFont({
variable: '--font-plex-mono',
});

/**
* Absolute base for the social card URLs below.
*
* Indexed with a non-literal key for the same reason `lib/api.ts` does it: Next
* inlines `process.env.SOME_NAME` at build time, so a variable that only exists
* on Railway would otherwise be frozen as `undefined` in the image.
*/
function siteUrl(): URL {
const key = 'PUBLIC_SITE_URL';
return new URL(process.env[key] ?? 'https://outreachgraph.com');
}

export const metadata: Metadata = {
metadataBase: siteUrl(),
title: 'OutreachGraph',
description: 'Turn public intent signals into warm conversations.',
applicationName: 'OutreachGraph',
/*
* `favicon.png` is the graph mark on transparency — it is the only asset that
* reads on a light tab strip and a dark one, which is why it is the icon
* everywhere rather than `logo.png`. The wordmark is white on the left half
* and would show as "Graph" alone at 16px on white.
*/
icons: {
icon: [{ url: '/favicon.png', type: 'image/png', sizes: '512x512' }],
shortcut: ['/favicon.png'],
apple: [{ url: '/favicon.png', sizes: '512x512' }],
},
openGraph: {
type: 'website',
siteName: 'OutreachGraph',
title: 'OutreachGraph',
description: 'Turn public intent signals into warm conversations.',
url: '/',
images: [{ url: '/favicon.png', width: 512, height: 512, alt: 'OutreachGraph' }],
},
twitter: {
card: 'summary',
title: 'OutreachGraph',
description: 'Turn public intent signals into warm conversations.',
images: [{ url: '/favicon.png', width: 512, height: 512, alt: 'OutreachGraph' }],
},
appleWebApp: {
capable: true,
title: 'OutreachGraph',
Expand Down
15 changes: 15 additions & 0 deletions apps/web/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,28 @@ export default function manifest(): MetadataRoute.Manifest {
background_color: '#0b0f17',
theme_color: '#0b0f17',
categories: ['business', 'productivity'],
/*
* The SVGs stay first for the browsers that take them, but Android's
* installer and most splash-screen generators still want a raster icon with
* a declared pixel size, so `favicon.png` is listed alongside them.
*
* It is not offered as `maskable`: the mark runs edge to edge on its canvas,
* and a maskable icon gets cropped to whatever shape the launcher uses. The
* padded SVG keeps that job.
*/
icons: [
{
src: '/icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/favicon.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-maskable.svg',
sizes: 'any',
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/offline/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { BrandMark } from '../../components/brand';

export const metadata = { title: 'Offline · OutreachGraph' };

/**
Expand All @@ -10,6 +12,8 @@ export const metadata = { title: 'Offline · OutreachGraph' };
export default function OfflinePage() {
return (
<div className="flex min-h-[70dvh] flex-col items-center justify-center text-center">
{/* Pre-cached with this page by the service worker, so it renders with no network. */}
<BrandMark className="mb-4 h-12 w-12 opacity-60" />
<h1 className="text-xl font-semibold">You are offline</h1>
<p className="text-ink-muted mt-2 max-w-xs text-sm">
Prospect data and the approval queue need a connection — approvals are never served from a
Expand Down
56 changes: 56 additions & 0 deletions apps/web/components/brand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* The brand lockup, in the two forms the assets actually support.
*
* `logo.png` sets "Outreach" in white and "Graph" in blue, so the full wordmark
* reads on the dark theme and half of it vanishes on the light one. That is why
* `BrandLockup` swaps: dark surfaces get the wordmark, light surfaces get the
* mark from `favicon.png` beside live text — which is also what shows if the
* image never arrives.
*
* Plain `<img>`, not `next/image`: the runtime optimiser needs `sharp`, which
* this workspace does not install, and the standalone build in docker/Dockerfile
* would 500 on the first request for an optimised asset.
*
* The intrinsic sizes below are the real pixel dimensions of the two files. They
* are here so the browser reserves the right box before the image loads.
*/

const WORDMARK = { src: '/logo.png', width: 1007, height: 256 } as const;
const MARK = { src: '/favicon.png', width: 512, height: 512 } as const;

/** The graph mark alone — the one asset that reads on any background. */
export function BrandMark({ className = 'h-7 w-7' }: { className?: string }) {
return <img {...MARK} alt="" aria-hidden className={className} />;
}

/** The full wordmark. Dark surfaces only, for the reason above. */
export function BrandWordmark({ className = 'h-7 w-auto' }: { className?: string }) {
return <img {...WORDMARK} alt="OutreachGraph" className={className} />;
}

/**
* Safe anywhere the surface follows the colour scheme.
*
* Only one branch is ever rendered, so assistive technology sees a single
* "OutreachGraph" rather than two.
*/
export function BrandLockup({
className = '',
height = 'h-7',
text = 'text-[17px]',
}: {
className?: string;
height?: string;
text?: string;
}) {
return (
<span className={`inline-flex items-center ${className}`}>
<BrandWordmark className={`hidden w-auto dark:block ${height}`} />

<span className="inline-flex items-center gap-2 dark:hidden">
<BrandMark className={`aspect-square w-auto ${height}`} />
<span className={`font-bold tracking-tight ${text}`}>OutreachGraph</span>
</span>
</span>
);
}
Binary file modified apps/web/public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 6 additions & 4 deletions apps/web/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
* error page.
*/

const VERSION = 'v1';
const VERSION = 'v2';
const SHELL_CACHE = `og-shell-${VERSION}`;
const ASSET_CACHE = `og-assets-${VERSION}`;

// The public landing page and the offline fallback. `/today` is deliberately
// absent: it is per-user and must never be served from a shared cache.
const SHELL = ['/', '/offline'];
// The public landing page and the offline fallback, plus the two brand assets
// those pages render — an offline screen that renders a broken image is worse
// than the browser error it replaces. `/today` is deliberately absent: it is
// per-user and must never be served from a shared cache.
const SHELL = ['/', '/offline', '/favicon.png', '/logo.png'];

self.addEventListener('install', (event) => {
event.waitUntil(
Expand Down
13 changes: 13 additions & 0 deletions packages/email/src/mailer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,17 @@ describe('verificationEmail', () => {
expect(message.html).not.toContain('<script>');
expect(message.html).toContain('&lt;script&gt;');
});

test('the brand mark is served from the same origin as the link', async () => {
const message = verificationEmail('a@b.com', 'https://og.com/verify?token=xyz');

expect(message.html).toContain('src="https://og.com/favicon.png"');
});

test('a link that is not an absolute url still produces a sendable message', async () => {
const message = verificationEmail('a@b.com', '/verify?token=xyz');

expect(message.html).not.toContain('<img');
expect(message.text).toContain('/verify?token=xyz');
});
});
27 changes: 27 additions & 0 deletions packages/email/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ function escapeHtml(value: string): string {
.replace(/"/g, '&quot;');
}

/**
* The brand mark, as an absolute URL on the same origin the link points at.
*
* Derived rather than configured, so there is no second base-URL setting to
* drift out of sync with the one that builds the verification link. `logo.png`
* is deliberately not used here: its "Outreach" is white, and mail clients
* compose on white.
*
* Returns undefined for anything that is not a parseable absolute URL — a
* relative link in a test or a local run should still produce a sendable
* message, just without the image.
*/
function markUrl(link: string): string | undefined {
try {
return new URL('/favicon.png', link).toString();
} catch {
return undefined;
}
}

/**
* The verification email.
*
Expand All @@ -28,8 +48,15 @@ export function verificationEmail(to: string, link: string): Message {
].join('\n');

const safe = escapeHtml(link);
const mark = markUrl(link);

const html = [
// Sized in the attributes as well as the style, because Outlook ignores CSS
// on images and would otherwise draw this at its full 512px.
mark
? `<p><img src="${escapeHtml(mark)}" alt="OutreachGraph" width="48" height="48" ` +
'style="width:48px;height:48px;border:0" /></p>'
: '',
'<p>Confirm your email to finish setting up OutreachGraph.</p>',
`<p><a href="${safe}">${safe}</a></p>`,
'<p>The link expires in 24 hours. If you did not create this account you can ',
Expand Down
Loading