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
5 changes: 5 additions & 0 deletions .changeset/subscribe-product-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": minor
---

Add an in-app product-updates subscription. The Resources menu is regrouped into Resources, Community, and Product updates, and a new Subscribe action opens a reusable email form (shadcn + react-hook-form) that registers the address through the `/api/subscribe` endpoint. The GitHub row now shows the repo's live star count.
4 changes: 2 additions & 2 deletions docs/src/app/(home)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { getGitHubStars } from '@/lib/github-stars';
import { getGitHubStars } from '@inkeep/open-knowledge-core';
import { SiteNav } from './site-nav';

export default async function Layout({ children }: LayoutProps<'/'>) {
const stars = await getGitHubStars();
const stars = await getGitHubStars({ next: { revalidate: 3600 } });
return (
<>
<SiteNav stars={stars} />
Expand Down
27 changes: 19 additions & 8 deletions docs/src/app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,36 @@ const subscribeSchema = z.object({
email: z.email(),
});

const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'content-type',
} as const;

function json(body: unknown, status: number): NextResponse {
return NextResponse.json(body, { status, headers: CORS_HEADERS });
}

export function OPTIONS(): NextResponse {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}

export async function POST(request: Request): Promise<NextResponse> {
if (!RESEND_API_KEY || !RESEND_SEGMENT_ID) {
console.error('[subscribe] RESEND_API_KEY or RESEND_SEGMENT_ID is not configured');
return NextResponse.json(
{ error: 'Subscriptions are not available right now.' },
{ status: 503 },
);
return json({ error: 'Subscriptions are not available right now.' }, 503);
}

let payload: unknown;
try {
payload = await request.json();
} catch {
return NextResponse.json({ error: 'Enter a valid email address.' }, { status: 400 });
return json({ error: 'Enter a valid email address.' }, 400);
}

const parsed = subscribeSchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json({ error: 'Enter a valid email address.' }, { status: 400 });
return json({ error: 'Enter a valid email address.' }, 400);
}

const resend = new Resend(RESEND_API_KEY);
Expand All @@ -41,8 +52,8 @@ export async function POST(request: Request): Promise<NextResponse> {

if (error) {
console.error(`[subscribe] Resend create-contact failed: ${error.name} - ${error.message}`);
return NextResponse.json({ error: 'Something went wrong. Please try again.' }, { status: 502 });
return json({ error: 'Something went wrong. Please try again.' }, 502);
}

return NextResponse.json({ ok: true });
return json({ ok: true }, 200);
}
109 changes: 81 additions & 28 deletions packages/app/src/components/HelpPopover.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, mock, test } from 'bun:test';
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
Expand Down Expand Up @@ -28,44 +28,59 @@ async function renderOpenHelpPopover() {
await userEvent.click(screen.getByRole('button', { name: 'Resources' }));
}

function linkShape(link: HTMLElement) {
return {
label: Array.from(link.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent)
.join('')
.trim(),
href: link.getAttribute('href'),
target: link.getAttribute('target'),
rel: link.getAttribute('rel'),
hasIcon: link.querySelector('svg') !== null,
};
}

const originalFetch = globalThis.fetch;

describe('HelpPopover runtime behavior', () => {
afterEach(() => cleanup());
beforeEach(() => {
globalThis.fetch = mock(
async () =>
new Response(JSON.stringify({ stargazers_count: 1234 }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
) as typeof globalThis.fetch;
});

afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
});

test('exports the component', async () => {
const mod = await import('./HelpPopover');
expect(typeof mod.HelpPopover).toBe('function');
});

test('opens a resources-only nav whose accessible names match the visible heading', async () => {
test('groups links under Resources, Community, and Product updates navs', async () => {
await renderOpenHelpPopover();

expect(screen.getAllByText('Resources').length).toBeGreaterThanOrEqual(2);
const nav = screen.getByRole('navigation', { name: 'Resources' });
expect(nav).not.toBeNull();
for (const heading of ['Resources', 'Community', 'Product updates']) {
expect(screen.getByRole('navigation', { name: heading })).not.toBeNull();
}
expect(screen.queryByText(/Help\s*&\s*Resources/i)).toBeNull();
expect(screen.queryByText('Setup')).toBeNull();
expect(screen.queryByText('Settings')).toBeNull();
expect(screen.queryByText('Install for Claude Chat')).toBeNull();
});

test('renders the external resource links in the required order', async () => {
test('renders Resources links in the required order', async () => {
await renderOpenHelpPopover();

const nav = screen.getByRole('navigation', { name: 'Resources' });
const links = within(nav).getAllByRole('link');
expect(
links.map((link) => ({
label: Array.from(link.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent)
.join('')
.trim(),
href: link.getAttribute('href'),
target: link.getAttribute('target'),
rel: link.getAttribute('rel'),
hasIcon: link.querySelector('svg') !== null,
})),
).toEqual([
expect(links.map(linkShape)).toEqual([
{
label: 'Docs',
href: 'https://openknowledge.ai/docs',
Expand All @@ -74,12 +89,28 @@ describe('HelpPopover runtime behavior', () => {
hasIcon: true,
},
{
label: 'GitHub',
href: 'https://github.com/inkeep/open-knowledge',
label: 'File an issue',
href: 'https://github.com/inkeep/open-knowledge/issues/new',
target: '_blank',
rel: 'noopener noreferrer',
hasIcon: true,
},
{
label: 'Website',
href: 'https://openknowledge.ai/',
target: '_blank',
rel: 'noopener noreferrer',
hasIcon: true,
},
]);
});

test('renders Community links in the required order', async () => {
await renderOpenHelpPopover();

const nav = screen.getByRole('navigation', { name: 'Community' });
const links = within(nav).getAllByRole('link');
expect(links.map(linkShape)).toEqual([
{
label: 'Discord',
href: 'https://discord.com/invite/YujKpFN49',
Expand All @@ -88,19 +119,41 @@ describe('HelpPopover runtime behavior', () => {
hasIcon: true,
},
{
label: 'X',
label: 'X (Twitter)',
href: 'https://x.com/OpenKnowledgeAI',
target: '_blank',
rel: 'noopener noreferrer',
hasIcon: true,
},
{
label: 'OpenKnowledge',
href: 'https://openknowledge.ai/',
label: 'GitHub',
href: 'https://github.com/inkeep/open-knowledge',
target: '_blank',
rel: 'noopener noreferrer',
hasIcon: true,
},
]);
});

test('shows the fetched GitHub star count on the GitHub row', async () => {
await renderOpenHelpPopover();

const nav = screen.getByRole('navigation', { name: 'Community' });
const githubLink = within(nav).getByRole('link', { name: /GitHub/ });
await waitFor(() => expect(within(githubLink).getByText('1.2k')).not.toBeNull());
});

test('Product updates exposes a What’s new link and a Subscribe action', async () => {
await renderOpenHelpPopover();

const nav = screen.getByRole('navigation', { name: 'Product updates' });
const whatsNew = within(nav).getByRole('link', { name: "What's new" });
expect(whatsNew.getAttribute('href')).toBe('https://github.com/inkeep/open-knowledge/releases');

const subscribe = within(nav).getByRole('button', { name: 'Subscribe' });
expect(subscribe).not.toBeNull();

await userEvent.click(subscribe);
expect(screen.getByTestId('subscribe-email')).not.toBeNull();
});
});
Loading