Skip to content

Commit

Permalink
Feat: 검색 플로우 로직 구현 (#37)
Browse files Browse the repository at this point in the history
* fix: 이전으로 버튼 라우팅 오류 수정

* feat: 실시간 검색어 랜덤 다섯개 선정

* feat: 검색 결과 공공 api 연결

* fix: 결과 렌더링 return문 빠진 오류 수정

* feat: 무한 스크롤 구현 - hook 작성

* feat: 무한 스크롤 로딩처리

* feat: 연관 검색어 로직

* refactor: SearchPage useRef 사용 및 리팩토링

* refactor: SearchResultPage 리팩토링

* feat: 연관 검색어 누르면 검색 결과 이동

* feat: 검색 결과 없음 뷰 구현

* feat: 좋아요 누름 구현

* fix: 이전으로 가기 관련 오류 수정

* feat: 연관 검색어 키워드 색 다르게 처리

* feat: 최근 검색어 setStorage 조건 추가

* feat: 공공 api 호출 IOS -> ETC

* feat: placecard background linear gradient 추가

* feat: 장소 이름 말줄임표 적용

* feat: 검색 결과 없을시 인기 검색어 없애기

* feat: contentTypeId 추가

* chore: stylelint fix
  • Loading branch information
seobbang authored Sep 22, 2024
1 parent f6c9490 commit 49bd777
Show file tree
Hide file tree
Showing 24 changed files with 704 additions and 242 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dependencies": {
"@supabase/supabase-js": "^2.45.2",
"axios": "^1.7.2",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1"
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file added src/api/public/main.ts
Empty file.
4 changes: 4 additions & 0 deletions src/api/index.ts → src/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ const client: AxiosInstance = axios.create({
});

export default client;

export const publicDataClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_PUBLIC_DATA_BASE_URL,
});
33 changes: 33 additions & 0 deletions src/apis/public/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 검색 관련 공공 데이터 API

import { Response } from '@/types/public';
import { SearchWord } from '@/types/search';

import { publicDataClient } from '..';

interface searchKeywordParams {
pageNo: number;
numOfRows: number;
MobileOS: 'IOS' | 'AND' | 'WIN' | 'ETC';
keyword: string;
contentTypeId: number;
}

export const getSearchKeyword = async (paramsInfo: searchKeywordParams) => {
let params = `MobileApp=UNITRIP&_type=json&arrange=O&serviceKey=${import.meta.env.VITE_PUBLIC_DATA_SERVICE_KEY}`;

for (const [key, value] of Object.entries(paramsInfo)) {
params += `&${key}=${value}`;
}

const {
data: {
response: {
body: { items },
},
},
} = await publicDataClient.get<Response<SearchWord>>(
`/searchKeyword1?${params}`,
);
return items;
};
3 changes: 3 additions & 0 deletions src/assets/icon/icon-heart-fill-mono.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/assets/icon/icon_big_info.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/assets/icon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export { default as HearingTypeIcon } from './icon_hearing_type.svg?react';
export { default as InfantTypeIcon } from './icon_infant_type.svg?react';
export { default as NoneTypeIcon } from './icon_none_type.svg?react';
export { default as PhysicalTypeIcon } from './icon_physical_type.svg?react';
export { default as BigInfoIcon } from './icon_big_info.svg?react';
export { default as HeartFillMonoIcon } from './icon-heart-fill-mono.svg?react';

// Universal icon
export { default as AudioGuideActiveIcon } from './icon_audioguide_active.svg?react';
Expand Down
68 changes: 56 additions & 12 deletions src/components/PlaceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,58 @@
import { css } from '@emotion/react';
import { useState } from 'react';
import { Link } from 'react-router-dom';

import { HeartMonoIcon, PinLocationMonoIcon } from '@/assets/icon';
import {
HeartFillMonoIcon,
HeartMonoIcon,
PinLocationMonoIcon,
} from '@/assets/icon';
import { COLORS, FONTS } from '@/styles/constants';

interface PlaceCardProps {
placeName: string;
address: string;
imgSrc: string;
onClickHeart?: () => void;
}

/**
* @param placeName 장소 이름
* @param address 주소
* @param imgSrc 대표 사진
* @param onClickHeart 하트 눌렀을 때 실행 함수
*/

const PlaceCard = (props: PlaceCardProps) => {
const { placeName, address } = props;
const { placeName, address, imgSrc, onClickHeart = () => {} } = props;

const [isHeart, setIsHeart] = useState(false);

const handleOnClick = () => {
setIsHeart((prev) => !prev);
onClickHeart();
};

return (
<Link to="" css={cardContainerCss}>
<button type="button">
<HeartMonoIcon css={iconCss} />
</button>
<p css={titleCss}>{placeName}</p>
<p css={addressCss}>
<PinLocationMonoIcon /> <span>{address}</span>
</p>
<Link to="" css={cardContainerCss(imgSrc, placeName)}>
<div css={backgroundCss}>
<button type="button" onClick={handleOnClick} css={iconCss}>
{isHeart ? <HeartFillMonoIcon /> : <HeartMonoIcon />}
</button>
<p css={titleCss}>{placeName}</p>
{address && (
<p css={addressCss}>
<PinLocationMonoIcon /> <span>{address}</span>
</p>
)}
</div>
</Link>
);
};

export default PlaceCard;

const cardContainerCss = css`
const cardContainerCss = (imgSrc: string, placeName: string) => css`
display: flex;
flex-direction: column;
position: relative;
Expand All @@ -40,16 +61,39 @@ const cardContainerCss = css`
height: 16.8rem;
border-radius: 1.2rem;
background-color: gray;
background-image: url(${imgSrc});
background-size: cover;
background-position: center center;
background-color: ${placeName ? COLORS.gray4 : COLORS.gray2};
`;

const backgroundCss = css`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 16.8rem;
border-radius: 1.2rem;
color: ${COLORS.white};
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.34) 100%
);
`;

const titleCss = css`
margin: 9.4rem 0 0 1.6rem;
${FONTS.H3};
width: calc(100% - 1.6rem);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;

const addressCss = css`
Expand Down
8 changes: 8 additions & 0 deletions src/hooks/use-async-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useEffect } from 'react';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useAsyncEffect = (effect: () => Promise<void>, deps: any[]) => {
useEffect(() => {
effect();
}, deps);
};
46 changes: 46 additions & 0 deletions src/hooks/use-infinite-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { MutableRefObject, useEffect, useRef } from 'react';

interface InfiniteScrollParams {
options?: { root: Element | null; rootMargin: string; threshold: number };
handleObserver: (
observer: IntersectionObserver,
target: MutableRefObject<HTMLElement | null>,
page: MutableRefObject<number>,
) => Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deps: any[];
}

/**
* @param options IntersectionObserver options
* @param handleObserver target 감지시 실행함수
* @param deps useEffect 의존성 배열
*/

export const useInfiniteScroll = ({
options,
handleObserver,
deps,
}: InfiniteScrollParams) => {
const target = useRef<HTMLDivElement | null>(null);
const page = useRef(1);

useEffect(() => {
page.current = 1;

const observer = new IntersectionObserver((entries, observer) => {
if (entries[0].intersectionRatio <= 0) return;

handleObserver(observer, target, page);
}, options);

//주시 시작
target.current && observer.observe(target.current);

return () => {
target.current && observer.unobserve(target.current);
};
}, deps);

return target;
};
3 changes: 3 additions & 0 deletions src/types/public.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Response<T> {
response: T;
}
39 changes: 39 additions & 0 deletions src/types/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface SearchResItem {
cat2: string;
cat3: string;
tel: string;
modifiedtime: string;
sigungucode: string;
contentid: string;
mlevel: string;
title: string;
addr1: string;
addr2: string;
areacode: string;
booktour: string;
cat1: string;
firstimage2: string;
mapx: string;
mapy: string;
cpyrhtDivCd: string;
contenttypeid: string;
createdtime: string;
firstimage: string;
}

export interface SearchWord {
header: {
resultCode: string;
resultMsg: string;
};
body: {
numOfRows: number;
pageNo: number;
totalCount: number;
items:
| {
item: SearchResItem[];
}
| '';
};
}
1 change: 1 addition & 0 deletions src/utils/storageSearchWord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const getStorageSearchWord = (): string[] => {
};

export const setStorageSearchWord = (newValue: string) => {
if (newValue === '') return;
const previousList: string[] = JSON.parse(localStorage.getItem(key) || '[]');

// 이미 존재하는지 확인
Expand Down
56 changes: 0 additions & 56 deletions src/views/Search/components/RelatedWordList.tsx

This file was deleted.

Loading

0 comments on commit 49bd777

Please sign in to comment.