diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml
new file mode 100644
index 0000000..2c69a00
--- /dev/null
+++ b/.github/workflows/dev-deploy.yml
@@ -0,0 +1,27 @@
+name: Frontend Deploy
+
+on:
+ push:
+ branches:
+ - dev
+ paths-ignore:
+ - '.github/workflows/**'
+
+jobs:
+ deploy:
+ runs-on: self-hosted
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Copy frontend code to shared volume
+ run: |
+ rm -rf /deploy/app/*
+ cp -r . /deploy/app/
+
+ - name: Restart Docker (Frontend Node.js)
+ run: |
+ cd /deploy/app
+ docker-compose down
+ docker-compose up -d --build
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a9f653a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+# 1) Builder 단계: 의존 설치 + 빌드
+FROM node:18-alpine AS builder
+WORKDIR /app
+
+# package-lock.json까지 복사해서 정확히 설치
+COPY package.json package-lock.json ./
+RUN npm ci
+
+# 소스 전체 복사
+COPY . .
+
+# Next.js 빌드 (App Router 기준으로 .next + static 페이지 생성)
+RUN npm run build
+
+# 2) Production 단계: 런타임용으로 경량화
+FROM node:18-alpine
+WORKDIR /app
+
+# production 환경 변수
+ENV NODE_ENV=production
+
+# 빌드 결과물만 복사
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/package-lock.json ./package-lock.json
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/public ./public
+
+# 컨테이너가 열 포트
+EXPOSE 3000
+
+# next start 로 서버 구동
+CMD ["npm", "start"]
diff --git a/README.md b/README.md
index 66bb426..6b0dfd5 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+Yak+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..527f155
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,19 @@
+version: '3.8'
+
+services:
+ next-app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: yakplus-frontend
+ environment:
+ - NODE_ENV=production
+ ports:
+ - "13000:3000"
+ restart: unless-stopped
+ networks:
+ - deploy_yakplus
+
+networks:
+ deploy_yakplus:
+ external: true
diff --git a/public/logo.svg b/public/logo.svg
new file mode 100644
index 0000000..70f14e8
--- /dev/null
+++ b/public/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git "a/public/\354\240\234\353\252\251 \354\227\206\353\212\224 \353\213\244\354\235\264\354\226\264\352\267\270\353\236\250.drawio.svg" "b/public/\354\240\234\353\252\251 \354\227\206\353\212\224 \353\213\244\354\235\264\354\226\264\352\267\270\353\236\250.drawio.svg"
new file mode 100644
index 0000000..7424f10
--- /dev/null
+++ "b/public/\354\240\234\353\252\251 \354\227\206\353\212\224 \353\213\244\354\235\264\354\226\264\352\267\270\353\236\250.drawio.svg"
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/app/drugs/[id]/page.js b/src/app/drugs/[id]/page.js
new file mode 100644
index 0000000..250568e
--- /dev/null
+++ b/src/app/drugs/[id]/page.js
@@ -0,0 +1,256 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import Header from '@/components/Header';
+import Footer from '@/components/Footer';
+import NoImage from '@/components/NoImage';
+import Link from 'next/link';
+
+export default function DrugDetailPage() {
+ const params = useParams();
+ const drugId = params.id;
+
+ const [drug, setDrug] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+
+ const fetchDrugDetail = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/drugs/search/detail/${drugId}`);
+ if (!response.ok) {
+ throw new Error('약품 정보를 불러오는 데 실패했습니다.');
+ }
+
+ const data = await response.json();
+ setDrug(data.data);
+ } catch (err) {
+ console.error('약품 상세 정보 조회 오류:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchDrugDetail();
+ }, [drugId]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error || !drug) {
+ return (
+
+
+
+
+ {error || '약품 정보를 찾을 수 없습니다.'}
+
+
+
+
+ );
+ }
+
+ // 약품 전문/일반 구분
+ const getEtcOtcName = (isGeneral) => {
+ return isGeneral ? "일반의약품" : "전문의약품";
+ };
+
+ // 주의사항 키 값 추출
+ const precautionKeys = drug.precaution ? Object.keys(drug.precaution) : [];
+
+ return (
+
+
+
+
+
+ {/* 상단 영역: 약품 기본 정보 */}
+
+ {/* 약품 이미지 */}
+
+ {drug.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 약품 기본 정보 */}
+
+
{drug.drugName}
+
+
+
+ 제약회사
+ {drug.company}
+
+
+ 품목기준코드
+ {drug.drugId}
+
+
+ 보관방법
+ {drug.storeMethod}
+
+
+ 의약품 구분
+ {getEtcOtcName(drug.isGeneral)}
+
+
+
+
+
+ 허가일
+ {drug.permitDate}
+
+
+ 유효기간
+
+ {drug.validTerm ? drug.validTerm : '정보 없음'}
+
+
+
+ 취소일자
+
+ {drug.cancelDate ? drug.cancelDate : '해당 없음'}
+
+
+
+ 취소사유
+
+ {drug.cancelName ? drug.cancelName : '해당 없음'}
+
+
+
+
+
+
+
+ {/* 성분 정보 */}
+ {drug.materialInfo && drug.materialInfo.length > 0 && (
+
+
+ 성분 정보
+
+
+
+
+
+ 성분명
+ 분량
+ 단위
+ 총량
+
+
+
+ {drug.materialInfo.map((material, index) => (
+
+
+
+ {material.성분명}
+
+
+ {material.분량}
+ {material.단위}
+ {material.총량}
+
+ ))}
+
+
+
+
+ )}
+
+ {/* 상세 정보 섹션 */}
+
+ {/* 효능효과 */}
+
+
+ 효능효과
+
+
+ {drug.efficacy?.length > 0 ? (
+
+ {drug.efficacy.map((item, i) => (
+ {item}
+ ))}
+
+ ) : (
+
정보가 없습니다.
+ )}
+
+
+
+ {/* 용법용량 */}
+
+
+ 용법용량
+
+
+ {drug.usage?.length > 0 ? (
+
+ {drug.usage.map((item, i) => (
+ {item}
+ ))}
+
+ ) : (
+
정보가 없습니다.
+ )}
+
+
+
+ {/* 주의사항 및 기타 정보 */}
+ {precautionKeys.length > 0 && (
+
+
+ 주의사항
+
+ {precautionKeys.map((key) => (
+
+
+ {key}
+
+
+ {drug.precaution[key]?.length > 0 ? (
+
+ {drug.precaution[key].map((item, i) => (
+ {item}
+ ))}
+
+ ) : (
+
정보가 없습니다.
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/layout.js b/src/app/layout.js
index 7bf337d..da6c022 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -12,8 +12,11 @@ const geistMono = Geist_Mono({
});
export const metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "약품 검색 플랫폼 Yak+",
+ description: "약품 검색 플랫폼 Yak+",
+ icons: {
+ icon: '/logo.svg', // SVG 경로
+ },
};
export default function RootLayout({ children }) {
diff --git a/src/app/page.js b/src/app/page.js
index d625a20..90c2351 100644
--- a/src/app/page.js
+++ b/src/app/page.js
@@ -1,103 +1,176 @@
+'use client';
+import dynamic from 'next/dynamic';
+import { useState, useEffect, useRef } from 'react';
import Image from "next/image";
+import Header from '../components/Header';
+import Footer from '../components/Footer';
+import { useRouter } from 'next/navigation';
-export default function Home() {
- return (
-
-
-
-
-
- Get started by editing{" "}
-
- src/app/page.js
-
- .
-
-
- Save and see your changes instantly.
-
-
+// 스켈레톤 UI 컴포넌트
+const SearchBarSkeleton = () => (
+
+ {/* 검색 모드 선택 탭을 위한 스켈레톤 */}
+
+
+ {/* 검색 입력 필드를 위한 스켈레톤 */}
+
+
+
+ {/* 드롭다운 버튼을 위한 스켈레톤 */}
+
+
+
+);
+
+const SearchBar = dynamic(() => import('../components/SearchBar'), {
+ ssr: false,
+ loading: () => ,
+});
+
+const MAX_RECENT_SEARCHES = 5; // 최대 저장할 최근 검색어 수
+
+const displayTypes = {
+ symptom: '증상',
+ ingredient: '성분명',
+ name: '약품명',
+ natural: '자연어'
+};
+
+const Home = () => {
+ const [recentSearches, setRecentSearches] = useState([]);
+ const [searchBarProps, setSearchBarProps] = useState({
+ initialQuery: '',
+ initialMode: 'natural',
+ initialType: 'symptom'
+ });
+ const searchBarRef = useRef(null);
+ const router = useRouter();
+
+ // 최근 검색어 클릭 핸들러
+ const handleRecentSearchClick = (searchItem) => {
+ // 검색어 클릭 시 바로 검색 페이지로 이동
+ if (searchItem.mode === 'natural') {
+ router.push(`/search?q=${encodeURIComponent(searchItem.query)}&mode=${searchItem.mode}&type=natural`);
+ } else {
+ router.push(`/search/${searchItem.type}?q=${encodeURIComponent(searchItem.query)}&mode=${searchItem.mode}&type=${searchItem.type}`);
+ }
+ };
+
+ // 최근 검색어 로드
+ useEffect(() => {
+ const savedSearches = sessionStorage.getItem('recentSearches');
+ if (savedSearches) {
+ setRecentSearches(JSON.parse(savedSearches));
+ }
+ }, []);
+
+ // 최근 검색어 삭제
+ const removeSearch = (searchToRemove) => {
+ const newSearches = recentSearches.filter(search => search.query !== searchToRemove.query);
+ setRecentSearches(newSearches);
+ sessionStorage.setItem('recentSearches', JSON.stringify(newSearches));
+ };
+
+ // 모든 최근 검색어 삭제
+ const clearAllSearches = () => {
+ setRecentSearches([]);
+ sessionStorage.removeItem('recentSearches');
+ };
-
-
-
- Deploy now
-
-
- Read our docs
-
+ const RecentSearches = () => {
+ if (recentSearches.length === 0) return null;
+
+ return (
+
+
+ {recentSearches.map((searchItem, index) => (
+
+
handleRecentSearchClick(searchItem)}
+ className="px-3 py-1.5 flex items-center gap-2"
+ >
+ {searchItem.query}
+
+ {displayTypes[searchItem.type]}
+
+
+
{
+ e.stopPropagation();
+ removeSearch(searchItem);
+ }}
+ className="pr-2 pl-1 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+
+
+
+
+
+ ))}
-
-
+ );
+ };
+
+ return (
+
+
+
+
);
-}
+};
+
+export default Home;
diff --git a/src/app/search/ingredient/page.js b/src/app/search/ingredient/page.js
new file mode 100644
index 0000000..3ee6f6b
--- /dev/null
+++ b/src/app/search/ingredient/page.js
@@ -0,0 +1,12 @@
+'use client';
+
+import { Suspense } from 'react';
+import SearchPage from '../../../components/SearchPage';
+
+export default function Page() {
+ return (
+ Loading... }>
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/search/name/page.js b/src/app/search/name/page.js
new file mode 100644
index 0000000..76dbed9
--- /dev/null
+++ b/src/app/search/name/page.js
@@ -0,0 +1,12 @@
+'use client';
+
+import { Suspense } from 'react';
+import SearchPage from '../../../components/SearchPage';
+
+export default function Page() {
+ return (
+
Loading... }>
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/search/page.js b/src/app/search/page.js
new file mode 100644
index 0000000..814b394
--- /dev/null
+++ b/src/app/search/page.js
@@ -0,0 +1,12 @@
+'use client';
+
+import { Suspense } from 'react';
+import SearchPage from '../../components/SearchPage';
+
+export default function Page() {
+ return (
+ Loading... }>
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/search/symptom/page.js b/src/app/search/symptom/page.js
new file mode 100644
index 0000000..67f085f
--- /dev/null
+++ b/src/app/search/symptom/page.js
@@ -0,0 +1,12 @@
+'use client';
+
+import { Suspense } from 'react';
+import SearchPage from '../../../components/SearchPage';
+
+export default function Page() {
+ return (
+ Loading...}>
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Footer.js b/src/components/Footer.js
new file mode 100644
index 0000000..e75de6a
--- /dev/null
+++ b/src/components/Footer.js
@@ -0,0 +1,22 @@
+'use client';
+
+const Footer = () => {
+ return (
+
+
+
+
YAK+
+
+ 더 나은 약품 정보를 위한 약품 검색 플랫폼, YAK+
+ {/* 나에게 딱 맞는 약품 검색, YAK+ */}
+
+
+ © {new Date().getFullYear()} YAK+. All rights reserved.
+
+
+
+
+ );
+};
+
+export default Footer;
\ No newline at end of file
diff --git a/src/components/Header.js b/src/components/Header.js
new file mode 100644
index 0000000..0138b3c
--- /dev/null
+++ b/src/components/Header.js
@@ -0,0 +1,70 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+
+const Header = () => {
+ return (
+
+ );
+};
+
+export default Header;
\ No newline at end of file
diff --git a/src/components/NoImage.js b/src/components/NoImage.js
new file mode 100644
index 0000000..508ffea
--- /dev/null
+++ b/src/components/NoImage.js
@@ -0,0 +1,59 @@
+const NoImage = ({ className = "" }) => {
+ return (
+
+
+
+ {/* 약병 몸체 */}
+
+
+ {/* 약병 뚜껑 */}
+
+
+ {/* 약병 라벨 */}
+ {/* */}
+
+ {/* X 표시 - 우측 하단으로 이동 및 크기 확대 */}
+ {/* */}
+
+
약품 이미지 준비 중
+
+
+ );
+};
+
+export default NoImage;
\ No newline at end of file
diff --git a/src/components/SearchBar.js b/src/components/SearchBar.js
new file mode 100644
index 0000000..df8a0dc
--- /dev/null
+++ b/src/components/SearchBar.js
@@ -0,0 +1,443 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+
+const SearchIcon = ({ color = '#2BA89C', onClick }) => (
+
+
+
+);
+
+const SearchBar = ({ initialQuery = '', showTabs = true, initialMode = 'natural', initialType = 'symptom'}) => {
+ const router = useRouter();
+ const [searchMode, setSearchMode] = useState(initialMode);
+ const [searchType, setSearchType] = useState(initialType);
+ const [searchQuery, setSearchQuery] = useState(initialQuery);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [suggestions, setSuggestions] = useState([]);
+ const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
+ const [isFocused, setIsFocused] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const [autoCompleteCache, setAutoCompleteCache] = useState({}); // 자동완성 결과 캐시
+ const MAX_CACHE_SIZE = 100;
+
+// 캐시 업데이트 헬퍼 함수
+// (MAX_CACHE_SIZE를 초과하면 가장 오래된 것 삭제)
+const updateCache = (prevCache, key, value) => {
+ const newCache = { ...prevCache };
+ newCache[key] = value;
+
+ if (Object.keys(newCache).length > MAX_CACHE_SIZE) {
+ const oldestKey = Object.keys(newCache)[0]; // 가장 먼저 들어간 키를 삭제
+ delete newCache[oldestKey];
+ }
+
+ return newCache;
+};
+
+ const searchTypes = {
+ symptom: '증상',
+ ingredient: '성분명',
+ name: '약품명'
+ };
+
+ const getPlaceholder = () => {
+ if (searchMode === 'natural') {
+ return '예) 머리가 아프고 열이 나요 (20자 이내)';
+ }
+
+ switch (searchType) {
+ case 'symptom':
+ return '증상을 입력하세요';
+ case 'ingredient':
+ return '성분명을 입력하세요';
+ case 'name':
+ return '약품명을 입력하세요';
+ default:
+ return '검색어를 입력하세요';
+ }
+ };
+
+
+ // 자동완성 데이터를 가져오는 함수
+ const fetchSuggestions = async (query, type) => {
+ try {
+ setIsLoading(true);
+
+ const url = `/api/drugs/autocomplete/${type}?q=${encodeURIComponent(query)}`;
+
+ // console.log('자동완성 요청 URL:', url);
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ // console.log('자동완성 응답:', response);
+
+ if (!response.ok) {
+ throw new Error(response.message);
+ }
+
+ const data = await response.json();
+ // console.log('자동완성 데이터:', data);
+
+ // 캐시 업데이트 - 기존 결과를 새로운 결과로 덮어씌움
+ const cacheKey = `${type}:${query}`;
+ const results = data.data.autoCompleteList || [];
+
+ // 캐시 무한정 커지지 않게 개수 체크하면서 추가 (MAX_CACHE_SIZE를 초과하면 가장 오래된 것 삭제)
+ setAutoCompleteCache(prev => updateCache(prev, cacheKey, results));
+
+ return results;
+ } catch (error) {
+ console.error('자동완성 에러:', error);
+ return [];
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 자동완성 결과 업데이트
+ useEffect(() => {
+ if (!isFocused) {
+ setSuggestions([]);
+ return;
+ }
+
+ const trimmedQuery = searchQuery.trim();
+ const cacheKey = `${searchType}:${trimmedQuery}`;
+
+ if (searchMode !== 'keyword' || trimmedQuery.length === 0) {
+ setSuggestions([]);
+ return;
+ }
+
+ if (autoCompleteCache[cacheKey]) {
+ // console.log('캐시 사용:', cacheKey);
+ setSuggestions(autoCompleteCache[cacheKey].map(text => ({ text, category: searchTypes[searchType] })));
+ return;
+ }
+
+ // 300ms 후에 서버 요청 예약
+ const handler = setTimeout(() => {
+ // console.log('서버 요청:', trimmedQuery);
+ fetchSuggestions(trimmedQuery, searchType)
+ .then(results => {
+ setSuggestions(results.map(text => ({ text, category: searchTypes[searchType] })));
+ });
+ }, 300);
+
+ // 다음 입력 시 기존 요청 취소
+ return () => clearTimeout(handler);
+
+ }, [searchQuery, searchType, searchMode, isFocused, autoCompleteCache]);
+
+
+ // 포커스 아웃 시 자동완성 닫기
+ const handleBlur = () => {
+ // 약간의 지연을 주어 클릭 이벤트가 처리될 수 있도록 함
+ setTimeout(() => {
+ setIsFocused(false);
+ }, 200);
+ };
+
+ // 최근 검색어 저장 함수
+ const saveRecentSearch = (query, mode, type) => {
+ try {
+ const savedSearches = sessionStorage.getItem('recentSearches');
+ const searches = savedSearches ? JSON.parse(savedSearches) : [];
+
+ const newSearch = {
+ query: query.trim(),
+ mode: mode,
+ type: type
+ };
+
+ const filteredSearches = searches.filter(item => item.query !== newSearch.query);
+ const updatedSearches = [newSearch, ...filteredSearches].slice(0, 5);
+
+ sessionStorage.setItem('recentSearches', JSON.stringify(updatedSearches));
+ } catch (error) {
+ console.error('Failed to save recent search:', error);
+ }
+ };
+
+ // 검색 모드(키워드,자연어) 감지하여 모드별 검색 결과창 라우팅
+ const handleSearch = (e) => {
+ e.preventDefault();
+ if (!searchQuery.trim()) return;
+
+ if (searchMode === 'keyword') {
+ switch (searchType) {
+ case 'symptom':
+ saveRecentSearch(searchQuery, searchMode, searchType);
+ router.push(`/search/symptom?q=${encodeURIComponent(searchQuery)}&mode=${searchMode}&type=${searchType}`);
+ break;
+ case 'ingredient':
+ saveRecentSearch(searchQuery, searchMode, searchType);
+ router.push(`/search/ingredient?q=${encodeURIComponent(searchQuery)}&mode=${searchMode}&type=${searchType}`);
+ break;
+ case 'name':
+ saveRecentSearch(searchQuery, searchMode, searchType);
+ router.push(`/search/name?q=${encodeURIComponent(searchQuery)}&mode=${searchMode}&type=${searchType}`);
+ break;
+ }
+ } else {
+ // 자연어 검색 처리
+ saveRecentSearch(searchQuery, searchMode, 'natural');
+ router.push(`/search?q=${encodeURIComponent(searchQuery)}&mode=${searchMode}&type=natural`);
+ }
+ };
+
+
+ const handleInputChange = (e) => {
+ const value = e.target.value;
+ if (searchMode === 'natural' && value.length > 20) {
+ return;
+ }
+ setSearchQuery(value);
+ setSelectedSuggestion(-1);
+ };
+
+
+ const handleKeyDown = (e) => {
+ if (searchMode === 'natural' && e.key === 'Enter') {
+ e.preventDefault();
+ handleSearch(e);
+ return;
+ }
+
+ if (!suggestions.length) return;
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ setSelectedSuggestion(prev =>
+ prev < suggestions.length - 1 ? prev + 1 : prev
+ );
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ setSelectedSuggestion(prev => prev > 0 ? prev - 1 : -1);
+ break;
+ case 'Enter':
+ e.preventDefault();
+ if (selectedSuggestion >= 0) {
+ const selectedItem = suggestions[selectedSuggestion];
+ handleSuggestionClick(selectedItem);
+ } else {
+ handleSearch(e);
+ }
+ break;
+ case 'Escape':
+ setSuggestions([]);
+ setSelectedSuggestion(-1);
+ break;
+ }
+ };
+
+ // 검색어의 일치하는 부분을 하이라이트하는 함수
+ const highlightMatch = (text) => {
+ if (!searchQuery) return text;
+ const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
+ return parts.map((part, i) =>
+ part.toLowerCase() === searchQuery.toLowerCase() ?
+ {part} : part
+ );
+ };
+
+ // 선택된 모드에 따른 색상 테마
+ const getThemeColor = () => {
+ return searchMode === 'keyword' ? '#2BA89C' : '#2978F2';
+ };
+
+ const handleSuggestionClick = (suggestion) => {
+ setSearchQuery(suggestion.text);
+ setSuggestions([]);
+ saveRecentSearch(suggestion.text, 'keyword', searchType);
+ switch (searchType) {
+ case 'symptom':
+ router.push(`/search/symptom?q=${encodeURIComponent(suggestion.text)}&mode=keyword&type=${searchType}`);
+ break;
+ case 'name':
+ router.push(`/search/name?q=${encodeURIComponent(suggestion.text)}&mode=keyword&type=${searchType}`);
+ break;
+ default:
+ router.push(`/search/symptom?q=${encodeURIComponent(suggestion.text)}&mode=keyword&type=${searchType}`);
+ }
+ };
+
+ return (
+
+ {/* 검색 모드 선택 탭 - showTabs가 true일 때만 표시 */}
+ {showTabs && (
+
+ {
+ setSearchMode('natural');
+ setSearchQuery('');
+ }}
+ >
+ 자연어
+
+ {
+ setSearchMode('keyword');
+ setSearchQuery('');
+ }}
+ >
+ 키워드
+
+
+ )}
+
+ {/* 검색 폼 */}
+
+
+
+
+ );
+};
+
+export default SearchBar;
diff --git a/src/components/SearchPage.js b/src/components/SearchPage.js
new file mode 100644
index 0000000..00ba8e6
--- /dev/null
+++ b/src/components/SearchPage.js
@@ -0,0 +1,242 @@
+'use client';
+
+import { useSearchParams, useRouter } from 'next/navigation';
+import { useState, useEffect } from 'react';
+import Header from './Header';
+import Footer from './Footer';
+import NoImage from './NoImage';
+import SearchBar from './SearchBar';
+
+const SearchPage = ({ searchType }) => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const query = searchParams.get('q');
+ const mode = searchParams.get('mode');
+ const type = searchParams.get('type') || searchType;
+ const pageParam = searchParams.get('page');
+
+ // URL에 페이지 파라미터가 있으면 사용하고, 없으면 기본값 1 사용
+ const [currentPage, setCurrentPage] = useState(pageParam ? parseInt(pageParam) : 1);
+ const [totalResults, setTotalResults] = useState(0);
+ const itemsPerPage = 10;
+
+ const [fetchedResults, setFetchedResults] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 약품 상세 페이지로 이동하는 함수
+ const navigateToDrugDetail = (drugId) => {
+ router.push(`/drugs/${drugId}`);
+ };
+
+ useEffect(() => {
+ if (!query) {
+ setFetchedResults([]);
+ setTotalResults(0);
+ return;
+ }
+ setIsLoading(true);
+ setError(null);
+ const apiPage = currentPage - 1;
+
+ let url = `/api/drugs/search/${type}?q=${encodeURIComponent(query)}&page=${apiPage}&size=${itemsPerPage}`;
+ let options = { method: 'GET' };
+
+
+ fetch(url, options) // 검색 타입에 따라 데이터 받아오는 방식 분기됨. !!! response 형식 정해지면 수정 필요
+ .then(res => {
+ if (!res.ok) throw new Error(res.message);
+ return res.json();
+ })
+ .then(data => {
+ let list = data.data.searchResponseList;
+ setFetchedResults(list);
+
+ // totalResponseCount가 있으면 그 값을 사용하고, 없으면 현재 목록 길이를 사용
+ const totalCount = data.data.totalResponseCount;
+ setTotalResults(totalCount);
+ if(mode === 'natural') {
+ setTotalResults(100);
+ }
+ })
+ .catch(err => setError(err.message))
+ .finally(() => setIsLoading(false));
+ }, [query, currentPage, mode, searchType, itemsPerPage]);
+
+ // 페이지 변경 함수
+ const handlePageChange = (pageNumber) => {
+ setCurrentPage(pageNumber);
+
+ // URL 업데이트
+ const params = new URLSearchParams(searchParams);
+ params.set('page', pageNumber.toString());
+ router.push(`${window.location.pathname}?${params.toString()}`);
+
+ // 페이지 상단으로 스크롤
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {/* 검색 결과 정보 헤더 */}
+ {!isLoading && !error && fetchedResults.length > 0 && (
+
+
+ {query} 검색 결과
+ {mode !== 'natural' && (
+ {totalResults.toLocaleString()}
+ )}
+ {mode !== 'natural' && '건'}
+
+ {mode === 'natural' && (
+
자연어 검색결과는 상위 {totalResults.toLocaleString()}건만 표시됩니다
+ )}
+
+ )}
+
+
+ {isLoading ? (
+
로딩 중...
+ ) : error ? (
+
에러: {error}
+ ) : fetchedResults.length > 0 ? (
+ fetchedResults.map(medicine => (
+
navigateToDrugDetail(medicine.drugId)}
+ >
+
+ {/* 이미지 영역 */}
+
+ {medicine.imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 정보 영역 */}
+
+ {/* 명칭 */}
+
+
+ 명 칭
+
+
+ {medicine.drugName}
+
+
+ {/* 제약회사 */}
+
+
+ 제약회사
+
+
+ {medicine.company}
+
+
+ {/* 효능 */}
+
+
+ 효 능
+
+
+ {medicine.efficacy.join(', ')}
+
+
+
+
+
+ ))
+ ) : (
+
+
검색 결과가 없습니다.
+
다른 검색어로 다시 시도해 보세요.
+
+ )}
+
+
+ {/* 페이지네이션 개선 */}
+ {totalResults > 0 && (
+
+ {/* 이전 페이지 버튼 */}
+ {currentPage > 1 && (
+ handlePageChange(currentPage - 1)}
+ className="px-3 py-1 rounded bg-white text-gray-600 hover:bg-gray-100"
+ >
+ <
+
+ )}
+
+ {/* 페이지 번호 버튼 */}
+ {Array.from({ length: Math.min(5, Math.ceil(totalResults / itemsPerPage)) }, (_, i) => {
+ // 현재 페이지를 중심으로 페이지 번호 표시
+ const totalPages = Math.ceil(totalResults / itemsPerPage);
+ let pageNum;
+
+ if (totalPages <= 5) {
+ // 전체 페이지가 5개 이하면 모든 페이지 표시
+ pageNum = i + 1;
+ } else if (currentPage <= 3) {
+ // 현재 페이지가 1,2,3이면 1~5 표시
+ pageNum = i + 1;
+ } else if (currentPage >= totalPages - 2) {
+ // 현재 페이지가 마지막 3페이지 안에 있으면 마지막 5페이지 표시
+ pageNum = totalPages - 4 + i;
+ } else {
+ // 그 외에는 현재 페이지 중심으로 앞뒤 2페이지씩 표시
+ pageNum = currentPage - 2 + i;
+ }
+
+ return (
+ handlePageChange(pageNum)}
+ className={`px-3 py-1 rounded ${
+ pageNum === currentPage
+ ? 'bg-[#2BA89C] text-white'
+ : 'bg-white text-gray-600 hover:bg-gray-100'
+ }`}
+ >
+ {pageNum}
+
+ );
+ })}
+
+ {/* 다음 페이지 버튼 */}
+ {currentPage < Math.ceil(totalResults / itemsPerPage) && (
+ handlePageChange(currentPage + 1)}
+ className="px-3 py-1 rounded bg-white text-gray-600 hover:bg-gray-100"
+ >
+ >
+
+ )}
+
+ )}
+
+
+
+
+ );
+};
+
+export default SearchPage;
\ No newline at end of file