diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..38ff92bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +이 파일은 Claude Code(claude.ai/code)가 본 저장소의 코드를 다룰 때 참고하는 가이드입니다. + +## 프로젝트 개요 + +ZERO-ONE 스터디 플랫폼 — 매일 아침을 함께 시작하는 1:1 기상 스터디 플랫폼. Next.js 15 (App Router), React 19, TypeScript 5, Tailwind CSS 4 기반. 패키지 매니저는 **Yarn 1.22+**, Node.js >=20 필요. + +## 명령어 + +```bash +yarn dev # Turbopack 개발 서버 실행 +yarn build # 프로덕션 빌드 +yarn lint # ESLint 검사 +yarn lint:fix # ESLint 자동 수정 +yarn typecheck # TypeScript 타입 검사 (tsc --noEmit) +yarn prettier # Prettier 포맷 검사 +yarn prettier:fix # Prettier 자동 포맷팅 +yarn storybook # Storybook 개발 서버 (포트 6006) +yarn build-storybook # Storybook 빌드 +yarn generate:api <이름> # API 쿼리 훅 보일러플레이트 생성 (예: yarn generate:api bank-search-api) +``` + +CI 파이프라인: lint → typecheck → prettier → build → build-storybook → 보안 감사. + +## 아키텍처 + +### 라우팅 (Next.js App Router) + +- `src/app/(landing)/` — 공개 랜딩 페이지 (`/`) +- `src/app/(service)/` — 인증 필요 서비스 페이지 (home, my-page, payment, premium-study 등) +- `src/app/(admin)/` — 관리자 페이지 (JWT의 `ROLE_ADMIN` 클레임으로 권한 보호) +- `src/middleware.ts` — 인증 처리: accessToken 쿠키 검증, `/api/v1/auth/access-token/refresh`로 자동 갱신, `/admin/*` 경로 관리자 권한 확인 + +### API 레이어 + +두 가지 통신 패턴이 공존: + +1. **레거시 axios** (`src/api/client/axios.ts`): baseURL `/api/v1/`, 토큰 갱신 큐 구현 (AUTH001 에러 시 갱신 트리거). 커스텀 엔드포인트에 사용. +2. **OpenAPI 자동 생성** (`src/api/openapi/`): 백엔드 Swagger에서 자동 생성된 타입과 서비스. **`src/api/openapi/` 내 파일을 직접 수정 금지** — 재생성됨. ESLint에서 이 디렉토리 제외됨. + +새 API 훅 추가 방법: + +```bash +yarn generate:api +# src/hooks/queries/<이름>.ts 파일 생성 (createApiInstance 보일러플레이트 포함) +``` + +생성된 파일에서 API 인스턴스를 사용해 TanStack Query 훅을 작성. + +### 상태 관리 + +- **Zustand** (`src/stores/`): 전역 클라이언트 상태. `useUserStore` (유저 정보 persist), `useLeaderStore`. +- **TanStack Query** (`src/hooks/queries/`): 서버 상태. 도메인별 쿼리 훅 (study, payment, evaluation, peer-review, settlement 등). 기본 staleTime: 60초. +- **React Hook Form + Zod** (`src/types/schemas/`): 폼 상태 + 런타임 유효성 검증. + +### 컴포넌트 구성 + +- `src/components/ui/` — shadcn/ui 기본 컴포넌트 (Button, Input, Dialog 등). 스타일: `new-york`. `components.json`에서 설정. +- `src/components/layout/` — Header, Sidebar, Footer +- `src/components/modals/`, `cards/`, `lists/`, `payment/`, `premium/` — 도메인별 그룹화된 컴포넌트 +- `src/features/`, `src/entities/`, `src/widgets/` — 부분적 FSD (Feature-Sliced Design) 구조, 향후 타입 기반 구조로 통합 예정 + +### 스타일링 + +- Tailwind CSS 4 + `@tailwindcss/postcss` 플러그인 +- 클래스 유틸리티: `clsx`, `tailwind-merge`, `class-variance-authority` (CVA) +- `prettier-plugin-tailwindcss`로 Tailwind 클래스 정렬 +- `src/app/global.css`에서 CSS 변수로 테마 관리 + +### 인증 플로우 + +1. OAuth 로그인 (카카오/구글) → 서버에서 JWT access + refresh 토큰 발급 +2. `accessToken`은 쿠키에 저장 (JS 접근 가능), `refresh_token`은 httpOnly 쿠키에 저장 +3. Axios 인터셉터가 `AUTH001` 에러 감지 → 토큰 갱신 → 실패한 요청 재시도 (중복 갱신 방지를 위한 큐 사용) +4. 미들웨어가 서버 측에서 네비게이션 시 토큰 검증, 유효하지 않으면 `/`로 리다이렉트 + +### 경로 별칭 + +`@/*`는 `./src/*`에 매핑됨 (tsconfig.json에서 설정) + +## 주요 컨벤션 + +- **커밋 메시지**: `feat:`, `fix:`, `refactor:`, `style:`, `docs:`, `test:`, `chore:` +- **브랜치 전략**: Feature 브랜치 → `develop` (스테이징: test.zeroone.it.kr) → `main` (프로덕션: www.zeroone.it.kr) +- **ESLint 설정**: RushStack 기반, strict TypeScript, React hooks, TanStack Query 플러그인, 임포트 정렬 (알파벳 + 그룹별) +- **Prettier**: 80자 너비, 작은따옴표, trailing comma, 2칸 들여쓰기 +- **SVG 처리**: `@svgr/webpack`이 next.config.ts에 설정되어 SVG를 React 컴포넌트로 임포트 가능 + +## 환경 변수 + +개발에 필요한 주요 `NEXT_PUBLIC_*` 변수: + +- `NEXT_PUBLIC_API_BASE_URL` — 백엔드 API 엔드포인트 +- `NEXT_PUBLIC_KAKAO_CLIENT_ID` — 카카오 OAuth +- `NEXT_PUBLIC_GOOGLE_CLIENT_ID` — 구글 OAuth +- `NEXT_PUBLIC_TOSS_CLIENT_KEY` — 토스페이먼츠 +- `NEXT_PUBLIC_CLARITY_PROJECT_ID` — Microsoft Clarity +- `NEXT_PUBLIC_GTM_ID` — Google Tag Manager diff --git a/package.json b/package.json index c6c9358f..234db76c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.18", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.27.1", "googleapis": "^164.1.0", "lucide-react": "^0.475.0", "next": "15.2.8", @@ -106,8 +107,7 @@ "typescript-eslint": "^8.24.0", "vitest": "^3.1.1" }, - "resolutions": { + "resolutions": { "strip-ansi": "6.0.1" } - } diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..f2c27928 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "ZERO-ONE - 개발자 스터디 플랫폼", + "short_name": "ZERO-ONE", + "description": "1:1, 그룹 스터디, 멘토링 등 다양한 방식의 학습을 지원하는 개발자 전문 스터디 플랫폼", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "/icons/logo.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} + diff --git a/src/api/openapi/.openapi-generator/FILES b/src/api/openapi/.openapi-generator/FILES index 8ed16123..7d3f9f80 100644 --- a/src/api/openapi/.openapi-generator/FILES +++ b/src/api/openapi/.openapi-generator/FILES @@ -194,7 +194,7 @@ docs/MatchingSystemStatusResponse.md docs/MatchingSystemStatusSchema.md docs/MemberAccountHistoryResponseDto.md docs/MemberApi.md -docs/MemberCreationRequest.md +docs/MemberCreationRequestDto.md docs/MemberCreationResponse.md docs/MemberCreationResponseContent.md docs/MemberFeature.md @@ -480,7 +480,7 @@ models/matching-request-response.ts models/matching-system-status-response.ts models/matching-system-status-schema.ts models/member-account-history-response-dto.ts -models/member-creation-request.ts +models/member-creation-request-dto.ts models/member-creation-response-content.ts models/member-creation-response.ts models/member-feature.ts diff --git a/src/api/openapi/api/member-api.ts b/src/api/openapi/api/member-api.ts index b91c9e9b..49adfdd2 100644 --- a/src/api/openapi/api/member-api.ts +++ b/src/api/openapi/api/member-api.ts @@ -24,7 +24,7 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore -import type { MemberCreationRequest } from '../models'; +import type { MemberCreationRequestDto } from '../models'; // @ts-ignore import type { MemberCreationResponse } from '../models'; // @ts-ignore @@ -203,13 +203,13 @@ export const MemberApiAxiosParamCreator = function (configuration?: Configuratio /** * 회원가입을 진행하는 엔드포인트 - nickname: 필수, 2~20자 한글/영문/숫자, 특수문자 불가 (중복 체크 필수) - loginId: 선택(소셜 로그인 시 비움), 일반 로그인용 식별자 - jobs: 선택, Enum 리스트 (최대 5개) 값 = [IT_NOBASE_BUSINESS_STARTUP, IT_NOBASE_AUTOMATION, IT_NOBASE_MY_SERVICE, IT_PRACTITIONER_PM_PO_PLANNING, IT_PRACTITIONER_FRONTEND, IT_PRACTITIONER_BACKEND, IT_PRACTITIONER_AI_ML, IT_PRACTITIONER_IOS, IT_PRACTITIONER_ANDROID, IT_PRACTITIONER_DEVOPS, IT_PRACTITIONER_DATA_ANALYSIS, IT_PRACTITIONER_QA, IT_PRACTITIONER_GAME_DEV, IT_PRACTITIONER_DESIGN, IT_PRACTITIONER_MARKETING, IT_PRACTITIONER_ETC] - career: 선택, Enum 값 = [BEGINNER, JOB_SEEKER, JUNIOR, MIDDLE, SENIOR] - studyFormatTypes: 선택, Enum 값 = [PROJECT, MENTORING, SEMINAR, CHALLENGE, BOOK_LECTURE] - goal: 선택, 자유 텍스트 입력(최대 100자) - imageExtension: 선택, Enum 값 = [DEFAULT, JPG, PNG, GIF, WEBP, SVG, JPEG] * @summary [회원가입/로그인 팝업] 회원가입 - * @param {MemberCreationRequest} memberCreationRequest + * @param {MemberCreationRequestDto} memberCreationRequestDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - signUp: async (memberCreationRequest: MemberCreationRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'memberCreationRequest' is not null or undefined - assertParamExists('signUp', 'memberCreationRequest', memberCreationRequest) + signUp: async (memberCreationRequestDto: MemberCreationRequestDto, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'memberCreationRequestDto' is not null or undefined + assertParamExists('signUp', 'memberCreationRequestDto', memberCreationRequestDto) const localVarPath = `/api/v1/members`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -233,7 +233,7 @@ export const MemberApiAxiosParamCreator = function (configuration?: Configuratio setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(memberCreationRequest, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(memberCreationRequestDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -300,12 +300,12 @@ export const MemberApiFp = function(configuration?: Configuration) { /** * 회원가입을 진행하는 엔드포인트 - nickname: 필수, 2~20자 한글/영문/숫자, 특수문자 불가 (중복 체크 필수) - loginId: 선택(소셜 로그인 시 비움), 일반 로그인용 식별자 - jobs: 선택, Enum 리스트 (최대 5개) 값 = [IT_NOBASE_BUSINESS_STARTUP, IT_NOBASE_AUTOMATION, IT_NOBASE_MY_SERVICE, IT_PRACTITIONER_PM_PO_PLANNING, IT_PRACTITIONER_FRONTEND, IT_PRACTITIONER_BACKEND, IT_PRACTITIONER_AI_ML, IT_PRACTITIONER_IOS, IT_PRACTITIONER_ANDROID, IT_PRACTITIONER_DEVOPS, IT_PRACTITIONER_DATA_ANALYSIS, IT_PRACTITIONER_QA, IT_PRACTITIONER_GAME_DEV, IT_PRACTITIONER_DESIGN, IT_PRACTITIONER_MARKETING, IT_PRACTITIONER_ETC] - career: 선택, Enum 값 = [BEGINNER, JOB_SEEKER, JUNIOR, MIDDLE, SENIOR] - studyFormatTypes: 선택, Enum 값 = [PROJECT, MENTORING, SEMINAR, CHALLENGE, BOOK_LECTURE] - goal: 선택, 자유 텍스트 입력(최대 100자) - imageExtension: 선택, Enum 값 = [DEFAULT, JPG, PNG, GIF, WEBP, SVG, JPEG] * @summary [회원가입/로그인 팝업] 회원가입 - * @param {MemberCreationRequest} memberCreationRequest + * @param {MemberCreationRequestDto} memberCreationRequestDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async signUp(memberCreationRequest: MemberCreationRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.signUp(memberCreationRequest, options); + async signUp(memberCreationRequestDto: MemberCreationRequestDto, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.signUp(memberCreationRequestDto, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['MemberApi.signUp']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -361,12 +361,12 @@ export const MemberApiFactory = function (configuration?: Configuration, basePat /** * 회원가입을 진행하는 엔드포인트 - nickname: 필수, 2~20자 한글/영문/숫자, 특수문자 불가 (중복 체크 필수) - loginId: 선택(소셜 로그인 시 비움), 일반 로그인용 식별자 - jobs: 선택, Enum 리스트 (최대 5개) 값 = [IT_NOBASE_BUSINESS_STARTUP, IT_NOBASE_AUTOMATION, IT_NOBASE_MY_SERVICE, IT_PRACTITIONER_PM_PO_PLANNING, IT_PRACTITIONER_FRONTEND, IT_PRACTITIONER_BACKEND, IT_PRACTITIONER_AI_ML, IT_PRACTITIONER_IOS, IT_PRACTITIONER_ANDROID, IT_PRACTITIONER_DEVOPS, IT_PRACTITIONER_DATA_ANALYSIS, IT_PRACTITIONER_QA, IT_PRACTITIONER_GAME_DEV, IT_PRACTITIONER_DESIGN, IT_PRACTITIONER_MARKETING, IT_PRACTITIONER_ETC] - career: 선택, Enum 값 = [BEGINNER, JOB_SEEKER, JUNIOR, MIDDLE, SENIOR] - studyFormatTypes: 선택, Enum 값 = [PROJECT, MENTORING, SEMINAR, CHALLENGE, BOOK_LECTURE] - goal: 선택, 자유 텍스트 입력(최대 100자) - imageExtension: 선택, Enum 값 = [DEFAULT, JPG, PNG, GIF, WEBP, SVG, JPEG] * @summary [회원가입/로그인 팝업] 회원가입 - * @param {MemberCreationRequest} memberCreationRequest + * @param {MemberCreationRequestDto} memberCreationRequestDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - signUp(memberCreationRequest: MemberCreationRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.signUp(memberCreationRequest, options).then((request) => request(axios, basePath)); + signUp(memberCreationRequestDto: MemberCreationRequestDto, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.signUp(memberCreationRequestDto, options).then((request) => request(axios, basePath)); }, }; }; @@ -420,12 +420,12 @@ export class MemberApi extends BaseAPI { /** * 회원가입을 진행하는 엔드포인트 - nickname: 필수, 2~20자 한글/영문/숫자, 특수문자 불가 (중복 체크 필수) - loginId: 선택(소셜 로그인 시 비움), 일반 로그인용 식별자 - jobs: 선택, Enum 리스트 (최대 5개) 값 = [IT_NOBASE_BUSINESS_STARTUP, IT_NOBASE_AUTOMATION, IT_NOBASE_MY_SERVICE, IT_PRACTITIONER_PM_PO_PLANNING, IT_PRACTITIONER_FRONTEND, IT_PRACTITIONER_BACKEND, IT_PRACTITIONER_AI_ML, IT_PRACTITIONER_IOS, IT_PRACTITIONER_ANDROID, IT_PRACTITIONER_DEVOPS, IT_PRACTITIONER_DATA_ANALYSIS, IT_PRACTITIONER_QA, IT_PRACTITIONER_GAME_DEV, IT_PRACTITIONER_DESIGN, IT_PRACTITIONER_MARKETING, IT_PRACTITIONER_ETC] - career: 선택, Enum 값 = [BEGINNER, JOB_SEEKER, JUNIOR, MIDDLE, SENIOR] - studyFormatTypes: 선택, Enum 값 = [PROJECT, MENTORING, SEMINAR, CHALLENGE, BOOK_LECTURE] - goal: 선택, 자유 텍스트 입력(최대 100자) - imageExtension: 선택, Enum 값 = [DEFAULT, JPG, PNG, GIF, WEBP, SVG, JPEG] * @summary [회원가입/로그인 팝업] 회원가입 - * @param {MemberCreationRequest} memberCreationRequest + * @param {MemberCreationRequestDto} memberCreationRequestDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public signUp(memberCreationRequest: MemberCreationRequest, options?: RawAxiosRequestConfig) { - return MemberApiFp(this.configuration).signUp(memberCreationRequest, options).then((request) => request(this.axios, this.basePath)); + public signUp(memberCreationRequestDto: MemberCreationRequestDto, options?: RawAxiosRequestConfig) { + return MemberApiFp(this.configuration).signUp(memberCreationRequestDto, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/src/api/openapi/docs/MemberApi.md b/src/api/openapi/docs/MemberApi.md index 8aaff78d..9bd2e5bb 100644 --- a/src/api/openapi/docs/MemberApi.md +++ b/src/api/openapi/docs/MemberApi.md @@ -194,7 +194,7 @@ const { status, data } = await apiInstance.getParticipatingStudies( [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **signUp** -> MemberCreationResponse signUp(memberCreationRequest) +> MemberCreationResponse signUp(memberCreationRequestDto) 회원가입을 진행하는 엔드포인트 - nickname: 필수, 2~20자 한글/영문/숫자, 특수문자 불가 (중복 체크 필수) - loginId: 선택(소셜 로그인 시 비움), 일반 로그인용 식별자 - jobs: 선택, Enum 리스트 (최대 5개) 값 = [IT_NOBASE_BUSINESS_STARTUP, IT_NOBASE_AUTOMATION, IT_NOBASE_MY_SERVICE, IT_PRACTITIONER_PM_PO_PLANNING, IT_PRACTITIONER_FRONTEND, IT_PRACTITIONER_BACKEND, IT_PRACTITIONER_AI_ML, IT_PRACTITIONER_IOS, IT_PRACTITIONER_ANDROID, IT_PRACTITIONER_DEVOPS, IT_PRACTITIONER_DATA_ANALYSIS, IT_PRACTITIONER_QA, IT_PRACTITIONER_GAME_DEV, IT_PRACTITIONER_DESIGN, IT_PRACTITIONER_MARKETING, IT_PRACTITIONER_ETC] - career: 선택, Enum 값 = [BEGINNER, JOB_SEEKER, JUNIOR, MIDDLE, SENIOR] - studyFormatTypes: 선택, Enum 값 = [PROJECT, MENTORING, SEMINAR, CHALLENGE, BOOK_LECTURE] - goal: 선택, 자유 텍스트 입력(최대 100자) - imageExtension: 선택, Enum 값 = [DEFAULT, JPG, PNG, GIF, WEBP, SVG, JPEG] @@ -204,16 +204,16 @@ const { status, data } = await apiInstance.getParticipatingStudies( import { MemberApi, Configuration, - MemberCreationRequest + MemberCreationRequestDto } from './api'; const configuration = new Configuration(); const apiInstance = new MemberApi(configuration); -let memberCreationRequest: MemberCreationRequest; // +let memberCreationRequestDto: MemberCreationRequestDto; // const { status, data } = await apiInstance.signUp( - memberCreationRequest + memberCreationRequestDto ); ``` @@ -221,7 +221,7 @@ const { status, data } = await apiInstance.signUp( |Name | Type | Description | Notes| |------------- | ------------- | ------------- | -------------| -| **memberCreationRequest** | **MemberCreationRequest**| | | +| **memberCreationRequestDto** | **MemberCreationRequestDto**| | | ### Return type diff --git a/src/api/openapi/docs/MemberCreationRequest.md b/src/api/openapi/docs/MemberCreationRequestDto.md similarity index 92% rename from src/api/openapi/docs/MemberCreationRequest.md rename to src/api/openapi/docs/MemberCreationRequestDto.md index b73f99a0..68b4256e 100644 --- a/src/api/openapi/docs/MemberCreationRequest.md +++ b/src/api/openapi/docs/MemberCreationRequestDto.md @@ -1,4 +1,4 @@ -# MemberCreationRequest +# MemberCreationRequestDto ## Properties @@ -17,9 +17,9 @@ Name | Type | Description | Notes ## Example ```typescript -import { MemberCreationRequest } from './api'; +import { MemberCreationRequestDto } from './api'; -const instance: MemberCreationRequest = { +const instance: MemberCreationRequestDto = { loginId, nickname, name, diff --git a/src/api/openapi/docs/TossWebhookPayload.md b/src/api/openapi/docs/TossWebhookPayload.md index b653e662..29e719d6 100644 --- a/src/api/openapi/docs/TossWebhookPayload.md +++ b/src/api/openapi/docs/TossWebhookPayload.md @@ -8,8 +8,10 @@ Name | Type | Description | Notes **eventType** | **string** | | [optional] [default to undefined] **createdAt** | **string** | | [optional] [default to undefined] **data** | [**PaymentData**](PaymentData.md) | | [optional] [default to undefined] -**paymentStatusChanged** | **boolean** | | [optional] [default to undefined] +**paymentRelatedEvent** | **boolean** | | [optional] [default to undefined] **latestCancel** | [**CancelData**](CancelData.md) | | [optional] [default to undefined] +**paymentStatusChanged** | **boolean** | | [optional] [default to undefined] +**depositCallback** | **boolean** | | [optional] [default to undefined] ## Example @@ -20,8 +22,10 @@ const instance: TossWebhookPayload = { eventType, createdAt, data, - paymentStatusChanged, + paymentRelatedEvent, latestCancel, + paymentStatusChanged, + depositCallback, }; ``` diff --git a/src/api/openapi/models/index.ts b/src/api/openapi/models/index.ts index 4f090dde..02c29077 100644 --- a/src/api/openapi/models/index.ts +++ b/src/api/openapi/models/index.ts @@ -129,7 +129,7 @@ export * from './matching-request-response'; export * from './matching-system-status-response'; export * from './matching-system-status-schema'; export * from './member-account-history-response-dto'; -export * from './member-creation-request'; +export * from './member-creation-request-dto'; export * from './member-creation-response'; export * from './member-creation-response-content'; export * from './member-feature'; diff --git a/src/api/openapi/models/member-creation-request.ts b/src/api/openapi/models/member-creation-request-dto.ts similarity index 67% rename from src/api/openapi/models/member-creation-request.ts rename to src/api/openapi/models/member-creation-request-dto.ts index d2200851..a39d289e 100644 --- a/src/api/openapi/models/member-creation-request.ts +++ b/src/api/openapi/models/member-creation-request-dto.ts @@ -14,7 +14,7 @@ -export interface MemberCreationRequest { +export interface MemberCreationRequestDto { /** * 회원의 로그인 아이디(소셜 로그인 시 비어있음) */ @@ -30,15 +30,15 @@ export interface MemberCreationRequest { /** * 직무 리스트 (최대 5개) (예: [IT_PRACTITIONER_BACKEND, IT_PRACTITIONER_FRONTEND]) */ - 'jobs'?: Array; + 'jobs'?: Array; /** * 경력 (예: JUNIOR) */ - 'career'?: MemberCreationRequestCareerEnum; + 'career'?: MemberCreationRequestDtoCareerEnum; /** * 관심 스터디 유형 리스트 (예: [\"PROJECT\", \"SEMINAR\"]) */ - 'studyFormatTypes'?: Array; + 'studyFormatTypes'?: Array; /** * 목표 및 다짐 (최대 100자) */ @@ -46,10 +46,10 @@ export interface MemberCreationRequest { /** * 이미지 확장자 - DEFAULT, JPG, PNG, GIF, WEBP, SVG, JPEG */ - 'imageExtension'?: MemberCreationRequestImageExtensionEnum; + 'imageExtension'?: MemberCreationRequestDtoImageExtensionEnum; } -export const MemberCreationRequestJobsEnum = { +export const MemberCreationRequestDtoJobsEnum = { ItNobaseBusinessStartup: 'IT_NOBASE_BUSINESS_STARTUP', ItNobaseAutomation: 'IT_NOBASE_AUTOMATION', ItNobaseMyService: 'IT_NOBASE_MY_SERVICE', @@ -68,8 +68,8 @@ export const MemberCreationRequestJobsEnum = { ItPractitionerEtc: 'IT_PRACTITIONER_ETC' } as const; -export type MemberCreationRequestJobsEnum = typeof MemberCreationRequestJobsEnum[keyof typeof MemberCreationRequestJobsEnum]; -export const MemberCreationRequestCareerEnum = { +export type MemberCreationRequestDtoJobsEnum = typeof MemberCreationRequestDtoJobsEnum[keyof typeof MemberCreationRequestDtoJobsEnum]; +export const MemberCreationRequestDtoCareerEnum = { Beginner: 'BEGINNER', JobSeeker: 'JOB_SEEKER', Junior: 'JUNIOR', @@ -77,8 +77,8 @@ export const MemberCreationRequestCareerEnum = { Senior: 'SENIOR' } as const; -export type MemberCreationRequestCareerEnum = typeof MemberCreationRequestCareerEnum[keyof typeof MemberCreationRequestCareerEnum]; -export const MemberCreationRequestStudyFormatTypesEnum = { +export type MemberCreationRequestDtoCareerEnum = typeof MemberCreationRequestDtoCareerEnum[keyof typeof MemberCreationRequestDtoCareerEnum]; +export const MemberCreationRequestDtoStudyFormatTypesEnum = { Project: 'PROJECT', Mentoring: 'MENTORING', Seminar: 'SEMINAR', @@ -86,8 +86,8 @@ export const MemberCreationRequestStudyFormatTypesEnum = { BookLecture: 'BOOK_LECTURE' } as const; -export type MemberCreationRequestStudyFormatTypesEnum = typeof MemberCreationRequestStudyFormatTypesEnum[keyof typeof MemberCreationRequestStudyFormatTypesEnum]; -export const MemberCreationRequestImageExtensionEnum = { +export type MemberCreationRequestDtoStudyFormatTypesEnum = typeof MemberCreationRequestDtoStudyFormatTypesEnum[keyof typeof MemberCreationRequestDtoStudyFormatTypesEnum]; +export const MemberCreationRequestDtoImageExtensionEnum = { Default: 'DEFAULT', Jpg: 'JPG', Png: 'PNG', @@ -97,6 +97,6 @@ export const MemberCreationRequestImageExtensionEnum = { Jpeg: 'JPEG' } as const; -export type MemberCreationRequestImageExtensionEnum = typeof MemberCreationRequestImageExtensionEnum[keyof typeof MemberCreationRequestImageExtensionEnum]; +export type MemberCreationRequestDtoImageExtensionEnum = typeof MemberCreationRequestDtoImageExtensionEnum[keyof typeof MemberCreationRequestDtoImageExtensionEnum]; diff --git a/src/api/openapi/models/toss-webhook-payload.ts b/src/api/openapi/models/toss-webhook-payload.ts index bd3385c4..c6c9f33a 100644 --- a/src/api/openapi/models/toss-webhook-payload.ts +++ b/src/api/openapi/models/toss-webhook-payload.ts @@ -24,7 +24,9 @@ export interface TossWebhookPayload { 'eventType'?: string; 'createdAt'?: string; 'data'?: PaymentData; - 'paymentStatusChanged'?: boolean; + 'paymentRelatedEvent'?: boolean; 'latestCancel'?: CancelData; + 'paymentStatusChanged'?: boolean; + 'depositCallback'?: boolean; } diff --git a/src/app/(service)/(my)/notification/page.tsx b/src/app/(service)/(my)/notification/page.tsx index ffa3e4a7..aa396981 100644 --- a/src/app/(service)/(my)/notification/page.tsx +++ b/src/app/(service)/(my)/notification/page.tsx @@ -86,7 +86,7 @@ export default function NotificationPage() { className="font-designer-13r h-[32px]" onClick={handleMarkAllAsRead} > - 모든 읽음 처리 + 모두 읽음 처리
diff --git a/src/app/(service)/home/home-content.tsx b/src/app/(service)/home/home-content.tsx new file mode 100644 index 00000000..b20f2849 --- /dev/null +++ b/src/app/(service)/home/home-content.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; +import TabNavigation from '@/components/home/tab-navigation'; +import ArchiveTab from '@/features/study/one-to-one/archive/ui/archive-tab'; +import CommunityTab from '@/features/study/one-to-one/balance-game/ui/community-tab'; +import HallOfFameTab from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab'; +import StudyHistoryTab from '@/features/study/one-to-one/history/ui/study-history-tab'; +import StudyTab from '@/features/study/one-to-one/schedule/ui/home-study-tab'; + +interface HomeContentProps { + activeTab: string; +} + +export default function HomeContent({ activeTab }: HomeContentProps) { + const renderTabContent = () => { + switch (activeTab) { + case 'study': + return ; + case 'history': + return ; + case 'ranking': + return ; + case 'archive': + return ; + case 'community': + return ; + default: + return ; + } + }; + + return ( + <> + + + 로딩 중... +
+ } + > + {renderTabContent()} + + + ); +} diff --git a/src/app/(service)/home/page.tsx b/src/app/(service)/home/page.tsx index 0e2b8815..b54d5980 100644 --- a/src/app/(service)/home/page.tsx +++ b/src/app/(service)/home/page.tsx @@ -1,8 +1,9 @@ import { Metadata } from 'next'; -import StudyCard from '@/features/study/schedule/ui/study-card'; +import StartStudyButton from '@/components/home/start-study-button'; import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; import Banner from '@/widgets/home/banner'; -import Sidebar from '@/widgets/home/sidebar'; +import FeedbackLink from '@/widgets/home/feedback-link'; +import HomeContent from './home-content'; export const metadata: Metadata = generateSEOMetadata({ title: '홈 - ZERO-ONE', @@ -13,17 +14,20 @@ export const metadata: Metadata = generateSEOMetadata({ canonicalUrl: 'https://www.zeroone.it.kr/home', }); -export default async function Home() { - return ( -
-
- - -
+export default async function Home({ + searchParams, +}: { + searchParams?: Promise<{ tab?: string }>; +}) { + const resolvedSearchParams = await searchParams; + const activeTab = resolvedSearchParams?.tab || 'study'; - + return ( +
+ + + +
); } diff --git a/src/app/(service)/insights/page.tsx b/src/app/(service)/insights/page.tsx index c725e109..30799732 100644 --- a/src/app/(service)/insights/page.tsx +++ b/src/app/(service)/insights/page.tsx @@ -6,10 +6,8 @@ import { fetchArticles, fetchCategories, } from '@/api/strapi/api/fetch-articles'; -import PageContainer from '@/components/layout/page-container'; import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; -import { getServerCookie } from '@/utils/server-cookie'; -import Sidebar from '@/widgets/home/sidebar'; +import Banner from '@/widgets/home/banner'; export const revalidate = 60; @@ -44,9 +42,6 @@ interface BlogPageProps { } export default async function BlogPage({ searchParams }: BlogPageProps) { - const memberIdStr = await getServerCookie('memberId'); - const isLoggedIn = !!memberIdStr; - const { category: selectedCategorySlug } = await searchParams; // 카테고리 목록과 아티클 목록을 병렬로 가져오기 @@ -59,8 +54,13 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { const articles = articlesRes.data ?? []; return ( - +
+ {/* 배너 */} +
+ +
+
ZERO-ONE 인사이트 @@ -92,6 +92,17 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { {category.name} ))} + + + 위클리 + + NEW + + +
{/* 아티클 목록 */} @@ -144,7 +155,6 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { )}
- {isLoggedIn && } - +
); } diff --git a/src/app/(service)/insights/weekly/[id]/page.tsx b/src/app/(service)/insights/weekly/[id]/page.tsx new file mode 100644 index 00000000..24c45459 --- /dev/null +++ b/src/app/(service)/insights/weekly/[id]/page.tsx @@ -0,0 +1,12 @@ +import VotingDetailPageClient from '@/features/study/one-to-one/balance-game/ui/voting-detail-page-client'; + +export default async function VotingDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const votingId = Number(id); + + return ; +} diff --git a/src/app/(service)/insights/weekly/page.tsx b/src/app/(service)/insights/weekly/page.tsx new file mode 100644 index 00000000..0b380169 --- /dev/null +++ b/src/app/(service)/insights/weekly/page.tsx @@ -0,0 +1,5 @@ +import BalanceGamePage from '@/features/study/one-to-one/balance-game/ui/balance-game-page'; + +export default function VotingPage() { + return ; +} diff --git a/src/app/(service)/layout.tsx b/src/app/(service)/layout.tsx index 8954c45e..41b1a551 100644 --- a/src/app/(service)/layout.tsx +++ b/src/app/(service)/layout.tsx @@ -42,9 +42,9 @@ export default function ServiceLayout({ -
+
-
{children}
+
{children}
diff --git a/src/app/(service)/mentoring/page.tsx b/src/app/(service)/mentoring/page.tsx new file mode 100644 index 00000000..0f665086 --- /dev/null +++ b/src/app/(service)/mentoring/page.tsx @@ -0,0 +1,31 @@ +import { Metadata } from 'next'; +import { Suspense } from 'react'; +import MentoringListPage from '@/components/pages/mentoring-list-page'; +import { generateMetadata as generateSEOMetadata } from '@/utils/seo'; + +export const metadata: Metadata = generateSEOMetadata({ + title: '1:1 멘토링 - ZERO-ONE', + description: + '전문 멘토와 1:1로 만나 맞춤형 상담과 지식을 얻어보세요. 텍스트 답변, 온라인 상담, 대면 컨설팅 등 다양한 방식으로 멘토링을 받을 수 있습니다.', + path: '/mentoring', + keywords: ['1:1 멘토링', '멘토링', '상담', '컨설팅', '전문가 상담'], + canonicalUrl: 'https://www.zeroone.it.kr/mentoring', +}); + +export default function MentoringPage() { + return ( + }> + + + ); +} + +function MentoringListPageSkeleton() { + return ( +
+
+ 로딩 중... +
+
+ ); +} diff --git a/src/app/(service)/one-on-one/page.tsx b/src/app/(service)/one-on-one/page.tsx new file mode 100644 index 00000000..8facbe95 --- /dev/null +++ b/src/app/(service)/one-on-one/page.tsx @@ -0,0 +1,5 @@ +import OneOnOnePage from '@/features/study/one-to-one/ui/one-on-one-page'; + +export default function OneOnOnePageRoute() { + return ; +} diff --git a/src/components/card/discussion-card.tsx b/src/components/card/discussion-card.tsx new file mode 100644 index 00000000..b16283b1 --- /dev/null +++ b/src/components/card/discussion-card.tsx @@ -0,0 +1,135 @@ +import { formatDistanceToNow } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { MessageCircle, ThumbsUp, ThumbsDown, Eye, Clock } from 'lucide-react'; +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { TOPIC_LABELS } from '@/mocks/discussion-mock-data'; +import { Discussion } from '@/types/discussion'; + +interface DiscussionCardProps { + discussion: Discussion; + onClick?: () => void; +} + +export default function DiscussionCard({ + discussion, + onClick, +}: DiscussionCardProps) { + const timeAgo = formatDistanceToNow(new Date(discussion.lastActivityAt), { + addSuffix: true, + locale: ko, + }); + + return ( +
+ {/* 헤더: 작성자 정보 & 주제 */} +
+
+ {/* 아바타 & 닉네임 */} +
e.stopPropagation()}> + +
+ +
+ + {discussion.author.nickname} + +
+ } + /> +
+ + {/* 시간 */} +
+ +
+ + {timeAgo} +
+
+
+ + {/* 주제 배지 */} +
+ {TOPIC_LABELS[discussion.topic]} +
+
+ + {/* 제목 */} +

+ {discussion.title} +

+ + {/* 요약 */} +

+ {discussion.summary} +

+ + {/* 태그 */} + {discussion.tags.length > 0 && ( +
+ {discussion.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* 하단 메타 정보 */} +
+
+ {/* 찬성 */} +
+ + {discussion.vote.agreeCount} +
+ + {/* 반대 */} +
+ + {discussion.vote.disagreeCount} +
+ + {/* 댓글 */} +
+ + {discussion.commentCount} +
+ + {/* 조회수 */} +
+ + {discussion.viewCount.toLocaleString()} +
+
+
+
+ ); +} diff --git a/src/components/card/mentor-card.tsx b/src/components/card/mentor-card.tsx new file mode 100644 index 00000000..7c8d8dc3 --- /dev/null +++ b/src/components/card/mentor-card.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { sendGTMEvent } from '@next/third-parties/google'; +import { + ExternalLink, + Sparkles, + XIcon, + MessageCircle, + Phone, + Users, +} from 'lucide-react'; +import Image from 'next/image'; +import { useState } from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import UserAvatar from '@/components/ui/avatar'; +import Badge from '@/components/ui/badge'; +import Button from '@/components/ui/button'; +import { Modal } from '@/components/ui/modal'; + +interface Mentor { + id: number; + // name: string; + nickname: string; + imageUrl?: string; + field: string; + keywords: string[]; + description: string; + notionUrl: string; + availableMethods: { + chat: boolean; // 채팅상담 + call: boolean; // 전화/온라인 상담 + offline: boolean; // 대면 컨설팅 + }; +} + +interface MentorCardProps { + mentor: Mentor; +} + +export default function MentorCard({ mentor }: MentorCardProps) { + const [isComingSoonModalOpen, setIsComingSoonModalOpen] = useState(false); + + const handleProfileClick = () => { + // GA4 이벤트 전송 (멘토 프로필 클릭) + sendGTMEvent({ + event: 'mentor_profile_click', + mentor_id: mentor.id, + mentor_nickname: mentor.nickname, + mentor_field: mentor.field, + location: 'mentoring_page', + }); + + if (mentor.notionUrl) { + window.open(mentor.notionUrl, '_blank', 'noopener,noreferrer'); + } + }; + + const handleApplyClick = (e: React.MouseEvent) => { + e.stopPropagation(); + + // GA4 이벤트 전송 + sendGTMEvent({ + event: 'mentoring_help_request_click', + mentor_id: mentor.id, + mentor_nickname: mentor.nickname, + mentor_field: mentor.field, + location: 'mentoring_page', + }); + + setIsComingSoonModalOpen(true); + }; + + return ( +
{ + // 모달이 열려 있으면 카드 클릭 무시 + if (!isComingSoonModalOpen) { + handleProfileClick(); + } + }} + > + {/* 프로필 이미지 영역 */} +
+ {mentor.imageUrl ? ( + {mentor.nickname} + ) : ( +
+ +
+ )} +
+ + {/* 컨텐츠 영역 */} +
+ {/* 뱃지 */} +
+ {mentor.field} +
+ + {/* 제목 */} +
+

+ {mentor.nickname} +

+ +
+ + {/* 설명 */} +

+ {mentor.description} +

+ + {/* 키워드 */} + {mentor.keywords.length > 0 && ( +
+ {mentor.keywords.map((keyword) => ( + + {keyword} + + ))} +
+ )} + + {/* 멘토링 방식 */} +
+
+ + + 채팅상담 + +
+
+ + + 전화/온라인 상담 + +
+
+ + + 대면 컨설팅 + +
+
+ + {/* 멘토링 문의하기 버튼 */} + +
+ + {/* 곧 오픈 예정 모달 */} + + + + e.stopPropagation()}> + + + 멘토링 서비스 + + + + + + + +
+ +
+ +
+

+ 곧 오픈 예정입니다! +

+

+ 1:1 멘토링 서비스를 준비하고 있어요. +
+ 조금만 기다려주시면 멘토와 함께 +
+ 성장할 수 있는 기회를 제공해드릴게요. +

+

+ 곧 만나요! 🚀 +

+
+
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/card/my-homework-status-card.tsx b/src/components/card/my-homework-status-card.tsx index edbd1477..c2b7d4bd 100644 --- a/src/components/card/my-homework-status-card.tsx +++ b/src/components/card/my-homework-status-card.tsx @@ -9,6 +9,7 @@ interface MyHomeworkStatusProps { myHomework?: HomeworkDetailResponseDto; isMissionClosed?: boolean; onSelectHomework: (homeworkId: number) => void; + onRefetch?: () => void; } export default function MyHomeworkStatusCard({ @@ -16,6 +17,7 @@ export default function MyHomeworkStatusCard({ myHomework, isMissionClosed = false, onSelectHomework, + onRefetch, }: MyHomeworkStatusProps) { // 미제출 상태 if (!myHomework || myHomework.homeworkStatus === 'NOT_SUBMITTED') { @@ -28,7 +30,7 @@ export default function MyHomeworkStatusCard({ 아직 과제를 제출하지 않았습니다. - +
); diff --git a/src/components/card/voting-card.tsx b/src/components/card/voting-card.tsx new file mode 100644 index 00000000..81b2b836 --- /dev/null +++ b/src/components/card/voting-card.tsx @@ -0,0 +1,133 @@ +import { MessageCircle, Users } from 'lucide-react'; +import Link from 'next/link'; +import React from 'react'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import UserAvatar from '@/components/ui/avatar'; +import UserProfileModal from '@/entities/user/ui/user-profile-modal'; +import { BalanceGame } from '@/types/balance-game'; +import VoteTimer from '../voting/vote-timer'; + +interface VotingCardProps { + voting: BalanceGame; + onClick?: () => void; +} + +export default function VotingCard({ voting, onClick }: VotingCardProps) { + const topOption = voting.options.reduce((prev, current) => + prev.percentage > current.percentage ? prev : current, + ); + + // myVote can be null or number (optionId) + const hasVoted = voting.myVote !== undefined && voting.myVote !== null; + + const cardContent = ( +
{ + e.preventDefault(); + onClick(); + } + : undefined + } + > + {/* 헤더: 작성자 & 상태 */} +
+ {/* 작성자 정보 */} +
e.stopPropagation()}> + +
+ +
+ + {voting.author.nickname} + +
+ } + /> +
+ + {/* 타이머 표시 */} + +
+ + {/* 제목 */} +

+ {voting.title} +

+ + {/* 태그 - 제목 바로 아래에 표시 */} + {voting.tags && Array.isArray(voting.tags) && voting.tags.length > 0 && ( +
+ {voting.tags.map((tag, index) => ( + + #{tag} + + ))} +
+ )} + + {/* 설명 (있으면) */} + {voting.description && ( +

+ {voting.description} +

+ )} + + {/* 간단한 투표 결과 미리보기 (투표했을 때만) */} + {hasVoted && ( +
+
+ 현재 1위 +
+
+ + {topOption.label} + + + {topOption.percentage.toFixed(1)}% + +
+
+ )} + + {/* 하단 메타 정보 */} +
+ {/* 총 투표 수 */} +
+ + {voting.totalVotes.toLocaleString()} +
+ + {/* 댓글 수 */} +
+ + {voting.commentCount || 0} +
+
+ + ); + + // onClick이 있으면 Link 없이 렌더링, 없으면 Link로 감싸기 + if (onClick) { + return cardContent; + } + + return {cardContent}; +} diff --git a/src/components/contents/homework-detail-content.tsx b/src/components/contents/homework-detail-content.tsx index 5ed09240..ecb1751e 100644 --- a/src/components/contents/homework-detail-content.tsx +++ b/src/components/contents/homework-detail-content.tsx @@ -40,13 +40,17 @@ export default function HomeworkDetailContent({ const isLeader = useIsLeader(currentUserId); const { data: homework, isLoading: isHomeworkLoading } = useGetHomework(homeworkId); - const { data: mission, isLoading: isMissionLoading } = - useGetMission(missionId); + const { + data: mission, + isLoading: isMissionLoading, + refetch: refetchMission, + } = useGetMission(missionId); - const handleDeleteSuccess = () => { + const handleDeleteSuccess = async () => { const params = new URLSearchParams(searchParams.toString()); params.delete('homeworkId'); router.push(`?${params.toString()}`); + await refetchMission(); }; if (isHomeworkLoading || !homework || isMissionLoading || !mission) { @@ -59,8 +63,9 @@ export default function HomeworkDetailContent({ // 미션 제출 가능 기간이 지나지 않았는지 확인 const isMissionActive = mission.status !== 'ENDED'; - // 삭제 가능 조건: 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태 - const canDelete = !isEvaluated && isMissionActive; + // 수정/삭제 가능 조건: 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태 + const isMyHomework = homework.submitterId === currentUserId; + const canEditOrDelete = isMyHomework && !isEvaluated && isMissionActive; const profileImageUrl = homework.submitterProfileImage?.resizedImages?.[0]?.resizedImageUrl ?? @@ -91,8 +96,8 @@ export default function HomeworkDetailContent({ - {/* 수정/삭제 버튼 - 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */} - {canDelete && ( + {/* 수정/삭제 버튼 - 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */} + {canEditOrDelete && (
); diff --git a/src/components/contents/mission-detail-content.tsx b/src/components/contents/mission-detail-content.tsx index 75ccba19..18674b55 100644 --- a/src/components/contents/mission-detail-content.tsx +++ b/src/components/contents/mission-detail-content.tsx @@ -32,7 +32,7 @@ export default function MissionDetailContent({ const memberId = useUserStore((state) => state.memberId); const isLeader = useIsLeader(memberId); - const { data: mission, isLoading } = useGetMission(missionId); + const { data: mission, isLoading, refetch } = useGetMission(missionId); // homeworks에서 내 과제 정보 찾기 const myHomework = useMemo(() => { @@ -84,6 +84,7 @@ export default function MissionDetailContent({ myHomework={myHomework} isMissionClosed={isMissionClosed} onSelectHomework={handleSelectHomework} + onRefetch={refetch} /> )} diff --git a/src/components/discussion/comment-form.tsx b/src/components/discussion/comment-form.tsx new file mode 100644 index 00000000..df2f9271 --- /dev/null +++ b/src/components/discussion/comment-form.tsx @@ -0,0 +1,104 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Send, Loader2 } from 'lucide-react'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; +import { CommentFormSchema, CommentFormData } from '@/types/schemas/zod-schema'; + +interface CommentFormProps { + onSubmit: (data: CommentFormData) => void | Promise; + isSubmitting?: boolean; + placeholder?: string; + autoFocus?: boolean; + initialValue?: string; + onCancel?: () => void; +} + +export default function CommentForm({ + onSubmit, + isSubmitting = false, + placeholder = '댓글을 입력하세요...', + autoFocus = false, + initialValue = '', + onCancel, +}: CommentFormProps) { + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(CommentFormSchema), + defaultValues: { + content: initialValue, + }, + }); + + const handleFormSubmit = async (data: CommentFormData) => { + await onSubmit(data); + reset(); + }; + + return ( +
+
+
+