diff --git a/api/generated/motimo/Api.ts b/api/generated/motimo/Api.ts index a9427f3..144e0f9 100644 --- a/api/generated/motimo/Api.ts +++ b/api/generated/motimo/Api.ts @@ -238,6 +238,12 @@ export interface TodoResultRs { fileUrl?: string; } +export interface ErrorResponse { + /** @format int32 */ + statusCode?: number; + message?: string; +} + export interface PointRs { /** * 사용자가 현재 획득한 포인트 @@ -380,18 +386,10 @@ export enum TodoResultRsEmotionEnum { GROWN = "GROWN", } -import type { - AxiosInstance, - AxiosRequestConfig, - HeadersDefaults, - ResponseType, -} from "axios"; -import axios from "axios"; - export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; -export interface FullRequestParams - extends Omit { +export interface FullRequestParams extends Omit { /** set parameter to `true` for call `securityWorker` for this request */ secure?: boolean; /** request path */ @@ -401,9 +399,13 @@ export interface FullRequestParams /** query params */ query?: QueryParamsType; /** format of response (i.e. response.json() -> format: "json") */ - format?: ResponseType; + format?: ResponseFormat; /** request body */ body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; } export type RequestParams = Omit< @@ -411,147 +413,225 @@ export type RequestParams = Omit< "body" | "method" | "query" | "path" >; -export interface ApiConfig - extends Omit { +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; securityWorker?: ( securityData: SecurityDataType | null, - ) => Promise | AxiosRequestConfig | void; - secure?: boolean; - format?: ResponseType; + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; } +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + export enum ContentType { Json = "application/json", + JsonApi = "application/vnd.api+json", FormData = "multipart/form-data", UrlEncoded = "application/x-www-form-urlencoded", Text = "text/plain", } export class HttpClient { - public instance: AxiosInstance; + public baseUrl: string = "http://158.179.175.134:8080"; private securityData: SecurityDataType | null = null; private securityWorker?: ApiConfig["securityWorker"]; - private secure?: boolean; - private format?: ResponseType; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); - constructor({ - securityWorker, - secure, - format, - ...axiosConfig - }: ApiConfig = {}) { - this.instance = axios.create({ - ...axiosConfig, - baseURL: axiosConfig.baseURL || "http://158.179.175.134:8080", - }); - this.secure = secure; - this.format = format; - this.securityWorker = securityWorker; + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); } public setSecurityData = (data: SecurityDataType | null) => { this.securityData = data; }; - protected mergeRequestParams( - params1: AxiosRequestConfig, - params2?: AxiosRequestConfig, - ): AxiosRequestConfig { - const method = params1.method || (params2 && params2.method); + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter( + (key) => "undefined" !== typeof query[key], + ); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? `?${queryString}` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.JsonApi]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input: any) => + Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : `${property}`, + ); + return formData; + }, new FormData()), + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { return { - ...this.instance.defaults, + ...this.baseApiParams, ...params1, ...(params2 || {}), headers: { - ...((method && - this.instance.defaults.headers[ - method.toLowerCase() as keyof HeadersDefaults - ]) || - {}), + ...(this.baseApiParams.headers || {}), ...(params1.headers || {}), ...((params2 && params2.headers) || {}), }, }; } - protected stringifyFormItem(formItem: unknown) { - if (typeof formItem === "object" && formItem !== null) { - return JSON.stringify(formItem); - } else { - return `${formItem}`; + protected createAbortSignal = ( + cancelToken: CancelToken, + ): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; } - } - protected createFormData(input: Record): FormData { - if (input instanceof FormData) { - return input; - } - return Object.keys(input || {}).reduce((formData, key) => { - const property = input[key]; - const propertyContent: any[] = - property instanceof Array ? property : [property]; + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; - for (const formItem of propertyContent) { - const isFileType = formItem instanceof Blob || formItem instanceof File; - formData.append( - key, - isFileType ? formItem : this.stringifyFormItem(formItem), - ); - } + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); - return formData; - }, new FormData()); - } + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; - public request = async ({ + public request = async ({ + body, secure, path, type, query, format, - body, + baseUrl, + cancelToken, ...params }: FullRequestParams): Promise => { const secureParams = - ((typeof secure === "boolean" ? secure : this.secure) && + ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && this.securityWorker && (await this.securityWorker(this.securityData))) || {}; const requestParams = this.mergeRequestParams(params, secureParams); - const responseFormat = format || this.format || undefined; - - if ( - type === ContentType.FormData && - body && - body !== null && - typeof body === "object" - ) { - body = this.createFormData(body as Record); - } - - if ( - type === ContentType.Text && - body && - body !== null && - typeof body !== "string" - ) { - body = JSON.stringify(body); - } + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; - return this.instance - .request({ + return this.customFetch( + `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, + { ...requestParams, headers: { ...(requestParams.headers || {}), - ...(type ? { "Content-Type": type } : {}), + ...(type && type !== ContentType.FormData + ? { "Content-Type": type } + : {}), }, - params: query, - responseType: responseFormat, - data: body, - url: path, - }) - .then((response) => response.data); + signal: + (cancelToken + ? this.createAbortSignal(cancelToken) + : requestParams.signal) || null, + body: + typeof body === "undefined" || body === null + ? null + : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response.clone() as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const data = !responseFormat + ? r + : await response[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data.data; + }); }; } @@ -881,14 +961,14 @@ export class Api { * @summary 세부 목표별 TODO 목록 조회 * @request GET:/v1/sub-goals/{subGoalId}/todos/incomplete-or-date * @secure - * @response `200` `CustomSlice` TODO 목록 조회 성공 - * @response `400` `(TodoRs)[]` 잘못된 요청 데이터 + * @response `200` `(TodoRs)[]` TODO 목록 조회 성공 + * @response `400` `ErrorResponse` 잘못된 요청 데이터 */ getIncompleteOrTodayTodos: ( subGoalId: string, params: RequestParams = {}, ) => - this.http.request({ + this.http.request({ path: `/v1/sub-goals/${subGoalId}/todos/incomplete-or-date`, method: "GET", secure: true, diff --git a/api/generator.mjs b/api/generator.mjs index 5fe5d83..e11c48a 100644 --- a/api/generator.mjs +++ b/api/generator.mjs @@ -19,7 +19,7 @@ const generate = async (domain, url) => { generateApi({ output: PATH_TO_OUTPUT_DIR, url, - httpClientType: "axios", // or "fetch" + httpClientType: "fetch", // or "fetch" defaultResponseAsSuccess: false, generateClient: true, generateRouteTypes: false, diff --git a/app/globals.css b/app/globals.css index 22162e9..85bc7b2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -31,6 +31,10 @@ body { color: var(--foreground); font-family: var(--font-suit-varialbe), Arial, Helvetica, sans-serif; + + /* 스크롤바 layout shift안되게 수정 */ + width: 100vw; + overflow-x: hidden; } /* Utility class to hide scrollbar */ @@ -51,4 +55,4 @@ body { -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain; scroll-behavior: smooth; -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index 1304186..d3992c0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; import "./globals.css"; import ModalRenderer from "./_components/ModalRenderer"; +import { MSWComponent } from "@/components/_mocks/MSWComponent"; const customFont = localFont({ src: "../public/fonts/SUIT-Variable.woff2", @@ -21,7 +22,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - +
+ {/* {process.env.NODE_ENV === "development" && } */} {children}
diff --git a/app/onboarding/_components/CompletionScreen.tsx b/app/onboarding/_components/CompletionScreen.tsx index d8a7c6c..69513de 100644 --- a/app/onboarding/_components/CompletionScreen.tsx +++ b/app/onboarding/_components/CompletionScreen.tsx @@ -2,6 +2,7 @@ import { ButtonRound } from "@/components/shared/ButtonRound/ButtonRound"; import MotiCheck from "@/components/shared/public/moti-check.svg"; +import Link from "next/link"; interface CompletionScreenProps { goal: string; @@ -67,7 +68,9 @@ export default function CompletionScreen({ {/* Period */}
-

목표 날짜

+

+ 목표 날짜 +

{formatPeriod()}

@@ -78,12 +81,10 @@ export default function CompletionScreen({ {/* Complete Button */}
- - 확인 - + + 확인 +
); -} \ No newline at end of file +} diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 2f0bc21..7c5f409 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -16,15 +16,15 @@ export default function OnboardingPage() { }); const updateOnboardingData = (data: Partial) => { - setOnboardingData(prev => ({ ...prev, ...data })); + setOnboardingData((prev) => ({ ...prev, ...data })); }; const nextStep = () => { - setCurrentStep(prev => prev + 1); + setCurrentStep((prev) => prev + 1); }; const prevStep = () => { - setCurrentStep(prev => prev - 1); + setCurrentStep((prev) => prev - 1); }; const renderStep = () => { @@ -46,9 +46,15 @@ export default function OnboardingPage() { periodType={onboardingData.periodType} monthCount={onboardingData.monthCount} targetDate={onboardingData.targetDate} - onPeriodTypeChange={(periodType: "months" | "date") => updateOnboardingData({ periodType })} - onMonthCountChange={(monthCount: number) => updateOnboardingData({ monthCount })} - onTargetDateChange={(targetDate: Date | null) => updateOnboardingData({ targetDate })} + onPeriodTypeChange={(periodType: "months" | "date") => + updateOnboardingData({ periodType }) + } + onMonthCountChange={(monthCount: number) => + updateOnboardingData({ monthCount }) + } + onTargetDateChange={(targetDate: Date | null) => + updateOnboardingData({ targetDate }) + } onNext={nextStep} onBack={prevStep} /> @@ -62,7 +68,8 @@ export default function OnboardingPage() { targetDate={onboardingData.targetDate} onComplete={() => { // Navigate to main app - window.location.href = "/"; + localStorage.setItem("hasCompletedOnboarding", "true"); + // window.location.href = "/"; }} /> ); @@ -72,8 +79,6 @@ export default function OnboardingPage() { }; return ( -
- {renderStep()} -
+
{renderStep()}
); -} \ No newline at end of file +} diff --git a/app/page.tsx b/app/page.tsx index aca7405..0ea9c83 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,7 +3,18 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -export default function Home() { +import { AppBar } from "@/components/shared"; +import GoalInfo from "@/components/shared/GoalInfo/GoalInfo"; +import TodoList from "@/components/main/TodoList/TodoList"; +import GoalTitleArea from "@/components/main/GoalTitleArea/GoalTitleArea"; + +import GoalMenuContainer from "@/components/main/GoalMenuContainer/GoalMenuContainer"; +import Banner from "@/components/shared/Banner/Banner"; +import GoalCard from "@/components/main/GoalCard/GoalCard"; +import MainHeader from "@/components/main/MainHeader/MainHeader"; +import { BottomTabBar } from "@/components/shared"; + +export default function Main() { const [isLoggedIn, setIsLoggedIn] = useState(null); // null = loading const router = useRouter(); @@ -12,8 +23,10 @@ export default function Home() { const checkLoginStatus = () => { // 더미 상태: localStorage에서 로그인 정보 확인 const loginStatus = localStorage.getItem("isLoggedIn"); - const hasCompletedOnboarding = localStorage.getItem("hasCompletedOnboarding"); - + const hasCompletedOnboarding = localStorage.getItem( + "hasCompletedOnboarding", + ); + if (!loginStatus || loginStatus !== "true" || !hasCompletedOnboarding) { // 로그인하지 않았거나 온보딩을 완료하지 않은 경우 router.replace("/onboarding"); @@ -25,63 +38,36 @@ export default function Home() { checkLoginStatus(); }, [router]); - // 로그인 상태 확인 중일 때 로딩 화면 - if (isLoggedIn === null) { - return ( -
-
-
-

로딩 중...

-
-
- ); - } + const tmpDaysOfServiceUse = 1; - // 로그인된 사용자를 위한 메인 대시보드 return ( -
-
-

MOTIMO

- -
- -
-

- 환영합니다! 🎉 -

-

- 온보딩이 완료되었습니다.
- 이곳에 메인 대시보드가 표시됩니다. -

- -
-
-

설정된 목표

-

- {typeof window !== 'undefined' ? localStorage.getItem("userGoal") || "목표가 설정되지 않았습니다" : ""} -

+ + {/*
+ +
+ */} + + {/*
+ + + +
*/} + +
+
- -
-
-
+ + ); } diff --git a/components/_mocks/MSWComponent.tsx b/components/_mocks/MSWComponent.tsx new file mode 100644 index 0000000..e4adb0a --- /dev/null +++ b/components/_mocks/MSWComponent.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export const MSWComponent = () => { + const [mswReady, setMswReady] = useState(false); + useEffect(() => { + const init = async () => { + const initMsw = await import("../../mocks/index").then( + (res) => res.initMsw, + ); + await initMsw(); + setMswReady(true); + }; + + if (!mswReady) { + init(); + } + }, [mswReady]); + + return null; +}; diff --git a/components/main/GoalCard/GoalCard.stories.tsx b/components/main/GoalCard/GoalCard.stories.tsx new file mode 100644 index 0000000..e333073 --- /dev/null +++ b/components/main/GoalCard/GoalCard.stories.tsx @@ -0,0 +1,51 @@ +// plop-templates/story.tsx.hbs +import type { Meta, StoryObj } from '@storybook/react'; +import GoalCard from './GoalCard'; // 실제 컴포넌트 파일 임포트 + +const meta = { + title: 'Components/GoalCard', // Storybook 사이드바 경로 (프로젝트 규칙에 맞게 수정) + component: GoalCard, + parameters: { + // Canvas 레이아웃을 중앙으로 정렬하거나 패딩을 추가할 수 있습니다. + layout: 'centered', + }, + // Docs 탭 자동 생성을 위해 필요합니다. + tags: ['autodocs'], + // Controls Addon에서 Props를 어떻게 제어할지, 설명을 추가합니다. + // 모든 스토리에 적용될 기본 Props (선택 사항) + args: { + // 예시: label: 'GoalCard', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// 가장 기본적인 Primary 스토리 +// argTypes를 Primary는 기본으로 가집니다. +export const Primary: Story = { + argTypes: { + // 예시: backgroundColor: { control: 'color', description: '컴포넌트 배경색' }, + }, + args: { + // Primary 스토리에만 적용될 Props + }, +}; + +/* +// UI variatns들을 스토리로 생성합니다. +// 추가적인 스토리 예시: +export const Secondary: Story = { + args: { + label: 'Secondary GoalCard', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Large GoalCard', + }, +}; +*/ \ No newline at end of file diff --git a/components/main/GoalCard/GoalCard.tsx b/components/main/GoalCard/GoalCard.tsx new file mode 100644 index 0000000..6b4046c --- /dev/null +++ b/components/main/GoalCard/GoalCard.tsx @@ -0,0 +1,119 @@ +"use client"; + +import useGoalStore from "@/stores/useGoalStore"; +import TodoList from "../TodoList/TodoList"; +import useGoalWithSubGoalTodo from "@/hooks/main/queries/useGoalWithSubGoalTodo"; +import { GoalWithSubGoalTodoRs } from "@/api/generated/motimo/Api"; +import GoalTitleArea from "../GoalTitleArea/GoalTitleArea"; +import GoalInfo from "@/components/shared/GoalInfo/GoalInfo"; +import TodoBottomSheet, { + TodoBottomSheetProps, + TodoInfoForSubmission, +} from "@/components/shared/BottomSheets/TodoBottomSheet/TodoBottomSheet"; +import useTodoList from "@/hooks/main/queries/useTodoList"; +import { useEffect, useRef, useState } from "react"; +import { createNewGoal } from "@/lib/main/goalFetching"; +import { createNewTodoOnSubGoal } from "@/lib/main/subGoalFetching"; +import { updateTodo } from "@/lib/main/todoFetching"; +import useActiveTodoBottomSheet from "@/stores/useActiveTodoBottomSheet"; +import useModal from "@/hooks/useModal"; +import { date2StringWithSpliter } from "@/utils/date2String"; + +interface GoalCardProps { + initSubGoalTodo?: GoalWithSubGoalTodoRs; +} + +const GoalCard = ({ initSubGoalTodo }: GoalCardProps) => { + const { goalId } = useGoalStore(); + const { data: goalWithSubGoalTodo } = useGoalWithSubGoalTodo(goalId || "", { + fallbackData: initSubGoalTodo, + }); + const [newTodo, setNewTodo] = useState(null); + const { mutate } = useTodoList(newTodo?.subGoalId ?? ""); + const { isActive, setIsActive, initContent } = useActiveTodoBottomSheet(); + const { isOpened: isModalOpened } = useModal(); + + // GoalInfo props 계산 + const goalLeftDate = Math.floor( + (new Date(goalWithSubGoalTodo.dueDate ?? "").getTime() - + new Date().getTime()) / + 1000 / + 24 / + 60 / + 60, + ); + + const totalTodoLenInGoal = goalWithSubGoalTodo.subGoals.reduce( + (acc, subGoal) => acc + subGoal.initTodoTotalLen, + 0, + ); + const checkedTodoLenInGoal = goalWithSubGoalTodo.subGoals.reduce( + (acc, subGoal) => acc + (subGoal.initTodoCheckedLen ?? 0), + 0, + ); + const goalLeftTodoNum = totalTodoLenInGoal - checkedTodoLenInGoal; + + // 투두 추가/변경에서 refetch + useEffect(() => { + mutate(); + }, [newTodo, mutate]); + + return ( + <> +
+ + +
+ {goalWithSubGoalTodo?.subGoals?.map((subGoalInfo) => { + return ; + })} + +
+
+ + ({ + id: subGoalInfo.subGoalId ?? "", + title: subGoalInfo.subGoal ?? "", + }))} + // modal이 등장하면 bottomSheet는 닫기. + openBottomSheet={ + !isModalOpened && goalWithSubGoalTodo?.subGoals.length > 0 + } + onSubmitTodo={async (newTodoInfo) => { + const isCreating = newTodoInfo.id ? false : true; + let fetchRes; + if (isCreating) { + fetchRes = await createNewTodoOnSubGoal(newTodoInfo.subGoalId, { + title: newTodoInfo.todo, + date: newTodoInfo?.date + ? date2StringWithSpliter(newTodoInfo?.date, "-") + : undefined, + }); + } else { + fetchRes = await updateTodo(newTodoInfo.id ?? "", { + date: newTodoInfo.date, + title: newTodoInfo.todo, + }); + } + + const isFetchOk = fetchRes ? true : false; + if (isFetchOk) { + setNewTodo(newTodoInfo); + } + + return isFetchOk; + }} + /> + + ); +}; +export default GoalCard; diff --git a/components/main/GoalMenuContainer/GoalMenuContainer.tsx b/components/main/GoalMenuContainer/GoalMenuContainer.tsx new file mode 100644 index 0000000..31729f5 --- /dev/null +++ b/components/main/GoalMenuContainer/GoalMenuContainer.tsx @@ -0,0 +1,75 @@ +"use client"; +import GoalMenu, { GoalMenuProps } from "@/components/shared/GoalMenu/GoalMenu"; +import useGoalList from "@/hooks/main/queries/useGoalList"; +import useGoalStore from "@/stores/useGoalStore"; +import { useEffect, useState } from "react"; + +import PlusSvg from "@/components/shared/public/Add_Plus.svg"; +import useModal from "@/hooks/useModal"; +import ModalAddingGoal from "@/components/shared/Modal/ModalAddingGoal/ModalAddingGoal"; +import { createNewGoal } from "@/lib/main/goalFetching"; + +type GoalMenuInfo = Pick & { + goalId: string; +}; +interface GoalMenuContainerProps { + initGoalsInfo?: GoalMenuInfo[]; + initSelectedGoalInfo?: GoalMenuProps["goal"]; +} + +const GoalMenuContainer = ({}: GoalMenuContainerProps) => { + const { data: goalMenuInfoList, mutate } = useGoalList(); + const [selectedGoalIdx, setSelectedGoalIdx] = useState(0); + const { updateGoalId } = useGoalStore(); + const { openModal, closeModal } = useModal(); + + useEffect(() => { + updateGoalId(goalMenuInfoList[selectedGoalIdx]?.goalId ?? null); + }, [goalMenuInfoList[selectedGoalIdx]?.goalId, updateGoalId]); + + const goalNum = goalMenuInfoList.length; + return ( + <> +
+
+

+ {`${goalNum}개의 목표`} +

+ +
+
+ {goalMenuInfoList.map((goalMenuInfo, idx) => ( + { + setSelectedGoalIdx(idx); + // updateGoalId(goalMenuInfo.goalId); + }} + /> + ))} +
+
+ + ); +}; +export default GoalMenuContainer; + +export type { GoalMenuInfo }; diff --git a/components/main/GoalTitleArea/GoalTitleArea.tsx b/components/main/GoalTitleArea/GoalTitleArea.tsx new file mode 100644 index 0000000..c46447c --- /dev/null +++ b/components/main/GoalTitleArea/GoalTitleArea.tsx @@ -0,0 +1,23 @@ +"use client"; +import RightArrowSvg from "@/public/images/Chevron_Right_MD.svg"; + +interface GoalTitleAreaProps { + goalTitle: string; +} + +const GoalTitleArea = ({ goalTitle }: GoalTitleAreaProps) => { + return ( + <> +
+

+ {goalTitle} +

+
+ + {/*
*/} +
+
+ + ); +}; +export default GoalTitleArea; diff --git a/components/main/MainHeader/MainHeader.tsx b/components/main/MainHeader/MainHeader.tsx new file mode 100644 index 0000000..2b5d800 --- /dev/null +++ b/components/main/MainHeader/MainHeader.tsx @@ -0,0 +1,35 @@ +"use client"; +import { AppBar } from "@/components/shared"; +import Banner from "@/components/shared/Banner/Banner"; +import { getCheerComment } from "@/lib/main/cheersFetching"; +import { getPoints } from "@/lib/main/pointsFetching"; +import useSWR from "swr"; + +interface MainHeaderProps { + daysOfServiceUse: number; +} +const MainHeader = ({ daysOfServiceUse }: MainHeaderProps) => { + // 임시 fetching. RSC에서 가져오도록 바꿔야 함 + const { data: cheerData } = useSWR("title", getCheerComment); + const { data: pointData } = useSWR("points", getPoints); + const cheerPhrase = cheerData?.cheerPhrase ?? ""; + const points = `${(pointData?.point ?? 0).toLocaleString()}P`; + + return ( + <> +
+
+ +
+
+ + + ); +}; + +export default MainHeader; diff --git a/components/main/TodoList/TodoList.stories.tsx b/components/main/TodoList/TodoList.stories.tsx new file mode 100644 index 0000000..73cbeda --- /dev/null +++ b/components/main/TodoList/TodoList.stories.tsx @@ -0,0 +1,167 @@ +// plop-templates/story.tsx.hbs +import type { Meta, StoryObj } from "@storybook/react"; +import TodoList, { TodoListProps } from "./TodoList"; // 실제 컴포넌트 파일 임포트 +import { + Primary as TodoItemPrimary, + CompleteNonSubmit, + IncompleteDateLate, + CompleteSubmitLateYesterday, +} from "@/components/shared/TodoItem/TodoItem.stories"; +import { TodoItemProps } from "@/components/shared/TodoItem/TodoItem"; + +const description = ` +스와이프는 상태로 만들지 않을 것 같으므로 Primary에서 확인할 것. +각 TodoItem에 대해서나 세부목표 추가나 모달이 존재해야 함. +모달을 어떻게 연결해놓을지 고민해야 함. +또한, 각 TodoList의 id와 todoItem의 id를 받아야 관련해서 mutate작업이 가능함. +**ㄴ> 백엔드에 문의** + +스와이프로 수정 및 삭제 드러날 때, zindex로 가려놓으면 Accessibility에서 악영향일진 체크해 봐야 함. + +고려사항 +- 체크박스 비동기 동작으로 인해 todoCheckedLen는 어떻게 처리할지 + (낙관적? 혹은 deferred? 혹은 fallback?) +- todo 수정, 제출할 때 request 타입 구체적 명시, +`; + +const meta = { + title: "Main/TodoList", // Storybook 사이드바 경로 (프로젝트 규칙에 맞게 수정) + component: TodoList, + parameters: { + // Canvas 레이아웃을 중앙으로 정렬하거나 패딩을 추가할 수 있습니다. + layout: "centered", + docs: { + description: { + component: description, + }, + }, + }, + // Docs 탭 자동 생성을 위해 필요합니다. + tags: ["autodocs"], + // Controls Addon에서 Props를 어떻게 제어할지, 설명을 추가합니다. + // 모든 스토리에 적용될 기본 Props (선택 사항) + args: { + // 예시: label: 'TodoList', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// 가장 기본적인 Primary 스토리 +// argTypes를 Primary는 기본으로 가집니다. + +/** 타입 변환 작업. (todoItemProps와 TodoListProps사이 괴리) */ + +type TodoItemsInfoType = NonNullable[0]; +const validTypes: (keyof TodoItemsInfoType)[] = [ + "checked", + "id", + "reported", + "targetDate", + "title", +]; + +const isTodoItemsInfoType = ( + field: string, +): field is keyof TodoItemsInfoType => { + return validTypes.includes(field as keyof TodoItemsInfoType); +}; +const convert2ValidTodoItemsInfoType = ( + todoItemsInfo: (TodoItemProps | TodoItemsInfoType)[], +) => { + return todoItemsInfo.map((info, idx) => + Object.entries(info).reduce( + (acc, [key, value]) => { + if (isTodoItemsInfoType(key)) return { ...acc, [key]: value }; + return acc; + }, + { id: idx } as unknown as TodoItemsInfoType, + ), + ); +}; + +const todoItemsInfo = convert2ValidTodoItemsInfoType([ + TodoItemPrimary.args, + CompleteNonSubmit.args, + IncompleteDateLate.args, + CompleteSubmitLateYesterday.args, +]); + +export const Primary: Story = { + argTypes: { + // 예시: backgroundColor: { control: 'color', description: '컴포넌트 배경색' }, + }, + args: { + subGoal: "세부 목표입니다", + initTodoCheckedLen: 2, + initTodoTotalLen: todoItemsInfo.length, + initTodoItemsInfo: todoItemsInfo, + + // Primary 스토리에만 적용될 Props + }, +}; + +export const NoSubGoal: Story = { + args: { + ...Primary.args, + subGoal: undefined, + goalId: "골아이디", + }, +}; +const todoItemsInfo4NoTotalTodo: (typeof Primary.args)["initTodoItemsInfo"] = + []; +export const NoTotalTodo: Story = { + args: { + ...Primary.args, + initTodoTotalLen: todoItemsInfo4NoTotalTodo.length, + initTodoItemsInfo: todoItemsInfo4NoTotalTodo, + }, +}; +export const NotFinishedTodos: Story = { + args: { + ...Primary.args, + }, +}; +/** 높이 312px안에서 투두 스크롤 가능해야함. + * 보여지는 순서가정해져 있으므로, 자세한건 figma에서 확인. 백엔드에서 소팅해줄 듯. + */ +const todoItemsInfo4NotFinishedTodosLong: (typeof Primary.args)["initTodoItemsInfo"] = + convert2ValidTodoItemsInfoType([ + ...(Primary.args.initTodoItemsInfo ?? []), + TodoItemPrimary.args, + TodoItemPrimary.args, + TodoItemPrimary.args, + TodoItemPrimary.args, + ]); +export const NotFinishedTodosLong: Story = { + args: { + ...Primary.args, + initTodoItemsInfo: todoItemsInfo4NotFinishedTodosLong, + initTodoTotalLen: todoItemsInfo4NotFinishedTodosLong.length, + }, +}; +export const FinishedTodos: Story = { + args: { + ...Primary.args, + initTodoCheckedLen: Primary.args.initTodoTotalLen, + }, +}; + +/* +// UI variatns들을 스토리로 생성합니다. +// 추가적인 스토리 예시: +export const Secondary: Story = { + args: { + label: 'Secondary TodoList', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Large TodoList', + }, +}; +*/ diff --git a/components/main/TodoList/TodoList.tsx b/components/main/TodoList/TodoList.tsx new file mode 100644 index 0000000..05cf1e8 --- /dev/null +++ b/components/main/TodoList/TodoList.tsx @@ -0,0 +1,460 @@ +"use client"; + +import { + Dispatch, + Fragment, + memo, + SetStateAction, + useState, + createContext, + useContext, + useOptimistic, + startTransition, +} from "react"; +import { KeyedMutator } from "swr"; +import { motion, useMotionValue } from "motion/react"; +import { animate } from "motion"; + +import UpArrowSvg from "@/public/images/Caret_Up_MD.svg"; +import DownArrowSvg from "@/public/images/Caret_Down_MD.svg"; +import PlusSvg from "../../shared/public/Add_Plus.svg"; +import PencilSvg from "@/public/images/Edit_Pencil_01.svg"; +import TrashCanSvg from "@/public/images/Trash_Full.svg"; + +import { TodoItemsInfo } from "@/types/todoList"; + +import TodoItem from "@/components/shared/TodoItem/TodoItem"; +import ModalAddingSubGoal from "@/components/shared/Modal/ModalAddingSubGoal/ModalAddingSubGoal"; + +import useModal from "@/hooks/useModal"; +import useTodoList from "@/hooks/main/queries/useTodoList"; +import useOptimisticToggle from "@/hooks/main/useOptimisticToggle"; +import useActiveTodoBottomSheet from "@/stores/useActiveTodoBottomSheet"; + +import { deleteTodo, toggleTodo } from "@/lib/main/todoFetching"; +// import { createNewTodoOnSubGoal } from "@/lib/main/subGoalFetching"; +import { createNewSubGoalOnGoal } from "@/lib/main/goalFetching"; +import { TodoRs } from "@/api/generated/motimo/Api"; +import useGoalWithSubGoalTodo from "@/hooks/main/queries/useGoalWithSubGoalTodo"; + +/** api generator로부터 받은 타입을 사용 */ + +interface TodoListProps { + /** 없다면, 다른 UI */ + subGoal?: string; + /** todoCheckedLen도 낙관적 업뎃이 필요한지는 모르겠다. */ + initTodoCheckedLen?: number; + /** todoToalLen이 0이면 todoItemsInfo길이가 0인거긴 한데... */ + initTodoTotalLen: number; + /** 길이가 0이라면, 다른 UI */ + initTodoItemsInfo?: TodoItemsInfo[]; + /** subGoal의 id */ + subGoalId?: string; + /** goal의 id */ + goalId?: string; +} + +const TodoListContext = createContext<{ + subGoalTitle?: string; + subGoalId?: string; + mutate?: KeyedMutator; + updateOptimisticCheckedLen?: (action: number) => void; +} | null>(null); + +const TodoList = ({ + subGoal, + initTodoCheckedLen = 0, + initTodoTotalLen, + initTodoItemsInfo, + subGoalId, + goalId, +}: TodoListProps) => { + // 펼친 상태가 기본 + const [isFolded, setIsFolded] = useState(false); + const { data: todoItemsInfo, mutate } = useTodoList(subGoalId ?? "", { + fallbackData: initTodoItemsInfo, + }); + const todoCheckedLen = + todoItemsInfo?.filter((todoItem) => todoItem.checked).length ?? + initTodoCheckedLen; + + const [optimisticCheckedLen, updateOptimisticCheckedLen] = useOptimistic( + todoCheckedLen, + (cur: number, delta: number) => { + return cur + delta; + }, + ); + + const todoTotalLen = todoItemsInfo ? todoItemsInfo.length : initTodoTotalLen; + + if (!subGoal || !subGoalId) + return ( + <> + + + ); + const hasTodoItemsInfo = todoItemsInfo ? todoItemsInfo.length > 0 : false; + return ( + <> +
+ {/** TodoList Header */} +
+ +
+ {subGoal} +
+ {hasTodoItemsInfo && ( +
+
+ {`${optimisticCheckedLen}/${todoTotalLen}`} +
+
+ )} +
+ +
+
+ + + +
+
+
+ + ); +}; +export default TodoList; +export type { TodoListProps }; + +/** TodoList 재료들 + * - NoSubGoal + * - TodoArea + */ + +interface NoSubGoalProps { + goalId?: string; +} + +const NoSubGoal = ({ goalId }: NoSubGoalProps) => { + const { closeModal, openModal } = useModal(); + const { mutate } = useGoalWithSubGoalTodo(goalId ?? ""); + + return ( + <> +
+
+
+ 세부 목표를 추가해주세요. +
+ +
+
+ + ); +}; + +const TodoArea = ({ + todoItemsInfo, + hasTodoItemsInfo, + todoCheckedLen, + todoTotalLen, +}: { + todoItemsInfo: TodoItemsInfo[]; + hasTodoItemsInfo: boolean; + todoCheckedLen: number; + todoTotalLen: number; +}) => { + const [selectedTodoItem, setSelectedTodoItem] = useState(null); + // todo를 모두 완료했을 때. + if (hasTodoItemsInfo && todoCheckedLen > 0 && todoCheckedLen === todoTotalLen) + return ( + <> + + + ); + + // todoItem도 없을 때. + if (!hasTodoItemsInfo) + return ( + <> + + + ); + + // 일반 케이스 + return ( + <> +
+ {todoItemsInfo.map((info) => { + return ( + + ); + })} +
+ + ); +}; + +/** TodoArea 재료들 */ + +const TodoItemContainer = ({ + info, + selectedTodoItem, + setSelectedTodoItem, +}: { + info: TodoItemsInfo; + selectedTodoItem: null | string; + setSelectedTodoItem: Dispatch>; +}) => { + const x = useMotionValue(0); + const contextContent = useContext(TodoListContext); + const { mutate, subGoalId, subGoalTitle, updateOptimisticCheckedLen } = + contextContent || {}; + + const [checked, toggleChecekdOptimistically] = useOptimisticToggle( + info.checked ?? false, + ); + const { setIsActive } = useActiveTodoBottomSheet(); + /** + * 여기에 todoItemProp에 들어갈 onChecked, onMoodClick에 대해 처리해야 하나? + * 그럼 Edit, Delete버튼에 대해 모달 띄우는거는? + * + * 모달 띄우는게 onMoodClick, Edit, Delete에 대해. + * onChecked는 바로 fetch를 보내야 하는데, + * + * => + * 그냥 이 안에서 클릭 이벤트에 대한 동작을 다 넣자. + * 모달이 사용된다면 모달 훅이랑 함께, 모달 안에 넣을 함수도 만들자. + */ + + return ( + +
+ { + const newX = dragInfo.offset.x < -30 ? -88 : 0; + animate(x, newX); + if (newX === -88) setSelectedTodoItem(info.id ?? null); + }} + transition={{ + type: "spring", + stiffness: 300, + damping: 30, + }} + > + { + startTransition(async () => { + toggleChecekdOptimistically(); + await toggleTodo(info.id); + mutate && mutate(); + }); + // updateOptimisticCheckedLen && + // updateOptimisticCheckedLen(checked ? -1 : +1); + }} + /> + +
+ { + /** 임시. 여기에 바텀 시트 관련 들어가야 함 */ + const initTodoInfo = { + date: info?.targetDate, + subGoalId: subGoalId ?? "", + subGoalTitle: subGoalTitle ?? "", + todo: info?.title, + id: info?.id, + }; + setIsActive(true, initTodoInfo); + }} + /> + { + await deleteTodo(info.id); + mutate && mutate(); + }} + /> +
+
+
+ // + ); +}; + +const OptimizedTodoItem = memo(TodoItem); + +const AllTodoFinished = () => { + return ( + <> +
+
+
+ 투두를 모두 완료했어요! +
+

+ {"새로운 투두를 추가하시거나\n세부 목표를 달성하실 수 있어요."} +

+
+
+
+ +
+
+
+
+ + ); +}; + +const NoTodo = () => { + return ( + <> +
+
+
+ 현재 등록된 할 일이 없어요! +
+
+
+ + ); +}; + +/** 재료들 */ + +interface EditButtonProps { + /** 밖에서 처리하도록 하기 */ + onEdit: () => void; +} + +const EditButton = ({ onEdit }: EditButtonProps) => { + return ( + <> + + + ); +}; + +interface DeleteButtonProps { + /** 밖에서 처리하도록 하기 */ + onDelete: () => Promise; +} + +const DeleteButton = ({ onDelete }: DeleteButtonProps) => { + return ( + <> + + + ); +}; diff --git a/components/shared/AppBar/AppBar.tsx b/components/shared/AppBar/AppBar.tsx index b6ac7de..7cac44d 100644 --- a/components/shared/AppBar/AppBar.tsx +++ b/components/shared/AppBar/AppBar.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "../utils/utils"; import { BellIcon } from "../../icons/BellIcon"; import { ChevronLeftIcon } from "../../icons/ChevronLeftIcon"; @@ -24,14 +24,16 @@ export const AppBar = ({ className, }: AppBarProps) => { return ( -
+
{/* Back button - shown for back and progress types */} {(type === "back" || type === "progress") && (
- } + {title && ( +
+

+ {title} +

+
+ )} {/* Progress bar - shown for progress type */} {type === "progress" && ( @@ -66,12 +69,13 @@ export const AppBar = ({ {/* Right area - shown for main type */} {type === "main" && (
- {points &&
- - {points} - -
- } + {points && ( +
+ + {points} + +
+ )} + +
+
+
+ + +
+
+
+ + + )} + +
+ + + + + ); +}; +export default TodoBottomSheet; + +export type { TodoInfoForSubmission, TodoBottomSheetProps }; + +interface BottomSheetSelectListProps { + subGoals: TodoBottomSheetProps["subGoals"]; + setVisibleSelect: Dispatch>; +} + +const BottomSheetSelectList = ({ + subGoals, + setVisibleSelect, +}: BottomSheetSelectListProps) => { + const nullabeTodoInfo = useContext(TodoInfoContext); + + return ( + <> +
    + {subGoals.map(({ title: subGoalTitle, id: subGoalId }) => { + const isSelected = nullabeTodoInfo?.todoInfo.subGoalId === subGoalId; + return ( +
  • { + nullabeTodoInfo && + nullabeTodoInfo.setTodoInfo((prev) => ({ + ...prev, + subGoalTitle: subGoalTitle, + subGoalId: subGoalId, + })); + + // select 끄기 + setVisibleSelect((prev) => !prev); + }} + className={`self-stretch flex justify-center ${ + isSelected ? "text-background-primary" : "text-label-strong" + } text-xs font-medium font-['SUIT_Variable'] leading-none`} + > +

    + {subGoalTitle} +

    + {isSelected && ( +
    + +
    + )} +
  • + ); + })} +
+ + ); +}; + +interface BottomSheetDateProps { + setShowDate: Dispatch>; +} + +const BottomSheetDate = ({ setShowDate }: BottomSheetDateProps) => { + const nullableTodoInfo = useContext(TodoInfoContext); + // initDate가 없다면 당일로. + const [selectedDate, setSelectedDate] = useState( + nullableTodoInfo?.todoInfo.date ?? new Date(), + ); + + return ( +
+
+
+ +
+

+ 날짜 +

+ +
+ setSelectedDate(newDate)} + /> +
+ ); +}; + +/** shared에서의 util이라 걍 여기에 정의함 */ +const date2stringSplitByDot = (date: Date) => + `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, "0")}.${String(date.getDate()).padStart(2, "0")}`; diff --git a/components/shared/BottomSheets/TodoResultBottomSheet/TodoResultBottomSheet.tsx b/components/shared/BottomSheets/TodoResultBottomSheet/TodoResultBottomSheet.tsx new file mode 100644 index 0000000..03e1492 --- /dev/null +++ b/components/shared/BottomSheets/TodoResultBottomSheet/TodoResultBottomSheet.tsx @@ -0,0 +1,26 @@ +import { Drawer } from "vaul"; +interface TodoResultBottomSheetProps { + openModal: boolean; +} +const TodoResultBottomSheet = ({ openModal }: TodoResultBottomSheetProps) => { + return ( + <> + + + { + // resetBottomSheet(); + }} + className="fixed inset-0 bg-black/10 z-10" + /> + + + + + + ); +}; + +export default TodoResultBottomSheet; diff --git a/components/shared/BottomTabBar/BottomTabBar.tsx b/components/shared/BottomTabBar/BottomTabBar.tsx index db318f5..687109d 100644 --- a/components/shared/BottomTabBar/BottomTabBar.tsx +++ b/components/shared/BottomTabBar/BottomTabBar.tsx @@ -1,7 +1,8 @@ -import { cn } from "@/lib/utils"; +import { cn } from "../utils/utils"; import { HomeIcon } from "../../icons/HomeIcon"; import { UserIcon } from "../../icons/UserIcon"; import { ChatIcon } from "../../icons/ChatIcon"; +import Link from "next/link"; interface BottomTabBarProps { type: "1" | "2" | "3" | "4"; @@ -15,61 +16,68 @@ export const BottomTabBar = ({ type, className }: BottomTabBarProps) => { id: "goal", label: "목표", icon: HomeIcon, - activeInTypes: ["1"] + activeInTypes: ["1"], + href: "/", }, { - id: "group", + id: "group", label: "그룹", icon: UserIcon, - activeInTypes: ["2"] + activeInTypes: ["2"], + href: "/group", }, { id: "feed", - label: "피드", + label: "피드", icon: ChatIcon, - activeInTypes: ["3"] + activeInTypes: ["3"], + href: "/feed", }, { id: "mypage", label: "마이페이지", icon: UserIcon, - activeInTypes: ["4"] - } + activeInTypes: ["4"], + href: "/mypage", + }, ]; return ( -
+
{tabs.map((tab) => { const isActive = tab.activeInTypes.includes(type); const IconComponent = tab.icon; - + return ( -
-
- -
-
- {tab.label} + +
+
+ +
+
+ {tab.label} +
-
+ ); })}
); -}; \ No newline at end of file +}; diff --git a/components/shared/Button/Button.tsx b/components/shared/Button/Button.tsx index f68945e..a78c343 100644 --- a/components/shared/Button/Button.tsx +++ b/components/shared/Button/Button.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "../utils/utils"; export interface ButtonProps extends React.ButtonHTMLAttributes { @@ -31,7 +31,7 @@ const Button = React.forwardRef( // Size variants with correct padding and font sizes from Figma { "px-3 py-1 text-sm h-8": size === "s", // 4px 12px, 14px font, 32px height - "px-4 py-2 text-base h-10": size === "m", // 8px 16px, 16px font, 40px height + "px-4 py-2 text-base h-10": size === "m", // 8px 16px, 16px font, 40px height "px-4 py-3 text-base h-12": size === "l", // 12px 16px, 16px font, 48px height }, @@ -41,7 +41,7 @@ const Button = React.forwardRef( "bg-Color-primary-50 text-Color-white hover:opacity-90 active:opacity-80 disabled:bg-Color-gray-20 disabled:text-Color-gray-50 disabled:cursor-not-allowed disabled:hover:opacity-100": variant === "filled", - // Outlined variant + // Outlined variant "border-[1.5px] border-Color-primary-50 text-Color-primary-50 bg-transparent hover:bg-Color-primary-50/5 active:bg-Color-primary-50/10 disabled:border-Color-gray-40 disabled:text-Color-gray-50 disabled:cursor-not-allowed disabled:hover:bg-transparent": variant === "outlined", diff --git a/components/shared/ButtonRound/ButtonRound.tsx b/components/shared/ButtonRound/ButtonRound.tsx index 88aee06..dc31cdd 100644 --- a/components/shared/ButtonRound/ButtonRound.tsx +++ b/components/shared/ButtonRound/ButtonRound.tsx @@ -1,7 +1,8 @@ -import { cn } from "@/lib/utils"; +import { cn } from "../utils/utils"; import { forwardRef } from "react"; -interface ButtonRoundProps extends React.ButtonHTMLAttributes { +interface ButtonRoundProps + extends React.ButtonHTMLAttributes { children: React.ReactNode; variant?: "primary"; size?: "default"; @@ -9,7 +10,17 @@ interface ButtonRoundProps extends React.ButtonHTMLAttributes } export const ButtonRound = forwardRef( - ({ children, variant = "primary", size = "default", className, disabled, ...props }, ref) => { + ( + { + children, + variant = "primary", + size = "default", + className, + disabled, + ...props + }, + ref, + ) => { return ( ); - } + }, ); -ButtonRound.displayName = "ButtonRound"; \ No newline at end of file +ButtonRound.displayName = "ButtonRound"; diff --git a/components/shared/Checkbox/Checkbox.tsx b/components/shared/Checkbox/Checkbox.tsx index 4c7e46e..10c91ab 100644 --- a/components/shared/Checkbox/Checkbox.tsx +++ b/components/shared/Checkbox/Checkbox.tsx @@ -1,7 +1,6 @@ "use client"; import { InputHTMLAttributes } from "react"; -// import CheckSvg from "../public/check.svg"; type CheckboxProps = Omit, "type">; const Checkbox = ({ ...props }: CheckboxProps) => { @@ -9,6 +8,7 @@ const Checkbox = ({ ...props }: CheckboxProps) => { <> { return ( <>
-
-
+
+
diff --git a/components/shared/GoalMenu/GoalMenu.tsx b/components/shared/GoalMenu/GoalMenu.tsx index c5a8b28..bbbb571 100644 --- a/components/shared/GoalMenu/GoalMenu.tsx +++ b/components/shared/GoalMenu/GoalMenu.tsx @@ -62,3 +62,4 @@ const GoalMenu = ({ ); }; export default GoalMenu; +export type { GoalMenuProps }; diff --git a/components/shared/Modal/ModalAddingSubGoal/ModalAddingSubGoal.tsx b/components/shared/Modal/ModalAddingSubGoal/ModalAddingSubGoal.tsx index 12babab..468e4ee 100644 --- a/components/shared/Modal/ModalAddingSubGoal/ModalAddingSubGoal.tsx +++ b/components/shared/Modal/ModalAddingSubGoal/ModalAddingSubGoal.tsx @@ -78,7 +78,9 @@ const Body = ({ > setSubGoal(e.target.value)} + onChange={(e) => { + setSubGoal(e.target.value); + }} value={subGoal} /> diff --git a/components/shared/Modal/_compound/Modal.tsx b/components/shared/Modal/_compound/Modal.tsx index 3e402a6..43c14cb 100644 --- a/components/shared/Modal/_compound/Modal.tsx +++ b/components/shared/Modal/_compound/Modal.tsx @@ -23,7 +23,7 @@ const ModalContainer = ({ <>
{ +export interface ModalButtonProps extends Omit { + // extends ButtonHTMLAttributes { color: "primary" | "alternative" | "negative"; text: string; disabled?: boolean; diff --git a/components/shared/README.md b/components/shared/README.md index 81ee17b..935123a 100644 --- a/components/shared/README.md +++ b/components/shared/README.md @@ -1,6 +1,6 @@ -# [motimo=shared-ui] +# motimo-shared-ui -motimo-shared-ui은(는) Motimo의 UI를 구축하기 위한 통일된 디자인 언어와 재사용 가능한 컴포넌트 세트입니다. 일관된 사용자 경험을 제공하고 개발 효율성을 높이는 것을 목표로 합니다. +motimo-shared-ui는 Motimo의 UI를 구축하기 위한 통일된 디자인 언어와 재사용 가능한 컴포넌트 세트입니다. 일관된 사용자 경험을 제공하고 개발 효율성을 높이는 것을 목표로 합니다. [![npm version](https://badge.fury.io/js/motimo-shared-ui.svg)](https://www.npmjs.com/package/motimo-shared-ui) [![npm downloads](https://img.shields.io/npm/dm/motimo-shared-ui.svg)](https://www.npmjs.com/package/motimo-shared-ui) @@ -27,10 +27,10 @@ npm 또는 yarn을 사용하여 패키지를 설치할 수 있습니다. ```bash # npm 사용 시 -npm install [your-package-name] +npm install motimo-shared-ui # yarn 사용 시 -yarn add [your-package-name] +yarn add motimo-shared-ui ``` ### 사용 방법 (Usage) @@ -54,28 +54,20 @@ function App() { export default App; ``` -### 문서 (Documentation) - -자세한 컴포넌트 사용법, 디자인 원칙, 가이드라인 등을 담고 있는 **문서 링크**를 제공해야 합니다. Storybook, Docusaurus, Next.js 등의 도구를 활용하여 별도의 문서 사이트를 구축하는 것이 일반적입니다. - -```markdown ### 📚 전체 문서 각 컴포넌트의 상세한 사용법, props, 디자인 가이드라인 등은 [여기에서]([스토리북 배포 문서 사이트 링크]) 확인하실 수 있습니다. **주요 문서:** -- [시작하기]([문서 사이트 링크]/getting-started) -- [컴포넌트]([문서 사이트 링크]/components) -- [디자인 토큰]([문서 사이트 링크]/design-tokens) -``` +- [시작하기] https://10th-motimo-storybook.vercel.app/?path=/docs/introduction--docs +- [예시] https://10th-motimo-storybook.vercel.app/?path=/docs/components-test--docs ### 🙌 기여하기 이 디자인 시스템의 발전에 기여하고 싶으시다면 언제든지 환영합니다! 자세한 내용은 [기여 가이드라인](CONTRIBUTING.md)을 참조해 주세요. -- [이슈 보고](https://github.com/[your-github-username]/[your-repo-name]/issues/new) -- [풀 리퀘스트 제출](https://github.com/[your-github-username]/[your-repo-name]/pulls) +- [이슈 보고](https://github.com/prography/10th-Motimo-FE/issues/new) ### 📝 라이선스 diff --git a/components/shared/SegmentedControl/SegmentedControl.tsx b/components/shared/SegmentedControl/SegmentedControl.tsx index 870459b..6381b95 100644 --- a/components/shared/SegmentedControl/SegmentedControl.tsx +++ b/components/shared/SegmentedControl/SegmentedControl.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "../utils/utils"; interface SegmentedControlOption { label: string; @@ -19,10 +19,12 @@ export const SegmentedControl = ({ className, }: SegmentedControlProps) => { return ( -
+
{options.map((option) => ( ))}
); -}; \ No newline at end of file +}; diff --git a/components/shared/SnackBar/SnackBar.tsx b/components/shared/SnackBar/SnackBar.tsx index b077fb2..7a8d80f 100644 --- a/components/shared/SnackBar/SnackBar.tsx +++ b/components/shared/SnackBar/SnackBar.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "../utils/utils"; import { WarningIcon } from "../../icons/WarningIcon"; interface SnackBarProps { @@ -17,10 +17,12 @@ export const SnackBar = ({ className, }: SnackBarProps) => { return ( -
+
{showIcon && (
@@ -33,11 +35,11 @@ export const SnackBar = ({
{actionText && (
-
); -}; \ No newline at end of file +}; diff --git a/components/shared/Toast/Toast.tsx b/components/shared/Toast/Toast.tsx index a65856b..9e1075c 100644 --- a/components/shared/Toast/Toast.tsx +++ b/components/shared/Toast/Toast.tsx @@ -1,22 +1,21 @@ -import { cn } from "@/lib/utils"; +import { cn } from "../utils/utils"; interface ToastProps { text: string; className?: string; } -export const Toast = ({ - text, - className, -}: ToastProps) => { +export const Toast = ({ text, className }: ToastProps) => { return ( -
+
{text}
); -}; \ No newline at end of file +}; diff --git a/components/shared/TodoItem/TodoItem.tsx b/components/shared/TodoItem/TodoItem.tsx index 5c2091f..4c9c476 100644 --- a/components/shared/TodoItem/TodoItem.tsx +++ b/components/shared/TodoItem/TodoItem.tsx @@ -46,13 +46,13 @@ const TodoItem = ({ return ( <>