| 1 |
import React, { useState, useEffect, useRef, useCallback } from 'react'; |
| 2 |
import { addFilter, applyFilters } from "@wordpress/hooks"; |
| 3 |
import { __ } from "@wordpress/i18n"; |
| 4 |
import FolderIcon from './FolderIcon'; |
| 5 |
import DocsIcon from './DocsIcon'; |
| 6 |
import MagnifyIcon from './MagnifyIcon'; |
| 7 |
import FaqIcon from './FaqIcon'; |
| 8 |
import NoResultIcon from './NoResultIcon'; |
| 9 |
import ClearIcon from './ClearIcon'; |
| 10 |
import ExpandableContent from './ExpandableContent'; |
| 11 |
import Xmark from './Xmark'; |
| 12 |
import apiFetch from '@wordpress/api-fetch'; |
| 13 |
|
| 14 |
const SEARCH_DEBOUNCE_MS = 250; |
| 15 |
const SEARCH_PER_PAGE = 20; |
| 16 |
|
| 17 |
// Trailing debounce used for analytics; the search itself is debounced inline via a ref-tracked timer. |
| 18 |
const debounce = (func, delay) => { |
| 19 |
let timeout; |
| 20 |
return (...args) => { |
| 21 |
if (timeout) clearTimeout(timeout); |
| 22 |
timeout = setTimeout(() => func(...args), delay); |
| 23 |
}; |
| 24 |
}; |
| 25 |
|
| 26 |
const SearchModal = ({ initialSearchQuery, closeModal, docCategories, popularDocs, recentFaqs, placeholder, enableFaqSearch, enableDocsSearch, enableAiSearchSuggestions, kbBasedSearch, topWrapperRef = null }) => { |
| 27 |
const [searchQuery, setSearchQuery] = useState(initialSearchQuery || ''); |
| 28 |
const [selectedCategory, setSelectedCategory] = useState(''); |
| 29 |
const [searchResults, setSearchResults] = useState([]); |
| 30 |
const [activeTab, setActiveTab] = useState('docs'); |
| 31 |
// 'idle' (showing popular/recent), 'loading', 'success', 'empty', 'error' |
| 32 |
const [status, setStatus] = useState('idle'); |
| 33 |
const inputRef = useRef(null); |
| 34 |
const cache = useRef({}); |
| 35 |
const abortRef = useRef(null); |
| 36 |
const debounceRef = useRef(null); |
| 37 |
// Last query the user typed — used to reject out-of-order responses even if AbortController is unsupported. |
| 38 |
const latestQueryRef = useRef(''); |
| 39 |
const decodeHtmlEntities = (str) => { |
| 40 |
const textArea = document.createElement('textarea'); |
| 41 |
textArea.innerHTML = str; |
| 42 |
return textArea.value; |
| 43 |
}; |
| 44 |
|
| 45 |
const storeSearchKeyword = useCallback( |
| 46 |
debounce(async (query, noResult = false) => { |
| 47 |
const namespace = 'betterdocs/v1/'; |
| 48 |
const relativePath = `search-insert?s=${query}${noResult ? '&no_result=1' : ''}`; |
| 49 |
try { |
| 50 |
await apiFetch({ path: `${namespace}${relativePath}` }); |
| 51 |
} catch (error) { |
| 52 |
console.error('Error storing search keyword:', error); |
| 53 |
} |
| 54 |
}, 300), |
| 55 |
[] |
| 56 |
); |
| 57 |
|
| 58 |
// Note: third arg `tab` is retained for backward compatibility with the Pro |
| 59 |
// `betterdocs_search_modal_popular_keyword` filter, which calls this directly. |
| 60 |
const fetchSearchResults = useCallback(async (query, category, tab) => { |
| 61 |
// Cancel any in-flight request — only the most recent query may resolve into state. |
| 62 |
if (abortRef.current) { |
| 63 |
abortRef.current.abort(); |
| 64 |
} |
| 65 |
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; |
| 66 |
abortRef.current = controller; |
| 67 |
latestQueryRef.current = query; |
| 68 |
|
| 69 |
const namespace = 'betterdocs/v1/'; |
| 70 |
let apiPath = `${namespace}search?s=${encodeURIComponent(query)}&per_page=${SEARCH_PER_PAGE}`; |
| 71 |
|
| 72 |
if (category) { |
| 73 |
apiPath += `&doc_category=${encodeURIComponent(category)}`; |
| 74 |
} |
| 75 |
if (kbBasedSearch) { |
| 76 |
apiPath += `&knowledge_base=${encodeURIComponent(kbBasedSearch)}`; |
| 77 |
} |
| 78 |
|
| 79 |
const commit = (results) => { |
| 80 |
// Belt-and-suspenders: ignore responses that no longer match the latest query. |
| 81 |
if (latestQueryRef.current !== query) return; |
| 82 |
setSearchResults(results); |
| 83 |
setStatus(results.length === 0 ? 'empty' : 'success'); |
| 84 |
}; |
| 85 |
|
| 86 |
if (cache.current[apiPath]) { |
| 87 |
commit(cache.current[apiPath]); |
| 88 |
return; |
| 89 |
} |
| 90 |
|
| 91 |
setStatus('loading'); |
| 92 |
|
| 93 |
try { |
| 94 |
const fetchOptions = { |
| 95 |
path: apiPath, |
| 96 |
headers: { |
| 97 |
'X-WP-Nonce': searchModalConfig?.nonce, |
| 98 |
}, |
| 99 |
}; |
| 100 |
if (controller) fetchOptions.signal = controller.signal; |
| 101 |
|
| 102 |
const results = await apiFetch(fetchOptions); |
| 103 |
if (latestQueryRef.current !== query) return; |
| 104 |
cache.current[apiPath] = results; |
| 105 |
commit(results); |
| 106 |
storeSearchKeyword(query, results.length === 0); |
| 107 |
} catch (error) { |
| 108 |
// Aborted requests are expected when the user keeps typing. |
| 109 |
if (error && (error.name === 'AbortError' || (controller && controller.signal.aborted))) { |
| 110 |
return; |
| 111 |
} |
| 112 |
if (latestQueryRef.current !== query) return; |
| 113 |
console.error('Error fetching search results:', error); |
| 114 |
setStatus('error'); |
| 115 |
} |
| 116 |
}, [storeSearchKeyword, kbBasedSearch]); |
| 117 |
|
| 118 |
// Backward-compat shim: Pro's popular-keyword chip calls setHasSearched(true) before |
| 119 |
// firing fetchSearchResults. We no longer track hasSearched in state, but expose a no-op |
| 120 |
// setter so the filter contract doesn't break. |
| 121 |
const setHasSearched = useCallback((val) => { |
| 122 |
if (!val) { |
| 123 |
setStatus('idle'); |
| 124 |
setSearchResults([]); |
| 125 |
} |
| 126 |
}, []); |
| 127 |
|
| 128 |
useEffect(() => { |
| 129 |
const shouldSearch = searchQuery.length >= betterdocsSearchModalConfig.search_letter_limit || |
| 130 |
(searchQuery.length > 0 && selectedCategory); |
| 131 |
|
| 132 |
if (debounceRef.current) { |
| 133 |
clearTimeout(debounceRef.current); |
| 134 |
debounceRef.current = null; |
| 135 |
} |
| 136 |
|
| 137 |
if (!shouldSearch) { |
| 138 |
if (abortRef.current) { |
| 139 |
abortRef.current.abort(); |
| 140 |
abortRef.current = null; |
| 141 |
} |
| 142 |
latestQueryRef.current = ''; |
| 143 |
setStatus('idle'); |
| 144 |
setSearchResults([]); |
| 145 |
return; |
| 146 |
} |
| 147 |
|
| 148 |
debounceRef.current = setTimeout(() => { |
| 149 |
fetchSearchResults(searchQuery, selectedCategory, activeTab); |
| 150 |
}, SEARCH_DEBOUNCE_MS); |
| 151 |
|
| 152 |
return () => { |
| 153 |
if (debounceRef.current) { |
| 154 |
clearTimeout(debounceRef.current); |
| 155 |
debounceRef.current = null; |
| 156 |
} |
| 157 |
}; |
| 158 |
}, [searchQuery, selectedCategory, activeTab, fetchSearchResults]); |
| 159 |
|
| 160 |
useEffect(() => { |
| 161 |
return () => { |
| 162 |
if (abortRef.current) abortRef.current.abort(); |
| 163 |
if (debounceRef.current) clearTimeout(debounceRef.current); |
| 164 |
}; |
| 165 |
}, []); |
| 166 |
|
| 167 |
const handleCategoryChange = (selectedOption) => { |
| 168 |
// Always update the selected category state |
| 169 |
// The useEffect will handle whether to trigger a search based on searchQuery length |
| 170 |
setSelectedCategory(selectedOption ? selectedOption.value : ''); |
| 171 |
}; |
| 172 |
|
| 173 |
const handleInsideClick = (e) => { |
| 174 |
e.stopPropagation(); // Prevents the modal from closing when clicking inside it |
| 175 |
}; |
| 176 |
|
| 177 |
const handleOutsideClick = () => { |
| 178 |
closeModal(); |
| 179 |
}; |
| 180 |
|
| 181 |
const handleClear = (e) => { |
| 182 |
e.stopPropagation(); // Prevent event from bubbling up |
| 183 |
setSearchQuery(''); |
| 184 |
inputRef.current.focus(); // Refocus the input field |
| 185 |
}; |
| 186 |
|
| 187 |
// Idle (no search) → fall back to props; otherwise use the live search results. |
| 188 |
const displayResults = status === 'idle' |
| 189 |
? (activeTab === 'docs' ? (popularDocs || []) : (recentFaqs || [])) |
| 190 |
: searchResults; |
| 191 |
|
| 192 |
const filterResults = (type) => displayResults.filter(result => result.post_type === type); |
| 193 |
|
| 194 |
// FAQ tab should appear when there are FAQs to show in the current state. |
| 195 |
const hasFaqResults = status === 'idle' |
| 196 |
? (recentFaqs && recentFaqs.length > 0) |
| 197 |
: searchResults.some(r => r.post_type === 'betterdocs_faq'); |
| 198 |
|
| 199 |
const tabs = [ |
| 200 |
enableDocsSearch ? { label: __('Docs', 'betterdocs'), type: 'docs', icon: <DocsIcon /> } : {}, |
| 201 |
...(hasFaqResults && enableFaqSearch ? [{ label: __('FAQ', 'betterdocs'), type: 'betterdocs_faq', icon: <FaqIcon /> }] : []), // Conditionally add FAQ tab |
| 202 |
]; |
| 203 |
|
| 204 |
useEffect(() => { |
| 205 |
if (inputRef.current) { |
| 206 |
inputRef.current.focus(); // Set focus on the input field when the modal opens |
| 207 |
} |
| 208 |
}, []); |
| 209 |
|
| 210 |
useEffect(() => { |
| 211 |
if ((enableDocsSearch == null && enableFaqSearch == null) || (!enableDocsSearch && !enableFaqSearch)) { // if both are disabled |
| 212 |
setActiveTab(''); |
| 213 |
} else if (!enableDocsSearch || (enableDocsSearch == null)) { // if docs is disabled |
| 214 |
setActiveTab('betterdocs_faq'); |
| 215 |
} else if (!enableFaqSearch || (enableFaqSearch == null)) { // if faq is disabled |
| 216 |
setActiveTab('docs'); |
| 217 |
} |
| 218 |
}, []); |
| 219 |
|
| 220 |
const searchNotFoundText = betterdocsSearchModalConfig?.search_not_found_text ? (betterdocsSearchModalConfig?.search_not_found_text |
| 221 |
).replace(/\.$/, '') + ' for' : __('No Docs found for', 'betterdocs'); |
| 222 |
|
| 223 |
return ( |
| 224 |
<div className="betterdocs-modal-overlay" onMouseDown={handleOutsideClick}> |
| 225 |
<div className="betterdocs-search-wrapper" style={{ display: 'block' }}> |
| 226 |
<div className="betterdocs-search-details" onMouseDown={handleInsideClick}> |
| 227 |
<div className="xmark"> |
| 228 |
<Xmark onClick={closeModal} width="20" height="20" fill="#475467" /> |
| 229 |
</div> |
| 230 |
<div className="betterdocs-search-header"> |
| 231 |
<MagnifyIcon /> |
| 232 |
<div className="betterdocs-searchform-input-wrap"> |
| 233 |
<input |
| 234 |
type="text" |
| 235 |
className="betterdocs-search-field" |
| 236 |
aria-label={__('Search Input', 'betterdocs')} |
| 237 |
placeholder={placeholder} |
| 238 |
value={searchQuery} |
| 239 |
onChange={(e) => setSearchQuery(e.target.value)} |
| 240 |
ref={inputRef} |
| 241 |
autoComplete="off" |
| 242 |
data-form-type="other" |
| 243 |
/> |
| 244 |
{searchQuery ? ( |
| 245 |
<ClearIcon className='clear-icon' onClick={handleClear} /> |
| 246 |
) : ( |
| 247 |
<span className='esc-button'>{__('ESC', 'betterdocs')}</span> |
| 248 |
)} |
| 249 |
</div> |
| 250 |
{betterdocsSearchModalConfig.advance_search && docCategories.length > 0 && activeTab === 'docs' && applyFilters( |
| 251 |
"betterdocs_search_modal_category_select", |
| 252 |
[], |
| 253 |
[selectedCategory, handleCategoryChange, docCategories, topWrapperRef] |
| 254 |
)} |
| 255 |
</div> |
| 256 |
{applyFilters( |
| 257 |
"betterdocs_search_modal_after_srarch_header", |
| 258 |
[], |
| 259 |
[searchQuery, setSearchQuery, setHasSearched, selectedCategory, activeTab, fetchSearchResults, closeModal, topWrapperRef, enableAiSearchSuggestions] |
| 260 |
)} |
| 261 |
{betterdocsSearchModalConfig.advance_search && applyFilters( |
| 262 |
"betterdocs_search_modal_popular_keyword", |
| 263 |
[], |
| 264 |
[setSearchQuery, setHasSearched, selectedCategory, activeTab, fetchSearchResults, topWrapperRef] |
| 265 |
)} |
| 266 |
|
| 267 |
<div className="betterdocs-search-content"> |
| 268 |
<div className="betterdocs-search-info-tab betterdocs-search-tabs"> |
| 269 |
{tabs.map(tab => { |
| 270 |
if (Object.keys(tab)?.length > 0) { |
| 271 |
return <div |
| 272 |
key={tab.type} |
| 273 |
className={`betterdocs-tab-items ${activeTab === tab.type ? 'active' : ''}`} |
| 274 |
onClick={() => setActiveTab(tab.type)} |
| 275 |
> |
| 276 |
{tab.icon && <span className="tab-icon">{tab.icon}</span>} |
| 277 |
<span>{tab.label}</span> |
| 278 |
</div> |
| 279 |
} |
| 280 |
})} |
| 281 |
</div> |
| 282 |
<div className="betterdocs-search-items-wrapper betterdocs-search-tab-content-wrapper"> |
| 283 |
{status === 'loading' ? ( |
| 284 |
<div className="betterdocs-search-skeleton" aria-busy="true" aria-live="polite"> |
| 285 |
{[0, 1, 2, 3, 4].map(i => ( |
| 286 |
<div className="betterdocs-search-skeleton-row" key={i}> |
| 287 |
<div className="skeleton-icon" /> |
| 288 |
<div className="skeleton-lines"> |
| 289 |
<div className="skeleton-line skeleton-line-title" /> |
| 290 |
<div className="skeleton-line skeleton-line-meta" /> |
| 291 |
</div> |
| 292 |
</div> |
| 293 |
))} |
| 294 |
</div> |
| 295 |
) : filterResults(activeTab).length > 0 ? ( |
| 296 |
filterResults(activeTab).map((result, index) => ( |
| 297 |
<div className="betterdocs-search-item-content" key={result.permalink || index}> |
| 298 |
<div className="betterdocs-search-item-list"> |
| 299 |
<div className="content-main"> |
| 300 |
<DocsIcon /> |
| 301 |
{result.permalink && result.post_type === 'docs' ? ( |
| 302 |
<a className="hasperma" href={result.permalink}> |
| 303 |
<h4>{decodeHtmlEntities(result.title)}</h4> |
| 304 |
</a> |
| 305 |
) : ( |
| 306 |
<h4>{decodeHtmlEntities(result.title)}</h4> |
| 307 |
)} |
| 308 |
</div> |
| 309 |
{result.post_type === 'betterdocs_faq' && ( |
| 310 |
<ExpandableContent content={result.content} wordLimit={30} /> |
| 311 |
)} |
| 312 |
{result.post_type === 'docs' && ( |
| 313 |
<div className="content-sub"> |
| 314 |
<FolderIcon /> |
| 315 |
<h5>{result.taxonomies && result.taxonomies.length > 0 ? decodeHtmlEntities(result.taxonomies) : 'No taxonomy'}</h5> |
| 316 |
</div> |
| 317 |
)} |
| 318 |
</div> |
| 319 |
</div> |
| 320 |
)) |
| 321 |
) : (status === 'empty' || status === 'error') && ( |
| 322 |
<div className="betterdocs-search-items-not-found"> |
| 323 |
<NoResultIcon /> |
| 324 |
<h4>{activeTab === 'docs' ? searchNotFoundText : __('No FAQ found for', 'betterdocs')} "{searchQuery}"</h4> |
| 325 |
</div> |
| 326 |
)} |
| 327 |
</div> |
| 328 |
</div> |
| 329 |
</div> |
| 330 |
</div> |
| 331 |
</div> |
| 332 |
); |
| 333 |
}; |
| 334 |
|
| 335 |
export default SearchModal; |
| 336 |
|