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:
163
admin-spa/src/routes/Help/DocContent.tsx
Normal file
163
admin-spa/src/routes/Help/DocContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
admin-spa/src/routes/Help/index.tsx
Normal file
160
admin-spa/src/routes/Help/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
admin-spa/src/routes/Help/types.ts
Normal file
31
admin-spa/src/routes/Help/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user