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: () => (
- <>
-
-
-
- >
- )
- },
- 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;
-
-export default meta;
-type Story = StoryObj;
-
-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 (
- <>
-
-
방에 혼자 남았을 때만
- 삭제할 수 있어요.
-
- >
- );
- } else {
- return (
- <>
- 방을 삭제할까요?
-
- {error && (
-
- {error.response?.data.message}
-
- )}
-
- >
- );
- }
-};
-
-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) => {
{errors.title && (
{errors.title?.message}
@@ -81,7 +77,7 @@ const RoomTab = ({ roomId }: RoomTabProps) => {
>
공지사항
- {watchAnnouncement.length} / {ANNOUNCEMENT.max}
+ {watchAnnouncement.length} / {FORM_LITERAL.announcement.max.value}
{
'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) => {
)}
- {/* // TODO: 루틴 수정을 제한하는 요구사항 발생 */}
- {/* */}
-
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: ,
TimeStep: ,
RoutineStep: ,
@@ -30,8 +28,10 @@ const stepComponents: {
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 = () => {
{steps.map((step) => (
-
@@ -60,12 +60,12 @@ const RoomNewPage = () => {
>
{stepComponents[step]}
-
+
))}
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 = () => {
- {/* TODO: 앞으로 사용되지 않을 가능성이 높습니다. */}
- {/*
- }>
-
-
- */}
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
-
- name="마무리">마무리 페이지
- name="방선택">방선택 페이지
- name="인증시간">인증시간 페이지
- name="루틴정보">루틴정보 페이지
- name="비밀번호">비밀번호 페이지
+
+
+ 마무리 페이지
+
+
+ 방선택 페이지
+ 인증시간 페이지
+ 루틴정보 페이지
+ 비밀번호 페이지
Step 컴포넌트가 아닌 요소는 렌더링에서 무시돼요.
- children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는
- 순서로 스텝을 보여줘요.
+ children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는 순서로
+ 스텝을 보여줘요.
+
+
+ 또는 각 스텝에서 다음 스텝으로 넘어가는 로직에 setStep을 사용하고 핸들러로
+ 전달할 수 있어요.
```
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;
+} satisfies Meta;
export default meta;
@@ -13,53 +13,54 @@ export const Default: Story = {
render: () =>
};
-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 (
-
- name="마무리">마무리 페이지
- name="비밀번호">
- 비밀번호 페이지
-
- name="루틴정보">
- 루틴정보 페이지
-
- name="인증시간">
- 인증시간 페이지
-
- name="방선택">방선택 페이지
+
+
+ 마무리 페이지
+
+
+ 비밀번호 페이지
+ 루틴정보 페이지
+ 인증시간 페이지
+ 방선택 페이지
Step 컴포넌트가 아닌 요소는 렌더링에서 무시돼요.
children에 순서를 뒤죽박죽으로 등록해도 steps 배열에 들어가있는
순서로 스텝을 보여줘요.
+
+ 또는 각 스텝에서 다음 스텝으로 넘어가는 로직에 setStep을 사용하고
+ 핸들러로 전달할 수 있어요.
+
- {funnel.hasPrev && (
+ {hasPrev && (
)}
- {funnel.hasNext && (
+ {hasNext && (
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 {
- current: T[number];
+interface FunnelProps {
+ step: T[number];
+ children: React.ReactNode;
}
-const Funnel = ({
- current,
+/** 하위 노드 중에서 렌더링 해야 할 Step 컴포넌트를 그리는 컴포넌트입니다. */
+const Funnel = ({
+ step,
children
-}: PropsWithChildren>) => {
+}: FunnelProps) => {
const validChildren = React.Children.toArray(children)
- .filter(React.isValidElement)
- .filter((child) => child.type === Step);
+ .filter(React.isValidElement)
+ .filter((child) => child.type === Step) as React.ReactElement<
+ StepProps
+ >[];
- 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 {
+export interface StepProps {
+ /** Funnel 컴포넌트에서 렌더링 할 Step을 필터링하는데 사용됩니다. */
name: T[number];
+ children: React.ReactNode;
}
-const Step = ({
- children
-}: PropsWithChildren>) => {
+/** 퍼널에서 스텝 별로 렌더링 할 컴포넌트입니다. */
+const Step = ({ children }: StepProps) => {
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 = (
+ steps: T,
+ initialStep: T[number] = steps[0]
+) => {
+ const [step, setStep] = useState(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 {
- current: T[number];
- hasPrev: boolean;
- hasNext: boolean;
- toPrev: VoidFunction;
- toNext: VoidFunction;
-}
-
-const useFunnel = (
- steps: T,
- initialStep: T[number] = steps[0]
-): FunnelHook => {
- const [current, setCurrent] = useState(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 = (steps: T) => ({
+ Funnel: Funnel,
+ Step: Step,
+ useFunnel: (initialStep?: T[number]) => useFunnel(steps, initialStep)
+});
+
+export default createFunnel;