view.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. 'use client';
  2. import './style.scss';
  3. import { useState, useEffect, useMemo, useCallback } from 'react';
  4. import { FaqCategory, FaqItem } from '@/types/response/page/faq';
  5. import { fetchApi } from '@/lib/utils/client';
  6. import { FaqCategoryResponse, FaqItemsResponse } from '@/types/response/page/faq';
  7. import NavTab from '@/app/(main)/support/navTab';
  8. import Loading from '@/app/component/Loading';
  9. // 텍스트에서 키워드를 <mark>로 감싸서 highlight
  10. function highlightText(text: string, keyword: string): React.ReactNode {
  11. if (!keyword.trim()) {
  12. return text;
  13. }
  14. const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  15. const regex = new RegExp(`(${escaped})`, 'gi');
  16. const parts = text.split(regex);
  17. return parts.map((part, i) =>
  18. regex.test(part) ? <mark key={i}>{part}</mark> : part
  19. );
  20. }
  21. // HTML 문자열에서 태그를 제거하고 텍스트만 추출
  22. function stripHtml(html: string): string {
  23. return html.replace(/<[^>]*>/g, '');
  24. }
  25. // HTML 문자열 내에서 키워드를 highlight (태그 내부는 건드리지 않음)
  26. function highlightHtml(html: string, keyword: string): string {
  27. if (!keyword.trim()) {
  28. return html;
  29. }
  30. const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  31. const regex = new RegExp(`(${escaped})`, 'gi');
  32. // 태그 외부의 텍스트에만 <mark> 적용
  33. return html.replace(/(<[^>]*>)|([^<]+)/g, (_match, tag, text) => {
  34. if (tag) {
  35. return tag;
  36. }
  37. return text.replace(regex, '<mark>$1</mark>');
  38. });
  39. }
  40. export default function View() {
  41. const [loading, setLoading] = useState<boolean>(true);
  42. const [categories, setCategories] = useState<FaqCategory[]>([]);
  43. const [items, setItems] = useState<FaqItem[]>([]);
  44. const [activeCategory, setActiveCategory] = useState<string>('');
  45. const [keyword, setKeyword] = useState<string>('');
  46. const [searchKeyword, setSearchKeyword] = useState<string>('');
  47. // 초기 데이터 로드
  48. useEffect(() => {
  49. Promise.all([
  50. fetchApi<FaqCategoryResponse>('/api/faq/categories'),
  51. fetchApi<FaqItemsResponse>('/api/faq/items', {
  52. method: 'POST',
  53. body: { Code: '' }
  54. }),
  55. ]).then(([catRes, itemRes]) => {
  56. if (catRes.success && catRes.data) {
  57. setCategories(catRes.data.list ?? []);
  58. }
  59. if (itemRes.success && itemRes.data) {
  60. setItems(itemRes.data.list ?? []);
  61. }
  62. }).finally(() => {
  63. setLoading(false);
  64. });
  65. }, []);
  66. const handleCategoryClick = useCallback((code: string) => {
  67. setActiveCategory(code);
  68. }, []);
  69. const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
  70. e.preventDefault();
  71. setSearchKeyword(keyword);
  72. setActiveCategory('');
  73. }, [keyword]);
  74. const handleReset = useCallback(() => {
  75. setKeyword('');
  76. setSearchKeyword('');
  77. setActiveCategory('');
  78. }, []);
  79. // 필터링된 FAQ 항목
  80. const filteredItems = useMemo(() => {
  81. let filtered = items;
  82. // 카테고리 필터
  83. if (activeCategory) {
  84. filtered = filtered.filter(item => item.categoryCode === activeCategory);
  85. }
  86. // 키워드 필터 (질문 + 답변 내용에서 검색)
  87. if (searchKeyword.trim()) {
  88. const lowerKeyword = searchKeyword.toLowerCase();
  89. filtered = filtered.filter(item => {
  90. const questionMatch = item.question.toLowerCase().includes(lowerKeyword);
  91. const answerMatch = item.answer ? stripHtml(item.answer).toLowerCase().includes(lowerKeyword) : false;
  92. return questionMatch || answerMatch;
  93. });
  94. }
  95. return filtered;
  96. }, [items, activeCategory, searchKeyword]);
  97. return (
  98. <>
  99. <NavTab currentTab='faq' />
  100. <section id='faq'>
  101. <p>자주 묻는 질문</p>
  102. {loading && <Loading />}
  103. {/* 검색 */}
  104. <form onSubmit={handleSubmit} autoComplete='off'>
  105. <dl>
  106. <dt>
  107. <input
  108. type='search'
  109. value={keyword}
  110. onChange={(e) => setKeyword(e.target.value)}
  111. placeholder='궁금한 점을 검색해 보세요.'
  112. />
  113. <button type='submit' className='btn btn-default'>검색</button>
  114. {searchKeyword && (
  115. <button type='button' className='btn btn-default' onClick={handleReset}>초기화</button>
  116. )}
  117. </dt>
  118. <dd>
  119. <ul>
  120. <li className={!activeCategory ? 'active' : ''}>
  121. <button type='button' onClick={() => handleCategoryClick('')}>전체</button>
  122. </li>
  123. {categories.map((cat) => (
  124. <li key={cat.id} className={activeCategory === cat.code ? 'active' : ''}>
  125. <button type='button' onClick={() => handleCategoryClick(cat.code)}>
  126. {cat.subject}
  127. </button>
  128. </li>
  129. ))}
  130. </ul>
  131. </dd>
  132. </dl>
  133. </form>
  134. <br />
  135. <hr />
  136. {/* 검색 결과 안내 */}
  137. {searchKeyword && (
  138. <p className='search-result'>
  139. <strong>&quot;{searchKeyword}&quot;</strong> 검색 결과 <strong>{filteredItems.length}</strong>건
  140. </p>
  141. )}
  142. {/* FAQ 목록 */}
  143. {!loading && (
  144. <div id='questions'>
  145. {filteredItems.length > 0 ? (
  146. filteredItems.map((item) => (
  147. <details key={item.id} className='faq-item'>
  148. <summary>
  149. <span className='faq-category'>{item.categorySubject}</span>
  150. <span className='faq-question'>
  151. {highlightText(item.question, searchKeyword)}
  152. </span>
  153. </summary>
  154. <div className='faq-answer'>
  155. {item.answer ? (
  156. <div dangerouslySetInnerHTML={{
  157. __html: highlightHtml(item.answer, searchKeyword)
  158. }} />
  159. ) : (
  160. <p className='no-answer'>답변이 준비 중입니다.</p>
  161. )}
  162. </div>
  163. </details>
  164. ))
  165. ) : (
  166. <div className='no-results'>
  167. {searchKeyword
  168. ? <p>검색 결과가 없습니다.</p>
  169. : <p>등록된 FAQ가 없습니다.</p>
  170. }
  171. </div>
  172. )}
  173. </div>
  174. )}
  175. </section>
  176. </>
  177. );
  178. }