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.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 ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.js - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+// 스켈레톤 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'); + }; -
- - Vercel logomark - Deploy now - - - Read our docs - + const RecentSearches = () => { + if (recentSearches.length === 0) return null; + + return ( +
+
+ {recentSearches.map((searchItem, index) => ( +
+ + +
+ ))}
-
-
+ ); + }; + + return ( +
+
+
+
Window icon - Examples - - - Globe icon + +
+ - Go to nextjs.org → - - + + {/* 최근 검색어 */} + {recentSearches.length > 0 && ( +
+
+

최근 검색어

+ +
+ +
+ )} +
+
+
); -} +}; + +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 ( + + ); +}; + +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 ( +
+
+ {/* 로고 */} + + {/* Yak+ Logo */} + YAK+ + + + {/* 건강 문구 */} + {/*
+
+

+ {'당신의 건강한 하루를 응원합니다'.split('').map((char, i) => ( + + {char === ' ' ? '\u00A0' : char} + + ))} +

+
+ +
*/} +
+ + +
+ ); +}; + +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 && ( +
+ + +
+ )} + + {/* 검색 폼 */} +
+
+ + setIsFocused(true)} + onBlur={handleBlur} + maxLength={searchMode === 'natural' ? 20 : undefined} + className={`w-full pl-16 pr-4 py-4 border rounded-lg focus:outline-none transition-all hover:shadow-md placeholder-gray-900 placeholder-opacity-100 text-black ${ + searchMode === 'keyword' + ? 'border-[#2BA89C] focus:ring-2 focus:ring-[#2BA89C]/20' + : 'border-[#2978F2] focus:ring-2 focus:ring-[#2978F2]/20' + }`} + /> + {searchMode === 'natural' && ( +
+ {searchQuery.length}/20 +
+ )} + + {/* 자동완성 드롭다운 */} + {isFocused && searchMode === 'keyword' && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ + {/* 검색 유형 선택 드롭다운 (키워드 모드일 때만 표시) */} + {searchMode === 'keyword' && ( +
+ + + {/* 드롭다운 메뉴 */} + {isDropdownOpen && ( +
+ {Object.entries(searchTypes).map(([type, label]) => ( + + ))} +
+ )} +
+ )} +
+ + +
+ ); +}; + +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.drugName} + +
+ {/* 제약회사 */} +
+ + 제약회사 + + + {medicine.company} + +
+ {/* 효능 */} +
+ + 효 능 + + + {medicine.efficacy.join(', ')} + +
+
+
+
+ )) + ) : ( +
+

검색 결과가 없습니다.

+

다른 검색어로 다시 시도해 보세요.

+
+ )} +
+ + {/* 페이지네이션 개선 */} + {totalResults > 0 && ( +
+ {/* 이전 페이지 버튼 */} + {currentPage > 1 && ( + + )} + + {/* 페이지 번호 버튼 */} + {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 ( + + ); + })} + + {/* 다음 페이지 버튼 */} + {currentPage < Math.ceil(totalResults / itemsPerPage) && ( + + )} +
+ )} +
+
+
+ ); +}; + +export default SearchPage; \ No newline at end of file