Add in-app wiki help system with accounts and members articles
Markdown-based help pages rendered in the admin UI. Sidebar category navigation with search. Articles: Getting Started, Accounts Overview, Members Overview, Payment Methods, Tax Exemptions. Written for non-technical store staff.
This commit is contained in:
148
packages/admin/src/routes/_authenticated/help.tsx
Normal file
148
packages/admin/src/routes/_authenticated/help.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { getWikiCategories, getWikiPage, type WikiPage } from '@/wiki'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/help')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: (search.page as string) || 'getting-started',
|
||||
}),
|
||||
component: HelpPage,
|
||||
})
|
||||
|
||||
function renderMarkdown(content: string) {
|
||||
// Simple markdown to HTML — handles headers, bold, lists, tips, notes
|
||||
return content
|
||||
.split('\n\n')
|
||||
.map((block, i) => {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
// Headers
|
||||
if (trimmed.startsWith('## '))
|
||||
return <h2 key={i} className="text-lg font-semibold mt-6 mb-2">{trimmed.slice(3)}</h2>
|
||||
if (trimmed.startsWith('# '))
|
||||
return <h1 key={i} className="text-2xl font-bold mb-4">{trimmed.slice(2)}</h1>
|
||||
|
||||
// Lists
|
||||
if (trimmed.match(/^\d+\./m) || trimmed.startsWith('- ')) {
|
||||
const items = trimmed.split('\n').filter(Boolean)
|
||||
const isOrdered = items[0].match(/^\d+\./)
|
||||
const Tag = isOrdered ? 'ol' : 'ul'
|
||||
return (
|
||||
<Tag key={i} className={`my-2 pl-6 space-y-1 ${isOrdered ? 'list-decimal' : 'list-disc'}`}>
|
||||
{items.map((item, j) => (
|
||||
<li key={j} className="text-sm">
|
||||
{renderInline(item.replace(/^(\d+\.\s*|-\s*)/, ''))}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
return <p key={i} className="text-sm leading-relaxed my-2">{renderInline(trimmed)}</p>
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function renderInline(text: string) {
|
||||
// Bold
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/)
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
|
||||
function HelpPage() {
|
||||
const categories = getWikiCategories()
|
||||
const search = Route.useSearch()
|
||||
const navigate = Route.useNavigate()
|
||||
const currentPage = getWikiPage(search.page)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const allPages = categories.flatMap((c) => c.pages)
|
||||
const filteredPages = searchQuery
|
||||
? allPages.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.content.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: null
|
||||
|
||||
function goToPage(slug: string) {
|
||||
navigate({ search: { page: slug } })
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 max-w-5xl">
|
||||
{/* Sidebar */}
|
||||
<div className="w-56 shrink-0 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search help..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredPages ? (
|
||||
<div className="space-y-1">
|
||||
{filteredPages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground px-2">No results</p>
|
||||
) : (
|
||||
filteredPages.map((p) => (
|
||||
<button
|
||||
key={p.slug}
|
||||
onClick={() => goToPage(p.slug)}
|
||||
className="block w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-accent"
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
categories.map((cat) => (
|
||||
<div key={cat.name}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-2 mb-1">
|
||||
{cat.name}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{cat.pages.map((p) => (
|
||||
<button
|
||||
key={p.slug}
|
||||
onClick={() => goToPage(p.slug)}
|
||||
className={`block w-full text-left px-2 py-1.5 text-sm rounded-md transition-colors ${
|
||||
search.page === p.slug
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{currentPage ? (
|
||||
<div className="prose-sm">{renderMarkdown(currentPage.content)}</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Page not found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user