KIM-JINO5 hai 2 meses
pai
achega
da9d4e46ea

+ 4 - 0
app/(forum)/board/[code]/view.tsx

@@ -14,6 +14,7 @@ import { BoardResponse, BoardPostsResponse } from '@/types/response/forum/board'
 import Post from '@/types/forum/post';
 import useDragScroll from '@/hooks/useDragScroll';
 import PostWriteButton from '../_component/PostWriteButton';
+import NavTab from '@/app/support/navTab';
 import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
 import HeaderContent from '../_component/HeaderContent';
 import FooterContent from '../_component/FooterContent';
@@ -183,6 +184,8 @@ export default function View({ _query, _board, _postList }: ViewProps)
 	}, [page, perPage, boardPrefixID, sort, handleFetchPosts]);
 
 	return (
+		<>
+		{_board.code === 'notice' && <NavTab currentTab='notice' />}
 		<div id='board'>
 			{loading && <Loading />}
 
@@ -295,5 +298,6 @@ export default function View({ _query, _board, _postList }: ViewProps)
 
 			<FooterContent isEnabled={_board.boardMeta.list.showFooter} content={_board.boardMeta.list.footerContent } />
 		</div>
+		</>
 	);
 }

+ 28 - 0
app/api/faq/[...path]/route.ts

@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/faq/${path.join('/')}`;
+	const url = new URL(request.url);
+
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, {
+		method: 'GET'
+	});
+
+	return NextResponse.json(res);
+}
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/faq/${path.join('/')}`;
+
+	const res: ResultDto = await fetchJson(endpoint, {
+		method: 'POST',
+		body: await request.arrayBuffer(),
+		headers: { 'Content-Type': request.headers.get('content-type') || '' }
+	});
+
+	return NextResponse.json(res);
+}

+ 16 - 0
app/api/popup/[...path]/route.ts

@@ -0,0 +1,16 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/${path.join('/')}`;
+
+	const res: ResultDto = await fetchJson(endpoint, {
+		method: 'POST',
+		body: await request.arrayBuffer(),
+		headers: { 'Content-Type': request.headers.get('content-type') || '' }
+	});
+
+	return NextResponse.json(res);
+}

+ 1 - 1
app/component/Layout.tsx

@@ -43,7 +43,7 @@ export default function Layout({ children }: { children: React.ReactNode })
                             </li>
                             <li>
                                 <Link href='/board/notice'>
-                                    공지사항
+                                    고객지원
                                 </Link>
                             </li>
                         </ul>

+ 204 - 0
app/component/PopupModal.scss

@@ -0,0 +1,204 @@
+// 팝업 모달 오버레이
+.popup-modal-overlay {
+	position: fixed;
+	inset: 0;
+	z-index: 9999;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	background: rgba(0, 0, 0, 0.5);
+	backdrop-filter: blur(4px);
+	-webkit-backdrop-filter: blur(4px);
+	animation: popupFadeIn 0.2s ease-out;
+}
+
+// 팝업 컨테이너
+.popup-modal-container {
+	position: relative;
+	width: 90%;
+	max-width: 500px;
+	max-height: 80vh;
+	background: #fff;
+	border-radius: 12px;
+	overflow: hidden;
+	box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+	animation: popupSlideUp 0.25s ease-out;
+}
+
+// 팝업 본문
+.popup-modal-body {
+	max-height: calc(80vh - 50px);
+	overflow-y: auto;
+
+	// Swiper 커스텀 스타일
+	.swiper {
+		width: 100%;
+	}
+
+	.swiper-button-prev,
+	.swiper-button-next {
+		color: #fff;
+		background: rgba(0, 0, 0, 0.3);
+		width: 36px;
+		height: 36px;
+		border-radius: 50%;
+		transition: background 0.2s;
+
+		&:hover {
+			background: rgba(0, 0, 0, 0.5);
+		}
+
+		&::after {
+			font-size: 14px;
+			font-weight: bold;
+		}
+	}
+
+	.swiper-pagination-bullet {
+		background: #fff;
+		opacity: 0.5;
+
+		&-active {
+			opacity: 1;
+			background: var(--color-primary-600, #2563eb);
+		}
+	}
+}
+
+// 팝업 슬라이드
+.popup-slide {
+	padding: 24px;
+}
+
+.popup-slide-subject {
+	font-size: 1.15rem;
+	font-weight: 700;
+	margin-bottom: 12px;
+	color: #111;
+	line-height: 1.4;
+}
+
+.popup-slide-content {
+	font-size: 0.95rem;
+	line-height: 1.7;
+	color: #333;
+	word-break: keep-all;
+
+	img {
+		max-width: 100%;
+		height: auto;
+		border-radius: 8px;
+	}
+
+	a {
+		color: var(--color-primary-600, #2563eb);
+		text-decoration: underline;
+	}
+}
+
+.popup-slide-link {
+	display: block;
+	text-decoration: none;
+	color: inherit;
+	cursor: pointer;
+
+	&:hover .popup-slide-content {
+		opacity: 0.85;
+	}
+}
+
+// 하단 - 하루동안 보지않기 + 닫기
+.popup-modal-footer {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding: 10px 16px;
+	border-top: 1px solid #eee;
+	background: #f9f9f9;
+}
+
+.popup-modal-dismiss {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+	cursor: pointer;
+	user-select: none;
+
+	input[type='checkbox'] {
+		width: 16px;
+		height: 16px;
+		accent-color: var(--color-primary-600, #2563eb);
+		cursor: pointer;
+	}
+
+	span {
+		font-size: 0.85rem;
+		color: #666;
+	}
+}
+
+.popup-modal-close {
+	padding: 6px 16px;
+	font-size: 0.85rem;
+	font-weight: 600;
+	color: #555;
+	background: #e5e5e5;
+	border: none;
+	border-radius: 6px;
+	cursor: pointer;
+	transition: background 0.15s;
+
+	&:hover {
+		background: #d4d4d4;
+	}
+}
+
+// 애니메이션
+@keyframes popupFadeIn {
+	from {
+		opacity: 0;
+	}
+	to {
+		opacity: 1;
+	}
+}
+
+@keyframes popupSlideUp {
+	from {
+		opacity: 0;
+		transform: translateY(20px);
+	}
+	to {
+		opacity: 1;
+		transform: translateY(0);
+	}
+}
+
+// 반응형
+@media (max-width: 480px) {
+	.popup-modal-container {
+		width: 95%;
+		max-height: 85vh;
+		border-radius: 10px;
+	}
+
+	.popup-slide {
+		padding: 16px;
+	}
+
+	.popup-slide-subject {
+		font-size: 1.05rem;
+	}
+
+	.popup-modal-body {
+		.swiper-button-prev,
+		.swiper-button-next {
+			width: 30px;
+			height: 30px;
+
+			&::after {
+				font-size: 12px;
+			}
+		}
+	}
+}

+ 168 - 0
app/component/PopupModal.tsx

@@ -0,0 +1,168 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { Swiper, SwiperSlide } from 'swiper/react';
+import { Navigation, Pagination } from 'swiper/modules';
+import { PopupItem, PopupResponse } from '@/types/response/page/popup';
+import { fetchApi } from '@/lib/utils/client';
+
+import 'swiper/css';
+import 'swiper/css/navigation';
+import 'swiper/css/pagination';
+import './PopupModal.scss';
+
+interface PopupModalProps {
+	position: string;
+}
+
+// localStorage 키 생성
+function getDismissKey(position: string): string {
+	return `popup_dismiss_${position}`;
+}
+
+// 24시간 이내에 dismiss 했는지 확인
+function isDismissed(position: string): boolean {
+	if (typeof window === 'undefined') {
+		return false;
+	}
+
+	const dismissed = localStorage.getItem(getDismissKey(position));
+	if (!dismissed) {
+		return false;
+	}
+
+	const dismissedAt = parseInt(dismissed, 10);
+	const now = Date.now();
+	const hours24 = 24 * 60 * 60 * 1000;
+
+	return (now - dismissedAt) < hours24;
+}
+
+// 유효한 팝업인지 확인 (isActive + 날짜 범위)
+function isValidPopup(item: PopupItem): boolean {
+	if (!item.isActive) {
+		return false;
+	}
+
+	const now = new Date();
+
+	if (item.startAt && new Date(item.startAt) > now) {
+		return false;
+	}
+
+	if (item.endAt && new Date(item.endAt) < now) {
+		return false;
+	}
+
+	return true;
+}
+
+export default function PopupModal({ position }: PopupModalProps) {
+	const [open, setOpen] = useState<boolean>(false);
+	const [items, setItems] = useState<PopupItem[]>([]);
+	const [dismissToday, setDismissToday] = useState<boolean>(false);
+
+	useEffect(() => {
+		// 이미 dismiss 되었으면 fetch하지 않음
+		if (isDismissed(position)) {
+			return;
+		}
+
+		fetchApi<PopupResponse>('/api/popup/popups', {
+			method: 'POST',
+			body: { Code: position }
+		}).then((res) => {
+			if (res.success && res.data) {
+				const validItems = (res.data.list ?? []).filter(isValidPopup);
+				if (validItems.length > 0) {
+					setItems(validItems);
+					setOpen(true);
+				}
+			}
+		});
+	}, [position]);
+
+	const handleClose = useCallback(() => {
+		if (dismissToday) {
+			localStorage.setItem(getDismissKey(position), Date.now().toString());
+		}
+		setOpen(false);
+	}, [dismissToday, position]);
+
+	// 팝업이 없거나 닫혔으면 렌더링하지 않음
+	if (!open || items.length === 0) {
+		return null;
+	}
+
+	return (
+		<div className='popup-modal-overlay' onClick={handleClose}>
+			<div className='popup-modal-container' onClick={(e) => e.stopPropagation()}>
+				{/* 팝업 본문 */}
+				<div className='popup-modal-body'>
+					{items.length === 1 ? (
+						// 단일 팝업
+						<PopupSlide item={items[0]} />
+					) : (
+						// 다중 팝업 - Swiper 슬라이드
+						<Swiper
+							modules={[Navigation, Pagination]}
+							navigation
+							pagination={{ clickable: true }}
+							loop={items.length > 1}
+							spaceBetween={0}
+							slidesPerView={1}
+						>
+							{items.map((item) => (
+								<SwiperSlide key={item.id}>
+									<PopupSlide item={item} />
+								</SwiperSlide>
+							))}
+						</Swiper>
+					)}
+				</div>
+
+				{/* 하단 - 하루 동안 보지 않기 + 닫기 */}
+				<div className='popup-modal-footer'>
+					<label className='popup-modal-dismiss'>
+						<input
+							type='checkbox'
+							checked={dismissToday}
+							onChange={(e) => setDismissToday(e.target.checked)}
+						/>
+						<span>하루 동안 보지 않기</span>
+					</label>
+					<button type='button' className='popup-modal-close' onClick={handleClose}>
+						닫기
+					</button>
+				</div>
+			</div>
+		</div>
+	);
+}
+
+// 개별 팝업 슬라이드
+function PopupSlide({ item }: { item: PopupItem }) {
+	const content = (
+		<div className='popup-slide'>
+			{item.subject && (
+				<h3 className='popup-slide-subject'>{item.subject}</h3>
+			)}
+			{item.content && (
+				<div
+					className='popup-slide-content'
+					dangerouslySetInnerHTML={{ __html: item.content }}
+				/>
+			)}
+		</div>
+	);
+
+	if (item.link) {
+		return (
+			<a href={item.link} target='_blank' rel='noopener noreferrer' className='popup-slide-link'>
+				{content}
+			</a>
+		);
+	}
+
+	return content;
+}

+ 28 - 0
app/docs/[code]/page.tsx

@@ -0,0 +1,28 @@
+'use server';
+
+import './style.scss';
+import { notFound } from 'next/navigation';
+import { fetchDocument } from '@/lib/api/page/document';
+
+export default async function DocumentPage({ params }: { params: Promise<{ code: string }> }) {
+	const { code } = await params;
+
+	const result = await fetchDocument(code);
+
+	if (!result.success || !result.data) {
+		return notFound();
+	}
+
+	const doc = result.data;
+
+	return (
+		<>
+			<article id='docs'>
+				<h1>{doc.subject}</h1>
+				{doc.content && (
+					<section dangerouslySetInnerHTML={{ __html: doc.content }} className='pb-10'/>
+				)}
+			</article>
+		</>
+	);
+}

+ 20 - 0
app/docs/[code]/style.scss

@@ -0,0 +1,20 @@
+#docs {
+	padding: 1.5625rem 2rem 2rem 2rem;
+	height: 100%;
+	min-width: 25.75rem;
+	max-width: 80rem;
+	margin: 0 auto;
+
+	h1 {
+		font-size: 1.375rem;
+		margin-bottom: 1.25rem;
+	}
+}
+
+@media (max-width: 576px) {
+	main {
+		#docs {
+			padding: 15px 10px 22px 10px;
+		}
+	}
+}

+ 9 - 0
app/docs/layout.tsx

@@ -0,0 +1,9 @@
+'use client';
+
+import Layout from "@/app/component/Layout";
+
+export default function DocsLayout({ children }: { children: React.ReactNode }) {
+    return (
+		<Layout>{children}</Layout>
+	);
+}

+ 5 - 0
app/docs/page.tsx

@@ -0,0 +1,5 @@
+import { notFound } from 'next/navigation';
+
+export default function DocsIndex() {
+	return notFound();
+}

+ 1 - 1
app/globals.scss

@@ -170,7 +170,7 @@ select, input, textarea {
 .btn-submit {
     color: #fff;
     background: #F7931A;
-	border: 1px solid #b96606;
+	border: 1px solid #f1880f;
     -webkit-box-shadow: inset 0 -2px 0 0 #d38817;
     box-shadow: inset 0 -2px 0 0 #d38817;
 

+ 16 - 11
app/layout.tsx

@@ -7,6 +7,7 @@ import { AuthProvider } from "@/contexts/authProvider";
 import { MemberProvider } from "@/contexts/memberProvider";
 import { ConfigProvider } from "@/contexts/configProvider";
 import { getAccessToken, getSignalRCryptoUrl, getSignalRChatUrl } from "@/lib/utils/server";
+import { fetchConfig } from "@/lib/api/system";
 
 const geistSans = Geist({
     variable: "--font-geist-sans",
@@ -18,16 +19,19 @@ const geistMono = Geist_Mono({
     subsets: ["latin"],
 });
 
-export const metadata: Metadata = {
-    title: "bitforum",
-    description: "Generated by create next app",
-    keywords: "nextjs, typescript, tailwindcss",
-    robots: {
-        index: true,
-        follow: true,
-        nocache: true
-    }
-};
+export async function generateMetadata(): Promise<Metadata> {
+    const config = (await fetchConfig())?.data;
+
+    return {
+        title: config?.basic?.siteName ?? 'bitforum',
+        description: config?.meta?.description ?? '',
+        keywords: config?.meta?.keywords ?? '',
+        authors: config?.meta?.author ? [{ name: config.meta.author }] : undefined,
+        applicationName: config?.meta.applicationName,
+        generator: config?.meta.generator,
+        robots: config?.meta.robots
+    };
+}
 
 export default async function RootLayout({
     children,
@@ -38,6 +42,7 @@ export default async function RootLayout({
 	const accessToken = await getAccessToken();
 	const signalRCryptoUrl = await getSignalRCryptoUrl();
 	const signalRChatUrl = await getSignalRChatUrl();
+    const config = (await fetchConfig())?.data;
 
     return (
         <html lang="ko">
@@ -45,7 +50,7 @@ export default async function RootLayout({
                 <SignalRProvider accessToken={accessToken} signalRCryptoUrl={signalRCryptoUrl} signalRChatUrl={signalRChatUrl}>
                     <AuthProvider>
                         <MemberProvider>
-                            <ConfigProvider>
+                            <ConfigProvider initialConfig={config}>
                                 {children}
                             </ConfigProvider>
                         </MemberProvider>

+ 2 - 0
app/page.tsx

@@ -1,9 +1,11 @@
 import Image from "next/image";
 import Layout from "@/app/component/Layout";
+import PopupModal from "@/app/component/PopupModal";
 
 export default function Home() {
     return (
         <Layout>
+        <PopupModal position='main' />
         <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
             <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
                 <Image

+ 2 - 5
app/support/contact/page.tsx

@@ -38,11 +38,8 @@ export default function View() {
 				<div className='bg-primary-100 rounded-xl text-start'>
 					<h3 className='text-lg font-semibold text-primary-800 mb-2'>지금 바로 제휴를 시작해보세요!</h3>
 					<p className='text-sm text-gray-600 mb-3'>궁금하신 점이 있다면 언제든 연락 주세요. 빠르게 답변드리겠습니다.</p>
-					<a
-						href='mailto:contact@bitforum.io'
-						className='inline-block mt-4 px-6 py-3 bg-orange-500 border border-[#b17200] text-white font-semibold rounded-md hover:bg-orange-600 transition'
-					>
-						contact@bitforum.io 로 제휴 문의하기
+					<a href='mailto:contact@bitforum.io' className='btn btn-submit mt-4 px-6 py-3 transition'>
+						제휴 문의
 					</a>
 				</div>
 			</section>

+ 4 - 4
app/support/contact/style.scss

@@ -1,13 +1,13 @@
 // 제휴 문의
 #contact {
-	padding: 0 32px 32px 32px;
+	padding: 24px 32px 32px 32px;
 	min-width: 410px;
 	max-width: 1920px;
 	margin: 0 auto;
 
 	> p {
-		font-weight: 600;
-		font-size: 22px;
-		margin-bottom: 20px;
+		font-weight: 500;
+		font-size: 1.375rem;
+		margin-bottom: 1.25rem;
 	}
 }

+ 1 - 5
app/support/faq/page.tsx

@@ -1,10 +1,6 @@
-'use server';
-
 import View from './view';
 
-export default async function FAQ() {
-	// 여기서 FAQ 목록을 호출
-
+export default function FAQ() {
 	return (
 		<View />
 	);

+ 189 - 13
app/support/faq/style.scss

@@ -1,14 +1,14 @@
 // FAQ
 #faq {
-	padding: 0 32px 32px 32px;
+	padding: 24px 32px 32px 32px;
 	min-width: 410px;
 	max-width: 1920px;
 	margin: 0 auto;
 
 	> p {
-		font-weight: 600;
-		font-size: 22px;
-		margin-bottom: 20px;
+		font-weight: 500;
+		font-size: 1.375rem;
+		margin-bottom: 1.25rem;
 	}
 
 	> form {
@@ -20,7 +20,7 @@
 				justify-content: flex-start;
 				align-items: center;
 				gap: 0.625rem;
-				
+
 				> input {
 					flex-grow: 0.08;
 					flex-basis: auto;
@@ -39,14 +39,20 @@
 					flex-direction: row;
 					flex-wrap: wrap;
 					column-gap: 14px;
+					row-gap: 8px;
 					list-style: none;
 
 					> li {
 						font-size: 16px;
 
+						&.active > button {
+							color: #e47911;
+							font-weight: 600;
+						}
+
 						> button {
 							cursor: pointer;
-							
+
 							&:hover {
 								color: #e47911;
 								background-color: var(--color-primary-100);
@@ -54,7 +60,7 @@
 							}
 						}
 
-						// 가운데 점 대신 1px짜리 수직선
+						// 수직선 구분
 						&::after {
 							content: '';
 							display: inline-block;
@@ -63,10 +69,9 @@
 							background-color: #858585;
 							margin-left: 15px;
 							position: relative;
-							top: 2px; // 글씨 정렬용 (필요시 조정)
+							top: 2px;
 						}
 
-						// 마지막 li는 점 숨김
 						&:last-child::after {
 							display: none;
 						}
@@ -76,11 +81,182 @@
 		}
 	}
 
+	// 검색 결과 안내
+	> .search-result {
+		font-size: 14px;
+		margin: 16px 0 8px;
+		font-weight: 400;
+	}
+
+	// FAQ 목록
 	> #questions {
-		> div > h3 > button {
-			&:hover, &:active {
-				text-decoration: underline;
+		margin-top: 16px;
+
+		> .faq-item {
+			border-bottom: 1px solid var(--color-border, #e5e5e5);
+
+			> summary {
+				display: flex;
+				align-items: center;
+				gap: 12px;
+				padding: 16px 4px;
+				cursor: pointer;
+				list-style: none;
+				user-select: none;
+				transition: background-color 0.15s;
+
+				&::-webkit-details-marker {
+					display: none;
+				}
+
+				&::before {
+					content: 'Q.';
+					font-weight: 700;
+					color: #e47911;
+					flex-shrink: 0;
+				}
+
+				&::after {
+					content: '';
+					display: inline-block;
+					width: 8px;
+					height: 8px;
+					border-right: 2px solid #999;
+					border-bottom: 2px solid #999;
+					transform: rotate(45deg);
+					transition: transform 0.2s ease;
+					flex-shrink: 0;
+					margin-left: auto;
+				}
+
+				&:hover {
+					background-color: var(--color-primary-50, #f9f9f9);
+				}
+
+				> .faq-category {
+					font-size: 13px;
+					color: #666;
+					background-color: var(--color-primary-100, #f0f0f0);
+					padding: 2px 8px;
+					border-radius: 4px;
+					flex-shrink: 0;
+					white-space: nowrap;
+				}
+
+				> .faq-question {
+					font-size: 15px;
+					font-weight: 500;
+					flex-grow: 1;
+				}
+			}
+
+			&[open] > summary {
+				&::after {
+					transform: rotate(-135deg);
+				}
+			}
+
+			> .faq-answer {
+				padding: 12px 4px 20px 32px;
+				font-size: 14px;
+				line-height: 1.7;
+				color: #444;
+
+				&::before {
+					content: 'A.';
+					font-weight: 700;
+					color: #3b82f6;
+					margin-right: 8px;
+				}
+
+				> .no-answer {
+					color: #999;
+					font-style: italic;
+				}
+			}
+		}
+
+		// 결과 없음
+		> .no-results {
+			padding: 48px 0;
+			text-align: center;
+			color: #999;
+			font-size: 15px;
+		}
+	}
+
+	// highlight
+	mark {
+		background-color: #fef08a;
+		color: inherit;
+		padding: 1px 2px;
+		border-radius: 2px;
+	}
+}
+
+// 반응형
+@media (max-width: 768px) {
+	#faq {
+		padding: 0 16px 24px 16px;
+		min-width: unset;
+
+		> p {
+			font-size: 18px;
+			margin-bottom: 16px;
+		}
+
+		> form > dl {
+			> dt {
+				flex-wrap: wrap;
+
+				> input {
+					flex-grow: 1;
+					width: 100%;
+				}
+
+				> button {
+					flex-shrink: 0;
+				}
+			}
+
+			> dd > ul {
+				column-gap: 10px;
+
+				> li {
+					font-size: 14px;
+
+					&::after {
+						margin-left: 10px;
+					}
+				}
+			}
+		}
+
+		> #questions > .faq-item {
+			> summary {
+				flex-wrap: wrap;
+				gap: 8px;
+				padding: 12px 4px;
+
+				> .faq-category {
+					font-size: 12px;
+				}
+
+				> .faq-question {
+					font-size: 14px;
+					width: 100%;
+					order: 1;
+				}
+
+				&::after {
+					order: 0;
+				}
+			}
+
+			> .faq-answer {
+				padding: 10px 4px 16px 16px;
+				font-size: 13px;
 			}
 		}
 	}
-}
+}

+ 161 - 49
app/support/faq/view.tsx

@@ -1,90 +1,202 @@
 'use client';
 
 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 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() {
-	const [error, setError] = useState<string>('');
 	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 [total, setTotal] = useState<number>(0);
-	const [page, setPage] = useState<number>(1);
-	// const [logs, setLogs] = useState<LoginLog[]>([]);
-
+	const [searchKeyword, setSearchKeyword] = useState<string>('');
 
+	// 초기 데이터 로드
 	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();
-		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 (
 		<>
 			<NavTab currentTab='faq' />
 
-			{/* 자주 묻는 질문(FAQ) */}
 			<section id='faq'>
 				<p>자주 묻는 질문</p>
 
-				<form onSubmit={(e) => handleSubmit(e)} autoComplete='off'>
+				{loading && <Loading />}
+
+				{/* 검색 */}
+				<form onSubmit={handleSubmit} autoComplete='off'>
 					<dl>
 						<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>
+							{searchKeyword && (
+								<button type='button' className='btn btn-default' onClick={handleReset}>초기화</button>
+							)}
 						</dt>
 						<dd>
 							<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>
+								{categories.map((cat) => (
+									<li key={cat.id} className={activeCategory === cat.code ? 'active' : ''}>
+										<button type='button' onClick={() => handleCategoryClick(cat.code)}>
+											{cat.subject}
+										</button>
+									</li>
+								))}
 							</ul>
 						</dd>
 					</dl>
 				</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 />
+				<hr />
 
-				<article className='pagination'>
-					<Pagination total={total} page={page} onPageChange={setPage} />
-				</article>
+				{/* 검색 결과 안내 */}
+				{searchKeyword && (
+					<p className='search-result'>
+						<strong>&quot;{searchKeyword}&quot;</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>
 		</>
 	);
-}
+}

+ 31 - 0
app/support/guide/page.tsx

@@ -0,0 +1,31 @@
+'use server';
+
+import './style.scss';
+import { notFound } from 'next/navigation';
+import { fetchDocument } from '@/lib/api/page/document';
+import NavTab from '@/app/support/navTab';
+
+export default async function Guide() {
+	const result = await fetchDocument('guide');
+
+	if (!result.success || !result.data) {
+		return notFound();
+	}
+
+	const doc = result.data;
+
+	return (
+		<>
+			<NavTab currentTab='guide' />
+
+			<article id='guide'>
+				<h1>{doc.subject}</h1>
+				<hr />
+				<br />
+				{doc.content && (
+					<section dangerouslySetInnerHTML={{ __html: doc.content }} />
+				)}
+			</article>
+		</>
+	);
+}

+ 12 - 0
app/support/guide/style.scss

@@ -0,0 +1,12 @@
+#guide {
+	padding: 24px 32px 32px 32px;
+	min-width: 410px;
+	max-width: 1920px;
+	margin: 0 auto;
+
+	h1 {
+		font-weight: 500;
+		font-size: 1.375rem;
+		margin-bottom: 1.25rem;
+	}
+}

+ 15 - 3
app/support/style.scss

@@ -6,21 +6,33 @@
 	flex-direction: row;
 	row-gap: 10px;
 	column-gap: 15px;
-	padding: 25px 0 24px 32px;
+	padding: 25px 0 0 32px;
 
 	> article {
 		> a {
 			color: #0D6295;
 			transition: color 0.3s;
-	
+
 			&:hover, &.active {
 				color: #e47911;
 				text-decoration: underline;
 			}
-			
+
 			&.active {
 				font-weight: bold;
 			}
 		}
 	}
+}
+
+@media (max-width: 576px) {
+	main {
+		#navTab {
+			padding: 15px 0 0 10px;
+		}
+
+		#board, #contact, #faq, #guide {
+			padding: 0 10px 22px 10px;
+		}
+	}
 }

+ 6 - 23
contexts/configProvider.tsx

@@ -1,34 +1,17 @@
 'use client';
 
-import { createContext, useContext, useEffect, useState } from 'react';
-import { fetchConfig } from '@/lib/api/system';
+import { createContext, useContext } from 'react';
 import Config from '@/types/config';
 
 const ConfigContext = createContext<Config|null>(null);
 
 // Context Provider
-export function ConfigProvider({ children }: { children: React.ReactNode }) {
-	const [config, setConfig] = useState<Config|null>(null);
-	const [loading, setLoading] = useState<boolean>(true);
-
-	useEffect(() => {
-		fetchConfig().then((configs) => {
-			if (configs) {
-				setConfig(configs.data);
-			}
-		}).catch((err) => {
-			console.error(err);
-		}).finally(() => {
-			setLoading(false);
-		});
-	}, []);
-
-	if (loading) {
-		return <></>;
-	}
-
+export function ConfigProvider({ children, initialConfig }: {
+	children: React.ReactNode,
+	initialConfig: Config|null
+}) {
 	return (
-		<ConfigContext.Provider value={config}>
+		<ConfigContext.Provider value={initialConfig}>
 			{children}
 		</ConfigContext.Provider>
 	);

+ 10 - 0
lib/api/page/document.ts

@@ -0,0 +1,10 @@
+import { ResultDto } from '@/types/response/common';
+import { DocumentResponse } from '@/types/response/page/document';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function fetchDocument(code: string): Promise<ResultDto<DocumentResponse|null>> {
+	return await fetchJson<DocumentResponse>(`/api/document/${code}`, {
+		method: 'GET',
+		headers: { 'Content-Type': 'application/json' },
+	});
+}

+ 18 - 0
lib/api/page/faq.ts

@@ -0,0 +1,18 @@
+import { ResultDto } from '@/types/response/common';
+import { FaqCategoryResponse, FaqItemsResponse } from '@/types/response/page/faq';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function fetchFaqCategories(): Promise<ResultDto<FaqCategoryResponse|null>> {
+	return await fetchJson<FaqCategoryResponse>('/api/faq/categories', {
+		method: 'GET',
+		headers: { 'Content-Type': 'application/json' },
+	});
+}
+
+export async function fetchFaqItems(code: string = ''): Promise<ResultDto<FaqItemsResponse|null>> {
+	return await fetchJson<FaqItemsResponse>('/api/faq/items', {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify({ Code: code }),
+	});
+}

+ 7 - 8
lib/api/system.ts

@@ -1,13 +1,12 @@
-'use server';
-
+import { cache } from 'react';
 import { ResultDto } from '@/types/response/common';
 import { fetchJson } from '@/lib/utils/server';
 import Config from '@/types/config';
 
 // Config 값 조회
-export async function fetchConfig(): Promise<ResultDto<Config|null>> {
-	return await fetchJson<Config>('/api/config', {
-		method: 'GET',
-		headers: {'Content-Type': 'application/json'},
-	});
-}
+export const fetchConfig = cache(async (): Promise<ResultDto<Config|null>> => {
+    return await fetchJson<Config>('/api/config', {
+        method: 'GET',
+        headers: {'Content-Type': 'application/json'},
+    });
+});

+ 20 - 0
package-lock.json

@@ -37,6 +37,7 @@
                 "react-dom": "^19.1.0",
                 "react-rnd": "^10.5.2",
                 "sass": "^1.83.0",
+                "swiper": "^12.1.2",
                 "tailwind-merge": "^2.6.0",
                 "tailwindcss-animate": "^1.0.7"
             },
@@ -18918,6 +18919,25 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/swiper": {
+            "version": "12.1.2",
+            "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.2.tgz",
+            "integrity": "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==",
+            "funding": [
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/swiperjs"
+                },
+                {
+                    "type": "open_collective",
+                    "url": "http://opencollective.com/swiper"
+                }
+            ],
+            "license": "MIT",
+            "engines": {
+                "node": ">= 4.7.0"
+            }
+        },
         "node_modules/tailwind-merge": {
             "version": "2.6.1",
             "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",

+ 1 - 0
package.json

@@ -38,6 +38,7 @@
         "react-dom": "^19.1.0",
         "react-rnd": "^10.5.2",
         "sass": "^1.83.0",
+        "swiper": "^12.1.2",
         "tailwind-merge": "^2.6.0",
         "tailwindcss-animate": "^1.0.7"
     },

+ 91 - 55
types/config.ts

@@ -1,83 +1,119 @@
-// /@types/config.ts
-
 export default interface Config {
+	id: number;
 	basic: BasicForm;
+	images: ImagesForm;
 	meta: MetaForm;
 	company: CompanyForm;
 	account: AccountForm;
 	emailTemplate: EmailTemplate;
+	external: ExternalApiForm;
+	// payment: PaymentForm;
 }
 
 export interface BasicForm {
 	siteName: string|null;
+	siteURL: string|null;
+	rootID: string|null;
+	fromEmail: string|null;
+	fromName: string|null;
 	smtpServer: string|null;
 	smtpPort: number|null;
-	smtpEnableSSL: string|null; // 'Y'|'N' 가능
+	smtpEnableSSL: boolean;
 	smtpUsername: string|null;
 	smtpPassword: string|null;
-	isMaintenance: string|null; // '0'|'1' 가능
+	adminWhiteIPList: string|null;
+	frontWhiteIPList: string|null;
+	blockAlertTitle: string|null;
+	blockAlertContent: string|null;
+	isMaintenance: boolean;
+	maintenanceContent: string|null;
+}
+
+export interface ImagesForm {
+	faviconPath: string|null;
+	logoSquarePath: string|null;
+	logoHorizontalPath: string|null;
+	ogDefaultPath: string|null;
+	twitterImagePath: string|null;
+	appleTouchIconPath: string|null;
+	appIcon192Path: string|null;
+	appIcon512Path: string|null;
 }
 
 export interface MetaForm {
-	metaKeyword?: string|null;
-	metaDescription?: string|null;
-	metaAuthor?: string|null;
-	metaViewport?: string|null;
-	metaApplicationName?: string|null;
-	metaGenerator?: string|null;
-	metaRobots?: string|null;
-	metaAdds?: string|null;
+	keywords: string|null;
+	description: string|null;
+	author: string|null;
+	viewport: string|null;
+	applicationName: string|null;
+	generator: string|null;
+	robots: string|null;
+	adds: string|null;
 }
 
 export interface CompanyForm {
-	companyName: string|null;
-	companyRegNo: string|null;
-	companyOwner: string|null;
-	companyTel: string|null;
-	companyFax: string|null;
-	companyRetailSaleNo: string|null;
-	companyAddedSaleNo: string|null;
-	companyZipCode: string|null;
-	companyHosting: string|null;
-	companyAdminName: string|null;
-	companyAdminEmail: string|null;
-	companySiteURL: string|null;
-	companyBankCode: number|null;
-	companyBankOwner: string|null;
-	companyBankNumber: string|null;
+	name: string|null;
+	regNo: string|null;
+	address: string|null;
+	zipCode: string|null;
+	owner: string|null;
+	tel: string|null;
+	fax: string|null;
+	retailSaleNo: string|null;
+	addedSaleNo: string|null;
+	hosting: string|null;
+	adminName: string|null;
+	adminEmail: string|null;
+	siteUrl: string|null;
+	bankCode: string|null;
+	bankOwner: string|null;
+	bankNumber: string|null;
 }
 
 export interface AccountForm {
-	isRegisterBlock: string; // 'Y'|'N'
-	isRegisterEmailAuth: string;
-	passwordMinLength: number;
-	passwordUppercaseLength: number;
-	passwordNumbersLength: number;
-	passwordSpecialcharsLength: number;
+	isRegisterBlock: boolean;
+	isRegisterEmailAuth: boolean;
+	passwordMinLength: number|null;
+	passwordUppercaseLength: number|null;
+	passwordNumbersLength: number|null;
+	passwordSpecialcharsLength: number|null;
 	deniedEmailList: string|null;
 	deniedNameList: string|null;
-	changeEmailDay: number;
-	changeNameDay: number;
-	changeSummaryDay: number;
-	changeIntroDay: number;
-	changePasswordDay: number;
-	maxLoginTryCount: number;
-	maxLoginTryLimitSecond: number;
+	changeEmailDay: number|null;
+	changeNameDay: number|null;
+	changeSummaryDay: number|null;
+	changeIntroDay: number|null;
+	changePasswordDay: number|null;
+	isLoginEmailVerifiedOnly: boolean;
+	maxLoginTryCount: number|null;
+	maxLoginTryLimitSecond: number|null;
 }
 
 export interface EmailTemplate {
-	registerEmailFormTitle: string;
-	registerEmailFormContent: string;
-	registrationEmailFormTitle: string;
-	registrationEmailFormContent: string;
-	resetPasswordEmailFormTitle: string;
-	resetPasswordEmailFormContent: string;
-	changedPasswordEmailFormTitle: string;
-	changedPasswordEmailFormContent: string;
-	withdrawEmailFormTitle: string;
-	withdrawEmailFormContent: string;
-	emailVerifyFormTitle: string;
-	emailVerifyFormContent: string;
-	changedEmailFormTitle: string;
-	changedEmailFormContent: string;
-}
+	registerEmailFormTitle: string|null;
+	registerEmailFormContent: string|null;
+	registrationEmailFormTitle: string|null;
+	registrationEmailFormContent: string|null;
+	resetPasswordEmailFormTitle: string|null;
+	resetPasswordEmailFormContent: string|null;
+	changedPasswordEmailFormTitle: string|null;
+	changedPasswordEmailFormContent: string|null;
+	withdrawEmailFormTitle: string|null;
+	withdrawEmailFormContent: string|null;
+	emailVerifyFormTitle: string|null;
+	emailVerifyFormContent: string|null;
+	changedEmailFormTitle: string|null;
+	changedEmailFormContent: string|null;
+}
+
+export interface ExternalApiForm {
+	youTubeApiKeyEnc: string|null;
+	youTubeApiName: string|null;
+	googleClientId: string|null;
+	googleClientSecretEnc: string|null;
+	googleAppId: string|null;
+}
+
+// export interface PaymentForm {
+
+// }

+ 9 - 0
types/response/page/document.ts

@@ -0,0 +1,9 @@
+export interface DocumentResponse {
+	id: number;
+	code: string;
+	subject: string;
+	content: string|null;
+	isActive: boolean;
+	updatedAt: string|null;
+	createdAt: string;
+}

+ 26 - 0
types/response/page/faq.ts

@@ -0,0 +1,26 @@
+export interface FaqCategoryResponse {
+	total: number;
+	list: FaqCategory[];
+}
+
+export interface FaqCategory {
+	id: number;
+	code: string;
+	subject: string;
+	order: number;
+}
+
+export interface FaqItemsResponse {
+	total: number;
+	list: FaqItem[];
+}
+
+export interface FaqItem {
+	id: number;
+	categoryID: number;
+	categoryCode: string;
+	categorySubject: string;
+	question: string;
+	answer: string|null;
+	order: number;
+}

+ 16 - 0
types/response/page/popup.ts

@@ -0,0 +1,16 @@
+export interface PopupResponse {
+	total: number;
+	list: PopupItem[];
+}
+
+export interface PopupItem {
+	id: number;
+	positionID: number;
+	subject: string;
+	content: string | null;
+	link: string | null;
+	startAt: string | null;
+	endAt: string | null;
+	order: number;
+	isActive: boolean;
+}