feat: Add in-app documentation system

Phase 1: Core Documentation
- Created docs/ folder with 8 markdown documentation files
- Getting Started, Installation, Troubleshooting, FAQ
- Configuration docs (Appearance, SPA Mode)
- Feature docs (Shop, Checkout)
- PHP registry with filter hook for addon extensibility

Phase 2: Documentation Viewer
- DocsController.php with REST API endpoints
- GET /woonoow/v1/docs - List all docs (with addon hook)
- GET /woonoow/v1/docs/{slug} - Get document content
- Admin SPA /help route with sidebar navigation
- Markdown rendering with react-markdown
- Added Help & Docs to More page for mobile access

Filter Hook: woonoow_docs_registry
Addons can register their own documentation sections.
This commit is contained in:
Dwindi Ramadhana
2026-01-04 11:43:32 +07:00
parent 1206117df1
commit 68c3423f50
18 changed files with 3083 additions and 4 deletions

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import type { DocContent as DocContentType } from './types';
interface DocContentProps {
slug: string;
}
export default function DocContent({ slug }: DocContentProps) {
const [doc, setDoc] = useState<DocContentType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDoc = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/wp-json/woonoow/v1/docs/${slug}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Document not found');
}
const data = await response.json();
if (data.success) {
setDoc(data.doc);
} else {
throw new Error(data.message || 'Failed to load document');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load document');
setDoc(null);
} finally {
setLoading(false);
}
};
fetchDoc();
}, [slug]);
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full mt-6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
</Alert>
);
}
if (!doc) {
return null;
}
return (
<article className="prose prose-slate dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Custom heading with anchor links
h1: ({ children }) => (
<h1 className="text-3xl font-bold mb-6 pb-4 border-b">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold mt-10 mb-4">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-medium mt-8 mb-3">{children}</h3>
),
// Styled tables
table: ({ children }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full border-collapse border border-border rounded-lg">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-border px-4 py-2">{children}</td>
),
// Styled code blocks
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
);
}
return (
<code className={className}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-muted p-4 rounded-lg overflow-x-auto my-4">
{children}
</pre>
),
// Styled blockquotes for notes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary bg-primary/5 pl-4 py-2 my-4 italic">
{children}
</blockquote>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary hover:underline"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc pl-6 my-4 space-y-2">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-6 my-4 space-y-2">{children}</ol>
),
// Horizontal rule
hr: () => <hr className="my-8 border-border" />,
}}
>
{doc.content}
</ReactMarkdown>
</article>
);
}

View File

@@ -0,0 +1,160 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Book, ChevronRight, FileText, Settings, Layers, Puzzle, Menu, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import DocContent from './DocContent';
import type { DocSection } from './types';
const iconMap: Record<string, React.ReactNode> = {
'book-open': <Book className="w-4 h-4" />,
'file-text': <FileText className="w-4 h-4" />,
'settings': <Settings className="w-4 h-4" />,
'layers': <Layers className="w-4 h-4" />,
'puzzle': <Puzzle className="w-4 h-4" />,
};
export default function Help() {
const [searchParams, setSearchParams] = useSearchParams();
const [sections, setSections] = useState<DocSection[]>([]);
const [loading, setLoading] = useState(true);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [sidebarOpen, setSidebarOpen] = useState(false);
const currentSlug = searchParams.get('doc') || 'getting-started';
// Fetch documentation registry
useEffect(() => {
const fetchDocs = async () => {
try {
const response = await fetch('/wp-json/woonoow/v1/docs', {
credentials: 'include',
});
const data = await response.json();
if (data.success) {
setSections(data.sections);
// Expand all sections by default
const expanded: Record<string, boolean> = {};
data.sections.forEach((section: DocSection) => {
expanded[section.key] = true;
});
setExpandedSections(expanded);
}
} catch (error) {
console.error('Failed to fetch docs:', error);
} finally {
setLoading(false);
}
};
fetchDocs();
}, []);
const toggleSection = (key: string) => {
setExpandedSections(prev => ({
...prev,
[key]: !prev[key],
}));
};
const selectDoc = (slug: string) => {
setSearchParams({ doc: slug });
setSidebarOpen(false); // Close mobile sidebar
};
const isActive = (slug: string) => slug === currentSlug;
return (
<div className="flex h-[calc(100vh-64px)] bg-background">
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="lg:hidden fixed bottom-4 right-4 z-50 bg-primary text-primary-foreground shadow-lg rounded-full w-12 h-12"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
{/* Sidebar */}
<aside
className={cn(
"w-72 border-r bg-muted/30 flex-shrink-0 transition-transform duration-300",
"fixed lg:relative inset-y-0 left-0 z-40 lg:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="p-4 border-b">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Book className="w-5 h-5" />
Documentation
</h2>
<p className="text-sm text-muted-foreground">Help & Guides</p>
</div>
<div className="h-[calc(100vh-180px)] overflow-y-auto">
<nav className="p-2">
{loading ? (
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
) : sections.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No documentation available</div>
) : (
sections.map((section) => (
<div key={section.key} className="mb-2">
<button
onClick={() => toggleSection(section.key)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-md"
>
{iconMap[section.icon] || <FileText className="w-4 h-4" />}
<span className="flex-1 text-left">{section.label}</span>
<ChevronRight
className={cn(
"w-4 h-4 transition-transform",
expandedSections[section.key] && "rotate-90"
)}
/>
</button>
{expandedSections[section.key] && (
<div className="ml-4 mt-1 space-y-1">
{section.items.map((item) => (
<button
key={item.slug}
onClick={() => selectDoc(item.slug)}
className={cn(
"w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors",
isActive(item.slug)
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{item.title}
</button>
))}
</div>
)}
</div>
))
)}
</nav>
</div>
</aside>
{/* Backdrop for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6 lg:p-10">
<DocContent slug={currentSlug} />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Documentation Types
*/
export interface DocItem {
slug: string;
title: string;
}
export interface DocSection {
key: string;
label: string;
icon: string;
items: DocItem[];
}
export interface DocContent {
slug: string;
title: string;
content: string;
}
export interface DocsRegistryResponse {
success: boolean;
sections: DocSection[];
}
export interface DocContentResponse {
success: boolean;
doc: DocContent;
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react';
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone, HelpCircle } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { useApp } from '@/contexts/AppContext';
@@ -32,6 +32,12 @@ const menuItems: MenuItem[] = [
label: __('Settings'),
description: __('Configure your store settings'),
to: '/settings'
},
{
icon: <HelpCircle className="w-5 h-5" />,
label: __('Help & Docs'),
description: __('Documentation and guides'),
to: '/help'
}
];