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);
export function listHelpCenterCategories() { try {
const keys = new Map<string, string>(); const res = await fetch(`/api/kb/articles?${params.toString()}`);
for (const article of HELP_ARTICLES) { if (!res.ok) return [];
if (!keys.has(article.categoryKey)) { const data = await res.json();
const title = article.categoryKey const raw: any[] = Array.isArray(data) ? data : (data.articles ?? []);
.split('-') return raw.map(normalizeArticle);
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1)) } catch {
.join(' '); return [];
keys.set(article.categoryKey, title);
}
} }
return Array.from(keys.entries()).map(([key, title], idx) => ({
id: `cat-${idx + 1}`,
key,
title,
}));
} }
export function getArticleBySlug(slug: string) { export async function fetchHelpCenterCategories(): Promise<HelpCategory[]> {
return HELP_ARTICLES.find((article) => article.slug === slug) || null; 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 { 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,49 +50,55 @@ 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"> <section class="public-section scene-dark">
<PublicHeader /> <div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<section class="public-section scene-dark"> <p class="eyebrow">{a().category || categoryTitle(a().categoryKey)}</p>
<div class="container panel panel-light" style={{ 'max-width': '960px' }}> <h1 class="title">{a().title}</h1>
<p class="eyebrow">{categoryTitle(article.categoryKey)}</p> <p class="subtitle">{a().summary}</p>
<h1 class="title">{article.title}</h1>
<p class="subtitle">{article.summary}</p>
<div class="help-article-tags" style={{ 'margin-top': '10px' }}> <Show when={a().tags.length > 0}>
{article.tags.map((tag) => <span class="help-article-tag">{tag}</span>)} <div class="help-article-tags" style={{ 'margin-top': '10px' }}>
</div> <For each={a().tags}>{(tag) => <span class="help-article-tag">{tag}</span>}</For>
<p class="note">Updated {new Date(article.updatedAt).toLocaleDateString()}</p> </div>
</Show>
<div class="help-article-body"> <p class="note">Updated {new Date(a().updatedAt).toLocaleDateString()}</p>
<p>{article.content}</p>
</div>
<div class="actions"> <div class="help-article-body">
<A class="btn" href="/help-center">Back to Help Center</A> <For each={a().content.split('\n\n').filter(Boolean)}>
<A class="btn primary" href="/auth/register?intent=customer&redirect=/users/onboarding/customer">Get Started</A> {(para) => <p>{para}</p>}
</div> </For>
</div> </div>
</section>
<section class="public-section scene-dark"> <div class="actions">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}> <A class="btn" href="/help-center">Back to Help Center</A>
<h2>Need more help?</h2> <A class="btn primary" href="/auth/register">Get Started</A>
<p class="sub">If this article does not solve your issue, send your question with context to support.</p> </div>
<div class="actions"> </div>
<a class="btn primary" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">Email support</a> </section>
<A class="btn" href="/help-center">Browse more articles</A>
</div> <section class="public-section scene-dark">
</div> <div class="container panel panel-light" style={{ 'max-width': '960px' }}>
</section> <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 /> <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,28 +98,33 @@ 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>
<div class="help-category-row"> <Show when={categories.loading}>
<For each={visibleCategories()}> <div class="help-category-row" style="color:#94a3b8;font-size:14px">Loading categories</div>
{(cat) => ( </Show>
<A <Show when={!categories.loading}>
class={`help-category-pill ${category() === cat.key ? 'help-category-pill-active' : ''}`} <div class="help-category-row">
href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}), category: cat.key }).toString()}`} <For each={visibleCategories()}>
> {(cat) => (
{cat.title} <A
</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()}`}
</For> >
</div> {cat.title}
</A>
)}
</For>
</div>
</Show>
</div> </div>
</section> </section>
@ -117,32 +138,44 @@ 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>
<div class="help-article-list"> <Show when={articles.loading}>
<For each={articles()}> <div style="padding:40px 0;text-align:center;color:#94a3b8">Loading articles</div>
{(article) => ( </Show>
<article class="help-article-card">
<p class="note">{categoryTitle(article.categoryKey)}</p> <Show when={!articles.loading}>
<h3> <div class="help-article-list">
<A class="help-article-link" href={`/help-center/article/${article.slug}`}>{article.title}</A> <For each={articles() ?? []}>
</h3> {(article) => (
<p class="help-article-summary">{article.summary}</p> <article class="help-article-card">
<div class="help-article-tags"> <p class="note">{article.category || categoryTitle(article.categoryKey)}</p>
<For each={article.tags}> <h3>
{(tag) => <span class="help-article-tag">{tag}</span>} <A class="help-article-link" href={`/help-center/article/${article.slug}`}>
</For> {article.title}
</div> </A>
<div class="help-article-meta"> </h3>
<span>Updated {new Date(article.updatedAt).toLocaleDateString()}</span> <p class="help-article-summary">{article.summary}</p>
<A class="help-read-link" href={`/help-center/article/${article.slug}`}>Read article</A> <div class="help-article-tags">
</div> <For each={article.tags}>
</article> {(tag) => <span class="help-article-tag">{tag}</span>}
)} </For>
</For> </div>
{articles().length === 0 && <article class="help-empty-card">No Help Center articles matched your filters.</article>} <div class="help-article-meta">
</div> <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> </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>