diff --git a/next.config.mjs b/next.config.mjs index d8a7fe46..90a7e7f8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,6 +8,7 @@ const nextConfig = { "github.com", "oopy.lazyrockets.com", "eehhqckznniu25210545.cdn.ntruss.com", + "storage.mrblog.net", ], }, webpack: (config, context) => { diff --git a/public/assets/icon/cardFewLogo.svg b/public/assets/icon/cardFewLogo.svg new file mode 100644 index 00000000..902a0846 --- /dev/null +++ b/public/assets/icon/cardFewLogo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 03f00770..ed12c1ba 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -102,6 +102,9 @@ @apply font-pretendard text-[15px]/[24.75px] font-normal; } + .body3-bold { + @apply font-pretendard text-[14px]/[23.1px] font-bold; + } .body3-medium { @apply font-pretendard text-[14px]/[21px] font-medium; } @@ -119,7 +122,10 @@ @apply font-pretendard text-[16px]/[25.44px] font-medium; } .sub3-semibold { - @apply font-pretendard text-[12px] font-semibold + @apply font-pretendard text-[12px] font-semibold; + } + .sub3-medium { + @apply font-pretendard text-[12px]/[18px] font-medium; } .scrollbar-hide { diff --git a/src/common/types/category.ts b/src/common/types/category.ts index 036ce0d5..4aa6d686 100644 --- a/src/common/types/category.ts +++ b/src/common/types/category.ts @@ -1,4 +1,8 @@ export type CategoryInfo = { - parameterName: string; - displayName: string; + code: number; + name: string; +}; + +export type CategoryInfoList = { + categories: CategoryInfo[]; }; diff --git a/src/main/components/CategoryTabs/category.test.tsx b/src/main/components/CategoryTabs/category.test.tsx index a0725a52..6f3c0c02 100644 --- a/src/main/components/CategoryTabs/category.test.tsx +++ b/src/main/components/CategoryTabs/category.test.tsx @@ -31,8 +31,8 @@ describe("카테고리 리스트 테스트", () => { await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(screen.getByText("전체")); - expect(screen.getByText("과학")); - expect(result.current.data?.length === 6).toBeTruthy(); + expect(screen.getByText("경제")); + expect(screen.getByText("IT")); + expect(result.current.data?.length === 5).toBeTruthy(); }); }); diff --git a/src/main/components/CategoryTabs/index.tsx b/src/main/components/CategoryTabs/index.tsx index d507ff24..6dd8681e 100644 --- a/src/main/components/CategoryTabs/index.tsx +++ b/src/main/components/CategoryTabs/index.tsx @@ -29,7 +29,7 @@ export default function CategoryTabs({ useEffect( function setInitCategory() { - if (categoryList) handleCategory(categoryList[0].displayName); + if (categoryList) handleCategory(categoryList[0].name); }, [categoryList], ); @@ -37,27 +37,24 @@ export default function CategoryTabs({ if (categoryList) return ( - + - {categoryList.map(({ parameterName, displayName }) => ( + {categoryList.map(({ name, code }) => ( handleCategory(displayName)} + name={name} + onClick={() => handleCategory(name)} > - {displayName} + {name} - {category === displayName && ( + {category === name && ( )} diff --git a/src/main/components/WorkbookCard/index.tsx b/src/main/components/WorkbookCard/index.tsx new file mode 100644 index 00000000..5551d8df --- /dev/null +++ b/src/main/components/WorkbookCard/index.tsx @@ -0,0 +1,26 @@ +import { WorkbookClientInfo } from "@main/types/workbook"; +import WorkbookCardDetail from "../WorkbookCardDetail"; + +export default function WorkbookCard({ + mainImageUrl, + metaComponent, + title, + writers, + personCourse, + buttonTitle, +}: WorkbookClientInfo) { + return ( +
+ + + {metaComponent} + + + + + +
+ ); +} diff --git a/src/main/components/WorkbookCardDetail/index.tsx b/src/main/components/WorkbookCardDetail/index.tsx new file mode 100644 index 00000000..e8a494e0 --- /dev/null +++ b/src/main/components/WorkbookCardDetail/index.tsx @@ -0,0 +1,77 @@ +import { WorkbookClientInfo } from "@main/types/workbook"; +import { Button } from "@shared/components/ui/button"; +import { cn } from "@shared/utils/cn"; +import Image from "next/image"; +import FewLogo from "public/assets/icon/cardFewLogo.svg"; + +const MainImage = ({ + mainImageUrl, +}: Pick) => ( + +); +const WorkbookDetailInfoWrapper = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => ( +
+ {children} +
+); + +const Title = ({ title }: Pick) => ( +

{title}

+); + +const WriterList = ({ writers }: Pick) => ( +
    + {writers.map((writer, idx) => ( +
  • {writer}
  • + ))} +
+); + +const PersonCourseWithFewLogo = ({ + personCourse, +}: Pick) => ( +
+ {personCourse} + +
+); + +const BottomButton = ({ + buttonTitle, +}: Pick) => ( + +); +const WorkbookCardDetail = { + MainImage, + WorkbookDetailInfoWrapper, + Title, + WriterList, + PersonCourseWithFewLogo, + BottomButton, +}; + +export default WorkbookCardDetail; diff --git a/src/main/components/WorkbookCardList/index.tsx b/src/main/components/WorkbookCardList/index.tsx new file mode 100644 index 00000000..363676e6 --- /dev/null +++ b/src/main/components/WorkbookCardList/index.tsx @@ -0,0 +1,87 @@ +import { WorkbookCardModel } from "@main/models/workbookCardModel"; +import { + WorkbookServerInfo, + WorkbookSubscriptionInfo, +} from "@main/types/workbook"; +import WorkbookCard from "../WorkbookCard"; +// TODO : api 연결필요 + mock +const data: WorkbookServerInfo[] = [ + { + id: 1, + mainImageUrl: + "https://storage.mrblog.net/files/dosi_draw/a3NgiDGW2H3NhsYp1Qp3RuWNzUx9sg8L2yyooYqF.jpg", + title: "몰티즈는 참지않긔", + description: + "몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔", + category: "경제", + createdAt: "2024-07-25 14:32:35", + writers: [ + { + id: 1, + name: "name1", + url: "https://example.com", + }, + ], + subscriberCount: 1, + }, + { + id: 2, + mainImageUrl: + "https://storage.mrblog.net/files/dosi_draw/a3NgiDGW2H3NhsYp1Qp3RuWNzUx9sg8L2yyooYqF.jpg", + title: + "몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔", + description: + "몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔", + category: "경제", + createdAt: "2024-07-25 14:32:35", + writers: [ + { + id: 2, + name: "name2", + url: "https://example.com", + }, + ], + subscriberCount: 2, + }, +]; +const subData: WorkbookSubscriptionInfo[] = [ + { + id: 1, + status: "ACTIVE", + totalDay: 10, + currentDay: 1, + rank: 0, + totalSubscriber: 100, + articleInfo: "{}", + }, + { + id: 2, + status: "DONE", + totalDay: 10, + currentDay: 10, + rank: 22, + totalSubscriber: 100, + articleInfo: "{}", + }, +]; + +interface WorkbookCardListProps { + category: string; +} +export default function WorkbookCardList({ category }: WorkbookCardListProps) { + const workbookCardModel = new WorkbookCardModel({ + initWorkbookSeverList: data, + initWorkbookSubscriptionInfoList: subData, + }); + return ( +
+ {workbookCardModel + .workbookCardList({ + workbookCombineList: workbookCardModel.workbookCombineListData, + }) + .map((data, idx) => ( + + ))} +
+ ); +} diff --git a/src/main/components/WorkbookCardsWrapper/index.tsx b/src/main/components/WorkbookCardsWrapper/index.tsx index a1e042c2..c2a25486 100644 --- a/src/main/components/WorkbookCardsWrapper/index.tsx +++ b/src/main/components/WorkbookCardsWrapper/index.tsx @@ -2,6 +2,7 @@ import useCategory from "@main/hooks/useCategory"; import CategoryTabs from "../CategoryTabs"; import MainContentWrapper from "../MainContentWrapper"; +import WorkbookCardList from "../WorkbookCardList"; export default function WorkbookCardsWrapper() { const { category, handleCategory } = useCategory(); @@ -13,6 +14,7 @@ export default function WorkbookCardsWrapper() { handleCategory={handleCategory} category={category} /> + ); } diff --git a/src/main/models/workbookCardModel.test.tsx b/src/main/models/workbookCardModel.test.tsx new file mode 100644 index 00000000..97c32f3c --- /dev/null +++ b/src/main/models/workbookCardModel.test.tsx @@ -0,0 +1,165 @@ +import { + WorkbookServerInfo, + WorkbookSubscriptionInfo, +} from "@main/types/workbook"; +import { beforeEach, describe, expect, it } from "vitest"; +import { WorkbookCardModel } from "./workbookCardModel"; + +// 테스트 데이터 +const mockWorkbookServerList: WorkbookServerInfo[] = [ + { + id: 1, + mainImageUrl: + "https://storage.mrblog.net/files/dosi_draw/a3NgiDGW2H3NhsYp1Qp3RuWNzUx9sg8L2yyooYqF.jpg", + title: "몰티즈는 참지않긔", + description: + "몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔", + category: "경제", + createdAt: "2024-07-25 14:32:35", + writers: [ + { + id: 1, + name: "name1", + url: "https://example.com", + }, + ], + subscriberCount: 1, + }, + { + id: 2, + mainImageUrl: + "https://storage.mrblog.net/files/dosi_draw/a3NgiDGW2H3NhsYp1Qp3RuWNzUx9sg8L2yyooYqF.jpg", + title: + "몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔", + description: + "몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔 몰티즈는 참지않긔", + category: "경제", + createdAt: "2024-07-25 14:32:35", + writers: [ + { + id: 2, + name: "name2", + url: "https://example.com", + }, + ], + subscriberCount: 2, + }, +]; +const mockWorkbookSubscriptionInfoList: WorkbookSubscriptionInfo[] = [ + { + id: 1, + status: "ACTIVE", + totalDay: 10, + currentDay: 1, + rank: 0, + totalSubscriber: 100, + articleInfo: "{}", + }, + { + id: 2, + status: "DONE", + totalDay: 10, + currentDay: 10, + rank: 22, + totalSubscriber: 100, + articleInfo: "{}", + }, +]; + +describe("메인 워크북 카드 모델 테스트", () => { + let model: WorkbookCardModel; + + beforeEach(() => { + model = new WorkbookCardModel({ + initWorkbookSeverList: mockWorkbookServerList, + initWorkbookSubscriptionInfoList: mockWorkbookSubscriptionInfoList, + }); + }); + + it("api 2개/1개 호출 후, 서버 데이터 합친 결과 확인 하기", () => { + const combinedData = model.getWorkbookServerCombineData(); + expect(combinedData).toEqual([ + expect.objectContaining({ + ...mockWorkbookServerList[0], + ...mockWorkbookSubscriptionInfoList[0], + }), + expect.objectContaining({ + ...mockWorkbookServerList[1], + ...mockWorkbookSubscriptionInfoList[1], + }), + ]); + }); + + it("워크북리스트 및 구독정보 set 형태로 잘 바뀌는지 테스트", () => { + const serverSet = model.transformDataToSet({ + data: mockWorkbookServerList, + }); + const subscriptionSet = model.transformDataToSet({ + data: mockWorkbookSubscriptionInfoList, + }); + + expect(serverSet).toHaveProperty("1"); + expect(subscriptionSet).toHaveProperty("1"); + }); + + it("데이터 합치고 버튼 타이틀 확인하기", () => { + const workbookCardList = model.workbookCardList({ + workbookCombineList: model.getWorkbookServerCombineData(), + }); + + expect(workbookCardList).toHaveLength(2); + expect(workbookCardList[0]).toHaveProperty("buttonTitle", "Day 1 학습하기"); + expect(workbookCardList[1]).toHaveProperty("buttonTitle", "공유하기"); + }); + + it("작가이름 리스트로 변환", () => { + const writerNames = model.getWriterNameList({ + writers: mockWorkbookServerList[0].writers, + }); + expect(writerNames).toEqual(["name1"]); + }); + + it("meta component 생성 테스트", () => { + const metaComponentActive = model.getMetaComponent({ + category: "경제", + currentDay: 10, + totalDay: 1, + }); + expect(metaComponentActive).contains(/Day 1\/10/); + + const metaComponentCompleted = model.getMetaComponent({ + category: "경제", + currentDay: 10, + totalDay: 10, + }); + expect(metaComponentCompleted).contains(/Day 10\/10/); + }); + + it("학습중 상태에 따른 인원 텍스트 함수 테스트", () => { + const personCourseActive = model.getPersonCourse({ + subscriberCount: 10, + status: "ACTIVE", + }); + expect(personCourseActive).toBe("10명 학습중"); + + const personCourseDone = model.getPersonCourse({ + subscriberCount: 20, + status: "DONE", + }); + expect(personCourseDone).toBe("총 20명"); + }); + + it("구독상태에 따른 버튼 타이틀 테스트", () => { + const buttonTitleActive = model.getButtonTitle({ + status: "ACTIVE", + currentDay: 5, + }); + expect(buttonTitleActive).toBe("Day 5 학습하기"); + + const buttonTitleDone = model.getButtonTitle({ + status: "DONE", + currentDay: 10, + }); + expect(buttonTitleDone).toBe("공유하기"); + }); +}); diff --git a/src/main/models/workbookCardModel.tsx b/src/main/models/workbookCardModel.tsx new file mode 100644 index 00000000..06d6b099 --- /dev/null +++ b/src/main/models/workbookCardModel.tsx @@ -0,0 +1,189 @@ +import { + WorkbookClientInfo, + WorkbookServerInfo, + WorkbookSubscriptionInfo, +} from "@main/types/workbook"; + +export class WorkbookCardModel { + constructor({ + initWorkbookSeverList, + initWorkbookSubscriptionInfoList, + }: { + initWorkbookSeverList: WorkbookServerInfo[]; + initWorkbookSubscriptionInfoList?: WorkbookSubscriptionInfo[]; + }) { + this.workbookList = initWorkbookSeverList; + if (initWorkbookSubscriptionInfoList) + this.workbookSubscriptionInfoList = initWorkbookSubscriptionInfoList; + this.workbookCombineList = this.getWorkbookServerCombineData(); + } + get workbookCombineListData() { + return this.workbookCombineList; + } + getWorkbookServerCombineData(): WorkbookCombineInfo[] { + if (this.workbookSubscriptionInfoList) { + const workbookCombineSet: WorkbookCombineInfoSet = {}; + + const workbookSetList = this.transformDataToSet({ + data: this.workbookList, + }); + const workbookSetSubscriptionInfoList = this.transformDataToSet({ + data: this.workbookSubscriptionInfoList, + }); + + for (const workbookKey in workbookSetList) { + const isCommonKey = + Object.prototype.hasOwnProperty.call(workbookSetList, workbookKey) && + Object.prototype.hasOwnProperty.call( + workbookSetSubscriptionInfoList, + workbookKey, + ); + + if (isCommonKey) { + const subscriptionItem = workbookSetSubscriptionInfoList[workbookKey]; + const workbookItem = workbookSetList[workbookKey]; + + workbookCombineSet[workbookKey] = { + ...workbookItem, + ...subscriptionItem, + }; + } else { + const workbookItem = workbookSetList[workbookKey]; + workbookCombineSet[workbookKey] = { + ...workbookItem, + }; + } + } + + return Object.entries(workbookCombineSet).map(([key, value]) => ({ + id: Number(key), + ...value, + })) as WorkbookCombineInfo[]; + } + return this.workbookList; + } + + workbookCardList({ + workbookCombineList, + }: { + workbookCombineList: WorkbookCombineInfo[]; + }): WorkbookClientInfo[] { + return workbookCombineList.map( + ({ + mainImageUrl, + title, + description, + category, + writers, + subscriberCount, + status, + currentDay, + totalDay, + }) => { + const changeToClientData: WorkbookClientInfo = { + mainImageUrl, + title, + writers: this.getWriterNameList({ writers }), + metaComponent: this.getMetaComponent({ + category, + currentDay, + totalDay, + }), + personCourse: this.getPersonCourse({ + subscriberCount, + status, + }), + buttonTitle: this.getButtonTitle({ + status, + currentDay, + }), + }; + return changeToClientData; + }, + ); + } + + getWriterNameList({ writers }: { writers: WorkbookServerInfo["writers"] }) { + return writers.map(({ name }) => name); + } + + getMetaComponent({ + category, + totalDay, + currentDay, + }: { + category: WorkbookServerInfo["category"]; + totalDay: WorkbookSubscriptionInfo["totalDay"] | undefined; + currentDay: WorkbookSubscriptionInfo["currentDay"] | undefined; + }): WorkbookClientInfo["metaComponent"] { + if (totalDay && currentDay) { + if (totalDay === currentDay) + return ( +

+ Day {currentDay}/{totalDay} +

+ ); + return ( +

+ Day {currentDay} + /{totalDay} +

+ ); + } + return

{category}

; + } + + getPersonCourse({ + subscriberCount, + status, + }: { + subscriberCount: WorkbookServerInfo["subscriberCount"]; + status: WorkbookSubscriptionInfo["status"] | undefined; + }): WorkbookClientInfo["personCourse"] { + if (status) { + if (status === "ACTIVE") return `${subscriberCount}명 학습중`; + if (status === "DONE") return `총 ${subscriberCount}명`; + } + return `${subscriberCount}명 학습중`; + } + + getButtonTitle({ + status, + currentDay, + }: { + status: WorkbookSubscriptionInfo["status"] | undefined; + currentDay: WorkbookSubscriptionInfo["currentDay"] | undefined; + }): WorkbookClientInfo["buttonTitle"] { + if (status && currentDay) { + if (status === "ACTIVE") return `Day ${currentDay} 학습하기`; + if (status === "DONE") return "공유하기"; + } + return "구독하기"; + } + + transformDataToSet({ + data, + }: { + data: WorkbookServerInfo[] | WorkbookSubscriptionInfo[]; + }) { + return data.reduce((acc, item) => { + const { id, ...rest } = item; + acc[id] = { + ...rest, + }; + return acc; + }, {}); + } + + private workbookList: WorkbookServerInfo[]; + private workbookSubscriptionInfoList: WorkbookSubscriptionInfo[] | undefined; + private workbookCombineList: WorkbookCombineInfo[]; +} + +type WorkbookCombineInfo = WorkbookServerInfo & + Partial; +type WorkbookCombineInfoSet = { + [key: number]: + | Omit + | Omit, "id">; +}; diff --git a/src/main/remotes/getWorkbookCategoryQueryOptions.ts b/src/main/remotes/getWorkbookCategoryQueryOptions.ts index 37ba3b3e..71487228 100644 --- a/src/main/remotes/getWorkbookCategoryQueryOptions.ts +++ b/src/main/remotes/getWorkbookCategoryQueryOptions.ts @@ -1,20 +1,20 @@ import { ApiResponse, fewFetch } from "@api/fewFetch"; -import { CategoryInfo } from "@common/types/category"; +import { CategoryInfo, CategoryInfoList } from "@common/types/category"; import { UseQueryOptions } from "@tanstack/react-query"; import { API_ROUTE, QUERY_KEY } from "."; -const getWorkbookCategory = (): Promise> => { +const getWorkbookCategory = (): Promise> => { return fewFetch().get(API_ROUTE.CATEGORY); }; export const getWorkbookCategoryQueryOptions = (): UseQueryOptions< - ApiResponse, + ApiResponse, unknown, CategoryInfo[] > => { return { queryKey: [QUERY_KEY.GET_CATEGORY], queryFn: () => getWorkbookCategory(), - select: (data) => data.data.data, + select: (data) => data.data.data.categories, }; }; diff --git a/src/main/types/workbook.ts b/src/main/types/workbook.ts new file mode 100644 index 00000000..4cefa767 --- /dev/null +++ b/src/main/types/workbook.ts @@ -0,0 +1,25 @@ +import { WorkbookInfo } from "@workbook/types"; + +type SubscriptionStatus = "ACTIVE" | "DONE"; + +export interface WorkbookSubscriptionInfo extends Pick { + status: SubscriptionStatus; + totalDay: number; + currentDay: number; + rank: number; + totalSubscriber: number; + articleInfo: string; // JSON문자열 +} + +export type WorkbookServerInfo = { + subscriberCount: number; +} & Omit; + +export interface WorkbookClientInfo { + mainImageUrl: string; + metaComponent: React.ReactElement; + title: string; + writers: string[]; + personCourse: string; + buttonTitle: string; +} diff --git a/src/mocks/response/category.json b/src/mocks/response/category.json index c1643ac6..f897abda 100644 --- a/src/mocks/response/category.json +++ b/src/mocks/response/category.json @@ -1,29 +1,27 @@ { - "message": "성공", - "data": [ - { - "parameterName": "all", - "displayName": "전체" - }, - { - "parameterName": "economy", - "displayName": "경제" - }, - { - "parameterName": "it", - "displayName": "IT" - }, - { - "parameterName": "marketing", - "displayName": "마케팅" - }, - { - "parameterName": "culture", - "displayName": "문화" - }, - { - "parameterName": "science", - "displayName": "과학" - } - ] + "data": { + "categories": [ + { + "code": 0, + "name": "경제" + }, + { + "code": 10, + "name": "IT" + }, + { + "code": 20, + "name": "마케팅" + }, + { + "code": 30, + "name": "교양" + }, + { + "code": 40, + "name": "과학" + } + ] + }, + "message": "성공" }