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; title: string;
summary: string; summary: string;
categoryKey: string; categoryKey: string;
category: string;
role: 'ALL' | 'company' | 'jobSeeker' | 'professional' | 'customer' | 'platform'; role: 'ALL' | 'company' | 'jobSeeker' | 'professional' | 'customer' | 'platform';
tags: string[]; tags: string[];
updatedAt: string; updatedAt: string;
content: string; content: string;
}; };
export const HELP_ARTICLES: HelpArticle[] = [ export type HelpCategory = {
{ id: string;
id: 'hc-1', key: string;
slug: 'how-verification-works', title: string;
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 function listHelpCenterArticles(input: { role?: string; categoryKey?: string; q?: string }) { // ── API fetchers ──────────────────────────────────────────────────────────────
const role = String(input.role || 'ALL');
const categoryKey = String(input.categoryKey || '').trim();
const q = String(input.q || '').trim().toLowerCase();
return HELP_ARTICLES.filter((article) => { export async function fetchHelpCenterArticles(input: {
const roleOk = role === 'ALL' || article.role === 'ALL' || article.role === role; role?: string;
const categoryOk = !categoryKey || article.categoryKey === categoryKey; categoryKey?: string;
const queryOk = !q || `${article.title} ${article.summary} ${article.tags.join(' ')}`.toLowerCase().includes(q); q?: string;
return roleOk && categoryOk && queryOk; }): 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);
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 [];
}
} }
export function listHelpCenterCategories() { export async function fetchHelpCenterCategories(): Promise<HelpCategory[]> {
const keys = new Map<string, string>(); try {
for (const article of HELP_ARTICLES) { const res = await fetch('/api/kb/categories');
if (!keys.has(article.categoryKey)) { if (!res.ok) return [];
const title = article.categoryKey const data = await res.json();
.split('-') const raw: any[] = Array.isArray(data) ? data : (data.categories ?? []);
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1)) return raw.map((c) => ({
.join(' '); id: c.id,
keys.set(article.categoryKey, title); key: c.slug,
} title: c.name,
}
return Array.from(keys.entries()).map(([key, title], idx) => ({
id: `cat-${idx + 1}`,
key,
title,
})); }));
} catch {
return [];
}
} }
export function getArticleBySlug(slug: string) { export async function fetchArticleBySlug(slug: string): Promise<HelpArticle | null> {
return HELP_ARTICLES.find((article) => article.slug === slug) || 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 { A, useParams } from '@solidjs/router';
import { createSignal, onCleanup, onMount } from 'solid-js'; import { Show, For, createSignal, createResource, onCleanup, onMount } from 'solid-js';
import { getArticleBySlug } from '~/lib/help-center'; import { fetchArticleBySlug } from '~/lib/help-center';
import PublicBackground from '~/components/PublicBackground'; import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader'; import PublicHeader from '~/components/PublicHeader';
import PublicFooter from '~/components/PublicFooter'; import PublicFooter from '~/components/PublicFooter';
@ -15,9 +15,10 @@ function categoryTitle(input: string) {
export default function HelpCenterArticlePage() { export default function HelpCenterArticlePage() {
const params = useParams(); const params = useParams();
const article = getArticleBySlug(params.slug || '');
const [scrollY, setScrollY] = createSignal(0); const [scrollY, setScrollY] = createSignal(0);
const [article] = createResource(() => params.slug, fetchArticleBySlug);
onMount(() => { onMount(() => {
const onScroll = () => setScrollY(window.scrollY || 0); const onScroll = () => setScrollY(window.scrollY || 0);
onScroll(); onScroll();
@ -25,12 +26,21 @@ export default function HelpCenterArticlePage() {
onCleanup(() => window.removeEventListener('scroll', onScroll)); onCleanup(() => window.removeEventListener('scroll', onScroll));
}); });
if (!article) {
return ( return (
<main class="lp-main"> <main class="lp-main">
<PublicBackground scrollY={scrollY()} /> <PublicBackground scrollY={scrollY()} />
<div class="lp-content"> <div class="lp-content">
<PublicHeader /> <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"> <section class="public-section scene-dark">
<div class="container panel panel-light"> <div class="container panel panel-light">
<h1 class="title">Article not found</h1> <h1 class="title">Article not found</h1>
@ -40,35 +50,34 @@ export default function HelpCenterArticlePage() {
</div> </div>
</div> </div>
</section> </section>
<PublicFooter /> </Show>
</div>
</main>
);
}
return ( <Show when={!article.loading && article()}>
<main class="lp-main"> {(a) => (
<PublicBackground scrollY={scrollY()} /> <>
<div class="lp-content">
<PublicHeader />
<section class="public-section scene-dark"> <section class="public-section scene-dark">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}> <div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<p class="eyebrow">{categoryTitle(article.categoryKey)}</p> <p class="eyebrow">{a().category || categoryTitle(a().categoryKey)}</p>
<h1 class="title">{article.title}</h1> <h1 class="title">{a().title}</h1>
<p class="subtitle">{article.summary}</p> <p class="subtitle">{a().summary}</p>
<Show when={a().tags.length > 0}>
<div class="help-article-tags" style={{ 'margin-top': '10px' }}> <div class="help-article-tags" style={{ 'margin-top': '10px' }}>
{article.tags.map((tag) => <span class="help-article-tag">{tag}</span>)} <For each={a().tags}>{(tag) => <span class="help-article-tag">{tag}</span>}</For>
</div> </div>
<p class="note">Updated {new Date(article.updatedAt).toLocaleDateString()}</p> </Show>
<p class="note">Updated {new Date(a().updatedAt).toLocaleDateString()}</p>
<div class="help-article-body"> <div class="help-article-body">
<p>{article.content}</p> <For each={a().content.split('\n\n').filter(Boolean)}>
{(para) => <p>{para}</p>}
</For>
</div> </div>
<div class="actions"> <div class="actions">
<A class="btn" href="/help-center">Back to Help Center</A> <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> <A class="btn primary" href="/auth/register">Get Started</A>
</div> </div>
</div> </div>
</section> </section>
@ -76,13 +85,20 @@ export default function HelpCenterArticlePage() {
<section class="public-section scene-dark"> <section class="public-section scene-dark">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}> <div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<h2>Need more help?</h2> <h2>Need more help?</h2>
<p class="sub">If this article does not solve your issue, send your question with context to support.</p> <p class="sub">
If this article does not solve your issue, send your question with context to support.
</p>
<div class="actions"> <div class="actions">
<a class="btn primary" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">Email support</a> <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> <A class="btn" href="/help-center">Browse more articles</A>
</div> </div>
</div> </div>
</section> </section>
</>
)}
</Show>
<PublicFooter /> <PublicFooter />
</div> </div>

View file

@ -1,6 +1,6 @@
import { A, useSearchParams } from '@solidjs/router'; import { A, useSearchParams } from '@solidjs/router';
import { For, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { For, Show, createMemo, createResource, createSignal, onCleanup, onMount } from 'solid-js';
import { listHelpCenterArticles, listHelpCenterCategories } from '~/lib/help-center'; import { fetchHelpCenterArticles, fetchHelpCenterCategories } from '~/lib/help-center';
import PublicBackground from '~/components/PublicBackground'; import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader'; import PublicHeader from '~/components/PublicHeader';
import PublicFooter from '~/components/PublicFooter'; import PublicFooter from '~/components/PublicFooter';
@ -30,22 +30,38 @@ export default function SupportPage() {
const category = createMemo(() => String(search.category || '')); const category = createMemo(() => String(search.category || ''));
const q = createMemo(() => String(search.q || '')); const q = createMemo(() => String(search.q || ''));
const categories = createMemo(() => listHelpCenterCategories()); const [categories] = createResource(fetchHelpCenterCategories);
const articles = createMemo(() =>
listHelpCenterArticles({ role: role(), categoryKey: category() || undefined, q: q() || undefined }), const articleParams = createMemo(() => ({
); role: role(),
categoryKey: category() || undefined,
q: q() || undefined,
}));
const [articles] = createResource(articleParams, (p) => fetchHelpCenterArticles(p));
const visibleCategories = createMemo(() => { 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>(); const seen = new Set<string>();
return articles() return arts
.filter((item) => { .filter((item) => {
if (seen.has(item.categoryKey)) return false; if (seen.has(item.categoryKey)) return false;
seen.add(item.categoryKey); seen.add(item.categoryKey);
return true; 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(() => { onMount(() => {
const onScroll = () => setScrollY(window.scrollY || 0); const onScroll = () => setScrollY(window.scrollY || 0);
@ -66,7 +82,7 @@ export default function SupportPage() {
<p class="eyebrow">Help Center</p> <p class="eyebrow">Help Center</p>
<h1 class="title">Get answers quickly</h1> <h1 class="title">Get answers quickly</h1>
<p class="subtitle"> <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> </p>
<form method="GET" action="/help-center" class="help-search-grid"> <form method="GET" action="/help-center" class="help-search-grid">
@ -82,16 +98,20 @@ export default function SupportPage() {
<div class="help-category-head"> <div class="help-category-head">
<p class="help-category-kicker">Categories</p> <p class="help-category-kicker">Categories</p>
{category() && ( <Show when={category()}>
<A <A
class="help-clear-filter" class="help-clear-filter"
href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}) }).toString()}`} href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}) }).toString()}`}
> >
Clear category filter Clear category filter
</A> </A>
)} </Show>
</div> </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"> <div class="help-category-row">
<For each={visibleCategories()}> <For each={visibleCategories()}>
{(cat) => ( {(cat) => (
@ -104,6 +124,7 @@ export default function SupportPage() {
)} )}
</For> </For>
</div> </div>
</Show>
</div> </div>
</section> </section>
@ -117,16 +138,23 @@ export default function SupportPage() {
? 'Latest articles' ? 'Latest articles'
: `${ROLE_LABELS[role()] || 'Role'} articles`} : `${ROLE_LABELS[role()] || 'Role'} articles`}
</h2> </h2>
<span>{articles().length} articles</span> <span>{articles()?.length ?? 0} articles</span>
</div> </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"> <div class="help-article-list">
<For each={articles()}> <For each={articles() ?? []}>
{(article) => ( {(article) => (
<article class="help-article-card"> <article class="help-article-card">
<p class="note">{categoryTitle(article.categoryKey)}</p> <p class="note">{article.category || categoryTitle(article.categoryKey)}</p>
<h3> <h3>
<A class="help-article-link" href={`/help-center/article/${article.slug}`}>{article.title}</A> <A class="help-article-link" href={`/help-center/article/${article.slug}`}>
{article.title}
</A>
</h3> </h3>
<p class="help-article-summary">{article.summary}</p> <p class="help-article-summary">{article.summary}</p>
<div class="help-article-tags"> <div class="help-article-tags">
@ -136,13 +164,18 @@ export default function SupportPage() {
</div> </div>
<div class="help-article-meta"> <div class="help-article-meta">
<span>Updated {new Date(article.updatedAt).toLocaleDateString()}</span> <span>Updated {new Date(article.updatedAt).toLocaleDateString()}</span>
<A class="help-read-link" href={`/help-center/article/${article.slug}`}>Read article</A> <A class="help-read-link" href={`/help-center/article/${article.slug}`}>
Read article
</A>
</div> </div>
</article> </article>
)} )}
</For> </For>
{articles().length === 0 && <article class="help-empty-card">No Help Center articles matched your filters.</article>} <Show when={(articles()?.length ?? 0) === 0}>
<article class="help-empty-card">No Help Center articles matched your filters.</article>
</Show>
</div> </div>
</Show>
</div> </div>
</section> </section>
@ -151,10 +184,14 @@ export default function SupportPage() {
<div> <div>
<p class="eyebrow">Still have questions?</p> <p class="eyebrow">Still have questions?</p>
<h2>Ask the support team</h2> <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>
<div class="hero-actions"> <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> <A class="lp-ghost-btn" href="/contact">Contact page</A>
</div> </div>
</div> </div>