|
@@ -1,90 +1,202 @@
|
|
|
'use client';
|
|
'use client';
|
|
|
|
|
|
|
|
import './style.scss';
|
|
import './style.scss';
|
|
|
-import { useState, useEffect } from 'react';
|
|
|
|
|
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
|
|
|
|
|
|
+import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
|
|
|
+import { FaqCategory, FaqItem } from '@/types/response/page/faq';
|
|
|
|
|
+import { fetchApi } from '@/lib/utils/client';
|
|
|
|
|
+import { FaqCategoryResponse, FaqItemsResponse } from '@/types/response/page/faq';
|
|
|
import NavTab from '@/app/support/navTab';
|
|
import NavTab from '@/app/support/navTab';
|
|
|
-import Pagination from '@/app/component/Pagination';
|
|
|
|
|
|
|
+import Loading from '@/app/component/Loading';
|
|
|
|
|
+
|
|
|
|
|
+// 텍스트에서 키워드를 <mark>로 감싸서 highlight
|
|
|
|
|
+function highlightText(text: string, keyword: string): React.ReactNode {
|
|
|
|
|
+ if (!keyword.trim()) {
|
|
|
|
|
+ return text;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
+ const regex = new RegExp(`(${escaped})`, 'gi');
|
|
|
|
|
+ const parts = text.split(regex);
|
|
|
|
|
+
|
|
|
|
|
+ return parts.map((part, i) =>
|
|
|
|
|
+ regex.test(part) ? <mark key={i}>{part}</mark> : part
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// HTML 문자열에서 태그를 제거하고 텍스트만 추출
|
|
|
|
|
+function stripHtml(html: string): string {
|
|
|
|
|
+ return html.replace(/<[^>]*>/g, '');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// HTML 문자열 내에서 키워드를 highlight (태그 내부는 건드리지 않음)
|
|
|
|
|
+function highlightHtml(html: string, keyword: string): string {
|
|
|
|
|
+ if (!keyword.trim()) {
|
|
|
|
|
+ return html;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
+ const regex = new RegExp(`(${escaped})`, 'gi');
|
|
|
|
|
+
|
|
|
|
|
+ // 태그 외부의 텍스트에만 <mark> 적용
|
|
|
|
|
+ return html.replace(/(<[^>]*>)|([^<]+)/g, (match, tag, text) => {
|
|
|
|
|
+ if (tag) {
|
|
|
|
|
+ return tag;
|
|
|
|
|
+ }
|
|
|
|
|
+ return text.replace(regex, '<mark>$1</mark>');
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
export default function View() {
|
|
export default function View() {
|
|
|
- const [error, setError] = useState<string>('');
|
|
|
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
|
-
|
|
|
|
|
- const [type, setType] = useState<string|null>(null);
|
|
|
|
|
|
|
+ const [categories, setCategories] = useState<FaqCategory[]>([]);
|
|
|
|
|
+ const [items, setItems] = useState<FaqItem[]>([]);
|
|
|
|
|
+ const [activeCategory, setActiveCategory] = useState<string>('');
|
|
|
const [keyword, setKeyword] = useState<string>('');
|
|
const [keyword, setKeyword] = useState<string>('');
|
|
|
- const [total, setTotal] = useState<number>(0);
|
|
|
|
|
- const [page, setPage] = useState<number>(1);
|
|
|
|
|
- // const [logs, setLogs] = useState<LoginLog[]>([]);
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const [searchKeyword, setSearchKeyword] = useState<string>('');
|
|
|
|
|
|
|
|
|
|
+ // 초기 데이터 로드
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
-
|
|
|
|
|
|
|
+ Promise.all([
|
|
|
|
|
+ fetchApi<FaqCategoryResponse>('/api/faq/categories'),
|
|
|
|
|
+ fetchApi<FaqItemsResponse>('/api/faq/items', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ body: { Code: '' }
|
|
|
|
|
+ }),
|
|
|
|
|
+ ]).then(([catRes, itemRes]) => {
|
|
|
|
|
+ if (catRes.success && catRes.data) {
|
|
|
|
|
+ setCategories(catRes.data.list ?? []);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (itemRes.success && itemRes.data) {
|
|
|
|
|
+ setItems(itemRes.data.list ?? []);
|
|
|
|
|
+ }
|
|
|
|
|
+ }).finally(() => {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ });
|
|
|
}, []);
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
+ const handleCategoryClick = useCallback((code: string) => {
|
|
|
|
|
+ setActiveCategory(code);
|
|
|
|
|
+ }, []);
|
|
|
|
|
|
|
|
- const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
|
|
|
|
|
+ const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
- setLoading(true);
|
|
|
|
|
|
|
+ setSearchKeyword(keyword);
|
|
|
|
|
+ setActiveCategory('');
|
|
|
|
|
+ }, [keyword]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleReset = useCallback(() => {
|
|
|
|
|
+ setKeyword('');
|
|
|
|
|
+ setSearchKeyword('');
|
|
|
|
|
+ setActiveCategory('');
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ // 필터링된 FAQ 항목
|
|
|
|
|
+ const filteredItems = useMemo(() => {
|
|
|
|
|
+ let filtered = items;
|
|
|
|
|
|
|
|
- // const formData = new FormData(e.currentTarget);
|
|
|
|
|
- // const data = Object.fromEntries(formData.entries()) as LoginLog;
|
|
|
|
|
|
|
+ // 카테고리 필터
|
|
|
|
|
+ if (activeCategory) {
|
|
|
|
|
+ filtered = filtered.filter(item => item.categoryCode === activeCategory);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ // 키워드 필터 (질문 + 답변 내용에서 검색)
|
|
|
|
|
+ if (searchKeyword.trim()) {
|
|
|
|
|
+ const lowerKeyword = searchKeyword.toLowerCase();
|
|
|
|
|
+ filtered = filtered.filter(item => {
|
|
|
|
|
+ const questionMatch = item.question.toLowerCase().includes(lowerKeyword);
|
|
|
|
|
+ const answerMatch = item.answer ? stripHtml(item.answer).toLowerCase().includes(lowerKeyword) : false;
|
|
|
|
|
+ return questionMatch || answerMatch;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return filtered;
|
|
|
|
|
+ }, [items, activeCategory, searchKeyword]);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
<NavTab currentTab='faq' />
|
|
<NavTab currentTab='faq' />
|
|
|
|
|
|
|
|
- {/* 자주 묻는 질문(FAQ) */}
|
|
|
|
|
<section id='faq'>
|
|
<section id='faq'>
|
|
|
<p>자주 묻는 질문</p>
|
|
<p>자주 묻는 질문</p>
|
|
|
|
|
|
|
|
- <form onSubmit={(e) => handleSubmit(e)} autoComplete='off'>
|
|
|
|
|
|
|
+ {loading && <Loading />}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 검색 */}
|
|
|
|
|
+ <form onSubmit={handleSubmit} autoComplete='off'>
|
|
|
<dl>
|
|
<dl>
|
|
|
<dt>
|
|
<dt>
|
|
|
- <input type='search' value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder='궁금한 점을 검색해 보세요.'/>
|
|
|
|
|
|
|
+ <input
|
|
|
|
|
+ type='search'
|
|
|
|
|
+ value={keyword}
|
|
|
|
|
+ onChange={(e) => setKeyword(e.target.value)}
|
|
|
|
|
+ placeholder='궁금한 점을 검색해 보세요.'
|
|
|
|
|
+ />
|
|
|
<button type='submit' className='btn btn-default'>검색</button>
|
|
<button type='submit' className='btn btn-default'>검색</button>
|
|
|
|
|
+ {searchKeyword && (
|
|
|
|
|
+ <button type='button' className='btn btn-default' onClick={handleReset}>초기화</button>
|
|
|
|
|
+ )}
|
|
|
</dt>
|
|
</dt>
|
|
|
<dd>
|
|
<dd>
|
|
|
<ul>
|
|
<ul>
|
|
|
- <li className='active'>
|
|
|
|
|
- <button type='button' onClick={() => setType('all')}>전체</button>
|
|
|
|
|
- </li>
|
|
|
|
|
- <li>
|
|
|
|
|
- <button type='button' onClick={() => setType('general')}>일반</button>
|
|
|
|
|
- </li>
|
|
|
|
|
- <li>
|
|
|
|
|
- <button type='button' onClick={() => setType('account')}>계정</button>
|
|
|
|
|
- </li>
|
|
|
|
|
- <li>
|
|
|
|
|
- <button type='button' onClick={() => setType('payment')}>결제</button>
|
|
|
|
|
- </li>
|
|
|
|
|
- <li>
|
|
|
|
|
- <button type='button' onClick={() => setType('security')}>보안</button>
|
|
|
|
|
|
|
+ <li className={!activeCategory ? 'active' : ''}>
|
|
|
|
|
+ <button type='button' onClick={() => handleCategoryClick('')}>전체</button>
|
|
|
</li>
|
|
</li>
|
|
|
|
|
+ {categories.map((cat) => (
|
|
|
|
|
+ <li key={cat.id} className={activeCategory === cat.code ? 'active' : ''}>
|
|
|
|
|
+ <button type='button' onClick={() => handleCategoryClick(cat.code)}>
|
|
|
|
|
+ {cat.subject}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </li>
|
|
|
|
|
+ ))}
|
|
|
</ul>
|
|
</ul>
|
|
|
</dd>
|
|
</dd>
|
|
|
</dl>
|
|
</dl>
|
|
|
</form>
|
|
</form>
|
|
|
|
|
|
|
|
- <br/>
|
|
|
|
|
- <hr />
|
|
|
|
|
-
|
|
|
|
|
- <Accordion id='questions' type='single' collapsible>
|
|
|
|
|
- <AccordionItem value='item-1'>
|
|
|
|
|
- <AccordionTrigger>Q. ASDASDASD</AccordionTrigger>
|
|
|
|
|
- <AccordionContent>
|
|
|
|
|
- A. Yes. It adheres to the WAI-ARIA design pattern.
|
|
|
|
|
- </AccordionContent>
|
|
|
|
|
- </AccordionItem>
|
|
|
|
|
- </Accordion>
|
|
|
|
|
-
|
|
|
|
|
<br />
|
|
<br />
|
|
|
|
|
+ <hr />
|
|
|
|
|
|
|
|
- <article className='pagination'>
|
|
|
|
|
- <Pagination total={total} page={page} onPageChange={setPage} />
|
|
|
|
|
- </article>
|
|
|
|
|
|
|
+ {/* 검색 결과 안내 */}
|
|
|
|
|
+ {searchKeyword && (
|
|
|
|
|
+ <p className='search-result'>
|
|
|
|
|
+ <strong>"{searchKeyword}"</strong> 검색 결과 <strong>{filteredItems.length}</strong>건
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* FAQ 목록 */}
|
|
|
|
|
+ {!loading && (
|
|
|
|
|
+ <div id='questions'>
|
|
|
|
|
+ {filteredItems.length > 0 ? (
|
|
|
|
|
+ filteredItems.map((item) => (
|
|
|
|
|
+ <details key={item.id} className='faq-item'>
|
|
|
|
|
+ <summary>
|
|
|
|
|
+ <span className='faq-category'>{item.categorySubject}</span>
|
|
|
|
|
+ <span className='faq-question'>
|
|
|
|
|
+ {highlightText(item.question, searchKeyword)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </summary>
|
|
|
|
|
+ <div className='faq-answer'>
|
|
|
|
|
+ {item.answer ? (
|
|
|
|
|
+ <div dangerouslySetInnerHTML={{
|
|
|
|
|
+ __html: highlightHtml(item.answer, searchKeyword)
|
|
|
|
|
+ }} />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <p className='no-answer'>답변이 준비 중입니다.</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </details>
|
|
|
|
|
+ ))
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className='no-results'>
|
|
|
|
|
+ {searchKeyword
|
|
|
|
|
+ ? <p>검색 결과가 없습니다.</p>
|
|
|
|
|
+ : <p>등록된 FAQ가 없습니다.</p>
|
|
|
|
|
+ }
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</section>
|
|
</section>
|
|
|
</>
|
|
</>
|
|
|
);
|
|
);
|
|
|
-}
|
|
|
|
|
|
|
+}
|