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 (
-
-
-
- ),
- "50": (
-
-
-
- ),
- "100": (
-
-
-
- ),
- }}
- />
+
+
+
+
+ ),
+ "50": (
+
+
+
+ ),
+ "100": (
+
+
+
+ ),
+ }}
+ />
+
);
});
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}
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];
-};