diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml new file mode 100644 index 00000000..1b86c689 --- /dev/null +++ b/.github/actions/install/action.yml @@ -0,0 +1,22 @@ +name: Install +description: 'Node.js와 NPM 패키지를 설치합니다.' +runs: + using: composite + steps: + - name: Node.js 설치 + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'npm' + + - name: 의존성 캐싱 + uses: actions/cache@v4 + id: npm-cache + with: + path: '**/node_modules' + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + + - name: 의존성 설치 + shell: bash + if: steps.npm-cache.outputs.cache-hit != 'true' + run: npm ci diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000..a2c707c1 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,97 @@ +name: CD + +on: + push: + branches: ['main', 'dev'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + VITE_DEV_BACKEND_API_ENDPOINT: ${{ secrets.VITE_DEV_BACKEND_API_ENDPOINT }} + VITE_PROD_BACKEND_API_ENDPOINT: ${{ secrets.VITE_PROD_BACKEND_API_ENDPOINT }} + VITE_DEV_DEPLOY_ENDPOINT: ${{ secrets.VITE_DEV_DEPLOY_ENDPOINT }} + VITE_PROD_DEPLOY_ENDPOINT: ${{ secrets.VITE_PROD_DEPLOY_ENDPOINT }} + + VITE_KAKAO_LOGIN_CLIENT_ID: ${{ secrets.VITE_KAKAO_LOGIN_CLIENT_ID }} + VITE_FIREBASE_API_KEY: ${{ secrets.VITE_FIREBASE_API_KEY }} + VITE_FIREBASE_AUTH_DOMAIN: ${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }} + VITE_FIREBASE_PROJECT_ID: ${{ secrets.VITE_FIREBASE_PROJECT_ID }} + VITE_FIREBASE_STORAGE_BUCKET: ${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }} + VITE_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }} + VITE_FIREBASE_APP_ID: ${{ secrets.VITE_FIREBASE_APP_ID }} + VITE_FIREBASE_MEASUREMENT_ID: ${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }} + VITE_FIREBASE_VAPID_PUBLIC_KEY: ${{ secrets.VITE_FIREBASE_VAPID_PUBLIC_KEY }} + + VITE_TOSS_CLIENT_KET: ${{ secrets.VITE_TOSS_CLIENT_KET }} + VITE_TOSS_CUSTOMER_KEY: ${{ secrets.VITE_TOSS_CUSTOMER_KEY }} + +jobs: + production: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Node.js 및 NPM 설치 + uses: ./.github/actions/install + + - name: .env 파일 생성 + run: | + echo "VITE_DEPLOY_TARGET=production" >> .env + jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]' <<< "$SECRETS_CONTEXT" >> .env + env: + SECRETS_CONTEXT: ${{ toJson(env) }} + + - name: .env 파일 확인 + run: cat .env + + - name: 빌드 + run: npm run build + + - name: AWS credentials 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: 'ap-northeast-2' + + - name: S3 버킷에 업로드 + run: aws s3 sync dist s3://${{ secrets.AWS_S3_WEB_HOST }} --delete + + - name: CloudFront 캐시 무효화 + run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_WEB_HOST_ID }} --paths '/*' + development: + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Node.js 및 NPM 설치 + uses: ./.github/actions/install + + - name: .env 파일 생성 + run: | + echo "VITE_DEPLOY_TARGET=development" >> .env + jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]' <<< "$SECRETS_CONTEXT" >> .env + env: + SECRETS_CONTEXT: ${{ toJson(env) }} + + - name: .env 파일 확인 + run: cat .env + + - name: 빌드 + run: npm run build + + - name: AWS credentials 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: 'ap-northeast-2' + + - name: S3 버킷에 업로드 + run: aws s3 sync dist s3://${{ secrets.AWS_S3_DEV_WEB_HOST }} --delete diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b9bb18c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + branches: ['main', 'dev'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + matrix: + command: ['build', 'build-storybook'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Node.js 및 NPM 설치 + uses: ./.github/actions/install + + - run: npm run ${{ matrix.command }} diff --git a/.github/workflows/deploy_dev.yml b/.github/workflows/deploy_dev.yml deleted file mode 100644 index a19268a3..00000000 --- a/.github/workflows/deploy_dev.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: React App CI/CD - -on: - push: - branches: ['dev'] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: 18.x - cache: 'npm' - - - name: Setup .env - run: | - echo "VITE_DEPLOY_TARGET"=development >> .env - echo "VITE_DEV_BACKEND_API_ENDPOINT=${{ secrets.VITE_DEV_BACKEND_API_ENDPOINT }}" >> .env - echo "VITE_PROD_BACKEND_API_ENDPOINT=${{ secrets.VITE_PROD_BACKEND_API_ENDPOINT }}" >> .env - echo "VITE_DEV_DEPLOY_ENDPOINT=${{ secrets.VITE_DEV_DEPLOY_ENDPOINT }}" >> .env - echo "VITE_PROD_DEPLOY_ENDPOINT=${{ secrets.VITE_PROD_DEPLOY_ENDPOINT }}" >> .env - - echo "VITE_KAKAO_LOGIN_CLIENT_ID=${{ secrets.VITE_KAKAO_LOGIN_CLIENT_ID }}" >> .env - echo "VITE_FIREBASE_API_KEY=${{ secrets.VITE_FIREBASE_API_KEY }}" >> .env - echo "VITE_FIREBASE_AUTH_DOMAIN=${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }}" >> .env - echo "VITE_FIREBASE_PROJECT_ID=${{ secrets.VITE_FIREBASE_PROJECT_ID }}" >> .env - echo "VITE_FIREBASE_STORAGE_BUCKET=${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }}" >> .env - echo "VITE_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }}" >> .env - echo "VITE_FIREBASE_APP_ID=${{ secrets.VITE_FIREBASE_APP_ID }}" >> .env - echo "VITE_FIREBASE_MEASUREMENT_ID=${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }}" >> .env - echo "VITE_FIREBASE_VAPID_PUBLIC_KEY=${{ secrets.VITE_FIREBASE_VAPID_PUBLIC_KEY }}" >> .env - echo "VITE_TOSS_CLIENT_KET=${{ secrets.VITE_TOSS_CLIENT_KET }}" >> .env - echo "VITE_TOSS_CUSTOMER_KEY=${{ secrets.VITE_TOSS_CUSTOMER_KEY }}" >> .env - - - run: cat .env - - - run: npm i - - run: npm run build --if-present - - - name: Upload to S3 Bucket - uses: awact/s3-action@master - with: - args: --acl public-read --follow-symlinks --delete - env: - SOURCE_DIR: 'dist' - AWS_REGION: 'ap-northeast-2' - AWS_S3_BUCKET: ${{ secrets.AWS_S3_DEV_WEB_HOST }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/deploy_prod.yml b/.github/workflows/deploy_prod.yml deleted file mode 100644 index f6e27d4e..00000000 --- a/.github/workflows/deploy_prod.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: React App CI/CD - -on: - push: - branches: ['main'] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: 18.x - cache: 'npm' - - - name: Setup .env - run: | - echo "VITE_DEPLOY_TARGET"=production >> .env - echo "VITE_DEV_BACKEND_API_ENDPOINT=${{ secrets.VITE_DEV_BACKEND_API_ENDPOINT }}" >> .env - echo "VITE_PROD_BACKEND_API_ENDPOINT=${{ secrets.VITE_PROD_BACKEND_API_ENDPOINT }}" >> .env - echo "VITE_DEV_DEPLOY_ENDPOINT=${{ secrets.VITE_DEV_DEPLOY_ENDPOINT }}" >> .env - echo "VITE_PROD_DEPLOY_ENDPOINT=${{ secrets.VITE_PROD_DEPLOY_ENDPOINT }}" >> .env - - echo "VITE_KAKAO_LOGIN_CLIENT_ID=${{ secrets.VITE_KAKAO_LOGIN_CLIENT_ID }}" >> .env - echo "VITE_FIREBASE_API_KEY=${{ secrets.VITE_FIREBASE_API_KEY }}" >> .env - echo "VITE_FIREBASE_AUTH_DOMAIN=${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }}" >> .env - echo "VITE_FIREBASE_PROJECT_ID=${{ secrets.VITE_FIREBASE_PROJECT_ID }}" >> .env - echo "VITE_FIREBASE_STORAGE_BUCKET=${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }}" >> .env - echo "VITE_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }}" >> .env - echo "VITE_FIREBASE_APP_ID=${{ secrets.VITE_FIREBASE_APP_ID }}" >> .env - echo "VITE_FIREBASE_MEASUREMENT_ID=${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }}" >> .env - echo "VITE_FIREBASE_VAPID_PUBLIC_KEY=${{ secrets.VITE_FIREBASE_VAPID_PUBLIC_KEY }}" >> .env - echo "VITE_TOSS_CLIENT_KET=${{ secrets.VITE_TOSS_CLIENT_KET }}" >> .env - echo "VITE_TOSS_CUSTOMER_KEY=${{ secrets.VITE_TOSS_CUSTOMER_KEY }}" >> .env - - - run: cat .env - - - run: npm i - - run: npm run build --if-present - - - name: Upload to S3 Bucket - uses: awact/s3-action@master - with: - args: --acl public-read --follow-symlinks --delete - env: - SOURCE_DIR: 'dist' - AWS_REGION: 'ap-northeast-2' - AWS_S3_BUCKET: ${{ secrets.AWS_S3_WEB_HOST }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - - name: Invalidate CloudFront - uses: chetan/invalidate-cloudfront-action@v2 - env: - DISTRIBUTION: ${{ secrets.AWS_CLOUDFRONT_WEB_HOST_ID }} - PATHS: '/*' - AWS_REGION: 'ap-northeast-2' - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml deleted file mode 100644 index 366ce6aa..00000000 --- a/.github/workflows/pull_request.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Pull Request - -on: - pull_request: - branches: ['main', 'dev'] - -jobs: - build-react: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: 18.x - cache: 'npm' - - - run: npm ci - - - name: Build React - run: npm run build --if-present - build-storybook: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: 18.x - cache: 'npm' - - - run: npm ci - - - name: Build Storybook - run: npm run build-storybook --if-present diff --git a/.vscode/settings.json b/.vscode/settings.json index 0a42598b..9e98413a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "files.associations": { "*.css": "tailwindcss" diff --git a/src/core/api/functions/roomAPI.ts b/src/core/api/functions/roomAPI.ts index d98d6fa2..d8da4a3b 100644 --- a/src/core/api/functions/roomAPI.ts +++ b/src/core/api/functions/roomAPI.ts @@ -36,8 +36,6 @@ const roomAPI = { roomId: string; title: string; announcement: string; - // TODO: 루틴 수정을 제한하는 요구사항 발생 - // routines: string[]; password: string; certifyTime: number; maxUserCount: number; diff --git a/src/domain/RoomForm/components/Password.tsx b/src/domain/RoomForm/components/Password.tsx index 3b3c1fde..b1d2f95e 100644 --- a/src/domain/RoomForm/components/Password.tsx +++ b/src/domain/RoomForm/components/Password.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useFormContext } from 'react-hook-form'; import { PasswordInput } from '@/shared/Input'; -import { PASSWORD } from '@/domain/RoomForm/constants/literals'; +import { FORM_LITERAL } from '@/domain/RoomForm/constants/literals'; import { errorStyle } from '../constants/styles'; interface PasswordProps { @@ -29,7 +29,7 @@ const Password = ({ placeholder }: PasswordProps) => { {errors.password && ( diff --git a/src/domain/RoomForm/components/Routines.tsx b/src/domain/RoomForm/components/Routines.tsx index d1d4dec8..71629c8d 100644 --- a/src/domain/RoomForm/components/Routines.tsx +++ b/src/domain/RoomForm/components/Routines.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { InnerTextInput } from '@/shared/Input'; import { Icon } from '@/shared/Icon'; -import { ROUTINE_COUNT, ROUTINE_NAME } from '../constants/literals'; +import { FORM_LITERAL } from '../constants/literals'; import { errorStyle, iconButtonStyle } from '../constants/styles'; const Routines = () => { @@ -25,7 +25,7 @@ const Routines = () => { }); const handleAppendRoutine = useCallback(() => { - if (routines.length >= ROUTINE_COUNT.max) { + if (routines.length >= FORM_LITERAL.routines.max.value) { return; } @@ -54,10 +54,10 @@ const Routines = () => { text={ watchRoutines[idx].value.length.toString() + ' / ' + - ROUTINE_NAME.max + FORM_LITERAL.routines.item.max.value } placeholder="루틴 이름" - maxLength={ROUTINE_NAME.max} + maxLength={FORM_LITERAL.routines.item.max.value} /> {idx !== 0 && (
{ />
- {routines.length} / {ROUTINE_COUNT.max} + {routines.length} / {FORM_LITERAL.routines.max.value}
diff --git a/src/domain/RoomForm/components/UserCount.tsx b/src/domain/RoomForm/components/UserCount.tsx index 66c345be..afa3a7e9 100644 --- a/src/domain/RoomForm/components/UserCount.tsx +++ b/src/domain/RoomForm/components/UserCount.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useFormContext } from 'react-hook-form'; import { Icon } from '@/shared/Icon'; import { iconButtonStyle, errorStyle } from '../constants/styles'; -import { USER_COUNT } from '../constants/literals'; +import { FORM_LITERAL } from '../constants/literals'; const UserCount = () => { const { @@ -15,7 +15,7 @@ const UserCount = () => { const handleSetUserCount = useCallback( (count: number) => { - if (count <= 0 || count > USER_COUNT.max) { + if (count <= 0 || count > FORM_LITERAL.userCount.max.value) { return; } diff --git a/src/domain/RoomForm/constants/literals.ts b/src/domain/RoomForm/constants/literals.ts index 6d576fc5..5bb8fc12 100644 --- a/src/domain/RoomForm/constants/literals.ts +++ b/src/domain/RoomForm/constants/literals.ts @@ -1,9 +1,80 @@ -// 새 종류 별 방 타입 import { RoomInfo } from '@/core/types/Room'; -export const ROOM_TYPES = ['MORNING', 'NIGHT'] as const; +export const FORM_LITERAL = { + /** 방 종류 */ + roomType: { + value: ['MORNING', 'NIGHT'], + message: '방 종류를 선택해주세요.' + }, + /** 루틴 목록 */ + routines: { + min: { + value: 0 + }, + max: { + value: 4, + message: '루틴은 최대 4개까지 등록할 수 있어요.' + }, + item: { + min: { + value: 2, + message: '루틴 내용은 2글자 이상이어야 해요.' + }, + max: { + value: 20, + message: '루틴 내용은 20글자 이하로 입력해주세요.' + } + } + }, + /** 방 제목 */ + title: { + min: { + value: 1, + message: '방 제목은 1글자 이상이어야 해요.' + }, + max: { + value: 20, + message: '방 제목은 20글자 이하로 입력해주세요.' + } + }, + /** 방에 참여할 수 있는 인원 수 */ + userCount: { + min: { + value: 1, + message: '인원을 1명 이상 선택해주세요.' + }, + max: { + value: 10, + message: '인원을 10명 이하로 선택해주세요.' + } + }, + /** 방 비밀번호 */ + password: { + min: { + value: 4, + message: '비밀번호는 4자리 이상이어야 해요.' + }, + max: { + value: 8, + message: '비밀번호는 8자리 이하로 입력해주세요.' + }, + onlyNumeric: { + message: '비밀번호는 숫자로만 입력해주세요.' + } + }, + /** 공지사항 */ + announcement: { + min: { + value: 0 + }, + max: { + value: 100, + message: '공지사항은 100글자 이하로 입력해주세요.' + } + } +} as const; -// 새 종류 및 이미지 +/** 새 종류 및 이미지 */ export const BIRD: Record< RoomInfo['roomType'], { @@ -24,59 +95,11 @@ export const BIRD: Record< } } as const; -// 새 종류 별 선택할 수 있는 인증 시간 범위 +/** 새 종류 별 선택할 수 있는 인증 시간 범위 */ export const TIME_RANGE: Record = { MORNING: [4, 10], NIGHT: [20, 26] }; -// 참여할 수 있는 최대 방 수 -export const ROOM_COUNT = { - max: 3 -}; - -// 등록할 수 있는 최대 루틴 수 -export const ROUTINE_COUNT = { - max: 4 -}; - -// 루틴 이름 길이 -export const ROUTINE_NAME = { - min: 2, - max: 20 -}; - -// 방 제목 길이 -export const ROOM_NAME = { - min: 1, - max: 20 -}; - -// 비밀번호 길이 -export const PASSWORD = { - min: 4, - max: 8 -}; - -// 참여자 수 -export const USER_COUNT = { - min: 1, - max: 10 -}; - -// 공지사항 길이 -export const ANNOUNCEMENT = { - min: 0, - max: 100 -}; - -// 폼 유효성 검사 메시지 -export const FORM_MESSAGE = { - ROOM_TYPE: `방 종류를 선택해주세요.`, - ANNOUNCEMENT: `공지사항은 ${ANNOUNCEMENT.min}글자에서 ${ANNOUNCEMENT.max}글자 사이여야 해요.`, - ROUTINE_NAME: `루틴 내용은 ${ROUTINE_NAME.min}글자에서 ${ROUTINE_NAME.max}글자 사이여야 해요.`, - ROOM_NAME: `방 제목은 ${ROOM_NAME.min}글자에서 ${ROOM_NAME.max}글자 사이여야 해요.`, - USER_COUNT: `인원을 올바르게 선택해주세요.`, - PASSWORD: `비밀번호는 ${PASSWORD.min}자리에서 ${PASSWORD.max}자리의 숫자여야 해요.`, - ONLY_NUMERIC_PASSWORD: `비밀번호는 숫자로만 입력해주세요.` -}; +/** 참여할 수 있는 최대 방 수 */ +export const MAX_ROOM_COUNT = 3; diff --git a/src/domain/RoomNew/components/BirdCardSection.tsx b/src/domain/RoomNew/components/BirdCardSection.tsx index 90f23579..64db1946 100644 --- a/src/domain/RoomNew/components/BirdCardSection.tsx +++ b/src/domain/RoomNew/components/BirdCardSection.tsx @@ -4,8 +4,8 @@ import { useFormContext } from 'react-hook-form'; import { roomOptions } from '@/core/api/options'; import { QueryErrorBoundary, NetworkFallback } from '@/shared/ErrorBoundary'; import { - ROOM_COUNT, - ROOM_TYPES, + MAX_ROOM_COUNT, + FORM_LITERAL, TIME_RANGE } from '@/domain/RoomForm/constants/literals'; import { Inputs } from '../hooks/useRoomForm'; @@ -29,12 +29,12 @@ const BirdCardSectionComponent = () => { }); const isFull = (type: Inputs['roomType']) => { - return roomCount[type] >= ROOM_COUNT.max; + return roomCount[type] >= MAX_ROOM_COUNT; }; return ( <> - {ROOM_TYPES.map((roomType) => ( + {FORM_LITERAL.roomType.value.map((roomType) => (
{ +interface NavbarProps { + funnel: ReturnType>; isPending: boolean; } -const Navbar = ({ - isPending, - current, - hasNext, - hasPrev, - toNext, - toPrev -}: NavbarProps) => { +const Navbar = ({ isPending, funnel }: NavbarProps) => { const { trigger } = useFormContext(); + const { step, hasNext, hasPrev, toNext, toPrev } = funnel; // 참여 중인 방 정보를 가져오는 동안 다음 스텝으로 넘어가지 못하도록 합니다. const { isSuccess } = useQuery({ ...roomOptions.myJoin() }); @@ -38,7 +33,7 @@ const Navbar = ({ return; } - const isCompleted = await trigger(validationMaps[current], { + const isCompleted = await trigger(validationMaps[step], { shouldFocus: true }); diff --git a/src/domain/RoomNew/hooks/useRoomForm.ts b/src/domain/RoomNew/hooks/useRoomForm.ts index 67b53d4a..24f88f49 100644 --- a/src/domain/RoomNew/hooks/useRoomForm.ts +++ b/src/domain/RoomNew/hooks/useRoomForm.ts @@ -6,18 +6,13 @@ import roomAPI from '@/core/api/functions/roomAPI'; import { useMoveRoute } from '@/core/hooks'; import { Toast } from '@/shared/Toast'; import { - FORM_MESSAGE, - ROOM_TYPES, - TIME_RANGE, - ROUTINE_NAME, - ROOM_NAME, - USER_COUNT, - PASSWORD + FORM_LITERAL as L, + TIME_RANGE } from '@/domain/RoomForm/constants/literals'; export const formSchema = z.object({ - roomType: z.enum(ROOM_TYPES, { - required_error: FORM_MESSAGE.ROOM_TYPE + roomType: z.enum(L.roomType.value, { + required_error: L.roomType.message }), certifyTime: z.number(), routines: z.array( @@ -25,26 +20,26 @@ export const formSchema = z.object({ value: z .string() .trim() - .min(ROUTINE_NAME.min, FORM_MESSAGE.ROUTINE_NAME) - .max(ROUTINE_NAME.max, FORM_MESSAGE.ROUTINE_NAME) + .min(L.routines.item.min.value, L.routines.item.min.message) + .max(L.routines.item.max.value, L.routines.item.max.message) }) ), title: z .string() .trim() - .min(ROOM_NAME.min, FORM_MESSAGE.ROOM_NAME) - .max(ROOM_NAME.max, FORM_MESSAGE.ROOM_NAME), + .min(L.title.min.value, L.title.min.message) + .max(L.title.max.value, L.title.max.message), userCount: z .number() - .gte(USER_COUNT.min, FORM_MESSAGE.USER_COUNT) - .lte(USER_COUNT.max, FORM_MESSAGE.USER_COUNT), + .gte(L.userCount.min.value, L.userCount.min.message) + .lte(L.userCount.max.value, L.userCount.max.message), password: z.literal('').or( z .string() - .min(PASSWORD.min, FORM_MESSAGE.PASSWORD) - .max(PASSWORD.max, FORM_MESSAGE.PASSWORD) + .min(L.password.min.value, L.password.min.message) + .max(L.password.max.value, L.password.max.message) .refine((v) => /^\d*$/.test(v), { - message: FORM_MESSAGE.ONLY_NUMERIC_PASSWORD + message: L.password.onlyNumeric.message }) ) }); diff --git a/src/domain/RoomNew/steps/PasswordStep.tsx b/src/domain/RoomNew/steps/PasswordStep.tsx index 8cb34081..9e0e7c71 100644 --- a/src/domain/RoomNew/steps/PasswordStep.tsx +++ b/src/domain/RoomNew/steps/PasswordStep.tsx @@ -1,9 +1,12 @@ import clsx from 'clsx'; -import { PASSWORD } from '@/domain/RoomForm/constants/literals'; +import { FORM_LITERAL } from '@/domain/RoomForm/constants/literals'; import { Password } from '@/domain/RoomForm'; import { headingStyle, descriptionStyle } from '../constants/styles'; const PasswordStep = () => { + const min = FORM_LITERAL.password.min.value; + const max = FORM_LITERAL.password.max.value; + return ( <>

@@ -14,8 +17,7 @@ const PasswordStep = () => {

- 선택사항입니다. {PASSWORD.min}자리에서 {PASSWORD.max}자리 숫자를 - 적어주세요! + 선택사항입니다. {min}자리에서 {max}자리 숫자를 적어주세요!

diff --git a/src/domain/RoomNew/steps/RoutineStep.tsx b/src/domain/RoomNew/steps/RoutineStep.tsx index 7c55c6c3..3bad1513 100644 --- a/src/domain/RoomNew/steps/RoutineStep.tsx +++ b/src/domain/RoomNew/steps/RoutineStep.tsx @@ -1,6 +1,6 @@ import { useFormContext } from 'react-hook-form'; import { Input } from '@/shared/Input'; -import { ROOM_NAME } from '@/domain/RoomForm/constants/literals'; +import { FORM_LITERAL } from '@/domain/RoomForm/constants/literals'; import { Routines, UserCount } from '@/domain/RoomForm'; import { errorStyle } from '../constants/styles'; import { Inputs } from '../hooks/useRoomForm'; @@ -20,7 +20,7 @@ const RoutineStep = () => { {errors.title &&

{errors.title.message}

} diff --git a/src/domain/RoomSetting/hooks/useRoomForm.ts b/src/domain/RoomSetting/hooks/useRoomForm.ts index 14a3bf0e..2346a5c4 100644 --- a/src/domain/RoomSetting/hooks/useRoomForm.ts +++ b/src/domain/RoomSetting/hooks/useRoomForm.ts @@ -6,47 +6,40 @@ import roomAPI from '@/core/api/functions/roomAPI'; import { roomOptions } from '@/core/api/options'; import { useMoveRoute } from '@/core/hooks'; import { Toast } from '@/shared/Toast'; -import { - ANNOUNCEMENT, - ROUTINE_NAME, - ROOM_NAME, - USER_COUNT, - PASSWORD, - FORM_MESSAGE -} from '@/domain/RoomForm/constants/literals'; +import { FORM_LITERAL as L } from '@/domain/RoomForm/constants/literals'; export const formSchema = z.object({ title: z .string() .trim() - .min(ROOM_NAME.min, FORM_MESSAGE.ROOM_NAME) - .max(ROOM_NAME.max, FORM_MESSAGE.ROOM_NAME), + .min(L.title.min.value, L.title.min.message) + .max(L.title.max.value, L.title.max.message), announcement: z .string() .trim() - .min(ANNOUNCEMENT.min, FORM_MESSAGE.ANNOUNCEMENT) - .max(ANNOUNCEMENT.max, FORM_MESSAGE.ANNOUNCEMENT), + .min(L.announcement.min.value) + .max(L.announcement.max.value, L.announcement.max.message), certifyTime: z.number(), routines: z.array( z.object({ value: z .string() .trim() - .min(ROUTINE_NAME.min, FORM_MESSAGE.ROUTINE_NAME) - .max(ROUTINE_NAME.max, FORM_MESSAGE.ROUTINE_NAME) + .min(L.routines.item.min.value, L.routines.item.min.message) + .max(L.routines.item.max.value, L.routines.item.max.message) }) ), userCount: z .number() - .gte(USER_COUNT.min, FORM_MESSAGE.USER_COUNT) - .lte(USER_COUNT.max, FORM_MESSAGE.USER_COUNT), + .gte(L.userCount.min.value, L.userCount.min.message) + .lte(L.userCount.max.value, L.userCount.max.message), password: z.literal('').or( z .string() - .min(PASSWORD.min, FORM_MESSAGE.PASSWORD) - .max(PASSWORD.max, FORM_MESSAGE.PASSWORD) + .min(L.password.min.value, L.password.min.message) + .max(L.password.max.value, L.password.max.message) .refine((v) => /^\d*$/.test(v), { - message: FORM_MESSAGE.ONLY_NUMERIC_PASSWORD + message: L.password.onlyNumeric.message }) ) }); @@ -79,8 +72,6 @@ const useRoomForm = ({ roomId, defaultValues }: useRoomFormProps) => { title: data.title, announcement: data.announcement, certifyTime: data.certifyTime % 24, - // TODO: 루틴 수정을 제한하는 요구사항 발생 - // routines: data.routines.map((r) => r.value), maxUserCount: data.userCount, password: data.password }, @@ -106,19 +97,11 @@ const useRoomForm = ({ roomId, defaultValues }: useRoomFormProps) => { }); if (error.response?.data?.validation) { - const { - title, - announcement, - routine, - password, - certifyTime, - maxUserCount - } = error.response.data.validation; + const { title, announcement, password, certifyTime, maxUserCount } = + error.response.data.validation; setError('title', { message: title }); setError('announcement', { message: announcement }); - // TODO: 루틴 수정을 제한하는 요구사항 발생 - // setError('routines', { message: routine }); setError('password', { message: password }); setError('certifyTime', { message: certifyTime }); setError('userCount', { message: maxUserCount }); diff --git a/src/domain/RoomSetting/index.ts b/src/domain/RoomSetting/index.ts index 214a8eee..bac55618 100644 --- a/src/domain/RoomSetting/index.ts +++ b/src/domain/RoomSetting/index.ts @@ -1,4 +1,3 @@ export { default as RoomTab } from './tabs/RoomTab'; export { default as MemberTab } from './tabs/MemberTab'; -export { default as RemoveTab } from './tabs/RemoveTab'; export { default as LoadingFallback } from './components/LoadingFallback'; diff --git a/src/domain/RoomSetting/tabs/RemoveTab.stories.tsx b/src/domain/RoomSetting/tabs/RemoveTab.stories.tsx deleted file mode 100644 index 61f93c5c..00000000 --- a/src/domain/RoomSetting/tabs/RemoveTab.stories.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import { http, HttpResponse } from 'msw'; -import { Title, Description, Stories } from '@storybook/blocks'; -import { baseURL } from '@/core/api/mocks/baseURL'; -import { RoomInfoBeforeEditing } from '@/core/api/mocks/datas/room'; -import RemoveTab from './RemoveTab'; - -const meta = { - title: 'Pages/RoomSetting/RemoveTab', - component: RemoveTab, - tags: ['autodocs'], - parameters: { - docs: { - description: { - component: '방 관리 페이지의 삭제 탭에서는 조건부 렌더링이 존재합니다.' - }, - page: () => ( - <> - - <Description /> - <Stories /> - </> - ) - }, - msw: { - handlers: [ - http.get(baseURL('/rooms/:roomId'), async ({ params }) => { - const { roomId } = params; - - switch (roomId) { - case '1': - return HttpResponse.json({ - ...RoomInfoBeforeEditing, - participants: RoomInfoBeforeEditing.participants.slice(0, 1) - }); - default: - return HttpResponse.json(RoomInfoBeforeEditing); - } - }) - ] - } - } -} satisfies Meta<typeof RemoveTab>; - -export default meta; -type Story = StoryObj<typeof meta>; - -export const OneParticipant: Story = { - args: { - roomId: '1' - }, - parameters: { - docs: { - description: { - story: '방에 참여한 사람이 본인만 남았다면 방 삭제가 가능합니다.' - } - } - } -}; - -export const MultiParticipant: Story = { - args: { - roomId: '2' - }, - parameters: { - docs: { - description: { - story: '참여자가 여러 명인 경우 방 삭제가 불가능합니다.' - } - } - } -}; diff --git a/src/domain/RoomSetting/tabs/RemoveTab.tsx b/src/domain/RoomSetting/tabs/RemoveTab.tsx deleted file mode 100644 index 7382263c..00000000 --- a/src/domain/RoomSetting/tabs/RemoveTab.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState } from 'react'; -import { - useMutation, - useQueryClient, - useSuspenseQuery -} from '@tanstack/react-query'; -import { roomOptions } from '@/core/api/options'; -import roomAPI from '@/core/api/functions/roomAPI'; -import { useMoveRoute } from '@/core/hooks'; -import { Input } from '@/shared/Input'; -import { LoadingSpinner } from '@/shared/LoadingSpinner'; -import { Toast } from '@/shared/Toast'; - -interface RemoveTabProps { - roomId: string; -} - -const RemoveTab = ({ roomId }: RemoveTabProps) => { - const [confirmInput, setConfirmInput] = useState(''); - const queryClient = useQueryClient(); - const moveTo = useMoveRoute(); - - const { data: room } = useSuspenseQuery({ - ...roomOptions.detail(roomId), - staleTime: Infinity - }); - - const { mutate, isPending, error } = useMutation({ - mutationFn: roomAPI.deleteRoom - }); - - const handleRemove = () => { - mutate(roomId, { - onSuccess: () => { - queryClient.removeQueries({ - queryKey: roomOptions.detail(roomId).queryKey - }); - - Toast.show({ message: '방을 삭제했어요.', status: 'confirm' }); - - moveTo('routines'); - }, - onError: (err) => { - console.error(err); - - Toast.show({ - message: err.response?.data.message ?? '오류가 발생했어요.', - status: 'danger' - }); - } - }); - }; - - if (room.participants.length > 1) { - return ( - <> - <h1 className={headingStyle}> - <p className="font-bold">방에 혼자 남았을 때만</p> - <p className="font-bold">삭제할 수 있어요.</p> - </h1> - </> - ); - } else { - return ( - <> - <h1 className={headingStyle}>방을 삭제할까요?</h1> - <section className="mt-4 flex flex-col gap-2"> - <label - htmlFor="confirm" - className={descriptionStyle} - > - 방의 이름 "{room.title}" 을 적어주세요. - </label> - <Input - id="confirm" - value={confirmInput} - autoComplete="off" - placeholder={room.title} - onChange={(e) => setConfirmInput(e.target.value)} - /> - </section> - {error && ( - <p className="ml-2 mt-2 text-base text-danger"> - {error.response?.data.message} - </p> - )} - <button - className="btn btn-danger mt-8 flex w-full items-start justify-center" - onClick={handleRemove} - disabled={confirmInput !== room.title || isPending} - > - {isPending ? <LoadingSpinner size="2xl" /> : '방 삭제'} - </button> - </> - ); - } -}; - -export default RemoveTab; - -// 헤딩에 적용할 스타일 -const headingStyle = 'text-xl font-bold'; - -// 회색으로 간결한 설명을 적을 때 적용할 스타일 -const descriptionStyle = 'text-base text-dark-gray'; diff --git a/src/domain/RoomSetting/tabs/RoomTab.tsx b/src/domain/RoomSetting/tabs/RoomTab.tsx index 02d8aecb..d7986489 100644 --- a/src/domain/RoomSetting/tabs/RoomTab.tsx +++ b/src/domain/RoomSetting/tabs/RoomTab.tsx @@ -6,12 +6,8 @@ import { roomOptions } from '@/core/api/options'; import { Input } from '@/shared/Input'; import { LoadingSpinner } from '@/shared/LoadingSpinner'; import { formatHourString } from '@/domain/TimePicker/utils/hour'; -import { - TIME_RANGE, - ANNOUNCEMENT, - ROOM_NAME -} from '@/domain/RoomForm/constants/literals'; -import { UserCount, Routines, Password } from '@/domain/RoomForm'; +import { TIME_RANGE, FORM_LITERAL } from '@/domain/RoomForm/constants/literals'; +import { UserCount, Password } from '@/domain/RoomForm'; import { TimePicker } from '@/domain/TimePicker'; import useRoomForm from '../hooks/useRoomForm'; @@ -67,7 +63,7 @@ const RoomTab = ({ roomId }: RoomTabProps) => { <Input id="title" {...register('title')} - maxLength={ROOM_NAME.max} + maxLength={FORM_LITERAL.title.max.value} /> {errors.title && ( <p className={errorStyle}>{errors.title?.message}</p> @@ -81,7 +77,7 @@ const RoomTab = ({ roomId }: RoomTabProps) => { > <b>공지사항</b> <p className="text-xs text-gray-400"> - {watchAnnouncement.length} / {ANNOUNCEMENT.max} + {watchAnnouncement.length} / {FORM_LITERAL.announcement.max.value} </p> </label> <ReactTextareaAutosize @@ -92,7 +88,7 @@ const RoomTab = ({ roomId }: RoomTabProps) => { 'dark:bg-dark-sub dark:focus:border-dark-point dark:focus:ring-dark-point' )} minRows={3} - maxLength={ANNOUNCEMENT.max} + maxLength={FORM_LITERAL.announcement.max.value} id="announcement" {...register('announcement')} /> @@ -122,12 +118,6 @@ const RoomTab = ({ roomId }: RoomTabProps) => { )} </section> - {/* // TODO: 루틴 수정을 제한하는 요구사항 발생 */} - {/* <section className={sectionStyle}> - <label className={labelStyle}>루틴 목록</label> - <Routines /> - </section> */} - <section className={sectionStyle}> <label className={labelStyle}>인원</label> <UserCount /> diff --git a/src/pages/RoomNewPage.tsx b/src/pages/RoomNewPage.tsx index 8f12c051..4ec49416 100644 --- a/src/pages/RoomNewPage.tsx +++ b/src/pages/RoomNewPage.tsx @@ -1,6 +1,6 @@ import { FormProvider } from 'react-hook-form'; import { motion } from 'framer-motion'; -import { useFunnel, Funnel } from '@/shared/Funnel'; +import { createFunnel } from '@/shared/Funnel'; import { Header } from '@/shared/Header'; import { BirdStep, @@ -20,9 +20,7 @@ export const steps = [ 'SummaryStep' ] as const; -const stepComponents: { - [key in (typeof steps)[number]]: JSX.Element; -} = { +const stepComponents: Record<(typeof steps)[number], JSX.Element> = { BirdStep: <BirdStep />, TimeStep: <TimeStep />, RoutineStep: <RoutineStep />, @@ -30,8 +28,10 @@ const stepComponents: { SummaryStep: <SummaryStep /> }; +const { Funnel, Step, useFunnel } = createFunnel(steps); + const RoomNewPage = () => { - const funnel = useFunnel(steps); + const funnel = useFunnel(); const { form, mutation, handleSubmit } = useRoomForm(); return ( @@ -48,7 +48,7 @@ const RoomNewPage = () => { <main className="grow overflow-auto px-8 py-12"> <Funnel {...funnel}> {steps.map((step) => ( - <Funnel.Step + <Step key={step} name={step} > @@ -60,12 +60,12 @@ const RoomNewPage = () => { > {stepComponents[step]} </motion.div> - </Funnel.Step> + </Step> ))} </Funnel> </main> <Navbar - {...funnel} + funnel={funnel} isPending={mutation.isPending} /> </form> diff --git a/src/pages/RoomSettingPage.tsx b/src/pages/RoomSettingPage.tsx index 11cd51dc..e82b0dce 100644 --- a/src/pages/RoomSettingPage.tsx +++ b/src/pages/RoomSettingPage.tsx @@ -40,12 +40,6 @@ const RoomSettingPage = () => { <MemberTab roomId={roomId} /> </Suspense> </TabItem> - {/* TODO: 앞으로 사용되지 않을 가능성이 높습니다. */} - {/* <TabItem title="방 삭제"> - <Suspense fallback={<LoadingFallback />}> - <RemoveTab roomId={roomId} /> - </Suspense> - </TabItem> */} </Tab> </ErrorBoundary> </QueryErrorBoundary> diff --git a/src/shared/Funnel/Funnel.mdx b/src/shared/Funnel/Funnel.mdx index 61cd55c4..d20e76bb 100644 --- a/src/shared/Funnel/Funnel.mdx +++ b/src/shared/Funnel/Funnel.mdx @@ -10,31 +10,52 @@ import * as FunnelStories from './Funnel.stories'; ## **사용 방법** -### 1. steps 정의 +### 1. createFunnel 함수 호출 + +먼저 createFunnel 함수를 호출해서 `Funnel`, `Step`, `useFunnel` 를 가져와주세요. +createFunnel 함수는 step 배열 정보를 일일히 제네릭으로 넘겨주지 않아도 되도록 도와주는 헬퍼 함수입니다. + +step 배열에는 **as const**를 붙여서 좁은 타입으로 만들어주세요. ```tsx -const steps = ["방선택", "인증시간", "루틴정보", "비밀번호", "마무리"] as const; +const { Funnel, Step, useFunnel } = createFunnel([ + '방선택', + '인증시간', + '루틴정보', + '비밀번호', + '마무리' +] as const); ``` ### 2. useFunnel 훅 선언 ```tsx -const funnel = useFunnel(steps, <최초로 보여줄 스텝>); +const { step, hasNext, hasPrev, setStep, toNext, toPrev } = useFunnel(); ``` ### 3. Funnel, Step 컴포넌트 사용 +렌더링하고자 하는 Step을 Funnel 컴포넌트로 감싸주세요. +그리고 Step 컴포넌트에는 name 속성을 넘겨주세요. + ```tsx -<Funnel {...funnel}> - <Funnel.Step<typeof steps> name="마무리">마무리 페이지</Funnel.Step> - <Funnel.Step<typeof steps> name="방선택">방선택 페이지</Funnel.Step> - <Funnel.Step<typeof steps> name="인증시간">인증시간 페이지</Funnel.Step> - <Funnel.Step<typeof steps> name="루틴정보">루틴정보 페이지</Funnel.Step> - <Funnel.Step<typeof steps> name="비밀번호">비밀번호 페이지</Funnel.Step> +<Funnel step={step}> + <Step name="마무리"> + <div>마무리 페이지</div> + <button onClick={() => setStep('방선택')}>처음으로 이동하기</button> + </Step> + <Step name="방선택">방선택 페이지</Step> + <Step name="인증시간">인증시간 페이지</Step> + <Step name="루틴정보">루틴정보 페이지</Step> + <Step name="비밀번호">비밀번호 페이지</Step> <div>Step 컴포넌트가 아닌 요소는 렌더링에서 무시돼요.</div> <div> - children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는 - 순서로 스텝을 보여줘요. + children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는 순서로 + 스텝을 보여줘요. + </div> + <div> + 또는 각 스텝에서 다음 스텝으로 넘어가는 로직에 setStep을 사용하고 핸들러로 + 전달할 수 있어요. </div> </Funnel> ``` diff --git a/src/shared/Funnel/Funnel.stories.tsx b/src/shared/Funnel/Funnel.stories.tsx index a3fd7750..03bd0c60 100644 --- a/src/shared/Funnel/Funnel.stories.tsx +++ b/src/shared/Funnel/Funnel.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { Funnel, useFunnel } from '.'; +import { createFunnel } from '.'; const meta = { title: 'Shared/Funnel' -} satisfies Meta<typeof Funnel>; +} satisfies Meta; export default meta; @@ -13,53 +13,54 @@ export const Default: Story = { render: () => <DefaultPage /> }; -const DefaultPage = () => { - const steps = [ - '방선택', - '인증시간', - '루틴정보', - '비밀번호', - '마무리' - ] as const; +const { Funnel, Step, useFunnel } = createFunnel([ + '방선택', + '인증시간', + '루틴정보', + '비밀번호', + '마무리' +] as const); - const funnel = useFunnel(steps, '인증시간'); +const DefaultPage = () => { + const { step, hasNext, hasPrev, setStep, toNext, toPrev } = useFunnel(); return ( <div className="bg-slate-100"> <div className="flex min-h-[700px] items-center justify-center text-4xl"> - <Funnel {...funnel}> - <Funnel.Step<typeof steps> name="마무리">마무리 페이지</Funnel.Step> - <Funnel.Step<typeof steps> name="비밀번호"> - 비밀번호 페이지 - </Funnel.Step> - <Funnel.Step<typeof steps> name="루틴정보"> - 루틴정보 페이지 - </Funnel.Step> - <Funnel.Step<typeof steps> name="인증시간"> - 인증시간 페이지 - </Funnel.Step> - <Funnel.Step<typeof steps> name="방선택">방선택 페이지</Funnel.Step> + <Funnel step={step}> + <Step name="마무리"> + <div>마무리 페이지</div> + <button onClick={() => setStep('방선택')}>처음으로 이동하기</button> + </Step> + <Step name="비밀번호">비밀번호 페이지</Step> + <Step name="루틴정보">루틴정보 페이지</Step> + <Step name="인증시간">인증시간 페이지</Step> + <Step name="방선택">방선택 페이지</Step> <div>Step 컴포넌트가 아닌 요소는 렌더링에서 무시돼요.</div> <div> children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는 순서로 스텝을 보여줘요. </div> + <div> + 또는 각 스텝에서 다음 스텝으로 넘어가는 로직에 setStep을 사용하고 + 핸들러로 전달할 수 있어요. + </div> </Funnel> </div> <div className="absolute bottom-0 w-full"> <div className="grid w-full grid-cols-2"> - {funnel.hasPrev && ( + {hasPrev && ( <button className="btn btn-danger col-start-1 w-full rounded-none" - onClick={funnel.toPrev} + onClick={toPrev} > 이전으로 </button> )} - {funnel.hasNext && ( + {hasNext && ( <button className="btn btn-success col-start-2 w-full rounded-none" - onClick={funnel.toNext} + onClick={toNext} > 다음으로 </button> diff --git a/src/shared/Funnel/components/Funnel.tsx b/src/shared/Funnel/components/Funnel.tsx index 961bc51e..71a717ff 100644 --- a/src/shared/Funnel/components/Funnel.tsx +++ b/src/shared/Funnel/components/Funnel.tsx @@ -1,32 +1,31 @@ -import React, { PropsWithChildren } from 'react'; -import { StepNames } from '../types/funnel'; -import Step from './Step'; +import React from 'react'; +import Step, { StepProps } from './Step'; -interface FunnelProps<T extends StepNames> { - current: T[number]; +interface FunnelProps<T extends readonly string[]> { + step: T[number]; + children: React.ReactNode; } -const Funnel = <T extends StepNames>({ - current, +/** 하위 노드 중에서 렌더링 해야 할 Step 컴포넌트를 그리는 컴포넌트입니다. */ +const Funnel = <T extends readonly string[]>({ + step, children -}: PropsWithChildren<FunnelProps<T>>) => { +}: FunnelProps<T>) => { const validChildren = React.Children.toArray(children) - .filter<React.ReactElement>(React.isValidElement) - .filter((child) => child.type === Step); + .filter(React.isValidElement) + .filter((child) => child.type === Step) as React.ReactElement< + StepProps<T> + >[]; - const currentStepIndex = validChildren.findIndex( - (step) => step.props.name === current - ); + const currentStep = validChildren.find((child) => child.props.name === step); - if (currentStepIndex === -1) { + if (!currentStep) { throw new Error( - '보여주려는 current 스텝이 Funnel의 children 중에 존재하지 않습니다.' + `Funnel의 children 중에서 ${step} 스텝이 존재하지 않습니다.` ); } - return <>{validChildren[currentStepIndex]}</>; + return <>{currentStep}</>; }; -Funnel.Step = Step; - export default Funnel; diff --git a/src/shared/Funnel/components/Step.tsx b/src/shared/Funnel/components/Step.tsx index 085948ad..d1a8979b 100644 --- a/src/shared/Funnel/components/Step.tsx +++ b/src/shared/Funnel/components/Step.tsx @@ -1,13 +1,11 @@ -import { PropsWithChildren } from 'react'; -import { StepNames } from '../types/funnel'; - -interface StepProps<T extends StepNames> { +export interface StepProps<T extends readonly string[]> { + /** Funnel 컴포넌트에서 렌더링 할 Step을 필터링하는데 사용됩니다. */ name: T[number]; + children: React.ReactNode; } -const Step = <T extends StepNames>({ - children -}: PropsWithChildren<StepProps<T>>) => { +/** 퍼널에서 스텝 별로 렌더링 할 컴포넌트입니다. */ +const Step = <T extends readonly string[]>({ children }: StepProps<T>) => { return <>{children}</>; }; diff --git a/src/shared/Funnel/hooks/useFunnel.ts b/src/shared/Funnel/hooks/useFunnel.ts new file mode 100644 index 00000000..5c08b328 --- /dev/null +++ b/src/shared/Funnel/hooks/useFunnel.ts @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +const useFunnel = <T extends readonly string[]>( + steps: T, + initialStep: T[number] = steps[0] +) => { + const [step, setStep] = useState<T[number]>(initialStep); + const currentIdx = steps.indexOf(step); + + const hasPrev = currentIdx > 0; + const hasNext = currentIdx < steps.length - 1; + + const toPrev = () => { + if (!hasPrev) return; + + setStep(steps[currentIdx - 1]); + }; + + const toNext = () => { + if (!hasNext) return; + + setStep(steps[currentIdx + 1]); + }; + + return { step, setStep, hasPrev, toPrev, hasNext, toNext }; +}; + +export default useFunnel; diff --git a/src/shared/Funnel/hooks/useFunnel.tsx b/src/shared/Funnel/hooks/useFunnel.tsx deleted file mode 100644 index 5bc819e3..00000000 --- a/src/shared/Funnel/hooks/useFunnel.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState } from 'react'; -import { StepNames } from '../types/funnel'; - -export interface FunnelHook<T extends StepNames> { - current: T[number]; - hasPrev: boolean; - hasNext: boolean; - toPrev: VoidFunction; - toNext: VoidFunction; -} - -const useFunnel = <T extends StepNames>( - steps: T, - initialStep: T[number] = steps[0] -): FunnelHook<T> => { - const [current, setCurrent] = useState<T[number]>(initialStep); - const currentIdx = steps.indexOf(current); - - const hasPrev = currentIdx > 0; - const hasNext = currentIdx < steps.length - 1; - - const toPrev = () => { - if (!hasPrev) return; - - setCurrent(steps[currentIdx - 1]); - }; - - const toNext = () => { - if (!hasNext) return; - - setCurrent(steps[currentIdx + 1]); - }; - - return { current, hasPrev, hasNext, toPrev, toNext }; -}; - -export default useFunnel; diff --git a/src/shared/Funnel/index.ts b/src/shared/Funnel/index.ts index 7343fc53..c92c3b35 100644 --- a/src/shared/Funnel/index.ts +++ b/src/shared/Funnel/index.ts @@ -1,2 +1,2 @@ -export { default as Funnel } from './components/Funnel'; +export { default as createFunnel } from './utils/createFunnel'; export { default as useFunnel } from './hooks/useFunnel'; diff --git a/src/shared/Funnel/types/funnel.ts b/src/shared/Funnel/types/funnel.ts deleted file mode 100644 index 7dc58324..00000000 --- a/src/shared/Funnel/types/funnel.ts +++ /dev/null @@ -1 +0,0 @@ -export type StepNames = readonly string[]; diff --git a/src/shared/Funnel/utils/createFunnel.ts b/src/shared/Funnel/utils/createFunnel.ts new file mode 100644 index 00000000..112a6161 --- /dev/null +++ b/src/shared/Funnel/utils/createFunnel.ts @@ -0,0 +1,16 @@ +import Funnel from '../components/Funnel'; +import Step from '../components/Step'; +import useFunnel from '../hooks/useFunnel'; + +/** + * 퍼널 컴포넌트와 훅을 이용하는데 있어서 스텝의 순서를 자동으로 제네릭에 의해 관리할 수 있도록 도와주는 함수 + * @param steps 퍼널 컴포넌트에서 사용할 스텝의 이름을 순서대로 나열한 배열 + * @returns 퍼널 컴포넌트와 훅을 반환하는 객체 + */ +const createFunnel = <T extends readonly string[]>(steps: T) => ({ + Funnel: Funnel<T>, + Step: Step<T>, + useFunnel: (initialStep?: T[number]) => useFunnel<T>(steps, initialStep) +}); + +export default createFunnel;