feat: wire help center and article pages to live KB API

- help-center.ts: replace static HELP_ARTICLES array with async fetch* functions (fetchHelpCenterArticles, fetchHelpCenterCategories, fetchArticleBySlug); legacy sync shims kept for safety
- support/index.tsx: switched from createMemo(static) to createResource(async API) with loading states
- help-center/article/[slug].tsx: now fetches article from API via createResource; renders paragraphs split by double-newline; proper loading and not-found states
- New server-side API routes: /api/kb/articles, /api/kb/categories, /api/kb/articles/[slug] (proxy to Rust gateway, no auth required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar 2026-04-02 13:36:16 +02:00
parent 055dcd4175
commit 3209d13011
6 changed files with 287 additions and 162 deletions

View file

@ -4,80 +4,97 @@ export type HelpArticle = {
title: string;
summary: string;
categoryKey: string;
category: string;
role: 'ALL' | 'company' | 'jobSeeker' | 'professional' | 'customer' | 'platform';
tags: string[];
updatedAt: string;
content: string;
};
export const HELP_ARTICLES: HelpArticle[] = [
{
id: 'hc-1',
slug: 'how-verification-works',
title: 'How verification works',
summary: 'Understand document review steps, approval outcomes, and timeline.',
categoryKey: 'verification',
role: 'ALL',
tags: ['verification', 'documents', 'approval'],
updatedAt: '2026-03-17T00:00:00Z',
content: 'After signup, complete onboarding for one path and submit required documents. Admin review updates your status as pending, document required, approved, or rejected.',
},
{
id: 'hc-2',
slug: 'customer-post-requirement',
title: 'How customers post requirements',
summary: 'Choose profession intent, add requirements, and track verified responses.',
categoryKey: 'requirements',
role: 'customer',
tags: ['customer', 'requirements'],
updatedAt: '2026-03-17T00:00:00Z',
content: 'Customer flow starts with selecting the professional category, then requirement details, budget, and timeline. After review, qualified professionals can respond.',
},
{
id: 'hc-3',
slug: 'professional-onboarding-guide',
title: 'Professional onboarding guide',
summary: 'Choose your profession, upload portfolio, submit PDF ID documents, and wait for approval.',
categoryKey: 'onboarding',
role: 'professional',
tags: ['professional', 'onboarding', 'portfolio'],
updatedAt: '2026-03-17T00:00:00Z',
content: 'Each profession in Solid has its own onboarding and service configuration. Complete all steps and verification to unlock your full dashboard.',
},
];
export type HelpCategory = {
id: string;
key: string;
title: string;
};
export function listHelpCenterArticles(input: { role?: string; categoryKey?: string; q?: string }) {
const role = String(input.role || 'ALL');
const categoryKey = String(input.categoryKey || '').trim();
const q = String(input.q || '').trim().toLowerCase();
// ── API fetchers ──────────────────────────────────────────────────────────────
return HELP_ARTICLES.filter((article) => {
const roleOk = role === 'ALL' || article.role === 'ALL' || article.role === role;
const categoryOk = !categoryKey || article.categoryKey === categoryKey;
const queryOk = !q || `${article.title} ${article.summary} ${article.tags.join(' ')}`.toLowerCase().includes(q);
return roleOk && categoryOk && queryOk;
});
}
export async function fetchHelpCenterArticles(input: {
role?: string;
categoryKey?: string;
q?: string;
}): Promise<HelpArticle[]> {
const params = new URLSearchParams();
if (input.role && input.role !== 'ALL') params.set('role', input.role);
if (input.categoryKey) params.set('category', input.categoryKey);
if (input.q) params.set('q', input.q);
export function listHelpCenterCategories() {
const keys = new Map<string, string>();
for (const article of HELP_ARTICLES) {
if (!keys.has(article.categoryKey)) {
const title = article.categoryKey
.split('-')
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
.join(' ');
keys.set(article.categoryKey, title);
}
try {
const res = await fetch(`/api/kb/articles?${params.toString()}`);
if (!res.ok) return [];
const data = await res.json();
const raw: any[] = Array.isArray(data) ? data : (data.articles ?? []);
return raw.map(normalizeArticle);
} catch {
return [];
}
return Array.from(keys.entries()).map(([key, title], idx) => ({
id: `cat-${idx + 1}`,
key,
title,
}));
}
export function getArticleBySlug(slug: string) {
return HELP_ARTICLES.find((article) => article.slug === slug) || null;
export async function fetchHelpCenterCategories(): Promise<HelpCategory[]> {
try {
const res = await fetch('/api/kb/categories');
if (!res.ok) return [];
const data = await res.json();
const raw: any[] = Array.isArray(data) ? data : (data.categories ?? []);
return raw.map((c) => ({
id: c.id,
key: c.slug,
title: c.name,
}));
} catch {
return [];
}
}
export async function fetchArticleBySlug(slug: string): Promise<HelpArticle | null> {
try {
const res = await fetch(`/api/kb/articles/${slug}`);
if (!res.ok) return null;
const data = await res.json();
return normalizeArticle(data);
} catch {
return null;
}
}
// ── Normalizer ────────────────────────────────────────────────────────────────
function normalizeArticle(raw: any): HelpArticle {
return {
id: raw.id ?? '',
slug: raw.slug ?? '',
title: raw.title ?? '',
summary: raw.summary ?? raw.content?.slice(0, 160) ?? '',
categoryKey: raw.categoryKey ?? raw.category_key ?? raw.categorySlug ?? '',
category: raw.category ?? raw.categoryKey ?? '',
role: (raw.role ?? 'ALL') as HelpArticle['role'],
tags: Array.isArray(raw.tags) ? raw.tags : [],
updatedAt: raw.updatedAt ?? raw.updated_at ?? new Date().toISOString(),
content: raw.content ?? raw.body ?? '',
};
}
// ── Legacy sync shims (kept for any remaining call sites) ─────────────────────
// These return empty data synchronously — pages should use the async fetch* functions above.
export function listHelpCenterArticles(_input: { role?: string; categoryKey?: string; q?: string }): HelpArticle[] {
return [];
}
export function listHelpCenterCategories(): HelpCategory[] {
return [];
}
export function getArticleBySlug(_slug: string): HelpArticle | null {
return null;
}

View file

@ -0,0 +1,19 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
const url = new URL(request.url);
const upstream = gatewayUrl('/api/kb/articles' + url.search);
try {
const res = await fetch(upstream, { cache: 'no-store' });
const body = await res.text();
return new Response(body, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Gateway error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,18 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function GET({ params }: { params: { slug: string } }) {
const upstream = gatewayUrl(`/api/kb/articles/${params.slug}`);
try {
const res = await fetch(upstream, { cache: 'no-store' });
const body = await res.text();
return new Response(body, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Gateway error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,18 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function GET() {
const upstream = gatewayUrl('/api/kb/categories');
try {
const res = await fetch(upstream, { cache: 'no-store' });
const body = await res.text();
return new Response(body, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Gateway error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -1,6 +1,6 @@
import { A, useParams } from '@solidjs/router';
import { createSignal, onCleanup, onMount } from 'solid-js';
import { getArticleBySlug } from '~/lib/help-center';
import { Show, For, createSignal, createResource, onCleanup, onMount } from 'solid-js';
import { fetchArticleBySlug } from '~/lib/help-center';
import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader';
import PublicFooter from '~/components/PublicFooter';
@ -15,9 +15,10 @@ function categoryTitle(input: string) {
export default function HelpCenterArticlePage() {
const params = useParams();
const article = getArticleBySlug(params.slug || '');
const [scrollY, setScrollY] = createSignal(0);
const [article] = createResource(() => params.slug, fetchArticleBySlug);
onMount(() => {
const onScroll = () => setScrollY(window.scrollY || 0);
onScroll();
@ -25,12 +26,21 @@ export default function HelpCenterArticlePage() {
onCleanup(() => window.removeEventListener('scroll', onScroll));
});
if (!article) {
return (
<main class="lp-main">
<PublicBackground scrollY={scrollY()} />
<div class="lp-content">
<PublicHeader />
return (
<main class="lp-main">
<PublicBackground scrollY={scrollY()} />
<div class="lp-content">
<PublicHeader />
<Show when={article.loading}>
<section class="public-section scene-dark">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<p style="color:#94a3b8;padding:40px 0;text-align:center">Loading article</p>
</div>
</section>
</Show>
<Show when={!article.loading && !article()}>
<section class="public-section scene-dark">
<div class="container panel panel-light">
<h1 class="title">Article not found</h1>
@ -40,49 +50,55 @@ export default function HelpCenterArticlePage() {
</div>
</div>
</section>
<PublicFooter />
</div>
</main>
);
}
</Show>
return (
<main class="lp-main">
<PublicBackground scrollY={scrollY()} />
<div class="lp-content">
<PublicHeader />
<section class="public-section scene-dark">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<p class="eyebrow">{categoryTitle(article.categoryKey)}</p>
<h1 class="title">{article.title}</h1>
<p class="subtitle">{article.summary}</p>
<Show when={!article.loading && article()}>
{(a) => (
<>
<section class="public-section scene-dark">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<p class="eyebrow">{a().category || categoryTitle(a().categoryKey)}</p>
<h1 class="title">{a().title}</h1>
<p class="subtitle">{a().summary}</p>
<div class="help-article-tags" style={{ 'margin-top': '10px' }}>
{article.tags.map((tag) => <span class="help-article-tag">{tag}</span>)}
</div>
<p class="note">Updated {new Date(article.updatedAt).toLocaleDateString()}</p>
<Show when={a().tags.length > 0}>
<div class="help-article-tags" style={{ 'margin-top': '10px' }}>
<For each={a().tags}>{(tag) => <span class="help-article-tag">{tag}</span>}</For>
</div>
</Show>
<div class="help-article-body">
<p>{article.content}</p>
</div>
<p class="note">Updated {new Date(a().updatedAt).toLocaleDateString()}</p>
<div class="actions">
<A class="btn" href="/help-center">Back to Help Center</A>
<A class="btn primary" href="/auth/register?intent=customer&redirect=/users/onboarding/customer">Get Started</A>
</div>
</div>
</section>
<div class="help-article-body">
<For each={a().content.split('\n\n').filter(Boolean)}>
{(para) => <p>{para}</p>}
</For>
</div>
<section class="public-section scene-dark">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<h2>Need more help?</h2>
<p class="sub">If this article does not solve your issue, send your question with context to support.</p>
<div class="actions">
<a class="btn primary" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">Email support</a>
<A class="btn" href="/help-center">Browse more articles</A>
</div>
</div>
</section>
<div class="actions">
<A class="btn" href="/help-center">Back to Help Center</A>
<A class="btn primary" href="/auth/register">Get Started</A>
</div>
</div>
</section>
<section class="public-section scene-dark">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<h2>Need more help?</h2>
<p class="sub">
If this article does not solve your issue, send your question with context to support.
</p>
<div class="actions">
<a class="btn primary" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">
Email support
</a>
<A class="btn" href="/help-center">Browse more articles</A>
</div>
</div>
</section>
</>
)}
</Show>
<PublicFooter />
</div>

View file

@ -1,6 +1,6 @@
import { A, useSearchParams } from '@solidjs/router';
import { For, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { listHelpCenterArticles, listHelpCenterCategories } from '~/lib/help-center';
import { For, Show, createMemo, createResource, createSignal, onCleanup, onMount } from 'solid-js';
import { fetchHelpCenterArticles, fetchHelpCenterCategories } from '~/lib/help-center';
import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader';
import PublicFooter from '~/components/PublicFooter';
@ -30,22 +30,38 @@ export default function SupportPage() {
const category = createMemo(() => String(search.category || ''));
const q = createMemo(() => String(search.q || ''));
const categories = createMemo(() => listHelpCenterCategories());
const articles = createMemo(() =>
listHelpCenterArticles({ role: role(), categoryKey: category() || undefined, q: q() || undefined }),
);
const [categories] = createResource(fetchHelpCenterCategories);
const articleParams = createMemo(() => ({
role: role(),
categoryKey: category() || undefined,
q: q() || undefined,
}));
const [articles] = createResource(articleParams, (p) => fetchHelpCenterArticles(p));
const visibleCategories = createMemo(() => {
if (categories().length > 0) return categories();
const cats = categories();
if (cats && cats.length > 0) return cats;
const arts = articles();
if (!arts) return [];
const seen = new Set<string>();
return articles()
return arts
.filter((item) => {
if (seen.has(item.categoryKey)) return false;
seen.add(item.categoryKey);
return true;
})
.map((item, idx) => ({ id: `derived-${idx + 1}`, key: item.categoryKey, title: categoryTitle(item.categoryKey) }));
.map((item, idx) => ({
id: `derived-${idx + 1}`,
key: item.categoryKey,
title: item.category || categoryTitle(item.categoryKey),
}));
});
const categoryName = createMemo(() => visibleCategories().find((cat) => cat.key === category())?.title || categoryTitle(category()));
const categoryName = createMemo(
() => visibleCategories().find((cat) => cat.key === category())?.title || categoryTitle(category()),
);
onMount(() => {
const onScroll = () => setScrollY(window.scrollY || 0);
@ -66,7 +82,7 @@ export default function SupportPage() {
<p class="eyebrow">Help Center</p>
<h1 class="title">Get answers quickly</h1>
<p class="subtitle">
Articles are loaded at runtime from published Help Center management content so public users always see the latest approved guidance.
Browse articles by role or category, or search for what you need.
</p>
<form method="GET" action="/help-center" class="help-search-grid">
@ -82,28 +98,33 @@ export default function SupportPage() {
<div class="help-category-head">
<p class="help-category-kicker">Categories</p>
{category() && (
<Show when={category()}>
<A
class="help-clear-filter"
href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}) }).toString()}`}
>
Clear category filter
</A>
)}
</Show>
</div>
<div class="help-category-row">
<For each={visibleCategories()}>
{(cat) => (
<A
class={`help-category-pill ${category() === cat.key ? 'help-category-pill-active' : ''}`}
href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}), category: cat.key }).toString()}`}
>
{cat.title}
</A>
)}
</For>
</div>
<Show when={categories.loading}>
<div class="help-category-row" style="color:#94a3b8;font-size:14px">Loading categories</div>
</Show>
<Show when={!categories.loading}>
<div class="help-category-row">
<For each={visibleCategories()}>
{(cat) => (
<A
class={`help-category-pill ${category() === cat.key ? 'help-category-pill-active' : ''}`}
href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}), category: cat.key }).toString()}`}
>
{cat.title}
</A>
)}
</For>
</div>
</Show>
</div>
</section>
@ -117,32 +138,44 @@ export default function SupportPage() {
? 'Latest articles'
: `${ROLE_LABELS[role()] || 'Role'} articles`}
</h2>
<span>{articles().length} articles</span>
<span>{articles()?.length ?? 0} articles</span>
</div>
<div class="help-article-list">
<For each={articles()}>
{(article) => (
<article class="help-article-card">
<p class="note">{categoryTitle(article.categoryKey)}</p>
<h3>
<A class="help-article-link" href={`/help-center/article/${article.slug}`}>{article.title}</A>
</h3>
<p class="help-article-summary">{article.summary}</p>
<div class="help-article-tags">
<For each={article.tags}>
{(tag) => <span class="help-article-tag">{tag}</span>}
</For>
</div>
<div class="help-article-meta">
<span>Updated {new Date(article.updatedAt).toLocaleDateString()}</span>
<A class="help-read-link" href={`/help-center/article/${article.slug}`}>Read article</A>
</div>
</article>
)}
</For>
{articles().length === 0 && <article class="help-empty-card">No Help Center articles matched your filters.</article>}
</div>
<Show when={articles.loading}>
<div style="padding:40px 0;text-align:center;color:#94a3b8">Loading articles</div>
</Show>
<Show when={!articles.loading}>
<div class="help-article-list">
<For each={articles() ?? []}>
{(article) => (
<article class="help-article-card">
<p class="note">{article.category || categoryTitle(article.categoryKey)}</p>
<h3>
<A class="help-article-link" href={`/help-center/article/${article.slug}`}>
{article.title}
</A>
</h3>
<p class="help-article-summary">{article.summary}</p>
<div class="help-article-tags">
<For each={article.tags}>
{(tag) => <span class="help-article-tag">{tag}</span>}
</For>
</div>
<div class="help-article-meta">
<span>Updated {new Date(article.updatedAt).toLocaleDateString()}</span>
<A class="help-read-link" href={`/help-center/article/${article.slug}`}>
Read article
</A>
</div>
</article>
)}
</For>
<Show when={(articles()?.length ?? 0) === 0}>
<article class="help-empty-card">No Help Center articles matched your filters.</article>
</Show>
</div>
</Show>
</div>
</section>
@ -151,10 +184,14 @@ export default function SupportPage() {
<div>
<p class="eyebrow">Still have questions?</p>
<h2>Ask the support team</h2>
<p class="sub">Share your role, what you tried, and which article you checked so support can respond faster.</p>
<p class="sub">
Share your role, what you tried, and which article you checked so support can respond faster.
</p>
</div>
<div class="hero-actions">
<a class="lp-primary-btn" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">Email support</a>
<a class="lp-primary-btn" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">
Email support
</a>
<A class="lp-ghost-btn" href="/contact">Contact page</A>
</div>
</div>