Skip to content

Commit

Permalink
Feat: business logic (#4)
Browse files Browse the repository at this point in the history
[ business-logic ] chore: zustand, react-query 라이브러리 추가
- fetch 및 상태 관리를 위해 @tanstack/react-query
- 전역상태관리를 위해 zustand
- 데이터 불변성 유지를 위해 Immer

[ business-logic ] feat: NFT 쿼리 및 타입 정의
- NFT 관련 무한 스크롤 쿼리 구현 (Top Banner, Scroll List)
- React Query 무한 스크롤 옵션 및 쿼리 키 설정
- 메인 페이지 초기 데이터 fetching 로직 추가
- NFT 관련 타입 정의

[ business-logic ] feat: Zustand 메인 스토어 및 타입 정의
- 메인 페이지 상태 관리를 위한 Zustand 스토어 생성
- 탭 상태 및 상태 변경 액션 정의
- Immer 미들웨어를 사용한 불변성 유지
- 타입 안전성을 위한 타입 정의

[ business-logic ] feat: Main 페이지 데이터 페칭, 스켈레톤 UI 구현
- Top NFT섹션 스켈레톤 UI 추가
- 데이터 페칭 로직 추가

[ business-logic ] feat: Add React Query DevTools for development debugging

[ business-logic ] refactor: LazyImage 컴포넌트 스켈레톤 UI Wrapper 추가
- 스켈레톤 UI에 루트 클래스명 적용
- 스켈레톤 로딩 시 래퍼 div 추가로 스타일링 유연성 개선

[ business-logic ] feat: VirtualScroller 무한 스크롤 기능 추가 ( 데이터페칭 )
- 무한 스크롤 상태 타입 정의 (infiniteScrollStatus)
- 마지막 아이템 감지 시 다음 페이지 데이터 로드 로직 구현
- 타입 파일 분리로 코드 구조화

[ business-logic ] refactor: Main, Sub 페이지 스켈레톤 UI 및 데이터 페칭 로직 개선
- 메인/서브 페이지 스켈레톤 UI 로직 리팩토링
- 데이터 로딩 상태에 따른 null 데이터 처리
- 무한 스크롤 및 데이터 페칭 상태 관리 개선
- 타입 정의 및 컴포넌트 로직 최적화

[ business-logic ] feat: TypeScript 유형 추출을 위한 ArrayItem 유틸리티 유형 추가
- 배열에서 요소 유형을 추출하기 위한 새로운 유틸리티 유형
- 배열 항목 유형을 추론하는 유형 안전 방법을 제공

[ business-logic ] bugfix: react-query 타입 문제
- 신규버전부터 key에 대한 추론값 이슈
* TanStack/query#8453

[ business-logic ] chore: Disable react-hooks/exhaustive-deps ESLint rule
- Temporarily turn off exhaustive-deps rule to suppress warnings
- Allows more flexibility in dependency management for hooks

[ business-logic ] refactor: Signal 옵션 추가 및 쿼리키 옵션 수정
- 요청 취소를 활성화하기 위해 API 호출에 AbortSignal 지원 추가
- 더 나은 캐싱을 위해 무한 스크롤 목록 쿼리 키에 카테고리 포함
- useFetchMainPageInit에서 하드코딩된 페이지 매개변수 제거
- 쿼리 기능 유형의 안전성 및 유연성 향상

[ business-logic ] refactor: 메인 템플릿에서 무한 스크롤 및 데이터 가져오기 개선
- VirtualScroller 업데이트
- 무한스크롤 로딩 및 스켈레톤 UI를 지원하도록 메인템플릿 수정
- NFT 항목에 대한 데이터 렌더링 및 유형 관리 간소화

[ business-logic ] refactor: 이미지 생성기 seed parameter 추가

[ business-logic ] refactor: NFT카테고리 무한 스크롤 영역 gcTime 수정
- 0으로 설정
* 추후 고도화된 요구사항으로 변경 예정
  • Loading branch information
pmmm114 authored Feb 21, 2025
1 parent 3bf3ab1 commit b095bf0
Show file tree
Hide file tree
Showing 169 changed files with 1,614 additions and 870 deletions.
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-hooks/exhaustive-deps': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,22 @@
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-virtual": "^3.11.3",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^10.1.1",
"lucide-react": "^0.474.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-intersection-observer": "^9.15.1",
"react-router": "^7.1.2",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.3"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.4",
Expand Down
14 changes: 12 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { RouterProvider } from 'react-router';
import { router } from './router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

import { router } from '@/router';

const queryClient = new QueryClient();

function App() {
return <RouterProvider router={router} />;
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

export default App;
42 changes: 36 additions & 6 deletions src/api/dto/dto.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,55 @@
import { QueryParamsType } from '@/api/client/http-client';

/**
* NFT 카테고리
*/
export type TCategory = 'ALL' | 'ART' | 'GAME';

/**
* NFT 카드 아이템
*/
export interface ICardItem {
/**
* 아이디
*/
id: number;
/**
* 제목
*/
title: string;
image: string;
/**
* 설명
*/
description: string;
/**
* 이미지 URL
*/
imageUrl?: string;
/**
* 카테고리
*/
category: Omit<TCategory, 'ALL'>;
/**
* 푸터
*/
footer: string;
}

export interface IGetTopBannerReq extends QueryParamsType {
page: string;
export interface IGetTopBannerReq {
page: number;
}
export interface IGetTopBannerRes {
list: Array<ICardItem>;
nextCursor: string;
hasNext: boolean;
}

export interface IGetScrollListReq extends QueryParamsType {
category: 'BEST' | 'RECOMMEND' | 'NEW';
page: string;
export interface IGetScrollListReq {
category: TCategory;
page: number;
}
export interface IGetScrollListRes {
list: Array<ICardItem>;
nextCursor: string;
hasNext: boolean;
}
27 changes: 27 additions & 0 deletions src/api/service/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AxiosResponse } from 'axios';

import { CLIENT_API } from '.';

/**
* INFO: 타입 추출을 위한 유틸리티 타입 정의
*/
export type ExtractResponseType<T> =
T extends Promise<AxiosResponse<infer R, unknown>> ? R : never;

/**
* INFO: 배열 타입 추출을 위한 유틸리티 타입 정의
*/
export type ExtractArrayType<T> = T extends Array<infer R> ? R : never;

export type TGetTopBanner = typeof CLIENT_API.DemoControllerApi.getTopBanner;
export type TGetScrollList = typeof CLIENT_API.DemoControllerApi.getScrollList;

export type TGetTopBannerParams = Parameters<TGetTopBanner>[0];
export type TGetScrollListParams = Parameters<TGetScrollList>[0];

export type TGetTopBannerResponse = ExtractResponseType<
ReturnType<TGetTopBanner>
>;
export type TGetScrollListResponse = ExtractResponseType<
ReturnType<TGetScrollList>
>;
8 changes: 7 additions & 1 deletion src/components/molecules/LazyImage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ const LazyImage = <T extends React.ElementType = 'img'>({

if (!src)
return (
<Skeleton className={applyClass(S.LAZY_IMAGE_TAILWIND_CLASS.SKELETON)} />
<div
className={applyClass(S.LAZY_IMAGE_TAILWIND_CLASS.ROOT, rootClassName)}
>
<Skeleton
className={applyClass(S.LAZY_IMAGE_TAILWIND_CLASS.SKELETON)}
/>
</div>
);

return (
Expand Down
75 changes: 39 additions & 36 deletions src/components/molecules/VirtualScroller/index.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,10 @@
import { useRef } from 'react';
import { useEffect, useRef } from 'react';
import { useWindowVirtualizer } from '@tanstack/react-virtual';

import { applyClass } from '@/utils/style/tailwind';

import * as S from './styles';

/**
* useWindowVirtualizer의 파라미터 타입 추론
*/
type UseWindowVirtualizerParams = Parameters<typeof useWindowVirtualizer>[0];

/**
* VirtualScroller의 기본 Props
*/
interface IVirtualScrollerProps<T extends React.ElementType> {
as?: T;
/**
* 열 간격
*/
columnsGap?: number;
/**
* 가상 스크롤러 옵션
*/
virtualizerOptions: UseWindowVirtualizerParams;
/**
* 가상화 높이가 정해지는 컨테이너의 ClassName
*/
scrollInnerClassName?: string;
/**
* 아이템 렌더링 함수
*/
renderItem: (index: number) => React.ReactNode;
}
/**
* VirtualScroller ComponentProps
*/
export type TExtendsVirtualScrollerComponentProps<T extends React.ElementType> =
IVirtualScrollerProps<T> & React.ComponentPropsWithRef<T>;
import * as T from './types';

/**
* react-virtual 가상 스크롤러를 지원하는 컴포넌트로 치환
Expand All @@ -47,11 +15,12 @@ const VirtualScroller = <T extends React.ElementType = 'div'>({
as,
columnsGap = 0,
virtualizerOptions,
infiniteScrollStatus,
scrollInnerClassName,
renderItem,
className,
...rest
}: TExtendsVirtualScrollerComponentProps<T>) => {
}: T.TExtendsVirtualScrollerComponentProps<T>) => {
const Component = as || 'div';
/**
* 스크롤 컨테이너 Ref
Expand All @@ -61,7 +30,7 @@ const VirtualScroller = <T extends React.ElementType = 'div'>({
/**
* 가상 스크롤러
*/
const defaultOptions: UseWindowVirtualizerParams = {
const defaultOptions: T.TUseWindowVirtualizerParams = {
count: 0,
estimateSize: () => 0,
scrollMargin: listRef.current?.offsetTop ?? 0,
Expand All @@ -72,6 +41,40 @@ const VirtualScroller = <T extends React.ElementType = 'div'>({
...virtualizerOptions,
});

/**
* 무한 스크롤 처리
*/
useEffect(() => {
// INFO: 무한 스크롤 상태가 없으면 종료
if (!infiniteScrollStatus) return;

// INFO: 마지막 아이템 조회
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();

// INFO: 마지막 아이템이 없으면 종료
if (!lastItem) return;

// INFO: 무한 스크롤 조건 처리
if (
lastItem.index >=
infiniteScrollStatus?.itemsCount -
infiniteScrollStatus?.fetchingTriggerIndexFromEnd &&
infiniteScrollStatus?.hasNextPage &&
!infiniteScrollStatus?.isFetchingNextPage
) {
infiniteScrollStatus?.fetchNextPage?.();
}
}, [
infiniteScrollStatus,
infiniteScrollStatus?.hasNextPage,
infiniteScrollStatus?.fetchingTriggerIndexFromEnd,
infiniteScrollStatus?.isFetchingNextPage,
infiniteScrollStatus?.fetchNextPage,
infiniteScrollStatus?.itemsCount,
virtualizer,
virtualizer.getVirtualItems(),
]);

return (
<Component
ref={listRef}
Expand Down
61 changes: 61 additions & 0 deletions src/components/molecules/VirtualScroller/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useWindowVirtualizer } from '@tanstack/react-virtual';

/**
* useWindowVirtualizer의 파라미터 타입 추론
*/
export type TUseWindowVirtualizerParams = Parameters<
typeof useWindowVirtualizer
>[0];

/**
* 무한 스크롤 상태
*/
export type TInfiniteScrollStatus = Partial<
ReturnType<typeof useInfiniteQuery>
> & {
/**
* 로딩 플레이스홀더 개수
*/
loadingPlaceholderCount?: number;
/**
* 호출한 데이터 리스의 길이
*/
itemsCount?: number;
/**
* 무한 스크롤을 호출할 인덱스, 뒤에서 계산
*/
fetchingTriggerIndexFromEnd?: number;
};
/**
* VirtualScroller의 기본 Props
*/
interface IVirtualScrollerProps<T extends React.ElementType> {
as?: T;
/**
* 열 간격
*/
columnsGap?: number;
/**
* 가상 스크롤러 옵션
*/
virtualizerOptions: TUseWindowVirtualizerParams;
/**
* 가상화 높이가 정해지는 컨테이너의 ClassName
*/
scrollInnerClassName?: string;
/**
* 아이템 렌더링 함수
*/
renderItem: (index: number) => React.ReactNode;
/**
* 무한 스크롤 상태
*/
infiniteScrollStatus?: TInfiniteScrollStatus;
}
/**
* VirtualScroller ComponentProps
*/
export type TExtendsVirtualScrollerComponentProps<
T extends React.ElementType = 'div',
> = IVirtualScrollerProps<T> & React.ComponentPropsWithRef<T>;
10 changes: 10 additions & 0 deletions src/components/templates/Main/Main.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const RECOMMENDED_NFT_TAILWIND_CLASS = {
FOOTER: {
TEXT: (className) => cn('line-clamp-1 break-all', className),
},
SKELETON: {
TITLE: (className) => cn('h-7', className),
DESCRIPTION: (className) => cn('h-5', className),
FOOTER: (className) => cn('h-5 flex-auto', className),
},
},
} as const satisfies ITailwindClass;

Expand Down Expand Up @@ -53,6 +58,11 @@ export const NFT_BY_CATEGORY_TAILWIND_CLASS = {
FOOTER: {
TEXT: (className) => cn('line-clamp-1 break-all', className),
},
SKELETON: {
TITLE: (className) => cn('h-7', className),
DESCRIPTION: (className) => cn('h-5', className),
FOOTER: (className) => cn('h-5 flex-auto', className),
},
},
VIRTUAL_SCROLLER: {
INNER: (className) => cn('mx-4 my-3', className),
Expand Down
Loading

0 comments on commit b095bf0

Please sign in to comment.