316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
"use client";
|
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
import { useKnowledgeBaseCategories, useFeaturedArticles, useKnowledgeBaseArticles } from '@/lib/hooks/useSupport';
|
|
import KnowledgeBaseArticleModal from './KnowledgeBaseArticleModal';
|
|
|
|
const KnowledgeBase = () => {
|
|
const { categories, loading: categoriesLoading } = useKnowledgeBaseCategories();
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
const [selectedArticleSlug, setSelectedArticleSlug] = useState<string | null>(null);
|
|
|
|
// Refs for scrolling to results
|
|
const articlesRef = useRef<HTMLDivElement>(null);
|
|
const emptyStateRef = useRef<HTMLDivElement>(null);
|
|
const articlesListRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Fetch all articles (for browsing and category filtering)
|
|
const { articles: allArticles, loading: allArticlesLoading } = useKnowledgeBaseArticles();
|
|
|
|
// Fetch featured articles (for default view)
|
|
const { articles: featuredArticles, loading: featuredLoading } = useFeaturedArticles();
|
|
|
|
// Determine which articles to display using useMemo for reactivity
|
|
const { displayArticles, isLoading, headerText } = useMemo(() => {
|
|
if (searchTerm) {
|
|
// If searching, filter all articles by search term
|
|
const filtered = allArticles.filter(article =>
|
|
article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
article.summary.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
article.content.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
return {
|
|
displayArticles: filtered,
|
|
isLoading: allArticlesLoading,
|
|
headerText: 'Search Results'
|
|
};
|
|
} else if (selectedCategory) {
|
|
// If a category is selected, filter articles by that category
|
|
const filtered = allArticles.filter(article => article.category_slug === selectedCategory);
|
|
const categoryName = categories.find(cat => cat.slug === selectedCategory)?.name || 'Category';
|
|
return {
|
|
displayArticles: filtered,
|
|
isLoading: allArticlesLoading,
|
|
headerText: `${categoryName} Articles`
|
|
};
|
|
} else {
|
|
return {
|
|
displayArticles: featuredArticles,
|
|
isLoading: featuredLoading,
|
|
headerText: 'Featured Articles'
|
|
};
|
|
}
|
|
}, [searchTerm, selectedCategory, allArticles, featuredArticles, allArticlesLoading, featuredLoading, categories]);
|
|
|
|
// Helper function to find the scrollable parent container
|
|
const findScrollableParent = (element: HTMLElement | null): HTMLElement | null => {
|
|
if (!element) return null;
|
|
|
|
let parent = element.parentElement;
|
|
while (parent) {
|
|
const style = window.getComputedStyle(parent);
|
|
const overflowY = style.overflowY || style.overflow;
|
|
const maxHeight = style.maxHeight;
|
|
|
|
// Check if this element is scrollable (has overflow and max-height)
|
|
if ((overflowY === 'auto' || overflowY === 'scroll') && maxHeight && maxHeight !== 'none') {
|
|
return parent;
|
|
}
|
|
|
|
parent = parent.parentElement;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Scroll to results when category is selected or search changes
|
|
useEffect(() => {
|
|
// Only scroll if we have a search term or selected category and articles are loaded
|
|
if ((searchTerm || selectedCategory) && !isLoading) {
|
|
// Wait for React to render the articles - use longer delay for category clicks
|
|
const scrollTimeout = setTimeout(() => {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (articlesRef.current) {
|
|
// Try to find the articles list first (most specific), then empty state, then first article item, then section
|
|
const articlesList = articlesRef.current.querySelector('.articles-list');
|
|
const firstArticle = articlesRef.current.querySelector('.article-item');
|
|
const emptyState = articlesRef.current.querySelector('.empty-state');
|
|
const targetElement = articlesList || firstArticle || emptyState || articlesRef.current;
|
|
|
|
if (targetElement) {
|
|
// Find the scrollable parent container (the modal)
|
|
const scrollableContainer = findScrollableParent(targetElement as HTMLElement);
|
|
|
|
if (scrollableContainer) {
|
|
// Calculate position relative to the scrollable container
|
|
const containerRect = scrollableContainer.getBoundingClientRect();
|
|
const targetRect = targetElement.getBoundingClientRect();
|
|
|
|
// Calculate the scroll position within the container
|
|
const scrollTop = scrollableContainer.scrollTop;
|
|
const relativeTop = targetRect.top - containerRect.top;
|
|
const offset = 20; // Small offset from top of container
|
|
|
|
scrollableContainer.scrollTo({
|
|
top: scrollTop + relativeTop - offset,
|
|
behavior: 'smooth'
|
|
});
|
|
} else {
|
|
// Fallback to window scroll if no scrollable container found
|
|
const elementTop = targetElement.getBoundingClientRect().top + window.pageYOffset;
|
|
const offsetPosition = elementTop - 100;
|
|
|
|
window.scrollTo({
|
|
top: offsetPosition,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}, 600); // Longer delay to ensure React has fully rendered the articles
|
|
|
|
return () => clearTimeout(scrollTimeout);
|
|
}
|
|
}, [searchTerm, selectedCategory, isLoading, displayArticles.length]);
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
// The search is already being performed by the hook
|
|
};
|
|
|
|
const handleCategoryClick = (categorySlug: string) => {
|
|
setSelectedCategory(categorySlug);
|
|
// Clear search when category is selected
|
|
if (searchTerm) {
|
|
setSearchTerm('');
|
|
}
|
|
// Scroll will be handled by useEffect after articles render
|
|
};
|
|
|
|
const filteredCategories = selectedCategory
|
|
? categories.filter(cat => cat.slug === selectedCategory)
|
|
: categories;
|
|
|
|
return (
|
|
<div className="knowledge-base">
|
|
<div className="row justify-content-center">
|
|
<div className="col-12 col-lg-10">
|
|
<div className="form-header text-center">
|
|
<h2>Knowledge Base</h2>
|
|
<p>Find answers to frequently asked questions and explore our documentation.</p>
|
|
</div>
|
|
|
|
{/* Search Bar */}
|
|
<form onSubmit={handleSearch} className="kb-search-form">
|
|
<div className="search-input-group">
|
|
<i className="fa-solid fa-search search-icon"></i>
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Search articles, topics, or keywords..."
|
|
className="form-control"
|
|
/>
|
|
{searchTerm && (
|
|
<button
|
|
type="button"
|
|
className="clear-search"
|
|
onClick={() => setSearchTerm('')}
|
|
aria-label="Clear search"
|
|
>
|
|
<i className="fa-solid fa-times"></i>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Categories */}
|
|
{!searchTerm && (
|
|
<div className="kb-categories">
|
|
<h3>Browse by Category</h3>
|
|
{categoriesLoading ? (
|
|
<div className="loading-state">
|
|
<div className="spinner-border" role="status">
|
|
<span className="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="row g-4">
|
|
{Array.isArray(categories) && categories.map(category => (
|
|
<div key={category.id} className="col-md-6 col-lg-4">
|
|
<div
|
|
className="category-card"
|
|
onClick={() => handleCategoryClick(category.slug)}
|
|
style={{ borderLeftColor: category.color }}
|
|
>
|
|
<div
|
|
className="category-icon"
|
|
style={{ color: category.color }}
|
|
>
|
|
<i className={`fa-solid ${category.icon}`}></i>
|
|
</div>
|
|
<div className="category-content">
|
|
<h4>{category.name}</h4>
|
|
<p>{category.description}</p>
|
|
<div className="category-meta">
|
|
<span className="article-count">
|
|
{category.article_count} {category.article_count === 1 ? 'article' : 'articles'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Featured/Search Results Articles */}
|
|
<div ref={articlesRef} className="kb-articles">
|
|
<div className="articles-header">
|
|
<div className="d-flex align-items-center justify-content-between">
|
|
<h3>{headerText}</h3>
|
|
{selectedCategory && !searchTerm && (
|
|
<button
|
|
className="btn btn-outline-primary btn-sm"
|
|
onClick={() => setSelectedCategory(null)}
|
|
>
|
|
<i className="fa-solid fa-arrow-left me-2"></i>
|
|
Back to All Articles
|
|
</button>
|
|
)}
|
|
</div>
|
|
{searchTerm && (
|
|
<p className="search-info">
|
|
Found {displayArticles.length} {displayArticles.length === 1 ? 'article' : 'articles'} for "{searchTerm}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="loading-state">
|
|
<div className="spinner-border" role="status">
|
|
<span className="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
) : displayArticles.length === 0 ? (
|
|
<div ref={emptyStateRef} className="empty-state">
|
|
<i className="fa-solid fa-search empty-icon"></i>
|
|
<h4>No articles found</h4>
|
|
<p>
|
|
{searchTerm
|
|
? `We couldn't find any articles matching "${searchTerm}". Try different keywords.`
|
|
: 'No articles available at the moment.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div ref={articlesListRef} className="articles-list">
|
|
{Array.isArray(displayArticles) && displayArticles.map(article => (
|
|
<div
|
|
key={article.id}
|
|
className="article-item"
|
|
onClick={() => setSelectedArticleSlug(article.slug)}
|
|
>
|
|
<div className="article-header">
|
|
<h4>{article.title}</h4>
|
|
{article.is_featured && (
|
|
<span className="featured-badge">
|
|
<i className="fa-solid fa-star me-1"></i>
|
|
Featured
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="article-summary">{article.summary}</p>
|
|
<div className="article-meta">
|
|
<span className="article-category">
|
|
<i className="fa-solid fa-folder me-1"></i>
|
|
{article.category_name}
|
|
</span>
|
|
<span className="article-stats">
|
|
<i className="fa-solid fa-eye me-1"></i>
|
|
{article.view_count} views
|
|
</span>
|
|
<span className="article-stats">
|
|
<i className="fa-solid fa-thumbs-up me-1"></i>
|
|
{article.helpful_count} helpful
|
|
</span>
|
|
</div>
|
|
<button className="article-read-more">
|
|
Read More <i className="fa-solid fa-arrow-right ms-2"></i>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Article Modal */}
|
|
{selectedArticleSlug && (
|
|
<KnowledgeBaseArticleModal
|
|
slug={selectedArticleSlug}
|
|
onClose={() => setSelectedArticleSlug(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default KnowledgeBase;
|
|
|