diff --git a/src/entities/record/api/getBooksSearch.ts b/src/entities/record/api/getBooksSearch.ts index 8bb2077..662b840 100644 --- a/src/entities/record/api/getBooksSearch.ts +++ b/src/entities/record/api/getBooksSearch.ts @@ -1,15 +1,25 @@ import { http } from "@repo/http"; -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions } from "@tanstack/react-query"; import type { Book } from "../model/book.model"; import { recordQueryKeys } from "./record.querykey"; export const GET_BOOKS_SEARCH_SORT_TYPE = { - ACCURACY: "ACCURACY", - PUBLISH_TIME: "PUBLISH_TIME", + SALES_POINT: { + value: "SALES_POINT", + label: "판매량", + }, + PUBLISH_TIME: { + value: "PUBLISH_TIME", + label: "출간일", + }, } as const; export type GetBooksSearchSortType = - (typeof GET_BOOKS_SEARCH_SORT_TYPE)[keyof typeof GET_BOOKS_SEARCH_SORT_TYPE]; + (typeof GET_BOOKS_SEARCH_SORT_TYPE)[keyof typeof GET_BOOKS_SEARCH_SORT_TYPE]["value"]; + +export const isGetBooksSearchSortType = (value: unknown): value is GetBooksSearchSortType => { + return Object.values(GET_BOOKS_SEARCH_SORT_TYPE).some((sortType) => sortType.value === value); +}; export interface GetBooksSearchRequest { query: string; @@ -41,10 +51,18 @@ export const getBooksSearch = async (param: GetBooksSearchRequest) => { return response.data; }; -export const getBooksSearchQueryOptions = (param: GetBooksSearchRequest) => { - return queryOptions({ +export const getBooksSearchInfiniteQueryOptions = (param: GetBooksSearchRequest) => { + return infiniteQueryOptions({ queryKey: recordQueryKeys.getBooksSearch(param), queryFn: () => getBooksSearch(param), retry: 0, + initialPageParam: param, + getNextPageParam: (lastPage) => { + if (lastPage.hasNext) { + const nextPage = lastPage.currentPage + 1; + return { ...param, startPage: nextPage }; + } + }, + enabled: param.query.length > 0, }); }; diff --git a/src/entities/user/model/user.model.test.ts b/src/entities/user/model/user.model.test.ts new file mode 100644 index 0000000..df523b3 --- /dev/null +++ b/src/entities/user/model/user.model.test.ts @@ -0,0 +1,128 @@ +import { EXP_TABLE, calculateUserLevel, getGoalExp } from "./user.model"; + +describe("User.model를 테스트합니다.", () => { + it("경험치가 1레벨 경험치보다 작으면 1레벨을 반환합니다.", () => { + const exp = EXP_TABLE[1] - 1; + const level = calculateUserLevel(exp); + expect(level).toBe(1); + }); + it("경험치가 1레벨 경험치보다 크면 2레벨을 반환합니다.", () => { + const exp = EXP_TABLE[1] + 1; + const level = calculateUserLevel(exp); + expect(level).toBe(2); + }); + it("경험치가 0이면 1레벨을 반환합니다.", () => { + const exp = 0; + const level = calculateUserLevel(exp); + expect(level).toBe(1); + }); + it("경험치가 9레벨 수준이면 9레벨을 반환합니다.", () => { + const exp = + EXP_TABLE[1] + + EXP_TABLE[2] + + EXP_TABLE[3] + + EXP_TABLE[4] + + EXP_TABLE[5] + + EXP_TABLE[6] + + EXP_TABLE[7] + + EXP_TABLE[8]; + const level = calculateUserLevel(exp); + expect(level).toBe(9); + }); + it("경험치가 9레벨 보다 높은 경우 10레벨을 반환합니다.", () => { + const exp = + EXP_TABLE[1] + + EXP_TABLE[2] + + EXP_TABLE[3] + + EXP_TABLE[4] + + EXP_TABLE[5] + + EXP_TABLE[6] + + EXP_TABLE[7] + + EXP_TABLE[8] + + EXP_TABLE[9]; + const level = calculateUserLevel(exp); + expect(level).toBe(10); + }); +}); + +describe("getGoalExp를 테스트합니다.", () => { + it("1레벨 경험치 목표는 100입니다.", () => { + const goalExp = getGoalExp(1); + expect(goalExp).toBe(EXP_TABLE[1]); + }); + it("2레벨 경험치 목표는 200입니다.", () => { + const goalExp = getGoalExp(2); + expect(goalExp).toBe(EXP_TABLE[1] + EXP_TABLE[2]); + }); + it("3레벨 경험치 목표는 300입니다.", () => { + const goalExp = getGoalExp(3); + expect(goalExp).toBe(EXP_TABLE[1] + EXP_TABLE[2] + EXP_TABLE[3]); + }); + it("4레벨 경험치 목표는 500입니다.", () => { + const goalExp = getGoalExp(4); + expect(goalExp).toBe(EXP_TABLE[1] + EXP_TABLE[2] + EXP_TABLE[3] + EXP_TABLE[4]); + }); + it("5레벨 경험치 목표는 800입니다.", () => { + const goalExp = getGoalExp(5); + expect(goalExp).toBe(EXP_TABLE[1] + EXP_TABLE[2] + EXP_TABLE[3] + EXP_TABLE[4] + EXP_TABLE[5]); + }); + it("6레벨 경험치 목표는 1300입니다.", () => { + const goalExp = getGoalExp(6); + expect(goalExp).toBe( + EXP_TABLE[1] + EXP_TABLE[2] + EXP_TABLE[3] + EXP_TABLE[4] + EXP_TABLE[5] + EXP_TABLE[6], + ); + }); + it("7레벨 경험치 목표는 2100입니다.", () => { + const goalExp = getGoalExp(7); + expect(goalExp).toBe( + EXP_TABLE[1] + + EXP_TABLE[2] + + EXP_TABLE[3] + + EXP_TABLE[4] + + EXP_TABLE[5] + + EXP_TABLE[6] + + EXP_TABLE[7], + ); + }); + it("8레벨 경험치 목표는 3400입니다.", () => { + const goalExp = getGoalExp(8); + expect(goalExp).toBe( + EXP_TABLE[1] + + EXP_TABLE[2] + + EXP_TABLE[3] + + EXP_TABLE[4] + + EXP_TABLE[5] + + EXP_TABLE[6] + + EXP_TABLE[7] + + EXP_TABLE[8], + ); + }); + it("9레벨 경험치 목표는 5500입니다.", () => { + const goalExp = getGoalExp(9); + expect(goalExp).toBe( + EXP_TABLE[1] + + EXP_TABLE[2] + + EXP_TABLE[3] + + EXP_TABLE[4] + + EXP_TABLE[5] + + EXP_TABLE[6] + + EXP_TABLE[7] + + EXP_TABLE[8] + + EXP_TABLE[9], + ); + }); + it("10레벨 경험치 목표는 9레벨과 동일합니다.", () => { + const goalExp = getGoalExp(10); + expect(goalExp).toBe( + EXP_TABLE[1] + + EXP_TABLE[2] + + EXP_TABLE[3] + + EXP_TABLE[4] + + EXP_TABLE[5] + + EXP_TABLE[6] + + EXP_TABLE[7] + + EXP_TABLE[8] + + EXP_TABLE[9], + ); + }); +}); diff --git a/src/entities/user/model/user.model.ts b/src/entities/user/model/user.model.ts index 91c0231..84e80df 100644 --- a/src/entities/user/model/user.model.ts +++ b/src/entities/user/model/user.model.ts @@ -1,3 +1,46 @@ export interface User { exp: number; } + +export const EXP_TABLE = { + 1: 100, + 2: 100, + 3: 100, + 4: 200, + 5: 300, + 6: 500, + 7: 800, + 8: 1300, + 9: 2100, +} as const; + +export const calculateUserLevel = (exp: number): number => { + let accumulatedExp = 0; + + for (const [level, requiredExp] of Object.entries(EXP_TABLE)) { + accumulatedExp += requiredExp; + if (exp < accumulatedExp) { + return Number(level); + } + } + + return Object.keys(EXP_TABLE).length + 1; +}; + +export const getGoalExp = (level: number) => { + let goalExp = 0; + + for (let i = 1; i <= level; i++) { + goalExp += EXP_TABLE[i as keyof typeof EXP_TABLE] ?? 0; + } + + return goalExp; +}; + +export const getExpPercentage = (exp: number, goalExp: number): number => { + const result = (exp / goalExp) * 100; + if (Number.isNaN(result)) { + return 1; + } + return result; +}; diff --git a/src/features/bookRecordWrite/BookRecordWriteFunnel.tsx b/src/features/bookRecordWrite/BookRecordWriteFunnel.tsx index 8551a8b..b17848d 100644 --- a/src/features/bookRecordWrite/BookRecordWriteFunnel.tsx +++ b/src/features/bookRecordWrite/BookRecordWriteFunnel.tsx @@ -1,3 +1,4 @@ +import { toast } from "@repo/design-system/Toast"; import { TopNavigation } from "@repo/design-system/TopNavigation"; import { Spacing } from "@repo/ui/Spacing"; import { Stack } from "@repo/ui/Stack"; @@ -5,12 +6,14 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useFunnel } from "@use-funnel/browser"; import { useLoading } from "@xionwcfm/react"; import { useNavigate } from "react-router"; -import { usePostRecords } from "~/entities/record/api/postRecords"; +import { GET_BOOKS_SEARCH_SORT_TYPE } from "~/entities/record/api/getBooksSearch"; +import { type PostRecordsRequest, usePostRecords } from "~/entities/record/api/postRecords"; import { useTestUserQueryOptions } from "~/entities/user/api/getTestUser"; -import BookRecordWriteProgressStep from "./BookRecordWriteProgressStep"; -import BookRecordWriteSearchStep from "./BookRecordWriteSearchStep"; -import BookRecordWriteTextStep from "./BookRecordWriteTextStep"; import { type BookRecordWriteFormOptionalState, bookRecordWriteSteps } from "./bookRecordStepState"; +import BookRecordWriteProgressStep from "./components/ProgressStep/BookRecordWriteProgressStep"; +import BookRecordWriteSearchStep from "./components/SearchStep/BookRecordWriteSearchStep"; +import { WriteSearchProvider } from "./components/SearchStep/WriteSearchStep.store"; +import BookRecordWriteTextStep from "./components/TextStep/BookRecordWriteTextStep"; const options = { id: "@bookrecordwrite", @@ -30,12 +33,22 @@ export const BookRecordWriteFunnel = () => { const navigate = useNavigate(); const { mutateAsync: createRecords } = usePostRecords(); const { data: user } = useSuspenseQuery(useTestUserQueryOptions()); + const userId = user.userId; const [loading, startLoading] = useLoading(); const handleBack = () => { navigate(-1); }; + const handleNext = async (body: PostRecordsRequest) => { + try { + await startLoading(createRecords(body)); + navigate("/book-record"); + } catch (_e) { + toast.main3("죄송해요 서버가 맛이 갔나봐요 ㅜㅅㅜ", { duration: 3500 }); + } + }; + return ( }> @@ -43,27 +56,29 @@ export const BookRecordWriteFunnel = () => { - ( - history.push("TextStep", { book })} /> - )} - TextStep={({ context, history }) => ( - history.push("ProgressStep", { content, book: context.book })} - /> - )} - ProgressStep={({ context }) => ( - { - const body = { ...context, gauge, userId: user.userId }; - await startLoading(createRecords(body)); - navigate("/book-record"); - }} - /> - )} - /> + + ( + history.push("TextStep", { book })} /> + )} + TextStep={({ context, history }) => ( + history.push("ProgressStep", { content, book: context.book })} + /> + )} + ProgressStep={({ context }) => ( + handleNext({ ...context, gauge, userId })} + /> + )} + /> + ); }; diff --git a/src/features/bookRecordWrite/BookRecordWriteSearchStep.tsx b/src/features/bookRecordWrite/BookRecordWriteSearchStep.tsx deleted file mode 100644 index a052e75..0000000 --- a/src/features/bookRecordWrite/BookRecordWriteSearchStep.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { AspectRatio } from "@repo/design-system/AspectRatio"; -import { Button } from "@repo/design-system/Button"; -import { Image } from "@repo/design-system/Image"; -import { SearchBar } from "@repo/design-system/SearchBar"; -import { MagnifyIcon } from "@repo/icon/MagnifyIcon"; -import { CenterStack } from "@repo/ui/CenterStack"; -import { Flex } from "@repo/ui/Flex"; -import { List } from "@repo/ui/List"; -import { Stack } from "@repo/ui/Stack"; -import { wrap } from "@suspensive/react"; -import type { Book } from "~/entities/record/model/book.model"; - -export interface BookRecordWriteSearchStepProps { - onNext: (book: Book) => void; -} - -export default function BookRecordWriteSearchStep(props: BookRecordWriteSearchStepProps) { - const { onNext } = props; - - return ( - - - } placeholder="책 제목을 검색해주세요" /> - - - - - 검색 결과 없음 - - - } - > - - - - - - ); -} - -const _SearchResult = wrap - .Suspense() - .ErrorBoundary({ fallback: null }) - .on((props: { query: string }) => { - const { query } = props; - - return ; - }); - -const SEARCH_ASSETS = { - EMPTY_FALLBACK: "/images/bookrecord/bookrecord_search_empty_fallback.webp", -}; diff --git a/src/features/bookRecordWrite/components/BookList.stories.tsx b/src/features/bookRecordWrite/components/BookList.stories.tsx deleted file mode 100644 index de52309..0000000 --- a/src/features/bookRecordWrite/components/BookList.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { List } from "@repo/ui/List"; -import type { Meta, StoryObj } from "@storybook/react"; -import { RecordedBookItem } from "../../bookRecordRead/components/RecordedBookItem"; -import { SearchBookItem } from "./SearchBookItem"; - -const meta: Meta = { - title: "bookRecord/BookList", - tags: ["autodocs"], -}; -export default meta; - -const IMAGE_URL = - "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSeTzw38qIlLaZRLNfbJCeDX7EE5QAGQHf-Hw&s"; -const BOOKLIST_CONTENT = - "과학 지식을 알기 쉽게 전달하는 과학 커뮤니케이터이며, 유튜브 과학 채널 유튜브 과학 채널유튜브 과학 채널 유튜브 과학 채널...유튜브 과학 채널...유튜브 과학 채널...유튜브 과학채널...유튜브 과학 채널...유튜브 과학 채널..."; - -export const Search: StoryObj = { - render: () => { - return ( - - alert("책 클릭")} - authorName={"궤도, 송용조"} - publishName={"페이지2북스"} - /> - alert("책 클릭")} - authorName={"궤도, 송용조"} - publishName={"페이지2북스"} - /> - - ); - }, -}; - -export const MyList: StoryObj = { - render: () => { - return ( - - alert("책 클릭")} - updatedAt={"2025.01.25"} - bookSummary={BOOKLIST_CONTENT} - gauge={100} - /> - alert("책 클릭")} - updatedAt={"2025.01.25"} - bookSummary={BOOKLIST_CONTENT} - gauge={100} - /> - - ); - }, -}; diff --git a/src/features/bookRecordWrite/BookRecordWriteProgressStep.stories.tsx b/src/features/bookRecordWrite/components/ProgressStep/BookRecordWriteProgressStep.stories.tsx similarity index 100% rename from src/features/bookRecordWrite/BookRecordWriteProgressStep.stories.tsx rename to src/features/bookRecordWrite/components/ProgressStep/BookRecordWriteProgressStep.stories.tsx diff --git a/src/features/bookRecordWrite/BookRecordWriteProgressStep.test.tsx b/src/features/bookRecordWrite/components/ProgressStep/BookRecordWriteProgressStep.test.tsx similarity index 100% rename from src/features/bookRecordWrite/BookRecordWriteProgressStep.test.tsx rename to src/features/bookRecordWrite/components/ProgressStep/BookRecordWriteProgressStep.test.tsx diff --git a/src/features/bookRecordWrite/BookRecordWriteProgressStep.tsx b/src/features/bookRecordWrite/components/ProgressStep/BookRecordWriteProgressStep.tsx similarity index 70% rename from src/features/bookRecordWrite/BookRecordWriteProgressStep.tsx rename to src/features/bookRecordWrite/components/ProgressStep/BookRecordWriteProgressStep.tsx index 00f80d1..4882197 100644 --- a/src/features/bookRecordWrite/BookRecordWriteProgressStep.tsx +++ b/src/features/bookRecordWrite/components/ProgressStep/BookRecordWriteProgressStep.tsx @@ -68,36 +68,38 @@ export default function BookRecordWriteProgressStep(props: BookRecordWriteProgre const ImageSection = memo((props: { status: "0" | "50" | "100" }) => { return ( - - read gauge 0 image - - ), - "50": ( - - read gauge 50 image - - ), - "100": ( - - read gauge 100 image - - ), - }} - /> + + + read gauge 0 image + + ), + "50": ( + + read gauge 50 image + + ), + "100": ( + + read gauge 100 image + + ), + }} + /> + ); }); diff --git a/src/features/bookRecordWrite/components/SearchStep/BookRecordWriteSearchStep.tsx b/src/features/bookRecordWrite/components/SearchStep/BookRecordWriteSearchStep.tsx new file mode 100644 index 0000000..a47c9d0 --- /dev/null +++ b/src/features/bookRecordWrite/components/SearchStep/BookRecordWriteSearchStep.tsx @@ -0,0 +1,28 @@ +import { Flex } from "@repo/ui/Flex"; +import { Spacing } from "@repo/ui/Spacing"; +import { Stack } from "@repo/ui/Stack"; +import type { Book } from "~/entities/record/model/book.model"; +import { WriteSearchBar } from "./WriteSearchBar"; +import { WriteSearchList } from "./WriteSearchList"; +import { WriteSearchToggleGroup } from "./WriteSearchToggleGroup"; + +export interface BookRecordWriteSearchStepProps { + onNext: (book: Book) => void; +} + +export default function BookRecordWriteSearchStep(props: BookRecordWriteSearchStepProps) { + const { onNext } = props; + + return ( + + + + + + + + + + + ); +} diff --git a/src/features/bookRecordWrite/components/SearchStep/WriteSearchBar.tsx b/src/features/bookRecordWrite/components/SearchStep/WriteSearchBar.tsx new file mode 100644 index 0000000..a4f5713 --- /dev/null +++ b/src/features/bookRecordWrite/components/SearchStep/WriteSearchBar.tsx @@ -0,0 +1,24 @@ +import { SearchBar } from "@repo/design-system/SearchBar"; +import { MagnifyIcon } from "@repo/icon/MagnifyIcon"; +import { useDebouncedInputValue } from "@xionwcfm/react"; +import { useEffect } from "react"; +import { useWriteSearchStore } from "./WriteSearchStep.store"; + +export const WriteSearchBar = () => { + const storeValue = useWriteSearchStore((state) => state.query); + const setQuery = useWriteSearchStore((state) => state.actions.setQuery); + const debouncing = useDebouncedInputValue(storeValue, { delay: 2000 }); + + useEffect(() => { + setQuery(debouncing.debouncedValue); + }, [setQuery, debouncing.debouncedValue]); + + return ( + debouncing.onChange(e.target.value)} + left={} + placeholder="책 제목을 검색해주세요" + /> + ); +}; diff --git a/src/features/bookRecordWrite/components/SearchBookItem.tsx b/src/features/bookRecordWrite/components/SearchStep/WriteSearchBookItem.tsx similarity index 77% rename from src/features/bookRecordWrite/components/SearchBookItem.tsx rename to src/features/bookRecordWrite/components/SearchStep/WriteSearchBookItem.tsx index 158167e..cf98083 100644 --- a/src/features/bookRecordWrite/components/SearchBookItem.tsx +++ b/src/features/bookRecordWrite/components/SearchStep/WriteSearchBookItem.tsx @@ -1,6 +1,8 @@ +import { AspectRatio } from "@repo/design-system/AspectRatio"; import { Image } from "@repo/design-system/Image"; import { Text } from "@repo/design-system/Text"; import { cn } from "@repo/design-system/cn"; +import { Box } from "@repo/ui/Box"; import { Flex } from "@repo/ui/Flex"; import { Stack } from "@repo/ui/Stack"; import { type Ref, forwardRef } from "react"; @@ -24,12 +26,16 @@ export const SearchBookItem = forwardRef(function SearchBookItem( onClick={onClick} className={cn("flex gap-[13px] max-h-[156px] text-left", className)} > - {`${title} + + + {`${title} + +
{title} diff --git a/src/features/bookRecordWrite/components/SearchStep/WriteSearchList.tsx b/src/features/bookRecordWrite/components/SearchStep/WriteSearchList.tsx new file mode 100644 index 0000000..efbb9e6 --- /dev/null +++ b/src/features/bookRecordWrite/components/SearchStep/WriteSearchList.tsx @@ -0,0 +1,95 @@ +import { AspectRatio } from "@repo/design-system/AspectRatio"; +import { Image } from "@repo/design-system/Image"; +import { Text } from "@repo/design-system/Text"; +import { cn } from "@repo/design-system/cn"; +import { CenterStack } from "@repo/ui/CenterStack"; +import { List } from "@repo/ui/List"; +import { Stack } from "@repo/ui/Stack"; +import { wrap } from "@suspensive/react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { InView } from "@xionwcfm/react"; +import { getBooksSearchInfiniteQueryOptions } from "~/entities/record/api/getBooksSearch"; +import type { Book } from "~/entities/record/model/book.model"; +import { SearchBookItem } from "./WriteSearchBookItem"; +import { useWriteSearchStore } from "./WriteSearchStep.store"; + +export const WriteSearchList = wrap.Suspense().on( + (props: { + onNext: (book: Book) => void; + }) => { + const { onNext } = props; + const query = useWriteSearchStore((state) => state.query); + const sortType = useWriteSearchStore((state) => state.sortType); + const maxResults = useWriteSearchStore((state) => state.maxResults); + const startPage = useWriteSearchStore((state) => state.startPage); + + const param = { query, sortType, maxResults, startPage }; + const queryOptions = getBooksSearchInfiniteQueryOptions(param); + const { data, hasNextPage, fetchNextPage, isLoading } = useInfiniteQuery(queryOptions); + const books = data?.pages.flatMap((page) => page.books) ?? []; + + const isObserverDisplay = hasNextPage && query.length > 0; + + const fallback = + query.length === 0 || isLoading ? : ; + + const handleIntersectStart = () => { + if (hasNextPage) { + fetchNextPage(); + } + }; + + return ( + <> + + {books.map((book) => ( + onNext(book)} + /> + ))} + + {isObserverDisplay && ( + +
+ + )} + + ); + }, +); + +const SEARCH_ASSETS = { + FIRST_FALLBACK: "/images/bookrecord/bookrecord_search_empty_fallback.webp", + SEARCH_RESULT_EMPTY_FALLBACK: "/images/bookrecord/bookrecord_tab_no_search_result_fallback.webp", +}; + +const FirstFallback = (props: { isLoading?: boolean }) => { + const { isLoading } = props; + return ( + + + 검색 결과 없음 + + + ); +}; + +const EmptyFallback = () => { + return ( + + + + 검색 결과 없음 + + + + 검색 결과가 없어요 + + + ); +}; diff --git a/src/features/bookRecordWrite/components/SearchStep/WriteSearchStep.store.tsx b/src/features/bookRecordWrite/components/SearchStep/WriteSearchStep.store.tsx new file mode 100644 index 0000000..dcab54c --- /dev/null +++ b/src/features/bookRecordWrite/components/SearchStep/WriteSearchStep.store.tsx @@ -0,0 +1,49 @@ +import { createSafeContext } from "@xionwcfm/react"; +import { type PropsWithChildren, useState } from "react"; +import { type StoreApi, createStore, useStore } from "zustand"; +import { + GET_BOOKS_SEARCH_SORT_TYPE, + type GetBooksSearchSortType, +} from "~/entities/record/api/getBooksSearch"; + +interface BookRecordSearchState { + query: string; + maxResults: number; + startPage: number; + sortType: GetBooksSearchSortType; +} + +interface BookRecordSearchAction { + actions: { + setQuery: (query: string) => void; + setSortType: (sortType: GetBooksSearchSortType) => void; + }; +} + +type BookRecordSearchStore = StoreApi; + +const [StoreContext, useStoreContext] = createSafeContext(null); + +export const WriteSearchProvider = (props: PropsWithChildren>) => { + const [store] = useState(() => + createStore((set) => ({ + query: props.query ?? "", + maxResults: props.maxResults ?? 80, + startPage: props.startPage ?? 1, + sortType: props.sortType ?? GET_BOOKS_SEARCH_SORT_TYPE.SALES_POINT.value, + actions: { + setQuery: (query: string) => set({ query }), + setSortType: (sortType: GetBooksSearchSortType) => set({ sortType }), + }, + })), + ); + + return {props.children}; +}; + +export const useWriteSearchStore = ( + selector: (state: BookRecordSearchState & BookRecordSearchAction) => T, +): T => { + const store = useStoreContext(); + return useStore(store, selector); +}; diff --git a/src/features/bookRecordWrite/components/SearchStep/WriteSearchToggleGroup.tsx b/src/features/bookRecordWrite/components/SearchStep/WriteSearchToggleGroup.tsx new file mode 100644 index 0000000..5b28909 --- /dev/null +++ b/src/features/bookRecordWrite/components/SearchStep/WriteSearchToggleGroup.tsx @@ -0,0 +1,39 @@ +import { textVariants } from "@repo/design-system/Text"; +import { cn } from "@repo/design-system/cn"; +import { Flex } from "@repo/ui/Flex"; +import { ToggleButton } from "@repo/ui/ToggleButton"; +import { ToggleButtonGroup } from "@repo/ui/ToggleButtonGroup"; +import { + GET_BOOKS_SEARCH_SORT_TYPE, + isGetBooksSearchSortType, +} from "~/entities/record/api/getBooksSearch"; +import { useWriteSearchStore } from "./WriteSearchStep.store"; + +export const WriteSearchToggleGroup = () => { + const sortType = useWriteSearchStore((state) => state.sortType); + const setSortType = useWriteSearchStore((state) => state.actions.setSortType); + + return ( + + isGetBooksSearchSortType(value) && setSortType(value)} + > + {Object.values(GET_BOOKS_SEARCH_SORT_TYPE).map((item) => ( + + {item.label} + + ))} + + + ); +}; diff --git a/src/features/bookRecordWrite/BookRecordWriteTextStep.stories.tsx b/src/features/bookRecordWrite/components/TextStep/BookRecordWriteTextStep.stories.tsx similarity index 100% rename from src/features/bookRecordWrite/BookRecordWriteTextStep.stories.tsx rename to src/features/bookRecordWrite/components/TextStep/BookRecordWriteTextStep.stories.tsx diff --git a/src/features/bookRecordWrite/BookRecordWriteTextStep.test.tsx b/src/features/bookRecordWrite/components/TextStep/BookRecordWriteTextStep.test.tsx similarity index 100% rename from src/features/bookRecordWrite/BookRecordWriteTextStep.test.tsx rename to src/features/bookRecordWrite/components/TextStep/BookRecordWriteTextStep.test.tsx diff --git a/src/features/bookRecordWrite/BookRecordWriteTextStep.tsx b/src/features/bookRecordWrite/components/TextStep/BookRecordWriteTextStep.tsx similarity index 100% rename from src/features/bookRecordWrite/BookRecordWriteTextStep.tsx rename to src/features/bookRecordWrite/components/TextStep/BookRecordWriteTextStep.tsx diff --git a/src/features/userExp/HabitSection.tsx b/src/features/userExp/HabitSection.tsx new file mode 100644 index 0000000..ac8fed3 --- /dev/null +++ b/src/features/userExp/HabitSection.tsx @@ -0,0 +1,173 @@ +import { AspectRatio } from "@repo/design-system/AspectRatio"; +import { Text } from "@repo/design-system/Text"; +import { MercuryIcon } from "@repo/icon/MercuryIcon"; +import { Flex } from "@repo/ui/Flex"; +import { Spacing } from "@repo/ui/Spacing"; +import { Stack } from "@repo/ui/Stack"; +import { wrap } from "@suspensive/react"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { isAfter, isSameDay } from "date-fns"; +import { useMemo } from "react"; +import { useTestUserQueryOptions } from "~/entities/user/api/getTestUser"; +import { ExpProgressBar } from "~/entities/user/components/ExpProgressBar"; +import { HabitCalendar } from "~/entities/user/components/HabitCalendar"; +import { calculateUserLevel, getExpPercentage, getGoalExp } from "~/entities/user/model/user.model"; + +const ExpSection = (props: { exp: number; goalExp: number; percentage: number; level: string }) => { + const { exp, goalExp, percentage, level } = props; + return ( + + + + {level} + + + + + + ); +}; + +const HabitBar = (props: { normalText: string; boldText: string }) => { + const { normalText, boldText } = props; + return ( + + + + + + + + + {normalText} + + + {boldText} + + + + ); +}; + +const getWeekDates = (date: Date) => { + const currentDate = new Date(date); + const currentDay = currentDate.getDay(); + const diffToMonday = currentDay === 0 ? -6 : 1 - currentDay; + + const monday = new Date(currentDate); + monday.setDate(currentDate.getDate() + diffToMonday); + + const weekDates = Array.from({ length: 7 }, (_, i) => { + const date = new Date(monday); + date.setDate(monday.getDate() + i); + return date; + }); + + return weekDates; +}; + +const getStatus = (context: { targetDate: Date; today: Date; isDone?: boolean }) => { + const { targetDate, today, isDone } = context; + if (isSameDay(targetDate, today) && !isDone) { + return "pending" as const; + } + + if (isAfter(targetDate, today)) { + return "pending" as const; + } + + if (isDone) { + return "success" as const; + } + + return "fail" as const; +}; + +const getDayName = (date: Date) => { + const dayEnum = { + 0: "일", + 1: "월", + 2: "화", + 3: "수", + 4: "목", + 5: "금", + 6: "토", + }; + return dayEnum[date.getDay() as keyof typeof dayEnum]; +}; + +const Fallback = () => { + return ( + <> + + + + + 습관 쌓기 + + + {getWeekDates(new Date()).map((date) => ( + {getDayName(date)}} + status={"pending"} + > + {date.getDate()} + + ))} + + + + + + + ); +}; + +export const HabitSection = wrap + .Suspense({ + fallback: , + }) + .on(() => { + const { data: user } = useSuspenseQuery(useTestUserQueryOptions()); + + const nickname = `테스터${user.nickname.slice(10, 14)}`; + const normalText = `${nickname}님은 현재`; + const successCount = 5; + const boldText = + successCount > 0 ? `${successCount}일 연속 습관 쌓는 중!` : "습관 쌓을 준비 중"; + + const level = calculateUserLevel(user.exp); + const goalExp = getGoalExp(level); + const exp = user.exp + 50; + const percentage = getExpPercentage(exp, goalExp); + + const weekDates = useMemo(() => getWeekDates(new Date()), []); + + return ( + <> + + + + + 습관 쌓기 + + + + {weekDates.map((date) => ( + {getDayName(date)}} + status={getStatus({ targetDate: date, today: new Date(), isDone: true })} + > + {date.getDate()} + + ))} + + + + + + + ); + }); diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 820e4ab..5d88167 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,17 +1,12 @@ import { AspectRatio } from "@repo/design-system/AspectRatio"; import { Image } from "@repo/design-system/Image"; import { NotificationBadge } from "@repo/design-system/NotificationBadge"; -import { Text } from "@repo/design-system/Text"; -import { MercuryIcon } from "@repo/icon/MercuryIcon"; import { CenterStack } from "@repo/ui/CenterStack"; import { Flex } from "@repo/ui/Flex"; import { Spacing } from "@repo/ui/Spacing"; import { Stack } from "@repo/ui/Stack"; -import { isAfter, isSameDay } from "date-fns"; import { motion } from "motion/react"; -import { useMemo } from "react"; -import { ExpProgressBar } from "~/entities/user/components/ExpProgressBar"; -import { HabitCalendar } from "~/entities/user/components/HabitCalendar"; +import { HabitSection } from "~/features/userExp/HabitSection"; export default function HomePage() { return ( @@ -19,9 +14,9 @@ export default function HomePage() {
- - + + ); } @@ -64,114 +59,7 @@ const MainSection = () => { ); }; -const ExpSection = () => { - return ( - - - - 레벨 9 - - - - - ); -}; - -const HabitBar = (props: { userName: string; successCount: number }) => { - const { userName, successCount } = props; - const normalText = `${userName}님은 현재`; - const boldText = successCount > 0 ? `${successCount}일 연속 습관 쌓는 중!` : "습관 쌓을 준비 중"; - return ( - - - - - - - - - {normalText} - - - {boldText} - - - ); -}; - const HOME_ASSETS = { HOME_LOGO: "/images/home/home_logo.webp", HOME_MERCURY: "/images/home/home_mercury.webp", }; - -const HabitSection = () => { - const weekDates = useMemo(() => getWeekDates(new Date()), []); - - return ( - - - 습관 쌓기 - - - {weekDates.map((date) => ( - {getDayName(date)}} - status={getStatus({ targetDate: date, today: new Date(), isDone: true })} - > - {date.getDate()} - - ))} - - - - - ); -}; - -const getStatus = (context: { targetDate: Date; today: Date; isDone?: boolean }) => { - const { targetDate, today, isDone } = context; - if (isSameDay(targetDate, today) && !isDone) { - return "pending" as const; - } - - if (isAfter(targetDate, today)) { - return "pending" as const; - } - - if (isDone) { - return "success" as const; - } - - return "fail" as const; -}; - -const getWeekDates = (date: Date) => { - const currentDate = new Date(date); - const currentDay = currentDate.getDay(); - const diffToMonday = currentDay === 0 ? -6 : 1 - currentDay; - - const monday = new Date(currentDate); - monday.setDate(currentDate.getDate() + diffToMonday); - - const weekDates = Array.from({ length: 7 }, (_, i) => { - const date = new Date(monday); - date.setDate(monday.getDate() + i); - return date; - }); - - return weekDates; -}; - -const getDayName = (date: Date) => { - const dayEnum = { - 0: "일", - 1: "월", - 2: "화", - 3: "수", - 4: "목", - 5: "금", - 6: "토", - }; - return dayEnum[date.getDay() as keyof typeof dayEnum]; -};