diff --git a/next.config.ts b/next.config.ts index 5a1ade85..dca67ead 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,12 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { images: { - domains: ['www.thecocktaildb.com'], + remotePatterns: [ + { + protocol: 'https', + hostname:'www.thecocktaildb.com' + } + ] }, env: { NPUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, diff --git a/src/app/community/page.tsx b/src/app/community/page.tsx index 86fbf54a..5a5ec3ba 100644 --- a/src/app/community/page.tsx +++ b/src/app/community/page.tsx @@ -3,7 +3,7 @@ import PageHeader from '@/domains/shared/components/page-header/PageHeader'; import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'SSOUL | 커뮤니티', + title: '커뮤니티', description: '칵테일에 관한 모든 이야기', }; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index d2548bbd..eaef7d76 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,7 +4,7 @@ import loginBg from '@/shared/assets/images/login_bg.webp'; import SocialLogin from '@/domains/login/main/SocialLogin'; export const metadata: Metadata = { - title: 'SSOUL | 로그인', + title: '로그인', description: '칵테일을 좋아하는 사람들을 위한 서비스 로그인 페이지', }; diff --git a/src/app/mypage/my-active/my-comment/page.tsx b/src/app/mypage/my-active/my-comment/page.tsx index 480939f8..69f24c97 100644 --- a/src/app/mypage/my-active/my-comment/page.tsx +++ b/src/app/mypage/my-active/my-comment/page.tsx @@ -2,7 +2,7 @@ import MyComment from '@/domains/mypage/components/pages/my-active/MyComment'; import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'SSOUL | 마이페이지', + title: '마이페이지', description: 'SSOUL 서비스에서 나의 활동을 관리할 수 있는 페이지입니다', }; diff --git a/src/app/mypage/my-active/my-like/page.tsx b/src/app/mypage/my-active/my-like/page.tsx index fb9205a4..2544af73 100644 --- a/src/app/mypage/my-active/my-like/page.tsx +++ b/src/app/mypage/my-active/my-like/page.tsx @@ -3,7 +3,7 @@ import MyLike from '@/domains/mypage/components/pages/my-active/MyLike'; import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'SSOUL | 마이페이지', + title: '마이페이지', description: 'SSOUL 서비스에서 나의 활동을 관리할 수 있는 페이지입니다', }; diff --git a/src/app/mypage/my-active/my-post/page.tsx b/src/app/mypage/my-active/my-post/page.tsx index 775ac070..21b0b161 100644 --- a/src/app/mypage/my-active/my-post/page.tsx +++ b/src/app/mypage/my-active/my-post/page.tsx @@ -2,7 +2,7 @@ import MyPost from '@/domains/mypage/components/pages/my-active/MyPost'; import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'SSOUL | 마이페이지', + title: '마이페이지', description: 'SSOUL 서비스에서 나의 활동을 관리할 수 있는 페이지입니다', }; diff --git a/src/app/mypage/my-alarm/page.tsx b/src/app/mypage/my-alarm/page.tsx index c094bd4d..68cb5d42 100644 --- a/src/app/mypage/my-alarm/page.tsx +++ b/src/app/mypage/my-alarm/page.tsx @@ -3,7 +3,7 @@ import MyAlarm from '@/domains/mypage/components/pages/my-alarm/MyAlarm'; import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'SSOUL | 마이페이지', + title: '마이페이지', description: 'SSOUL 서비스에서 나의 활동을 관리할 수 있는 페이지입니다', }; diff --git a/src/app/mypage/my-bar/page.tsx b/src/app/mypage/my-bar/page.tsx index 54fa638d..d726dbac 100644 --- a/src/app/mypage/my-bar/page.tsx +++ b/src/app/mypage/my-bar/page.tsx @@ -2,7 +2,7 @@ import MyBar from '@/domains/mypage/components/pages/my-bar/MyBar'; import { Metadata } from 'next'; export const metadata: Metadata = { - title: 'SSOUL | 마이페이지', + title: '마이페이지', description: 'SSOUL 서비스에서 나의 활동을 관리할 수 있는 페이지입니다', }; diff --git a/src/app/recipe/page.tsx b/src/app/recipe/page.tsx index 06cb569c..5ab5d9d1 100644 --- a/src/app/recipe/page.tsx +++ b/src/app/recipe/page.tsx @@ -1,14 +1,12 @@ import { Metadata } from 'next'; -import Input from '@/shared/components/Input-box/Input'; -import Accordion from '../../domains/recipe/components/main/Accordion'; import PageHeader from '@/domains/shared/components/page-header/PageHeader'; import { Suspense } from 'react'; import SkeletonRecipe from '@/domains/recipe/skeleton/SkeletonRecipe'; -import Cocktails from '@/domains/recipe/main/Cocktails'; +import Cocktails from '@/domains/recipe/components/main/Cocktails'; export const metadata: Metadata = { - title: 'SSOUL | 칵테일레시피', + title: '칵테일레시피', description: '칵테일 레시피가 궁금하신 분들을 위한 레시피 페이지', }; @@ -20,15 +18,6 @@ function Page() {
}> -
- - -
diff --git a/src/domains/recipe/api/CocktailSearch.tsx b/src/domains/recipe/api/CocktailSearch.tsx new file mode 100644 index 00000000..aacd03fc --- /dev/null +++ b/src/domains/recipe/api/CocktailSearch.tsx @@ -0,0 +1,58 @@ +'use client'; +import { getApi } from '@/app/api/config/appConfig'; +import { Cocktail } from '../types/types'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +interface Props { + setData: Dispatch>; + setNoResults: Dispatch>; +} + +function CocktailSearch({ setData, setNoResults }: Props) { + const [alcoholStrengths, setAlcoholStrengths] = useState([]); + const [cocktailTypes, setCocktailTypes] = useState([]); + const [alcoholBaseTypes, setAlcoholBaseTypes] = useState([]); + + const searchApi = async (v?: string) => { + const keyword = v?.trim() ?? ''; + const body = { + keyword, + alcoholStrengths, + cocktailTypes, + alcoholBaseTypes, + page: 0, + size: 100, + }; + + if (!keyword && !alcoholStrengths.length && !cocktailTypes.length && !alcoholBaseTypes.length) { + setData([]); + setNoResults(false); + return null; + } + + const res = await fetch(`${getApi}/cocktails/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = await res.json(); + + setData(json.data); + setNoResults(json.data.length === 0); + }; + + useEffect(() => { + searchApi(); + }, [alcoholStrengths, cocktailTypes, alcoholBaseTypes]); + + return { + searchApi, + setAlcoholBaseTypes, + setAlcoholStrengths, + setCocktailTypes, + alcoholBaseTypes, + alcoholStrengths, + cocktailTypes, + }; +} +export default CocktailSearch; diff --git a/src/domains/recipe/api/RecipeFetch.tsx b/src/domains/recipe/api/RecipeFetch.tsx new file mode 100644 index 00000000..1fb008af --- /dev/null +++ b/src/domains/recipe/api/RecipeFetch.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { getApi } from '@/app/api/config/appConfig'; +import { Cocktail } from '../types/types'; +import { Dispatch, SetStateAction, useCallback } from 'react'; + +interface Props { + setData: React.Dispatch>; + lastId: number | null; + setLastId: Dispatch>; + hasNextPage: boolean; + setHasNextPage: Dispatch>; + SIZE?: number; +} +// api/cocktais fetch용 +export const RecipeFetch = ({ + setData, + lastId, + setLastId, + hasNextPage, + setHasNextPage, + SIZE = 20, +}: Props) => { + const fetchData = useCallback(async () => { + // 쿼리파라미터에 값 넣기 + if (!hasNextPage) return; + const url = new URL(`${getApi}/cocktails`); + url.searchParams.set('size', String(SIZE)); + if (typeof lastId === 'number') { + url.searchParams.set('lastId', String(lastId)); + } + + const res = await fetch(url.toString(), { method: 'GET' }); + if (!res.ok) throw new Error('레시피 데이터 요청실패'); + + const json = await res.json(); + const list: Cocktail[] = json.data ?? []; + + // 중복 아이디 에러있어서 Map으로 Merge + setData((prev) => + Array.from(new Map([...prev, ...list].map((i) => [i.cocktailId, i])).values()) + ); + + if (list.length > 0) { + setLastId(list[list.length - 1].cocktailId); + } + setHasNextPage(list.length === SIZE); + }, [hasNextPage, lastId, setData, setLastId, setHasNextPage, SIZE]); + return { fetchData }; +}; diff --git a/src/domains/recipe/components/details/BackBtn.tsx b/src/domains/recipe/components/details/BackBtn.tsx index 1a8fa46b..b4e2a6dc 100644 --- a/src/domains/recipe/components/details/BackBtn.tsx +++ b/src/domains/recipe/components/details/BackBtn.tsx @@ -1,14 +1,22 @@ 'use client'; + +import { useRouter } from 'next/navigation'; import Back from '@/shared/assets/icons/back_36.svg'; -import Link from 'next/link'; -function BackBtn() { +function BackButton() { + const router = useRouter(); + + const handleClick = () => { + const url = sessionStorage.getItem('saveUrl'); + if (!url) return; + router.push(url); + }; + return ( - ); } -export default BackBtn; + +export default BackButton; diff --git a/src/domains/recipe/components/main/Accordion.tsx b/src/domains/recipe/components/main/Accordion.tsx index 4364e627..685053d0 100644 --- a/src/domains/recipe/components/main/Accordion.tsx +++ b/src/domains/recipe/components/main/Accordion.tsx @@ -1,36 +1,164 @@ 'use client'; import SelectBox from '@/shared/components/select-box/SelectBox'; +import { Dispatch, SetStateAction, useEffect, useMemo } from 'react'; +import { useSearchParams, usePathname, useRouter } from 'next/navigation'; + +interface Props { + setAlcoholBaseTypes: Dispatch>; + setAlcoholStrengths: Dispatch>; + setCocktailTypes: Dispatch>; +} const SELECT_OPTIONS = [ { id: 'abv', - option: ['전체', '약한 도수', '가벼운 도수', '중간 도수', '센 도수', '매우 센 도수'], + option: [ + '전체', + '논알콜', + '약한 도수 (1~5%)', + '가벼운 도수 (6~15%)', + '중간 도수(16~25%)', + '센 도수(26~36%)', + '매우 센 도수(36%~)', + ], + map: { + 전체: null, + 논알콜: 'NON_ALCOHOLIC', + '약한 도수 (1~5%)': 'WEAK', + '가벼운 도수 (6~15%)': 'LIGHT', + '중간 도수(16~25%)': 'MEDIUM', + '센 도수(26~36%)': 'STRONG', + '매우 센 도수(36%~)': 'VERY_STRONG', + }, title: '도수', }, { id: 'base', - option: ['전체', '위스키', '진', '럼', '보드카', '데킬라', '리큐르'], + option: ['전체', '위스키', '브랜디', '진', '럼', '보드카', '데킬라', '리큐르', '와인', '기타'], + map: { + 전체: null, + 진: 'GIN', + 럼: 'RUM', + 보드카: 'VODKA', + 위스키: 'WHISKY', + 데킬라: 'TEQUILA', + 리큐르: 'LIQUEUR', + 브랜디: 'BRANDY', + 와인: 'WINE', + 기타: 'OTHER', + }, title: '베이스', }, { id: 'glass', option: ['전체', '클래식', '롱', '슈터', '숏'], + map: { + 전체: null, + 숏: 'SHORT', + 롱: 'LONG', + 클래식: 'CLASSIC', + 슈터: 'SHOOTER', + }, title: '글라스', }, ]; -function Accordion() { +function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // url 파라미터에서 값을 가져와 POST를 도와줌 + useEffect(() => { + const abv = searchParams.get('abv'); + const base = searchParams.get('base'); + const glass = searchParams.get('glass'); + + setAlcoholStrengths(abv ? [abv] : []); + setAlcoholBaseTypes(base ? [base] : []); + setCocktailTypes(glass ? [glass] : []); + }, [searchParams, setAlcoholStrengths, setAlcoholBaseTypes, setCocktailTypes]); + + // 파라미터 값을 한글로 역 변환해주는 함수 + const getDisplayValue = (id: string, code: string | null): string => { + if (!code) return '전체'; + + const optionGroup = SELECT_OPTIONS.find((opt) => opt.id === id); + if (!optionGroup) return '전체'; + + // map 객체에서 code와 일치하는 key(한글)를 찾기 + const entry = Object.entries(optionGroup.map).find(([_, value]) => value === code); + return entry ? entry[0] : '전체'; + }; + + // URL 파라미터에서 현재 선택된 값 가져오기 아코디언 UI에 적용 + const currentValues = useMemo(() => { + return { + abv: getDisplayValue('abv', searchParams.get('abv')), + base: getDisplayValue('base', searchParams.get('base')), + glass: getDisplayValue('glass', searchParams.get('glass')), + }; + }, [searchParams]); + + const handleSelect = (id: string, value: string) => { + const optionGroup = SELECT_OPTIONS.find((opt) => opt.id === id); + if (!optionGroup) return; + + // 선택한 옵션의 밸류를 전달 + const code = optionGroup.map[value as keyof typeof optionGroup.map] ?? null; + const arr = code ? [code] : []; + + // 상태 즉시 업데이트 + switch (id) { + case 'abv': + setAlcoholStrengths(arr); + break; + case 'base': + setAlcoholBaseTypes(arr); + break; + case 'glass': + setCocktailTypes(arr); + break; + } + + // URL 업데이트 + const params = new URLSearchParams(searchParams.toString()); + + if (code) { + params.set(id, code); + } else { + params.delete(id); + } + + const queryString = params.toString(); + const newUrl = `${pathname}?${queryString}`; + + router.push(newUrl); + // shallow routing으로 URL만 변경 + // window.history.pushState({ ...window.history.state, as: newUrl, url: newUrl }, '', newUrl); + }; + return (
    {SELECT_OPTIONS.map(({ id, option, title }) => { + const currentValue = currentValues[id as keyof typeof currentValues]; + return (
  • - + handleSelect(id, value)} + />
  • ); })}
); } + export default Accordion; diff --git a/src/domains/recipe/main/CocktailFilter.tsx b/src/domains/recipe/components/main/CocktailFilter.tsx similarity index 74% rename from src/domains/recipe/main/CocktailFilter.tsx rename to src/domains/recipe/components/main/CocktailFilter.tsx index 79205d68..5d2c4a0b 100644 --- a/src/domains/recipe/main/CocktailFilter.tsx +++ b/src/domains/recipe/components/main/CocktailFilter.tsx @@ -1,9 +1,9 @@ import SelectBox from '@/shared/components/select-box/SelectBox'; -function CocktailFilter({ cocktailsEA }: { cocktailsEA: number[] }) { +function CocktailFilter({ cocktailsEA }: { cocktailsEA: string }) { return (
-

{cocktailsEA[0]}개

+

{cocktailsEA}개

); diff --git a/src/domains/recipe/main/CocktailList.tsx b/src/domains/recipe/components/main/CocktailList.tsx similarity index 79% rename from src/domains/recipe/main/CocktailList.tsx rename to src/domains/recipe/components/main/CocktailList.tsx index c8946cae..56c0ce7b 100644 --- a/src/domains/recipe/main/CocktailList.tsx +++ b/src/domains/recipe/components/main/CocktailList.tsx @@ -3,12 +3,12 @@ import { useRef } from 'react'; import Link from 'next/link'; import { useIntersectionObserver } from '@/domains/shared/hook/useIntersectionObserver'; -import { Cocktail } from '../types/types'; +import { Cocktail } from '../../types/types'; import CocktailCard from '@/domains/shared/components/cocktail-card/CocktailCard'; interface Props { cocktails: Cocktail[]; - RecipeFetch: (cursor?: string | undefined) => Promise; + RecipeFetch?: (cursor?: string | undefined) => Promise; hasNextPage: boolean; lastId: number | null; onItemClick: () => void; @@ -17,14 +17,20 @@ interface Props { function CocktailList({ cocktails, RecipeFetch, hasNextPage, lastId, onItemClick }: Props) { const cocktailRef = useRef(null); const onIntersect: IntersectionObserverCallback = ([entry]) => { + if (!RecipeFetch) return; if (!lastId) return; - if (entry.isIntersecting && lastId > 1) { + if (entry.isIntersecting) { RecipeFetch(); } }; useIntersectionObserver(cocktailRef, onIntersect, hasNextPage); + const handleClick = () => { + sessionStorage.setItem('listScrollY', String(window.scrollY)); + sessionStorage.setItem('saveUrl', String(location.href)); + }; + return (
    - { - sessionStorage.setItem('listScrollY', String(window.scrollY)); - }} - className="block" - > + void; +} + +function CocktailSearchBar({ value, onChange }: Props) { + return ( + onChange(e.target.value)} + variant="search" + className="w-full md:max-w-80" + /> + ); +} +export default CocktailSearchBar; diff --git a/src/domains/recipe/components/main/Cocktails.tsx b/src/domains/recipe/components/main/Cocktails.tsx new file mode 100644 index 00000000..c756d0d4 --- /dev/null +++ b/src/domains/recipe/components/main/Cocktails.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useEffect } from 'react'; +import CocktailFilter from './CocktailFilter'; +import CocktailList from './CocktailList'; +import { Cocktail } from '../../types/types'; +import { useMemoScroll } from '../../../shared/hook/useMemoScroll'; +import Accordion from './Accordion'; +import { RecipeFetch } from '../../api/RecipeFetch'; +import CocktailSearchBar from './CocktailSearchBar'; +import useSearchControl from '../../hook/useSearchControl'; +import CocktailSearch from '../../api/CocktailSearch'; + +function Cocktails() { + const { + data, + setData, + lastId, + setLastId, + hasNextPage, + setHasNextPage, + handleItemClick, + shouldFetch, + } = useMemoScroll({ + storageKey: 'cocktails_scroll_state', + eventName: 'resetCocktailsScroll', + }); + const { inputValue, keyword, isSearching, onInputChange, noResults, setNoResults } = + useSearchControl({ delay: 300, storageKey: 'cocktails_scoll_state' }); + const { fetchData } = RecipeFetch({ setData, lastId, setLastId, hasNextPage, setHasNextPage }); + const { + searchApi, + setAlcoholBaseTypes, + setAlcoholStrengths, + setCocktailTypes, + alcoholBaseTypes, + cocktailTypes, + alcoholStrengths, + } = CocktailSearch({ + setData, + setNoResults, + }); + + const countLabel = isSearching + ? hasNextPage + ? `검색결과 현재 ${data.length}+` + : `검색결과 총 ${data.length}` + : hasNextPage + ? `전체 ${data.length}+` + : `전체 ${data.length}`; + + // 초기 로드 시 검색어가 있으면 검색 실행 + useEffect(() => { + const readyForFirstLoad = !isSearching && hasNextPage && lastId == null && data.length === 0; + + if (readyForFirstLoad) { + fetchData(); + } + }, [hasNextPage, lastId]); + + // 검색어 변경 시 + useEffect(() => { + if (isSearching && keyword.trim()) { + setLastId(null); + setHasNextPage(false); + searchApi(keyword.trim()); + } else if (!isSearching) { + // 검색어를 지웠을 때만 초기화 + setData([]); + setLastId(null); + setHasNextPage(true); + } + }, [keyword, isSearching, alcoholBaseTypes, alcoholStrengths, cocktailTypes]); + + // 일반 fetch + useEffect(() => { + if (!shouldFetch || isSearching) return; + fetchData(); + }, [shouldFetch, isSearching]); + + return ( +
    +
    + + +
    + + + +
    + {isSearching && noResults ? ( +
    검색결과가 없습니다.
    + ) : ( + + )} +
    +
    + ); +} + +export default Cocktails; diff --git a/src/domains/recipe/details/DetailItem.tsx b/src/domains/recipe/details/DetailItem.tsx index 1eba2c14..88c17c5f 100644 --- a/src/domains/recipe/details/DetailItem.tsx +++ b/src/domains/recipe/details/DetailItem.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; import Label from '@/domains/shared/components/label/Label'; import AbvGraph from '@/domains/shared/components/abv-graph/AbvGraph'; import { labelTitle } from '../utills/labelTitle'; -import useGlass from './hook/useGlass'; +import useGlass from '../hook/useGlass'; interface Props { name: string; diff --git a/src/domains/recipe/details/DetailRecipe.tsx b/src/domains/recipe/details/DetailRecipe.tsx index aa4c7e54..810ce7b1 100644 --- a/src/domains/recipe/details/DetailRecipe.tsx +++ b/src/domains/recipe/details/DetailRecipe.tsx @@ -1,4 +1,4 @@ -import { ozToMl } from './hook/ozToMl'; +import { ozToMl } from '../hook/ozToMl'; type Recipe = { ingredientName: string; diff --git a/src/domains/recipe/details/hook/ozToMl.ts b/src/domains/recipe/hook/ozToMl.ts similarity index 59% rename from src/domains/recipe/details/hook/ozToMl.ts rename to src/domains/recipe/hook/ozToMl.ts index fa3e33f0..f6e1de62 100644 --- a/src/domains/recipe/details/hook/ozToMl.ts +++ b/src/domains/recipe/hook/ozToMl.ts @@ -1,22 +1,23 @@ -// 유니코드 분수 매핑 (소수점 대신 정수 ml로 변환) +// 분수 매핑 const FRAC_MAP: Record = { - '¼': 8, // 0.25 * 30 - '½': 15, // 0.5 * 30 - '¾': 23, // 0.75 * 30 - '⅓': 10, // 1/3 * 30 ≈ 10 - '⅔': 20, // 2/3 * 30 ≈ 20 - '⅕': 6, // 1/5 * 30 - '⅖': 12, // 2/5 * 30 - '⅗': 18, // 3/5 * 30 - '⅘': 24, // 4/5 * 30 - '⅙': 5, // 1/6 * 30 - '⅚': 25, // 5/6 * 30 - '⅛': 4, // 1/8 * 30 - '⅜': 11, // 3/8 * 30 - '⅝': 19, // 5/8 * 30 - '⅞': 26, // 7/8 * 30 + '¼': 8, + '½': 15, + '¾': 23, + '⅓': 10, + '⅔': 20, + '⅕': 6, + '⅖': 12, + '⅗': 18, + '⅘': 24, + '⅙': 5, + '⅚': 25, + '⅛': 4, + '⅜': 11, + '⅝': 19, + '⅞': 26, }; +// 정규식 분리 용 class const FRAC_CLASS = Object.keys(FRAC_MAP).join(''); export function ozToMl(input: string): number | '' { diff --git a/src/domains/recipe/details/hook/useGlass.tsx b/src/domains/recipe/hook/useGlass.tsx similarity index 100% rename from src/domains/recipe/details/hook/useGlass.tsx rename to src/domains/recipe/hook/useGlass.tsx diff --git a/src/domains/recipe/hook/useSearchControl.tsx b/src/domains/recipe/hook/useSearchControl.tsx new file mode 100644 index 00000000..a00e40f1 --- /dev/null +++ b/src/domains/recipe/hook/useSearchControl.tsx @@ -0,0 +1,67 @@ +import { debounce } from '@/shared/utills/debounce'; +import { useEffect, useMemo, useState } from 'react'; + +interface UseSearchControlProps { + delay?: number; + storageKey?: string; // 검색 상태 저장용 키 +} + +function useSearchControl({ delay = 300, storageKey }: UseSearchControlProps) { + // 초기값을 sessionStorage에서 복원 + const [inputValue, setInputValue] = useState(() => { + if (typeof window === 'undefined' || !storageKey) return ''; + const saved = sessionStorage.getItem(`${storageKey}_search`); + return saved ? JSON.parse(saved).inputValue : ''; + }); + + const [keyword, setKeyword] = useState(() => { + if (typeof window === 'undefined' || !storageKey) return ''; + const saved = sessionStorage.getItem(`${storageKey}_search`); + return saved ? JSON.parse(saved).keyword : ''; + }); + + const [noResults, setNoResults] = useState(false); + + const isSearching = keyword.trim().length > 0; + + // 검색 상태를 sessionStorage에 저장 + useEffect(() => { + if (!storageKey) return; + sessionStorage.setItem( + `${storageKey}_search`, + JSON.stringify({ + inputValue, + keyword, + }) + ); + }, [inputValue, keyword, storageKey]); + + const debouncedKeyword = useMemo(() => debounce((v: string) => setKeyword(v), delay), [delay]); + + const onInputChange = (v: string) => { + setInputValue(v); + debouncedKeyword(v); + }; + + // 검색 상태 초기화 함수 + const resetSearch = () => { + setInputValue(''); + setKeyword(''); + setNoResults(false); + if (storageKey) { + sessionStorage.removeItem(`${storageKey}_search`); + } + }; + + return { + inputValue, + keyword, + isSearching, + onInputChange, + noResults, + setNoResults, + resetSearch, + }; +} + +export default useSearchControl; diff --git a/src/domains/recipe/main/Cocktails.tsx b/src/domains/recipe/main/Cocktails.tsx deleted file mode 100644 index 697bb095..00000000 --- a/src/domains/recipe/main/Cocktails.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import CocktailFilter from './CocktailFilter'; -import CocktailList from './CocktailList'; -import { getApi } from '@/app/api/config/appConfig'; -import { Cocktail } from '../types/types'; -import { useMemoScroll } from '../../shared/hook/useMemoScroll'; - -function Cocktails() { - const SIZE = 20; - const [isLoading, setIsLoading] = useState(false); - - const { - data, - setData, - lastId, - setLastId, - hasNextPage, - setHasNextPage, - handleItemClick, - shouldFetch, - } = useMemoScroll({ - storageKey: 'cocktails_scroll_state', - eventName: 'resetCocktailsScroll', - }); - - const num = data.map((a) => a.cocktailId); - - const RecipeFetch = async () => { - if (isLoading || !hasNextPage) { - return; - } - - setIsLoading(true); - - try { - const url = new URL(`${getApi}/cocktails`); - url.searchParams.set('size', String(SIZE)); - if (typeof lastId === 'number') { - url.searchParams.set('lastId', String(lastId)); - } - - const res = await fetch(url.toString()); - if (!res.ok) throw new Error('레시피 데이터 요청실패'); - - const json = await res.json(); - const list: Cocktail[] = json.data ?? []; - - setData((prev) => - Array.from(new Map([...prev, ...list].map((i) => [i.cocktailId, i])).values()) - ); - - if (list.length > 0) { - setLastId(list[list.length - 1].cocktailId); - } - - setHasNextPage(list.length === SIZE); - } catch (err) { - console.error(err); - } finally { - setIsLoading(false); - } - }; - - // shouldFetch가 true일 때 fetch 실행 - useEffect(() => { - if (shouldFetch && data.length === 0) { - RecipeFetch(); - } - }, [shouldFetch]); - - return ( -
    - -
    - -
    -
    - ); -} - -export default Cocktails; diff --git a/src/domains/shared/components/keep/Keep.tsx b/src/domains/shared/components/keep/Keep.tsx index 2a6b1c5d..5c36cb86 100644 --- a/src/domains/shared/components/keep/Keep.tsx +++ b/src/domains/shared/components/keep/Keep.tsx @@ -14,6 +14,7 @@ interface Props { function Keep({ className, cocktailId }: Props) { const [isClick, setIsClick] = useState(false); + const handleClick = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); diff --git a/src/domains/shared/hook/useMemoScroll.ts b/src/domains/shared/hook/useMemoScroll.ts index a2bc0b4e..5be220e7 100644 --- a/src/domains/shared/hook/useMemoScroll.ts +++ b/src/domains/shared/hook/useMemoScroll.ts @@ -1,4 +1,3 @@ -// hooks/useScrollRestoration.ts (또는 useMemoScroll.ts) import { useEffect, useRef, useState, useCallback } from 'react'; interface UseScrollRestorationProps { @@ -14,6 +13,8 @@ interface ScrollState { timestamp: number; } +// 뒤로가기시 스크롤위치 기억 함수 + export function useMemoScroll({ storageKey, eventName = 'resetScroll', @@ -25,7 +26,6 @@ export function useMemoScroll({ const [data, setData] = useState([]); const [lastId, setLastId] = useState(null); const [hasNextPage, setHasNextPage] = useState(true); - // fetch필요 여부 const [shouldFetch, setShouldFetch] = useState(false); // 스크롤 복원중일 때 값이 바뀜 diff --git a/src/shared/components/Input-box/Input.tsx b/src/shared/components/Input-box/Input.tsx index 3f2fb1cf..d8bf2b44 100644 --- a/src/shared/components/Input-box/Input.tsx +++ b/src/shared/components/Input-box/Input.tsx @@ -4,15 +4,10 @@ import { cva } from 'class-variance-authority'; import { HTMLInputTypeAttribute, Ref } from 'react'; import Search from '@/shared/assets/icons/search_32.svg'; import Button from '../button/Button'; -// select나올떄 자연스러운 처리 화살표 로테이트 [x] -// 인풋 타입받을 수 있게 수정 [x] -// 인풋접근성 라벨이 중요함 라벨 을 div에 묶어서 하거나 label로 인풋감싸거나 div로 묶고 같은 선상에두게 [x] -// div안에 라벨이랑감싸기 [x] -// 텍스트 에어리어 버전도 만들기 -// 인풋 잘림 = 라인height 인풋 높이랑 맞춰두기 [x] interface Props { placeholder: string; + value?: string; type?: HTMLInputTypeAttribute; ref?: Ref; size?: 'default' | 'lg'; @@ -44,6 +39,7 @@ function Input({ size, variant = 'default', className, + value, id, onChange, ...rest @@ -54,6 +50,7 @@ function Input({ ; option: string[]; title: string; + value?: string; onChange?: (value: string) => void; use?: string; } -// groupKey를 Props로 내릴경우 == 아코디언 없는 경우 == select박스 -function SelectBox({ id, groupKey, ref, option, title, onChange, use }: Props) { +function SelectBox({ id, groupKey, ref, option, title, value, onChange, use }: Props) { const [isOpen, setIsOpen] = useState(false); - const [select, setSelect] = useState(''); + const [select, setSelect] = useState(value || ''); const menuRef = useRef(null); const ingroup = !!groupKey; - // groupKey가 있을경우 true - // groupkey일 경우 전달받은 ID로 식별 아닐경우 title로 식별 const keyId = useMemo(() => id ?? title, [id, title]); - // id가 없을경우 title로 키 아이디를 받음 + + // value prop이 변경되면 select state도 업데이트 + useEffect(() => { + if (value !== undefined) { + setSelect(value); + } + }, [value]); useCloseOutside({ menuRef, @@ -39,22 +43,18 @@ function SelectBox({ id, groupKey, ref, option, title, onChange, use }: Props) { const { openId, toggleGroup, closeGroup } = useAccordionStore( useShallow((s) => ({ openId: ingroup ? (s.openByGroup[groupKey] ?? null) : null, - // 그룹키가 없으면 openId == null 따라서 state로 관리됨 toggleGroup: s.toggle, closeGroup: s.closeGroup, })) ); - //groupkey가 있을 떄와 없을때로 구분해서 state혹은 store로 관리 const localOpen = ingroup ? openId === keyId : isOpen; - // 그룹일 경우 filter와 id abv | base | glass 를 const toggle = () => { if (ingroup) toggleGroup(groupKey, keyId); else setIsOpen((prev) => { const next = !prev; - console.log('TOGGLE BTN CLICK:', { prev, next }); return next; }); }; @@ -71,11 +71,6 @@ function SelectBox({ id, groupKey, ref, option, title, onChange, use }: Props) { close(); }; - useCloseOutside({ - menuRef, - onClose: close, - }); - return (